Repository navigation
fix(quote): a quote's own discount, tax or shipping edit re-derives its totals - #2109
Conversation
…ts totals quote_workflow now derives discount_amount and total_price in the same write whenever an update to an unsettled quote carries subtotal, discount, tax or shipping_handling. quote_total_rollup writes only subtotal and lets that step derive the rest, so the header formula lives once. Settled quotes, quotes with no subtotal, and the predicate path (D3) derive nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
席位核对复核人:repo:hotcrm 执行席(#1353)· 判决 ACCEPT · 钉 head 形态:一卡一 PR, 前提实测:diff 逐行读过。 门禁 check-runs(钉 head):Build and Test (22.x) ✓ · Quality Checks ✓ · Check Changeset ✓ · Playwright ✓ · link-check ✓ · CodeQL ✓ · Analyze Code (javascript) ✓ · Label Pull Request ✓ · Vercel Preview Comments ✓ — 9/9 success,mergeable_state 接受的偏差:① 提交 范围外去向:dev 实测用户可手填 落地条款:席位经中继 |
Fixes #2083
Clause-②: no
What changed
quote_workflow(src/revenue/objects/quote.hook.ts) gains one additivebeforeUpdatestep. When an update to an unsettled quote carriessubtotal,discount,taxorshipping_handling, the step writesdiscount_amount = subtotal × discount%andtotal_price = subtotal − discount_amount + tax + shipping_handlinginto that same write. It runs for every writer, a user or not, but never on a quote that wasaccepted/expiredbefore the write. It also skips a quote with nosubtotal, and it stands down on the predicate path (D3, asforecast.hook.tsdoes), because it readsprevious.quote_total_rollup(src/revenue/objects/quote_line_item.hook.ts) now writes onlysubtotal. That write passes through the step above, so the header formula exists in exactly one place..changeset/2083-quote-header-recompute.md. The docs need no edit:content/docs/sales/quotes.mdx:49("Anything you change in the line items, discount, tax or shipping fields, the totals update automatically") is true now.Why one formula with two triggers, and not one function with two callers
A hook body runs body-only in the QuickJS sandbox, with no module scope (see the header of
_line-item-price-fill.ts).pnpm lint(os lint --strict) refuses a body that reaches outside itself ashook-body/not-lowerable. So no helper function can be shared between two handlers. The alternative is to keep the formula in one handler and route the other writer through it. The rollup now supplies only the line sum, and the quote's ownbeforeUpdatederives the rest. The rollup cannot double-apply anything, because it no longer writes either derived figure.Measured: own server (
objectstack dev -p 4183,@objectstack/*18.0.0-next.1), REST as the seeded admin4de5343ePATCHQTE-0005{tax: 7000}total_price86,400 (formula 87,000)total_price87,000{discount: 10}discount_amount0,total_price86,400 (formula 8,000 / 79,000){shipping_handling: 500}total_price86,400 (formula 79,500)PATCHaccepted QTE-0002{tax: 1}RECORD_LOCKEDRECORD_LOCKED, same message; row unchangedPATCHexpired QTE-0001{discount: 1}RECORD_LOCKEDRECORD_LOCKED, same message; row unchanged{discount: 10}, then{status: accepted}contract_value99,970 (formula 95,220)quantity+ 1 on QTE-0004subtotal1,225,000 = the line sum;discount_amount183,750;total_price1,122,850; consistentPATCH {"discount":10}only; the record page then reads Total Price 44,100.00 and Discount Amount 4,500.00 (DOM text probe); the API agreesThe base rows confirm the card's premise on 18.0.0-next.1. They also measure the card's inferred contract consequence: an acceptance right after a header edit drafted the contract at the stale total. The seed quotes boot to identical figures on this branch, because all six seed rows already satisfy the formula.
Tests
New
describeintest/hooks-runtime.test.ts(real engine, a sales rep's writes): each of the three edits stores the re-derived pair in that same write; a settled-quote control, where the rep is refused withcodeRECORD_LOCKED,status409, and a system write oftaxon the accepted quote re-derives nothing; and an acceptance right after a discount edit drafts the contract at the re-derived 78,400.Ablations: the suites import
objectstack.configfrom source, so nodist/is involved. Each mutation went throughablation-replace.mjswith an anchor that must hit, and each restore was verified (blob == HEAD,git diff HEADempty).typeof subtotal === 'number' && false): 12 red. The three edits and the contract case are red. The rollup's own two cases (705, and the 9,810 of A quote's total ignores line-item tax: lines total 10,945 while the quote shows 9,810 (the rollup sums pre-tax subtotals and never fills crm_quote.tax) #2069) are red, and so are the sixacceptQuote-based CPQ cases inhooks-runtime-sales.test.ts, whose fixture waits for the rollup's 120,000 total. The settled control stays green. So the rollup's header totals now come only from this step.total_pricefrom 86,400 to 87,000.Gates, final head
7255b5acThese are hotcrm's own
pnpm verifychain, run locally on7255b5acwith a clean tree. Each exit code was captured before any pipe.build,test,validate,lintandtypecheckran under the shared verify lock.pnpm validate✓ Validation passedpnpm typechecktsc --noEmit, no diagnosticspnpm lint(os lint --strict)18 suggestion(s), no errors (the hook still lowers)pnpm lint:i18n-gate✓ i18n lint gate: 0 i18n/missing-* issuespnpm hygiene✓ source hygiene cleanpnpm hygiene:tokens✓ source token ratchet clean;src/revenueauthored total ~18,934 to ~19,058 (ceiling ~28,500), business semantics ~15,794 to ~15,918 (ceiling ~24,000)pnpm build✓ Build complete,all 44 callables are body-only, 8 author-time warnings (8 on base too)pnpm testTest Files 179 passed (179),Tests 3663 passed, 1 skipped (3664)Playwright e2e runs in CI.
Acceptance notes
@objectstack/spec18.0.0-next.1 offersField.formula(already oncrm_quote.display_titleand on the line'ssubtotal/total_price) andField.summary. Sodiscount_amount/total_pricecould in principle become formulas over the stored inputs, and neither hook would write them. That route was not taken, per the card. Its known costs: a formula is not a stored column, and a lowered hook body cannot read one (quote.hook.ts, thesubject()note).quote_accepted_contract_draftreadstotal_price,quote.view.tssums it (summary: 'sum',summarizeField), andquote_generationwrites it at create. None of this was measured.quote_total_rollup. These sit outside this card's file surface and are not edited here:src/revenue/objects/quote.object.ts:179-193(feat(picklists): the shared option lists becomepicklistmetadata —*.picklist.tsandField.select({ picklist })replace_picklists.ts(acceptance of objectstack#18164) #2000's lane this round),src/revenue/objects/quote_line_item.object.ts:184-188,src/revenue/data/revenue.seed.ts:142-150andsrc/sales/objects/_thresholds.ts:58. The formula they state is unchanged. What changed is the writer: the rollup writessubtotal, andquote_workflowderives the pair in the same write. Carrier: none.quote.object.ts:358also still names the freezequote_pricing_guard. Carrier: none.quote_generation(src/revenue/flows/quote-generation.flow.ts:176-177) still computesdiscount_amount/total_pricein CEL at create time. That is an insert, which this card'sbeforeUpdatestep does not cover, so it is not touched here.subtotalkeeps the totals it has. A predicate (bulk) update of a pricing input derives nothing, like the otherprevious-reading derivations in this app.2efb525fcarries aCo-Authored-Bytrailer that names the model, copied from the session's attribution reminder. The later commit uses the model-free pair. Branch history is not rewritten.Generated by Claude Code