Skip to content

fix(leads,contacts): the Convert Lead screen names the account the lead will join; Add to Campaign on a contact is declared on the row menu only (#2073 items 5b, 8) - #2203

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-2073-items-5b-8-contact-toolbar-conversion-account
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-2073-items-5b-8-contact-toolbar-conversion-account

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #2073 (items 5b, 8)
Clause-②: no

Items 5b and 8 only. The card stays open for item 6 (its second half).

What this does

Item 8, the Convert Lead screen. It asked only Create Opportunity? (是否创建商机?), and find_account matched an account by the lead's normalized company name after the screen. The rep was never told which account the lead would join.

  • src/sales/flows/lead-conversion.flow.ts: find_account runs ahead of screen_1. This is the pattern get_lead already follows: a read before the screen, every write behind it. A new assignment node, compose_account_notice, binds exactly one of existingAccountName (the matched account's own name) and newAccountName (the lead's company) from that read, and sets the other to null. The screen's body text now carries an account notice after the duplicate warning:
    • existing: "Lattice Education is the existing account this lead will join."
    • new: "No account you can see matches the company on this lead, so converting creates a new account named Brightwater Labs."
  • The submit branches on the same binding (decision_account, unchanged), so what the screen says is what is done. No match runs after the screen.
  • Edges: e31 (decision_match_key → screen_1), e44 (decision_amount → find_account) and e4 (find_account → decision_account) are retired and left vacant. e48 carries e31's condition onto find_account, e49 and e50 lead through the notice to the screen, and e51 carries e44's condition onto decision_account.
  • Why the template has two holes. A screen description is a per-run {{ }} text slot, which the engine translates in the run's locale. A hole is a path plus an optional formatter, with no logic (@objectstack/formula's template engine), and default: is its only conditional. So each hole's default: carries the other branch's words, and the sentence is built name-first. Composing the sentence in a variable would ship it in English to every locale, because no pack reaches a variable's text.
  • src/sales/translations/{zh-CN,es-ES,ja-JP}/app.ts: flows.lead_conversion.screens.screen_1.description translates the whole template. The en pack carries no flows block; the authored copy is English.
  • Untouched: opportunityName's default ([Decision] Which of the 33 UI and UX suggestions from the 2026-10-20 browser passes to adopt (seat: adopt 23, fold 4 into open cards, defer 6) #2194's T1-U09 is undecided), every field, every refusal.

Item 5b, Add to Campaign on a contact.

Tests.

  • New file test/lead-conversion-account-notice.test.ts, 13 cases:
    • Structural: find_account and the notice precede screen_1, no node ahead of the screen writes, and no match runs after it.
    • Per locale: every pack (and the authored English) is rendered through templateEngine for both branches with the same company name. The pin is that existing and new read differently, each names the account exactly once, and the duplicate warning still leads.
    • Five runtime paths as a sales_rep, each reading back the account it ends on: an account the rep owns; no match; the named account deleted before the submit; a colleague's unshared account with the same name; and one differing only in case and spacing.
  • test/campaign-enrollment-doors.test.ts: a describe for 5b. It checks the declaration, and that All Contacts still names the action in bulkActions in the per-record form.
  • Moved pins:
    • test/account-name-normalized-match.test.ts: the match-key partition reads e32 / e48.
    • test/lead-duplicate-visibility.test.ts: two "no warning on a clean lead" cases now assert the warning's absence instead of description === undefined. The body text always carries the notice now.

Docs, content/docs/sales/leads in three locales, conversion section only:

  • The form names the account before you submit.
  • If it is not the expected one, cancel, correct the lead's Company, and convert again.
  • The match only looks at accounts you can see, and the page says what happens when a colleague's unshared account has the same name.
  • The section also names the button as shipped, Convert Lead (转化线索 / 轉化線索), where it said Convert.

Changeset. .changeset/2073-conversion-screen-names-the-account.md (patch).

Item 8: show the match, or let the rep pick, by the four axes

What the platform can express was measured first.

  • Show needs the match read before the screen, plus a translated template; both exist.

  • Pick would be a type: 'lookup' screen field with reference: 'crm_account', defaulting to the match. The console's runner draws that as a record picker over the user's own data source, so it offers only accounts the rep can see.

  • Real business need. Measured on the shipped demo: 3 of the 21 seeded leads carry a company equal to a seeded account's name, and 0 carry a near-miss that normalization misses and a person would have to pick by hand. The card's tester saw only the missing information ("Matching into the rep's own account worked"). The existing correction path for a mismatch is the lead's own Company, the match key's producer. Showing the match makes that path visible: a rep who reads "creates a new account named Acme Corp" when the customer is Acme Corporation can cancel and fix it. Picking adds re-parenting a conversion onto any account, and nothing measured asks for that. The picker would not reach a hidden account either, so it does not help the hidden-account paths.

  • Long-term soundness. Show keeps the normalized key as the one authority: the producer canonicalizes, the flow matches, the screen reports. Pick adds a second authority, an id the client posts. The flow would have to re-read it as the user, and it would have to give "cleared the prefilled match" a meaning. That meaning is a same-name create, which is either the unique-name refusal or a case-variant duplicate.

  • Making AI mistakes harder. Show is one read moved, one assignment and one template, and nothing downstream has to tolerate anything. Pick puts a client-collected id into writes, a trust boundary that is easy to forget to re-check.

  • No scope growth. Show adds no capability. Pick adds one.

So the choice is show.

Measured

Engine: @objectstack/verify boot of this app on 18.0.0-next.5. The converter is a sales_rep and the colleague is another sales_rep; crm_account is sharingModel: 'private', so neither reads the other's accounts. The screen is submitted with its prefilled bag and the box unticked. Before is main 9db9a58b; after is this branch's flow, unchanged since e88a7c26.

path before: screen body · outcome after: screen body · outcome
A, an account the rep owns none · joins it "Visible Co … is the existing account this lead will join." · joins it, 1 account
C, no match none · 1 new account "… creates a new account named Fresh Co …" · joins the 1 new account, owned by the rep
B1, a colleague's account, same name none · FLOW_FAILED 400 "Node 'create_account' failed: … Duplicate record refused on 'crm_account' … No record was written.", lead unconverted "… creates a new account named Hidden Co …" · the same refusal, unchanged
B2, a colleague's account, same key, different case and spacing none · converts; 2 accounts share the key "… creates a new account named HIDDEN near …" (never the hidden name) · the same: a second account, the rep's
D, the matched account deleted (system) between screen and submit none · converts into a NEW account the rep was never told about "Gone Co … is the existing account this lead will join." · FLOW_FAILED 400 at create_contact ("requires edit access to its master record"), nothing written, lead unconverted

Browser: objectstack dev on this worktree with pnpm demo:staff, Chromium at /opt/pw-browsers/chromium, 1440×900, signed in as eu.rep@objectos.ai (sales_rep + eu_sales_team, who sees 2 accounts: Lattice Education and Northwind Energy). Each path ran from the lead's Convert Lead button, then Submit with the box unticked, then a read back as the admin. The screenshots are kept on the rig.

  • Before (9db9a58b), en-US: the dialog read "Lead Conversion Process / Conversion Details / Create Opportunity?" on all four paths. zh-CN read "线索转化流程 / 转化详情 / 是否创建商机?".
    • A (Northwind Energy) joined Northwind Energy.
    • B1 (Vertex Analytics, NA, hidden) showed the duplicate refusal above in the dialog and a toast, and the lead stayed qualified.
    • B2 (VERTEX analytics) created VERTEX analytics beside Vertex Analytics.
    • C created a new account.
  • After, en-US:
    • A (Lattice Education): "Lattice Education is the existing account this lead will join." It joined tEjAHnzxbV8-6gR4, Lattice Education.
    • B1 (Stark Medical, NA, hidden): "No account you can see matches the company on this lead, so converting creates a new account named Stark Medical." The submit was refused as before, and the lead stayed qualified with 1 Stark account.
    • B2 (STARK medical): the new-account sentence naming STARK medical. It created STARK medical, owned by the rep, beside Stark Medical.
    • C: the new-account sentence naming Brightwater Labs after2. It joined that one new account.
  • After, zh-CN:
    • A: "Northwind Energy 是该线索将加入的现有客户。" It joined Northwind Energy.
    • B1 (Wayne Enterprises): "没有你能查看的客户与该线索的公司匹配,因此转化会新建一个客户,名称为 Wayne Enterprises。" The submit was refused.
    • B2: the same sentence naming WAYNE enterprises. A second account was created.
    • C: the same sentence naming Brightwater Labs afterzh. One new account.
  • The leading space the template leaves before the notice when there is no warning is not drawn (the screenshots show the sentence flush left).

Mechanism assumptions

  1. Holds.
    • get_lead ran ahead of screen_1 (e0, e27, and the gates to e31).
    • find_account ran after it (e43 → decision_amount → e44) and only read.
    • use_new_account / use_existing_account normalize both branches onto accountId.
    • A screen description is a {{ }} text slot rendered per run and translated by the engine (renderFlowTextSlot, key flows.lead_conversion.screens.screen_1.description).
    • A field's defaultValue is a CEL envelope evaluated before the wire.
    • A field's inlineHelpText is forwarded raw, so it is static and cannot show a fetched name.
  2. Holds in part; pick was not built.
    • The runner (@objectstack/console 18.0.0-next.5) draws type: 'lookup' with a reference as a record picker over the user's data source.
    • What the flow does with a picked id the rep cannot see was not measured. D measured create_contact refusing an account the rep cannot edit ("requires edit access to its master record"), so the write as the user would refuse it. That is inferred for a picked id.
  3. Holds. The packs' flows.lead_conversion.screens.screen_1 block translates the screen, and description already existed there as {{duplicateWarning}}. All three non-en packs carry the new template. pnpm lint:i18n-gate reads 0 i18n/missing-*.
  4. Holds. content/docs/sales/leads.mdx and its twins describe the form ("A small form pops up asking …"). The conversion section is updated.

Tests and gates (head 541524d, main b91ac1b merged)

pnpm verify, step by step through the shared verify lock. Each exit is read from the lock's VERDICT command-exit line.

step exit · reading
pnpm validate && pnpm typecheck && pnpm lint && pnpm lint:i18n-gate && pnpm hygiene && pnpm hygiene:tokens && pnpm build 0 · "Validation passed"; 16 lint suggestions, none naming these files; "0 i18n/missing-* issues"; "source hygiene clean"; src/sales business semantics ~57,643 tokens (ceiling ~88,500); build complete with the same 8 author-time warnings, none naming these items
vitest run --shard=1/3 --maxWorkers=2 0 · 69 files, 1137 passed
vitest run --shard=2/3 --maxWorkers=2 0 · 69 files, 1296 passed, 1 skipped (the existing it.skipIf in test/script-main-guard.test.ts)
vitest run --shard=3/3 --maxWorkers=2 0 · 68 files, 1619 passed
  • The shards hold 206 files, the count find test -name '*.test.ts' gives.
  • tsc --listFiles lists both changed test files, so typecheck covers them.
  • A control-byte scan of the 13 changed files found no hit, and a positive control did.

Ablation, one per changed behaviour, run once and not kept. Both ran on ed681098 through scripts/ablation-replace.mjs in wrap mode, inside a script trapping git checkout HEAD -- on both absolute paths. No build is needed: the tests read src/ through the composed stack.

  • A1 put list_toolbar back on add_contact_to_campaign. The anchor went 1 → 0 and the blob 3617682b → ac02b5ab. The 5b describe gave "Tests 1 failed | 5 passed | 28 skipped (34)", the predicted case: "declares the row menu as its only location".
  • A2 put the body text back to '{{duplicateWarning}}'. The anchor went 1 → 0 and the blob ace246ec → fc4b766c. test/lead-conversion-account-notice.test.ts gave "Tests 7 failed | 6 passed (13)", the 7 predicted: the en render case, the warning-ahead case, and the five runtime paths. The three pack render cases and the structural cases stayed green.
  • Restore: both blobs equal HEAD: again, and git diff HEAD is empty.

Acceptance notes

  • The hidden-account paths are unchanged, and put to the seat as a question.
    • B1 (a colleague's unshared account with the same name) is refused at submit.
    • B2 (same key, different case or spacing) creates a second account.
    • The screen now tells the rep a new account will be created in both. That is true from the rep's view, and it cannot say more without naming an account the rep may not read.
    • ⛔ The match is not elevated, by ruling. The leads page tells the rep to ask the account's owner or an administrator to share it.
  • B1's refusal reads as the platform's raw node failure, "Node 'create_account' failed: create_record(crm_account) failed: …", in English under zh-CN too. It is the same before and after. It is the platform's text for a failed flow node, so it is reported to the seat, not changed here.
  • D is a behaviour change, deliberately.
    • Before, a match deleted while the screen was open became a new account at submit, which the rep was never told about.
    • Now the submit acts on the account the screen named, or writes nothing.
    • The refusal text there is the same raw platform node failure.
  • 5b changes nothing on screen. It removes a placement whose only guard was an unrelated predicate.
  • docs/feature-inventory.md stays with Browser pass #2058 — HotCRM-side defects (18 items: accepted quote still takes lines, half-converted leads, agents losing their cases, unreachable Sales Home, …) — list card, split per item at dispatch #2060 item 16, and no row there becomes wrong. LEA-010 describes the dedupe, which is unchanged; the account notice is not in it, which is that claim's call.
  • getting-started/quick-tour (three locales) also calls the button Convert. That page is outside this claim, and it is reported to the seat.

Generated by Claude Code

…ad will join; Add to Campaign on a contact declares the row menu only

The conversion's account match now runs ahead of the screen, and the
screen's body text says, before the rep submits, whether the lead joins an
existing account (named) or creates a new one (named after the company).
The submit branches on that same read. Every locale pack translates the
notice. The contact action drops its list_toolbar placement, as the lead
action did.

Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r
Co-authored-by: Claude <noreply@anthropic.com>
…he conversion notice and the contact action

The leads page (three locales) says the form shows which account the lead
will join, that the match only looks at accounts the rep can see, and what
happens when a colleague's unshared account carries the same name.

Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r
Co-authored-by: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 20, 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 20, 2026 1:58pm UTC

Request Review

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT (#2073 items 5b, 8) · PR #2203 at 541524da · repo:hotcrm seat 2 · os-support-ai · session session_01KqkTQNLcxVc588eZpJmp1r · 2026-10-20T14:04Z · report 6292654905 on hotcrm#2073

  1. Shape:
    • Draft, base main b91ac1b7, which is still main. It opens with Part of #2073 (items 5b, 8) and Clause-②: no, and the body carries no closing keyword. Assignee os-support-ai.
    • 13 files, +467 / −38, all inside the claim's surface. docs/feature-inventory.md and every content/docs/ page other than sales/leads*.mdx are untouched. check-governed-merges --pr: no governed path.
    • 0 model-identifier lines in the commits.
  2. The diff, read:
    • 5b: add_contact_to_campaign declares ['list_item'], the same as create_campaign, with the ⛔ reason. Nothing changes on screen, as measured.
    • 8, remedy "show": find_account runs ahead of screen_1, as get_lead already does, and writes nothing. compose_account_notice binds exactly one of existingAccountName / newAccountName, and the screen's body says which account the lead will join.
      • decision_account branches after the screen on the same binding, so the screen and the submit cannot disagree. The ⛔ comment forbids a second read.
      • The notice is one template that every pack translates whole. Its two default: arms carry the two branches, and the new test renders each locale for both.
      • Edges: e31, e44 and e4 are retired and stay vacant. e48 and e51 carry their conditions unchanged, and e49 / e50 route through the notice.
      • One deliberate behaviour change: an account deleted while the screen is open now refuses the submit with nothing written, where before it silently created a new one. The seat accepts it, because the submit stays what the screen said.
    • The rep's view stays the subject: the match runs as the user, and nothing widens a read, as the ruling required.
  3. The open question, the seat's call: A, keep. It covers a colleague's unshared account with the same name (a late refusal) or the same key (a second account).
    • This is the seat's call, and the maintainer can veto it.
    • Reach needs a cross-territory lead for a customer an unshared colleague owns, and nobody has reported it.
    • B puts an elevated read in the conversion path.
    • C, a unique name_normalized, is the structural fix. It reverses the decision recorded at account.object.ts, and it needs a merge pass first on any deployment that already holds near-duplicates. Should the maintainer want the gap closed, C over B.
  4. Changeset and docs, read sentence by sentence: each matches the diff. The leads page in all three locales now says what the screen shows, and names the button Convert Lead (转化线索 / 轉化線索), which is its shipped label.
  5. Gates:
    • The dev's verify on 541524da, the merged tree:
      • the static chain: VERDICT command-exit 0, with 0 i18n/missing-* issues;
      • the three shards: 1137, 1296 + 1 skipped, and 1619 passed;
      • 206 files.
    • Both ablations went red on the predicted cases (1 and 7), and each restore is proven blob-equal.
    • Engine before and after, at 9db9a58b and on the branch. Browser in en-US and zh-CN as eu.rep, on a rebuilt artifact; a stale first reading was discarded.
    • CI on 541524da at this review: 9 checks success, 1 running. Landing waits for every check.
  6. Out of scope, dispositions:

Landing: once every check on the head is green, this seat flips the PR ready and arms auto-merge, bringing main in first if it has moved. PR #2202, the 18.0.0 upgrade, may land first. After the merge, #2073 goes back to pm:queue for item 6's second half.

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 documentation Improvements or additions to documentation metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants