diff --git a/frontend/src/__tests__/fixtures/pricing.ts b/frontend/src/__tests__/fixtures/pricing.ts new file mode 100644 index 0000000..19c9bcb --- /dev/null +++ b/frontend/src/__tests__/fixtures/pricing.ts @@ -0,0 +1,20 @@ +/** TECH-SPEC §8 TV-8 pricing fixture. */ +export function tv8Fixture(): { + bdft: number; + pricePerBdft: number; + expectedRecommended: number; + overridePrice: number; + expectedOverride: number; + manualPriceNoRule: number; + expectedManual: number; +} { + return { + bdft: 0.96, + pricePerBdft: 12.50, + expectedRecommended: 12.00, + overridePrice: 150.00, + expectedOverride: 150.00, + manualPriceNoRule: 99.99, + expectedManual: 99.99, + }; +} diff --git a/frontend/src/__tests__/fixtures/thicknessBand.ts b/frontend/src/__tests__/fixtures/thicknessBand.ts new file mode 100644 index 0000000..dbfbadb --- /dev/null +++ b/frontend/src/__tests__/fixtures/thicknessBand.ts @@ -0,0 +1,11 @@ +/** TECH-SPEC §8 TV-9 mock taxonomy fixture for thickness attribute. */ +import type { ThicknessRange } from "../../../lib/pipeline/thicknessBand.js"; + +/** Mock Woo thickness attribute terms (synced from store). */ +export function mockThicknessRanges(): ThicknessRange[] { + return [ + { wooId: 101, label: '3/4"–1"', minIn: 0.75, maxIn: 1.0 }, + { wooId: 102, label: '1"–1 1/2"', minIn: 1.0, maxIn: 1.5 }, + { wooId: 103, label: '1 1/2"–2"', minIn: 1.5, maxIn: 2.0 }, + ]; +} diff --git a/frontend/src/__tests__/tv8-pricing.test.ts b/frontend/src/__tests__/tv8-pricing.test.ts new file mode 100644 index 0000000..df7cb64 --- /dev/null +++ b/frontend/src/__tests__/tv8-pricing.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { computeRecommendedPrice, computeSlabPrice } from "../lib/pipeline/pricing.js"; +import { tv8Fixture } from "./fixtures/pricing.js"; + +describe("TV-8 Pricing", () => { + const fx = tv8Fixture(); + + describe("computeRecommendedPrice", () => { + it("bdft × price_per_bdft rounds to expected recommended price", () => { + const rec = computeRecommendedPrice(fx.bdft, fx.pricePerBdft); + expect(rec).toBe(fx.expectedRecommended); + expect(rec).toBe(12.00); + }); + + it("0 bdft yields 0", () => { + expect(computeRecommendedPrice(0, 99.99)).toBe(0); + }); + }); + + describe("computeSlabPrice — with pricing rule", () => { + it("recommended price pre-fills when no override", () => { + const result = computeSlabPrice(fx.bdft, { + species: "Walnut", + pricePerBdft: fx.pricePerBdft, + }); + + expect(result.recommendedPrice).toBe(fx.expectedRecommended); + expect(result.finalPrice).toBe(fx.expectedRecommended); + expect(result.priceSource).toBe("recommendation"); + }); + + it("user override stored as rounded override price", () => { + const result = computeSlabPrice(fx.bdft, { + species: "Walnut", + pricePerBdft: fx.pricePerBdft, + }, fx.overridePrice); + + expect(result.recommendedPrice).toBe(fx.expectedRecommended); + expect(result.finalPrice).toBe(fx.expectedOverride); + expect(result.priceSource).toBe("override"); + }); + }); + + describe("computeSlabPrice — no pricing rule", () => { + it("recommended price is null, manual price stored", () => { + const result = computeSlabPrice(fx.bdft, undefined, fx.manualPriceNoRule); + + expect(result.recommendedPrice).toBeNull(); + expect(result.finalPrice).toBe(fx.expectedManual); + expect(result.priceSource).toBe("override"); + }); + + it("manual price rounds half-up to 2 decimals", () => { + const result = computeSlabPrice(fx.bdft, undefined, 12.345); + + expect(result.finalPrice).toBe(12.35); + expect(result.priceSource).toBe("override"); + }); + }); +}); diff --git a/frontend/src/__tests__/tv9-thickness-band.test.ts b/frontend/src/__tests__/tv9-thickness-band.test.ts new file mode 100644 index 0000000..5c792ec --- /dev/null +++ b/frontend/src/__tests__/tv9-thickness-band.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { findThicknessBand } from "../lib/pipeline/thicknessBand.js"; +import { mockThicknessRanges } from "./fixtures/thicknessBand.js"; + +describe("TV-9 Thickness → Woo range", () => { + const ranges = mockThicknessRanges(); + + it("1 inch rounds up into 1\"–1 1/2\"", () => { + const band = findThicknessBand(1.0, ranges); + + expect(band).toBeDefined(); + expect(band!.wooId).toBe(102); + expect(band!.label).toBe('1"–1 1/2"'); + expect(band!.minIn).toBe(1.0); + expect(band!.maxIn).toBe(1.5); + }); + + it("0.75 inches maps to 3/4\"–1\"", () => { + const band = findThicknessBand(0.75, ranges); + + expect(band).toBeDefined(); + expect(band!.wooId).toBe(101); + expect(band!.label).toBe('3/4"–1"'); + }); + + it("0.875 inches maps to 3/4\"–1\"", () => { + const band = findThicknessBand(0.875, ranges); + + expect(band).toBeDefined(); + expect(band!.wooId).toBe(101); + }); + + it("1.5 inches maps to 1 1/2\"–2\"", () => { + const band = findThicknessBand(1.5, ranges); + + expect(band).toBeDefined(); + expect(band!.wooId).toBe(103); + expect(band!.label).toBe('1 1/2"–2"'); + }); + + it("returns null when no range contains the thickness", () => { + const band = findThicknessBand(0.5, ranges); + expect(band).toBeNull(); + + const band2 = findThicknessBand(2.5, ranges); + expect(band2).toBeNull(); + }); + + it("rounds thickness to nearest 1/8\" before matching", () => { + const band = findThicknessBand(1.01, ranges); + + expect(band).toBeDefined(); + expect(band!.wooId).toBe(102); + }); +}); diff --git a/frontend/src/lib/pipeline/index.ts b/frontend/src/lib/pipeline/index.ts index 3ecdfb7..33a2fbb 100644 --- a/frontend/src/lib/pipeline/index.ts +++ b/frontend/src/lib/pipeline/index.ts @@ -4,3 +4,5 @@ export * from "./mask.js"; export * from "./units.js"; export * from "./axis.js"; export * from "./crop.js"; +export * from "./pricing.js"; +export * from "./thicknessBand.js"; diff --git a/frontend/src/lib/pipeline/pricing.ts b/frontend/src/lib/pipeline/pricing.ts new file mode 100644 index 0000000..d60e13f --- /dev/null +++ b/frontend/src/lib/pipeline/pricing.ts @@ -0,0 +1,91 @@ +import { INCH_STEP, PRICE_DECIMALS } from "./constants.js"; + +/** Pricing rule synced from WooCommerce (leaf species). */ +export interface PricingRule { + species: string; + pricePerBdft: number; +} + +/** Result of pricing computation for a slab. */ +export interface PricingResult { + /** Recommended price from the pricing rule, or null when no rule exists. */ + recommendedPrice: number | null; + /** The price to store (rounded half-up to 2 decimals). */ + finalPrice: number; + /** Where the stored price came from. */ + priceSource: "recommendation" | "override"; +} + +/** + * TECH-SPEC §6 pricing: bdft × price_per_bdft, rounded half-up to 2 decimals. + * + * @param bdft — board feet (2 decimals, already rounded by caller) + * @param pricePerBdft — species price per board foot from Settings + * @returns recommended price rounded to 2 decimals + */ +export function computeRecommendedPrice( + bdft: number, + pricePerBdft: number, +): number { + const factor = 10 ** PRICE_DECIMALS; + return Math.floor(Math.round(bdft * pricePerBdft * 100) / 100 * factor + 0.5) / factor; +} + +/** + * TECH-SPEC §6 pricing with override and missing-rule handling. + * + * Three paths: + * 1. Has rule, no override → recommended price, source "recommendation" + * 2. Has rule, override → override stored, source "override" + * 3. No rule, manual price → manual stored, source "override" + * + * @param bdft — board feet (2 decimals) + * @param pricingRule — pricing rule for the species, or undefined if none + * @param userOverride — manual price entered by the user, if any + * @returns pricing result with recommended price, final stored price, and source + */ +export function computeSlabPrice( + bdft: number, + pricingRule?: PricingRule, + userOverride?: number, +): PricingResult { + const recommendedPrice = + pricingRule != null + ? computeRecommendedPrice(bdft, pricingRule.pricePerBdft) + : null; + + if (userOverride != null) { + return { + recommendedPrice, + finalPrice: roundPrice(userOverride), + priceSource: "override", + }; + } + + if (recommendedPrice != null) { + return { + recommendedPrice, + finalPrice: recommendedPrice, + priceSource: "recommendation", + }; + } + + // No rule, no override — this should not happen at publish, + // but return a valid shape with null recommended and 0 final. + return { + recommendedPrice: null, + finalPrice: 0, + priceSource: "override", + }; +} + +/** Round price half-up to 2 decimals. */ +function roundPrice(price: number): number { + if (!Number.isFinite(price)) { + return price; + } + const factor = 10 ** PRICE_DECIMALS; + const scaled = Math.abs(price) * factor; + const rounded = Math.floor(scaled + 0.5) / factor; + return price < 0 ? -rounded : rounded; +} diff --git a/frontend/src/lib/pipeline/thicknessBand.ts b/frontend/src/lib/pipeline/thicknessBand.ts new file mode 100644 index 0000000..52d5394 --- /dev/null +++ b/frontend/src/lib/pipeline/thicknessBand.ts @@ -0,0 +1,47 @@ +import { INCH_STEP } from "./constants.js"; + +/** + * A WooCommerce attribute term for the Thickness attribute. + * Each term represents a thickness range (e.g. "1"–1 1/2""). + * + * The range is half-open: [minIn, maxIn). A thickness equal to minIn + * belongs to this range; a thickness equal to maxIn belongs to the + * next higher range. This implements the "rounds up" rule from + * TECH-SPEC §2: "Thickness Woo attribute — same pick, rounded UP + * into a Woo range." + */ +export interface ThicknessRange { + wooId: number; + /** Display label from Woo, e.g. '1"–1 1/2"'. */ + label: string; + /** Lower bound in inches (inclusive). */ + minIn: number; + /** Upper bound in inches (exclusive). */ + maxIn: number; +} + +/** + * Find the Woo thickness range that a given thickness rounds up into. + * + * The thickness is first rounded to the nearest 1/8" step, then matched + * against the half-open ranges [minIn, maxIn). If the thickness falls + * below the lowest range or above the highest, null is returned. + * + * @param thicknessIn — thickness in inches (1/8" steps) + * @param ranges — sorted thickness ranges from Woo attribute sync + * @returns the matching range, or null if no range contains the value + */ +export function findThicknessBand( + thicknessIn: number, + ranges: ThicknessRange[], +): ThicknessRange | null { + const rounded = Math.round(thicknessIn / INCH_STEP) * INCH_STEP; + + for (const range of ranges) { + if (rounded >= range.minIn && rounded < range.maxIn) { + return range; + } + } + + return null; +}