From e45dfb745fe2eb2f38147ba4d03c54a60215ef81 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:04:47 +0000 Subject: [PATCH 1/4] fix(flows): a sales rep converts a lead into a new account; the revenue is copied by a system sub-flow lead_conversion's create_account wrote annual_revenue, which the sales_rep permission set makes read-only, so every rep's conversion into a new account failed with "Field write denied ... [annual_revenue]" and nothing was written. create_account (still the converting user's own write) no longer carries the column. A new runAs: 'system' flow, lead_conversion_account_revenue, writes the lead's revenue onto the account that write created, called through a subflow node (AGENTS.md house rule 9). Since 18.0.0-next.0 no door that starts a flow by name admits a non-system caller to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv --- objectstack.composition.ts | 3 +- src/sales/flows/index.ts | 1 + .../lead-conversion-account-revenue.flow.ts | 103 +++++++++++++++ src/sales/flows/lead-conversion.flow.ts | 40 +++++- test/flow-conversion.test.ts | 118 ++++++++++++++++-- 5 files changed, 249 insertions(+), 16 deletions(-) create mode 100644 src/sales/flows/lead-conversion-account-revenue.flow.ts diff --git a/objectstack.composition.ts b/objectstack.composition.ts index 8b146c5d..15a8157f 100644 --- a/objectstack.composition.ts +++ b/objectstack.composition.ts @@ -107,7 +107,7 @@ import { campaignHook, campaignMemberHook } from './src/marketing/objects/hooks. import { ContactWelcomeFlow, ForecastSnapshotFlow, LeadAssignmentFlow, AccountApprovalFlow, - LeadConversionFlow, LeadConversionApprovalFlow, + LeadConversionFlow, LeadConversionAccountRevenueFlow, LeadConversionApprovalFlow, OpportunityApprovalFlow, OpportunityApprovalOnCreateFlow, OpportunityStatusChangeApprovalFlow, OpportunityQualificationApprovalFlow, OpportunityStagnationFlow, OpportunityWonAlertFlow, ScheduleFollowUpFlow, @@ -218,6 +218,7 @@ export const appFlows = [ CampaignContactMemberEnrollFlow, AccountApprovalFlow, LeadConversionFlow, + LeadConversionAccountRevenueFlow, LeadConversionApprovalFlow, ScheduleFollowUpFlow, OpportunityApprovalFlow, diff --git a/src/sales/flows/index.ts b/src/sales/flows/index.ts index 722c7939..c2e665fa 100644 --- a/src/sales/flows/index.ts +++ b/src/sales/flows/index.ts @@ -18,6 +18,7 @@ * sources every package reads along its edge into sales. */ export { LeadConversionFlow } from './lead-conversion.flow'; +export { LeadConversionAccountRevenueFlow } from './lead-conversion-account-revenue.flow'; export { LeadConversionApprovalFlow } from './lead-conversion-approval.flow'; export { AccountApprovalFlow } from './account-approval.flow'; export { ScheduleFollowUpFlow } from './schedule-followup.flow'; diff --git a/src/sales/flows/lead-conversion-account-revenue.flow.ts b/src/sales/flows/lead-conversion-account-revenue.flow.ts new file mode 100644 index 00000000..78bf234f --- /dev/null +++ b/src/sales/flows/lead-conversion-account-revenue.flow.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; +type Flow = Automation.Flow; + +/** + * The lead's annual revenue, copied onto the account a conversion just + * created: the one elevated write in Convert Lead (#2016). + * + * ## Why this flow exists + * + * `lead_conversion` copies the lead's `annual_revenue` onto the NEW account it + * creates: the lead field documents itself as exactly that value, and the + * account's list views, roll-ups, `account_metrics` and the executive + * dashboard read it. The `sales_rep` permission set makes + * `crm_account.annual_revenue` read-only (`editable: false`), which is + * published policy for admins. A screen flow runs as its caller, so a rep's + * conversion into a new account was refused at `create_account` with + * "Field write denied … [annual_revenue]", and nothing was converted. + * + * The maintainer's ruling (D, then B) keeps the lock and moves the write into + * a dedicated `runAs: 'system'` flow called through a `subflow` node, which is + * AGENTS.md house rule 9 ("Elevate as little as possible"). It waited for the + * platform: through 17.7.0 any signed-in member could start a `runAs: 'system'` + * autolaunched flow directly. Since 18.0.0-next.0 every door that starts a + * flow by name (the trigger route, a `type: 'flow'` action, a declared + * endpoint) refuses a non-system caller this flow, admin included, with + * `PERMISSION_DENIED` / 403 (objectstack#22310, #22407). A parent's `subflow` + * node is the only way in. `test/flow-conversion.test.ts` pins the refusal. + * + * ## Why it copies one column and does not create the account + * + * The account is created by the converting user's own write in + * `lead_conversion` (`create_account`), so the object gate and every other + * field permission still apply to it. This flow writes the one column that + * user may not edit, onto the account that write just created. Same shape as + * `case_escalation_stamp`: the user does the work, a system callee writes the + * protected column afterwards. + * + * Creating the whole account here was measured first and refused: the system + * context skips the object gate as well as the field lock. A marketing user or + * a service agent (both may read leads; neither may create accounts) then + * created an account through Convert Lead and the run failed one node later at + * `create_contact`, leaving that account behind. Today their conversion into a + * new company stops at `create_account` with nothing written, and it still + * does. + * + * ## ⛔ What this flow deliberately does NOT do + * + * It reads nothing, so it scans nothing (house rule 10 governs elevated scans + * and roll-ups). It updates one row by the id its caller hands it, and the + * caller hands it the id of the account it created in the same run. A system + * run keeps the trigger user's organization, and that account was born in the + * converting user's organization by their own write. + * + * Elevation is not anonymity: the platform carries the converting user + * through a system run, so the update is attributed to them (`updated_by`). + */ +export const LeadConversionAccountRevenueFlow: Flow = { + name: 'lead_conversion_account_revenue', + label: 'Copy Lead Revenue to New Account', + description: + "Write the converted lead's annual revenue onto the account lead_conversion just created, using the system context. Called by lead_conversion through a subflow node.", + type: 'autolaunched', + status: 'active', + + // ⭐ THE POINT OF THIS FILE: one `update_record`, one column. + runAs: 'system', + + variables: [ + // Both supplied by `lead_conversion`'s `copy_account_revenue` node. + // ⛔ `accountId` is only ever the account the caller created in the same + // run. Handing this flow any other account would let a caller overwrite + // revenue it may not edit. + { name: 'accountId', type: 'text', isInput: true, isOutput: false }, + { name: 'annualRevenue', type: 'number', isInput: true, isOutput: false }, + ], + + nodes: [ + // `config: {}`: an autolaunched callee is handed its params by the + // `subflow` executor and loads no trigger record of its own. Same shape as + // `case_escalation_stamp` and the two campaign-member callees. + { id: 'start', type: 'start', label: 'Start', config: {} }, + { + id: 'copy_revenue', type: 'update_record', label: 'Copy Annual Revenue', + config: { + objectName: 'crm_account', + filter: { id: cel`accountId` }, + // ⛔ Do not add fields here. Every column in this node is written with + // the elevated context; anything the converting user may write belongs + // in `lead_conversion`'s `create_account`. + fields: { annual_revenue: cel`annualRevenue` }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'copy_revenue', type: 'default' }, + { id: 'e2', source: 'copy_revenue', target: 'end', type: 'default' }, + ], +}; diff --git a/src/sales/flows/lead-conversion.flow.ts b/src/sales/flows/lead-conversion.flow.ts index a2de52b3..e81cbd92 100644 --- a/src/sales/flows/lead-conversion.flow.ts +++ b/src/sales/flows/lead-conversion.flow.ts @@ -437,6 +437,11 @@ export const LeadConversionFlow: Flow = { // hook-owned, and `account_protection` derives it from the `name` written // here, so an account created by this node is immediately findable by the // next conversion. + // + // ⛔ No `annual_revenue` here, ever, not even as null. The `sales_rep` + // permission set makes it read-only, and the field lock refuses the KEY + // whatever its value, so carrying it failed every rep's conversion into + // a new account (#2016). `copy_account_revenue` below writes it. id: 'create_account', type: 'create_record', label: 'Create Account', config: { objectName: 'crm_account', @@ -447,7 +452,6 @@ export const LeadConversionFlow: Flow = { name: cel`leadRecord.company`, phone: cel`has(leadRecord.phone) ? leadRecord.phone : null`, website: cel`has(leadRecord.website) ? leadRecord.website : null`, industry: cel`has(leadRecord.industry) ? leadRecord.industry : null`, - annual_revenue: cel`has(leadRecord.annual_revenue) ? leadRecord.annual_revenue : null`, number_of_employees: cel`has(leadRecord.number_of_employees) ? leadRecord.number_of_employees : null`, billing_address: cel`has(leadRecord.address) ? leadRecord.address : null`, owner_id: cel`current_user.id`, is_active: true, @@ -455,6 +459,27 @@ export const LeadConversionFlow: Flow = { outputVariable: 'createdAccount', }, }, + { + // The ELEVATED half of the new-account branch, and the only elevation + // in this flow (AGENTS.md house rule 9): the lead's annual revenue, + // written onto the account `create_account` just created by + // `lead_conversion_account_revenue` (`runAs: 'system'`). This flow stays + // `runAs: 'user'`, so the account itself is still the converting user's + // own write, held to their object and field permissions. Why the callee + // copies one column instead of creating the account is recorded in its + // header (`lead-conversion-account-revenue.flow.ts`). + // + // ⛔ `accountId` is only ever `createdAccount.id`: the callee writes + // with the system context onto whatever account it is handed. + id: 'copy_account_revenue', type: 'subflow', label: 'Copy Lead Revenue to Account', + config: { + flowName: 'lead_conversion_account_revenue', + input: { + accountId: cel`createdAccount.id`, + annualRevenue: cel`has(leadRecord.annual_revenue) ? leadRecord.annual_revenue : null`, + }, + }, + }, { id: 'use_new_account', type: 'assignment', label: 'Use New Account', config: { assignments: { accountId: cel`createdAccount.id` } }, @@ -625,7 +650,7 @@ export const LeadConversionFlow: Flow = { edges: [ // ⛔ A retired edge's id stays VACANT and every surviving edge keeps the id - // it has always had — `e1`, `e3` and `e15` are gaps on purpose. + // it has always had — `e1`, `e3`, `e7` and `e15` 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' }, @@ -633,9 +658,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`, `e3` 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`, `e3`, `e7` and `e15` 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) ──────────────────────────── // @@ -720,7 +745,10 @@ export const LeadConversionFlow: Flow = { // shape #650 retired from this repo's decision nodes. { id: 'e5', source: 'decision_account', target: 'use_existing_account', type: 'default', condition: P`vars.matchedAccount != null`, label: 'Existing' }, { id: 'e6', source: 'decision_account', target: 'create_account', type: 'default', condition: P`vars.matchedAccount == null`, label: 'New' }, - { id: 'e7', source: 'create_account', target: 'use_new_account', type: 'default' }, + // `e7` (create_account → use_new_account) is RETIRED and stays vacant + // (#2016): the revenue copy sits between them now, on `e41` / `e42`. + { id: 'e41', source: 'create_account', target: 'copy_account_revenue', type: 'default' }, + { id: 'e42', source: 'copy_account_revenue', target: 'use_new_account', type: 'default' }, // Both account branches converge on the contact-dedupe lookup. { id: 'e8', source: 'use_new_account', target: 'find_contact', type: 'default' }, { id: 'e9', source: 'use_existing_account', target: 'find_contact', type: 'default' }, diff --git a/test/flow-conversion.test.ts b/test/flow-conversion.test.ts index 8a20a121..53a55b10 100644 --- a/test/flow-conversion.test.ts +++ b/test/flow-conversion.test.ts @@ -39,13 +39,9 @@ let rep: Person; let k = 0; beforeAll(async () => { verify = await hotcrmStack(); - // The converter is a sales MANAGER. On 17.7.0 a sales rep's conversion of a - // lead into a NEW account is refused: the flow's `create_account` writes - // `annual_revenue`, which `sales_rep` may read but not edit - // (`'crm_account.annual_revenue': { editable: false }`), and the flow runs as - // its caller — reported as a finding. This file's subject is what the - // conversion creates and reuses, so it runs as a persona whose conversion - // completes. + // The converter here is a sales MANAGER, kept from when a rep's conversion + // into a NEW account was refused (#2016). The rep's own conversion into a + // new account is pinned in its own block below. rep = await signUpPerson(verify, 'manager@flow-conversion.test', { name: 'Conversion Manager', positions: ['sales_manager'], permissionSets: ['sales_manager'], }); @@ -170,8 +166,8 @@ describe('lead_conversion flow — runtime', () => { * * Converted by a sales REP onto an account the rep already owns. The flow runs * as its caller, so this is the persona whose field-level grants every write - * below has to clear; onto an EXISTING account because a rep's conversion into - * a new one is refused at `create_account` (the note on the manager above). + * below has to clear. The account is an EXISTING one: these cases are about + * the contact, and the new-account branch is pinned in the #2016 block below. */ describe('lead_conversion — the contact keeps the lead’s consent and person fields (#2068)', () => { let salesRep: Person; @@ -383,3 +379,107 @@ describe('lead_conversion — a ticked Create Opportunity needs an amount before expect((await after(who, lead.id)).opportunities[0]!.amount).toBe(40_000); }); }); + +/** + * A sales REP converts a lead into a NEW account (#2016). + * + * `sales_rep` may read `crm_account.annual_revenue` but not edit it, and the + * conversion runs as its caller, so while `create_account` copied the lead's + * revenue every rep's conversion into a new account failed there with nothing + * written. The account is still the rep's own write; the revenue alone is + * written by `lead_conversion_account_revenue`, a `runAs: 'system'` flow that + * only a parent's `subflow` node can start. + */ +describe('lead_conversion — a rep converts into a NEW account, and the revenue comes with it (#2016)', () => { + let salesRep: Person; + let marketer: Person; + let admin: string; + beforeAll(async () => { + salesRep = await signUpPerson(verify, 'rep-new-account@flow-conversion.test', { + name: 'New Account Rep', positions: ['sales_rep'], permissionSets: ['sales_rep'], + }); + marketer = await signUpPerson(verify, 'marketer@flow-conversion.test', { + name: 'Conversion Marketer', permissionSets: ['marketing_user'], + }); + admin = await verify.signIn(); + }, 120_000); + + /** A qualified lead owned by `owner`, for a company no account carries yet. */ + const leadOf = async (owner: Person, revenue: number | null) => { + const who = identity(); + const [lead] = await verify.seed('crm_lead', [{ + company: who.company, email: who.email, first_name: 'Rita', last_name: 'Ruiz', status: 'qualified', + owner_id: owner.id, ...(revenue === null ? {} : { annual_revenue: revenue }), + }]); + return { who, lead: lead! }; + }; + + /** The run's refusal as its ADR-0112 envelope, or `null` when the call succeeded. */ + const refusalOf = (call: Promise): Promise => + call.then(() => null, (e: Rec) => ({ code: e?.code, status: e?.status ?? e?.statusCode })); + + it.each([ + ['a sales rep', () => salesRep, 4_200_000], + ['a sales rep, lead with no revenue', () => salesRep, null], + ['a sales manager', () => rep, 4_200_000], + ] as const)('%s: converted into a new account that carries the lead’s revenue', async (_label, by, revenue) => { + const person = by(); + const { who, lead } = await leadOf(person, revenue); + const done = await runConversion(lead.id, undefined, person); + expect(done.error ?? null).toBeNull(); + + const out = await after(who, lead.id); + const organization = (await verify.contextFor(person.token)).tenantId; + expect({ + accounts: out.accounts.length, contacts: out.contacts.length, opportunities: out.opportunities.length, + isConverted: out.lead.is_converted, convertedAccount: out.lead.converted_account, + }).toEqual({ accounts: 1, contacts: 1, opportunities: 1, isConverted: true, convertedAccount: out.accounts[0]!.id }); + // The account is the converter's own, in the converter's organization; + // the elevated copy changes none of that. + expect(out.accounts[0]).toMatchObject({ + annual_revenue: revenue, owner_id: person.id, created_by: person.id, organization_id: organization, + }); + }); + + it('the revenue lock stands: the rep still may not write annual_revenue on that account', async () => { + const { who, lead } = await leadOf(salesRep, 4_200_000); + await runConversion(lead.id, { createOpportunity: false }, salesRep); + const [account] = (await after(who, lead.id)).accounts; + expect(account?.owner_id, 'fixture: the rep owns the converted account').toBe(salesRep.id); + + const refused = await refusalOf( + verify.hooks.run('crm_account', 'update', { id: account!.id, annual_revenue: 1 }, { as: salesRep.token }), + ); + expect(refused).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect((await verify.rows('crm_account', { id: account!.id }))[0]?.annual_revenue).toBe(4_200_000); + }); + + it.each([ + ['a sales rep', () => salesRep.token], + ['the admin', () => admin], + ] as const)('%s cannot start lead_conversion_account_revenue directly — only a subflow node can', async (_label, token) => { + const { who, lead } = await leadOf(rep, 4_200_000); + await runConversion(lead.id, { createOpportunity: false }); + const [account] = (await after(who, lead.id)).accounts; + + const refused = await refusalOf(verify.flows.run( + 'lead_conversion_account_revenue', { accountId: account!.id, annualRevenue: 999 }, { as: token() }, + )); + expect(refused).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect((await verify.rows('crm_account', { id: account!.id }))[0]?.annual_revenue).toBe(4_200_000); + }); + + it('a marketing user, who may not create accounts, still converts nothing into a new company', async () => { + // The elevation covers the revenue column only, so the account is still + // created under the converting user's object grant. Creating the whole + // account in the system flow was measured to leave an account behind for + // this persona, written past `allowCreate: false`, before the run failed + // at `create_contact`. + const { who, lead } = await leadOf(marketer, 4_200_000); + const refused = await refusalOf(runConversion(lead.id, { createOpportunity: false }, marketer)); + const out = await after(who, lead.id); + expect({ ...refused, accounts: out.accounts.length, isConverted: out.lead.is_converted }).toEqual({ + code: 'FLOW_FAILED', status: 400, accounts: 0, isConverted: false, + }); + }); +}); From 08a49d66be3a86b83c08297ec79c99b9e8657cef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:06:52 +0000 Subject: [PATCH 2/4] docs(automation): list Copy Lead Revenue to New Account; changeset for the rep conversion fix The automation page's built-in flow table gains the new sub-flow in all three locales (33 flows), and the coverage ledger carries its two Chinese row labels. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv --- .changeset/2016-rep-conversion-new-account.md | 11 +++++++++++ content/docs/administration/automation.mdx | 3 ++- content/docs/administration/automation.zh-Hans.mdx | 3 ++- content/docs/administration/automation.zh-Hant.mdx | 3 ++- test/automation-docs-coverage.test.ts | 4 ++++ 5 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 .changeset/2016-rep-conversion-new-account.md diff --git a/.changeset/2016-rep-conversion-new-account.md b/.changeset/2016-rep-conversion-new-account.md new file mode 100644 index 00000000..0b0609a5 --- /dev/null +++ b/.changeset/2016-rep-conversion-new-account.md @@ -0,0 +1,11 @@ +--- +'hotcrm': patch +--- + +A sales rep can now convert a lead into a new account. Before this fix the conversion stopped with "Field write denied … [annual_revenue]" whenever the lead's company had no account yet, and nothing was created; a sales manager had to convert it instead. The new account still carries the lead's **Annual Revenue**, and it is owned and created by the rep who converted it. + +**Annual Revenue** stays read-only for sales reps. The rep's conversion creates the account as before, and a new sub-flow, **Copy Lead Revenue to New Account**, copies the lead's revenue onto that account with system privilege. Nobody can start the sub-flow on its own, administrators included; it runs only as a step of **Lead Conversion Process**. + +A user who may not create accounts, such as a marketing user or a service agent, still cannot convert a lead into a new account, and nothing is written when they try. + +On every conversion into a new account, a sales manager's included, the revenue now arrives just after the account is created. The account's approval request records the account as it was submitted, so its snapshot in the approval inbox shows no annual revenue. The account itself carries it. diff --git a/content/docs/administration/automation.mdx b/content/docs/administration/automation.mdx index 373deff1..0317e4e1 100644 --- a/content/docs/administration/automation.mdx +++ b/content/docs/administration/automation.mdx @@ -50,11 +50,12 @@ A flow fires one of three ways, set by its start node: > **Auto-launch needs the `triggers` capability.** Record-change and scheduled flows only fire when the stack's `requires` list includes `triggers` — it installs the record-change + schedule trigger providers (schedule triggers also use the job service). Screen flows are always launched manually. -**Built-in flows in HotCRM** (32). Each row carries the flow's own label — the name listed in **Studio → Automation → Flows**, and the name you pick from in **Studio → Developer → Flow Runs**, so a run you are chasing can be looked up here verbatim: +**Built-in flows in HotCRM** (33). Each row carries the flow's own label — the name listed in **Studio → Automation → Flows**, and the name you pick from in **Studio → Developer → Flow Runs**, so a run you are chasing can be looked up here verbatim: | Flow | Trigger | What it does | | --- | --- | --- | | **Lead Conversion Process** | Screen | Convert a qualified lead into an account + contact (+ optional opportunity), then notify | +| **Copy Lead Revenue to New Account** | Subflow | Called by **Lead Conversion Process** to copy the lead's annual revenue onto the account it has just created, with system privilege | | **Generate Quote from Opportunity** | Screen | Build a quote from an opportunity and move it to *Proposal* | | **Schedule Follow-up** | Screen | Create the next follow-up task on a lead, already linked to it and owned by the right user | | **Enroll Members in Campaign** | Screen | Bulk-enroll eligible leads or contacts into this campaign, skipping the already-enrolled and the opted-out | diff --git a/content/docs/administration/automation.zh-Hans.mdx b/content/docs/administration/automation.zh-Hans.mdx index 1ed1eb24..d9de6e76 100644 --- a/content/docs/administration/automation.zh-Hans.mdx +++ b/content/docs/administration/automation.zh-Hans.mdx @@ -50,11 +50,12 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动 > **自动触发需要 `triggers` 能力。** 记录变更与计划类流程,只有当 stack 的 `requires` 列表包含 `triggers` 时才会触发 —— 它会安装记录变更与计划触发器提供方(计划触发器还依赖 job 服务)。屏幕类流程始终为手动启动。 -**HotCRM 中的内置流程**(32 个)。每一行用的都是流程自身的标签 —— 也就是 **Studio → 自动化 → 流程** 里列出、并在 **Studio → 开发者 → 流程运行记录** 里供你挑选的那个名字,因此一次运行可以逐字回到这张表里查: +**HotCRM 中的内置流程**(33 个)。每一行用的都是流程自身的标签 —— 也就是 **Studio → 自动化 → 流程** 里列出、并在 **Studio → 开发者 → 流程运行记录** 里供你挑选的那个名字,因此一次运行可以逐字回到这张表里查: | 流程 | 触发 | 它做什么 | | --- | --- | --- | | **线索转化流程** | 屏幕 | 将合格潜在客户转化为客户 + 联系人(+ 可选商机),然后通知 | +| **将线索年营收写入新客户** | 子流程 | 由**线索转化流程**调用,以系统权限把线索的年营收写入它刚创建的客户 | | **由商机生成报价** | 屏幕 | 从商机生成报价,并将其推进到 *Proposal* | | **安排跟进** | 屏幕 | 在线索上创建下一个跟进任务,并已挂好关联与负责人 | | **将成员登记进营销活动** | 屏幕 | 批量把符合条件的线索或联系人登记进本营销活动,跳过已登记与已退订的 | diff --git a/content/docs/administration/automation.zh-Hant.mdx b/content/docs/administration/automation.zh-Hant.mdx index c2d729b0..1cc92bd1 100644 --- a/content/docs/administration/automation.zh-Hant.mdx +++ b/content/docs/administration/automation.zh-Hant.mdx @@ -52,11 +52,12 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動 > **自動觸發需要 `triggers` 能力。** 記錄變更與排程類流程,只有當 stack 的 `requires` 列表包含 `triggers` 時才會觸發 —— 它會安裝記錄變更與排程觸發器提供方(排程觸發器還依賴 job 服務)。螢幕類流程始終為手動啟動。 -**HotCRM 中的內建流程**(32 個)。每一行用的都是流程自身的標籤 —— 也就是 **Studio → Automation → Flows** 裡列出、並在 **Studio → Developer → Flow Runs** 裡供你挑選的那個名字,因此一次執行可以逐字回到這張表裡查: +**HotCRM 中的內建流程**(33 個)。每一行用的都是流程自身的標籤 —— 也就是 **Studio → Automation → Flows** 裡列出、並在 **Studio → Developer → Flow Runs** 裡供你挑選的那個名字,因此一次執行可以逐字回到這張表裡查: | 流程 | 觸發 | 它做什麼 | | --- | --- | --- | | **線索轉化流程** | 螢幕 | 將合格潛在客戶轉化為客戶 + 聯絡人(+ 可選商機),然後通知 | +| **將線索年營收寫入新客戶** | 子流程 | 由**線索轉化流程**呼叫,以系統權限把線索的年營收寫入它剛建立的客戶 | | **由商機產生報價** | 螢幕 | 從商機生成報價,並將其推進到 *Proposal* | | **安排跟進** | 螢幕 | 在線索上建立下一個跟進任務,並已掛好關聯與負責人 | | **將成員登記進行銷活動** | 螢幕 | 批次把符合條件的線索或聯絡人登記進本行銷活動,跳過已登記與已退訂的 | diff --git a/test/automation-docs-coverage.test.ts b/test/automation-docs-coverage.test.ts index 98ddafb7..6e08b4c1 100644 --- a/test/automation-docs-coverage.test.ts +++ b/test/automation-docs-coverage.test.ts @@ -200,6 +200,10 @@ const ROW_LABEL: Record> = { // The flow itself carries no locale-pack entry, so this ledger is where the // page's spelling is pinned — it moves with the three pages, not after them. lead_conversion: { 'zh-Hans': '线索转化流程', 'zh-Hant': '線索轉化流程' }, + // The elevated callee behind Convert Lead's new-account branch (#2016), + // 年营收 and 客户 as the zh-CN pack labels the column and the object, and + // 子流程 in the type column like the other callees. + lead_conversion_account_revenue: { 'zh-Hans': '将线索年营收写入新客户', 'zh-Hant': '將線索年營收寫入新客戶' }, account_approval: { 'zh-Hans': '客户审批', 'zh-Hant': '客戶審批' }, lead_conversion_approval: { 'zh-Hans': '线索转化审批', 'zh-Hant': '線索轉化審批' }, quote_generation: { 'zh-Hans': '由商机生成报价', 'zh-Hant': '由商機產生報價' }, From d6467748167797d9beb1735285bad747cac44da0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:11:08 +0000 Subject: [PATCH 3/4] docs(flows): record the measured price of the revenue-copy shape on its callee The account's approval request is opened on the insert, so its submission snapshot carries no annual revenue; the account itself does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv --- .../flows/lead-conversion-account-revenue.flow.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/sales/flows/lead-conversion-account-revenue.flow.ts b/src/sales/flows/lead-conversion-account-revenue.flow.ts index 78bf234f..cad7707f 100644 --- a/src/sales/flows/lead-conversion-account-revenue.flow.ts +++ b/src/sales/flows/lead-conversion-account-revenue.flow.ts @@ -38,13 +38,17 @@ type Flow = Automation.Flow; * `case_escalation_stamp`: the user does the work, a system callee writes the * protected column afterwards. * - * Creating the whole account here was measured first and refused: the system + * Creating the whole account here was measured first and rejected: the system * context skips the object gate as well as the field lock. A marketing user or * a service agent (both may read leads; neither may create accounts) then * created an account through Convert Lead and the run failed one node later at - * `create_contact`, leaving that account behind. Today their conversion into a - * new company stops at `create_account` with nothing written, and it still - * does. + * `create_contact`, leaving that account behind. Their conversion into a new + * company stops at `create_account` with nothing written, as it always has; + * `test/flow-conversion.test.ts` pins that for the marketing user. + * + * ⚠️ The price of this shape, measured: `account_approval` opens the account's + * approval request on the insert, before this flow runs, so the request's + * submission snapshot carries no annual revenue. The account itself does. * * ## ⛔ What this flow deliberately does NOT do * From 4e876995c31a430cc04bb46ae83a3c22166649a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:32:05 +0000 Subject: [PATCH 4/4] docs: the stack registers 33 flows; the variable-binding harness boots the revenue callee README and docs/STATUS.md state the registered flow count, which the new sub-flow moves from 32 to 33. The end-to-end lead_conversion cases in flow-variable-conditions register flows by name, so they now register the callee the conversion's new-account branch calls. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv --- README.md | 4 ++-- docs/STATUS.md | 2 +- test/flow-variable-conditions.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 238d8b09..b4996d62 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # HotCRM > **The reference app for AI-written enterprise software.** A complete CRM — -> 18 objects, 32 flows, 5 dashboards, 6 AI skills, 4 languages — built as four +> 18 objects, 33 flows, 5 dashboards, 6 AI skills, 4 languages — built as four > packages (sales, service, revenue, marketing) that compile to one artifact. > The **sales package**, the one a customer installs, carries its whole > business semantics (objects, flows, actions, hooks) in **~57k tokens** @@ -69,7 +69,7 @@ HotCRM is a complete, opinionated CRM built as the **first official application* | `crm_event` | | | | | `crm_event_attendee` | | | | -Plus **6 AI skills** (a skills-only surface — HotCRM defines no agents of its own; the skills attach to the platform `ask` assistant), **5 dashboards**, **32 flows**, **31 actions**, **9 datasets**, **4 language bundles** (en, zh-CN, es-ES, ja-JP), **6 permission profiles**, **12 positions**, and **9 sharing rules**. +Plus **6 AI skills** (a skills-only surface — HotCRM defines no agents of its own; the skills attach to the platform `ask` assistant), **5 dashboards**, **33 flows**, **31 actions**, **9 datasets**, **4 language bundles** (en, zh-CN, es-ES, ja-JP), **6 permission profiles**, **12 positions**, and **9 sharing rules**. > **Business reader?** The ObjectStack docs tour every one of these capabilities in plain business language — [What Can It Do?](https://objectstack.ai/docs/capabilities) — with HotCRM as the running example on every page. diff --git a/docs/STATUS.md b/docs/STATUS.md index 6fc9a106..07f931fa 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -21,7 +21,7 @@ loader registers: HotCRM v4.0.1 Data: 18 Objects 357 Fields UI: 1 Apps 14 Views 8 Pages 5 Dashboards 10 Reports 31 Actions -Logic: 32 Flows +Logic: 33 Flows Security: 12 Positions 7 Permissions ``` diff --git a/test/flow-variable-conditions.test.ts b/test/flow-variable-conditions.test.ts index 8e819099..3ff4302d 100644 --- a/test/flow-variable-conditions.test.ts +++ b/test/flow-variable-conditions.test.ts @@ -1134,7 +1134,7 @@ describe('the two defects, reproduced end-to-end', () => { }, 60_000); it('lead_conversion: a resume signal that omits the untouched checkbox still converts', async () => { - const b = await boot(['lead_conversion']); + const b = await boot(['lead_conversion', 'lead_conversion_account_revenue']); try { const api = b.ql.createContext({ isSystem: true }); const lead = await api.object('crm_lead').insert({ @@ -1179,7 +1179,7 @@ describe('the two defects, reproduced end-to-end', () => { }, 60_000); it('lead_conversion: an explicit yes still creates the opportunity', async () => { - const b = await boot(['lead_conversion']); + const b = await boot(['lead_conversion', 'lead_conversion_account_revenue']); try { const api = b.ql.createContext({ isSystem: true }); const lead = await api.object('crm_lead').insert({