Repository navigation
fix(quote_generation): refuse Generate Quote before qualification approval instead of leaving an orphan quote - #2034
Merged
objectstack-fleet[bot] merged 5 commits intoOct 9, 2026
Conversation
…roval instead of leaving an orphan quote With the REQ-0006 qualification gate armed, a deal whose qualification_approval_status is pending or rejected had its quote created and then the stage write refused by opportunity_lifecycle, so the run failed with FLOW_FAILED 400 and the draft quote stayed behind. The flow now reads the verdict at click time, ahead of the form and of every writer, and a partitioning decision routes pending/rejected to an end node with outcome 'refused': the run ends refused, nothing is written, and the rep is told qualification approval comes first. Every other verdict, including the shipped not_required default, runs exactly as before. Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3 Co-authored-by: Claude <noreply@anthropic.com>
…he gate is armed The Generating a quote section of the Quotes page states the rule in all three locales, and the state-machines note that quoting can happen at any open stage names the qualification exception. Adds the patch changeset. Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3 Co-authored-by: Claude <noreply@anthropic.com>
…ed fetch to stay inside the revenue token ceiling The first cut cost ~393 tokens against the 178 left under the src/revenue authored-total ceiling (19,000), and the ratchet is paid by compressing, never by raising it. The gate is now one get_record whose filter is the gate's reading (pending or rejected), two complementary conditional edges off it (no decision node, no has() guard on a get_record-dominated read), and the same end node with outcome 'refused'. Behaviour is unchanged: src/revenue reads ~18,994 tokens. Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3 Co-authored-by: Claude <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
This was referenced Oct 9, 2026
…on approval too The Qualification approval section now says quoting waits while the deal is Pending or Rejected, and the Approved row opens Generate Quote with the stage and the won/lost call, in all three locales. The new Quotes-page paragraphs name the button by its UI label (生成报价单 / 生成報價單). Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3 Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Oct 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2032
Clause-②: no
Ruling executed: hotcrm-R74b item 2, B. Before 立项 is approved, Generate Quote refuses and creates nothing (REQ-0006 step 11, with steps 15 and 18). With the gate off, the default, nothing changes and the #2020 keep-stage rule still holds.
Measured before the fix (the public door)
On base
08cfa209(@objectstack/*17.7.0), I armed the gate the way the flow header says an install does:crm_opportunity.qualification_approval_statusdefaultValuenot_required→pending, trap-restored afterwards (blob proven equal to HEAD). Then a sales_rep created a 50,000 deal atqualification, so the deal was bornpending, and drovequote_generationthrough the verify handle's flow doors (flows.run+flows.resume):FLOW_FAILED400:Node 'update_opportunity' failed: update_record(crm_opportunity) failed: This deal needs qualification approval first: tick Request Qualification Approval. Stage can change once it is approved.The run summary showedcreate_quoteacted 1 andupdate_opportunityfailed (stranded).crm_quotewas left behind (Q-REPRO, draft, 45,000).qualification, verdictpending.The fix, and why it is the minimal one
Only
src/revenue/flows/quote-generation.flow.tschanges behaviour:get_held(get_record) right afterstart, ahead of the form and of every writer. Its filter is the gate's reading:{ id: '{recordId}', qualification_approval_status: { $in: ['pending', 'rejected'] } }. That is the same two valuesopportunity_lifecyclerefuses on, with the same fail-open on every other value (not_required,approved, no verdict).heldtherefore binds the deal only while it is held, andnullotherwise.e9/e10: complementary conditionals off that data node (vars.held != null/vars.held == null). This is the shapebilling-handoff-contract-activated.flow.tsrecords. There is nohas()guard because the read is get_record-dominated (the casetest/flow-variable-conditions.test.tssays needs none, likelead_conversion'se5/e6).refuse_held: anendnode withoutcome: 'refused'. The run endsrefused: never failed, never parked, nothing written. The console shows the message with Close only, with no Submit and no "completed" toast.e1(start → screen_1) is retired and left vacant. New edges takee7,e9,e10.Why here and only here:
POST /automation/quote_generation/trigger. The hook already refuses the stage write. What was missing was refusing beforecreate_quote.generate_quotekeeps its visibility. Hiding it would leave the rep a missing button with no reason, because an action'svisible/disabledare bare predicates with no message. The ruling asks for a refusal the user can see.get_opportunitystays after the screen, so pricing and the quote_generation leaves its quote behind when the deal is approval-locked: a rep's run fails RECORD_LOCKED at update_opportunity and the created quote stays #2015 approval branch (e4a/e4b, untouched) still read the deal as it is at submit. The gate's own read happens at click time. The verdict cannot drift toward held while the form is open, becausenot_requiredandapprovedare never written back topending.Token ratchet. The first cut cost ~393 tokens against the 178 left under the
src/revenueauthored-total ceiling (19,000). The ratchet is paid by compressing and the ceiling stays where it is, so the gate was rewritten as the single filtered read above.src/revenuenow reads ~18,994 / 19,000. Comments are free, so the rationale stays in the file.Measured after the fix (same door, same armed install)
flows.runreturned{ success: true, status: 'refused', refusalMessage: 'Qualification approval comes first: tick Request Qualification Approval. Nothing was created.' }, with norunIdand noscreen. The summary had acted 0 and failed 0; onlystartandget_heldran.crm_quoterows. The deal is stillqualification/pending. Thesys_automation_runrow hasstatus: refused,error: nullandrefusal_messageset.The test that pins the business fact
test/flow-quote.test.ts› quote_generation flow — the 立项 gate (#2032). Each deal carries its verdict from the moment it is written, which is the row an armed install produces:pending/rejected: refused before the form and before any write. The run isrefused/success: true, has norunIdand noscreen, and its summary shows acted 0 and failed 0. 0 quotes, and stage and verdict are unchanged.approved: the quote is drafted (45,000 at 10%) and the stage advances toproposal.not_required, and a deal with no verdict): unchanged. The quote is drafted and the stage advances.pending,rejected,approved,not_requiredandnull, the flow refuses exactly whenopportunity_lifecyclerefuses the same rep's stage move (RECORD_LOCKED/ 409).Reverse verification, from the committed
f57bf734. Each leg was trap-restored and proven by blob hash withgit diff HEADat 0 bytes:866add41: 4 failed | 14 passed. The pending and rejected refusals failed, and so did the pending and rejected agreement cases.$in: ['pending'](original count 0, injected count 1): 2 failed | 16 passed. Both failures were the rejected cases. Both legs turned red in the predicted direction.Deviation from the dispatch's suggested route: the refusal sentence ships in English only
The dispatch asked for the sentence to be translated in every locale. At 17.7.0 the
flowstranslation group carries only a flow'slabeland its screens'titleand field copy. It has no key for anendnode'smessage. Measured withTranslationDataSchema.safeParse:flows.quote_generation.screens.X.titleis accepted, buttranslateFlowoverlays onlytype: 'screen'nodes, so it would translate nothing.screens.X.descriptionis refused ("description— a screen's body text — is not part of the flows translation surface").screens.X.message,flows.X.refusalsandflows.X.messageare all refused as unrecognized keys.The only translatable alternative is a message-only screen with a translated title. That is the shape the platform's
outcome: 'refused'replaced, because it renders Submit and then toasts Flow "…" completed at a user who was just refused (spec 17.4.0 changelog). Its run also stays parked when the dialog is closed, since the console never cancels a run. So the refusal uses the platform's first-class construct. The dialog is titled with the action's translated label, the docs state the rule in all three locales, and the missing translation key is reported as a platform gap for upstream filing. The existingopportunity_lifecyclerefusal of the same gate is English-only too.Docs and changeset
.changeset/2032-quote-before-qualification.md('hotcrm': patch).Acceptance notes
content/docs/sales/opportunity-qualification*.mdx› Qualification approval lists what waits for 立项 ("moving its stage and the won/lost call") and does not mention Generate Quote, which now waits too. That page is outside this PR's claimed file surface, so it is not edited here. It is reported for a docs follow-up: one clause per locale.crm_quoteallowCreate: true(src/sales/profiles/sales-rep.profile.ts), so a quote created by hand on an unqualified deal is not held. Whether step 11 also covers that door is a business question. It was not measured here.lead_conversion's three refusals (refuse_unapproved,refuse_confirmed_duplicate,refuse_no_match_key) are still message-only screens. Noted, not filed.Gates
pnpm verify@ee54bc50(origin/mainf071b36cmerged in):os-verify-lock: VERDICT command-exit 0. Steps:✓ Validation passed,tsc --noEmitclean,lint --strict18 suggestion(s) and 0 errors,✓ i18n lint gate: 0,✓ source hygiene clean,✓ source token ratchet clean(src/revenue authored total ~18,994, ceiling ~19,000),✓ Build complete,Test Files 172 passed (172),Tests 3587 passed | 1 skipped (3588).pnpm changeset status --since=origin/main: exit 0,hotcrmpatch.pnpm verifyis the repo's whole local chain.test:coveragethresholds (hooks only, untouched), the docs app, link-check and Playwright e2e are declared to CI.Patch round 1 (appended by the
repo:hotcrmseat)Head
2688dacd, on the seat's review ofee54bc50(amended claim6077845274):content/docs/sales/opportunity-qualification*.mdx§ Qualification approval now says Generate Quote waits for 立项 too, and its Approved row opens it; the new zh paragraphs on the Quotes pages name the button by its zh-CN label, 生成报价单 / 生成報價單. Docs only, 5 files, +8 / −8. The refusal sentence stays English:flowstranslations have no key for anendnode's message, filed as objectstack-ai/objectstack#22450.Generated by Claude Code