Skip to content
Skip to main content
Novel Systems home
Decision log
D-002August 3, 2026Accepted

Cut sizes are shared; prices are not

Affects: backend/src/services/cpq-engine.ts, backend/src/controllers/quotes.controller.ts, backend/test/cpq-engine.test.ts, scripts/check-cutlist-parity.mjs, .github/workflows/ci.yml

What was wrong

The two engines priced the same shade and only one of them knew how to cut it.

lib/cpq-engine.ts has calculateCutList(), which turns a finished opening into the four pieces a shop actually makes: tube length, hembar length, fabric cut width, fabric cut drop. backend/src/services/cpq-engine.ts had none of it. Its CalculatedLineItem exposed openingSquareFeet, fabricSquareFeetWithWaste, labourHours, the component costs and the two money figures — everything required to bill for a blind and nothing required to build one.

The practical consequence: a work order created from an API-priced quote had no cut sizes on it. The only way to get them was to re-enter the opening into the marketing site's sandbox and copy the numbers across by hand, which is a transcription step between a database and a saw.

The decision

The server engine gains the cut-size arithmetic as additional output. It keeps its own price.

This is option (i) of open question A in PRD.md, and the reasoning is that the two engines are not two implementations of one thing that drifted. They are two products. lib/cpq-engine.ts prices *shop wholesale* — fabric, tube, hembar, aluminium surcharge, fabrication labour — and doubles it to retail. backend/src/services/cpq-engine.ts prices *supply and install* — resolved SKU costs from the tenant's own rate card, plus an installation labour model with a regional tier multiplier. A contractor buying a made blind and a building owner buying a hung blind are buying different things and the two prices should differ.

Cut sizes are in a different category entirely. A tube is 1.25 inches under the finished width because that is where the end caps sit. That is a fact about the hardware, and it does not become a different fact because of who is being invoiced or which engine answered. Duplicating a *price* across the two engines would be wrong; duplicating a *dimension* is unavoidable and merely has to be kept honest.

So:

  • calculateCutList(width, drop) is exported from the server engine and called by every priced line. It is deliberately reachable without a price, because a work order needs the cut list and has no business asking for commercial information to obtain it.
  • The four allowances are declared in both engines and scripts/check-cutlist-parity.mjs fails the build if any of them disagree.
  • Cut sizes are computed from the finished opening, never from a costing intermediate. Deriving a shop-floor fact from a commercial one would couple them for no reason and would break the moment the rate card changed.
  • The output is per unit, never multiplied by quantity. Eight identical blinds are eight identical cuts; a tube length that scaled with quantity would be a number no saw can produce, and it would look plausible in JSON.

Why a parity script rather than a shared module

The same answer as D-001, with a sharper consequence.

The engines cannot import from each other — one is bundled into the browser and the other must never be — so the constants are declared twice and will drift unless something notices. A margin disagreement produces a wrong price, and a wrong price is eventually caught by a bookkeeper reconciling an invoice. A cut-size disagreement produces a wrong piece of extruded aluminium. Nobody reconciles aluminium. It is caught when an installer is standing in a lobby holding a tube that is a quarter of an inch too long, which is a truck roll, a remake and a customer who now watches every delivery.

check-cutlist-parity.mjs runs in prebuild and in CI beside the margin check. It refuses a zero allowance explicitly, for the same reason the margin check refuses a zero floor: zero is the shape these constants take when somebody neutralises them to get a build through, and it would otherwise pass as agreement.

The unit tests assert the arithmetic separately, transcribed from the locked reference derivation in the header of lib/cpq-engine.ts — 96.00"W × 120.00"D gives 94.75 tube, 95.00 hembar, 94.50 × 124.00 fabric, 81.375 ft². The parity script proves the two files *declare* the same allowances; the tests prove the server actually *uses* them and arrives at the published figures. Either check alone would pass while the other failed.

The one thing a reader will get wrong

cutList.fabricSquareFeet and fabricSquareFeetWithWaste are both fabric areas on the same line item and they are not the same number, on purpose.

The first is the rectangle that gets cut: (W − 1.5) × (D + 4) / 144, for one unit. The second is the goods that get bought: opening area × the SKU's yield factor × 1.15 waste, times quantity. At 48" × 72" with yield 1.0 and quantity 1 they are 24.5417 and 27.6 — near enough to look like a rounding bug and far enough apart to be real money on a large order.

A test asserts both figures together specifically so that a future reader who decides one of them is wrong finds out from a failing test why they are different, rather than making them agree and quietly changing what the platform charges for fabric.

What would make this wrong

A hardware system with different end caps — a different bracket family, a cassette that seats the tube differently — would need the allowances to come from the SKU rather than from a module constant. That is a rate-card schema change and a migration, and at that point both engines take the allowances as input and the parity script has nothing left to compare. It should be made when a second hardware system actually exists, not in anticipation of one.