Repository navigation
fix(lead): refuse a conversion whose opportunity amount is zero or less before anything is written - #2112
Conversation
…less before anything is written A ticked Create Opportunity with an amount of 0 or less reached create_opportunity after create_account and create_contact had run, and crm_opportunity's amount_positive rule then failed the run: one account, one contact, no opportunity, an unconverted lead. A screen field's min is inclusive in the 18.0.0-next.1 spec, so "greater than zero" cannot be declared on the dialog. decision_amount now sits between screen_1 and find_account (e2 retired, e43-e46) and ends a ticked, non-positive amount on a field-less refusal screen ahead of every write; its two edges restate amount_positive's predicate and partition every shape. opportunityAmount is declared with a null default so the edges never read an unbound variable. The refusal is translated in the zh-CN, es-ES and ja-JP packs like its three siblings. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv
…ed run The refusal added in the previous commit was a message-only screen, the shape the three older refusals in this flow use. Measured in the console, that shape toasts "Saved — next step" over the refusal, and Submit on such a screen toasts convert_lead's "Lead converted successfully!" although nothing was converted. It also records the run as completed. refuse_nonpositive_amount is now an end node with outcome: 'refused' and a message, as quote_generation's refuse_held already is: the run ends refused, the console shows the message with Close only, and the packs translate it under refusals.refuse_nonpositive_amount.message. e46 is gone with the screen; e43-e45 are unchanged. 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 形态: 前提实测: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 接受的偏差:① 建议路线的「消息屏」先实现后被替换:控制台实测它会在拒绝上方弹「Saved — next step」、提交时弹「转化成功」,改用一等的 refused 结束节点;「同一轮重提」因此改为「新开一轮」。② 落点多出三个语言包文件(各 +6,拒绝文案翻译)。 范围外去向:另外三个拒绝弹窗(重复、未审批、无匹配键)点「提交」会弹「线索转化成功」而线索并未转化 → 已作为 #2060 第 2c 项记录(评论 落地条款:席位经中继 |
Part of #2060 (item 2b only, addendum comment 6122864495). The card stays open for its other items.
Clause-②: no
What was wrong
Converting a lead with Create Opportunity ticked and an amount of 0 or less half-converted it.
crm_opportunity'samount_positiverule refuses an amount of zero or less, but the conversion dialog let it through.create_opportunitythen failed aftercreate_accountandcreate_contacthad already written their rows.Measured on the unchanged base
4de5343e(@objectstack/*18.0.0-next.1), in-process through the real resume route:0FLOW_FAILED/ 400, "Amount must be greater than zero"-5FLOW_FAILED/ 400, "Amount must be ≥ 0""0"(string)FLOW_FAILED/ 400, "Amount must be greater than zero"0.5,0.001,25000,"25000"Why a decision node, and not a screen bound
The installed 18.0.0-next.1
ScreenFieldConfigSchemadeclares twelve keys:name,label,type,required,options,defaultValue,placeholder,visibleWhen,min,max,inlineHelpText,reference. It has no exclusive bound and no validation expression.validateScreenInputsrefuses only a value belowmin, so a value equal tominpasses: the bound is inclusive. Amin: 0.01stand-in would refuse amounts the object accepts (0.001converts, measured above).The platform declares no transactional flow run, so the conversion has to refuse before it writes anything. There is no undo step and no compensation logic.
The change
decision_amountsits betweenscreen_1andfind_account, so every write is downstream of it.e2is retired and stays vacant. The new edges aree43(screen_1 to the decision),e44ande45.e45refuses whencreateOpportunity == true, the amount is not null, and the amount is ≤ 0. That isamount_positive's own predicate, so the two cannot disagree about which amounts convert.e44is the De Morgan negation ofe45. Exactly one of the two holds on every shape, and an unticked box never reads the amount.refuse_nonpositive_amountis anendnode withoutcome: 'refused'and amessage. This is the platform's first-class refusal, whichquote_generation'srefuse_heldalready uses. The run endsrefused, neverfailedorcompleted. Nothing is written, and the console shows the message with Close only.opportunityAmountis now declared withdefaultValue: null, because the new edges read it. Without the default,test/flow-variable-conditions.test.ts's CLASS 2 sweep fails one44ande45(measured below). The screen's answer overwrites the default. A ticked box with no amount is still refused by the screen'srequired(item 2), before any edge runs.flows.lead_conversion.refusals.refuse_nonpositive_amount.message. The engine renders the message in the run's locale.Why not a message-only screen like the three older refusals in this flow
The PM's suggested route was a field-less
waitForInputscreen, the shaperefuse_confirmed_duplicate,refuse_unapprovedandrefuse_no_match_keyuse. I built that first (3561dc2d), then measured it in the console:convert_lead's "Lead converted successfully!" while the lead stays unconverted (measured; see Acceptance notes).EndConfigSchemanames the message-only screen as the shapeoutcome: 'refused'replaces.613729f5therefore moves this refusal to theendnode. The three older refusals are untouched (out of scope).Measured after the change
objectstack dev -p 4160 --freshon this branch, admin session; trigger, then resume):0: 200,success: true,status: refused, norunId. 0 accounts, 0 contacts, 0 opportunities; lead qualified, not converted.-5: the same.25000: 200, converted; 1 / 1 / 1, amount 25000.Accept-Languageset to zh-CN, ja-JP or es-ES,refusalMessagecame back in that language./opt/pw-browsers/chromium, DOM probes):["Close","Close"]). No toast fired.Tests
New block in
test/flow-conversion.test.ts, beside item 2's:codeundefined,success: true,status: 'refused',runIdundefined, 0 / 0 / 0, lead qualified and not converted. It then asserts that a refusal message is present (the message text is not pinned). A new run with 25000 then converts fully.decision_amounthas exactly one live edge on every shape, judged on the real evaluator: unticked/null, unticked/0, ticked/0, ticked/-5, ticked/0.01, ticked/25000, ticked/null.Counter-proofs, run on committed
613729f5. Each one restored the flow bygit checkout HEAD, proven by blob3ef28163== HEAD and an emptygit diff HEAD:Tests 3 failed | 1 passed. Both refusal cases come backcode: FLOW_FAILED,status: 400, accounts 1, contacts 1. The partition test is red.e43aimed back atfind_account(ablation-replace, anchor 1 to 0):Tests 2 failed | 2 passed, with the same base shape.defaultValue: nulldropped:flow-variable-conditionsTests 1 failed | 69 passed. The failure nameslead_conversion.edge:e44ande45readingopportunityAmount, which no node binds on every path.Local gates on
613729f5, each exit code captured on its own:pnpm validate0 (✓ Validation passed) ·pnpm typecheck0 ·pnpm lint0 (18 suggestion(s), 0 errors) ·pnpm lint:i18n-gate0 (✓ 0i18n/missing-*issues) ·pnpm hygiene0 (✓ source hygiene clean) ·pnpm hygiene:tokens0 (✓ source token ratchet clean;src/salesbusiness semantics ~58,693 → ~58,934 of ~88,500) ·pnpm build0 (✓ Build complete) ·pnpm testunder the verify lock:VERDICT command-exit 0,Test Files 179 passed (179),Tests 3664 passed | 1 skipped (3665). Playwright e2e is CI's.Acceptance notes
refuse_confirmed_duplicate, Submit toasts "Lead converted successfully!" although the lead is not converted. The runs also recordcompleted. The route is the one this PR takes: anendnode withoutcome: 'refused'.content/docs/sales/leads*.mdx: no sentence became false, so the docs are untouched.Generated by Claude Code