diff --git a/.changeset/account-registration-profile-and-approval.md b/.changeset/account-registration-profile-and-approval.md new file mode 100644 index 000000000..9e71f08d2 --- /dev/null +++ b/.changeset/account-registration-profile-and-approval.md @@ -0,0 +1,44 @@ +--- +'hotcrm': minor +--- + +An account now records **who it is on paper**, **what it spends**, and **whether it has +been signed off**. Five new optional fields and one new gated field on `crm_account`, a +new **Business Profile** section, and an account approval flow (REQ-0003, the +`crm_account` half of REQ-0002's steps 1, 3, 4 and 5). + +**Registration Number.** The counterparty's registered identity as issued by its own +government registry. Until now nothing on the account carried one: `Account Number` is a +sequence *we* issue and already spends itself as half the record title, so invoicing, +credit checks and entity resolution had no key to work from. The field is deliberately +country-neutral — the requirement arrived as 社会信用代码 because that customer registers +in China, but a Japanese install writes 法人番号 here, a German one a +Handelsregisternummer and a US one an EIN, and a field named after one country's registry +could not be renamed by any of them. It is searchable, so the value is findable from +global search and the lookup picker. + +**Business Profile** — **Incumbent Vendor**, **Annual Purchasing Budget** and **Payment +Cycle**, in their own new section. Generic B2B sales intelligence: who holds the account +today, how much there is to win this year, and how they pay. The existing `Annual Revenue` +answers none of the three — it is what the counterparty *earns*, not what it will *spend* +with vendors. + +**Approval Status**, and the **Account Approval** flow behind it. A newly created account +now starts at *Pending*, appears in the approval inbox HotCRM already mounts, and reaches +*Approved* only through a decision on that request. The shape is the one +`crm_opportunity.approval_status` already uses — `readonly`, with a real field-level +default — and not a second boolean beside `Active`, which answers a different question +("is this account live at all", rather than "has this record been signed off"). An install +that wants no account sign-off changes that one default to *Approved*, whereupon the flow's +start condition is false for every record and the gate is off but still switchable. + +**Commercial Capability** ships as metadata only and **gates nothing yet.** The field +records whether new business may be opened against an account (*Full* / *Settlement Only*), +which is the primitive REQ-0003 cares most about — some counterparties are payable but not +sellable. The rule that was to enforce it cannot currently be authored: it was specified as +a `validations[]` entry on `crm_opportunity` reading its account's category, and a +validation predicate can read only the opportunity's own stored columns. Until that is +resolved the value classifies and does not restrict, and the field is documented as such +rather than shipped as a promise the product does not keep. + +Ships in all four locale packs (en, zh-CN, es-ES, ja-JP). diff --git a/README.md b/README.md index c1296b8a0..ec672906e 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, 30 flows, 5 dashboards, 6 AI skills, 4 languages — built as four +> 18 objects, 31 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 **~50k tokens** @@ -68,7 +68,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**, **30 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**, **31 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/content/docs/administration/automation.mdx b/content/docs/administration/automation.mdx index 536ca3b69..17bebfc37 100644 --- a/content/docs/administration/automation.mdx +++ b/content/docs/administration/automation.mdx @@ -50,7 +50,7 @@ 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** (30). 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** (31). 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 | | --- | --- | --- | @@ -70,6 +70,7 @@ A flow fires one of three ways, set by its start node: | **Large Deal Approval** | Record change (update) | Tiered sign-off via approval nodes — Sales Manager ≥ $100K, Sales Director > $500K | | **Large Deal Approval (on create)** | Record change (insert) | The same intake for opportunities *created* at or above the threshold | | **Lead Conversion Approval** | Record change (insert) | Sign-off a lead needs before it can be converted. Ships switched OFF — it opens nothing until an install arms the gate on the lead's **Conversion Approval** field | +| **Account Approval** | Record change (insert) | Sign-off a newly created account needs before it counts as established data. Ships switched **ON** — a new account starts *Pending* and is decided in the approval inbox; an install that wants no account sign-off changes the default on the account's **Approval Status** field | | **Large Deal Won Alert** | Record change (update) | When an opportunity of $100K or more turns *Closed Won*, notify the owner — the owner alone, not their manager | | **Billing Hand-off: Closed Won** | Record change (update) | On the transition into *Closed Won*, enqueue one durable POST of the deal + account + line items to the billing endpoint | | **Billing Hand-off: Contract Activated** | Record change (update) | On the transition into *Activated*, enqueue one durable POST of the contract + account + the originating deal's line items | diff --git a/content/docs/administration/automation.zh-Hans.mdx b/content/docs/administration/automation.zh-Hans.mdx index 45b6e0c9a..e7f50d1ee 100644 --- a/content/docs/administration/automation.zh-Hans.mdx +++ b/content/docs/administration/automation.zh-Hans.mdx @@ -50,7 +50,7 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动 > **自动触发需要 `triggers` 能力。** 记录变更与计划类流程,只有当 stack 的 `requires` 列表包含 `triggers` 时才会触发 —— 它会安装记录变更与计划触发器提供方(计划触发器还依赖 job 服务)。屏幕类流程始终为手动启动。 -**HotCRM 中的内置流程**(30 个)。每一行用的都是流程自身的标签 —— 也就是 **Studio → Automation → Flows** 里列出、并在 **Studio → Developer → Flow Runs** 里供你挑选的那个名字,因此一次运行可以逐字回到这张表里查: +**HotCRM 中的内置流程**(31 个)。每一行用的都是流程自身的标签 —— 也就是 **Studio → Automation → Flows** 里列出、并在 **Studio → Developer → Flow Runs** 里供你挑选的那个名字,因此一次运行可以逐字回到这张表里查: | 流程 | 触发 | 它做什么 | | --- | --- | --- | @@ -70,6 +70,7 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动 | **大额商机审批** | 记录变更(更新) | 通过 approval 节点分级签核 —— 销售经理 ≥ $100K,销售总监 > $500K | | **大额商机审批(新建时)** | 记录变更(插入) | 同一套受理逻辑,用于*创建时*金额已达到阈值的商机 | | **线索转化审批** | 记录变更(插入) | 线索转化前需要的签核。出厂默认是**关闭**的:在线索的**转化审批**字段上打开闸门之前,它不会发起任何审批 | +| **客户审批** | 记录变更(插入) | 新建客户在成为正式数据之前需要的签核。出厂默认是**打开**的:新客户以**待审批**状态创建,并在审批收件箱中裁定;不需要客户签核的安装,改掉客户**审批状态**字段的默认值即可 | | **大额商机赢单提醒** | 记录变更(更新) | 金额 $100K 及以上的商机转为 *Closed Won* 时,通知负责人 —— 只通知负责人本人,不通知其经理 | | **计费交接:赢单** | 记录变更(更新) | 商机转入 *Closed Won* 的那一刻,向计费端点投递一次带重试的 POST:商机 + 客户 + 行项目 | | **计费交接:合同激活** | 记录变更(更新) | 合同转入 *Activated* 的那一刻,向计费端点投递一次带重试的 POST:合同 + 客户 + 来源商机的行项目 | diff --git a/content/docs/administration/automation.zh-Hant.mdx b/content/docs/administration/automation.zh-Hant.mdx index a48e0320d..6a6e00a19 100644 --- a/content/docs/administration/automation.zh-Hant.mdx +++ b/content/docs/administration/automation.zh-Hant.mdx @@ -52,7 +52,7 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動 > **自動觸發需要 `triggers` 能力。** 記錄變更與排程類流程,只有當 stack 的 `requires` 列表包含 `triggers` 時才會觸發 —— 它會安裝記錄變更與排程觸發器提供方(排程觸發器還依賴 job 服務)。螢幕類流程始終為手動啟動。 -**HotCRM 中的內建流程**(30 個)。每一行用的都是流程自身的標籤 —— 也就是 **Studio → Automation → Flows** 裡列出、並在 **Studio → Developer → Flow Runs** 裡供你挑選的那個名字,因此一次執行可以逐字回到這張表裡查: +**HotCRM 中的內建流程**(31 個)。每一行用的都是流程自身的標籤 —— 也就是 **Studio → Automation → Flows** 裡列出、並在 **Studio → Developer → Flow Runs** 裡供你挑選的那個名字,因此一次執行可以逐字回到這張表裡查: | 流程 | 觸發 | 它做什麼 | | --- | --- | --- | @@ -72,6 +72,7 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動 | **大額商機審批** | 記錄變更(更新) | 透過 approval 節點分級簽核 —— 銷售經理 ≥ $100K,銷售總監 > $500K | | **大額商機審批(新建時)** | 記錄變更(插入) | 同一套受理邏輯,用於*建立時*金額已達到閾值的商機 | | **線索轉化審批** | 記錄變更(插入) | 線索轉化前需要的簽核。出廠預設是**關閉**的:在線索的**轉化審批**欄位上打開閘門之前,它不會發起任何審批 | +| **客戶審批** | 記錄變更(插入) | 新建客戶在成為正式資料之前需要的簽核。出廠預設是**打開**的:新客戶以**待審批**狀態建立,並在審批收件匣中裁定;不需要客戶簽核的安裝,改掉客戶**審批狀態**欄位的預設值即可 | | **大額商機贏單提醒** | 記錄變更(更新) | 金額 $100K 及以上的商機轉為 *Closed Won* 時,通知負責人 —— 只通知負責人本人,不通知其經理 | | **計費交接:贏單** | 記錄變更(更新) | 商機轉入 *Closed Won* 的那一刻,向計費端點投遞一次帶重試的 POST:商機 + 客戶 + 行項目 | | **計費交接:合約啟用** | 記錄變更(更新) | 合約轉入 *Activated* 的那一刻,向計費端點投遞一次帶重試的 POST:合約 + 客戶 + 來源商機的行項目 | diff --git a/docs/STATUS.md b/docs/STATUS.md index b7d6ded0e..c95cd1e3d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -19,9 +19,9 @@ loader registers: ```text HotCRM v3.1.0 -Data: 18 Objects 337 Fields +Data: 18 Objects 343 Fields UI: 1 Apps 14 Views 8 Pages 5 Dashboards 10 Reports 31 Actions -Logic: 30 Flows +Logic: 31 Flows Security: 12 Positions 7 Permissions ``` diff --git a/objectstack.composition.ts b/objectstack.composition.ts index 71c6eb18d..ce4fadfbd 100644 --- a/objectstack.composition.ts +++ b/objectstack.composition.ts @@ -95,6 +95,7 @@ import { campaignHook, campaignMemberHook } from './src/marketing/objects/hooks. // Flows — the registration order the single `allFlows` array used to hold. import { ContactWelcomeFlow, DemoBootstrapFlow, ForecastSnapshotFlow, LeadAssignmentFlow, + AccountApprovalFlow, LeadConversionFlow, LeadConversionApprovalFlow, OpportunityApprovalFlow, OpportunityApprovalOnCreateFlow, OpportunityStagnationFlow, OpportunityWonAlertFlow, ScheduleFollowUpFlow, @@ -210,6 +211,7 @@ export const allFlows = [ CloseCaseFlow, ClaimCaseFlow, CaseEscalationStampFlow, + AccountApprovalFlow, LeadConversionFlow, LeadConversionApprovalFlow, ScheduleFollowUpFlow, diff --git a/src/sales/flows/account-approval.flow.ts b/src/sales/flows/account-approval.flow.ts new file mode 100644 index 000000000..130bae5aa --- /dev/null +++ b/src/sales/flows/account-approval.flow.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { P } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; +type Flow = Automation.Flow; + +/** + * Account Approval — the sign-off a new account record needs before it counts + * as established data. + * + * REQ-0003 step 5: 「客户信息提交后进入审批流,审批通过后客户正式生效」. Expressed as an + * **approval node** (`type: 'approval'`, ADR-0019), the construct + * `opportunity-approval.flow.ts` and `lead-conversion-approval.flow.ts` already + * use — ⛔ there is no `workflow` metadata type and no standalone + * `ApprovalProcess` to author. + * + * ## ⚠️ This flow ships ARMED, and that is the one place it differs from its + * ## lead sibling + * + * `lead-conversion-approval` is inert until an install opts in, because + * REQ-0005 asked for a gate that is off by default. REQ-0003 asks for the + * opposite in as many words — acceptance 4: "A newly created account sits in a + * pending state, appears in the platform approval inbox HotCRM already mounts, + * and only reaches the approved state through a decision on that request." So + * `crm_account.approval_status` ships `defaultValue: 'pending'` and every new + * account enters here. + * + * The switch is still that `defaultValue`, not this flow's `status`: an install + * that wants no account sign-off sets it to `approved`, the start condition + * below is then false for every record that will ever exist, and the gate is + * off AND still switchable. ⛔ Do NOT reach for `status: 'draft'` or + * `'obsolete'` instead — both were measured on the pinned + * `@objectstack/service-automation` 17.4.0 and neither is an off switch: draft + * flows still fire their triggers, and `isFlowEnabled` composes authoring + * status with the installation's activation ledger so that `obsolete` is a gate + * no install can ever turn back ON. That measurement is recorded in + * `lead-conversion-approval.flow.ts`; it is quoted here, ⛔ not re-derived. + * + * ## Why the trigger is insert-only + * + * `pending` appears at exactly one moment — the field default, stamped as the + * row is written — so the request is opened where it appears. ⛔ An afterUpdate + * twin would re-enter on every edit made while the account sat pending and open + * a second request for the same decision. An account that predates this field + * carries no `approval_status` at all, so the `has()` guard below reads it as + * "not gated" and it is never dragged into an approval it was not created + * under. + */ +export const AccountApprovalFlow: Flow = { + name: 'account_approval', + label: 'Account Approval', + description: + 'Sign-off a newly created account needs before it counts as established data. Switchable from crm_account.approval_status.', + type: 'record_change', + status: 'active', + // Same reading the two sibling approval flows record. A gate exists to + // CONSTRAIN the submitter, so the submitter's own scope is the wrong identity + // to evaluate it under; and the verdict writes below land on + // `approval_status`, which is `readonly: true` on the object, so only a + // platform write reaches it at all. + runAs: 'system', + + variables: [{ name: 'accountId', type: 'text', isInput: true, isOutput: false }], + + nodes: [ + { + id: 'start', + type: 'start', + label: 'Start', + config: { + objectName: 'crm_account', + triggerType: 'record-after-create', + // TOTALITY (AGENTS.md — validation predicates must be TOTAL): `has()` + // on every read. A flow condition is strict CEL on every run, and an + // unguarded read against a driver that omits absent columns aborts — + // which from 17.0.0-rc.2 FAILS THE RUN rather than skipping it. + condition: P`has(record.approval_status) && record.approval_status == "pending"`, + }, + }, + { + id: 'get_account', + type: 'get_record', + label: 'Get Account', + config: { + objectName: 'crm_account', + filter: { id: '{record.id}' }, + outputVariable: 'accountRecord', + }, + }, + { + id: 'account_review', + type: 'approval', + label: 'Account Review', + config: { + // The customer's chain is 销售负责人 → 销售总监; REQ-0003 rules that named + // chain **C** (overlay — one company's org chart), so core ships the + // one generic tier against the manager bench this app already declares. + approvers: [{ type: 'position', value: 'sales_manager' }], + // Explicit though it is the schema default, exactly as both sibling + // flows author it: approvers snapshot at request creation, so an empty + // bench would otherwise leave the request undecidable — and here that + // means an account nobody can ever approve. + onEmptyApprovers: 'admin_rescue', + behavior: 'first_response', + // ⚠️ `false`, matching the lead sibling rather than the opportunity one. + // An account under review is a record a rep is still working — contacts + // are being attached, the address is being corrected, activity is + // landing on it. Locking it would freeze exactly the data-entry the + // approval exists to review. Nothing is at risk: the verdict column is + // `readonly: true`, so the only field this decision touches is one no + // user may write anyway. + lockRecord: false, + approvalStatusField: 'approval_status', + }, + }, + { + id: 'mark_approved', + type: 'update_record', + label: 'Mark Approved', + config: { + objectName: 'crm_account', + filter: { id: '{record.id}' }, + fields: { approval_status: 'approved' }, + }, + }, + { + id: 'notify_approved', + type: 'notify', + label: 'Notify Owner — Approved', + config: { + recipients: ['{accountRecord.owner_id}'], + channels: ['inbox'], + topic: 'account_approved', + title: 'Account approved: {accountRecord.name}', + message: 'This account has been signed off and is now established data.', + actionUrl: '/crm_account/{record.id}', + }, + }, + { + id: 'mark_rejected', + type: 'update_record', + label: 'Mark Rejected', + config: { + objectName: 'crm_account', + filter: { id: '{record.id}' }, + fields: { approval_status: 'rejected' }, + }, + }, + { + id: 'notify_rejected', + type: 'notify', + label: 'Notify Owner — Rejected', + config: { + recipients: ['{accountRecord.owner_id}'], + channels: ['inbox'], + severity: 'warning', + topic: 'account_rejected', + title: 'Account not approved: {accountRecord.name}', + message: 'This account was not signed off. Correct its details and ask for another review.', + actionUrl: '/crm_account/{record.id}', + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'get_account', type: 'default' }, + { id: 'e2', source: 'get_account', target: 'account_review', type: 'default' }, + // Approval-node branch labels — the engine resumes down the out-edge whose + // `label` matches the recorded decision. + { id: 'e3', source: 'account_review', target: 'mark_approved', type: 'default', label: 'approve' }, + { id: 'e4', source: 'account_review', target: 'mark_rejected', type: 'default', label: 'reject' }, + { id: 'e5', source: 'mark_approved', target: 'notify_approved', type: 'default' }, + { id: 'e6', source: 'notify_approved', target: 'end', type: 'default' }, + { id: 'e7', source: 'mark_rejected', target: 'notify_rejected', type: 'default' }, + { id: 'e8', source: 'notify_rejected', target: 'end', type: 'default' }, + ], +}; diff --git a/src/sales/flows/index.ts b/src/sales/flows/index.ts index 8fd734da1..ad5e41196 100644 --- a/src/sales/flows/index.ts +++ b/src/sales/flows/index.ts @@ -18,6 +18,7 @@ */ export { LeadConversionFlow } from './lead-conversion.flow'; export { LeadConversionApprovalFlow } from './lead-conversion-approval.flow'; +export { AccountApprovalFlow } from './account-approval.flow'; export { ScheduleFollowUpFlow } from './schedule-followup.flow'; export { DemoBootstrapFlow } from './demo-bootstrap.flow'; export { OpportunityApprovalFlow, OpportunityApprovalOnCreateFlow } from './opportunity-approval.flow'; diff --git a/src/sales/objects/account.object.ts b/src/sales/objects/account.object.ts index 8f9afd3fd..0831a9b9d 100644 --- a/src/sales/objects/account.object.ts +++ b/src/sales/objects/account.object.ts @@ -22,7 +22,7 @@ export const Account = ObjectSchema.create({ // FORMULA (display_title/full_name): without this, $search auto-defaults to // the formula field, which isn't a real column, so the lookup picker + global // search silently return zero. These are real, indexed columns. - searchableFields: ['name', 'account_number'], + searchableFields: ['name', 'account_number', 'registration_number'], highlightFields: ['account_number', 'name', 'type', 'owner_id'], // Field groups organize the form layout. Array order == display order. @@ -30,6 +30,10 @@ export const Account = ObjectSchema.create({ fieldGroups: [ { key: 'basic', label: 'Basic Information', icon: 'building' }, { key: 'financials', label: 'Financials', icon: 'dollar-sign' }, + // REQ-0003 acceptance 3: the business-profile fields render as their own + // group with NO `record:details` section authored anywhere — the derived + // layout ladder's rung 1 (AGENTS.md: escape hatches are for extreme cases). + { key: 'business_profile', label: 'Business Profile', icon: 'briefcase' }, { key: 'contact_info', label: 'Contact Information', icon: 'phone' }, { key: 'ownership', label: 'Ownership & Status', icon: 'users' }, { key: 'branding', label: 'Branding', icon: 'palette', collapse: 'collapsed' }, @@ -166,6 +170,121 @@ export const Account = ObjectSchema.create({ group: 'basic', }), + /** + * The counterparty's registered identity in a government registry. + * + * ⛔ NOT named after one country's registry, and that is the whole design + * constraint (REQ-0003 *Disposition & rationale*). The requirement arrived + * as 社会信用代码 because the customer registers in China; a Japanese install + * writes 法人番号 here, a German one a Handelsregisternummer, a US one an + * EIN. A `unified_social_credit_code` field would carry one country's + * registry in its NAME, where no install can rename it — so the core field + * is generic and the registry it came from is the install's own knowledge. + * + * ⚠️ Distinct from `account_number` above, which is OUR sequence + * (`Field.autonumber`, `ACC-{000000}`) and already spent as half the record + * title. That is an identifier we issue; this is one we were given, and + * invoicing, credit checks and entity resolution key on the latter. + * + * `searchable` + the `searchableFields` entry above is REQ-0003 acceptance + * 1 — the value is findable from global search and the lookup picker. It is + * indexed below for that read path. + * + * ⛔ NOT `unique: true`. Refusing a save because two rows carry the same + * registered identity is a data-quality POLICY, and this repo has already + * chosen the soft shape for that question once — the hard unique on + * `crm_lead.email` was removed because refusing a repeat is worse than + * recording it. The same reasoning `name_normalized` records: a match key, + * not a policy. Deduplication is a review affordance this app does not have. + */ + registration_number: Field.text({ + label: 'Registration Number', + description: + "The counterparty's registered identity as issued by its own government registry — whichever registry that is. Not the Account Number, which we issue.", + searchable: true, + maxLength: 64, + group: 'basic', + }), + + /** + * What this account may be used for commercially — the category that GATES + * capability rather than merely classifying (REQ-0003, the record's most + * valuable primitive: *some counterparties are payable but not sellable*). + * + * ## Why a field BESIDE `type`, and not four more options on it + * + * REQ-0003 leaves this open ("either new options on `type` or a field + * beside it, decided when built"). It is a separate field because `type` + * is a RELATIONSHIP-STAGE axis — prospect → customer → former, plus + * partner — and it already has two readers that would be broken by a value + * meaning "may not be sold to": `account.hook.ts` promotes on it, and + * `opportunity.hook.ts` flips a won deal's account to `customer`. An + * `agency` option on `type` would therefore be overwritten into `customer` + * by the first won deal booked against it, silently un-restricting the + * account. The two axes are independent — an agency can be a prospect, a + * customer or a former customer — so they are two fields. + * + * ## Generic values only + * + * REQ-0003 rules the customer's own vocabulary (招标代理公司 …) **C**: + * overlay configuration, ⛔ never committed into core. Core ships the + * capability distinction itself; an install maps its market's categories + * onto these two values. + * + * ## ⚠️ THIS FIELD GATES NOTHING YET, and that is not an oversight + * + * REQ-0003 is explicit that "a category no rule reads is prose, and the + * rule is the point", and `objectstack validate` says the same thing back + * about this declaration in as many words: "declared but nothing in this + * stack reads or displays it … Verdict: inert". + * + * The rule REQ-0003 specifies for it — "Authored as a `validations[]` entry + * on `crm_opportunity` reading its account's category" — CANNOT BE WRITTEN, + * measured on the pinned packages rather than assumed: + * + * • a `validations[]` predicate is evaluated by + * `checkPredicate(rule, merged, previous)`, which calls + * `ExpressionEngine.evaluate(expr, { record, previous })` — plain data + * objects, the opportunity's OWN stored columns and nothing else; + * • `record.crm_account.type` does not traverse the lookup. Measured: + * `runtime: No such key: type`; + * • `os.lookup("crm_account", …)` is not reachable either. `EvalContext` + * declares an optional `api.lookup`, but `buildScope()` in + * `@objectstack/formula` builds `os` from ONLY `ctx.user` / `ctx.org` / + * `ctx.env` and never binds `ctx.api` — so the call has no overload even + * when a caller passes one. Measured: `runtime: found no matching + * overload for 'dyn.lookup(string, dyn)'`. `checkPredicate` does not + * pass `api` at all; + * • and an unevaluable predicate REJECTS THE WRITE (#4649), so authoring + * it literally would refuse every opportunity write — insert and update + * alike — which is the bricking REQ-0003 acceptance 2 forbids, in its + * most extreme form. + * + * ⇒ The gate needs a construct decision that is not this file's to take + * (hotcrm#1915). ⛔ Do NOT "fix" this by widening the predicate or by + * denormalising the category onto `crm_opportunity` on the quiet: a copy + * needs a writer and goes stale the moment an account is reclassified, and + * a hook that can read the account can simply refuse, which is what + * `lead.hook.ts` already does for the conversion gate. + * + * ⚠️ `events: ['insert']` on a validation rule IS honoured — measured in + * `@objectstack/objectql`, which filters rules by `r.events ?? ['insert', + * 'update']` against the write mode — so the transition-gate/invariant + * distinction (AGENTS.md metadata semantics rule 7) is expressible here and + * is NOT what blocks this. Only the cross-object read is. + */ + commercial_capability: Field.select({ + label: 'Commercial Capability', + group: 'basic', + description: + 'Whether new business may be opened against this account. Settlement Only accounts stay fully usable for billing and payment, but no new opportunity may be linked to them.', + defaultValue: 'full', + options: [ + { label: 'Full', value: 'full', color: '#00AA00', default: true }, + { label: 'Settlement Only', value: 'settlement_only', color: '#FFA500' }, + ], + }), + // Select fields with custom options type: Field.select({ label: 'Account Type', @@ -234,6 +353,46 @@ export const Account = ObjectSchema.create({ group: 'financials', }), + // ─── Business profile (REQ-0003 step 3) ──────────────────────────── + // + // Generic B2B sales intelligence, not IT-services vocabulary: who holds the + // account today, how much there is to win this year, and how they pay. + // + // ⚠️ `annual_revenue` above answers none of these — it is the account's OWN + // turnover, what the counterparty earns, whereas the budget below is what + // it will SPEND with vendors. Reusing one for the other is the mistake this + // group exists to prevent. + + incumbent_vendor: Field.text({ + label: 'Incumbent Vendor', + description: 'The supplier currently holding this account for the spend we are chasing.', + maxLength: 255, + group: 'business_profile', + }), + + annual_purchasing_budget: Field.currency({ + label: 'Annual Purchasing Budget', + description: "What this account expects to SPEND with vendors this year — not its own revenue.", + scale: 2, + min: 0, + group: 'business_profile', + }), + + // Payment terms as a select, not free text: it is read by a person sizing + // cash-flow risk, and a picklist keeps that domain knowable. The values are + // the net-terms ladder every B2B install shares; a customer whose contracts + // use a different ladder overlays its own. + payment_cycle: Field.select({ + label: 'Payment Cycle', + group: 'business_profile', + options: [ + { label: 'Prepaid', value: 'prepaid' }, + { label: 'Net 30', value: 'net_30' }, + { label: 'Net 60', value: 'net_60' }, + { label: 'Net 90', value: 'net_90' }, + ], + }), + // Contact Information phone: Field.text({ label: 'Phone', @@ -361,6 +520,46 @@ export const Account = ObjectSchema.create({ trackHistory: true, }), + /** + * Where this account stands in its sign-off (REQ-0003 step 5 / acceptance 4). + * + * The shape `crm_opportunity.approval_status` already uses: `readonly` so + * only the platform's own approval write reaches it, and a FIELD-level + * `defaultValue` — option-level `default: true` only preselects in a UI + * form, so an API or flow insert would land `null` and never match the + * flow's entry condition. + * + * ⛔ NOT a second boolean beside `is_active`, which REQ-0003 rules out by + * name. `is_active` answers "is this account live at all"; this answers + * "has the record been signed off", and collapsing them would make + * deactivating an account indistinguishable from rejecting one. + * + * ⚠️ Ships `pending`, so a NEW account enters the approval inbox on + * creation — REQ-0003 acceptance 4 in as many words ("A newly created + * account sits in a pending state"). An install that wants no account + * sign-off changes this one default to `approved`; from then on the flow's + * start condition is false for every record and the gate is off, switchable + * and inert. That is the same off-switch `crm_lead.conversion_approval_ + * status` records, and ⛔ NOT the flow's `status`, for the reasons measured + * in `lead-conversion-approval.flow.ts`. + * + * ⛔ There is deliberately no `not_required` value. On the lead the gate + * ships OFF and needs a fourth value to say so; here acceptance 4 ships it + * ON, and `approved` already spells "nothing to decide". + */ + approval_status: Field.select({ + label: 'Approval Status', + group: 'ownership', + readonly: true, + trackHistory: true, + defaultValue: 'pending', + options: [ + { label: 'Pending', value: 'pending', color: '#FFA500', default: true }, + { label: 'Approved', value: 'approved', color: '#00AA00' }, + { label: 'Rejected', value: 'rejected', color: '#FF0000' }, + ], + }), + // Brand color (new field type) brand_color: Field.color({ label: 'Brand Color', @@ -495,6 +694,9 @@ export const Account = ObjectSchema.create({ // `get_record` has no `sort` option, so the conversion would reuse an // arbitrary one. Reusing one of N is still better than creating the N+1th. { fields: ['name_normalized'] }, + // `searchableFields` carries `registration_number`, and the note at the top + // of this file records why those must be real, indexed columns. + { fields: ['registration_number'] }, ], // API surface + capabilities. `files` and `feeds` are live and enforced (see diff --git a/src/sales/translations/en/objects.customer.ts b/src/sales/translations/en/objects.customer.ts index cdede440d..c6ce6f37a 100644 --- a/src/sales/translations/en/objects.customer.ts +++ b/src/sales/translations/en/objects.customer.ts @@ -24,6 +24,26 @@ export const customer: Record = { pluralLabel: 'Accounts', fields: { account_number: { label: 'Account Number' }, + registration_number: { + label: 'Registration Number', + help: "The counterparty's registered identity as issued by its own government registry. Not the Account Number, which we issue.", + }, + commercial_capability: { + label: 'Commercial Capability', + help: 'Whether new business may be opened against this account. Settlement Only accounts stay usable for billing and payment, but no new opportunity may be linked to them.', + options: { full: 'Full', settlement_only: 'Settlement Only' }, + }, + incumbent_vendor: { label: 'Incumbent Vendor', help: 'The supplier currently holding this account for the spend we are chasing.' }, + annual_purchasing_budget: { label: 'Annual Purchasing Budget', help: 'What this account expects to spend with vendors this year — not its own revenue.' }, + payment_cycle: { + label: 'Payment Cycle', + options: { prepaid: 'Prepaid', net_30: 'Net 30', net_60: 'Net 60', net_90: 'Net 90' }, + }, + approval_status: { + label: 'Approval Status', + help: 'Where this account stands in its sign-off. A new account starts Pending and is decided in the approval inbox.', + options: { pending: 'Pending', approved: 'Approved', rejected: 'Rejected' }, + }, name: { label: 'Account Name', help: 'Legal name of the company or organization' }, type: { label: 'Type', @@ -92,6 +112,7 @@ export const customer: Record = { _sections: { basic: { label: 'Basic Information' }, financials: { label: 'Financials' }, + business_profile: { label: 'Business Profile' }, contact_info: { label: 'Contact Information' }, ownership: { label: 'Ownership & Status' }, branding: { label: 'Branding' }, diff --git a/src/sales/translations/es-ES/objects.customer.ts b/src/sales/translations/es-ES/objects.customer.ts index 34081507d..5777a299b 100644 --- a/src/sales/translations/es-ES/objects.customer.ts +++ b/src/sales/translations/es-ES/objects.customer.ts @@ -25,6 +25,26 @@ export const customer: Record = { description: 'Empresas y organizaciones con las que mantenemos relación comercial', fields: { account_number: { label: 'Número de Cuenta' }, + registration_number: { + label: 'Número de Registro', + help: 'La identidad registrada de la contraparte, emitida por su propio registro gubernamental. No es el Número de Cuenta, que emitimos nosotros.', + }, + commercial_capability: { + label: 'Capacidad Comercial', + help: 'Si se puede abrir nuevo negocio con esta cuenta. Las cuentas de Solo Liquidación siguen siendo utilizables para facturación y pagos, pero no se les puede vincular ninguna oportunidad nueva.', + options: { full: 'Completa', settlement_only: 'Solo Liquidación' }, + }, + incumbent_vendor: { label: 'Proveedor Actual', help: 'El proveedor que actualmente atiende el gasto que perseguimos en esta cuenta.' }, + annual_purchasing_budget: { label: 'Presupuesto Anual de Compras', help: 'Lo que esta cuenta espera gastar con proveedores este año, no sus propios ingresos.' }, + payment_cycle: { + label: 'Ciclo de Pago', + options: { prepaid: 'Prepago', net_30: '30 días', net_60: '60 días', net_90: '90 días' }, + }, + approval_status: { + label: 'Estado de Aprobación', + help: 'En qué punto de su aprobación está esta cuenta. Una cuenta nueva comienza Pendiente y se decide en la bandeja de aprobaciones.', + options: { pending: 'Pendiente', approved: 'Aprobada', rejected: 'Rechazada' }, + }, name: { label: 'Nombre de Cuenta', help: 'Nombre legal de la empresa u organización' }, name_normalized: { label: 'Nombre de Cuenta (Normalizado)', @@ -133,6 +153,7 @@ export const customer: Record = { _sections: { basic: { label: 'Información Básica' }, financials: { label: 'Datos Financieros' }, + business_profile: { label: 'Perfil de Negocio' }, contact_info: { label: 'Información de Contacto' }, ownership: { label: 'Propiedad y Estado' }, branding: { label: 'Identidad de Marca' }, diff --git a/src/sales/translations/ja-JP/objects.customer.ts b/src/sales/translations/ja-JP/objects.customer.ts index 393563e0d..6272dabf5 100644 --- a/src/sales/translations/ja-JP/objects.customer.ts +++ b/src/sales/translations/ja-JP/objects.customer.ts @@ -25,6 +25,26 @@ export const customer: Record = { description: '当社と取引のある企業・組織', fields: { account_number: { label: '取引先番号' }, + registration_number: { + label: '登記番号', + help: '取引先が自国の登記機関から交付された登記上の識別番号。当社が採番する取引先番号とは異なります。', + }, + commercial_capability: { + label: '商談可否区分', + help: 'この取引先に対して新規の商談を開始できるかどうか。決済のみの取引先は請求・入金には引き続き利用できますが、新規商談を関連付けることはできません。', + options: { full: '制限なし', settlement_only: '決済のみ' }, + }, + incumbent_vendor: { label: '現行ベンダー', help: '当社が狙っている支出を現在受注しているベンダー。' }, + annual_purchasing_budget: { label: '年間調達予算', help: 'この取引先が今年度ベンダーに支出する見込み額であり、自社の売上高ではありません。' }, + payment_cycle: { + label: '支払サイクル', + options: { prepaid: '前払い', net_30: '30日', net_60: '60日', net_90: '90日' }, + }, + approval_status: { + label: '承認ステータス', + help: 'この取引先の承認状況。新規取引先は承認待ちで作成され、承認トレイで判断されます。', + options: { pending: '承認待ち', approved: '承認済み', rejected: '却下' }, + }, name: { label: '取引先名', help: '会社または組織の正式名称' }, name_normalized: { label: '取引先名(正規化)', @@ -117,6 +137,7 @@ export const customer: Record = { _sections: { basic: { label: '基本情報' }, financials: { label: '財務情報' }, + business_profile: { label: 'ビジネス情報' }, contact_info: { label: '連絡先情報' }, ownership: { label: '所有者・ステータス' }, branding: { label: 'ブランディング' }, diff --git a/src/sales/translations/zh-CN/objects.customer.ts b/src/sales/translations/zh-CN/objects.customer.ts index af34c7868..ed5db342e 100644 --- a/src/sales/translations/zh-CN/objects.customer.ts +++ b/src/sales/translations/zh-CN/objects.customer.ts @@ -25,6 +25,26 @@ export const customer: Record = { description: '与我们有业务往来的公司和组织', fields: { account_number: { label: '客户编号' }, + registration_number: { + label: '登记证件号', + help: '对方在其所属政府登记机关取得的登记标识。不同于由我方发放的客户编号。', + }, + commercial_capability: { + label: '商务能力', + help: '该客户是否可以承接新业务。仅结算类客户依然可用于开票与收付款,但不能关联新的商机。', + options: { full: '完整', settlement_only: '仅结算' }, + }, + incumbent_vendor: { label: '现有供应商', help: '当前承接该客户这部分支出的供应商。' }, + annual_purchasing_budget: { label: '年度采购预算', help: '该客户本年度预计向供应商支出的金额,而非其自身营收。' }, + payment_cycle: { + label: '付款周期', + options: { prepaid: '预付', net_30: '30 天', net_60: '60 天', net_90: '90 天' }, + }, + approval_status: { + label: '审批状态', + help: '该客户的审批进展。新建客户初始为待审批,并在审批收件箱中裁定。', + options: { pending: '待审批', approved: '已通过', rejected: '已驳回' }, + }, name: { label: '客户名称', help: '公司或组织的法定名称' }, type: { label: '类型', @@ -117,6 +137,7 @@ export const customer: Record = { _sections: { basic: { label: '基本信息' }, financials: { label: '财务信息' }, + business_profile: { label: '业务信息' }, contact_info: { label: '联系信息' }, ownership: { label: '归属与状态' }, branding: { label: '品牌' }, diff --git a/test/account-approval-gate.test.ts b/test/account-approval-gate.test.ts new file mode 100644 index 000000000..fc1c2b7bb --- /dev/null +++ b/test/account-approval-gate.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import stack from '../objectstack.config'; +import { AccountApprovalFlow } from '../src/sales/flows/account-approval.flow'; +import { makeFlowHarness, type Rec } from './helpers/flow-harness'; + +/** + * The account approval gate, and the thing that actually has to hold about it: + * **it is ON in the box** — the one place it inverts its lead sibling. + * + * REQ-0003 acceptance 4 asks for it in as many words: "A newly created account + * sits in a pending state, appears in the platform approval inbox HotCRM + * already mounts, and only reaches the approved state through a decision on + * that request." `lead_conversion_approval` ships OFF because REQ-0005 asked + * for the opposite, so the two flows are deliberately not symmetric and a + * reader comparing them needs this file to say which way each one points. + * + * ⚠️ The shipped-default assertion is not decoration. `defaultValue: 'pending'` + * IS the switch — an install that wants no account sign-off changes that one + * value to `approved`, whereupon the start condition below is false for every + * record that will ever exist. A silent change to it would disarm the gate for + * every install, and no other test in this repo would notice. + */ + +type AnyRec = Record; + +const objects: AnyRec[] = (stack as any).objects ?? []; +const account = objects.find((o) => o.name === 'crm_account') as AnyRec | undefined; + +/** Evaluate a flow condition exactly as the engine does (cf. flow-record-change). */ +function conditionHolds(condition: unknown, vars: Record): boolean { + const h = makeFlowHarness({}, {}); + const engine = h.engine as unknown as { + evaluateCondition(c: unknown, v: Map): boolean; + }; + const expr = typeof condition === 'string' ? { dialect: 'cel', source: condition } : condition; + return engine.evaluateCondition(expr, new Map(Object.entries(vars))); +} + +const startCondition = (AccountApprovalFlow.nodes as Rec[]) + .find((n) => n.id === 'start')?.config?.condition; + +describe('account approval — the gate ships armed (REQ-0003)', () => { + it('registers under the name the docs and Flow Runs list it by', () => { + expect(AccountApprovalFlow.name).toBe('account_approval'); + expect(AccountApprovalFlow.type).toBe('record_change'); + // A gate exists to CONSTRAIN the submitter, so it is not evaluated under + // the submitter's own scope — and the verdict lands on a `readonly` column. + expect(AccountApprovalFlow.runAs).toBe('system'); + }); + + it('the approval column ships `pending`, which is what arms it', () => { + const field = account?.fields?.approval_status; + expect(field, 'crm_account.approval_status is missing').toBeTruthy(); + // FIELD-level default, not an option-level `default: true`: the latter only + // preselects in a UI form, so an API or flow insert would land `null` and + // never match the start condition below. + expect(field.defaultValue).toBe('pending'); + // Only the platform's own approval write may reach it. + expect(field.readonly).toBe(true); + }); + + it('the start condition fires for a new account and for nothing else', () => { + expect(conditionHolds(startCondition, { record: { approval_status: 'pending' } })).toBe(true); + expect(conditionHolds(startCondition, { record: { approval_status: 'approved' } })).toBe(false); + expect(conditionHolds(startCondition, { record: { approval_status: 'rejected' } })).toBe(false); + }); + + it('an account from before the column existed is not dragged in (TOTALITY)', () => { + // The `has()` guard is the whole point: strict CEL aborts on an absent key, + // and from 17.0.0-rc.2 an unevaluable condition FAILS THE RUN rather than + // skipping it. An account with no approval column is a pre-existing record + // and must read as "not gated". + expect(conditionHolds(startCondition, { record: { name: 'Acme Corp' } })).toBe(false); + }); + + it('setting the column to `approved` is a real off switch', () => { + // The documented way to run without account sign-off. If this ever stops + // being false, changing the default would no longer disarm the gate. + expect(conditionHolds(startCondition, { record: { approval_status: 'approved' } })).toBe(false); + }); + + it('the approval node writes the column the object declares', () => { + const node = (AccountApprovalFlow.nodes as Rec[]).find((n) => n.id === 'account_review'); + expect(node?.config?.approvalStatusField).toBe('approval_status'); + expect(account?.fields?.[node?.config?.approvalStatusField as string]).toBeTruthy(); + // Insert-only: `pending` appears at exactly one moment, so an afterUpdate + // twin would open a second request for the same decision on every edit made + // while the account sat pending. + const start = (AccountApprovalFlow.nodes as Rec[]).find((n) => n.id === 'start'); + expect(start?.config?.triggerType).toBe('record-after-create'); + }); +}); diff --git a/test/automation-docs-coverage.test.ts b/test/automation-docs-coverage.test.ts index 1188a5027..e4cb79b7f 100644 --- a/test/automation-docs-coverage.test.ts +++ b/test/automation-docs-coverage.test.ts @@ -208,6 +208,7 @@ 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': '線索轉化流程' }, + account_approval: { 'zh-Hans': '客户审批', 'zh-Hant': '客戶審批' }, lead_conversion_approval: { 'zh-Hans': '线索转化审批', 'zh-Hant': '線索轉化審批' }, quote_generation: { 'zh-Hans': '由商机生成报价', 'zh-Hant': '由商機產生報價' }, schedule_followup: { 'zh-Hans': '安排跟进', 'zh-Hant': '安排跟進' }, diff --git a/test/runtime-coverage.test.ts b/test/runtime-coverage.test.ts index d18bf63d9..ac355585e 100644 --- a/test/runtime-coverage.test.ts +++ b/test/runtime-coverage.test.ts @@ -90,6 +90,7 @@ const RUNTIME_TEST_FILES = [ // agree about what OFF means, and the evidence is only legible with all four // side by side. `lead_conversion_approval`'s own runtime evidence is its // start condition driven through the real engine there. + 'account-approval-gate.test.ts', 'lead-conversion-approval-gate.test.ts', ];