Skip to content

fix(quote): a quote's own discount, tax or shipping edit re-derives its totals - #2109

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-2083-quote-header-recompute
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-2083-quote-header-recompute

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #2083

Clause-②: no

What changed

  • quote_workflow (src/revenue/objects/quote.hook.ts) gains one additive beforeUpdate step. When an update to an unsettled quote carries subtotal, discount, tax or shipping_handling, the step writes discount_amount = subtotal × discount% and total_price = subtotal − discount_amount + tax + shipping_handling into that same write. It runs for every writer, a user or not, but never on a quote that was accepted / expired before the write. It also skips a quote with no subtotal, and it stands down on the predicate path (D3, as forecast.hook.ts does), because it reads previous.
  • quote_total_rollup (src/revenue/objects/quote_line_item.hook.ts) now writes only subtotal. That write passes through the step above, so the header formula exists in exactly one place.
  • The freeze from PR fix(revenue): an accepted or expired quote's line items are frozen with it (#2060 item 1) #2076 is untouched. The step sits after the freeze and the approval gate, and it skips a settled quote, so it cannot reopen one.
  • Changeset: .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 as hook-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 own beforeUpdate derives 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 admin

Probe base 4de5343e this branch
PATCH QTE-0005 {tax: 7000} 200; total_price 86,400 (formula 87,000) 200; total_price 87,000
then {discount: 10} 200; discount_amount 0, total_price 86,400 (formula 8,000 / 79,000) 200; 8,000 / 79,000
then {shipping_handling: 500} 200; total_price 86,400 (formula 79,500) 200; 79,500
PATCH accepted QTE-0002 {tax: 1} 409 RECORD_LOCKED 409 RECORD_LOCKED, same message; row unchanged
PATCH expired QTE-0001 {discount: 1} 409 RECORD_LOCKED 409 RECORD_LOCKED, same message; row unchanged
QTE-0003 {discount: 10}, then {status: accepted} contract CTR-0005 drafted at contract_value 99,970 (formula 95,220) CTR-0005 at 95,220
a line's quantity + 1 on QTE-0004 not measured subtotal 1,225,000 = the line sum; discount_amount 183,750; total_price 1,122,850; consistent
Console edit dialog, QTE-0006, Totals tab, Discount % 0 to 10, Update not measured the dialog sends PATCH {"discount":10} only; the record page then reads Total Price 44,100.00 and Discount Amount 4,500.00 (DOM text probe); the API agrees

The 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 describe in test/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 with code RECORD_LOCKED, status 409, and a system write of tax on 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.config from source, so no dist/ is involved. Each mutation went through ablation-replace.mjs with an anchor that must hit, and each restore was verified (blob == HEAD, git diff HEAD empty).

Gates, final head 7255b5ac

These are hotcrm's own pnpm verify chain, run locally on 7255b5ac with a clean tree. Each exit code was captured before any pipe. build, test, validate, lint and typecheck ran under the shared verify lock.

Gate Exit Verdict line
pnpm validate 0 ✓ Validation passed
pnpm typecheck 0 tsc --noEmit, no diagnostics
pnpm lint (os lint --strict) 0 18 suggestion(s), no errors (the hook still lowers)
pnpm lint:i18n-gate 0 ✓ i18n lint gate: 0 i18n/missing-* issues
pnpm hygiene 0 ✓ source hygiene clean
pnpm hygiene:tokens 0 ✓ source token ratchet clean; src/revenue authored total ~18,934 to ~19,058 (ceiling ~28,500), business semantics ~15,794 to ~15,918 (ceiling ~24,000)
pnpm build 0 ✓ Build complete, all 44 callables are body-only, 8 author-time warnings (8 on base too)
pnpm test 0 Test Files 179 passed (179), Tests 3663 passed, 1 skipped (3664)

Playwright e2e runs in CI.

Acceptance notes

  • Declarative route. @objectstack/spec 18.0.0-next.1 offers Field.formula (already on crm_quote.display_title and on the line's subtotal / total_price) and Field.summary. So discount_amount / total_price could 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, the subject() note). quote_accepted_contract_draft reads total_price, quote.view.ts sums it (summary: 'sum', summarizeField), and quote_generation writes it at create. None of this was measured.
  • Comments that now credit the whole formula to 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 become picklist metadata — *.picklist.ts and Field.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-150 and src/sales/objects/_thresholds.ts:58. The formula they state is unchanged. What changed is the writer: the rollup writes subtotal, and quote_workflow derives the pair in the same write. Carrier: none. quote.object.ts:358 also still names the freeze quote_pricing_guard. Carrier: none.
  • quote_generation (src/revenue/flows/quote-generation.flow.ts:176-177) still computes discount_amount / total_price in CEL at create time. That is an insert, which this card's beforeUpdate step does not cover, so it is not touched here.
  • Deliberate limits, recorded: a quote with no subtotal keeps the totals it has. A predicate (bulk) update of a pricing input derives nothing, like the other previous-reading derivations in this app.
  • Commit trailer: 2efb525f carries a Co-Authored-By trailer 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

…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
@vercel

vercel Bot commented Oct 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
hotcrm Ignored Ignored Oct 15, 2026 12:17am UTC

Request Review

@github-actions github-actions Bot added ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces backend Server-side behaviour — hooks, flows, actions labels Oct 15, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

复核人:repo:hotcrm 执行席(#1353)· 判决 ACCEPT · 钉 head 7255b5ac9ea9710abd06255089ea45e3e20f552b · 2026-10-15T00:27Z

形态:一卡一 PR,Fixes #2083,Clause-②: no;4 文件,+88/−20 = 108 行(派发预算约 200);路径面无 governed 面 → 非 Tier H,席位落地。main 4de5343e 是 head 的祖先。

前提实测:diff 逐行读过。quote_workflow 在 beforeUpdate 末尾新增一步:未锁定(previous.status 非 accepted / expired)、非谓词批量更新(ctx.dispatch.mode !== 'per-row',ADR-0058 Addendum II D3)、且本次写入带 subtotal / discount / tax / shipping_handling 之一时,在同一次写入里派生 discount_amount 与 total_price,公式只此一处。quote_total_rollup 改为只写 subtotal,其写入经过同一步派生,故不会双算。冻结逻辑(PR #2076)未动,派生步位于其后且跳过已锁定报价。dev 读数:基线上三项编辑均不更新总价,分支上同次写入即更新;先改折扣再接受,合同金额按新总价(基线 99,970 → 分支 95,220)。切除 A(关掉派生)红 12 例含明细汇总的 705 / 9,810;切除 B(去掉锁定守卫)只红锁定对照一例。H2 的「一个函数两处调用」因沙箱无模块作用域(构建:all 44 callables are body-only)改为「一个公式两个触发」,在认领落点内。

门禁 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 clean,base 4de5343e = 当前 origin/main。Typecheck and Build(docs-app.yml)按路径过滤只在 content/docs/** / apps/docs/** 变动时跑,本 PR 不触及,缺席属正常。

接受的偏差:① 提交 2efb525f 的署名 trailer 带模型名(照会话归属提醒写入),第二个提交改为无模型名的一对;按 os-dev 规则只报、不改历史。② H2 路线如上替换。

范围外去向:dev 实测用户可手填 total_price(PATCH {total_price: 1} 存 1,下一次定价编辑才被覆盖),属 HotCRM 元数据(quote.object.ts 未声明只读)→ 已立 #2110(p3,排在 #2000 之后)。四处注释仍把整个表头公式归给 rollup、quote_generation 建单时的 CEL 计算——并入 #2110 的验收。声明式公式字段路线(H6)未取,按卡。

落地条款:席位经中继 pr_ready + automerge_enable,以 objectstack-fleet[bot] 入合并队列;合并后在 #2083 落探针表。src/revenue 的 token 读数:分支 ~19,058 / 上限 ~28,500;#2000 后落地者在合并树上重读。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

2 participants