diff --git a/.changeset/2073-conversion-screen-names-the-account.md b/.changeset/2073-conversion-screen-names-the-account.md new file mode 100644 index 000000000..4a7aa03dd --- /dev/null +++ b/.changeset/2073-conversion-screen-names-the-account.md @@ -0,0 +1,11 @@ +--- +"hotcrm": patch +--- + +**The Convert Lead screen now says which account the lead will join, before you submit.** It used to ask only **Create Opportunity?** (是否创建商机? in Simplified Chinese) and then matched an account by the lead's company in silence. Now the screen names the existing account the lead will join, by that account's own name, or says that no account you can see matches and that converting creates a new account named after the lead's company. It reads in every shipped language. If the account is not the one you expected, cancel, correct the lead's **Company**, and convert again. + +The match is still the same normalized company-name match, and it still only looks at accounts you can see. An account owned by a colleague and not shared with you is never matched, and the screen now tells you a new account will be created. If that account has exactly the same name, the conversion is refused as a duplicate record and nothing is created, as before. If its name differs only in capitalisation or spacing, a second account is created, as before. Ask the account's owner or an administrator to share it with you before converting. + +The match now happens when the screen opens, and the conversion acts on that match. If the named account is deleted while the screen is open, the conversion is refused and nothing is created. Before, a new account the screen had never mentioned was created instead. + +**Add to Campaign on a contact is declared on each row's ⋮ menu only.** It also carried a list-header placement. That header button never appeared, because the action is offered only for a contact who has not opted out of email, and a list header has no contact. The placement is gone so that a later change to that rule cannot bring the header button back. On **All Contacts** you still tick rows and run it from the selection toolbar. diff --git a/content/docs/sales/leads.mdx b/content/docs/sales/leads.mdx index cb7ca4db9..5395c4263 100644 --- a/content/docs/sales/leads.mdx +++ b/content/docs/sales/leads.mdx @@ -125,7 +125,7 @@ Deleting a Contact, or the Account above it, is never blocked by a lead that poi ## Converting a qualified lead -A lead whose **Duplicate Status** is **Confirmed** cannot be converted. **Convert** +A lead whose **Duplicate Status** is **Confirmed** cannot be converted. **Convert Lead** opens a refusal instead of the conversion form: a reviewer has already compared this lead against an existing record and recorded that it repeats one, so converting it would create a second account, contact and opportunity for the same buyer. The @@ -134,14 +134,18 @@ confirmed duplicate as one instead. A lead flagged only *Suspected* still conver that flag is a machine's guess, and the conversion screen warns about it rather than stopping you. -When a lead is qualified, click **Convert**. A small form pops up asking: +When a lead is qualified, click **Convert Lead**. A small form pops up asking: - Should we also create an Opportunity? - If yes — what's the opportunity name and amount? +Above those questions, the form tells you which account the lead will join before you submit: the existing account it matched, by that account's name, or a new account named after the lead's company when no account you can see matches. If that is not the account you expected — say the lead reads `Acme Corp` and your customer is `Acme Corporation` — cancel, correct the lead's **Company**, and convert again. + +The match only looks at accounts you can see. An account owned by a colleague and not shared with you is never matched, so the form says a new account will be created. If that account has exactly the same name, the conversion is then refused as a duplicate record and nothing is created; if its name differs only in capitalisation or spacing, a second account is created. So when you expect the company to be an existing customer and the form says the account is new, ask the **Account Owner** or an administrator to share the account with you before you convert. + The system then, in a single step: -1. Creates an **Account** from the lead's company information — or reuses the one you already have. The match ignores capitalisation and extra spaces, so a lead for `ACME Corp` lands on your existing `Acme Corp` instead of creating a second account. It is not a fuzzy match: `Acme Corp` and `Acme Corporation` are still two different companies. +1. Creates an **Account** from the lead's company information — or reuses the existing one the form named. The match ignores capitalisation and extra spaces, so a lead for `ACME Corp` lands on your existing `Acme Corp` instead of creating a second account. It is not a fuzzy match: `Acme Corp` and `Acme Corporation` are still two different companies. 2. Creates a **Contact** from the lead's person information, attached to that account. It becomes the account's primary contact only when the account has none yet: converting into an account that already has a primary contact leaves that contact as primary. 3. *(Optional)* Creates an **Opportunity** in the *Prospecting* stage with a 90-day close date. 4. Marks the lead as **Converted** and locks it. diff --git a/content/docs/sales/leads.zh-Hans.mdx b/content/docs/sales/leads.zh-Hans.mdx index 3b4e8fe71..ff1302fa2 100644 --- a/content/docs/sales/leads.zh-Hans.mdx +++ b/content/docs/sales/leads.zh-Hans.mdx @@ -116,20 +116,24 @@ description: 在入站咨询成为真正交易之前对其进行捕获、评分 ## 转化一条已确认的线索 -**重复状态**为**已确认重复**的线索无法转化。点击**转化**后弹出的是一条拒绝说明,而不是转化表单: +**重复状态**为**已确认重复**的线索无法转化。点击**转化线索**后弹出的是一条拒绝说明,而不是转化表单: 已经有人把这条线索与某条既有记录做过比对,并判定它是重复的,此时转化会为同一个买家再造一套客户、 联系人和商机。线索上的**重复线索管理**区块指明了保留下来的那条记录——请把已确认的重复线索按重复 关闭,而不是转化它。仅被标记为*疑似重复*的线索仍然可以转化:那只是系统的猜测,转化界面会提示你, 但不会拦住你。 -当一条线索通过资格审核后,点击**转化**。会弹出一个小表单询问: +当一条线索通过资格审核后,点击**转化线索**。会弹出一个小表单询问: - 我们是否也要创建一个商机? - 如果是——商机名称和金额是多少? +在这些问题上方,表单会在你提交之前告诉你该线索将加入哪个客户:匹配到的现有客户(显示该客户的名称),或者在没有你能查看的客户与之匹配时,一个以线索公司命名的新客户。如果这不是你预期的客户——比如线索写的是 `Acme Corp`,而你的客户是 `Acme Corporation`——请取消,更正线索的**公司**,然后重新转化。 + +匹配只查看你能查看的客户。同事拥有、且没有共享给你的客户不会被匹配到,因此表单会显示将新建客户。如果那个客户的名称与之完全相同,转化随后会因重复记录而被拒绝,不会创建任何内容;如果名称只在大小写或空格上不同,则会再创建一个客户。所以,当你认为这家公司已经是现有客户、而表单却显示要新建客户时,请先请**客户负责人**或管理员把该客户共享给你,再进行转化。 + 然后系统会一步完成: -1. 根据线索的公司信息创建一个**客户**——如果已经有同名客户,则直接复用。匹配时忽略大小写和多余空格,因此公司名为 `ACME Corp` 的线索会落到你已有的 `Acme Corp` 上,而不是再建一个客户。这不是模糊匹配:`Acme Corp` 与 `Acme Corporation` 仍然是两家不同的公司。 +1. 根据线索的公司信息创建一个**客户**——或者直接复用表单所列出的现有客户。匹配时忽略大小写和多余空格,因此公司名为 `ACME Corp` 的线索会落到你已有的 `Acme Corp` 上,而不是再建一个客户。这不是模糊匹配:`Acme Corp` 与 `Acme Corporation` 仍然是两家不同的公司。 2. 根据线索的个人信息创建一个**联系人**,附加到该客户上。只有当该客户还没有主要联系人时,它才成为主要联系人:转化到一个已有主要联系人的客户时,原来的主要联系人保持不变。 3. *(可选)* 在*寻找客户*阶段创建一个**商机**,预计成交日期设为 90 天后。 4. 将线索标记为**已转化**并锁定它。 diff --git a/content/docs/sales/leads.zh-Hant.mdx b/content/docs/sales/leads.zh-Hant.mdx index 712f0aaa1..a31825e8c 100644 --- a/content/docs/sales/leads.zh-Hant.mdx +++ b/content/docs/sales/leads.zh-Hant.mdx @@ -118,20 +118,24 @@ description: 在入站詢問成為真正交易之前對其進行擷取、評分 ## 轉化一筆已確認的潛在客戶 -**重複狀態**為**已確認重複**的潛在客戶無法轉化。點擊**轉化**後彈出的是一條拒絕說明,而不是轉化 +**重複狀態**為**已確認重複**的潛在客戶無法轉化。點擊**轉化線索**後彈出的是一條拒絕說明,而不是轉化 表單:已經有人把這筆潛在客戶與某筆既有記錄做過比對,並判定它是重複的,此時轉化會為同一個買家再造 一套客戶、聯絡人和商機。記錄上的**重複線索管理**區塊指明了保留下來的那筆記錄——請把已確認的重複 潛在客戶按重複關閉,而不是轉化它。僅被標記為*疑似重複*的潛在客戶仍然可以轉化:那只是系統的猜測, 轉化介面會提示你,但不會攔住你。 -當一筆潛在客戶通過資格審核後,點擊**轉化**。會彈出一個小表單詢問: +當一筆潛在客戶通過資格審核後,點擊**轉化線索**。會彈出一個小表單詢問: - 我們是否也要建立一個商機? - 如果是——商機名稱和金額是多少? +在這些問題上方,表單會在你提交之前告訴你該潛在客戶將加入哪個客戶:比對到的既有客戶(顯示該客戶的名稱),或者在沒有你能查看的客戶與之相符時,一個以潛在客戶公司命名的新客戶。如果這不是你預期的客戶——比如潛在客戶寫的是 `Acme Corp`,而你的客戶是 `Acme Corporation`——請取消,更正潛在客戶的**公司**,然後重新轉化。 + +比對只查看你能查看的客戶。同事擁有、且沒有共享給你的客戶不會被比對到,因此表單會顯示將新建客戶。如果那個客戶的名稱與之完全相同,轉化隨後會因重複記錄而被拒絕,不會建立任何內容;如果名稱只在大小寫或空格上不同,則會再建立一個客戶。所以,當你認為這家公司已經是既有客戶、而表單卻顯示要新建客戶時,請先請**客戶負責人**或管理員把該客戶共享給你,再進行轉化。 + 然後系統會一步完成: -1. 根據潛在客戶的公司資訊建立一個**客戶**——若已有同名客戶,則直接重用。比對時忽略大小寫與多餘空格,因此公司名為 `ACME Corp` 的潛在客戶會落到你既有的 `Acme Corp` 上,而不是再建一個客戶。這不是模糊比對:`Acme Corp` 與 `Acme Corporation` 仍然是兩家不同的公司。 +1. 根據潛在客戶的公司資訊建立一個**客戶**——或者直接重用表單所列出的既有客戶。比對時忽略大小寫與多餘空格,因此公司名為 `ACME Corp` 的潛在客戶會落到你既有的 `Acme Corp` 上,而不是再建一個客戶。這不是模糊比對:`Acme Corp` 與 `Acme Corporation` 仍然是兩家不同的公司。 2. 根據潛在客戶的個人資訊建立一個**聯絡人**,附加到該客戶上。只有當該客戶還沒有主要聯絡人時,它才成為主要聯絡人:轉化到一個已有主要聯絡人的客戶時,原本的主要聯絡人保持不變。 3. *(可選)* 在*尋找客戶*階段建立一個**商機**,預計成交日期設為 90 天後。 4. 將潛在客戶標記為**已轉化**並鎖定它。 diff --git a/src/sales/actions/contact.actions.ts b/src/sales/actions/contact.actions.ts index 0f48ab87c..3617682bf 100644 --- a/src/sales/actions/contact.actions.ts +++ b/src/sales/actions/contact.actions.ts @@ -81,7 +81,20 @@ export const AddContactToCampaignAction: Action = { type: 'flow', target: 'campaign_contact_member_enroll', recordIdParam: 'contactId', - locations: ['list_toolbar', 'list_item'], + // The same two doors as `create_campaign` on `crm_lead` (#2073 items 5 and + // 5b): `list_item`, every row's menu, and the SELECTION BAR a view opens by + // naming the action in its `bulkActions` (All Contacts does), which takes no + // location here. + // ⛔ Do not add `list_toolbar` back. A header button is drawn with nothing + // selected, and this action runs on exactly one contact: launched from the + // header, the lead side's twin opened the whole Campaign dialog, then refused + // in English ("This action runs on a single record — select a row first.") + // and sent nothing. Here the header button happens to draw nowhere, only + // because `visible` below reads `record.email_opt_out` and a list header + // binds no record (measured on the three contact views, as marketing and + // as the admin, on 18.0.0-next.5). Relaxing that predicate would bring the + // defect back on contacts, so the placement is not left to depend on it. + locations: ['list_item'], params: [ { name: 'campaignId', diff --git a/src/sales/flows/lead-conversion.flow.ts b/src/sales/flows/lead-conversion.flow.ts index 6ee6ab275..ace246ec2 100644 --- a/src/sales/flows/lead-conversion.flow.ts +++ b/src/sales/flows/lead-conversion.flow.ts @@ -87,7 +87,28 @@ export const LeadConversionFlow: Flow = { // than an empty paragraph. That is why the conditionality lives in an // assignment and not in the copy: a screen `description` has no // `visibleWhen`. - description: '{{duplicateWarning}}', + // + // The ACCOUNT NOTICE follows the warning (#2073 item 8): which account + // the lead will join, said before the rep submits — the existing + // account `find_account` matched, by that account's own name, or a new + // account named after the lead's company. Until then the match ran + // after this screen, so the rep was asked only "Create Opportunity?" + // and the account was chosen in silence. + // + // `compose_account_notice` binds exactly one of `existingAccountName` + // / `newAccountName` to a name and the other to `null`, and each + // hole's `default:` carries the OTHER branch's words, so one template + // reads " is the existing account this lead will join." or + // "No account you can see matches …, so converting creates a new + // account named ." Name-first because a template hole is a + // path and a formatter, never logic (`@objectstack/formula`'s template + // engine), and `default:` is its one conditional. ⛔ Do not compose the + // sentence in a variable instead: no locale pack reaches a variable's + // text (the reason the warning is English-only), and this notice shows + // on every conversion. Every pack translates the whole template, and + // `test/lead-conversion-account-notice.test.ts` renders each one for + // both branches. + description: '{{duplicateWarning}} {{ existingAccountName | default:"No account you can see matches the company on this lead, so converting creates a new account named " }}{{ newAccountName | default:" is the existing account this lead will join" }}.', // `visibleWhen` on a screen field is BARE CEL over the screen's own // field names — not the CEL value envelopes the rest of this flow uses // for filters and `update_record` fields, evaluated on the server. The @@ -183,9 +204,9 @@ export const LeadConversionFlow: Flow = { }, }, { - // ⛔ This fetch must stay AHEAD of the screen: the duplicate warning - // reads `leadRecord`, and a screen that suspends before the fetch has - // nothing to read. The fetch itself never depended on the screen — + // ⛔ This fetch must stay AHEAD of the screen: the duplicate warning and + // the account notice read `leadRecord`, and a screen that suspends + // before the fetch has nothing to read. The fetch itself never depended on the screen — // `recordId` is an input variable, seeded before the run starts. id: 'get_lead', type: 'get_record', label: 'Get Lead Record', config: { objectName: 'crm_lead', filter: { id: cel`recordId` }, outputVariable: 'leadRecord' }, @@ -367,9 +388,11 @@ export const LeadConversionFlow: Flow = { }, }, { - // Branching is on edges `e44` / `e45` — see `decision_account`. The + // Branching is on edges `e51` / `e45` — see `decision_account`. The // only gate AFTER `screen_1`, because it judges an answer the rep typed - // there, and it dominates every write (`find_account` below only reads). + // there, and it dominates every write (`find_account`, which used to sit + // between it and them, runs ahead of the screen since #2073 item 8 and + // only reads). id: 'decision_amount', type: 'decision', label: 'Opportunity Amount Above Zero?', }, { @@ -451,9 +474,54 @@ export const LeadConversionFlow: Flow = { // raw `name`: a missing key means the producer could not derive one, and // a tolerant consumer path would hide that while restoring the exact bug // this node exists to fix. + // + // ## Why the match runs AHEAD of the screen (#2073 item 8) + // + // `screen_1` says which account the lead will join, and it can only say + // what has already been read: `compose_account_notice` turns this node's + // answer into the screen's account notice, and `decision_account`, after + // the screen, branches on the SAME binding, so what the rep is told is + // what the submit does. ⛔ Do not re-run the match after the screen: a + // second read could choose another account than the one the screen + // named. Moving it is the `get_lead` pattern — a read before the screen, + // every write behind it — and this node writes nothing. + // + // ⚠️ The match is the CONVERTING USER's view, by design. The flow runs + // as the user, so an account the rep may not read is not found, and the + // screen tells the rep a new account will be created. Measured on + // 18.0.0-next.5 as a `sales_rep`, for an account another rep owns: with + // the SAME name the submit is refused by the per-organization unique + // account name ("Duplicate record refused … No record was written."), + // and the lead stays unconverted; with a name that differs only in case + // or spacing a second account with the same match key is created. + // ⛔ Never elevate this read to close that gap: a system-context match + // would file the rep's contact and opportunity under an account they + // cannot read, and name that account to them on this screen. id: 'find_account', type: 'get_record', label: 'Find Existing Account', config: { objectName: 'crm_account', filter: { name_normalized: cel`leadRecord.company_normalized` }, outputVariable: 'matchedAccount' }, }, + { + // The account notice's two values (#2073 item 8), read only by + // `screen_1`'s body text. Exactly one is a name on every run: the + // matched account's own `name` — it can differ from the lead's company + // in case and spacing, and the account's spelling is the one the rep + // will find in Accounts — or the lead's company, the name + // `create_account` writes. The other is `null`, which the template's + // `default:` turns into this branch's words (see `screen_1`). + // + // One node with two ternaries, not a decision and two assignments: it + // only reads what `find_account` bound, the binding `decision_account`'s + // edges read after the screen, so the notice and the branch cannot + // disagree. `name` is `required` on `crm_account` and `company` on + // `crm_lead`, so neither read needs a guard. + id: 'compose_account_notice', type: 'assignment', label: 'Compose Account Notice', + config: { + assignments: { + existingAccountName: cel`matchedAccount != null ? matchedAccount.name : null`, + newAccountName: cel`matchedAccount == null ? leadRecord.company : null`, + }, + }, + }, { // Branching is on edges `e5` / `e6`. No `config.condition` here: a // `decision` node's singular one is never evaluated. @@ -697,8 +765,8 @@ export const LeadConversionFlow: Flow = { edges: [ // ⛔ A retired edge's id stays VACANT and every surviving edge keeps the id - // it has always had — `e1`, `e2`, `e3`, `e7`, `e12`, `e15`, `e26`, `e30` and - // `e33` are gaps on purpose. + // it has always had — `e1`, `e2`, `e3`, `e4`, `e7`, `e12`, `e15`, `e26`, + // `e30`, `e31`, `e33` and `e44` are gaps on purpose. // Renumbering to close a gap rewrites ids that other notes, tests and run // logs refer to. { id: 'e0', source: 'start', target: 'get_lead', type: 'default' }, @@ -706,9 +774,9 @@ export const LeadConversionFlow: Flow = { // not reuse a vacant one, and do not assume an unused-looking id is free. // A duplicate id is INERT (traversal filters out-edges by `source`, never // by `id`), so nothing fails and the collision survives to trap the next - // editor picking out of the sequence. `e1`, `e2`, `e3`, `e7`, `e12`, `e15`, - // `e26`, `e30` and `e33` are vacant retired ids and stay that way; `e27` is above - // the highest live id, not a gap-fill. + // editor picking out of the sequence. `e1`, `e2`, `e3`, `e4`, `e7`, `e12`, + // `e15`, `e26`, `e30`, `e31`, `e33` and `e44` are vacant retired ids and stay + // that way; `e27` is above the highest live id, not a gap-fill. { id: 'e27', source: 'get_lead', target: 'decision_approval', type: 'default' }, // ── The approval gate (REQ-0005 step 7) ──────────────────────────── // @@ -766,29 +834,40 @@ export const LeadConversionFlow: Flow = { // ── The match-key guard (#2017) ──────────────────────────────────── // // TOTAL and PARTITIONING, for the reason recorded at `e28` / `e29`: `e32` - // is `e31` negated by De Morgan, so exactly one holds for every shape + // is `e48` negated by De Morgan, so exactly one holds for every shape // `get_lead` can bind — unbound, a null row, the column absent, null, // empty, or keyed. `has()` alone is not the test: on a row that carries // the column as null it answers true, so the value needs `isBlank` too. // Fail-CLOSED, unlike the approval gate: without a key there is nothing to // match on, and a guess is the defect. - { id: 'e31', source: 'decision_match_key', target: 'screen_1', type: 'default', condition: P`has(vars.leadRecord) && has(vars.leadRecord.company_normalized) && !isBlank(vars.leadRecord.company_normalized)`, label: 'Has match key' }, + // + // `e31` (decision_match_key → screen_1) is RETIRED and stays vacant + // (#2073 item 8): the account match sits between them now, so the screen + // can say which account the lead will join. `e48` carries `e31`'s + // condition unchanged, onto `find_account`; `e49` and `e50` lead on + // through the notice to the screen. + { id: 'e48', source: 'decision_match_key', target: 'find_account', type: 'default', condition: P`has(vars.leadRecord) && has(vars.leadRecord.company_normalized) && !isBlank(vars.leadRecord.company_normalized)`, label: 'Has match key' }, { id: 'e32', source: 'decision_match_key', target: 'refuse_no_match_key', type: 'default', condition: P`!has(vars.leadRecord) || !has(vars.leadRecord.company_normalized) || isBlank(vars.leadRecord.company_normalized)`, label: 'No match key' }, + { id: 'e49', source: 'find_account', target: 'compose_account_notice', type: 'default' }, + { id: 'e50', source: 'compose_account_notice', target: 'screen_1', type: 'default' }, // `e26`, `e30` and `e33` (each refusal → `end`) are RETIRED and stay // vacant (#2060 item 2c): `e25`, `e28` and `e32` end the run at a refusing // `end`, which no edge leaves, so no node that writes is downstream of a // refusal — every create/update sits behind `screen_1`. // `e2` (screen_1 → find_account) is RETIRED and stays vacant (#2060 item // 2b): the amount gate sits between them now, on `e43` – `e45`. The pair - // is TOTAL and PARTITIONING (`e45` is `e44` negated by De Morgan), pinned + // is TOTAL and PARTITIONING (`e45` is `e51` negated by De Morgan), pinned // in `test/flow-conversion.test.ts`; an unticked box never reads the amount. // `e45` ends the run at its target, a refusing `end`: no edge leaves it. + // `e44` (decision_amount → find_account) and `e4` (find_account → + // decision_account) are RETIRED and stay vacant (#2073 item 8): the match + // runs ahead of the screen now, so an accepted amount goes straight to + // the account branch, on `e51`, which carries `e44`'s condition unchanged. { id: 'e43', source: 'screen_1', target: 'decision_amount', type: 'default' }, - { id: 'e44', source: 'decision_amount', target: 'find_account', type: 'default', condition: P`vars.createOpportunity != true || vars.opportunityAmount == null || vars.opportunityAmount > 0`, label: 'Amount accepted' }, + { id: 'e51', source: 'decision_amount', target: 'decision_account', type: 'default', condition: P`vars.createOpportunity != true || vars.opportunityAmount == null || vars.opportunityAmount > 0`, label: 'Amount accepted' }, { id: 'e45', source: 'decision_amount', target: 'refuse_nonpositive_amount', type: 'default', condition: P`vars.createOpportunity == true && vars.opportunityAmount != null && vars.opportunityAmount <= 0`, label: 'Zero or less' }, - { id: 'e4', source: 'find_account', target: 'decision_account', type: 'default' }, // Existing account → reuse; no account → create. Both converge on create_contact. - // `find_account` is reached only with a match key (`e31`), so a match + // `find_account` is reached only with a match key (`e48`), so a match // here is an equality on a real key, never the has-no-value predicate. // ⛔ No second copy of the key test on these two edges: it could never // be false here, and an inert restatement of a live predicate is the diff --git a/src/sales/translations/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index d952ebaf6..d4125aa84 100644 --- a/src/sales/translations/es-ES/app.ts +++ b/src/sales/translations/es-ES/app.ts @@ -657,8 +657,13 @@ export const appSurface: Omit = { screen_1: { title: 'Detalles de la Conversión', // The duplicate warning's text is a variable the flow assigns; this - // key can only carry the hole that shows it. - description: '{{duplicateWarning}}', + // key can only carry the hole that shows it. The account notice + // after it is translated here (#2073 item 8): exactly one of the two + // holes holds a name, and each hole's `default:` is the OTHER + // branch's words, so the sentence stays name-first. Keep both holes, + // in this order, with a `default:` each (see `screen_1` in + // `lead-conversion.flow.ts`). + description: '{{duplicateWarning}} {{ existingAccountName | default:"Ninguna cuenta que usted pueda ver coincide con la empresa de este prospecto, así que la conversión crea una cuenta nueva llamada " }}{{ newAccountName | default:" es la cuenta existente a la que se unirá este prospecto" }}.', fields: { createOpportunity: { label: '¿Crear oportunidad?' }, opportunityName: { label: 'Nombre de Oportunidad' }, diff --git a/src/sales/translations/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index 90d65f2cc..1b2695628 100644 --- a/src/sales/translations/ja-JP/app.ts +++ b/src/sales/translations/ja-JP/app.ts @@ -659,8 +659,13 @@ export const appSurface: Omit = { screen_1: { title: '変換詳細', // The duplicate warning's text is a variable the flow assigns; this - // key can only carry the hole that shows it. - description: '{{duplicateWarning}}', + // key can only carry the hole that shows it. The account notice + // after it is translated here (#2073 item 8): exactly one of the two + // holes holds a name, and each hole's `default:` is the OTHER + // branch's words, so the sentence stays name-first. Keep both holes, + // in this order, with a `default:` each (see `screen_1` in + // `lead-conversion.flow.ts`). + description: '{{duplicateWarning}} {{ existingAccountName | default:"このリードの会社名に一致する、あなたが閲覧できる取引先はありません。変換すると、次の名前で新しい取引先が作成されます:" }}{{ newAccountName | default:" は、このリードが加わる既存の取引先です" }}。', fields: { createOpportunity: { label: '商談を作成しますか?' }, opportunityName: { label: '商談名' }, diff --git a/src/sales/translations/zh-CN/app.ts b/src/sales/translations/zh-CN/app.ts index cbc232d3e..686c44325 100644 --- a/src/sales/translations/zh-CN/app.ts +++ b/src/sales/translations/zh-CN/app.ts @@ -664,8 +664,13 @@ export const appSurface: Omit = { screen_1: { title: '转化详情', // The duplicate warning's text is a variable the flow assigns; this - // key can only carry the hole that shows it. - description: '{{duplicateWarning}}', + // key can only carry the hole that shows it. The account notice + // after it is translated here (#2073 item 8): exactly one of the two + // holes holds a name, and each hole's `default:` is the OTHER + // branch's words, so the sentence stays name-first. Keep both holes, + // in this order, with a `default:` each (see `screen_1` in + // `lead-conversion.flow.ts`). + description: '{{duplicateWarning}} {{ existingAccountName | default:"没有你能查看的客户与该线索的公司匹配,因此转化会新建一个客户,名称为 " }}{{ newAccountName | default:" 是该线索将加入的现有客户" }}。', fields: { createOpportunity: { label: '是否创建商机?' }, opportunityName: { label: '商机名称' }, diff --git a/test/account-name-normalized-match.test.ts b/test/account-name-normalized-match.test.ts index ea7ef8a30..51ef5c320 100644 --- a/test/account-name-normalized-match.test.ts +++ b/test/account-name-normalized-match.test.ts @@ -617,10 +617,12 @@ describe('acceptance: a case/whitespace variant reuses the same account', () => ['the column absent (sparse datasource)', { leadRecord: { id: 'l1' } }, 'e32'], ['the column null (SQL)', { leadRecord: { id: 'l1', company_normalized: null } }, 'e32'], ['the column empty', { leadRecord: { id: 'l1', company_normalized: '' } }, 'e32'], - ['a key', { leadRecord: { id: 'l1', company_normalized: 'acme corp' } }, 'e31'], + // `e48` carries the condition `e31` had (#2073 item 8): it leads to the + // account match now, which runs ahead of the screen. + ['a key', { leadRecord: { id: 'l1', company_normalized: 'acme corp' } }, 'e48'], ])('the match-key guard takes exactly one edge for %s', (_label, vars, expected) => { const out = (LeadConversionFlow.edges as Rec[]).filter((e) => e.source === 'decision_match_key'); - expect(out.map((e) => e.id).sort()).toEqual(['e31', 'e32']); + expect(out.map((e) => e.id).sort()).toEqual(['e32', 'e48']); expect(out.filter((e) => conditionHolds(verify, e.condition, vars)).map((e) => e.id)).toEqual([expected]); }); diff --git a/test/campaign-enrollment-doors.test.ts b/test/campaign-enrollment-doors.test.ts index 1d3753fa7..1e7bc7b0e 100644 --- a/test/campaign-enrollment-doors.test.ts +++ b/test/campaign-enrollment-doors.test.ts @@ -360,3 +360,28 @@ describe('Add to Campaign on a lead is drawn on the row menu and the selection b expect(enrolled).toEqual([w.leadA.id, w.leadB.id].sort()); }, ENROLL_BUDGET_MS); }); + +/** + * Add to Campaign on a contact takes the same two doors (#2073 item 5b). + * + * The contact-side twin of the case above. Its header button drew nowhere + * even while it declared `list_toolbar`, measured on the three contact views + * on 18.0.0-next.5, but only because its `visible` reads + * `record.email_opt_out` and a list header binds no record. So the placement + * is pinned on its own, and not left to that predicate. The row door's run is + * pinned by the #2167 blocks above (`addContact`). + */ +describe('Add to Campaign on a contact is declared on the row menu only, like the lead action (#2073 item 5b)', () => { + const views: Rec[] = (stack as Rec).views ?? []; + + it('declares the row menu as its only location', () => { + expect(actionNamed('add_contact_to_campaign').locations).toEqual(['list_item']); + }); + + it('All Contacts still opens the selection bar on it, in the per-record bare-string form', () => { + const allContacts = views.map((v) => v.list).find((l) => l?.name === 'all_contacts'); + expect(allContacts, 'no all_contacts list view').toBeTruthy(); + expect(allContacts!.bulkActions).toContain('add_contact_to_campaign'); + expect(actionNamed('add_contact_to_campaign')).toMatchObject({ type: 'flow', recordIdParam: 'contactId' }); + }); +}); diff --git a/test/lead-conversion-account-notice.test.ts b/test/lead-conversion-account-notice.test.ts new file mode 100644 index 000000000..105cd110f --- /dev/null +++ b/test/lead-conversion-account-notice.test.ts @@ -0,0 +1,267 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll } from 'vitest'; +import { templateEngine } from '@objectstack/formula'; +import type { VerifyStack } from '@objectstack/verify'; +import { LeadConversionFlow } from '../src/sales/flows/lead-conversion.flow'; +import { localePacks } from './helpers/composed-stack'; +import { hotcrmStack, signUpPerson, type Person } from './helpers/verify-stack'; + +type Rec = Record; + +/** + * The Convert Lead screen says which account the lead will join (#2073 item 8). + * + * Before, the screen asked only "Create Opportunity?" and `find_account` ran + * after it, so the account was matched by company name in silence. Measured on + * 18.0.0-next.5 in the console, as a `sales_rep` in en-US and zh-CN: the + * dialog read "Conversion Details / Create Opportunity?" on every path. + * + * Now `find_account` runs ahead of the screen, `compose_account_notice` binds + * exactly one of `existingAccountName` / `newAccountName` from that one read, + * and the screen's body text (one template, translated whole in every pack) + * names the existing account, or the new account it will create. The submit + * branches on the same read, so what the screen says is what is done. + * + * ⚠️ The match is the converting rep's own view: the flow runs as the user, so + * an account the rep may not read is never found and never named. The two + * hidden-account cases below pin what that means, measured on the same + * engine before and after this change (unchanged by it): with the SAME name, + * the submit is refused by the per-organization unique account name and + * nothing is written; with a name that differs only in case or spacing, a + * second account with the same match key is created. The screen tells the rep + * a new account will be created in both, which is true from where the rep + * stands; it can say no more without naming an account the rep cannot read. + */ + +const flowNodes = LeadConversionFlow.nodes as Rec[]; +const flowEdges = LeadConversionFlow.edges as Rec[]; +const screen1 = flowNodes.find((n) => n.id === 'screen_1')!; + +/** Node ids reachable from `from`, never walking out of `stopAt`. */ +function reachable(from: string, stopAt?: string): Set { + const seen = new Set([from]); + const queue = [from]; + while (queue.length) { + const id = queue.shift()!; + if (id === stopAt) continue; + for (const e of flowEdges.filter((x) => x.source === id)) { + if (!seen.has(e.target)) { seen.add(e.target); queue.push(e.target); } + } + } + return seen; +} + +describe('the account match is read before the screen, and only there (#2073 item 8)', () => { + it('find_account and the notice run ahead of screen_1, and nothing ahead of it writes', () => { + const before = reachable('start', 'screen_1'); + expect(before.has('screen_1'), 'screen_1 is unreachable').toBe(true); + expect(before.has('find_account'), 'the match runs after the screen again — it cannot be shown').toBe(true); + expect(before.has('compose_account_notice')).toBe(true); + const writers = flowNodes + .filter((n) => before.has(n.id) && n.id !== 'screen_1') + .filter((n) => ['create_record', 'update_record', 'delete_record', 'subflow', 'notify'].includes(n.type)) + .map((n) => n.id); + expect(writers, 'a write now runs before the rep has submitted the screen').toEqual([]); + }); + + it('the submit acts on that one read: no match runs after the screen', () => { + const after = reachable('screen_1'); + expect(after.has('decision_account'), 'the account branch is unreachable after the screen').toBe(true); + expect(after.has('find_account'), 'a second match after the screen could pick another account than the one named').toBe(false); + }); +}); + +/** + * Every shipped locale renders the notice for both branches, through the + * platform's own template renderer (`templateEngine`, which the screen + * executor's `renderTextSlot` calls). The authored template is the English + * text; each other pack translates the whole template. The business fact + * pinned: for the SAME company name, the rep can tell an existing account from + * a new one, in every language. + */ +describe('the account notice reads right for both branches in every locale (#2073 item 8)', () => { + const NAME = 'Northwind Energy'; + const templates: Array<[string, string]> = [ + ['en (authored)', String(screen1.config.description)], + ...localePacks + .filter(([, pack]) => pack?.flows?.lead_conversion !== undefined) + .map(([locale, pack]) => [locale, String(pack.flows.lead_conversion.screens?.screen_1?.description ?? '')] as [string, string]), + ]; + const render = (source: string, vars: Rec): string => { + const r = templateEngine.evaluate({ dialect: 'template', source }, { extra: { duplicateWarning: null, ...vars } } as Rec); + if (!r.ok) throw new Error(`does not render: ${r.error.message}`); + return String(r.value); + }; + + it('every non-English pack translates the screen', () => { + const translated = templates.map(([locale]) => locale).filter((l) => !l.startsWith('en')).sort(); + const shipped = localePacks.map(([locale]) => locale).filter((l) => l !== 'en').sort(); + expect(translated).toEqual(shipped); + }); + + it.each(templates)('%s: names the account, and says existing or new', (_locale, source) => { + expect(source.startsWith('{{duplicateWarning}}'), 'the duplicate warning no longer leads the body text').toBe(true); + const existing = render(source, { existingAccountName: NAME, newAccountName: null }); + const created = render(source, { existingAccountName: null, newAccountName: NAME }); + for (const text of [existing, created]) { + expect(text.split(NAME).length - 1, `the account name is not said exactly once: ${text}`).toBe(1); + } + // Strip the name: what is left is the branch's own words, and they must + // differ, or "existing" and "new" read the same for the same company. + const words = (t: string) => t.replace(NAME, '').trim(); + expect(words(existing)).not.toBe(words(created)); + expect(words(existing).length).toBeGreaterThan(0); + expect(words(created).length).toBeGreaterThan(0); + }); + + it('the duplicate warning still shows, ahead of the notice', () => { + const warning = 'Suspected duplicate — compare first.'; + for (const [, source] of templates) { + const text = render(source, { duplicateWarning: warning, existingAccountName: NAME, newAccountName: null }); + expect(text.startsWith(warning)).toBe(true); + expect(text).toContain(NAME); + } + }); +}); + +// ── The four paths, as a sales rep, on the real engine ───────────────────── + +let verify: VerifyStack; +let rep: Person; +let colleague: Person; +let k = 0; + +beforeAll(async () => { + verify = await hotcrmStack(); + // Two reps who own their own accounts and nothing else: `crm_account` is + // `sharingModel: 'private'`, so neither reads the other's. + rep = await signUpPerson(verify, 'rep@lead-conversion-account-notice.test', { + name: 'Notice Rep', positions: ['sales_rep'], permissionSets: ['sales_rep'], + }); + colleague = await signUpPerson(verify, 'colleague@lead-conversion-account-notice.test', { + name: 'Notice Colleague', positions: ['sales_rep'], permissionSets: ['sales_rep'], + }); +}, 120_000); + +const fold = (v: string): string => v.trim().toLowerCase().replace(/\s+/g, ' '); + +const accountAs = (owner: Person, name: string) => + verify.hooks.run('crm_account', 'insert', { name, is_active: true }, { as: owner.token }); + +const leadFor = (company: string) => { + const n = ++k; + return verify.hooks.run('crm_lead', 'insert', { + company, email: `buyer${n}@lead-conversion-account-notice.test`, first_name: 'Pat', last_name: `Notice${n}`, + lead_source: 'web', status: 'qualified', + }, { as: rep.token }); +}; + +/** Start the conversion as the rep: the screen it pauses on. */ +async function screenFor(leadId: string): Promise<{ started: Rec; description: string }> { + const started = await verify.flows.run('lead_conversion', { recordId: leadId }, { as: rep.token }) as Rec; + const screen = started.screen ?? started.output?.screen; + expect(screen?.nodeId, 'the conversion did not pause on its screen').toBe('screen_1'); + return { started, description: String(screen.description ?? '') }; +} + +/** Submit the screen as the console does (its prefilled bag), box unticked; the refusal envelope if it threw. */ +async function submit(started: Rec): Promise<{ ok: true } | { ok: false; code?: string; status?: number; message: string }> { + const screen = started.screen ?? started.output?.screen; + const prefilled: Rec = {}; + for (const f of (screen?.fields ?? []) as Rec[]) if (f.defaultValue !== undefined) prefilled[f.name] = f.defaultValue; + try { + await verify.flows.resume(started as never, { ...prefilled, createOpportunity: false }, { as: rep.token }); + return { ok: true }; + } catch (err) { + const e = err as Rec; + return { ok: false, code: e.code, status: e.status, message: String(e.message) }; + } +} + +const keyed = (name: string) => verify.rows('crm_account', { name_normalized: fold(name) }); +const leadRow = async (id: string) => (await verify.rows('crm_lead', { id }))[0]!; + +const EXISTING = 'is the existing account this lead will join.'; +const CREATES = 'No account you can see matches the company on this lead, so converting creates a new account named'; + +describe('as a sales rep, the screen names the account each path ends on (#2073 item 8)', () => { + it('an account the rep can see: named as the existing account, and the lead joins it', async () => { + const n = ++k; + const account = await accountAs(rep, `Visible Co ${n}`); + // The lead's spelling differs in case and spacing: the notice names the + // ACCOUNT's spelling, the one the rep will find in Accounts. + const lead = await leadFor(`VISIBLE co ${n}`); + const { started, description } = await screenFor(lead.id); + expect(description.trim()).toBe(`Visible Co ${n} ${EXISTING}`); + + expect(await submit(started)).toEqual({ ok: true }); + expect((await leadRow(lead.id)).converted_account, 'the lead joined another account than the one named').toBe(account.id); + expect((await keyed(`Visible Co ${n}`)).map((a) => a.id), 'a second account was created').toEqual([account.id]); + }); + + it('no account matches: names the new account, and the lead joins the one it creates', async () => { + const n = ++k; + const lead = await leadFor(`Fresh Co ${n}`); + const { started, description } = await screenFor(lead.id); + expect(description.trim()).toBe(`${CREATES} Fresh Co ${n}.`); + + expect(await submit(started)).toEqual({ ok: true }); + const created = await keyed(`Fresh Co ${n}`); + expect(created.map((a) => [a.name, a.owner_id]), 'not exactly one new account, owned by the rep').toEqual([[`Fresh Co ${n}`, rep.id]]); + expect((await leadRow(lead.id)).converted_account).toBe(created[0]!.id); + }); + + it('the account the screen named is deleted before the submit: nothing is written, and no other account is used', async () => { + // The window this change opens: the match is read when the screen is + // served, and the submit acts on that read. Measured before the change, + // the same run matched again at submit, found nothing and created a NEW + // account the rep was never told about. Now the submit cannot attach the + // contact to the deleted account, and is refused before any write. + const n = ++k; + const account = await accountAs(rep, `Gone Co ${n}`); + const lead = await leadFor(`Gone Co ${n}`); + const { started, description } = await screenFor(lead.id); + expect(description.trim()).toBe(`Gone Co ${n} ${EXISTING}`); + // An administrator's delete while the dialog is open (a rep may not delete accounts). + await verify.kernel.getService('objectql').delete('crm_account', { where: { id: account.id }, context: { isSystem: true } }); + + const answer = await submit(started) as Rec; + expect({ ok: answer.ok, code: answer.code, status: answer.status }, 'the submit was not refused') + .toEqual({ ok: false, code: 'FLOW_FAILED', status: 400 }); + expect(Boolean((await leadRow(lead.id)).is_converted), 'the lead was converted').toBe(false); + expect(await keyed(`Gone Co ${n}`), 'the submit created an account the screen never named').toHaveLength(0); + }); + + it('a colleague\'s account the rep cannot see, same name: the screen says new, the submit is refused, nothing is written', async () => { + const n = ++k; + const hidden = await accountAs(colleague, `Hidden Co ${n}`); + expect(await verify.rows('crm_account', { id: hidden.id }, { as: rep.token }), 'the rep can read the colleague\'s account') + .toHaveLength(0); + const lead = await leadFor(`Hidden Co ${n}`); + const { started, description } = await screenFor(lead.id); + expect(description.trim()).toBe(`${CREATES} Hidden Co ${n}.`); + + const answer = await submit(started) as Rec; + expect({ ok: answer.ok, code: answer.code, status: answer.status }, 'the submit was not refused') + .toEqual({ ok: false, code: 'FLOW_FAILED', status: 400 }); + expect(answer.message, 'refused by something other than the unique account name').toContain('Duplicate record refused'); + expect(Boolean((await leadRow(lead.id)).is_converted), 'the lead was converted').toBe(false); + expect((await keyed(`Hidden Co ${n}`)).map((a) => a.id)).toEqual([hidden.id]); + }); + + it('a colleague\'s account the rep cannot see, same key only: the screen says new, and a second account is created', async () => { + const n = ++k; + const hidden = await accountAs(colleague, `Hidden Near ${n}`); + const lead = await leadFor(`HIDDEN near ${n}`); + const { started, description } = await screenFor(lead.id); + expect(description.trim()).toBe(`${CREATES} HIDDEN near ${n}.`); + expect(description, 'the screen named an account the rep cannot read').not.toContain(`Hidden Near ${n}`); + + expect(await submit(started)).toEqual({ ok: true }); + const accounts = await keyed(`Hidden Near ${n}`); + const mine = accounts.filter((a) => a.id !== hidden.id); + expect(mine.map((a) => [a.name, a.owner_id])).toEqual([[`HIDDEN near ${n}`, rep.id]]); + expect((await leadRow(lead.id)).converted_account).toBe(mine[0]!.id); + }); +}); diff --git a/test/lead-duplicate-visibility.test.ts b/test/lead-duplicate-visibility.test.ts index 32368f91b..a788ec8ff 100644 --- a/test/lead-duplicate-visibility.test.ts +++ b/test/lead-duplicate-visibility.test.ts @@ -580,8 +580,10 @@ describe('lead_conversion — the warning at the moment of conversion', () => { expect(started.error ?? null, 'the run failed before it could show the screen').toBeNull(); expect(started.status).toBe('paused'); expect(screen, 'the conversion screen never suspended').toBeTruthy(); - expect(screen!.description, 'a clean lead was warned about being a duplicate') - .toBeUndefined(); + // The body text carries the account notice on every conversion since + // #2073 item 8, so "says nothing" is read as the warning's absence. + expect(String(screen!.description ?? ''), 'a clean lead was warned about being a duplicate') + .not.toContain('Suspected duplicate'); const done: AnyRec = await verify.flows.resume(started as never, { createOpportunity: true, opportunityName: `Skyline Deal ${lead.company}`, opportunityAmount: 50_000, @@ -745,7 +747,10 @@ describe('lead_conversion — a confirmed duplicate is refused (#1288)', () => { })); expect(screen!.nodeId, 'a lead with no verdict was still refused').toBe('screen_1'); - expect(screen!.description, 'a lead with no verdict was warned about one').toBeUndefined(); + // The body text carries the account notice on every conversion since + // #2073 item 8, so "no warning" is read as the warning's absence, not the + // description's. + expect(String(screen!.description ?? ''), 'a lead with no verdict was warned about one').not.toContain('Suspected duplicate'); expect(products, 'clearing the verdict did not restore conversion').toEqual(FULLY_CONVERTED); }); });