diff --git a/.changeset/2060-item-2c-refusal-outcome.md b/.changeset/2060-item-2c-refusal-outcome.md new file mode 100644 index 00000000..3b4f3303 --- /dev/null +++ b/.changeset/2060-item-2c-refusal-outcome.md @@ -0,0 +1,7 @@ +--- +"hotcrm": patch +--- + +**A refused lead conversion no longer tells the rep the lead was converted.** Convert Lead refuses three kinds of lead: one whose Duplicate Status is Confirmed, one whose Conversion Approval is Pending or Rejected, and one whose company has no match key. Each refusal opened a dialog with a Submit button, and clicking Submit showed "Lead converted successfully!" although nothing had been converted. + +The refusal now ends the conversion on the spot. The dialog shows the same explanation, opening with "Conversion refused", and offers Close only: no Submit and no success message. Which leads are refused is unchanged, and a refused conversion still creates nothing and leaves the lead as it was. A run started through the API reports the conversion as `refused`, with the explanation, rather than pausing on a screen. diff --git a/src/sales/flows/lead-conversion.flow.ts b/src/sales/flows/lead-conversion.flow.ts index 997c177d..9b3466e9 100644 --- a/src/sales/flows/lead-conversion.flow.ts +++ b/src/sales/flows/lead-conversion.flow.ts @@ -253,8 +253,8 @@ export const LeadConversionFlow: Flow = { // explain itself and `disabled` greys one out with no reason attached. // `errorMessage` on the Action is a single static string — what the // console toasts on a FAILED run — and cannot say anything about THIS - // lead. A screen node's `description` is a text slot rendered per run - // and shown by `FlowRunner` as the dialog body. + // lead. A refusing `end` node's `message` is a text slot rendered per + // run and shown by `FlowRunner` as the dialog body. // // The flow is also the only choke point covering every door: the // record-header button, the list-row button and the `action_convert_lead` @@ -262,15 +262,22 @@ export const LeadConversionFlow: Flow = { // `visible` / `disabled` are console-side and say nothing to the other // two. // - // ## What the rep sees, measured on the shipped console bundle + // ## What the rep sees // - // A run that PAUSES returns `{ success: true, silent: true }` to the - // action framework (`RecordDetailView`'s flow handler), and `silent` - // suppresses the success toast — so `convert_lead`'s - // `successMessage: 'Lead converted successfully!'` does NOT fire behind - // this dialog. Submitting it resumes into `end`; nothing is created and - // the lead is untouched on either path, because every write in this flow - // is downstream of `screen_1`. + // A first-class refusal (`EndConfigSchema` `outcome: 'refused'`), as in + // `refuse_nonpositive_amount` and `quote_generation`'s `refuse_held`: the + // run ends `refused`, is never resumed, and the console shows the message + // under the flow's label with Close only — no Submit and no success + // toast. Until #2060 item 2c each of the three refusals was a + // message-only `waitForInput` screen whose Submit resumed into `end`, so + // the run COMPLETED and the console toasted `convert_lead`'s "Lead + // converted successfully!" over a lead it had not converted (measured in + // the console on all three). Nothing is written on this path, because + // every write in this flow is downstream of `screen_1`. + // + // An `end` node has no heading, so each message opens with the words the + // screen's title carried ("Conversion refused") and the body follows + // unchanged. // // ## What the copy may claim // @@ -292,19 +299,12 @@ export const LeadConversionFlow: Flow = { // section this line points at, and a hand-copied machine list in prose is // the drift AGENTS.md documentation rule 5 forbids. // - // The title and the body are translated in the packs - // (`flows.lead_conversion.screens.refuse_confirmed_duplicate`). - id: 'refuse_confirmed_duplicate', type: 'screen', label: 'Conversion Refused', + // Translated as `refusals.refuse_confirmed_duplicate.message` in the packs. + id: 'refuse_confirmed_duplicate', type: 'end', label: 'Conversion Refused', config: { - title: 'Conversion refused', - description: - "This lead's Duplicate Status is Confirmed: a reviewer compared it against an existing record and recorded that it repeats one. Converting would create a second account, contact and opportunity for the same buyer. The Duplicate Management section on this lead names the surviving record; disqualify this lead as a duplicate instead. Only a reviewer revising that verdict reopens conversion.", - // A message-only screen: no fields, so the pause has to be asked for. - // `waitForInput` is what turns a field-less screen from a server-side - // pass-through into the dialog the rep reads (the executor's - // `shouldPause`), and without it this node would fall through to `end` - // in silence — a refusal nobody is told about. - waitForInput: true, + outcome: 'refused', + message: + "Conversion refused. This lead's Duplicate Status is Confirmed: a reviewer compared it against an existing record and recorded that it repeats one. Converting would create a second account, contact and opportunity for the same buyer. The Duplicate Management section on this lead names the surviving record; disqualify this lead as a duplicate instead. Only a reviewer revising that verdict reopens conversion.", }, }, { @@ -322,15 +322,14 @@ export const LeadConversionFlow: Flow = { // half-converted. Refusing HERE, ahead of `screen_1`, is what makes the // gate whole: every create and update in this flow sits behind that // screen, and this path never reaches it. - id: 'refuse_unapproved', type: 'screen', label: 'Conversion Refused', + // + // A refusing `end`, for the reason given at `refuse_confirmed_duplicate`; + // translated as `refusals.refuse_unapproved.message` in the packs. + id: 'refuse_unapproved', type: 'end', label: 'Conversion Refused', config: { - title: 'Conversion refused', - description: - "This lead has not been approved for conversion. An approver has to sign it off before it can become an account, contact and opportunity; the Conversion Approval field on the lead shows whether the decision is still pending or was refused. Keep working the lead in the meantime — nothing about it is frozen.", - // Field-less screen: `waitForInput` is what turns it from a server-side - // pass-through into the dialog the rep reads. See - // `refuse_confirmed_duplicate` for the mechanism. - waitForInput: true, + outcome: 'refused', + message: + "Conversion refused. This lead has not been approved for conversion. An approver has to sign it off before it can become an account, contact and opportunity; the Conversion Approval field on the lead shows whether the decision is still pending or was refused. Keep working the lead in the meantime — nothing about it is frozen.", }, }, { @@ -356,18 +355,15 @@ export const LeadConversionFlow: Flow = { // The copy claims only what holds on every path into it: the key is // derived from `company` on every write that carries it // (`lead_duplicate_check`), and nothing has been written, because every - // create and update in this flow sits behind `screen_1`. Its title and - // body are translated in the packs, like the other two refusals. + // create and update in this flow sits behind `screen_1`. // - // Field-less screen: `waitForInput` is what turns it from a server-side - // pass-through into the dialog the rep reads. See - // `refuse_confirmed_duplicate` for the mechanism. - id: 'refuse_no_match_key', type: 'screen', label: 'Conversion Refused', + // A refusing `end`, for the reason given at `refuse_confirmed_duplicate`; + // translated as `refusals.refuse_no_match_key.message` in the packs. + id: 'refuse_no_match_key', type: 'end', label: 'Conversion Refused', config: { - title: 'Conversion refused', - description: - "This lead's company has no match key, so conversion cannot tell which account it belongs to, and it will not guess: a guess could file the contact and opportunity under another customer. Saving the lead's Company rebuilds the key, so save it again, then convert. Nothing has been created.", - waitForInput: true, + outcome: 'refused', + message: + "Conversion refused. This lead's company has no match key, so conversion cannot tell which account it belongs to, and it will not guess: a guess could file the contact and opportunity under another customer. Saving the lead's Company rebuilds the key, so save it again, then convert. Nothing has been created.", }, }, { @@ -386,8 +382,8 @@ export const LeadConversionFlow: Flow = { // disagree about which amounts convert. // // A first-class refusal (`EndConfigSchema` `outcome: 'refused'`), as in - // `quote_generation`, and ⛔ not the message-only screen the three - // refusals above use: the run ends `refused` rather than `completed`, + // `quote_generation` and the three refusals above, and ⛔ not a + // message-only screen: the run ends `refused` rather than `completed`, // and the console shows the message with Close only. A screen here was // measured to toast "Saved — next step" over a refusal, and its Submit // toasts `convert_lead`'s "Lead converted successfully!". Translated as @@ -687,7 +683,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` and `e15` are gaps on purpose. + // it has always had — `e1`, `e2`, `e3`, `e7`, `e15`, `e26`, `e30` and `e33` + // 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' }, @@ -695,9 +692,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` and `e15` 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`, `e7`, `e15`, `e26`, + // `e30` and `e33` 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) ──────────────────────────── // @@ -718,8 +715,6 @@ export const LeadConversionFlow: Flow = { && (vars.leadRecord.conversion_approval_status == "pending" || vars.leadRecord.conversion_approval_status == "rejected")`, label: 'Not approved' }, { id: 'e29', source: 'decision_approval', target: 'decision_duplicate', type: 'default', condition: P`!has(vars.leadRecord) || !has(vars.leadRecord.conversion_approval_status) || (vars.leadRecord.conversion_approval_status != "pending" && vars.leadRecord.conversion_approval_status != "rejected")`, label: 'Clear' }, - // Rejoins nothing, exactly like `e26`: the refusal IS not reaching a writer. - { id: 'e30', source: 'refuse_unapproved', target: 'end', type: 'default' }, // ⚠️ Both conditions are TOTAL, and on this surface that is not a style // preference: a flow condition is interpreted strict CEL on every run, and // an unguarded field read against a driver that omits absent columns @@ -765,13 +760,10 @@ export const LeadConversionFlow: Flow = { // 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' }, { 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' }, - // Rejoins nothing, exactly like `e26` / `e30`. - { id: 'e33', source: 'refuse_no_match_key', target: 'end', type: 'default' }, - // The refusal branch rejoins nothing: it goes straight to `end`, so no - // node that writes is downstream of it. That is the refusal — the flow's - // every create/update sits behind `screen_1`, which this path never - // reaches. - { id: 'e26', source: 'refuse_confirmed_duplicate', target: 'end', 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 diff --git a/src/sales/translations/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index ca0258b6..77131eb1 100644 --- a/src/sales/translations/es-ES/app.ts +++ b/src/sales/translations/es-ES/app.ts @@ -670,23 +670,20 @@ export const appSurface: Omit = { closeDate: { label: 'Fecha de Cierre' }, }, }, + }, + refusals: { refuse_confirmed_duplicate: { - title: 'Conversión rechazada', - description: - 'El Estado del Duplicado de este prospecto es Confirmado: un revisor lo comparó con un registro existente y registró que lo repite. Convertirlo crearía una segunda cuenta, contacto y oportunidad para el mismo comprador. La sección Gestión de Duplicados de este prospecto indica el registro que se conserva; descalifique este prospecto como duplicado. Solo un revisor que cambie ese veredicto vuelve a habilitar la conversión.', + message: + 'Conversión rechazada. El Estado del Duplicado de este prospecto es Confirmado: un revisor lo comparó con un registro existente y registró que lo repite. Convertirlo crearía una segunda cuenta, contacto y oportunidad para el mismo comprador. La sección Gestión de Duplicados de este prospecto indica el registro que se conserva; descalifique este prospecto como duplicado. Solo un revisor que cambie ese veredicto vuelve a habilitar la conversión.', }, refuse_unapproved: { - title: 'Conversión rechazada', - description: - 'Este prospecto no ha sido aprobado para la conversión. Un aprobador debe darle el visto bueno antes de que pueda convertirse en una cuenta, un contacto y una oportunidad; el campo Aprobación de Conversión del prospecto muestra si la decisión sigue pendiente o fue rechazada. Mientras tanto, siga trabajando el prospecto: nada de él queda bloqueado.', + message: + 'Conversión rechazada. Este prospecto no ha sido aprobado para la conversión. Un aprobador debe darle el visto bueno antes de que pueda convertirse en una cuenta, un contacto y una oportunidad; el campo Aprobación de Conversión del prospecto muestra si la decisión sigue pendiente o fue rechazada. Mientras tanto, siga trabajando el prospecto: nada de él queda bloqueado.', }, refuse_no_match_key: { - title: 'Conversión rechazada', - description: - 'La empresa de este prospecto no tiene clave de coincidencia, así que la conversión no puede saber a qué cuenta pertenece y no lo va a adivinar: una suposición podría archivar el contacto y la oportunidad bajo otro cliente. Guardar la Empresa del prospecto reconstruye la clave, así que guárdela de nuevo y luego conviértalo. No se ha creado nada.', + message: + 'Conversión rechazada. La empresa de este prospecto no tiene clave de coincidencia, así que la conversión no puede saber a qué cuenta pertenece y no lo va a adivinar: una suposición podría archivar el contacto y la oportunidad bajo otro cliente. Guardar la Empresa del prospecto reconstruye la clave, así que guárdela de nuevo y luego conviértalo. No se ha creado nada.', }, - }, - refusals: { refuse_nonpositive_amount: { message: 'Conversión rechazada: el monto de una oportunidad debe ser mayor que cero. No se creó nada. Vuelva a convertir el prospecto con el monto que vale el negocio, o deje sin marcar ¿Crear oportunidad? para convertirlo sin una.', diff --git a/src/sales/translations/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index 8a612a86..352afb1e 100644 --- a/src/sales/translations/ja-JP/app.ts +++ b/src/sales/translations/ja-JP/app.ts @@ -672,23 +672,20 @@ export const appSurface: Omit = { closeDate: { label: '完了予定日' }, }, }, + }, + refusals: { refuse_confirmed_duplicate: { - title: '変換は拒否されました', - description: - 'このリードの重複ステータスは「重複確定」です。レビュー担当者が既存のレコードと照合し、重複であると記録しました。変換すると、同じ購入者に対して取引先・取引先責任者・商談がもう 1 件ずつ作成されます。残すレコードはこのリードの「重複管理」セクションに表示されています。代わりに、このリードを重複として不適格にしてください。変換を再び可能にできるのは、その判定を見直すレビュー担当者だけです。', + message: + '変換は拒否されました。このリードの重複ステータスは「重複確定」です。レビュー担当者が既存のレコードと照合し、重複であると記録しました。変換すると、同じ購入者に対して取引先・取引先責任者・商談がもう 1 件ずつ作成されます。残すレコードはこのリードの「重複管理」セクションに表示されています。代わりに、このリードを重複として不適格にしてください。変換を再び可能にできるのは、その判定を見直すレビュー担当者だけです。', }, refuse_unapproved: { - title: '変換は拒否されました', - description: - 'このリードはまだ変換の承認を得ていません。取引先・取引先責任者・商談に変換するには、先に承認者の承認が必要です。リードの「コンバージョン承認」項目で、判断がまだ承認待ちか、却下されたかを確認できます。それまでの間もリードへの対応は続けてください。凍結されるものはありません。', + message: + '変換は拒否されました。このリードはまだ変換の承認を得ていません。取引先・取引先責任者・商談に変換するには、先に承認者の承認が必要です。リードの「コンバージョン承認」項目で、判断がまだ承認待ちか、却下されたかを確認できます。それまでの間もリードへの対応は続けてください。凍結されるものはありません。', }, refuse_no_match_key: { - title: '変換は拒否されました', - description: - 'このリードの会社名には照合キーがないため、変換ではどの取引先に属するかを判断できず、推測もしません。推測すると、取引先責任者と商談が別の顧客の下に登録されるおそれがあります。リードの「会社名」を保存するとキーが再作成されるので、もう一度保存してから変換してください。何も作成されていません。', + message: + '変換は拒否されました。このリードの会社名には照合キーがないため、変換ではどの取引先に属するかを判断できず、推測もしません。推測すると、取引先責任者と商談が別の顧客の下に登録されるおそれがあります。リードの「会社名」を保存するとキーが再作成されるので、もう一度保存してから変換してください。何も作成されていません。', }, - }, - refusals: { refuse_nonpositive_amount: { message: '変換は拒否されました。商談金額は 0 より大きくなければなりません。何も作成されていません。取引の金額を入力してもう一度リードを変換するか、「商談を作成しますか?」のチェックを外して商談なしで変換してください。', diff --git a/src/sales/translations/zh-CN/app.ts b/src/sales/translations/zh-CN/app.ts index 5cf1ff3b..971c525f 100644 --- a/src/sales/translations/zh-CN/app.ts +++ b/src/sales/translations/zh-CN/app.ts @@ -671,23 +671,20 @@ export const appSurface: Omit = { closeDate: { label: '预计成交日期' }, }, }, + }, + refusals: { refuse_confirmed_duplicate: { - title: '转化被拒绝', - description: - '该线索的「重复状态」为「已确认重复」:审核人已将其与现有记录比对,并记录为重复。转化会为同一买家再创建一个客户、联系人和商机。本线索的「重复线索管理」部分列出了保留的记录;请改为以重复为由将此线索设为「未通过」。只有审核人修改该结论后才能重新转化。', + message: + '转化被拒绝。该线索的「重复状态」为「已确认重复」:审核人已将其与现有记录比对,并记录为重复。转化会为同一买家再创建一个客户、联系人和商机。本线索的「重复线索管理」部分列出了保留的记录;请改为以重复为由将此线索设为「未通过」。只有审核人修改该结论后才能重新转化。', }, refuse_unapproved: { - title: '转化被拒绝', - description: - '该线索尚未获准转化。必须由审批人批准后,才能转化为客户、联系人和商机;线索上的「转化审批」字段显示该决定仍在待审批还是已被驳回。在此期间请继续跟进该线索——它的任何内容都没有被冻结。', + message: + '转化被拒绝。该线索尚未获准转化。必须由审批人批准后,才能转化为客户、联系人和商机;线索上的「转化审批」字段显示该决定仍在待审批还是已被驳回。在此期间请继续跟进该线索——它的任何内容都没有被冻结。', }, refuse_no_match_key: { - title: '转化被拒绝', - description: - '该线索的公司没有匹配键,因此转化无法判断它属于哪个客户,也不会去猜测:猜测可能会把联系人和商机归到另一个客户名下。保存线索的「公司」会重新生成匹配键,请重新保存后再转化。尚未创建任何记录。', + message: + '转化被拒绝。该线索的公司没有匹配键,因此转化无法判断它属于哪个客户,也不会去猜测:猜测可能会把联系人和商机归到另一个客户名下。保存线索的「公司」会重新生成匹配键,请重新保存后再转化。尚未创建任何记录。', }, - }, - refusals: { refuse_nonpositive_amount: { message: '转化被拒绝:商机金额必须大于零。尚未创建任何内容。请填写这笔交易的金额后重新转化该线索,或不勾选「是否创建商机?」,以不创建商机的方式转化。', diff --git a/test/account-name-normalized-match.test.ts b/test/account-name-normalized-match.test.ts index 4cc52f01..ea7ef8a3 100644 --- a/test/account-name-normalized-match.test.ts +++ b/test/account-name-normalized-match.test.ts @@ -577,10 +577,11 @@ describe('acceptance: a case/whitespace variant reuses the same account', () => * writes too (a non-string name or company folds to `null`, above), not * only from an un-backfilled install. * - * So the flow refuses ahead of its form (`refuse_no_match_key`), and the - * pin reads the outcome off the engine after the rep submits that dialog: - * the lead is not converted, the unrelated account gained no contact, and - * no account was created for the lead's company. + * So the flow refuses ahead of its form, at a refusing `end` + * (`refuse_no_match_key`, `outcome: 'refused'`), and the pin reads that + * outcome and the engine's store together: the lead is not converted, the + * unrelated account gained no contact, and no account was created for the + * lead's company. */ it('a lead with no match key is refused, and attaches to no account (#2017)', async () => { const n = ++k; @@ -589,14 +590,12 @@ describe('acceptance: a case/whitespace variant reuses the same account', () => const legacy = await leadFor(company('Acme Corp', n)); await systemUpdate(verify, 'crm_lead', { id: legacy.id, company_normalized: null }); - const started = await verify.flows.run('lead_conversion', { recordId: legacy.id }, { as: manager.token }); - const screen = (started.screen ?? null) as Rec | null; - expect(started.status, 'the conversion did not stop on a dialog').toBe('paused'); - expect(screen?.nodeId, 'a key-less lead reached the conversion form').toBe('refuse_no_match_key'); - // Submitting the refusal is the rep's next click, and the state after it - // is the claim: the branch reaches `end` without one create or update. - const done = await verify.flows.resume(started, { createOpportunity: false }, { as: manager.token }); - expect(done.success, 'submitting the refusal failed the run').toBe(true); + const run = await verify.flows.run('lead_conversion', { recordId: legacy.id }, { as: manager.token }) as Rec; + // A refusal is a successful evaluation that says no: `refused`, never + // parked on a dialog whose Submit would complete the run (#2060 item 2c). + expect({ success: run.success, status: run.status, runId: run.runId, screen: run.screen }, 'the key-less lead was not refused') + .toEqual({ success: true, status: 'refused', runId: undefined, screen: undefined }); + expect(String(run.refusalMessage ?? ''), 'the rep is not told why').not.toBe(''); const [after] = await verify.rows('crm_lead', { id: legacy.id }); expect(Boolean(after!.is_converted), 'the key-less lead was converted').toBe(false); diff --git a/test/flow-conversion.test.ts b/test/flow-conversion.test.ts index d476b9ab..4488882c 100644 --- a/test/flow-conversion.test.ts +++ b/test/flow-conversion.test.ts @@ -390,9 +390,9 @@ describe('lead_conversion — a ticked Create Opportunity needs an amount before * `create_opportunity` after `create_account` and `create_contact` had run: * `FLOW_FAILED`, one account, one contact, no opportunity, an unconverted * lead. `decision_amount` now turns it away right after the dialog, at a - * refusing `end`: the resume SUCCEEDS and the run ends `refused` (the only - * refusal in this flow that ends that way), and the claim is that outcome - * and an empty store, compared in one object. + * refusing `end`: the resume SUCCEEDS and the run ends `refused` (since + * #2060 item 2c every refusal in this flow ends that way), and the claim is + * that outcome and an empty store, compared in one object. */ describe('lead_conversion — a ticked Create Opportunity with an amount of zero or less is refused before anything is written (#2060 item 2b)', () => { const state = async (who: { company: string; email: string }, leadId: string) => { diff --git a/test/lead-conversion-approval-gate.test.ts b/test/lead-conversion-approval-gate.test.ts index ee6ca7bd..5cfa0555 100644 --- a/test/lead-conversion-approval-gate.test.ts +++ b/test/lead-conversion-approval-gate.test.ts @@ -65,19 +65,23 @@ const edgeCondition = (id: string) => /** * One write by the rep against their own lead, standing in `previous`: the - * lead is written as the system (its verdict column is `readonly`, so only a - * system write can put it in a state), then the rep's update runs the app's + * lead is written as the system (`leadWith`: its verdict column is `readonly`, + * so only a system write can put it in a state), then the rep's update runs the app's * `lead_automation` hook for real. Resolves on success, rejects with the * engine's error. */ -const write = async (input: AnyRec, previous: AnyRec): Promise => { +const leadWith = async (previous: AnyRec): Promise => { const n = ++k; const [lead] = await verify.seed('crm_lead', [{ first_name: 'Ada', last_name: 'Lovelace', company: `Acme ${n}`, email: `ada${n}@lead-conversion-approval-gate.test`, status: 'qualified', owner_id: rep.id, }]); if (Object.keys(previous).length > 0) await systemUpdate(verify, 'crm_lead', { id: lead!.id, ...previous }); - await verify.hooks.run('crm_lead', 'update', { id: lead!.id, ...input }, { as: rep.token }); + return lead!; +}; +const write = async (input: AnyRec, previous: AnyRec): Promise => { + const lead = await leadWith(previous); + await verify.hooks.run('crm_lead', 'update', { id: lead.id, ...input }, { as: rep.token }); }; /** @@ -150,7 +154,7 @@ describe('lead_conversion — the decision_approval branch', () => { // `lead-duplicate-visibility.test.ts`, and for the measured reason recorded // there: a decision node declaring no `config.conditions` takes EVERY // out-edge whose condition holds, IN PARALLEL. Two overlapping guards here - // would show the refusal screen and convert the lead in one run. + // would refuse the conversion and convert the lead in one run. const SHAPES: [string, Record][] = [ ...OFF_SHAPES.map(([l, s]) => [l, { leadRecord: { id: 'l1', ...s } }] as [string, Record]), ...REFUSING_SHAPES.map(([l, s]) => [l, { leadRecord: { id: 'l1', ...s } }] as [string, Record]), @@ -162,16 +166,28 @@ describe('lead_conversion — the decision_approval branch', () => { expect(taken).toHaveLength(1); }); - it.each(REFUSING_SHAPES)('routes %s to the refusal screen, ahead of every writer', (_l, shape) => { + it.each(REFUSING_SHAPES)('refuses a lead %s ahead of every writer: the run ends refused', async (_l, shape) => { const vars = { leadRecord: { id: 'l1', ...shape } }; expect(conditionHolds(edgeCondition('e28'), vars)).toBe(true); - // The refusal rejoins nothing: the flow's first create sits behind - // `screen_1`, which this path never reaches. + // A refusing `end` (#2060 item 2c): no edge leaves it, and the flow's + // first create sits behind `screen_1`, which this path never reaches. const e28 = (LeadConversionFlow.edges as Rec[]).find((e) => e.id === 'e28'); expect(e28?.target).toBe('refuse_unapproved'); - expect((LeadConversionFlow.edges as Rec[]) - .filter((e) => e.source === 'refuse_unapproved') - .map((e) => e.target)).toEqual(['end']); + expect((LeadConversionFlow.edges as Rec[]).filter((e) => e.source === 'refuse_unapproved')).toEqual([]); + + const lead = await leadWith(shape); + const run = await verify.flows.run('lead_conversion', { recordId: lead.id }, { as: rep.token }) as AnyRec; + const [after] = await verify.rows('crm_lead', { id: lead.id }); + expect({ + success: run.success, status: run.status, runId: run.runId, screen: run.screen, + accounts: (await verify.rows('crm_account', { name: lead.company })).length, + contacts: (await verify.rows('crm_contact', { email: lead.email })).length, + isConverted: Boolean(after!.is_converted), leadStatus: after!.status, + }).toEqual({ + success: true, status: 'refused', runId: undefined, screen: undefined, + accounts: 0, contacts: 0, isConverted: false, leadStatus: 'qualified', + }); + expect(String(run.refusalMessage ?? ''), 'the rep is not told why').not.toBe(''); }); it.each(OFF_SHAPES)('lets %s straight through to the duplicate verdict', (_l, shape) => { diff --git a/test/lead-duplicate-visibility.test.ts b/test/lead-duplicate-visibility.test.ts index 38dfa62b..32368f91 100644 --- a/test/lead-duplicate-visibility.test.ts +++ b/test/lead-duplicate-visibility.test.ts @@ -631,12 +631,14 @@ describe('lead_conversion — the warning at the moment of conversion', () => { * guess would need an override flag, and an override flag is the shape this * exhibit must not demonstrate. * - * ## Why these pins resume the run instead of reading the screen + * ## Why these pins read the outcome AND the store * - * A paused run has created nothing on ANY branch yet, so a pin that stopped at - * the pause would be green against a flow with no refusal in it at all. Each - * case below submits the screen it stopped on and then counts what the store - * holds — the refusal is the four zeroes, not the dialog. + * The refusal is a refusing `end` (`outcome: 'refused'`): the run ends there, + * `refused` and never resumable, so there is no dialog to submit. Each case + * reads that outcome and counts what the store holds in ONE object — the + * refusal is the outcome and the four zeroes together, not the dialog. Until + * #2060 item 2c it was a message-only screen whose Submit completed the run, + * and the console toasted "Lead converted successfully!" over it. * * ## The before-picture these replace * @@ -658,12 +660,23 @@ describe('lead_conversion — a confirmed duplicate is refused (#1288)', () => { ['a survivor that has since been erased', () => ({ duplicate_of_type: 'erased' })], ]; + /** Start the conversion of `lead`; the run's outcome, its refusal and the store. */ + const refusalOf = async (lead: Rec) => { + const { started } = await startConversion(lead); + return { + outcome: { success: started.success, status: started.status, runId: started.runId, screen: started.screen }, + message: String(started.refusalMessage ?? ''), + products: await productsOf(lead), + }; + }; + it.each(CONFIRMED_SURVIVORS)('refuses, and creates nothing — %s', async (_label, link) => { - const { screen, products } = await convert(await seedLead({ ...link(), duplicate_status: 'confirmed' })); + const { outcome, message, products } = await refusalOf(await seedLead({ ...link(), duplicate_status: 'confirmed' })); - expect(screen!.nodeId, 'the run did not stop on the refusal screen') - .toBe('refuse_confirmed_duplicate'); - expect(products, 'a confirmed duplicate was converted anyway').toEqual(NOTHING_CONVERTED); + expect({ ...outcome, ...products }, 'the run did not end refused, or a confirmed duplicate was converted anyway').toEqual({ + success: true, status: 'refused', runId: undefined, screen: undefined, ...NOTHING_CONVERTED, + }); + expect(message, 'the rep is not told why').not.toBe(''); }); it('says why, naming the verdict and where the surviving record is', async () => { @@ -671,11 +684,8 @@ describe('lead_conversion — a confirmed duplicate is refused (#1288)', () => { duplicate_of_type: 'crm_lead', duplicate_of_lead: survivorLeadId, duplicate_status: 'confirmed', }); - const { screen } = await convert(lead); - - const description = String(screen!.description ?? ''); - expect(String(screen!.title ?? ''), 'the refusal dialog has no title of its own') - .toContain('refused'); + const { message: description } = await refusalOf(lead); + expect(description, 'the refusal does not say the conversion was refused').toContain('refused'); // The ruling's item 1, both halves. The VERDICT, in the vocabulary the // record itself publishes: `duplicate_status`'s label is "Duplicate Status"