From f14d82d8251aaef69baa261b73d47e1e3a356007 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 16:41:46 +0000 Subject: [PATCH] feat(account): registration identifier, business profile, and account approval (REQ-0003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `crm_account` half of REQ-0002's steps 1, 3, 4 and 5, per REQ-0003: - `registration_number` — the counterparty's registered identity, with a country-neutral name and label (a field named after one country's registry could not be renamed by any install), searchable and indexed so the value is findable from global search and the lookup picker. - `commercial_capability` — the category that is meant to gate capability. Ships as metadata only; see below and the field's own docblock. - `incumbent_vendor`, `annual_purchasing_budget`, `payment_cycle` — the business-profile block, in a new `business_profile` fieldGroup so the layout stays derived (no `record:details` section is authored). - `approval_status` + `account-approval.flow.ts` — an `approval` node inside a `record_change` flow, the construct `opportunity-approval.flow.ts` already uses. Ships armed, because REQ-0003 acceptance 4 asks for that in as many words; the switch is the field's `defaultValue`, never the flow's `status`. All four locale packs, the automation-page flow table in all three doc locales, and the STATUS.md / README counts move with it. ⚠️ THE CAPABILITY GATE IS NOT IN THIS COMMIT, and cannot be authored as REQ-0003 line 128 specifies it. A `validations[]` predicate on `crm_opportunity` is evaluated against plain data — the opportunity's own stored columns — so it cannot read its account's category: relation traversal throws `No such key`, and `os.lookup` has no overload because `buildScope()` in `@objectstack/formula` never binds `ctx.api`. An unevaluable predicate rejects the write, so authoring it literally would refuse every opportunity write. Measurements are in the field's docblock and the PR body. ⚠️ `pnpm verify` is RED on the source token ratchet: business semantics is ~53,837 against a ~53,000 ceiling, over by ~837. Raising that ceiling needs a maintainer ruling (hotcrm#1928) and is deliberately not attempted here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W --- ...count-registration-profile-and-approval.md | 44 ++++ README.md | 4 +- content/docs/administration/automation.mdx | 3 +- .../administration/automation.zh-Hans.mdx | 3 +- .../administration/automation.zh-Hant.mdx | 3 +- docs/STATUS.md | 4 +- objectstack.composition.ts | 2 + src/sales/flows/account-approval.flow.ts | 178 +++++++++++++++ src/sales/flows/index.ts | 1 + src/sales/objects/account.object.ts | 204 +++++++++++++++++- src/sales/translations/en/objects.customer.ts | 21 ++ .../translations/es-ES/objects.customer.ts | 21 ++ .../translations/ja-JP/objects.customer.ts | 21 ++ .../translations/zh-CN/objects.customer.ts | 21 ++ test/account-approval-gate.test.ts | 94 ++++++++ test/automation-docs-coverage.test.ts | 1 + test/runtime-coverage.test.ts | 1 + 17 files changed, 618 insertions(+), 8 deletions(-) create mode 100644 .changeset/account-registration-profile-and-approval.md create mode 100644 src/sales/flows/account-approval.flow.ts create mode 100644 test/account-approval-gate.test.ts 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', ];