Skip to content

A buyer's draft and a B2B proposal keep a part roll: draft qty and proposal quantity are Decimal(12,3) - #36

Merged
goetchstone merged 4 commits into
mainfrom
fix/draft-proposal-qty-decimal
Oct 17, 2026
Merged

goetchstone merged 4 commits into
mainfrom
fix/draft-proposal-qty-decimal

Conversation

@goetchstone

Copy link
Copy Markdown
Owner

Summary

Fabric is drafted, quoted and ordered by the yard, but a buyer's draft and a B2B proposal held their quantities as Int. BuyerDraftItem.qty and ProposalLineItem.quantity were the last two whole-number columns a part roll can reach, found by #35's sweep for the same bug shape (rule 49). I exercised them first against the Int columns, and each lost the fraction without an error:

  • a draft typed as 12.5 yards was stored as 1 (optionalInt swaps any non-integer for the default), and an edit to 12.5 was refused;
  • a real PO for 12.5 yards imported into a buy as 12 (Math.trunc), so the buy priced 12 yards: $96, not $100;
  • a proposal line of 12.5 yards was stored as 12, and converting the proposal ordered 12;
  • a proposal quantity of "lots" reached Prisma as NaN and answered 500.

This is one of the follow-ups left by ledger-plan PR 4 ("Handheld floor" in the roadmap). It does not depend on #34 or #35, which leave these models and routes alone.

What changes

  • Migration 20261022120000_draft_and_proposal_qty_decimal: both columns become Decimal(12,3), default 1. The widening happens in place (integer -> numeric), so existing rows keep their values. The timestamp comes after The store-grain count keeps a part roll: count and reconciliation quantities are Decimal(12,3) #35's 20261021120000.
  • Writers read a typed quantity with parseQty (lib/inventory/qtyInput.ts):
    • Draft qty. Zero or more, to three places. Zero was already a legitimate draft qty (buyerDraftRequestBody.test.ts pins it, and home-accessory imports draft backorders at 0).
      • On create, a blank, garbage or negative qty still falls back to 1, as before. A negative qty used to be stored as given.
      • On edit (validatedQty), a blank, garbage or negative qty is a 400. A blank qty used to be stored as 0.
    • Historical PO import. Keeps the ordered yards. It still skips zero, negative and non-numeric lines (rule 31).
    • Proposal quantity. It must be greater than zero, or the route answers 400 and says why. A line added with no quantity is 1.
  • Readers convert with toQty, because JSON sends a Decimal as text (the A stock position or transfer reaches the screen as a number: the warehouse routes convert the Decimal with toQty instead of letting JSON send it as text #29 bug shape):
    • draftItemForClient and draftSpend (lib/buyerDraftQty.ts) on the item list, create, get and edit, the draft PO, the buy rollup and the archive;
    • proposalLineForClient (lib/proposalQty.ts) on the proposal and on the line add and edit routes;
    • an inline conversion in the three exports, the performance report, convert-to-order and the PDF.
  • PDF. An item page now shows "each × qty = line" for any quantity but 1. The test was > 1, so half a yard showed a yard's price as the line. The text comes from itemPriceText, which has a unit test.
  • Screens.
    • Barcode quick-add reads its qty with parseQty instead of parseInt, with a decimal keypad.
    • The draft wizard and proposal quantity boxes take decimals (step="any").
    • The proposal's inline quantity box only sends a value that is a quantity, so clearing the box mid-edit no longer writes 0.
  • Runbooks:
    • docs/domains/buyer-drafts.md: the qty is fractional, and the int truncation is gone. Its edge-case list said zero or negative import lines were "clamped to 1". buildImportFromPurchaseOrder skips them, so the runbook now says that.
    • The B2B Proposals section of docs/domains/sales-orders.md.

Trace / Verification

  • Trace (rule 45). No shared infrastructure is touched: nothing under lib/adapters, lib/auth, paymentService, secureUpload or automations.
    • grep -rlnE "\.(buyerDraftItem|proposalLineItem)\." app/src finds 19 files. I read every route that reaches the two models through a nested include as well (draft PO, buy, archive, performance, exports, proposal GET, PDF and convert-to-order).
    • Each one that sends, sums or multiplies a quantity is changed here.
    • These do not read the quantity, so they are unchanged: the ordorite runner's auto-link, linked-pos, reorder and images.
    • The apparel and home-accessory commits write through buildItemCreateData, so they take the new parsing.
  • Failing first. draftAndProposalQtyFractional.integration.test.ts calls the real routes behind their real permission gates; only next-auth's session is mocked. Against the Int columns, 6 of its 7 tests failed, for the reasons listed in the summary. After the change, all 7 pass.
  • Mutation-checked. I reintroduced each bug, watched the test fail, and restored the file:
    • sending the proposal's lines without proposalLineForClient fails with Expected: 12.375, Received: "12.375";
    • Math.trunc on the imported qty fails with Expected: "12.5", Received: "12".
  • Migration. I ran prisma migrate deploy of every migration into an empty scratch database, then prisma migrate diff --from-config-datasource --to-schema --exit-code. It reported "No difference detected".
  • Self-heal sweep (rule 49). After this PR and The store-grain count keeps a part roll: count and reconciliation quantities are Decimal(12,3) #35, no Int column named like a quantity can receive a part roll. The two that remain are whole by nature: Product.cartonQty (units per carton) and TillCount.quantity (cash).
  • Found, not fixed (rule 48). None of the four draft-spend sums filters by status: the workbench's PO total and buy bar, GET /buys/[id] and the archive. So a CANCELLED draft still counts against the buy's budget, even though the runbook's fix for a double count is "cancel the forward-flow drafts". This PR keeps that behaviour, and it is tracked in ROADMAP.md → "Handheld floor".
  • Unverified (rule 58). I did not check the draft wizard, barcode quick-add or proposal builder in a browser, so typing a decimal into those boxes has not been tried. The routes are exercised through their handlers against real Postgres.

Scan / gate state

npm run check:local stops at its integration step from an agent session, because Prisma refuses the harness's db push --accept-data-loss without the owner's consent. I ran each step directly instead.

Check Result
validate (lint, typecheck, format) clean: 0 errors. The warnings are existing ones; the changed files add none.
unit tests 3,949 pass (20 skipped), including the updated buyerDraftRequestBody, historicalPoImport and new proposalPdf tests
integration 102 files: 888 pass (5 skipped), 0 failing. Run one file at a time against fbc_test_db, with this migration's SQL applied and no schema push, the same way scripts/run-integration-tests.sh runs them. Two existing tests failed on the first run because they compared the Decimal qty with a number (buyerDraftCrud, homeAccessoryOrderCommit). Their assertions now read qty.toNumber(), as those files already do for cost, and both pass.
markdownlint clean
Semgrep 0 findings
OSV (security:deps) no issues (existing suppressions only)
CodeQL runs in CI on this PR

Test plan

  • On /app/admin/buyer-drafts, add a fabric item with qty 12.5. The card shows Qty 12.5, and the buy's bar adds 12.5 × cost.
  • Use "Quick add by barcode" with qty 2.375. A toast reads "Added 2.375 × …".
  • Use "Import historical PO" on a PO with a fractional fabric line. The draft keeps the yards.
  • On a proposal, set a line's qty to 12.5, then download the PDF. The item page reads "each x 12.5 = …".
  • Convert that proposal. The order line is for 12.5 and its total is 12.5 × retail.

🤖 Generated with Claude Code

goetchstone and others added 3 commits October 17, 2026 06:04
…ty and ProposalLineItem.quantity are Decimal(12,3)

The last two whole-number quantity columns a part roll can reach (found
by #35's rule-49 sweep). Exercised first against the Int columns
(draftAndProposalQtyFractional.integration.test.ts, 6 of 7 failing): a
draft typed as 12.5 yards was stored as 1, a real PO for 12.5 yards was
drafted as 12, a buy priced 12 yards (96, not 100), a proposal line of
12.5 was stored and ordered as 12, and a typed "lots" answered 500.

- Migration 20261022120000_draft_and_proposal_qty_decimal: both columns
  widen integer -> numeric in place, default 1.
- Writers read a typed quantity with parseQty. A draft qty is zero or
  more (zero was already a legitimate draft qty); create still falls back
  to 1, edit refuses with a 400. The historical PO import keeps the
  ordered yards instead of truncating. A proposal quantity must be greater
  than zero, else 400.
- Readers convert with toQty: draftItemForClient / draftSpend
  (lib/buyerDraftQty.ts) and proposalLineForClient (lib/proposalQty.ts)
  on every route that sends or prices a row; exports, the performance
  report, convert-to-order and the PDF convert inline.
- The PDF's item page shows "each x qty = line" for any quantity but 1
  (it was > 1, so half a yard showed a yard's price as the line).
- Barcode quick-add reads its qty with parseQty, not parseInt; the draft
  wizard and proposal quantity boxes take decimals.
- Runbooks: buyer-drafts.md (the int truncation is gone; zero-quantity
  import lines are skipped, not clamped to 1 as it said) and the
  proposals section of sales-orders.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@goetchstone
goetchstone enabled auto-merge (squash) October 17, 2026 11:05
@goetchstone
goetchstone merged commit dce1aa6 into main Oct 17, 2026
12 checks passed
@goetchstone
goetchstone deleted the fix/draft-proposal-qty-decimal branch October 17, 2026 16:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant