From 85e5dbd7edccc9a29be0af96fca77bfc56f9c20f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 09:51:35 +0000 Subject: [PATCH 1/9] feat(opportunity): qualification, the customer calendar, the deal narrative and approval on status change (REQ-0006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighteen new fields on `crm_opportunity`, two new derived `fieldGroups` sections, one new list view and one new approval flow — REQ-0006's B items, the `crm_opportunity` half of REQ-0002 steps 8 through 14. Qualification: will_bid, controllability, priority, deal_level, is_subcontracted and its note. The customer's own procurement calendar: customer_initiation_date, expected_tender_date, expected_signing_date and the two amounts — never folded into `close_date`, which is a single date and is OUR forecast close. Deal narrative: customer_background, project_background, risk_analysis, payment_terms. business_line sits BESIDE `type` and does not overload it. Signing entity and revenue-recognition type get no field at all, by REQ-0006 acceptance 5. Layout stays DERIVED: every field opts in with `group:` and no `record:details` section or `form.sections` entry is authored, so `pnpm validate` reports the data-entry fields as carrier-only. That reading is expected, not a defect, and the reason is recorded beside the block — the same verdict `crm_account`'s business-profile fields carry. The status-change gate is an `approval` node in a new record-change flow, and it SHIPS OFF: the switch is `status_change_approval_status`'s `defaultValue`, shipped `not_required`, so its start condition is false for every record that has ever existed and amount-tiered approval is bit-for-bit what it is today. Not the flow's `status` — `draft` still fires triggers and `obsolete` composes into a gate no install can turn on, both measured and recorded on the lead-conversion sibling. Because a record-change flow binds an AFTER hook, the rep writes a REQUEST (`requested_status`) and the approved decision writes `stage`, which is what acceptance 3's "the stage does not change until it is decided" requires. A transition gate in `opportunity.hook.ts` refuses a direct user move into a closed stage while the gate is armed; it reads the gate column input-first so the approving flow's own single-payload write passes. NOT LANDABLE AS IT STANDS: this exceeds the `src/sales` token ratchet — business semantics ~55,986 vs ~55,000 (over by ~986) and authored total ~101,395 vs ~100,000 (over by ~1,395). The tree is the measurement basis for that gap and awaits a maintainer ceiling ruling. Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W Co-authored-by: Claude --- ...unity-qualification-and-status-approval.md | 43 ++++ docs/STATUS.md | 4 +- objectstack.composition.ts | 2 + src/sales/flows/index.ts | 1 + ...opportunity-status-change-approval.flow.ts | 182 +++++++++++++++ src/sales/objects/opportunity.hook.ts | 39 +++- src/sales/objects/opportunity.object.ts | 212 ++++++++++++++++++ src/sales/views/opportunity.view.ts | 35 +++ 8 files changed, 515 insertions(+), 3 deletions(-) create mode 100644 .changeset/opportunity-qualification-and-status-approval.md create mode 100644 src/sales/flows/opportunity-status-change-approval.flow.ts diff --git a/.changeset/opportunity-qualification-and-status-approval.md b/.changeset/opportunity-qualification-and-status-approval.md new file mode 100644 index 000000000..1289eb27c --- /dev/null +++ b/.changeset/opportunity-qualification-and-status-approval.md @@ -0,0 +1,43 @@ +--- +'hotcrm': minor +--- + +An opportunity now records **whether it is worth pursuing**, **the customer's own +procurement calendar**, **the story behind the deal**, and **whether a won/lost call has +been signed off**. Eighteen new fields on `crm_opportunity`, two new derived sections, a +new list view, and a status-change approval gate that ships switched off (REQ-0006 — the +`crm_opportunity` half of REQ-0002's steps 8 through 14). + +**Qualification.** *Will Bid*, *Controllability*, *Priority*, *Deal Level* and +*Involves Subcontracting* (with a note), in their own **Qualification** section. This is +the triage vocabulary of any seller that cannot pursue every deal, and nothing on the +object carried it before: *Forecast Category* answers a different question — it is the +roll-up bucket, derived from the stage — and a judgement about whether to chase a deal is +not a forecast. The values are generic on purpose; a grading scale of your own is +configuration on top of them. + +**The customer's procurement calendar.** *Customer Initiation Date*, *Expected Tender +Date* and *Expected Signing Date*, with the amounts expected at tender and at signing. +*Close Date* is a single date and it is **our** forecast close — it could never carry +three distinct buyer-side events, which is what an outsourcing seller actually plans +against. A new **Tender This Quarter** list view windows the tender date alone, so "deals +whose tender lands this quarter" is one tab and touches the forecast close date nowhere. + +**Deal Narrative** — *Customer Background*, *Project Background*, *Risk Analysis* and +*Payment Terms*, in their own section. Everything a rep wanted to write about a deal used +to collapse into one *Description* field; split apart, each part is reviewable on its own. + +**Business Line**, beside *Opportunity Type* rather than inside it: type's values are a +relationship taxonomy (new business, renewal, expansion) that reporting already reads, and +a line-of-business classification is a different axis. Generic delivery-model values only. + +**Status Change Approval**, and the flow behind it. With the gate armed, declaring a deal +*Won* or *Lost* is a **request** — the rep sets *Requested Status* with the win/loss +reason, the request appears in the approval inbox HotCRM already mounts, and the stage +moves only when the request is approved. Irreversible transitions are the ones worth +gating, and the approval this app had could not see them: it keys on amount alone, so a +$10K deal reached *Closed Won* with no sign-off at all. **The gate ships off.** Its switch +is the field default on *Status Change Approval*, shipped as *Not Required*, so no deal is +ever born pending, the flow's start condition is false for every record, and amount-tiered +approval behaves exactly as it does today. An install arms the gate by changing that one +default; deals that predate the arming are untouched. diff --git a/docs/STATUS.md b/docs/STATUS.md index c95cd1e3d..13a54cc16 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -19,9 +19,9 @@ loader registers: ```text HotCRM v3.1.0 -Data: 18 Objects 343 Fields +Data: 18 Objects 361 Fields UI: 1 Apps 14 Views 8 Pages 5 Dashboards 10 Reports 31 Actions -Logic: 31 Flows +Logic: 32 Flows Security: 12 Positions 7 Permissions ``` diff --git a/objectstack.composition.ts b/objectstack.composition.ts index 6ffd0166a..09561d86d 100644 --- a/objectstack.composition.ts +++ b/objectstack.composition.ts @@ -102,6 +102,7 @@ import { AccountApprovalFlow, LeadConversionFlow, LeadConversionApprovalFlow, OpportunityApprovalFlow, OpportunityApprovalOnCreateFlow, + OpportunityStatusChangeApprovalFlow, OpportunityStagnationFlow, OpportunityWonAlertFlow, ScheduleFollowUpFlow, TaskDueReminderFlow, TaskUrgentAlertFlow, BillingHandoffClosedWonFlow, } from './src/sales/flows/index.js'; @@ -224,6 +225,7 @@ export const allFlows = [ DemoBootstrapFlow, OpportunityApprovalFlow, OpportunityApprovalOnCreateFlow, + OpportunityStatusChangeApprovalFlow, QuoteGenerationFlow, ContractRenewalFlow, CaseSlaMonitorFlow, diff --git a/src/sales/flows/index.ts b/src/sales/flows/index.ts index ad5e41196..296d1a2c4 100644 --- a/src/sales/flows/index.ts +++ b/src/sales/flows/index.ts @@ -22,6 +22,7 @@ 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'; +export { OpportunityStatusChangeApprovalFlow } from './opportunity-status-change-approval.flow'; export { OpportunityStagnationFlow } from './opportunity-stagnation.flow'; export { ForecastSnapshotFlow } from './forecast-snapshot.flow'; export { LeadAssignmentFlow } from './lead-assignment.flow'; diff --git a/src/sales/flows/opportunity-status-change-approval.flow.ts b/src/sales/flows/opportunity-status-change-approval.flow.ts new file mode 100644 index 000000000..cd1f7c90b --- /dev/null +++ b/src/sales/flows/opportunity-status-change-approval.flow.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2026 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; + +/** + * Opportunity Status Change Approval — sign-off before a deal is declared won + * or abandoned. + * + * REQ-0006 steps 13 + 14: 「发起赢单、弃单…状态变更操作,填写变更原因与说明」 then + * 「重要状态变更需审批,**通过后商机状态正式生效**」. Expressed as an **approval node** + * (`type: 'approval'`, ADR-0019), the construct `opportunity-approval.flow.ts` + * already uses — ⛔ there is no `workflow` metadata type and no standalone + * `ApprovalProcess` to author. + * + * ## Why the STAGE is not what the rep writes + * + * REQ-0006 acceptance 3: the request opens "and the stage does not change + * until it is decided". A `record_change` flow binds an AFTER hook, so by the + * time it runs the stage has already moved — an after-flow can lock the record + * but it cannot un-move it. So the rep writes the REQUEST + * (`requested_status`) and the approved decision writes the STAGE, which is + * also literally what the customer's own step 14 says ("通过后…正式生效"). + * `opportunity.hook.ts` refuses a direct user move into a closed stage while + * the gate is armed, and names this field in the refusal. + * + * ## ⚠️ This flow is INERT until an install arms the gate, and that is by design + * + * REQ-0006 acceptance 3 again: "with it off (the default), the existing + * amount-tiered behaviour of `opportunity-approval.flow.ts` is bit-for-bit + * what it is today." + * + * The switch is `crm_opportunity.status_change_approval_status`'s + * `defaultValue`, ⛔ not this flow's `status`. Shipped, that default is + * `not_required`, so no deal is ever born `pending`, the start condition below + * is false for every record that has ever existed, and this flow opens nothing + * — it costs one predicate evaluation per update. An install arms the gate by + * changing that one value to `'pending'`. + * + * ⛔ Do NOT reach for `status: 'draft'` as the off switch instead, and ⛔ not + * `'obsolete'` either. Both readings are measured and recorded in + * `lead-conversion-approval.flow.ts`'s docstring on the pinned + * `@objectstack/service-automation` 17.4.0: draft flows still fire their + * triggers, and `isFlowEnabled` composes `obsolete` with the installation's + * activation ledger into a gate no install can ever turn ON. ⛔ Do not + * re-derive it here. + * + * ⇒ A deal that predates the arming keeps `not_required` and never enters, + * so arming the gate invalidates no existing record. + * + * ## ⚠️ Its own verdict column, NOT `approval_status` + * + * `approval_status` belongs to the amount-tiered flow. Sharing it would make + * either gate's verdict erase the other's — and worse, re-arm the amount flow, + * whose entry condition fires on `approval_status == "not_required"`. + */ +export const OpportunityStatusChangeApprovalFlow: Flow = { + name: 'opportunity_status_change_approval', + label: 'Opportunity Status Change Approval', + description: + 'Sign-off a deal needs before it is declared won or lost. Inert unless the install arms the gate on crm_opportunity.status_change_approval_status.', + type: 'record_change', + status: 'active', + // REQ-0006 acceptance 4, and the same reading `opportunity_approval` records + // from its own measured failure: a gate that engages only for writers + // carrying a session is not a control — a `runAs: 'system'` sweep or import + // would simply bypass it. The verdict and the stage write below also land on + // columns the readonly strip protects, which only a platform write reaches. + runAs: 'system', + + variables: [ + { name: 'opportunityId', type: 'text', isInput: true, isOutput: false }, + ], + + nodes: [ + { + id: 'start', + type: 'start', + label: 'Start', + config: { + objectName: 'crm_opportunity', + triggerType: 'record-after-update', + // 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. The + // absent case must read as "not gated": a deal with no gate column at + // all predates the field and closes the way it always did. + // + // The `pending` half is the arming switch; the `requested_status` half + // is what makes this an UPDATE trigger without re-entering on every + // subsequent edit — once the approval node stamps a verdict the status + // is no longer `pending`, and `apply_status` / `clear_request` below + // leave the pair unable to satisfy this condition again. + condition: P`has(record.status_change_approval_status) && record.status_change_approval_status == "pending" + && has(record.requested_status) && record.requested_status != null && record.requested_status != ""`, + }, + }, + { + id: 'get_opportunity', + type: 'get_record', + label: 'Get Opportunity', + config: { objectName: 'crm_opportunity', filter: { id: '{record.id}' }, outputVariable: 'oppRecord' }, + }, + { + id: 'status_review', + type: 'approval', + label: 'Status Change Review', + config: { + // The customer's chain is 销售负责人 → 事业部负责人; the generic shape core + // ships is the manager bench this app already declares approvers + // against. A second tier, or a different position, is overlay + // configuration (REQ-0006 acceptance 5). + approvers: [{ type: 'position', value: 'sales_manager' }], + // Explicit though it is the schema default, exactly as both sibling + // approvals author it: approvers snapshot at request creation, so an + // empty bench would leave the request undecidable — and here that + // means a deal nobody can ever close. + onEmptyApprovers: 'admin_rescue', + behavior: 'first_response', + // `true`, matching `opportunity_approval` and NOT the lead gate: a deal + // awaiting a won/lost verdict must not keep moving underneath the + // approver. Its narrative fields are what the freeze hook leaves open + // anyway. + lockRecord: true, + approvalStatusField: 'status_change_approval_status', + }, + }, + { + // The approved decision is what moves the stage — "通过后正式生效". + // + // ⚠️ Both fields in ONE write, and that is load-bearing: the + // status-change guard in `opportunity.hook.ts` reads the gate column + // INPUT-FIRST, so this write presents itself as already-approved and the + // guard lets the stage through. Splitting it into two writes would have + // the guard refuse the flow's own write (ELEVATION IS NOT ANONYMITY — + // `runAs: 'system'` carries the triggering user, so `ctx.user?.id` is + // present in this run). + // + // `win_reason` / `loss_reason` are `requiredWhen` the stage is closed and + // are evaluated against the merged record, so the values the rep captured + // when requesting satisfy them here. + id: 'apply_status', + type: 'update_record', + label: 'Apply Requested Status', + config: { + objectName: 'crm_opportunity', + filter: { id: '{record.id}' }, + fields: { + stage: '{oppRecord.requested_status}', + status_change_approval_status: 'approved', + approved_date: '{NOW()}', + }, + }, + }, + { + // A rejected request is cleared, not left standing: the rep may capture a + // different one, and leaving `requested_status` set would re-open this + // request the moment an install re-armed the record. + id: 'clear_request', + type: 'update_record', + label: 'Clear Rejected Request', + config: { + objectName: 'crm_opportunity', + filter: { id: '{record.id}' }, + fields: { status_change_approval_status: 'rejected', requested_status: null }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'get_opportunity', type: 'default' }, + { id: 'e2', source: 'get_opportunity', target: 'status_review', type: 'default' }, + // Approval-node branch labels. + { id: 'e3', source: 'status_review', target: 'apply_status', type: 'default', label: 'approve' }, + { id: 'e4', source: 'status_review', target: 'clear_request', type: 'default', label: 'reject' }, + { id: 'e5', source: 'apply_status', target: 'end', type: 'default' }, + { id: 'e6', source: 'clear_request', target: 'end', type: 'default' }, + ], +}; diff --git a/src/sales/objects/opportunity.hook.ts b/src/sales/objects/opportunity.hook.ts index a20c2556f..5f33a6f30 100644 --- a/src/sales/objects/opportunity.hook.ts +++ b/src/sales/objects/opportunity.hook.ts @@ -89,7 +89,16 @@ const opportunityValidationHook: Hook = { // readonly declaration is what keeps a hand-edit out. Measured in // `test/readonly-write-semantics.test.ts` and pinned for these two columns // in `test/audit-stamp-readonly.test.ts`. - const APPROVAL_FIELDS = new Set(['approval_status', 'approved_date']); + // `status_change_approval_status` and `requested_status` join the + // allow-list for the same reason and by the same mechanism: the REQ-0006 + // gate's flow stamps its verdict and clears the request AFTER the deal has + // reached a closed stage, and elevation is not anonymity — the freeze + // guard below would judge those writes as user edits on a closed record + // and re-lock an in-flight approval. + const APPROVAL_FIELDS = new Set([ + 'approval_status', 'approved_date', + 'status_change_approval_status', 'requested_status', + ]); // Stage → forecast category. const STAGE_FORECAST: Record = { prospecting: 'pipeline', @@ -104,6 +113,34 @@ const opportunityValidationHook: Hook = { const { event, input } = ctx; const previous = ctx.previous; + // ─── Status-change gate (REQ-0006 steps 13-14) ────────────────────── + // + // A TRANSITION GATE, not an invariant (AGENTS.md metadata semantics rule + // 7): it refuses the next move, and every deal already sitting in a closed + // stage keeps it. Inert unless the install arms the gate — shipped, + // `status_change_approval_status` defaults to `not_required` and this + // condition is false for every record that has ever existed, which is what + // keeps REQ-0006 acceptance 3's "bit-for-bit what it is today" true. + // + // ⚠️ The gate value is read INPUT-FIRST, and that is load-bearing. The + // approving flow writes `stage` and `status_change_approval_status: + // 'approved'` in ONE payload; reading `previous` first would see `pending` + // and refuse the flow's own write. `runAs: 'system'` does not help — + // elevation is not anonymity, so `ctx.user?.id` is present in that run + // exactly as the APPROVAL_FIELDS note above records. + if (event === 'beforeUpdate' && previous && ctx.user?.id) { + const gate = (input.status_change_approval_status ?? previous.status_change_approval_status) as string | undefined; + const next = input.stage as string | undefined; + const closing = (next === 'closed_won' || next === 'closed_lost') && next !== previous.stage; + if (closing && gate === 'pending') { + throw refuse( + 'This deal\'s status change needs approval: set Requested Status (and the win/loss reason) instead. The stage takes effect when the request is approved.', + 'APPROVAL_REQUIRED', + 409, + ); + } + } + // Freeze closed opportunities — but guard ONLY genuine USER edits. A write // with no authenticated user (`ctx.user?.id` absent) is a system / seed / // backfill write and must pass: the seed's `close_date: daysAgo(15)` diff --git a/src/sales/objects/opportunity.object.ts b/src/sales/objects/opportunity.object.ts index 696b19c66..51319de2a 100644 --- a/src/sales/objects/opportunity.object.ts +++ b/src/sales/objects/opportunity.object.ts @@ -30,6 +30,8 @@ export const Opportunity = ObjectSchema.create({ { key: 'basic', label: 'Basic Information', icon: 'dollar-sign' }, { key: 'financials', label: 'Financials', icon: 'trending-up' }, { key: 'sales_process', label: 'Sales Process', icon: 'target' }, + { key: 'qualification', label: 'Qualification', icon: 'filter' }, + { key: 'narrative', label: 'Deal Narrative', icon: 'book-open' }, { key: 'classification', label: 'Classification', icon: 'tag' }, { key: 'campaign', label: 'Campaigns', icon: 'flag', collapse: 'collapsed' }, { key: 'notes', label: 'Notes & Next Steps', icon: 'file-text' }, @@ -94,6 +96,75 @@ export const Opportunity = ObjectSchema.create({ group: 'financials', }), + // ─── Qualification (REQ-0006 step 8) ─────────────────────────────── + // + // The standard triage vocabulary of a seller that cannot pursue every + // deal. `forecast_category` answers a different question — it is the + // forecast roll-up bucket, derived from `stage` — so none of this is + // folded into it. GENERIC values only: a customer's own grading scale is + // overlay configuration (REQ-0006 acceptance 5). + // + // ⚠️ `pnpm validate` reports the fields in this block, in the narrative + // block below, and the customer-side dates as "carrier-only — declared + // but nothing in this stack reads or displays it", and that reading is + // EXPECTED here rather than a defect to tidy away. The liveness + // diagnostic counts view columns, form sections, page bindings, flow + // nodes, formulas, hooks and actions as sites; it does NOT count + // `fieldGroups` membership, which is precisely the DERIVED layout + // REQ-0006 acceptance 1 asks for. ⛔ Do NOT answer the warning by + // authoring a `record:details` section — that is the escape hatch + // AGENTS.md reserves for a named customer demand, and it would trade + // acceptance 1 for a quieter log. `crm_account`'s business-profile block + // carries the same verdict on `main` for the same reason (#1948). + will_bid: Field.boolean({ + label: 'Will Bid', + description: 'Whether we intend to bid. Unset means the decision is open.', + group: 'qualification', + }), + + controllability: Field.select({ + label: 'Controllability', + group: 'qualification', + options: [ + { label: 'High', value: 'high' }, + { label: 'Medium', value: 'medium' }, + { label: 'Low', value: 'low' }, + ], + }), + + priority: Field.select({ + label: 'Priority', + group: 'qualification', + options: [ + { label: 'High', value: 'high' }, + { label: 'Medium', value: 'medium' }, + { label: 'Low', value: 'low' }, + ], + }), + + deal_level: Field.select({ + label: 'Deal Level', + group: 'qualification', + options: [ + { label: 'Strategic', value: 'strategic' }, + { label: 'Key', value: 'key' }, + { label: 'Standard', value: 'standard' }, + ], + }), + + // The FACT of subcontracting is generic and belongs in core; the + // customer's supplier list is not and is not proposed here. + is_subcontracted: Field.boolean({ + label: 'Involves Subcontracting', + defaultValue: false, + group: 'qualification', + }), + + subcontracting_note: Field.textarea({ + label: 'Subcontracting Note', + group: 'qualification', + }), + // Sales Process stage: Field.select({ label: 'Stage', @@ -145,6 +216,42 @@ export const Opportunity = ObjectSchema.create({ group: 'sales_process', }), + // ─── The customer's own procurement calendar (REQ-0006 step 8) ───── + // + // ⛔ Never folded into `close_date`: that is a single date and it is OUR + // expected close. These are three distinct BUYER-side events, and + // REQ-0006 acceptance 2 requires them to be reportable independently of + // the forecast close date — see the `tender_this_quarter` list view, + // which predicates on `expected_tender_date` alone. + customer_initiation_date: Field.date({ + label: 'Customer Initiation Date', + group: 'sales_process', + }), + + expected_tender_date: Field.date({ + label: 'Expected Tender Date', + group: 'sales_process', + }), + + expected_signing_date: Field.date({ + label: 'Expected Signing Date', + group: 'sales_process', + }), + + expected_tender_amount: Field.currency({ + label: 'Expected Tender Amount', + scale: 2, + min: 0, + group: 'sales_process', + }), + + expected_signing_amount: Field.currency({ + label: 'Expected Signing Amount', + scale: 2, + min: 0, + group: 'sales_process', + }), + // Additional Classification type: Field.select({ label: 'Opportunity Type', @@ -157,6 +264,22 @@ export const Opportunity = ObjectSchema.create({ ] }), + // ⛔ BESIDE `type`, never replacing or overloading it: `type`'s four + // values are a RELATIONSHIP taxonomy that `opportunity.hook.ts` and + // reporting read. Generic delivery-model values only — the customer's own + // line-of-business list is overlay (REQ-0006 acceptance 5). + business_line: Field.select({ + label: 'Business Line', + group: 'classification', + options: [ + { label: 'Product', value: 'product' }, + { label: 'Professional Services', value: 'services' }, + { label: 'Consulting', value: 'consulting' }, + { label: 'Support & Maintenance', value: 'support' }, + { label: 'Other', value: 'other' }, + ], + }), + lead_source: Field.select({ label: 'Lead Source', group: 'classification', @@ -210,6 +333,37 @@ export const Opportunity = ObjectSchema.create({ group: 'notes', }), + // ─── Deal narrative (REQ-0006 step 10) ───────────────────────────── + // + // The standard deal-review narrative, split out of `description` so each + // part is reviewable, templatable and (later) reportable on its own — + // ⛔ not more prose crammed into one markdown field. + // + // ⚠️ `payment_terms` is PROSE here and a select on `crm_quote` / + // `crm_contract`: what a rep records mid-pursuit is the shape of a term + // still being negotiated, not the net-terms ladder a signed document + // carries. ⛔ Do not "unify" it onto PAYMENT_TERMS_OPTIONS — that would + // force a half-agreed term into a closed vocabulary. + customer_background: Field.markdown({ + label: 'Customer Background', + group: 'narrative', + }), + + project_background: Field.markdown({ + label: 'Project Background', + group: 'narrative', + }), + + risk_analysis: Field.markdown({ + label: 'Risk Analysis', + group: 'narrative', + }), + + payment_terms: Field.textarea({ + label: 'Payment Terms', + group: 'narrative', + }), + // Flags is_private: Field.boolean({ label: 'Private', @@ -271,6 +425,64 @@ export const Opportunity = ObjectSchema.create({ readonly: true, }), + // ─── The status-change gate's switch AND its verdict column ──────── + // + // ⚠️ This `defaultValue` IS the switch, and shipped it is `not_required`, + // so the gate is OFF by default — REQ-0006 acceptance 3: "with it off + // (the default), the existing amount-tiered behaviour of + // `opportunity-approval.flow.ts` is bit-for-bit what it is today". No + // record is ever born `pending`, `opportunity_status_change_approval`'s + // start condition is false for every row that has ever existed, and + // nothing about the amount-tiered flow changes. An install ARMS the gate + // by changing this one value to `'pending'`. + // + // ⛔ Not the flow's `status`, for the reason + // `lead-conversion-approval.flow.ts` records and measured: `draft` still + // fires its triggers, and `obsolete` composes with the installation's + // activation ledger into a gate no install can ever turn ON. + // + // ⚠️ Deliberately NOT `approval_status`. That column is the amount-tiered + // flow's, and sharing it would make either gate's verdict erase the + // other's — and re-trigger the amount flow, whose entry condition keys on + // `approval_status == "not_required"`. + // + // `readonly: true` on the same audited grounds as `approval_status` + // (#1666): the only writers are the gate's own `runAs: 'system'` flow and + // the insert default, and both survive the readonly strip. + // What the rep is ASKING for, ⛔ never the stage itself: REQ-0006 + // acceptance 3 requires the stage not to move until the request is + // decided, and a `record_change` flow binds an AFTER hook — by the time it + // runs, a stage the rep wrote has already moved. So the rep writes the + // request here (with the win/loss reason), and the approved decision + // writes `stage` — the customer's own step 14, 「通过后商机状态正式生效」. + // + // Values ARE the two stage values, so the flow's approve branch can apply + // it with `stage: '{oppRecord.requested_status}'` and no mapping table can + // drift. Not readonly: this is the one half of the gate a person writes. + // Inert while the gate is off — nothing reads it unless the install arms + // `status_change_approval_status`. + requested_status: Field.select({ + label: 'Requested Status', + group: 'sales_process', + options: [ + { label: 'Won', value: 'closed_won' }, + { label: 'Lost', value: 'closed_lost' }, + ], + }), + + status_change_approval_status: Field.select({ + label: 'Status Change Approval', + group: 'sales_process', + readonly: true, + defaultValue: 'not_required', + options: [ + { label: 'Not Required', value: 'not_required', default: true }, + { label: 'Pending', value: 'pending', color: '#FFA500' }, + { label: 'Approved', value: 'approved', color: '#00AA00' }, + { label: 'Rejected', value: 'rejected', color: '#FF0000' }, + ], + }), + // ─── Win / Loss analysis ─────────────────────────────────────────── // // ⛔ The reason is captured AT CLOSE, and that is enforced, not requested. diff --git a/src/sales/views/opportunity.view.ts b/src/sales/views/opportunity.view.ts index 6556a0f00..e8ab59d46 100644 --- a/src/sales/views/opportunity.view.ts +++ b/src/sales/views/opportunity.view.ts @@ -288,6 +288,41 @@ export const OpportunityViews = defineView({ sort: [{ field: 'stage_entry_date', order: 'asc' }, { field: 'close_date', order: 'asc' }], }, + /** + * Tender expected this quarter — REQ-0006 acceptance 2 in one view. + * + * The point of the view is the predicate: it windows + * `expected_tender_date`, the CUSTOMER's procurement calendar, and touches + * `close_date` nowhere — which is what "reportable independently of + * `close_date`" means and what a single forecast date could never express. + * + * Same inclusive `{current_quarter_*}` bounds as `closing_this_quarter` + * below, exact for the same measured reason: both bounds are date macros + * resolved server-side, and `expected_tender_date` is a `Field.date()` + * stored as `YYYY-MM-DD` TEXT, so the inclusive upper bound drops no rows. + */ + tender_this_quarter: { + name: 'tender_this_quarter', + type: 'grid', + label: 'Tender This Quarter', + data: { provider: 'object', object: 'crm_opportunity' }, + columns: ['name', 'crm_account', 'expected_tender_date', 'expected_tender_amount', 'expected_signing_date', 'stage', 'owner_id'], + filter: [ + { field: 'stage', operator: 'not_in', value: ['closed_won', 'closed_lost'] }, + { field: 'expected_tender_date', operator: 'greater_than_or_equal', value: '{current_quarter_start}' }, + { field: 'expected_tender_date', operator: 'less_than_or_equal', value: '{current_quarter_end}' }, + ], + sort: [{ field: 'expected_tender_date', order: 'asc' }], + // Empty is the legitimate state on a fresh install: no seed row carries + // a customer-side tender date, so this tab opens on nothing until a rep + // records one. Unexplained, that reads as broken. + emptyState: { + title: 'No Tenders Expected This Quarter', + message: 'This tab lists open deals whose customer-side tender date falls inside the current quarter. Record Expected Tender Date on a deal to see it here.', + icon: 'calendar-clock', + }, + }, + /** Closing this quarter (commit + best_case) — sales-manager forecast */ closing_this_quarter: { name: 'closing_this_quarter', From c6e61d211e27987ce42cd0a49c2638a3b7b7095c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:31:36 +0000 Subject: [PATCH 2/9] feat(opportunity-page): the Details tab renders REQ-0006's qualification and narrative groups Since #1990 every Details section is a `{ group }` reference to one of crm_opportunity's fieldGroups, so a group the object declares renders on the record page only once the page names it. The two groups REQ-0006 adds are referenced right after `sales_process`, in the order the object declares them, and both keep `hideEmpty: false` on #1211's reasoning: every member is something the seller is expected to fill in, and no deal that predates REQ-0006 carries any of them, so the platform default would hide both sections on every existing deal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- src/sales/pages/opportunity_detail.page.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/sales/pages/opportunity_detail.page.ts b/src/sales/pages/opportunity_detail.page.ts index 6d276c2a5..4b6594896 100644 --- a/src/sales/pages/opportunity_detail.page.ts +++ b/src/sales/pages/opportunity_detail.page.ts @@ -161,12 +161,25 @@ export const OpportunityDetailPage: Page = { // `financials`: both members sit in the strip, the // derived list is empty, and the renderer draws nothing // for an empty list whatever `hideEmpty` says. + // + // `qualification` and `narrative` (REQ-0006) sit right + // after `sales_process`, the order the object declares + // them in, and both keep `hideEmpty: false` on the same + // reasoning: every member is a judgement or a piece of + // prose the seller is expected to write (bid / no-bid, + // controllability, priority, deal level, subcontracting; + // the four narrative fields), none sits in the strip, and + // no deal that predates REQ-0006 carries any of them — so + // without the key both sections vanish on every existing + // deal, which is exactly where a seller would fill them. sections: [ { group: 'basic' }, { group: 'financials' }, { group: 'classification', hideEmpty: false }, { group: 'campaign', hideEmpty: false }, { group: 'sales_process' }, + { group: 'qualification', hideEmpty: false }, + { group: 'narrative', hideEmpty: false }, { group: 'crm_forecast' }, { group: 'notes', hideEmpty: false }, ], From 92f90e28fc638fa60e98d81deddf0cbcdc5c2063 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:43:15 +0000 Subject: [PATCH 3/9] fix(opportunity): the status-change gate holds after a rejection, opens once per request, and refuses with a declared code Three defects in the REQ-0006 gate as built, each found by reading it against its siblings and pinned by a new runtime file: - A rejected deal was ungated. The hook refused a direct close only while the verdict read `pending`, and the flow re-opened only from `pending`, so one rejection disarmed the gate on that deal for good: the rep could close it straight away with no approval. `rejected` now refuses too (as `lead_automation` refuses a conversion in both states) and a new request from `rejected` opens a fresh approval. - The start condition tested the current value, not the transition. The approval node's own `pending` stamp is an update of the deal while the request is open, so it re-fired the flow and the second run died on the plugin's DUPLICATE_REQUEST guard. The condition now requires the request to be new on the write (`previous.requested_status` differs, guarded fail-closed), the idiom `billing_handoff_closed_won` records. - The refusal carried `APPROVAL_REQUIRED`, which is no member of the platform ErrorCode enum, so the platform would have demoted it and derived the code from the status. It is now `RECORD_LOCKED` / 409, the declared `locked` class the lead gate uses; the hand-maintained refusal site count moves 20 -> 21. test/opportunity-status-change-approval-gate.test.ts: the shipped-OFF default, the start condition over every OFF and ARMED shape, both approval branches, the write-path refusal envelope, and the user-less run reaching the approval node (with the runAs counter-proof). Registered in runtime-coverage's RUNTIME_TEST_FILES. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- ...opportunity-status-change-approval.flow.ts | 35 ++- src/sales/objects/opportunity.hook.ts | 15 +- ...tunity-status-change-approval-gate.test.ts | 285 ++++++++++++++++++ test/refusal-envelope.test.ts | 10 +- test/runtime-coverage.test.ts | 3 + 5 files changed, 332 insertions(+), 16 deletions(-) create mode 100644 test/opportunity-status-change-approval-gate.test.ts diff --git a/src/sales/flows/opportunity-status-change-approval.flow.ts b/src/sales/flows/opportunity-status-change-approval.flow.ts index cd1f7c90b..99a9f1dcb 100644 --- a/src/sales/flows/opportunity-status-change-approval.flow.ts +++ b/src/sales/flows/opportunity-status-change-approval.flow.ts @@ -88,13 +88,28 @@ export const OpportunityStatusChangeApprovalFlow: Flow = { // absent case must read as "not gated": a deal with no gate column at // all predates the field and closes the way it always did. // - // The `pending` half is the arming switch; the `requested_status` half - // is what makes this an UPDATE trigger without re-entering on every - // subsequent edit — once the approval node stamps a verdict the status - // is no longer `pending`, and `apply_status` / `clear_request` below - // leave the pair unable to satisfy this condition again. - condition: P`has(record.status_change_approval_status) && record.status_change_approval_status == "pending" - && has(record.requested_status) && record.requested_status != null && record.requested_status != ""`, + // Three halves: + // + // - the gate is ARMED: `pending` (born armed, no request decided yet) + // or `rejected` (the last request was refused). `rejected` re-opens + // on a new request because `opportunity.hook.ts` refuses a direct + // close in both states — if only `pending` entered here, a rejected + // deal could be neither requested nor closed, ever. + // - a request is present; + // - the request is NEW on this write. TRANSITION, not current value — + // the idiom `billing_handoff_closed_won` records for this object. + // Without it the approval node's own `pending` stamp (an update of + // this record, through `approvalStatusField`) re-fires this flow + // while the request is still open, and the second run dies on the + // plugin's DUPLICATE_REQUEST guard. `previous.*` is guarded + // FAIL-CLOSED: no visible prior value, no visible new request. + // + // `apply_status` leaves the gate `approved` (out of reach) and + // `clear_request` empties the request, so neither re-enters. + condition: P`has(record.status_change_approval_status) + && (record.status_change_approval_status == "pending" || record.status_change_approval_status == "rejected") + && has(record.requested_status) && record.requested_status != null && record.requested_status != "" + && has(previous.requested_status) && previous.requested_status != record.requested_status`, }, }, { @@ -155,9 +170,9 @@ export const OpportunityStatusChangeApprovalFlow: Flow = { }, }, { - // A rejected request is cleared, not left standing: the rep may capture a - // different one, and leaving `requested_status` set would re-open this - // request the moment an install re-armed the record. + // A rejected request is cleared, not left standing, and the verdict stays + // `rejected` so the hook keeps refusing a direct close: the way on is a + // new request, which the start condition opens as a fresh approval. id: 'clear_request', type: 'update_record', label: 'Clear Rejected Request', diff --git a/src/sales/objects/opportunity.hook.ts b/src/sales/objects/opportunity.hook.ts index 3ce14fa94..54c78eeb0 100644 --- a/src/sales/objects/opportunity.hook.ts +++ b/src/sales/objects/opportunity.hook.ts @@ -128,14 +128,25 @@ const opportunityValidationHook: Hook = { // and refuse the flow's own write. `runAs: 'system'` does not help — // elevation is not anonymity, so `ctx.user?.id` is present in that run // exactly as the APPROVAL_FIELDS note above records. + // + // ⚠️ `rejected` refuses too, exactly as `lead_automation` refuses a + // conversion in both `pending` and `rejected`. An approver's "no" is not a + // release: if only `pending` refused, a single rejection would disarm the + // gate on that deal for good, and the rep could then close it directly with + // no approval at all. From `rejected` the way forward is a NEW request — the + // flow's start condition re-opens on it — never a direct stage write. + // + // RECORD_LOCKED / 409 (`REFUSAL_CODES.locked`): while the gate holds, the + // deal's own state freezes `stage` against a move into a closed stage — the + // same class the lead sibling answers with. if (event === 'beforeUpdate' && previous && ctx.user?.id) { const gate = (input.status_change_approval_status ?? previous.status_change_approval_status) as string | undefined; const next = input.stage as string | undefined; const closing = (next === 'closed_won' || next === 'closed_lost') && next !== previous.stage; - if (closing && gate === 'pending') { + if (closing && (gate === 'pending' || gate === 'rejected')) { throw refuse( 'This deal\'s status change needs approval: set Requested Status (and the win/loss reason) instead. The stage takes effect when the request is approved.', - 'APPROVAL_REQUIRED', + 'RECORD_LOCKED', 409, ); } diff --git a/test/opportunity-status-change-approval-gate.test.ts b/test/opportunity-status-change-approval-gate.test.ts new file mode 100644 index 000000000..14721f1e1 --- /dev/null +++ b/test/opportunity-status-change-approval-gate.test.ts @@ -0,0 +1,285 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import stack from '../objectstack.config'; +import opportunityHooks from '../src/sales/objects/opportunity.hook'; +import { OpportunityApprovalFlow } from '../src/sales/flows/opportunity-approval.flow'; +import { OpportunityStatusChangeApprovalFlow } from '../src/sales/flows/opportunity-status-change-approval.flow'; +import { hookNamed, makeCtx, makeHarness } from './helpers/hook-harness'; +import { makeFlowHarness, type Rec } from './helpers/flow-harness'; + +/** + * The status-change gate (REQ-0006 steps 13-14), and the thing that has to + * hold about it first: **it is OFF in the box** (acceptance 3, "with it off + * (the default), the existing amount-tiered behaviour of + * `opportunity-approval.flow.ts` is bit-for-bit what it is today"). + * + * The gate is authored across three surfaces that must agree — + * `crm_opportunity.status_change_approval_status`'s default (the switch), + * `opportunity_status_change_approval`'s start condition, and the + * `beforeUpdate` refusal in `opportunity_lifecycle`. One file, for the reason + * the lead sibling gives: a gate that is off on two surfaces and on for the + * third is worse than one that is simply on. + * + * ⚠️ Armed, the gate has to hold in BOTH non-approved states. `rejected` is + * not a release: a deal an approver refused must still not be closable by a + * direct stage write, and it must still be able to ask again. Both halves are + * pinned below, because each was missing once. + */ + +type AnyRec = Record; + +const objects: AnyRec[] = (stack as any).objects ?? []; +const opportunity = objects.find((o) => o.name === 'crm_opportunity') 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 startNode = (OpportunityStatusChangeApprovalFlow.nodes as Rec[]).find((n) => n.id === 'start'); +const startCondition = startNode?.config?.condition; +const reviewNode = (OpportunityStatusChangeApprovalFlow.nodes as Rec[]).find((n) => n.type === 'approval'); + +/** A request arriving on this write: `previous` had none, `record` has one. */ +const requesting = (gate: AnyRec, requested = 'closed_won') => ({ + record: { id: 'o1', stage: 'negotiation', ...gate, requested_status: requested }, + previous: { id: 'o1', stage: 'negotiation', ...gate, requested_status: null }, +}); + +/** The verdict column in every shape a real record can present it OFF in. */ +const OFF_SHAPES: [string, AnyRec][] = [ + ['the shipped default', { status_change_approval_status: 'not_required' }], + ['an approved deal', { status_change_approval_status: 'approved' }], + ['a deal older than the column', {}], + ['an explicit null', { status_change_approval_status: null }], +]; +/** …and the two shapes an ARMED gate holds in. */ +const ARMED_SHAPES: [string, AnyRec][] = [ + ['awaiting a first request', { status_change_approval_status: 'pending' }], + ['refused by an approver', { status_change_approval_status: 'rejected' }], +]; + +describe('the gate ships OFF — the field default is the switch', () => { + it('found the opportunity object and both gate columns', () => { + expect(opportunity, 'crm_opportunity missing from the stack').toBeTruthy(); + expect(opportunity?.fields?.status_change_approval_status, 'the verdict column is gone').toBeTruthy(); + expect(opportunity?.fields?.requested_status, 'the request column is gone').toBeTruthy(); + }); + + it('defaults to not_required, at FIELD level, and only the platform writes it', () => { + const f = opportunity!.fields.status_change_approval_status as AnyRec; + // Field-level: an option-level `default: true` only preselects in a UI form, + // so an API insert would land a null. `'pending'` here would arm the gate + // for every new deal of every install — the one value this pin exists for. + expect(f.defaultValue).toBe('not_required'); + expect(f.readonly, 'a user-writable verdict is not a verdict').toBe(true); + // The request is the half a person writes. + expect(opportunity!.fields.requested_status.readonly).not.toBe(true); + }); + + it('is a transition gate, not an invariant: no validation re-states it', () => { + // AGENTS.md metadata semantics rule 7: a `validations[]` copy would be + // evaluated against `{...previous, ...data}` and judge every deal already + // closed before the gate was armed. + const rules = (opportunity?.validations ?? []) as AnyRec[]; + const restating = rules.filter((r) => /status_change_approval_status|requested_status/.test(JSON.stringify(r))); + expect(restating.map((r) => r.name)).toEqual([]); + }); + + it('has its own verdict column — the amount-tiered flow keeps `approval_status` to itself', () => { + // Sharing the column would let either gate's verdict erase the other's and + // re-trigger the amount flow, whose entry keys on `approval_status`. + expect(reviewNode?.config?.approvalStatusField).toBe('status_change_approval_status'); + const amountNodes = (OpportunityApprovalFlow.nodes as Rec[]).filter((n) => n.type === 'approval'); + expect(amountNodes.length).toBeGreaterThan(0); + for (const n of amountNodes) expect(n.config?.approvalStatusField).toBe('approval_status'); + }); +}); + +describe('opportunity_status_change_approval — start condition', () => { + it('is an update trigger on crm_opportunity, elevated, and locks the deal while it waits', () => { + expect(OpportunityStatusChangeApprovalFlow.name).toBe('opportunity_status_change_approval'); + expect(OpportunityStatusChangeApprovalFlow.type).toBe('record_change'); + expect(OpportunityStatusChangeApprovalFlow.runAs).toBe('system'); + expect(startNode?.config?.objectName).toBe('crm_opportunity'); + expect(startNode?.config?.triggerType).toBe('record-after-update'); + expect(reviewNode?.config?.lockRecord).toBe(true); + }); + + it.each(OFF_SHAPES)('opens NO approval request for %s, even with a request on the write', (_l, shape) => { + expect(conditionHolds(startCondition, requesting(shape))).toBe(false); + }); + + it.each(ARMED_SHAPES)('opens a request for a deal %s when the rep asks for a status', (_l, shape) => { + expect(conditionHolds(startCondition, requesting(shape))).toBe(true); + expect(conditionHolds(startCondition, requesting(shape, 'closed_lost'))).toBe(true); + }); + + it('does not re-open on the approval node\'s own `pending` stamp (TRANSITION, not current value)', () => { + // `approvalStatusField` stamps `pending` through an update of this record + // while the request is still open. The request is unchanged on that write, + // so it is not a new request — testing the current value alone re-fired + // this flow there, and the second run died on DUPLICATE_REQUEST. + const gate = { status_change_approval_status: 'pending', requested_status: 'closed_won' }; + expect(conditionHolds(startCondition, { + record: { id: 'o1', stage: 'negotiation', ...gate }, + previous: { id: 'o1', stage: 'negotiation', ...gate }, + })).toBe(false); + }); + + it('opens nothing on an armed deal until a request is actually written', () => { + const gate = { status_change_approval_status: 'pending', requested_status: null }; + expect(conditionHolds(startCondition, { + record: { id: 'o1', stage: 'negotiation', description: 'edited', ...gate }, + previous: { id: 'o1', stage: 'negotiation', ...gate }, + })).toBe(false); + }); + + it('claims no new request when the prior row is invisible (fail-closed, the bulk-update shape)', () => { + expect(conditionHolds(startCondition, { + record: { id: 'o1', status_change_approval_status: 'pending', requested_status: 'closed_won' }, + previous: null, + })).toBe(false); + }); + + it('is TOTAL — a deal with neither column reads as "not gated", never as an abort', () => { + expect(conditionHolds(startCondition, { record: { id: 'o1', name: 'Acme' }, previous: { id: 'o1' } })).toBe(false); + }); +}); + +describe('the approval branches leave the gate unable to re-enter', () => { + const node = (id: string) => (OpportunityStatusChangeApprovalFlow.nodes as Rec[]).find((n) => n.id === id); + const edge = (label: string) => (OpportunityStatusChangeApprovalFlow.edges as Rec[]) + .find((e) => e.source === reviewNode?.id && e.label === label); + + it('approve applies the REQUEST to the stage and stamps `approved` in the same write', () => { + expect(edge('approve')?.target).toBe('apply_status'); + const fields = node('apply_status')?.config?.fields as AnyRec; + expect(fields.stage).toBe('{oppRecord.requested_status}'); + expect(fields.status_change_approval_status).toBe('approved'); + }); + + it('reject clears the request and keeps the verdict `rejected` — still armed, free to ask again', () => { + expect(edge('reject')?.target).toBe('clear_request'); + const fields = node('clear_request')?.config?.fields as AnyRec; + expect(fields).toEqual({ status_change_approval_status: 'rejected', requested_status: null }); + // …and from there a NEW request opens a fresh approval. + expect(conditionHolds(startCondition, requesting({ status_change_approval_status: 'rejected' }))).toBe(true); + }); +}); + +describe('the write path — opportunity_lifecycle refuses a direct close while the gate holds', () => { + const guard = hookNamed(opportunityHooks, 'opportunity_lifecycle'); + + // `null` = a system write. Not `undefined`: that would select the default. + const write = (input: AnyRec, previous: AnyRec, user: { id: string } | null = { id: 'usr_1' }) => + guard.handler( + makeCtx({ + event: 'beforeUpdate', + input: { id: 'opp_1', ...input }, + previous: { + id: 'opp_1', name: 'Big Deal', stage: 'negotiation', amount: 100, ...previous, + }, + user: user ?? undefined, + api: makeHarness().api, + }), + ); + + const refusal = (input: AnyRec, previous: AnyRec) => + write(input, previous).then(() => null, (e: AnyRec) => e); + + it.each(ARMED_SHAPES)('refuses a deal %s moving straight to closed_won or closed_lost', async (_l, shape) => { + for (const stage of ['closed_won', 'closed_lost']) { + const err = await refusal({ stage, win_reason: 'best_fit', loss_reason: 'price' }, shape); + expect(err, `the gate let a direct ${stage} through`).toBeTruthy(); + // ADR-0112 envelope: the CODE and the STATUS are the contract. A bare + // `toThrow()` would pass on an unenveloped Error. + expect(err.code).toBe('RECORD_LOCKED'); + expect(err.status).toBe(409); + // The sentence names the way forward, so the rep is not left guessing. + expect(String(err.message)).toContain('Requested Status'); + } + }); + + it.each(OFF_SHAPES)('lets a direct close through for %s — today\'s behaviour, unchanged', async (_l, shape) => { + await expect(write({ stage: 'closed_won', win_reason: 'best_fit' }, shape)).resolves.toBeUndefined(); + }); + + it('lets the approving flow\'s own write through: the verdict is read INPUT-FIRST', async () => { + // `apply_status` writes the stage and `approved` in one payload, under the + // triggering user (elevation is not anonymity). Reading `previous` first + // would see `pending` and refuse the flow's own decision. + await expect(write( + { stage: 'closed_won', status_change_approval_status: 'approved' }, + { status_change_approval_status: 'pending', requested_status: 'closed_won', win_reason: 'best_fit' }, + )).resolves.toBeUndefined(); + }); + + it('stops one act, not the work: an armed deal stays editable short of closing', async () => { + await expect(write( + { stage: 'proposal', requested_status: 'closed_lost', loss_reason: 'price' }, + { status_change_approval_status: 'pending' }, + )).resolves.toBeUndefined(); + }); + + it('judges only USER writes — a system write (no user) carries no session to refuse', async () => { + // This repo's system-write signal (AGENTS.md: `ctx.user` is absent on + // system and seed writes), the same boundary the closed-deal freeze below + // it draws. Acceptance 4 is the FLOW's half: a request written with no + // session still opens an approval — pinned in the next block. + await expect(write({ stage: 'closed_won', win_reason: 'best_fit' }, + { status_change_approval_status: 'pending' }, null)).resolves.toBeUndefined(); + }); +}); + +/** + * REQ-0006 acceptance 4: "A gate opened by a writer with no session still + * engages — the measured failure recorded in `opportunity-approval.flow.ts`'s + * docstring does not recur." That failure was a user-less trigger dying at the + * flow's first data node, leaving the deal ungated. So the run is fired here + * with NO trigger user, and the counter-proof strips `runAs` back to its + * schema default — direction decided before running it: the stripped run must + * fail at `get_opportunity` with the engine's `[runAs]` refusal. + */ +describe('acceptance 4 — a request written with no session still reaches the approval', () => { + type Run = { success: boolean; error?: string; summary?: { nodes?: Rec[] } }; + const NAME = 'opportunity_status_change_approval'; + + const deal = { + id: 'o1', name: 'Acme Renewal', amount: 50_000, stage: 'negotiation', owner_id: 'rep1', + status_change_approval_status: 'pending', requested_status: 'closed_won', win_reason: 'best_fit', + }; + + async function fire(flow: Rec) { + const h = makeFlowHarness({ [NAME]: flow as never }, { crm_opportunity: [{ ...deal }] }); + return (await (h.engine as unknown as { execute(n: string, c: Rec): Promise }) + .execute(NAME, { params: {}, event: 'record_change', record: deal, previous: { ...deal, requested_status: null } })) as Run; + } + const nodeStatus = (r: Run, id: string) => (r.summary?.nodes ?? []).find((n) => n.nodeId === id)?.status; + + it('passes `get_opportunity` and stops only at the approval node', async () => { + const result = await fire(OpportunityStatusChangeApprovalFlow as unknown as Rec); + expect(String(result.error ?? ''), 'the request is bypassing approval').not.toContain('[runAs]'); + expect(nodeStatus(result, 'get_opportunity')).toBe('success'); + // Harness-only stop: the approval executor ships in + // @objectstack/plugin-approvals, which this in-memory harness does not + // install — the same stop `flow-record-change.test.ts` records for + // `opportunity_approval`. Not a runAs failure. + expect(nodeStatus(result, 'status_review')).toBe('failure'); + expect(String(result.error)).toContain("No executor registered for node type 'approval'"); + }); + + it('…and never gets that far once `runAs` is dropped', async () => { + const { runAs: _dropped, ...withoutRunAs } = OpportunityStatusChangeApprovalFlow as unknown as Rec; + const result = await fire(withoutRunAs); + expect(String(result.error)).toContain('[runAs] refusing a data operation'); + expect(nodeStatus(result, 'get_opportunity')).toBe('failure'); + expect(nodeStatus(result, 'status_review'), 'approval was never requested').toBeUndefined(); + }); +}); diff --git a/test/refusal-envelope.test.ts b/test/refusal-envelope.test.ts index 2f93b85da..c0a798542 100644 --- a/test/refusal-envelope.test.ts +++ b/test/refusal-envelope.test.ts @@ -137,10 +137,12 @@ describe('every refusal names a code the platform will echo (#1075)', () => { it('found every swept call site', () => { // 18 until REQ-0003 added the account capability gate in // `opportunity.hook.ts`; 19 until #549 added the activated-contract - // refusal to `account_protection`. The number is hand-maintained on - // purpose: a new refusal has to be a deliberate edit here, so a guard that - // quietly stopped being swept cannot hide behind a count that follows it. - expect(sites).toHaveLength(20); + // refusal to `account_protection`; 20 until REQ-0006 added the + // status-change gate to `opportunity_lifecycle`. The number is + // hand-maintained on purpose: a new refusal has to be a deliberate edit + // here, so a guard that quietly stopped being swept cannot hide behind a + // count that follows it. + expect(sites).toHaveLength(21); }); it('uses only members of the platform ErrorCode enum', () => { diff --git a/test/runtime-coverage.test.ts b/test/runtime-coverage.test.ts index 97f932af9..5a4dd609e 100644 --- a/test/runtime-coverage.test.ts +++ b/test/runtime-coverage.test.ts @@ -93,6 +93,9 @@ const RUNTIME_TEST_FILES = [ 'account-approval-gate.test.ts', 'opportunity-account-capability-gate.test.ts', 'lead-conversion-approval-gate.test.ts', + // REQ-0006 — the status-change gate: its start condition, both approval + // branches, the `opportunity_lifecycle` refusal and the user-less run. + 'opportunity-status-change-approval-gate.test.ts', // #1828 — the `line_number` assigner on both line items. Same precedent: its // evidence is an ENGINE fact (a hook-written readonly key survives the // non-system strip, a caller-supplied one does not) measured on a real From 6dda55beea5036513a0db5e717c94237918852be Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:46:34 +0000 Subject: [PATCH 4/9] feat(i18n): REQ-0006's opportunity fields, view and groups in all four locale packs; the form offers the customer calendar The four `objects.pipeline.ts` packs (en, zh-CN, es-ES, ja-JP) gain every REQ-0006 field label and option label, `will_bid`'s help text, the `tender_this_quarter` view label and empty state, and the `qualification` and `narrative` section headings. They were left out of the measurement build on purpose until the ceiling ruling settled the field roster (#1951 ruling A); `pnpm lint:i18n-gate` now reads 0 `i18n/missing-*` issues. opportunity.view.ts: the `forecast` form section names the customer's procurement calendar (initiation, tender and signing dates and amounts). `tender_this_quarter` filters on `expected_tender_date`, and a filtered field that no form offers is a list view that can never match a deal a rep created through the UI (`test/metadata-references.test.ts`). The reason a `{ group: 'sales_process' }` reference cannot stand in is written beside the fields (AGENTS.md ladder rung 3). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- src/sales/translations/en/objects.pipeline.ts | 41 +++++++++++++++++ .../translations/es-ES/objects.pipeline.ts | 44 +++++++++++++++++++ .../translations/ja-JP/objects.pipeline.ts | 37 ++++++++++++++++ .../translations/zh-CN/objects.pipeline.ts | 34 ++++++++++++++ src/sales/views/opportunity.view.ts | 18 ++++++++ 5 files changed, 174 insertions(+) diff --git a/src/sales/translations/en/objects.pipeline.ts b/src/sales/translations/en/objects.pipeline.ts index fc433c565..38f7a1cf7 100644 --- a/src/sales/translations/en/objects.pipeline.ts +++ b/src/sales/translations/en/objects.pipeline.ts @@ -288,8 +288,47 @@ export const pipeline: Record = { label: 'Loss/Win Details', help: 'Free-text context behind the win or loss reason.', }, + // REQ-0006 — qualification, the customer's procurement calendar, the + // deal narrative, the business line and the status-change gate. + will_bid: { + label: 'Will Bid', + help: 'Whether we intend to bid. Unset means the decision is open.', + }, + controllability: { label: 'Controllability', options: { high: 'High', medium: 'Medium', low: 'Low' } }, + priority: { label: 'Priority', options: { high: 'High', medium: 'Medium', low: 'Low' } }, + deal_level: { label: 'Deal Level', options: { strategic: 'Strategic', key: 'Key', standard: 'Standard' } }, + is_subcontracted: { label: 'Involves Subcontracting' }, + subcontracting_note: { label: 'Subcontracting Note' }, + customer_initiation_date: { label: 'Customer Initiation Date' }, + expected_tender_date: { label: 'Expected Tender Date' }, + expected_signing_date: { label: 'Expected Signing Date' }, + expected_tender_amount: { label: 'Expected Tender Amount' }, + expected_signing_amount: { label: 'Expected Signing Amount' }, + business_line: { + label: 'Business Line', + options: { + product: 'Product', services: 'Professional Services', consulting: 'Consulting', + support: 'Support & Maintenance', other: 'Other', + }, + }, + customer_background: { label: 'Customer Background' }, + project_background: { label: 'Project Background' }, + risk_analysis: { label: 'Risk Analysis' }, + payment_terms: { label: 'Payment Terms' }, + requested_status: { label: 'Requested Status', options: { closed_won: 'Won', closed_lost: 'Lost' } }, + status_change_approval_status: { + label: 'Status Change Approval', + options: { not_required: 'Not Required', pending: 'Pending', approved: 'Approved', rejected: 'Rejected' }, + }, }, _views: { + tender_this_quarter: { + label: 'Tender This Quarter', + emptyState: { + title: 'No Tenders Expected This Quarter', + message: 'This tab lists open deals whose customer-side tender date falls inside the current quarter. Record Expected Tender Date on a deal to see it here.', + }, + }, open_opportunities: { label: 'Open Deals' }, all_opportunities: { label: 'All Opportunities' }, pipeline_kanban: { label: 'Sales Pipeline' }, @@ -313,6 +352,8 @@ export const pipeline: Record = { classification: { label: 'Classification' }, campaign: { label: 'Campaigns' }, notes: { label: 'Notes & Next Steps' }, + qualification: { label: 'Qualification' }, + narrative: { label: 'Deal Narrative' }, crm_forecast: { label: 'Forecast & Metrics' }, // The detail page's sections reference the groups above (#1452), so it // has no section names of its own. diff --git a/src/sales/translations/es-ES/objects.pipeline.ts b/src/sales/translations/es-ES/objects.pipeline.ts index e7c990071..5f33aa516 100644 --- a/src/sales/translations/es-ES/objects.pipeline.ts +++ b/src/sales/translations/es-ES/objects.pipeline.ts @@ -323,8 +323,50 @@ export const pipeline: Record = { label: 'Detalles de Ganancia/Pérdida', help: 'Contexto en texto libre detrás del motivo de ganancia o pérdida.', }, + // REQ-0006 — calificación, calendario de compra del cliente, narrativa + // del negocio, línea de negocio y aprobación del cambio de estado. + will_bid: { + label: 'Presentaremos Oferta', + help: 'Si tenemos intención de presentar oferta. Sin valor significa que la decisión sigue abierta.', + }, + controllability: { label: 'Controlabilidad', options: { high: 'Alta', medium: 'Media', low: 'Baja' } }, + priority: { label: 'Prioridad', options: { high: 'Alta', medium: 'Media', low: 'Baja' } }, + deal_level: { label: 'Nivel del Negocio', options: { strategic: 'Estratégico', key: 'Clave', standard: 'Estándar' } }, + is_subcontracted: { label: 'Incluye Subcontratación' }, + subcontracting_note: { label: 'Nota de Subcontratación' }, + customer_initiation_date: { label: 'Fecha de Inicio del Proyecto (Cliente)' }, + expected_tender_date: { label: 'Fecha Prevista de Licitación' }, + expected_signing_date: { label: 'Fecha Prevista de Firma' }, + expected_tender_amount: { label: 'Importe Previsto de Licitación' }, + expected_signing_amount: { label: 'Importe Previsto de Firma' }, + business_line: { + label: 'Línea de Negocio', + options: { + product: 'Producto', services: 'Servicios Profesionales', consulting: 'Consultoría', + support: 'Soporte y Mantenimiento', other: 'Otro', + }, + }, + customer_background: { label: 'Antecedentes del Cliente' }, + project_background: { label: 'Antecedentes del Proyecto' }, + risk_analysis: { label: 'Análisis de Riesgos' }, + payment_terms: { label: 'Condiciones de Pago' }, + requested_status: { label: 'Estado Solicitado', options: { closed_won: 'Ganada', closed_lost: 'Perdida' } }, + status_change_approval_status: { + label: 'Aprobación del Cambio de Estado', + options: { + not_required: 'No Requerida', pending: 'Pendiente', + approved: 'Aprobada', rejected: 'Rechazada', + }, + }, }, _views: { + tender_this_quarter: { + label: 'Licitaciones de Este Trimestre', + emptyState: { + title: 'No hay licitaciones previstas este trimestre', + message: 'Esta pestaña muestra los negocios abiertos cuya fecha de licitación del cliente cae dentro del trimestre actual. Registra la Fecha Prevista de Licitación en un negocio para verlo aquí.', + }, + }, open_opportunities: { label: 'Oportunidades Abiertas' }, all_opportunities: { label: 'Todas las Oportunidades' }, pipeline_kanban: { label: 'Pipeline de Ventas' }, @@ -357,6 +399,8 @@ export const pipeline: Record = { classification: { label: 'Clasificación' }, campaign: { label: 'Campañas' }, notes: { label: 'Notas y Próximos Pasos' }, + qualification: { label: 'Calificación' }, + narrative: { label: 'Narrativa del Negocio' }, // Nombres de sección del formulario en opportunity.view.ts (#1100) overview: { label: 'Resumen' }, forecast: { label: 'Previsión' }, diff --git a/src/sales/translations/ja-JP/objects.pipeline.ts b/src/sales/translations/ja-JP/objects.pipeline.ts index 0638d4f47..7ac57f034 100644 --- a/src/sales/translations/ja-JP/objects.pipeline.ts +++ b/src/sales/translations/ja-JP/objects.pipeline.ts @@ -287,8 +287,43 @@ export const pipeline: Record = { }, }, loss_details: { label: '受注・失注の詳細', help: '受注・失注理由の補足説明(自由記述)。' }, + // REQ-0006 — 適格判定、顧客側の調達スケジュール、商談の経緯、事業区分、ステータス変更承認。 + will_bid: { label: '入札予定', help: '入札する意向があるかどうか。未設定は判断が保留中であることを示します。' }, + controllability: { label: 'コントロール度', options: { high: '高', medium: '中', low: '低' } }, + priority: { label: '優先度', options: { high: '高', medium: '中', low: '低' } }, + deal_level: { label: '商談ランク', options: { strategic: '戦略', key: '重点', standard: '標準' } }, + is_subcontracted: { label: '外注あり' }, + subcontracting_note: { label: '外注メモ' }, + customer_initiation_date: { label: '顧客の案件化日' }, + expected_tender_date: { label: '入札予定日' }, + expected_signing_date: { label: '契約締結予定日' }, + expected_tender_amount: { label: '入札予定金額' }, + expected_signing_amount: { label: '契約予定金額' }, + business_line: { + label: '事業区分', + options: { + product: '製品', services: 'プロフェッショナルサービス', consulting: 'コンサルティング', + support: '保守・サポート', other: 'その他', + }, + }, + customer_background: { label: '顧客概要' }, + project_background: { label: 'プロジェクト背景' }, + risk_analysis: { label: 'リスク分析' }, + payment_terms: { label: '支払条件' }, + requested_status: { label: '申請ステータス', options: { closed_won: '受注', closed_lost: '失注' } }, + status_change_approval_status: { + label: 'ステータス変更承認', + options: { not_required: '承認不要', pending: '承認待ち', approved: '承認済み', rejected: '却下' }, + }, }, _views: { + tender_this_quarter: { + label: '今四半期に入札予定', + emptyState: { + title: '今四半期に入札予定の商談はありません', + message: 'このタブには、顧客側の入札予定日が今四半期内にあるオープン商談が表示されます。商談に「入札予定日」を入力すると、ここに表示されます。', + }, + }, open_opportunities: { label: '進行中の商談' }, all_opportunities: { label: '全商談' }, pipeline_kanban: { label: 'セールスパイプライン' }, @@ -315,6 +350,8 @@ export const pipeline: Record = { classification: { label: '分類' }, campaign: { label: 'キャンペーン' }, notes: { label: 'メモ・次のステップ' }, + qualification: { label: '適格判定' }, + narrative: { label: '商談の経緯' }, // opportunity.view.ts のフォームセクション名 (#1100) overview: { label: '概要' }, forecast: { label: '予測' }, diff --git a/src/sales/translations/zh-CN/objects.pipeline.ts b/src/sales/translations/zh-CN/objects.pipeline.ts index 2f7e75781..ebfa168ef 100644 --- a/src/sales/translations/zh-CN/objects.pipeline.ts +++ b/src/sales/translations/zh-CN/objects.pipeline.ts @@ -305,8 +305,40 @@ export const pipeline: Record = { }, }, loss_details: { label: '赢/丢单详情', help: '赢单或丢单原因的补充说明。' }, + // REQ-0006 — 资格评估、客户侧采购日程、商机叙述、业务线与状态变更审批。 + will_bid: { label: '是否投标', help: '我方是否打算投标。留空表示尚未决定。' }, + controllability: { label: '可控性', options: { high: '高', medium: '中', low: '低' } }, + priority: { label: '优先级', options: { high: '高', medium: '中', low: '低' } }, + deal_level: { label: '商机级别', options: { strategic: '战略级', key: '重点', standard: '普通' } }, + is_subcontracted: { label: '涉及分包' }, + subcontracting_note: { label: '分包说明' }, + customer_initiation_date: { label: '客户立项日期' }, + expected_tender_date: { label: '预计招标日期' }, + expected_signing_date: { label: '预计签约日期' }, + expected_tender_amount: { label: '预计招标金额' }, + expected_signing_amount: { label: '预计签约金额' }, + business_line: { + label: '业务线', + options: { product: '产品', services: '专业服务', consulting: '咨询服务', support: '运维支持', other: '其他' }, + }, + customer_background: { label: '客户简介' }, + project_background: { label: '项目背景' }, + risk_analysis: { label: '风险分析' }, + payment_terms: { label: '付款条款' }, + requested_status: { label: '申请变更状态', options: { closed_won: '赢单', closed_lost: '丢单' } }, + status_change_approval_status: { + label: '状态变更审批', + options: { not_required: '无需审批', pending: '审批中', approved: '已批准', rejected: '已驳回' }, + }, }, _views: { + tender_this_quarter: { + label: '本季度预计招标', + emptyState: { + title: '本季度暂无预计招标的商机', + message: '本标签页列出客户侧预计招标日期落在当前季度内的进行中商机。在商机上填写“预计招标日期”后,它就会出现在这里。', + }, + }, open_opportunities: { label: '进行中商机' }, all_opportunities: { label: '全部商机' }, pipeline_kanban: { label: '销售流水线' }, @@ -333,6 +365,8 @@ export const pipeline: Record = { classification: { label: '分类' }, campaign: { label: '营销活动' }, notes: { label: '备注与下一步' }, + qualification: { label: '资格评估' }, + narrative: { label: '商机叙述' }, // opportunity.view.ts 表单区块名称 (#1100) overview: { label: '概览' }, forecast: { label: '预测' }, diff --git a/src/sales/views/opportunity.view.ts b/src/sales/views/opportunity.view.ts index 75b273888..bf3160041 100644 --- a/src/sales/views/opportunity.view.ts +++ b/src/sales/views/opportunity.view.ts @@ -418,6 +418,24 @@ export const OpportunityViews = defineView({ 'stage_entry_date', 'days_in_stage', 'is_private', + // REQ-0006 step 8 — the customer's own procurement calendar. Named + // here (AGENTS.md ladder rung 3), and the reason in writing: + // `tender_this_quarter` filters on `expected_tender_date`, and a + // filtered field no form offers is a view that can never match a + // deal a rep created through the UI (`test/metadata-references + // .test.ts`). The fields declare `group: 'sales_process'`, so every + // DERIVED surface — the record page's Details tab — already carries + // them; this form authors `sections`, which win outright over + // derivation. A `{ group: 'sales_process' }` reference cannot stand + // in: it would render `stage`, `probability` and `close_date` + // (curated in Overview, `stage` `required`) and `stage_entry_date` + // (above) a second time, and pull the approval verdict columns and + // the inert-by-default `requested_status` into the create dialog. + 'customer_initiation_date', + 'expected_tender_date', + 'expected_tender_amount', + 'expected_signing_date', + 'expected_signing_amount', ], }, { From ca9f386321ed96a76201fc717f4d2f824ac75523 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:55:42 +0000 Subject: [PATCH 5/9] docs(opportunity): REQ-0006's qualification, customer calendar, narrative and status-change gate, for reps and admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - content/docs/sales/opportunity-qualification{,.zh-Hans,.zh-Hant}.mdx: the user-facing page REQ-0006's product response asks for — whether a deal is worth pursuing, the customer's procurement calendar against our close date, the deal narrative, the business line, and the optional sign-off on a won/lost call (off by default; how a request, approval and rejection play out; how an admin arms it). Business concepts, not a field roster. Registered in the three sales meta files. - content/docs/administration/automation{,.zh-Hans,.zh-Hant}.mdx: the Opportunity Status Change Approval row, the header count 30 -> 31, and a paragraph under Approvals; the ledger in test/automation-docs-coverage.test.ts gains its two Chinese row labels. - content/docs/sales/opportunities{,.zh-Hans,.zh-Hant}.mdx: the Tender This Quarter row and section (ten saved views), the two new field groups and the new fields in the field-group table, the customer calendar on the form's Forecast tab, and the Details tab described as the object's groups — it has been a list of `{ group }` references since #1990, so "three sections holding seven fields" was already untrue and this branch's two new sections would have made it more so. The zh-Hant name is pinned in test/docs-view-rosters.test.ts. - README.md: 30 -> 31 flows, the count the stack registers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- README.md | 4 +- content/docs/administration/automation.mdx | 5 +- .../administration/automation.zh-Hans.mdx | 5 +- .../administration/automation.zh-Hant.mdx | 5 +- content/docs/sales/meta.json | 1 + content/docs/sales/meta.zh-Hans.json | 1 + content/docs/sales/meta.zh-Hant.json | 1 + content/docs/sales/opportunities.mdx | 37 ++++---- content/docs/sales/opportunities.zh-Hans.mdx | 37 ++++---- content/docs/sales/opportunities.zh-Hant.mdx | 37 ++++---- .../docs/sales/opportunity-qualification.mdx | 87 ++++++++++++++++++ .../opportunity-qualification.zh-Hans.mdx | 87 ++++++++++++++++++ .../opportunity-qualification.zh-Hant.mdx | 89 +++++++++++++++++++ test/automation-docs-coverage.test.ts | 3 + test/docs-view-rosters.test.ts | 1 + 15 files changed, 341 insertions(+), 59 deletions(-) create mode 100644 content/docs/sales/opportunity-qualification.mdx create mode 100644 content/docs/sales/opportunity-qualification.zh-Hans.mdx create mode 100644 content/docs/sales/opportunity-qualification.zh-Hant.mdx diff --git a/README.md b/README.md index bf4688a8a..6270bbc9b 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 **~54k tokens** @@ -69,7 +69,7 @@ HotCRM is a complete, opinionated CRM built as the **first official application* | `crm_event` | | | | | `crm_event_attendee` | | | | -Plus **6 AI skills** (a skills-only surface — HotCRM defines no agents of its own; the skills attach to the platform `ask` assistant), **5 dashboards**, **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 d14ee8083..e4aaa5d32 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 | | --- | --- | --- | @@ -69,6 +69,7 @@ A flow fires one of three ways, set by its start node: | **Urgent Task Alert** | Record change (insert) | Notify the owner when a task is created at *Urgent* | | **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 | +| **Opportunity Status Change Approval** | Record change (update) | Sign-off a deal needs before it is declared won or lost. Ships switched OFF — it opens nothing until an install arms the gate on the opportunity's **Status Change Approval** field; armed, the rep sets **Requested Status** and the stage moves only when the request is approved | | **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 | @@ -109,6 +110,8 @@ Since ObjectStack 7.4, approvals are modeled as **`approval` nodes inside a flow HotCRM's built-in **Opportunity Approval** flow chains two approval nodes for tiered sign-off (manager → director). See [Revenue › Approvals](/docs/revenue/approvals) for thresholds, what approvers see, and the audit trail. +A second, independent gate — **Opportunity Status Change Approval** — asks for sign-off before a deal is declared won or lost. It ships switched off and keeps its verdict in its own field, so arming it never changes the amount-based sign-off; see [Sales › Opportunity Qualification](/docs/sales/opportunity-qualification). + ## Order of operations When a record is saved, the order is fixed: diff --git a/content/docs/administration/automation.zh-Hans.mdx b/content/docs/administration/automation.zh-Hans.mdx index 07699cd4d..ddebc1065 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 → 自动化 → 流程** 里列出、并在 **Studio → 开发者 → 流程运行记录** 里供你挑选的那个名字,因此一次运行可以逐字回到这张表里查: +**HotCRM 中的内置流程**(31 个)。每一行用的都是流程自身的标签 —— 也就是 **Studio → 自动化 → 流程** 里列出、并在 **Studio → 开发者 → 流程运行记录** 里供你挑选的那个名字,因此一次运行可以逐字回到这张表里查: | 流程 | 触发 | 它做什么 | | --- | --- | --- | @@ -69,6 +69,7 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动 | **紧急任务提醒** | 记录变更(插入) | 任务以 *Urgent* 创建时通知其负责人 | | **大额商机审批** | 记录变更(更新) | 通过 approval 节点分级签核 —— 销售经理 ≥ $100K,销售总监 > $500K | | **大额商机审批(新建时)** | 记录变更(插入) | 同一套受理逻辑,用于*创建时*金额已达到阈值的商机 | +| **商机状态变更审批** | 记录变更(更新) | 商机宣布赢单或丢单之前需要的签核。出厂默认是**关闭**的:在商机的**状态变更审批**字段上打开闸门之前,它不会发起任何审批;打开之后,销售填写**申请变更状态**,请求获批后阶段才会变更 | | **线索转化审批** | 记录变更(插入) | 线索转化前需要的签核。出厂默认是**关闭**的:在线索的**转化审批**字段上打开闸门之前,它不会发起任何审批 | | **客户审批** | 记录变更(插入) | 新建客户在成为正式数据之前需要的签核。出厂默认是**打开**的:新客户以**待审批**状态创建,并在审批收件箱中裁定;不需要客户签核的安装,改掉客户**审批状态**字段的默认值即可 | | **大额商机赢单提醒** | 记录变更(更新) | 金额 $100K 及以上的商机转为 *Closed Won* 时,通知负责人 —— 只通知负责人本人,不通知其经理 | @@ -109,6 +110,8 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动 HotCRM 内置的 **商机审批** 流程串联两个 approval 节点实现分级签核(经理 → 总监)。关于阈值、审批人所见以及审计轨迹,参见 [营收云 › 审批](/zh-Hans/docs/revenue/approvals)。 +另有一道独立的闸门 —— **商机状态变更审批** —— 在商机宣布赢单或丢单之前要求签核。它出厂默认关闭,并把结论记在自己的字段里,因此打开它不会改变按金额分级的签核;参见[销售云 › 商机资格评估](/zh-Hans/docs/sales/opportunity-qualification)。 + ## 操作顺序 当一条记录被保存时,顺序是固定的: diff --git a/content/docs/administration/automation.zh-Hant.mdx b/content/docs/administration/automation.zh-Hant.mdx index e7c8547db..7fd8ea861 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** 裡供你挑選的那個名字,因此一次執行可以逐字回到這張表裡查: | 流程 | 觸發 | 它做什麼 | | --- | --- | --- | @@ -71,6 +71,7 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動 | **緊急任務提醒** | 記錄變更(插入) | 任務以 *Urgent* 建立時通知其負責人 | | **大額商機審批** | 記錄變更(更新) | 透過 approval 節點分級簽核 —— 銷售經理 ≥ $100K,銷售總監 > $500K | | **大額商機審批(新建時)** | 記錄變更(插入) | 同一套受理邏輯,用於*建立時*金額已達到閾值的商機 | +| **商機狀態變更審批** | 記錄變更(更新) | 商機宣布贏單或丟單之前需要的簽核。出廠預設是**關閉**的:在商機的**狀態變更審批**欄位上打開閘門之前,它不會發起任何審批;打開之後,銷售填寫**申請變更狀態**,請求獲批後階段才會變更 | | **線索轉化審批** | 記錄變更(插入) | 線索轉化前需要的簽核。出廠預設是**關閉**的:在線索的**轉化審批**欄位上打開閘門之前,它不會發起任何審批 | | **客戶審批** | 記錄變更(插入) | 新建客戶在成為正式資料之前需要的簽核。出廠預設是**打開**的:新客戶以**待審批**狀態建立,並在審批收件匣中裁定;不需要客戶簽核的安裝,改掉客戶**審批狀態**欄位的預設值即可 | | **大額商機贏單提醒** | 記錄變更(更新) | 金額 $100K 及以上的商機轉為 *Closed Won* 時,通知負責人 —— 只通知負責人本人,不通知其經理 | @@ -111,6 +112,8 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動 HotCRM 內建的 **商機審批** 流程串聯兩個 approval 節點實作分級簽核(經理 → 總監)。關於閾值、審批人所見以及稽核軌跡,參見 [營收雲 › 審批](/zh-Hant/docs/revenue/approvals)。 +另有一道獨立的閘門 —— **商機狀態變更審批** —— 在商機宣布贏單或丟單之前要求簽核。它出廠預設關閉,並把結論記在自己的欄位裡,因此打開它不會改變按金額分級的簽核;參見[銷售雲 › 商機資格評估](/zh-Hant/docs/sales/opportunity-qualification)。 + ## 操作順序 當一條記錄被儲存時,順序是固定的: diff --git a/content/docs/sales/meta.json b/content/docs/sales/meta.json index eeb8e9358..a7df33827 100644 --- a/content/docs/sales/meta.json +++ b/content/docs/sales/meta.json @@ -9,6 +9,7 @@ "contacts", "buying-centre", "opportunities", + "opportunity-qualification", "pipeline-management", "forecasting", "quotes", diff --git a/content/docs/sales/meta.zh-Hans.json b/content/docs/sales/meta.zh-Hans.json index c70d6b13a..4306a8ffb 100644 --- a/content/docs/sales/meta.zh-Hans.json +++ b/content/docs/sales/meta.zh-Hans.json @@ -9,6 +9,7 @@ "contacts", "buying-centre", "opportunities", + "opportunity-qualification", "pipeline-management", "forecasting", "quotes", diff --git a/content/docs/sales/meta.zh-Hant.json b/content/docs/sales/meta.zh-Hant.json index 571158fde..9fa742c7f 100644 --- a/content/docs/sales/meta.zh-Hant.json +++ b/content/docs/sales/meta.zh-Hant.json @@ -9,6 +9,7 @@ "contacts", "buying-centre", "opportunities", + "opportunity-qualification", "pipeline-management", "forecasting", "quotes", diff --git a/content/docs/sales/opportunities.mdx b/content/docs/sales/opportunities.mdx index 5053d9445..6c2f0f3eb 100644 --- a/content/docs/sales/opportunities.mdx +++ b/content/docs/sales/opportunities.mdx @@ -45,37 +45,31 @@ Those two fields are what the Sales dashboard's **win rate** and **Why We Lose** ## What an opportunity record stores -Three different things organise `crm_opportunity`'s fields — the object's own field groups, the opportunity detail screen, and the opportunity form — and no two of them agree, so it is worth knowing which one you are being shown. +Three things organise `crm_opportunity`'s fields — the object's own field groups, the opportunity detail screen, and the opportunity form. The detail screen follows the field groups; the form does not, so it is worth knowing which one you are being shown. ### The object's field groups -The seven names below are real, but not one of them is a screen: they are `crm_opportunity`'s **field groups** (`fieldGroups` in `src/sales/objects/opportunity.object.ts`), the object's own filing scheme for its fields. Neither the detail screen nor the form renders them — each declares sections of its own, listed below. This is, though, the one list that accounts for every field the object has: +The nine names below are `crm_opportunity`'s **field groups** (`fieldGroups` in `src/sales/objects/opportunity.object.ts`), the object's own filing scheme for its fields. The detail screen's **Details** tab is built from them; the form declares tabs of its own, listed below. This is the one list that accounts for every field the object has: | Field group | Fields | | --- | --- | | **Basic Information** | Opportunity Owner, Opportunity Name, Account, Primary Contact | | **Financials** | Amount, Expected Revenue | -| **Sales Process** | Stage, Probability (%), Close Date, Stage Entry Date, Approval Status, Approved Date | -| **Classification** | Opportunity Type, Lead Source, Win Reason, Loss Reason, Loss/Win Details | +| **Sales Process** | Stage, Probability (%), Close Date, Stage Entry Date, Customer Initiation Date, Expected Tender Date, Expected Signing Date, Expected Tender Amount, Expected Signing Amount, Approval Status, Approved Date, Requested Status, Status Change Approval | +| **Qualification** | Will Bid, Controllability, Priority, Deal Level, Involves Subcontracting, Subcontracting Note | +| **Deal Narrative** | Customer Background, Project Background, Risk Analysis, Payment Terms | +| **Classification** | Opportunity Type, Business Line, Lead Source, Win Reason, Loss Reason, Loss/Win Details | | **Campaigns** *(collapsed by default)* | Campaign | | **Notes & Next Steps** | Description, Next Steps | | **Forecast & Metrics** *(collapsed by default)* | Days in Current Stage, Private, Forecast Category | -Four of those seven rows used to be written differently on this page, and the differences were not cosmetic: **Probability (%)** is in *Sales Process*, not *Financials*; *Sales Process* also holds **Approval Status** and **Approved Date**, and an opportunity has no *created date* field at all; the campaign field's label is **Campaign**, not *Source campaign*; and *Forecast & Metrics* holds **Days in Current Stage**, **Private** and **Forecast Category** — not *line item totals*, which is not a field on this object, and not **Approval Status**, which is one row further up. +Four of the original seven rows used to be written differently on this page, and the differences were not cosmetic: **Probability (%)** is in *Sales Process*, not *Financials*; *Sales Process* also holds **Approval Status** and **Approved Date**, and an opportunity has no *created date* field at all; the campaign field's label is **Campaign**, not *Source campaign*; and *Forecast & Metrics* holds **Days in Current Stage**, **Private** and **Forecast Category** — not *line item totals*, which is not a field on this object, and not **Approval Status**, which is one row further up. ### The detail screen -The **Details** tab of the opportunity detail page (`src/sales/pages/opportunity_detail.page.ts`) declares **three** sections, holding **seven** fields: +The **Details** tab of the opportunity detail page (`src/sales/pages/opportunity_detail.page.ts`) declares no sections of its own: each of its sections is one of the field groups above, with that group's name, fields and collapsed state, in this order — **Basic Information**, **Financials**, **Classification**, **Campaigns**, **Sales Process**, **Qualification**, **Deal Narrative**, **Forecast & Metrics**, **Notes & Next Steps**. A field added to a group therefore reaches the tab by itself. -| Section | Fields | -| --- | --- | -| **Opportunity Information** | Opportunity Type, Lead Source, Campaign | -| **Stage & Forecast** | Stage, Forecast Category | -| **Description** *(collapsible)* | Description, Next Steps | - -*Description* is the only one of the three that can be collapsed, and nothing marks it collapsed to begin with, so it opens along with the rest. - -The tab is shorter than its section names suggest, and that is deliberate: a section here lists only the fields it is responsible for. **Amount**, **Close Date**, **Probability (%)**, **Expected Revenue**, **Opportunity Owner** and **Account** are already on the **Key Information** strip above the tabs, and **Opportunity Name** is the page title — so the tab repeats none of them. +The tab is shorter than that list suggests, and that is deliberate. **Amount**, **Close Date**, **Probability (%)**, **Expected Revenue**, **Opportunity Owner** and **Account** are already on the **Key Information** strip above the tabs, and **Opportunity Name** is the page title — so the tab repeats none of them, and *Financials*, left with nothing to show, does not appear. **Classification**, **Campaigns**, **Qualification**, **Deal Narrative** and **Notes & Next Steps** stay on screen even when every field in them is empty, as labelled rows waiting to be filled in; the other sections appear once one of their fields has a value. ### The form @@ -84,7 +78,7 @@ The opportunity form (`src/sales/views/opportunity.view.ts`) is tabbed, and its | Section | Fields | | --- | --- | | **Overview** | Opportunity Name, Account, Primary Contact, Stage, Amount, Probability (%), Close Date, Opportunity Owner | -| **Forecast** | Expected Revenue, Forecast Category, Opportunity Type, Lead Source, Campaign, Stage Entry Date, Days in Current Stage, Private | +| **Forecast** | Expected Revenue, Forecast Category, Opportunity Type, Lead Source, Campaign, Stage Entry Date, Days in Current Stage, Private, Customer Initiation Date, Expected Tender Date, Expected Tender Amount, Expected Signing Date, Expected Signing Amount | | **Sales Strategy** | Next Steps | | **Win / Loss** *(collapsed by default)* | Win Reason, Loss Reason, Loss/Win Details | @@ -146,7 +140,7 @@ On top of that: ## Standard list views -Opportunities opens on **Open Deals**, and the switcher along the top of the list carries nine saved views — these, and nothing else: +Opportunities opens on **Open Deals**, and the switcher along the top of the list carries ten saved views — these, and nothing else: | View | What it shows | | --- | --- | @@ -159,6 +153,7 @@ Opportunities opens on **Open Deals**, and the switcher along the top of the lis | **Deal Cards** | Gallery cards — account, amount, stage, probability, close date, owner — for pipeline and executive reviews. | | **⚠️ Stale Opportunities · Longest in Stage First** | Open deals ordered by how long they have sat in their current stage, longest first. Nothing is cut off by age — **Days in Current Stage** is a column, so you read the wait off the row. The daily [Stalled Deal Alert](/docs/administration/automation) is what acts on it, nudging the owner once a deal passes 14 days in one stage. | | **Closing This Quarter** | Open **Commit** and **Best Case** deals whose close date falls inside the current quarter — see below. | +| **Tender This Quarter** | Open deals whose **Expected Tender Date** — the customer's own tender date, not our close date — falls inside the current quarter, soonest tender first — see below. | ### Closing This Quarter @@ -172,6 +167,12 @@ That third filter is what makes the name true: summing the **Amount** column giv **An empty list here is an answer, not a broken view.** When nothing matches, the view says so in place of the grid — *No Deals Closing This Quarter*, with a pointer back to **Open Deals** — because a quarter whose commit has slipped out entirely is exactly the state a manager needs to see. A freshly seeded demo org meets the same screen in the closing days of a quarter, when every sample deal lands in the next one. +### Tender This Quarter + +Where **Closing This Quarter** reads *our* forecast, this view reads the *customer's* calendar. It lists open deals whose **Expected Tender Date** falls inside the current quarter, with the tender amount and the expected signing date beside it, and it never looks at the close date — a deal we expect to sign next year can still have a tender this quarter, and this is where it shows up. Both ends of the quarter are computed each time the list runs. + +**It is empty until somebody records a tender date.** No sample deal carries one, so a fresh install opens this view on *No Tenders Expected This Quarter*. See [Opportunity Qualification](/docs/sales/opportunity-qualification) for the customer-side dates and how they differ from the close date. + ## The opportunity detail layout When you open an opportunity, you'll see: @@ -180,7 +181,7 @@ When you open an opportunity, you'll see: - **Key Information** — the highlights strip immediately under the header, and where this deal's numbers really are: **Amount**, **Close Date**, **Probability (%)**, **Expected Revenue**, **Opportunity Owner** and **Account**, side by side. The account therefore appears twice on the screen — once as the header subtitle, once here. - **Sales Path** — the stage strip below the highlights: all seven stages in funnel order, the current one marked and the ones already passed ticked. It is a **read-only indicator, not a control** — the stages are not clickable, so there is no one-click progression, and clicking a stage does not move the deal to it. You move a deal by editing its **Stage** field, on the *Details* tab or in the edit form, and the transition rules in *The 7 sales stages* above are what decide whether the move is allowed. - **Three tabs:** - - **Details** — the tab a deal opens on. **Three** sections holding **seven** fields, listed under *What an opportunity record stores* above; the fields already on the **Key Information** strip are deliberately not repeated here. + - **Details** — the tab a deal opens on. Its sections are the object's field groups, described under *What an opportunity record stores* above; the fields already on the **Key Information** strip are deliberately not repeated here. - **Related** — three panels in an accordion, and only the first is open when you arrive. **Quotes** — this deal's quotes; the **Generate Quote** button that runs the [Quote Generation flow](/docs/sales/quotes#generating-a-quote) is not on this list, it is one of the header buttons above. **Products** — the deal's line items; the panel lists them, links to the full list, and carries an **Add** button. That button is an `add` picker over the product catalog, not an action: there is no **Add Product** action in this app (see *Line items — what's actually being sold* above). **Open Tasks** — this deal's `crm_task` records through **Related Opportunity**, filtered to those whose status is not *Completed*, ten at a time. - **Activity** — a unified feed of comments, logged calls (`sys_activity`), outbound emails (`sys_email`), task completions, and field-history audit entries. Every tracked change on the opportunity (stage, amount, close-date, owner) shows up here automatically; there is no separate "history" tab. - **Competitors & Notes** — there is no such panel. The page (`src/sales/pages/opportunity_detail.page.ts`) declares three regions: a header, the main column carrying the tab strip, and one narrow side column (`aside`) whose only occupant is the reference rail described below. Nothing on the page renders talking points, and there is no competitors field left to render either: the opportunity's **Competitors** multi-select offered three placeholder options (*Competitor A*, *Competitor B*, *Competitor C*), no screen in the app ever read the value back, and #1061 retired it. The only competitor signal the app records is the *Lost to Competitor* closed-lost reason, with the free-text **Loss/Win Details** beside it. The closest thing to notes on the page itself is the *Details* tab's collapsible **Description** section, which carries **Description** and **Next Steps**. diff --git a/content/docs/sales/opportunities.zh-Hans.mdx b/content/docs/sales/opportunities.zh-Hans.mdx index fc78f7fd4..26c290b8f 100644 --- a/content/docs/sales/opportunities.zh-Hans.mdx +++ b/content/docs/sales/opportunities.zh-Hans.mdx @@ -45,37 +45,31 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶 ## 商机记录存储的内容 -把 `crm_opportunity` 的字段组织起来的东西有三样——对象自己的字段分组、商机详情界面、商机表单——而且两两都不一致,所以要紧的是分清你看的是哪一样。 +把 `crm_opportunity` 的字段组织起来的东西有三样——对象自己的字段分组、商机详情界面、商机表单。详情界面跟随字段分组,表单则不,所以要紧的是分清你看的是哪一样。 ### 对象自己的字段分组 -下面这七个名字都是真的,但没有一个是界面:它们是 `crm_opportunity` 的**字段分组**(`src/sales/objects/opportunity.object.ts` 里的 `fieldGroups`),是对象给自己字段定的归档方案。详情界面和表单都不渲染它们——两者各自声明了自己的分区,见下文。不过,这确实是唯一一份把对象全部字段都交代到的清单: +下面这九个名字是 `crm_opportunity` 的**字段分组**(`src/sales/objects/opportunity.object.ts` 里的 `fieldGroups`),是对象给自己字段定的归档方案。详情界面的 *Details* 标签页就是由它们组成的;表单则声明了自己的标签页,见下文。这是唯一一份把对象全部字段都交代到的清单: | 字段分组 | 字段 | | --- | --- | | **基本信息** | 商机负责人、商机名称、所属客户、主要联系人 | | **财务** | 金额、预期收入 | -| **销售流程** | 阶段、成交概率 (%)、预计成交日期、进入当前阶段日期、审批状态、批准时间 | -| **分类** | 类型、线索来源、赢单原因、丢单原因、赢/丢单详情 | +| **销售流程** | 阶段、成交概率 (%)、预计成交日期、进入当前阶段日期、客户立项日期、预计招标日期、预计签约日期、预计招标金额、预计签约金额、审批状态、批准时间、申请变更状态、状态变更审批 | +| **资格评估** | 是否投标、可控性、优先级、商机级别、涉及分包、分包说明 | +| **商机叙述** | 客户简介、项目背景、风险分析、付款条款 | +| **分类** | 类型、业务线、线索来源、赢单原因、丢单原因、赢/丢单详情 | | **营销活动** *(默认折叠)* | 营销活动 | | **备注与后续步骤** | 描述、下一步 | | **预测与指标** *(默认折叠)* | 当前阶段天数、私密、预测类别 | -这七行里有四行过去在本页上写的是别的样子,而且差别不是措辞层面的:**成交概率 (%)** 在*销售流程*里,不在*财务*里;*销售流程*还装着**审批状态**与**批准时间**,而商机上根本没有*创建日期*这个字段;营销活动那个字段的标签就是**营销活动**,不是*来源营销活动*;*预测与指标*装的是**当前阶段天数**、**私密**与**预测类别**——不是*行项目合计*(这个对象上没有这样一个字段),也不是**审批状态**(它在上面一行)。 +原来那七行里有四行过去在本页上写的是别的样子,而且差别不是措辞层面的:**成交概率 (%)** 在*销售流程*里,不在*财务*里;*销售流程*还装着**审批状态**与**批准时间**,而商机上根本没有*创建日期*这个字段;营销活动那个字段的标签就是**营销活动**,不是*来源营销活动*;*预测与指标*装的是**当前阶段天数**、**私密**与**预测类别**——不是*行项目合计*(这个对象上没有这样一个字段),也不是**审批状态**(它在上面一行)。 ### 详情界面 -商机详情页(`src/sales/pages/opportunity_detail.page.ts`)的 *Details* 标签页声明了**三个**分区,装着**七个**字段: +商机详情页(`src/sales/pages/opportunity_detail.page.ts`)的 *Details* 标签页不声明自己的分区:它的每个分区就是上表中的一个字段分组,沿用该分组的名字、字段和折叠状态,顺序是**基本信息**、**财务**、**分类**、**营销活动**、**销售流程**、**资格评估**、**商机叙述**、**预测与指标**、**备注与后续步骤**。所以往某个分组里加一个字段,它会自己出现在这个标签页上。 -| 分区 | 字段 | -| --- | --- | -| **Opportunity Information** | 类型、线索来源、营销活动 | -| **Stage & Forecast** | 阶段、预测类别 | -| **Description** *(可折叠)* | 描述、下一步 | - -三个分区里只有*Description*可以折叠,而且没有任何地方把它标成初始折叠,所以它和其余两个一起是展开的。 - -这个标签页比它的分区名字听上去要短,而且是有意为之:这里的分区只列它自己负责的字段。**金额**、**预计成交日期**、**成交概率 (%)**、**预期收入**、**商机负责人**与**所属客户**已经在标签页上方的**关键信息**摘要栏里了,而**商机名称**就是页面标题——所以这个标签页一个都不重复。 +这个标签页比那份清单听上去要短,而且是有意为之。**金额**、**预计成交日期**、**成交概率 (%)**、**预期收入**、**商机负责人**与**所属客户**已经在标签页上方的**关键信息**摘要栏里了,而**商机名称**就是页面标题——所以这个标签页一个都不重复,*财务*因此无可显示,也就不出现。**分类**、**营销活动**、**资格评估**、**商机叙述**与**备注与后续步骤**即使所有字段都为空也会留在界面上,显示为等待填写的带标签空行;其余分区要等其中某个字段有值才出现。 ### 表单 @@ -84,7 +78,7 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶 | 分区 | 字段 | | --- | --- | | **Overview** | 商机名称、所属客户、主要联系人、阶段、金额、成交概率 (%)、预计成交日期、商机负责人 | -| **Forecast** | 预期收入、预测类别、类型、线索来源、营销活动、进入当前阶段日期、当前阶段天数、私密 | +| **Forecast** | 预期收入、预测类别、类型、线索来源、营销活动、进入当前阶段日期、当前阶段天数、私密、客户立项日期、预计招标日期、预计招标金额、预计签约日期、预计签约金额 | | **Sales Strategy** | 下一步 | | **Win / Loss** *(默认折叠)* | 赢单原因、丢单原因、赢/丢单详情 | @@ -146,7 +140,7 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶 ## 标准列表视图 -商机列表默认打开**进行中商机**,列表顶部的视图切换器里就是下面这九个已保存视图——只有这九个: +商机列表默认打开**进行中商机**,列表顶部的视图切换器里就是下面这十个已保存视图——只有这十个: | 视图 | 展示什么 | | --- | --- | @@ -159,6 +153,7 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶 | **商机卡片** | 卡片墙——客户、金额、阶段、赢单概率、预计成交日期、负责人——用于流水线评审和管理层过会。 | | **⚠️ 停滞商机 · 按阶段停留时间排序** | 进行中的交易按在当前阶段停留的时间排序,停得最久的排在最上面。这里不按停留天数做截断——**当前阶段停留天数**是一列,等了多久直接在行上读。真正据此行动的是每日的[停滞商机提醒](/zh-Hans/docs/administration/automation):一笔交易在同一阶段停满 14 天,就会提醒负责人。 | | **本季度待成交商机** | 处于**承诺(Commit)**或**最佳情况(Best Case)**、且预计成交日期落在当前季度内的进行中交易——见下文。 | +| **本季度预计招标** | **预计招标日期**——客户自己的招标日期,而不是我方的预计成交日期——落在当前季度内的进行中交易,招标日期最近的排在最前——见下文。 | ### 本季度待成交商机 @@ -172,6 +167,12 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶 **这里空着是一个答案,而不是视图坏了。** 没有记录匹配时,视图会用一段说明取代表格——*本季度暂无待成交商机*,并指回**进行中商机**——因为"本季度的承诺全部滑出去了"恰恰是经理必须看见的状态。刚装好示例数据的演示环境在季度最后几天也会看到同一屏:此时所有样例交易都落在了下个季度。 +### 本季度预计招标 + +**本季度待成交商机**读的是*我方*的预测,这个视图读的是*客户*的日程。它列出**预计招标日期**落在当前季度内的进行中交易,旁边带着预计招标金额和预计签约日期,而且完全不看预计成交日期——一笔我们预计明年才签的交易,招标可能就在本季度,它会出现在这里。季度的两端在每次打开列表时重新计算。 + +**有人填写招标日期之前,它是空的。** 示例商机都没有填这个日期,所以新安装打开这个视图时看到的是*本季度暂无预计招标的商机*。客户侧日期是什么、和预计成交日期有什么区别,见[商机资格评估](/zh-Hans/docs/sales/opportunity-qualification)。 + ## 商机详情布局 当你打开一个商机时,会看到: @@ -180,7 +181,7 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶 - **关键信息**——紧贴头部下方的摘要栏,也是这笔交易的数字真正待的地方:**金额**、**预计成交日期**、**成交概率 (%)**、**预期收入**、**商机负责人**、**所属客户**并排排列。所以客户在这个界面上出现了两次:一次是头部副标题,一次在这里。 - **销售路径**——摘要栏下方的阶段条:七个阶段按漏斗顺序排开,当前阶段被标出,已经走过的打勾。它是一个**只读指示器,而不是控件**——阶段并不可点击,所以既没有「一键推进阶段」这回事,点某个阶段也不会把交易移到那个阶段去。推进一笔交易靠改它的**阶段**字段(在 *Details* 标签页里或编辑表单上),而这次移动允不允许,由上文《7 个销售阶段》里的转换规则决定。 - **三个标签页:** - - **Details**——打开一笔交易时停在的那一个。**三个**分区、**七个**字段,清单见上文《商机记录存储的内容》;已经在**关键信息**摘要栏里的那些字段,这里刻意不重复。 + - **Details**——打开一笔交易时停在的那一个。它的分区就是对象的字段分组,见上文《商机记录存储的内容》;已经在**关键信息**摘要栏里的那些字段,这里刻意不重复。 - **Related**——一个手风琴里的三块面板,进来时只有第一块是展开的。**Quotes**——这笔交易的报价单;运行 [报价生成流程](/zh-Hans/docs/sales/quotes#generating-a-quote) 的那个**生成报价单**按钮并不在这个列表上,它是上面头部的按钮之一。**Products**——这笔交易的行项目;这个面板把它们列出来、给一个跳转到完整列表的链接,并带一个 **Add** 按钮。那个按钮是一个 `add` 选取器,不是一个动作:这个应用里没有**添加产品**这个动作(见上文《行项目——实际在销售什么》)。**Open Tasks**——本商机经**关联商机**字段挂上来的 `crm_task` 记录,过滤掉状态为 *Completed* 的,一次十条。 - **Activity**——统一汇集评论、已记录通话(`sys_activity`)、外发邮件(`sys_email`)、任务完成以及字段历史审计条目。商机上每个被跟踪的更改(阶段、金额、预计成交日期、负责人)都会自动出现在这里;没有单独的"历史"标签页。 - **竞争对手与备注**——不存在这样一个面板。这一页(`src/sales/pages/opportunity_detail.page.ts`)声明了三个区域:一个头部、一个装着那组标签页的主列,以及一条窄侧栏(`aside`),而侧栏里唯一的组件就是下一条讲的参考栏。页面上没有任何组件渲染「谈话要点」,也没有竞争对手字段可渲染了:商机上那个**竞争对手**多选字段只有三个占位选项(*Competitor A*、*Competitor B*、*Competitor C*),应用里没有任何界面把它读回来,#1061 已将其退役。应用里仅存的竞争对手信号,是「输给竞争对手」这条丢单原因,以及它旁边的自由文本 **赢/丢单详情**。这一页上最接近「备注」的,是 *Details* 标签页里那个可折叠的 **描述** 分区,里面放着 **描述** 与 **下一步**。 diff --git a/content/docs/sales/opportunities.zh-Hant.mdx b/content/docs/sales/opportunities.zh-Hant.mdx index 7f36e09bc..aaed4f1ff 100644 --- a/content/docs/sales/opportunities.zh-Hant.mdx +++ b/content/docs/sales/opportunities.zh-Hant.mdx @@ -47,37 +47,31 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階 ## 商機記錄儲存的內容 -把 `crm_opportunity` 的欄位組織起來的東西有三樣——物件自己的欄位分組、商機詳情介面、商機表單——而且兩兩都不一致,所以要緊的是分清你看的是哪一樣。 +把 `crm_opportunity` 的欄位組織起來的東西有三樣——物件自己的欄位分組、商機詳情介面、商機表單。詳情介面跟隨欄位分組,表單則不,所以要緊的是分清你看的是哪一樣。 ### 物件自己的欄位分組 -下面這七個名字都是真的,但沒有一個是介面:它們是 `crm_opportunity` 的**欄位分組**(`src/sales/objects/opportunity.object.ts` 裡的 `fieldGroups`),是物件給自己欄位定的歸檔方案。詳情介面和表單都不渲染它們——兩者各自宣告了自己的分區,見下文。不過,這確實是唯一一份把物件全部欄位都交代到的清單: +下面這九個名字是 `crm_opportunity` 的**欄位分組**(`src/sales/objects/opportunity.object.ts` 裡的 `fieldGroups`),是物件給自己欄位定的歸檔方案。詳情介面的 *Details* 標籤頁就是由它們組成的;表單則宣告了自己的標籤頁,見下文。這是唯一一份把物件全部欄位都交代到的清單: | 欄位分組 | 欄位 | | --- | --- | | **基本資訊** | 商機負責人、商機名稱、所屬客戶、主要聯絡人 | | **財務** | 金額、預期收入 | -| **銷售流程** | 階段、成交機率 (%)、預計成交日期、進入當前階段日期、審批狀態、批准時間 | -| **分類** | 類型、潛在客戶來源、贏單原因、丟單原因、贏/丟單詳情 | +| **銷售流程** | 階段、成交機率 (%)、預計成交日期、進入當前階段日期、客戶立項日期、預計招標日期、預計簽約日期、預計招標金額、預計簽約金額、審批狀態、批准時間、申請變更狀態、狀態變更審批 | +| **資格評估** | 是否投標、可控性、優先級、商機級別、涉及分包、分包說明 | +| **商機敘述** | 客戶簡介、專案背景、風險分析、付款條款 | +| **分類** | 類型、業務線、潛在客戶來源、贏單原因、丟單原因、贏/丟單詳情 | | **行銷活動** *(預設摺疊)* | 行銷活動 | | **備註與後續步驟** | 描述、下一步 | | **預測與指標** *(預設摺疊)* | 當前階段天數、私密、預測類別 | -這七行裡有四行過去在本頁上寫的是別的樣子,而且差別不是措辭層面的:**成交機率 (%)** 在*銷售流程*裡,不在*財務*裡;*銷售流程*還裝著**審批狀態**與**批准時間**,而商機上根本沒有*建立日期*這個欄位;行銷活動那個欄位的標籤就是**行銷活動**,不是*來源行銷活動*;*預測與指標*裝的是**當前階段天數**、**私密**與**預測類別**——不是*行項目合計*(這個物件上沒有這樣一個欄位),也不是**審批狀態**(它在上面一行)。 +原來那七行裡有四行過去在本頁上寫的是別的樣子,而且差別不是措辭層面的:**成交機率 (%)** 在*銷售流程*裡,不在*財務*裡;*銷售流程*還裝著**審批狀態**與**批准時間**,而商機上根本沒有*建立日期*這個欄位;行銷活動那個欄位的標籤就是**行銷活動**,不是*來源行銷活動*;*預測與指標*裝的是**當前階段天數**、**私密**與**預測類別**——不是*行項目合計*(這個物件上沒有這樣一個欄位),也不是**審批狀態**(它在上面一行)。 ### 詳情介面 -商機詳情頁(`src/sales/pages/opportunity_detail.page.ts`)的 *Details* 標籤頁宣告了**三個**分區,裝著**七個**欄位: +商機詳情頁(`src/sales/pages/opportunity_detail.page.ts`)的 *Details* 標籤頁不宣告自己的分區:它的每個分區就是上表中的一個欄位分組,沿用該分組的名字、欄位和摺疊狀態,順序是**基本資訊**、**財務**、**分類**、**行銷活動**、**銷售流程**、**資格評估**、**商機敘述**、**預測與指標**、**備註與後續步驟**。所以往某個分組裡加一個欄位,它會自己出現在這個標籤頁上。 -| 分區 | 欄位 | -| --- | --- | -| **Opportunity Information** | 類型、潛在客戶來源、行銷活動 | -| **Stage & Forecast** | 階段、預測類別 | -| **Description** *(可摺疊)* | 描述、下一步 | - -三個分區裡只有*Description*可以摺疊,而且沒有任何地方把它標成初始摺疊,所以它和其餘兩個一起是展開的。 - -這個標籤頁比它的分區名字聽上去要短,而且是有意為之:這裡的分區只列它自己負責的欄位。**金額**、**預計成交日期**、**成交機率 (%)**、**預期收入**、**商機負責人**與**所屬客戶**已經在標籤頁上方的**關鍵資訊**摘要欄裡了,而**商機名稱**就是頁面標題——所以這個標籤頁一個都不重複。 +這個標籤頁比那份清單聽上去要短,而且是有意為之。**金額**、**預計成交日期**、**成交機率 (%)**、**預期收入**、**商機負責人**與**所屬客戶**已經在標籤頁上方的**關鍵資訊**摘要欄裡了,而**商機名稱**就是頁面標題——所以這個標籤頁一個都不重複,*財務*因此無可顯示,也就不出現。**分類**、**行銷活動**、**資格評估**、**商機敘述**與**備註與後續步驟**即使所有欄位都為空也會留在介面上,顯示為等待填寫的帶標籤空行;其餘分區要等其中某個欄位有值才出現。 ### 表單 @@ -86,7 +80,7 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階 | 分區 | 欄位 | | --- | --- | | **Overview** | 商機名稱、所屬客戶、主要聯絡人、階段、金額、成交機率 (%)、預計成交日期、商機負責人 | -| **Forecast** | 預期收入、預測類別、類型、潛在客戶來源、行銷活動、進入當前階段日期、當前階段天數、私密 | +| **Forecast** | 預期收入、預測類別、類型、潛在客戶來源、行銷活動、進入當前階段日期、當前階段天數、私密、客戶立項日期、預計招標日期、預計招標金額、預計簽約日期、預計簽約金額 | | **Sales Strategy** | 下一步 | | **Win / Loss** *(預設摺疊)* | 贏單原因、丟單原因、贏/丟單詳情 | @@ -148,7 +142,7 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階 ## 標準列表視圖 -商機列表預設打開**進行中商機**,列表頂部的視圖切換器裡就是下面這九個已儲存視圖——只有這九個: +商機列表預設打開**進行中商機**,列表頂部的視圖切換器裡就是下面這十個已儲存視圖——只有這十個: | 視圖 | 展示什麼 | | --- | --- | @@ -161,6 +155,7 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階 | **商機卡片** | 卡片牆——客戶、金額、階段、贏單機率、預計成交日期、負責人——用於流水線評審和管理層過會。 | | **⚠️ 停滯商機 · 按階段停留時間排序** | 進行中的交易按在當前階段停留的時間排序,停得最久的排在最上面。這裡不按停留天數做截斷——**當前階段停留天數**是一欄,等了多久直接在列上讀。真正據此行動的是每日的[停滯商機提醒](/zh-Hant/docs/administration/automation):一筆交易在同一階段停滿 14 天,就會提醒負責人。 | | **本季度待成交商機** | 處於**承諾(Commit)**或**最佳情況(Best Case)**、且預計成交日期落在當前季度內的進行中交易——見下文。 | +| **本季度預計招標** | **預計招標日期**——客戶自己的招標日期,而不是我方的預計成交日期——落在當前季度內的進行中交易,招標日期最近的排在最前——見下文。 | ### 本季度待成交商機 @@ -174,6 +169,12 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階 **這裡空著是一個答案,而不是視圖壞了。** 沒有記錄符合時,視圖會用一段說明取代表格——*本季度暫無待成交商機*,並指回**進行中商機**——因為「本季度的承諾全部滑出去了」恰恰是經理必須看見的狀態。剛裝好範例資料的示範環境在季度最後幾天也會看到同一屏:此時所有範例交易都落在了下個季度。 +### 本季度預計招標 + +**本季度待成交商機**讀的是*我方*的預測,這個視圖讀的是*客戶*的日程。它列出**預計招標日期**落在當前季度內的進行中交易,旁邊帶著預計招標金額和預計簽約日期,而且完全不看預計成交日期——一筆我們預計明年才簽的交易,招標可能就在本季度,它會出現在這裡。季度的兩端在每次打開列表時重新計算。 + +**有人填寫招標日期之前,它是空的。** 示例商機都沒有填這個日期,所以新安裝打開這個視圖時看到的是*本季度暫無預計招標的商機*。客戶側日期是什麼、和預計成交日期有什麼區別,見[商機資格評估](/zh-Hant/docs/sales/opportunity-qualification)。 + ## 商機詳情版面 當你打開一個商機時,會看到: @@ -182,7 +183,7 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階 - **關鍵資訊**——緊貼頭部下方的摘要欄,也是這筆交易的數字真正待的地方:**金額**、**預計成交日期**、**成交機率 (%)**、**預期收入**、**商機負責人**、**所屬客戶**並排排列。所以客戶在這個介面上出現了兩次:一次是頭部副標題,一次在這裡。 - **銷售路徑**——摘要欄下方的階段條:七個階段按漏斗順序排開,當前階段被標出,已經走過的打勾。它是一個**唯讀指示器,而不是控制項**——階段並不可點擊,所以既沒有「一鍵推進階段」這回事,點某個階段也不會把交易移到那個階段去。推進一筆交易靠改它的**階段**欄位(在 *Details* 標籤頁裡或編輯表單上),而這次移動允不允許,由上文《7 個銷售階段》裡的轉換規則決定。 - **三個標籤頁:** - - **Details**——打開一筆交易時停在的那一個。**三個**分區、**七個**欄位,清單見上文《商機記錄儲存的內容》;已經在**關鍵資訊**摘要欄裡的那些欄位,這裡刻意不重複。 + - **Details**——打開一筆交易時停在的那一個。它的分區就是物件的欄位分組,見上文《商機記錄儲存的內容》;已經在**關鍵資訊**摘要欄裡的那些欄位,這裡刻意不重複。 - **Related**——一個手風琴裡的三塊面板,進來時只有第一塊是展開的。**Quotes**——這筆交易的報價單;執行 [報價生成流程](/zh-Hant/docs/sales/quotes#generating-a-quote) 的那個**生成報價單**按鈕並不在這個列表上,它是上面頭部的按鈕之一。**Products**——這筆交易的行項目;這個面板把它們列出來、給一個跳轉到完整列表的連結,並帶一個 **Add** 按鈕。那個按鈕是一個 `add` 選取器,不是一個動作:這個應用裡沒有**新增產品**這個動作(見上文《行項目——實際在銷售什麼》)。**Open Tasks**——本商機經**關聯商機**欄位掛上來的 `crm_task` 記錄,過濾掉狀態為 *Completed* 的,一次十筆。 - **Activity**——統一彙集評論、已記錄通話(`sys_activity`)、外發郵件(`sys_email`)、任務完成以及欄位歷史稽核條目。商機上每個被追蹤的更改(階段、金額、預計成交日期、負責人)都會自動出現在這裡;沒有單獨的「歷史」標籤頁。 - **競爭對手與備註**——不存在這樣一個面板。這一頁(`src/sales/pages/opportunity_detail.page.ts`)宣告了三個區域:一個頭部、一個裝著那組標籤頁的主欄,以及一條窄側欄(`aside`),而側欄裡唯一的元件就是下一條講的參考欄。頁面上沒有任何元件渲染「談話要點」,也沒有競爭對手欄位可渲染了:商機上那個**競爭對手**多選欄位只有三個佔位選項(*Competitor A*、*Competitor B*、*Competitor C*),應用裡沒有任何介面把它讀回來,#1061 已將其退役。應用裡僅存的競爭對手訊號,是「輸給競爭對手」這條丟單原因,以及它旁邊的自由文字 **贏/丟單詳情**。這一頁上最接近「備註」的,是 *Details* 標籤頁裡那個可摺疊的 **描述** 分區,裡面放著 **描述** 與 **下一步**。 diff --git a/content/docs/sales/opportunity-qualification.mdx b/content/docs/sales/opportunity-qualification.mdx new file mode 100644 index 000000000..02f06df7d --- /dev/null +++ b/content/docs/sales/opportunity-qualification.mdx @@ -0,0 +1,87 @@ +--- +title: Opportunity Qualification +description: Whether a deal is worth pursuing, the customer's own procurement calendar, the written case for the deal, and the optional sign-off before a deal is declared won or lost. +--- + +# Qualification, the customer's calendar, and sign-off on the outcome + +An opportunity already says what the deal is worth and where it stands in *your* pipeline. Four more things on the record answer questions those numbers cannot: + +- **Qualification** — should we pursue this deal at all, and how hard? +- **The customer's calendar** — when the buyer plans to start, tender and sign, and for how much. +- **Deal Narrative** — the written case for the deal, in parts a reviewer can read one at a time. +- **Status Change Approval** — whether somebody has to sign off before a deal is declared won or lost. **Off unless your admin turns it on.** + +All of it sits on the opportunity's **Details** tab, in the **Qualification**, **Sales Process** and **Deal Narrative** sections. + +## Should we pursue it? + +A seller who cannot chase every deal triages. The **Qualification** section records that judgement: + +- **Will Bid** — whether you intend to bid. Leave it empty while the decision is still open; an empty box and a *no* are different answers. +- **Controllability** — how much of the outcome you can influence: a deal you shaped from the start is *High*, an open tender you found last week is *Low*. +- **Priority** and **Deal Level** — how much attention the deal gets, and how much it matters to the business (*Strategic*, *Key* or *Standard*). +- **Involves Subcontracting**, with a **Subcontracting Note** — whether part of the work will be delivered by someone else. The margin often depends on it, so say so early. + +None of this is **Forecast Category**. That field is worked out from the stage and feeds the forecast roll-up; these are judgements a person makes, and they do not change when the deal moves. + +## The customer's calendar, not your close date + +**Close Date** is *your* forecast: one date, the day you expect to win. A considered purchase runs on the buyer's own calendar, which has more than one date in it, and that calendar is what you actually plan against: + +- **Customer Initiation Date** — when the customer formally starts the project on their side. +- **Expected Tender Date** and **Expected Tender Amount** — when the tender is expected, and roughly how big it is. +- **Expected Signing Date** and **Expected Signing Amount** — when signature is expected, and for how much. + +They sit in the **Sales Process** section and on the form's **Forecast** tab. Because they are separate from **Close Date**, a list can be built on them alone: the **Tender This Quarter** view lists open deals whose tender falls in the current quarter, whatever their close date says — see [Opportunities › Standard list views](/docs/sales/opportunities). + +## The written case for the deal + +**Description** is one box, and a deal review needs more than one thing written down. The **Deal Narrative** section splits it: + +- **Customer Background** — who the customer is and what matters to them. +- **Project Background** — what the project is and why it exists now. +- **Risk Analysis** — what could lose the deal or make it unprofitable. +- **Payment Terms** — the terms as they stand in the conversation. This is free text on purpose: a term still being negotiated rarely fits the fixed list a signed quote or contract uses. + +Attachments still go on the record's files, as they always have. + +## Business line + +**Opportunity Type** says how the deal relates to the customer — new business, an upgrade, a renewal, an expansion. **Business Line** is a separate question: *what kind of business* the deal is — **Product**, **Professional Services**, **Consulting**, **Support & Maintenance** or **Other**. The starter list is generic; your admin can replace it with your own lines of business. + +## When closing a deal needs approval + +Declaring a deal won or lost moves the forecast, commission and planning, and it cannot be taken back. Some organisations want sign-off on that moment, whatever the deal's size. The **Status Change Approval** field records where that stands: + +| Status Change Approval | What it means | +| --- | --- | +| **Not Required** | No sign-off is asked for. This is how HotCRM ships, and it is what every deal shows unless your admin has turned the gate on. | +| **Pending** | The gate is on for this deal and no status change has been approved yet. | +| **Approved** | The requested status was approved, and the deal's stage has moved to it. | +| **Rejected** | The approver said no to the last request. The deal stays where it was. | + +You never set this field yourself — it is filled in by the approval, and it is read-only. + +**While a deal is Pending or Rejected, you cannot move its stage to Closed Won or Closed Lost directly.** Instead: + +1. Set **Requested Status** to *Won* or *Lost*, and fill in the **Win Reason** or **Loss Reason** as you would when closing. +2. An approval request opens in the approval inbox, and the deal is locked while it waits. +3. If it is approved, the stage moves to what you asked for. If it is rejected, your request is cleared and the deal stays open — you can request again when something changes. + +> **Nothing changes for you unless your admin arms the gate.** Out of the box every deal reads *Not Required* and closing works exactly as it always has. The amount-based **Large Deal Approval** is a separate gate and is not affected either way. Deals created before the gate was turned on keep closing freely — arming it affects new deals, and it never reopens a deal you already closed. + +## Tips for sales reps + +- ✅ Fill in **Will Bid** as soon as you have decided, and leave it empty until then. A pipeline review reads the empty ones as the decisions still to make. +- ✅ Record the customer's tender date the day you hear it. It is the date your bid team plans against, and **Tender This Quarter** can only list the deals that carry one. +- ✅ If closing is refused, look at **Status Change Approval** before asking anyone: *Pending* means set **Requested Status** instead, *Rejected* is a decision to talk about. +- ⛔ Don't move **Close Date** to match the tender date. The two are different events, and the forecast depends on the close date being yours. + +## Tips for admins + +- The **Qualification** and **Deal Narrative** sections stay visible on the **Details** tab even when every field in them is empty, so a seller sees what is still to be filled in. +- **Controllability**, **Priority**, **Deal Level** and **Business Line** are ordinary picklists — see [Customization › Extending Objects](/docs/customization/extending-objects) to replace the starter values with your own grading scale and lines of business. +- The gate is **off by default** and is armed by changing the default value of the opportunity's **Status Change Approval** field from *Not Required* to *Pending*. From then on each new deal is born *Pending*; deals that already exist keep *Not Required*. Requests are routed to the holders of the `sales_manager` position, and the flow behind them is **Opportunity Status Change Approval**, listed in [Administration › Automation](/docs/administration/automation). +- Staff that position before you arm the gate. An approval routed to an empty bench has nobody to decide it, and a deal waiting on it stays locked. +- A request written by an integration, with nobody signed in, still opens an approval. The refusal of a direct close, though, applies to edits made by signed-in users: a system write that sets the stage itself is not stopped. diff --git a/content/docs/sales/opportunity-qualification.zh-Hans.mdx b/content/docs/sales/opportunity-qualification.zh-Hans.mdx new file mode 100644 index 000000000..f634c7fce --- /dev/null +++ b/content/docs/sales/opportunity-qualification.zh-Hans.mdx @@ -0,0 +1,87 @@ +--- +title: 商机资格评估 +description: 这笔交易值不值得跟、客户自己的采购日程、写下来的商机论证,以及宣布赢单或丢单之前可选的那道签核。 +--- + +# 资格评估、客户的日程,以及对结果的签核 + +商机上本来就写着这笔交易值多少、在*你的*管线里走到了哪一步。记录上还有四样东西,回答的是那些数字回答不了的问题: + +- **资格评估**——这笔交易要不要跟,花多大力气跟? +- **客户的日程**——买方打算什么时候立项、招标、签约,各是多少金额。 +- **商机叙述**——这笔交易的书面论证,拆成评审人可以逐段读的几部分。 +- **状态变更审批**——宣布赢单或丢单之前是否需要有人签核。**除非管理员打开,否则它是关着的。** + +这些都在商机的 *Details* 标签页上,分别位于**资格评估**、**销售流程**和**商机叙述**三个分区里。 + +## 这笔交易要不要跟 + +没法把每一笔交易都追到底的销售,就得做取舍。**资格评估**分区记录的就是这个判断: + +- **是否投标**——你是否打算投标。还没决定时就留空;空着和填"否"是两个不同的答案。 +- **可控性**——结果有多少在你的影响之内:从一开始就由你参与塑造的交易是*高*,上周才发现的公开招标是*低*。 +- **优先级**与**商机级别**——这笔交易得到多少关注,以及它对业务有多重要(*战略级*、*重点*或*普通*)。 +- **涉及分包**,以及**分包说明**——是否有一部分工作会交给别人交付。利润往往取决于此,所以要尽早说明。 + +这些都不是**预测类别**。那个字段是由阶段推算出来、汇入预测汇总的;这里记录的是人做出的判断,不会随交易推进而改变。 + +## 客户的日程,而不是你的预计成交日期 + +**预计成交日期**是*你的*预测:一个日期,也就是你预计赢单的那一天。一次慎重的采购按照买方自己的日程推进,那份日程里不止一个日期,而它才是你真正据以安排工作的东西: + +- **客户立项日期**——客户在他们那一侧正式启动项目的时间。 +- **预计招标日期**与**预计招标金额**——预计什么时候招标,规模大概多大。 +- **预计签约日期**与**预计签约金额**——预计什么时候签约,金额多少。 + +它们位于**销售流程**分区,也在表单的 **Forecast** 标签页上。正因为它们和**预计成交日期**是分开的,列表可以只按它们来建:**本季度预计招标**视图列出招标落在当前季度内的进行中交易,不管预计成交日期写的是什么——见[销售云 › 商机](/zh-Hans/docs/sales/opportunities)。 + +## 写下来的商机论证 + +**描述**只有一个框,而一次商机评审需要写下来的不止一件事。**商机叙述**分区把它拆开: + +- **客户简介**——客户是谁、他们在意什么。 +- **项目背景**——这个项目是什么、为什么是现在。 +- **风险分析**——什么可能让这笔交易丢掉,或者让它不赚钱。 +- **付款条款**——谈判进行到当下时的条款。这里有意做成自由文本:还在谈的条款,很少能套进已签报价单或合同所用的那份固定清单。 + +附件照旧放在记录的文件里。 + +## 业务线 + +**类型**说的是这笔交易和客户是什么关系——新业务、升级、续约、扩展。**业务线**回答的是另一个问题:这笔交易*属于哪一类业务*——**产品**、**专业服务**、**咨询服务**、**运维支持**或**其他**。起始清单是通用的;管理员可以把它换成你们自己的业务线。 + +## 什么时候关单需要审批 + +宣布一笔交易赢单或丢单,会牵动预测、提成和规划,而且无法撤回。有些组织希望无论金额大小,都要对这一刻签核。商机上的**状态变更审批**字段记录这件事走到哪一步: + +| 状态变更审批 | 含义 | +| --- | --- | +| **无需审批** | 不要求签核。这是 HotCRM 的出厂状态,除非管理员打开了闸门,否则每笔交易都是这个值。 | +| **审批中** | 这笔交易的闸门是开着的,还没有哪次状态变更获批。 | +| **已批准** | 申请的状态已获批准,交易的阶段已经变更为它。 | +| **已驳回** | 审批人否决了上一次申请。交易停留在原来的阶段。 | + +这个字段不由你填写——它由审批流程写入,是只读的。 + +**只要交易处在"审批中"或"已驳回",你就不能直接把它的阶段改成成交或失败。** 应当这样做: + +1. 把**申请变更状态**设为*赢单*或*丢单*,并像关单时一样填写**赢单原因**或**丢单原因**。 +2. 审批收件箱里会出现一条审批请求,等待期间这笔交易被锁定。 +3. 获批后,阶段会变更为你申请的那个状态;被驳回时,你的申请会被清空,交易保持进行中——情况有变时可以再次申请。 + +> **管理员不打开闸门,你这边什么都不会变。** 出厂状态下每笔交易都显示*无需审批*,关单和以前完全一样。按金额分级的**大额商机审批**是另一道闸门,无论这道开不开都不受影响。闸门打开之前创建的交易也照旧可以自由关单——打开闸门影响的是新交易,而且它绝不会重新打开你已经关掉的交易。 + +## 给销售的建议 + +- ✅ 一决定就填**是否投标**,没决定就留空。管线评审会把空着的那些当作还没做的决定来读。 +- ✅ 听到客户的招标日期当天就把它记下来。投标团队按这个日期排工作,而**本季度预计招标**只能列出填了日期的交易。 +- ✅ 关单被拒绝时,先看**状态变更审批**的值再去问人:*审批中*意味着应改为填写**申请变更状态**,*已驳回*是需要谈一谈的结论。 +- ⛔ 不要把**预计成交日期**改成和招标日期一样。两者是不同的事件,而预测依赖的是你自己的预计成交日期。 + +## 给管理员的建议 + +- **资格评估**与**商机叙述**两个分区即使所有字段都为空,也会留在 *Details* 标签页上,让销售看到还有哪些没填。 +- **可控性**、**优先级**、**商机级别**和**业务线**都是普通的选择列表——把起始值换成你们自己的分级和业务线,见[定制化 › 扩展对象](/zh-Hans/docs/customization/extending-objects)。 +- 这道闸门**默认关闭**,打开的方式是把商机**状态变更审批**字段的默认值从*无需审批*改成*审批中*。从那时起,每笔新交易一创建就处于*审批中*;已有的交易保持*无需审批*。申请会路由给 `sales_manager` 岗位的成员;背后的流程叫**商机状态变更审批**,列在[系统管理 › 自动化](/zh-Hans/docs/administration/automation)里。 +- 打开闸门之前先把那个岗位配上人。路由到空岗位的审批没有人能做决定,而等待它的交易会一直被锁定。 +- 由集成写入、没有任何人登录的申请,同样会发起审批。不过,拒绝直接关单只针对已登录用户的编辑:直接写入阶段的系统写入不会被拦下。 diff --git a/content/docs/sales/opportunity-qualification.zh-Hant.mdx b/content/docs/sales/opportunity-qualification.zh-Hant.mdx new file mode 100644 index 000000000..0c59351d0 --- /dev/null +++ b/content/docs/sales/opportunity-qualification.zh-Hant.mdx @@ -0,0 +1,89 @@ +--- +title: 商機資格評估 +description: 這筆交易值不值得跟、客戶自己的採購日程、寫下來的商機論證,以及宣布贏單或丟單之前可選的那道簽核。 +--- + +# 資格評估、客戶的日程,以及對結果的簽核 + +本應用未隨附繁體中文語言包,因此本頁出現的介面名詞依固定順序取用:zh-CN 語言包已收錄者,採其用詞的繁體寫法;未收錄者,保留產品內的英文原名,不另造譯名。 + +商機上本來就寫著這筆交易值多少、在*你的*管線裡走到了哪一步。記錄上還有四樣東西,回答的是那些數字回答不了的問題: + +- **資格評估**——這筆交易要不要跟,花多大力氣跟? +- **客戶的日程**——買方打算什麼時候立項、招標、簽約,各是多少金額。 +- **商機敘述**——這筆交易的書面論證,拆成評審人可以逐段讀的幾部分。 +- **狀態變更審批**——宣布贏單或丟單之前是否需要有人簽核。**除非管理員打開,否則它是關著的。** + +這些都在商機的 *Details* 標籤頁上,分別位於**資格評估**、**銷售流程**和**商機敘述**三個分區裡。 + +## 這筆交易要不要跟 + +沒法把每一筆交易都追到底的銷售,就得做取捨。**資格評估**分區記錄的就是這個判斷: + +- **是否投標**——你是否打算投標。還沒決定時就留空;空著和填「否」是兩個不同的答案。 +- **可控性**——結果有多少在你的影響之內:從一開始就由你參與塑造的交易是*高*,上週才發現的公開招標是*低*。 +- **優先級**與**商機級別**——這筆交易得到多少關注,以及它對業務有多重要(*戰略級*、*重點*或*普通*)。 +- **涉及分包**,以及**分包說明**——是否有一部分工作會交給別人交付。利潤往往取決於此,所以要儘早說明。 + +這些都不是**預測類別**。那個欄位是由階段推算出來、匯入預測匯總的;這裡記錄的是人做出的判斷,不會隨交易推進而改變。 + +## 客戶的日程,而不是你的預計成交日期 + +**預計成交日期**是*你的*預測:一個日期,也就是你預計贏單的那一天。一次慎重的採購按照買方自己的日程推進,那份日程裡不只一個日期,而它才是你真正據以安排工作的東西: + +- **客戶立項日期**——客戶在他們那一側正式啟動專案的時間。 +- **預計招標日期**與**預計招標金額**——預計什麼時候招標,規模大概多大。 +- **預計簽約日期**與**預計簽約金額**——預計什麼時候簽約,金額多少。 + +它們位於**銷售流程**分區,也在表單的 **Forecast** 標籤頁上。正因為它們和**預計成交日期**是分開的,列表可以只按它們來建:**本季度預計招標**視圖列出招標落在當前季度內的進行中交易,不管預計成交日期寫的是什麼——見[銷售雲 › 商機](/zh-Hant/docs/sales/opportunities)。 + +## 寫下來的商機論證 + +**描述**只有一個框,而一次商機評審需要寫下來的不只一件事。**商機敘述**分區把它拆開: + +- **客戶簡介**——客戶是誰、他們在意什麼。 +- **專案背景**——這個專案是什麼、為什麼是現在。 +- **風險分析**——什麼可能讓這筆交易丟掉,或者讓它不賺錢。 +- **付款條款**——談判進行到當下時的條款。這裡有意做成自由文字:還在談的條款,很少能套進已簽報價單或合約所用的那份固定清單。 + +附件照舊放在記錄的檔案裡。 + +## 業務線 + +**類型**說的是這筆交易和客戶是什麼關係——新業務、升級、續約、擴展。**業務線**回答的是另一個問題:這筆交易*屬於哪一類業務*——**產品**、**專業服務**、**諮詢服務**、**運維支援**或**其他**。起始清單是通用的;管理員可以把它換成你們自己的業務線。 + +## 什麼時候關單需要審批 + +宣布一筆交易贏單或丟單,會牽動預測、提成和規劃,而且無法撤回。有些組織希望無論金額大小,都要對這一刻簽核。商機上的**狀態變更審批**欄位記錄這件事走到哪一步: + +| 狀態變更審批 | 含義 | +| --- | --- | +| **無需審批** | 不要求簽核。這是 HotCRM 的出廠狀態,除非管理員打開了閘門,否則每筆交易都是這個值。 | +| **審批中** | 這筆交易的閘門是開著的,還沒有哪次狀態變更獲批。 | +| **已批准** | 申請的狀態已獲批准,交易的階段已經變更為它。 | +| **已駁回** | 審批人否決了上一次申請。交易停留在原來的階段。 | + +這個欄位不由你填寫——它由審批流程寫入,是唯讀的。 + +**只要交易處在「審批中」或「已駁回」,你就不能直接把它的階段改成成交或失敗。** 應當這樣做: + +1. 把**申請變更狀態**設為*贏單*或*丟單*,並像關單時一樣填寫**贏單原因**或**丟單原因**。 +2. 審批收件匣裡會出現一條審批請求,等待期間這筆交易被鎖定。 +3. 獲批後,階段會變更為你申請的那個狀態;被駁回時,你的申請會被清空,交易保持進行中——情況有變時可以再次申請。 + +> **管理員不打開閘門,你這邊什麼都不會變。** 出廠狀態下每筆交易都顯示*無需審批*,關單和以前完全一樣。按金額分級的**大額商機審批**是另一道閘門,無論這道開不開都不受影響。閘門打開之前建立的交易也照舊可以自由關單——打開閘門影響的是新交易,而且它絕不會重新打開你已經關掉的交易。 + +## 給銷售的建議 + +- ✅ 一決定就填**是否投標**,沒決定就留空。管線評審會把空著的那些當作還沒做的決定來讀。 +- ✅ 聽到客戶的招標日期當天就把它記下來。投標團隊按這個日期排工作,而**本季度預計招標**只能列出填了日期的交易。 +- ✅ 關單被拒絕時,先看**狀態變更審批**的值再去問人:*審批中*意味著應改為填寫**申請變更狀態**,*已駁回*是需要談一談的結論。 +- ⛔ 不要把**預計成交日期**改成和招標日期一樣。兩者是不同的事件,而預測依賴的是你自己的預計成交日期。 + +## 給管理員的建議 + +- **資格評估**與**商機敘述**兩個分區即使所有欄位都為空,也會留在 *Details* 標籤頁上,讓銷售看到還有哪些沒填。 +- **可控性**、**優先級**、**商機級別**和**業務線**都是普通的選擇清單——把起始值換成你們自己的分級和業務線,見[客製化 › 擴展物件](/zh-Hant/docs/customization/extending-objects)。 +- 這道閘門**預設關閉**,打開的方式是把商機**狀態變更審批**欄位的預設值從*無需審批*改成*審批中*。從那時起,每筆新交易一建立就處於*審批中*;已有的交易保持*無需審批*。申請會路由給 `sales_manager` 職位的成員;背後的流程叫**商機狀態變更審批**,列在[系統管理 › 自動化](/zh-Hant/docs/administration/automation)裡。 +- 打開閘門之前先把那個職位配上人。路由到空職位的審批沒有人能做決定,而等待它的交易會一直被鎖定。 +- 由整合寫入、沒有任何人登入的申請,同樣會發起審批。不過,拒絕直接關單只針對已登入使用者的編輯:直接寫入階段的系統寫入不會被攔下。 diff --git a/test/automation-docs-coverage.test.ts b/test/automation-docs-coverage.test.ts index 0f1fa77c0..c9c209f7b 100644 --- a/test/automation-docs-coverage.test.ts +++ b/test/automation-docs-coverage.test.ts @@ -235,6 +235,9 @@ const ROW_LABEL: Record> = { 'zh-Hans': '大额商机审批(新建时)', 'zh-Hant': '大額商機審批(新建時)', }, + // REQ-0006: 状态变更, the wording of the field that arms it + // (`status_change_approval_status`, 状态变更审批 in the zh-CN pack). + opportunity_status_change_approval: { 'zh-Hans': '商机状态变更审批', 'zh-Hant': '商機狀態變更審批' }, opportunity_won_alert: { 'zh-Hans': '大额商机赢单提醒', 'zh-Hant': '大額商機贏單提醒' }, case_escalation: { 'zh-Hans': '工单升级流程', 'zh-Hant': '工單升級流程' }, case_escalation_on_create: { diff --git a/test/docs-view-rosters.test.ts b/test/docs-view-rosters.test.ts index 9da460393..9122b1e4b 100644 --- a/test/docs-view-rosters.test.ts +++ b/test/docs-view-rosters.test.ts @@ -574,6 +574,7 @@ describe('a docs list-view roster names the views the app ships (#1194)', () => my_open_deals: '我的進行中商機', stale_opportunities: '⚠️ 停滯商機 · 按階段停留時間排序', closing_this_quarter: '本季度待成交商機', + tender_this_quarter: '本季度預計招標', }, crm_task: { all_tasks: '全部任務', From ea40e43a5950a43bfe2dcf64fd814605cf7bf0cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:10:41 +0000 Subject: [PATCH 6/9] fix(opportunity-form): REQ-0006's qualification, narrative, business line and request are editable Measured on a fresh 17.6.0 boot of this branch: the record page's Details tab draws the Qualification and Deal Narrative groups READ-ONLY (no inline edit), and the Edit / New dialog is opportunity.view.ts's tabbed form, which authors `sections` and therefore wins outright over the fieldGroups derivation. So twelve of the eighteen REQ-0006 fields - the qualification block, the narrative block, Business Line and Requested Status - rendered but had no editing surface anywhere in the app, and with the gate armed a rep could not raise a status-change request at all. - `{ group: 'qualification' }` and `{ group: 'narrative' }` tabs: AGENTS.md ladder rung 2, exactly as contact.view.ts renders REQ-0004's buying centre. Nothing is enumerated; members, labels and icons come from the object, so REQ-0006 acceptance 1 holds on the form from fieldGroups alone. - `business_line` beside `type` on the Forecast tab, and `requested_status` at the head of Win / Loss, visible only while the gate holds the deal (pending or rejected): rung 3, each with its reason written beside it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- src/sales/views/opportunity.view.ts | 33 ++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/sales/views/opportunity.view.ts b/src/sales/views/opportunity.view.ts index bf3160041..7fe287255 100644 --- a/src/sales/views/opportunity.view.ts +++ b/src/sales/views/opportunity.view.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { P } from '@objectstack/spec'; import { defineView } from '@objectstack/spec/ui'; /** @@ -413,6 +414,11 @@ export const OpportunityViews = defineView({ 'expected_revenue', 'forecast_category', 'type', + // REQ-0006 step 9 — named beside `type` (rung 3): this section is a + // curated cross-group set, and a `{ group: 'classification' }` + // reference would render `type` and `lead_source` twice and pull the + // win/loss reasons out of their own tab below. + 'business_line', 'lead_source', 'crm_campaign', 'stage_entry_date', @@ -438,6 +444,19 @@ export const OpportunityViews = defineView({ 'expected_signing_amount', ], }, + // REQ-0006 acceptance 1, rendered FROM `fieldGroups` (AGENTS.md ladder + // rung 2), the same way `contact.view.ts` renders REQ-0004's buying + // centre: this form authors `sections`, and an authored `sections` array + // wins outright over the renderer's `fieldGroups` derivation — so a group + // declared on the object alone reaches the record page's Details tab and + // NEVER this form, which is also the Edit and New dialog. Measured on the + // 17.6.0 console: the Details tab draws these groups read-only (no inline + // edit), so before these two lines the qualification and narrative + // fields had no editing surface anywhere in the app. A group reference + // enumerates nothing: members, label and icon come from the object, so a + // field added to either group reaches this form by itself. + { group: 'qualification', columns: 2 }, + { group: 'narrative', columns: 1 }, { name: 'sales_strategy', label: 'Sales Strategy', @@ -455,7 +474,19 @@ export const OpportunityViews = defineView({ collapsible: true, collapsed: true, columns: 2, - fields: ['win_reason', 'loss_reason', { field: 'loss_details', span: 'full' }], + fields: [ + // REQ-0006 steps 13-14 — the request half of the status-change gate, + // beside the reasons a request needs (rung 3: it is one field of the + // 13-member `sales_process` group, whose others are curated above or + // machine-written). Shown only while the gate holds the deal, so an + // install that never arms it never sees a field that does nothing. + { + field: 'requested_status', + visibleOn: P`has(record.status_change_approval_status) + && (record.status_change_approval_status == "pending" || record.status_change_approval_status == "rejected")`, + }, + 'win_reason', 'loss_reason', { field: 'loss_details', span: 'full' }, + ], }, ], }, From 3a907d2e88da7e1d34804bac1d0e9a6c1dbd7dd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:26:40 +0000 Subject: [PATCH 7/9] fix(opportunity-form): keep the two group references, drop the rung-3 fields; correct ea40e43a's reading ea40e43a's message says the Details tab draws the REQ-0006 groups read-only. That reading was wrong: my probe missed the pencil. Re-measured on the same 17.6.0 boot, the pencil puts the whole Details tab into an edit mode with a Save bar. It edits every select, boolean, date, currency and textarea field, so the qualification block, the customer calendar, Business Line, Payment Terms and Requested Status can all be edited there (saved and read back through REST). It offers no editor for a MARKDOWN field, though: Customer Background, Project Background and Risk Analysis stay read-only, and so does the object's own `description`. The same boot also shows the tabbed Edit / New dialog renders no tab for a `{ group }` section. That holds for the two references ea40e43a added and for the contact form's REQ-0004 buying centre on `main`. A markdown field the form NAMES does get an editor (the account dialog's Description). So: - `business_line` and `requested_status` come out of the form again. Details edit mode covers them, and per-field enumeration was the escape hatch the ruling on this card refused. - `{ group: 'qualification' }` / `{ group: 'narrative' }` stay. They are the contract-correct rung-2 form, the same as the contact precedent, and they wait for the platform to render group sections in a tabbed form. They are not routed around (AGENTS.md: a platform defect is waited for). The note beside them now records what renders on 17.6.0 and what does not. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- src/sales/views/opportunity.view.ts | 47 +++++++++++------------------ 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/src/sales/views/opportunity.view.ts b/src/sales/views/opportunity.view.ts index 7fe287255..e996c0326 100644 --- a/src/sales/views/opportunity.view.ts +++ b/src/sales/views/opportunity.view.ts @@ -1,6 +1,5 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; import { defineView } from '@objectstack/spec/ui'; /** @@ -414,11 +413,6 @@ export const OpportunityViews = defineView({ 'expected_revenue', 'forecast_category', 'type', - // REQ-0006 step 9 — named beside `type` (rung 3): this section is a - // curated cross-group set, and a `{ group: 'classification' }` - // reference would render `type` and `lead_source` twice and pull the - // win/loss reasons out of their own tab below. - 'business_line', 'lead_source', 'crm_campaign', 'stage_entry_date', @@ -444,17 +438,22 @@ export const OpportunityViews = defineView({ 'expected_signing_amount', ], }, - // REQ-0006 acceptance 1, rendered FROM `fieldGroups` (AGENTS.md ladder - // rung 2), the same way `contact.view.ts` renders REQ-0004's buying - // centre: this form authors `sections`, and an authored `sections` array - // wins outright over the renderer's `fieldGroups` derivation — so a group - // declared on the object alone reaches the record page's Details tab and - // NEVER this form, which is also the Edit and New dialog. Measured on the - // 17.6.0 console: the Details tab draws these groups read-only (no inline - // edit), so before these two lines the qualification and narrative - // fields had no editing surface anywhere in the app. A group reference - // enumerates nothing: members, label and icon come from the object, so a - // field added to either group reaches this form by itself. + // REQ-0006 acceptance 1, FROM `fieldGroups` (AGENTS.md ladder rung 2), + // the way `contact.view.ts` references REQ-0004's buying centre: this + // form authors `sections`, which win outright over the renderer's + // `fieldGroups` derivation, so a group declared on the object alone + // never reaches this form — the Edit and New dialog. A group reference + // enumerates nothing; members, label and icon come from the object. + // + // ⚠️ Measured on the 17.6.0 console: the tabbed dialog renders NO tab + // for a `{ group }` section — this one, and the contact form's buying + // centre on `main` alike. That is the platform's to fix, and these two + // lines are the contract-correct form waiting for it (AGENTS.md: a + // platform defect is waited for, never routed around). Until then the + // qualification fields, Business Line and Requested Status are edited + // through the Details tab's edit mode; the three MARKDOWN narrative + // fields are not — that edit mode offers no markdown editor (the + // object's own `description` is in the same position). { group: 'qualification', columns: 2 }, { group: 'narrative', columns: 1 }, { @@ -474,19 +473,7 @@ export const OpportunityViews = defineView({ collapsible: true, collapsed: true, columns: 2, - fields: [ - // REQ-0006 steps 13-14 — the request half of the status-change gate, - // beside the reasons a request needs (rung 3: it is one field of the - // 13-member `sales_process` group, whose others are curated above or - // machine-written). Shown only while the gate holds the deal, so an - // install that never arms it never sees a field that does nothing. - { - field: 'requested_status', - visibleOn: P`has(record.status_change_approval_status) - && (record.status_change_approval_status == "pending" || record.status_change_approval_status == "rejected")`, - }, - 'win_reason', 'loss_reason', { field: 'loss_details', span: 'full' }, - ], + fields: ['win_reason', 'loss_reason', { field: 'loss_details', span: 'full' }], }, ], }, From 8333d7eec890104bf481597e58cb03aff536a5bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:31:53 +0000 Subject: [PATCH 8/9] docs(opportunity-gate): the re-fire the transition term stops, as measured rather than as read 92f90e28 said that without the transition term the approval node's own `pending` stamp re-fired the flow "and the second run died on the plugin's DUPLICATE_REQUEST guard". The re-fire is real; the effect was inferred from the plugin source and was wrong. Measured on a 17.6.0 boot with the gate armed and the term deleted (local only, restored): one re-entry per request, each caught by the engine's self-trigger guard, which warns that "the guard as authored does not exclude the flow's own write-back"; no DUPLICATE_REQUEST. With the term in place the same scenario logs no re-entry at all. The flow and test comments now say that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- .../opportunity-status-change-approval.flow.ts | 13 ++++++++----- .../opportunity-status-change-approval-gate.test.ts | 3 ++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/sales/flows/opportunity-status-change-approval.flow.ts b/src/sales/flows/opportunity-status-change-approval.flow.ts index 99a9f1dcb..3ceb73772 100644 --- a/src/sales/flows/opportunity-status-change-approval.flow.ts +++ b/src/sales/flows/opportunity-status-change-approval.flow.ts @@ -98,11 +98,14 @@ export const OpportunityStatusChangeApprovalFlow: Flow = { // - a request is present; // - the request is NEW on this write. TRANSITION, not current value — // the idiom `billing_handoff_closed_won` records for this object. - // Without it the approval node's own `pending` stamp (an update of - // this record, through `approvalStatusField`) re-fires this flow - // while the request is still open, and the second run dies on the - // plugin's DUPLICATE_REQUEST guard. `previous.*` is guarded - // FAIL-CLOSED: no visible prior value, no visible new request. + // Without it the flow re-fires on its own write-back while the + // request is still open (the approval node's `pending` stamp through + // `approvalStatusField` is an update of this record). Measured on + // 17.6.0 with this term deleted: one re-entry per request, each + // caught only by the engine's self-trigger guard ("the guard as + // authored does not exclude the flow's own write-back"); with it, + // none. `previous.*` is guarded FAIL-CLOSED: no visible prior + // value, no visible new request. // // `apply_status` leaves the gate `approved` (out of reach) and // `clear_request` empties the request, so neither re-enters. diff --git a/test/opportunity-status-change-approval-gate.test.ts b/test/opportunity-status-change-approval-gate.test.ts index 14721f1e1..761acbbeb 100644 --- a/test/opportunity-status-change-approval-gate.test.ts +++ b/test/opportunity-status-change-approval-gate.test.ts @@ -125,7 +125,8 @@ describe('opportunity_status_change_approval — start condition', () => { // `approvalStatusField` stamps `pending` through an update of this record // while the request is still open. The request is unchanged on that write, // so it is not a new request — testing the current value alone re-fired - // this flow there, and the second run died on DUPLICATE_REQUEST. + // this flow on its own write-back (measured on 17.6.0: one re-entry per + // request, stopped only by the engine's self-trigger guard). const gate = { status_change_approval_status: 'pending', requested_status: 'closed_won' }; expect(conditionHolds(startCondition, { record: { id: 'o1', stage: 'negotiation', ...gate }, From 033ba5818c11890285595dd54010470d8893bc34 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:48:17 +0000 Subject: [PATCH 9/9] fix(opportunity): the three narrative fields are textareas, so the Details tab can write them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customer Background, Project Background and Risk Analysis move from Field.markdown to Field.textarea, the same declaration `payment_terms` already uses (label and group, nothing else). REQ-0006's product response names "Field.markdown / Field.textarea per field". Measured on the 17.6.0 console: the Details tab's edit mode is the only surface the narrative group renders on, since the tabbed Edit dialog draws no `{ group }` section. That edit mode gives every textarea an editor and offers none for a markdown field, so the three were readable but could never be written. The maintainer ruled the textarea option on #1950 (2026-10-03, 「改成 textarea(推荐)」). The object's own `description` stays markdown; it is outside this card. The note above the narrative block records the reason, and the form's group-reference note no longer calls these fields unwritable. No label, translation, docs page or changeset text named their type, so nothing else changes. Token reading unchanged (both type names are eight characters). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- src/sales/objects/opportunity.object.ts | 13 ++++++++++--- src/sales/views/opportunity.view.ts | 9 ++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/sales/objects/opportunity.object.ts b/src/sales/objects/opportunity.object.ts index ca34699b6..922fdc728 100644 --- a/src/sales/objects/opportunity.object.ts +++ b/src/sales/objects/opportunity.object.ts @@ -340,17 +340,24 @@ export const Opportunity = ObjectSchema.create({ // still being negotiated, not the net-terms ladder a signed document // carries. ⛔ Do not "unify" it onto PAYMENT_TERMS_OPTIONS — that would // force a half-agreed term into a closed vocabulary. - customer_background: Field.markdown({ + // + // All four are `Field.textarea` (REQ-0006 names markdown OR textarea per + // field). Measured on the 17.6.0 console, the Details tab's edit mode — + // the surface these fields render on — has no markdown editor, so a + // markdown field here could be read but never written. The maintainer + // ruled textarea for the three that were markdown (2026-10-03, on #1950: + // 「改成 textarea(推荐)」). + customer_background: Field.textarea({ label: 'Customer Background', group: 'narrative', }), - project_background: Field.markdown({ + project_background: Field.textarea({ label: 'Project Background', group: 'narrative', }), - risk_analysis: Field.markdown({ + risk_analysis: Field.textarea({ label: 'Risk Analysis', group: 'narrative', }), diff --git a/src/sales/views/opportunity.view.ts b/src/sales/views/opportunity.view.ts index e996c0326..e476d3293 100644 --- a/src/sales/views/opportunity.view.ts +++ b/src/sales/views/opportunity.view.ts @@ -449,11 +449,10 @@ export const OpportunityViews = defineView({ // for a `{ group }` section — this one, and the contact form's buying // centre on `main` alike. That is the platform's to fix, and these two // lines are the contract-correct form waiting for it (AGENTS.md: a - // platform defect is waited for, never routed around). Until then the - // qualification fields, Business Line and Requested Status are edited - // through the Details tab's edit mode; the three MARKDOWN narrative - // fields are not — that edit mode offers no markdown editor (the - // object's own `description` is in the same position). + // platform defect is waited for, never routed around). Until then every + // REQ-0006 field is edited through the Details tab's edit mode — which + // offers no markdown editor, the reason the narrative fields are + // textareas (see the note above `customer_background`). { group: 'qualification', columns: 2 }, { group: 'narrative', columns: 1 }, {