From 2fb9e8c174b6529a8cdfb72e02b462b69947ba4d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:38:53 +0000 Subject: [PATCH 1/6] feat(picklists): the shared option lists become `picklist` metadata The five option lists more than one select field offers are now platform picklists, referenced by name with `Field.select({ picklist })`: salutation, industry, lead_source and related_to_type (sales), and payment_terms (revenue). Each list used by one field returns inline to it. Option labels for picklist-bound fields move to `picklists.` in all four locale packs. `src/sales/objects/_picklists.ts` is deleted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3 --- objectstack.composition.ts | 11 + objectstack.config.ts | 7 +- src/revenue/objects/contract.object.ts | 11 +- src/revenue/objects/quote.object.ts | 6 +- src/revenue/picklists/index.ts | 13 + .../picklists/payment_terms.picklist.ts | 35 +++ src/sales/actions/activity-actions.ts | 4 +- src/sales/actions/opportunity.actions.ts | 9 +- src/sales/dashboards/executive.dashboard.ts | 17 +- src/sales/flows/schedule-followup.flow.ts | 10 +- src/sales/mappings/_shared.ts | 4 +- src/sales/objects/_picklists.ts | 286 ------------------ src/sales/objects/account.object.ts | 7 +- src/sales/objects/contact.object.ts | 11 +- src/sales/objects/event.object.ts | 35 ++- src/sales/objects/event_attendee.object.ts | 11 +- src/sales/objects/lead.hook.ts | 4 +- src/sales/objects/lead.object.ts | 120 ++++++-- src/sales/objects/opportunity.object.ts | 30 +- src/sales/objects/task.object.ts | 27 +- src/sales/picklists/index.ts | 29 ++ src/sales/picklists/industry.picklist.ts | 39 +++ src/sales/picklists/lead_source.picklist.ts | 39 +++ .../picklists/related_to_type.picklist.ts | 31 ++ src/sales/picklists/salutation.picklist.ts | 28 ++ src/sales/translations/en/app.ts | 45 ++- src/sales/translations/en/objects.activity.ts | 16 +- src/sales/translations/en/objects.commerce.ts | 16 +- src/sales/translations/en/objects.customer.ts | 26 +- src/sales/translations/en/objects.pipeline.ts | 36 +-- src/sales/translations/es-ES/app.ts | 45 ++- .../translations/es-ES/objects.activity.ts | 16 +- .../translations/es-ES/objects.commerce.ts | 16 +- .../translations/es-ES/objects.customer.ts | 26 +- .../translations/es-ES/objects.pipeline.ts | 36 +-- src/sales/translations/ja-JP/_shared.ts | 23 -- src/sales/translations/ja-JP/app.ts | 45 ++- .../translations/ja-JP/objects.activity.ts | 15 +- .../translations/ja-JP/objects.commerce.ts | 9 +- .../translations/ja-JP/objects.customer.ts | 11 +- .../translations/ja-JP/objects.pipeline.ts | 13 +- src/sales/translations/zh-CN/app.ts | 45 ++- .../translations/zh-CN/objects.activity.ts | 16 +- .../translations/zh-CN/objects.commerce.ts | 16 +- .../translations/zh-CN/objects.customer.ts | 26 +- .../translations/zh-CN/objects.pipeline.ts | 36 +-- src/sales/views/lead.view.ts | 8 +- src/sales/views/opportunity.view.ts | 2 +- test/lead-duplicate-management.test.ts | 13 +- test/quote-accepted-payment-terms.test.ts | 29 +- test/view-references.test.ts | 43 ++- 51 files changed, 726 insertions(+), 726 deletions(-) create mode 100644 src/revenue/picklists/index.ts create mode 100644 src/revenue/picklists/payment_terms.picklist.ts delete mode 100644 src/sales/objects/_picklists.ts create mode 100644 src/sales/picklists/index.ts create mode 100644 src/sales/picklists/industry.picklist.ts create mode 100644 src/sales/picklists/lead_source.picklist.ts create mode 100644 src/sales/picklists/related_to_type.picklist.ts create mode 100644 src/sales/picklists/salutation.picklist.ts diff --git a/objectstack.composition.ts b/objectstack.composition.ts index 2223c9f4e..fc0d2a96a 100644 --- a/objectstack.composition.ts +++ b/objectstack.composition.ts @@ -51,6 +51,9 @@ import * as serviceObjects from './src/service/objects/index.js'; import * as revenueObjects from './src/revenue/objects/index.js'; import * as marketingObjects from './src/marketing/objects/index.js'; +import * as salesPicklists from './src/sales/picklists/index.js'; +import * as revenuePicklists from './src/revenue/picklists/index.js'; + import * as salesActions from './src/sales/actions/index.js'; import * as serviceActions from './src/service/actions/index.js'; import * as marketingActions from './src/marketing/actions/index.js'; @@ -260,6 +263,14 @@ export const allObjects = byExportName({ ...salesObjects, ...serviceObjects, ...revenueObjects, ...marketingObjects, }); +/** + * Every shared option list (`*.picklist.ts`) — the lists a select field + * references by name with `Field.select({ picklist })` instead of copying + * options. A field naming a list registered here by nothing is refused by + * `pnpm validate` and fails the boot, so this line is load-bearing. + */ +export const allPicklists = byExportName({ ...salesPicklists, ...revenuePicklists }); + /** Every UI / AI-callable action. */ export const allActions = byExportName({ ...salesActions, ...serviceActions, ...marketingActions }); diff --git a/objectstack.config.ts b/objectstack.config.ts index c6a4252fd..fee52c03f 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -7,7 +7,7 @@ import { defineStack } from '@objectstack/spec'; // live in this file (⛔ this module may carry no named export: the build parses // it against a `.strict` stack schema and fails on any key but the default). import { - allObjects, allActions, allDashboards, allDatasets, allReports, + allObjects, allPicklists, allActions, allDashboards, allDatasets, allReports, allMappings, allApps, allViews, allPages, allTranslations, allEmailTemplates, allProfiles, allHooks, allFlows, allSkills, CrmSharingRules, CrmPositions, @@ -144,6 +144,11 @@ export default defineStack({ requires: ['automation', 'triggers', 'analytics', 'auth', 'ui', 'approvals', 'sharing', 'hierarchy-security'], objects: allObjects, + // Shared option lists (#2000, objectstack#18164). Each select field that + // offers one names it — `Field.select({ picklist: 'industry' })` — and the + // platform serves that field with the list's resolved options, refuses a + // write outside them, and relabels them from `picklists.` per locale. + picklists: allPicklists, actions: allActions, dashboards: allDashboards, datasets: allDatasets, diff --git a/src/revenue/objects/contract.object.ts b/src/revenue/objects/contract.object.ts index 45fd82133..289a1d053 100644 --- a/src/revenue/objects/contract.object.ts +++ b/src/revenue/objects/contract.object.ts @@ -2,7 +2,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { P } from '@objectstack/spec'; -import { PAYMENT_TERMS_OPTIONS } from '../../sales/objects/_picklists'; /** * Contract Object @@ -146,12 +145,12 @@ export const Contract = ObjectSchema.create({ payment_terms: Field.select({ label: 'Payment Terms', group: 'value', - // Canonical set shared with Quote: an accepted quote's terms (incl. + // The list shared with Quote: an accepted quote's terms (incl. // due_on_receipt) must survive the copy onto the contract. That copy is - // `quote_on_accepted` (src/objects/quote.hook.ts). This field's `net_30` - // option default applies only when the quote itself carried no term, - // which is the intended fall-through, not a gap. - options: [...PAYMENT_TERMS_OPTIONS], + // `quote_on_accepted` (src/revenue/objects/quote.hook.ts). The list's + // `net_30` option default applies only when the quote itself carried no + // term, which is the intended fall-through, not a gap. + picklist: 'payment_terms', }), // Renewal diff --git a/src/revenue/objects/quote.object.ts b/src/revenue/objects/quote.object.ts index 03861deb5..ee6871154 100644 --- a/src/revenue/objects/quote.object.ts +++ b/src/revenue/objects/quote.object.ts @@ -2,7 +2,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { F, P, cel } from '@objectstack/spec'; -import { PAYMENT_TERMS_OPTIONS } from '../../sales/objects/_picklists'; import { QUOTE_DISCOUNT_CEILING } from '../../sales/objects/_thresholds'; /** @@ -228,8 +227,9 @@ export const Quote = ObjectSchema.create({ payment_terms: Field.select({ label: 'Payment Terms', group: 'terms', - // Canonical set shared with Contract (#490) — see _picklists.ts. - options: [...PAYMENT_TERMS_OPTIONS], + // The list shared with Contract (#490) — + // `src/revenue/picklists/payment_terms.picklist.ts`. + picklist: 'payment_terms', }), shipping_terms: Field.text({ diff --git a/src/revenue/picklists/index.ts b/src/revenue/picklists/index.ts new file mode 100644 index 000000000..0f1f19f01 --- /dev/null +++ b/src/revenue/picklists/index.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Revenue picklists barrel. + * + * The doctrine for this metadata type is written once, in + * `src/sales/picklists/index.ts`; this file is the revenue package's half of + * the same file-by-file registration. `payment_terms` lives here because both + * fields that reference it — `crm_quote.payment_terms` and + * `crm_contract.payment_terms` — are this package's. + */ + +export { PaymentTermsPicklist } from './payment_terms.picklist'; diff --git a/src/revenue/picklists/payment_terms.picklist.ts b/src/revenue/picklists/payment_terms.picklist.ts new file mode 100644 index 000000000..57dbfa1f1 --- /dev/null +++ b/src/revenue/picklists/payment_terms.picklist.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { definePicklist } from '@objectstack/spec/data'; + +/** + * Payment Terms — `crm_quote.payment_terms` and `crm_contract.payment_terms`. + * + * One list, referenced by name from both fields: an accepted quote's terms + * carry over to the contract, so the contract vocabulary must cover every + * quote value (`due_on_receipt` was Quote-only before the two were unified). + * Both fields are this package's, so the list is too — the lowest package all + * its consumers depend on. + * + * The carry-over is `quote_on_accepted` in `src/revenue/objects/quote.hook.ts`, + * named here because this rationale spent its first months unenforced (#873): + * the hook drafted the contract without `payment_terms`, so every accepted + * quote produced a contract on the `net_30` default below regardless of what + * was negotiated — a shared vocabulary justified by a copy that did not exist. + * A comment that names its enforcer can be checked; this one could not be. + * + * ⛔ Not `crm_opportunity.payment_terms`, which is a different vocabulary on + * purpose — see that field. + */ +export const PaymentTermsPicklist = definePicklist({ + name: 'payment_terms', + label: 'Payment Terms', + description: 'When an invoice falls due — shared by quotes and the contracts they become.', + options: [ + { label: 'Net 15', value: 'net_15' }, + { label: 'Net 30', value: 'net_30', default: true }, + { label: 'Net 60', value: 'net_60' }, + { label: 'Net 90', value: 'net_90' }, + { label: 'Due on Receipt', value: 'due_on_receipt' }, + ], +}); diff --git a/src/sales/actions/activity-actions.ts b/src/sales/actions/activity-actions.ts index 55c85571e..739b73ea3 100644 --- a/src/sales/actions/activity-actions.ts +++ b/src/sales/actions/activity-actions.ts @@ -87,8 +87,8 @@ const nameFieldMap = (objectDefs: Record): Record ({ label, value })), } ], successMessage: 'Opportunities updated successfully!', diff --git a/src/sales/dashboards/executive.dashboard.ts b/src/sales/dashboards/executive.dashboard.ts index d7f915392..e5bc8c373 100644 --- a/src/sales/dashboards/executive.dashboard.ts +++ b/src/sales/dashboards/executive.dashboard.ts @@ -1,29 +1,28 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Dashboard, InlineLocaleMap } from '@objectstack/spec/ui'; -import { LEAD_SOURCE_OPTIONS } from '../objects/_picklists'; +import { LeadSourcePicklist } from '../picklists/lead_source.picklist'; import { pipelineByStageFunnelWidget } from './shared-widgets'; /** * `lead_source` filter option labels, keyed by the CANONICAL stored value. * * Only the WORDING is written here. The ROSTER is derived below from - * {@link LEAD_SOURCE_OPTIONS} — the one constant `crm_lead`, `crm_contact` and - * `crm_opportunity` each spread into their own `lead_source` field — so this + * {@link LeadSourcePicklist} — the one `lead_source` picklist `crm_lead`, + * `crm_contact` and `crm_opportunity` each reference by name — so this * control can only ever offer values a record can actually hold. It used to * hand-copy six of the twelve, and one of those six was spelled `advertising` * against a canonical `advertisement`: picking it ANDed a term no row matches * into every bound widget and the whole dashboard read zero with no error and * no empty state (#1849). * - * A value added to the constant with no row here renders `label: undefined`, + * A value added to the picklist with no row here renders `label: undefined`, * which `pnpm validate` rejects by name — the roster cannot drift back * silently, which is the property a second hand-copy could not have. * * The strings are the language packs' own, copied from - * `src/translations//objects.pipeline.ts` - * (`crm_lead.fields.lead_source.options`; ja-JP resolves them through - * `src/translations/ja-JP/_shared.ts`), so the filter bar does not coin a + * `src/sales/translations//app.ts` (`picklists.lead_source.options`, + * which every `lead_source` field inherits), so the filter bar does not coin a * second vocabulary for a value the record page already labels. They are * spelled inline rather than reached through `GlobalFilterSchema.object`, * which is inert in this Console build — measured on #1822, see `./index.ts`. @@ -102,8 +101,8 @@ export const ExecutiveDashboard: Dashboard = { type: 'select', scope: 'dashboard', // Roster derived, wording from the language packs — see - // LEAD_SOURCE_FILTER_LABELS above. Order follows the canonical constant. - options: LEAD_SOURCE_OPTIONS.map(({ value }) => ({ + // LEAD_SOURCE_FILTER_LABELS above. Order follows the picklist. + options: LeadSourcePicklist.options.map(({ value }) => ({ value, label: LEAD_SOURCE_FILTER_LABELS[String(value)], })), diff --git a/src/sales/flows/schedule-followup.flow.ts b/src/sales/flows/schedule-followup.flow.ts index b29dbac01..d042fb9c3 100644 --- a/src/sales/flows/schedule-followup.flow.ts +++ b/src/sales/flows/schedule-followup.flow.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type * as Automation from '@objectstack/spec/automation'; -import { TASK_TYPE_OPTIONS, plainOptions } from '../objects/_picklists'; +import { Task } from '../objects/task.object'; type Flow = Automation.Flow; /** @@ -46,9 +46,11 @@ export const ScheduleFollowUpFlow: Flow = { { name: 'dueDate', label: 'Due Date', type: 'date', required: true }, { name: 'activityType', label: 'Activity Type', type: 'select', - // Mirrors crm_task.type exactly. ⛔ Never hand-copy the subset — it - // silently drops an option (`other`) from the picker. - options: plainOptions(TASK_TYPE_OPTIONS), + // crm_task.type's OWN options, read off the object definition and + // projected to bare `{ label, value }` pairs (a screen select has no + // use for colors or the default). ⛔ Never hand-copy the subset — it + // silently drops an option (`other`) from the picker (#490). + options: Task.fields.type.options!.map(({ label, value }) => ({ label, value })), }, { name: 'priority', label: 'Priority', type: 'select', diff --git a/src/sales/mappings/_shared.ts b/src/sales/mappings/_shared.ts index b8616d6d2..c1a794380 100644 --- a/src/sales/mappings/_shared.ts +++ b/src/sales/mappings/_shared.ts @@ -21,7 +21,7 @@ * intended outcome for everything else. */ -/** Foreign spellings → `INDUSTRY_OPTIONS` values (`src/objects/_picklists.ts`). */ +/** Foreign spellings → `industry` picklist values (`src/sales/picklists/industry.picklist.ts`). */ export const INDUSTRY_SYNONYMS: Record = { 'SaaS': 'software', 'Saas': 'software', @@ -46,7 +46,7 @@ export const INDUSTRY_SYNONYMS: Record = { 'Travel & Hospitality': 'hospitality', }; -/** Foreign spellings → `LEAD_SOURCE_OPTIONS` values. */ +/** Foreign spellings → `lead_source` picklist values (`src/sales/picklists/lead_source.picklist.ts`). */ export const LEAD_SOURCE_SYNONYMS: Record = { 'Website': 'web', 'Web Site': 'web', diff --git a/src/sales/objects/_picklists.ts b/src/sales/objects/_picklists.ts deleted file mode 100644 index 2e1b50646..000000000 --- a/src/sales/objects/_picklists.ts +++ /dev/null @@ -1,286 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SelectOption } from '@objectstack/spec/data'; - -/** - * Canonical picklist value sets shared across objects, flows, and actions. - * - * Several fields carry the same business vocabulary on more than one object - * (`lead_source` on Lead/Contact/Opportunity, `industry` on Lead/Account, - * `salutation` on Lead/Contact, `payment_terms` on Quote/Contract), and a few - * flows/actions re-declare an object's own options inline (Task `type` in the - * schedule_followup screen, Opportunity `stage` in mass_update_stage). Each - * copy drifted independently (#490): the lead_conversion flow copies - * `leadRecord.industry` / `leadRecord.lead_source` verbatim onto the created - * Account/Opportunity, so any Lead-only value (`logistics`, `webinar`, …) - * became an illegal enum value on the target object. - * - * Declaring the set ONCE here and spreading it into every declaration keeps - * the vocabularies identical by construction. Cross-object copies (flows, - * conversion mappings) are then always legal. - * - * NOTE: spread (`[...X]`) at every use site so each ObjectSchema gets its own - * array instance — schemas must not alias one another's metadata. - */ - -/** Salutation — Lead + Contact. Superset (incl. `prof`, formerly Contact-only). */ -export const SALUTATION_OPTIONS: SelectOption[] = [ - { label: 'Mr.', value: 'mr' }, - { label: 'Ms.', value: 'ms' }, - { label: 'Mrs.', value: 'mrs' }, - { label: 'Dr.', value: 'dr' }, - { label: 'Prof.', value: 'prof' }, -]; - -/** - * Industry — Lead + Account. Superset (formerly 15 on Lead, 6 on Account); - * lead_conversion copies `leadRecord.industry` onto the created Account, so - * Account MUST accept every Lead value. - */ -export const INDUSTRY_OPTIONS: SelectOption[] = [ - { label: 'Technology', value: 'technology' }, - { label: 'Software / SaaS', value: 'software' }, - { label: 'Finance', value: 'finance' }, - { label: 'Healthcare', value: 'healthcare' }, - { label: 'Retail', value: 'retail' }, - { label: 'Manufacturing', value: 'manufacturing' }, - { label: 'Education', value: 'education' }, - { label: 'Real Estate', value: 'real_estate' }, - { label: 'Media & Entertainment', value: 'media' }, - { label: 'Logistics', value: 'logistics' }, - { label: 'Hospitality', value: 'hospitality' }, - { label: 'Energy & Utilities', value: 'energy' }, - { label: 'Government', value: 'government' }, - { label: 'Non-profit', value: 'nonprofit' }, - { label: 'Other', value: 'other' }, -]; - -/** - * Lead Source — Lead + Contact + Opportunity. Superset (formerly 12/5/6); - * lead_conversion copies `leadRecord.lead_source` onto the created - * Opportunity, so Opportunity MUST accept every Lead value. - */ -export const LEAD_SOURCE_OPTIONS: SelectOption[] = [ - { label: 'Web', value: 'web' }, - { label: 'Referral', value: 'referral' }, - { label: 'Event / Trade Show', value: 'event' }, - { label: 'Webinar', value: 'webinar' }, - { label: 'Partner', value: 'partner' }, - { label: 'Advertisement', value: 'advertisement' }, - { label: 'Paid Search', value: 'paid_search' }, - { label: 'Social Media', value: 'social' }, - { label: 'Content / Blog', value: 'content' }, - { label: 'Cold Call', value: 'cold_call' }, - { label: 'Email Campaign', value: 'email_campaign' }, - { label: 'Other', value: 'other' }, -]; - -/** - * Payment Terms — Quote + Contract. Superset (incl. `due_on_receipt`, - * formerly Quote-only): an accepted quote's terms carry over to the contract, - * so the contract vocabulary must cover every quote value. - * - * The carry-over is `quote_on_accepted` in `src/objects/quote.hook.ts`, named - * here because this rationale spent its first months unenforced (#873): the - * hook drafted the contract without `payment_terms`, so every accepted quote - * produced a contract on the `net_30` option default below regardless of what - * was negotiated — a shared vocabulary justified by a copy that did not exist. - * A comment that names its enforcer can be checked; this one could not be. - */ -export const PAYMENT_TERMS_OPTIONS: SelectOption[] = [ - { label: 'Net 15', value: 'net_15' }, - { label: 'Net 30', value: 'net_30', default: true }, - { label: 'Net 60', value: 'net_60' }, - { label: 'Net 90', value: 'net_90' }, - { label: 'Due on Receipt', value: 'due_on_receipt' }, -]; - -/** - * Task Type — crm_task.type, also re-rendered as the `activityType` select in - * the schedule_followup screen flow (which had drifted to a 5-value copy - * missing `other`). - */ -export const TASK_TYPE_OPTIONS: SelectOption[] = [ - { label: 'Call', value: 'call' }, - { label: 'Email', value: 'email' }, - { label: 'Meeting', value: 'meeting' }, - { label: 'Follow-up', value: 'follow_up' }, - { label: 'Demo', value: 'demo' }, - { label: 'Other', value: 'other' }, -]; - -/** - * Opportunity Stage — crm_opportunity.stage, also re-rendered as the `stage` - * param of the mass_update_stage action. Colors/default only matter on the - * object schema; use {@link plainOptions} where a screen/param select wants - * bare label/value pairs. - */ -export const OPPORTUNITY_STAGE_OPTIONS: SelectOption[] = [ - { label: 'Prospecting', value: 'prospecting', color: '#808080', default: true }, - { label: 'Qualification', value: 'qualification', color: '#FFA500' }, - { label: 'Needs Analysis', value: 'needs_analysis', color: '#FFD700' }, - { label: 'Proposal', value: 'proposal', color: '#4169E1' }, - { label: 'Negotiation', value: 'negotiation', color: '#9370DB' }, - { label: 'Closed Won', value: 'closed_won', color: '#00AA00' }, - { label: 'Closed Lost', value: 'closed_lost', color: '#FF0000' }, -]; - -/** - * Event Type — crm_event.type, and the `kind` discriminator the per-object - * activity actions stamp on the `crm_event` row they insert (#592). - * - * Distinct from {@link TASK_TYPE_OPTIONS} on purpose: a Task is something a rep - * still owes someone, an Event is an interaction that occupies a slot on the - * calendar. `email` is therefore absent here (an email is not a meeting slot; - * it stays a `sys_email` + `sys_activity` pair) and `webinar` / `onsite_visit` - * are present, because those are the meeting shapes a rep books. - */ -export const EVENT_TYPE_OPTIONS: SelectOption[] = [ - { label: 'Meeting', value: 'meeting', color: '#4169E1', default: true }, - { label: 'Call', value: 'call', color: '#00AA00' }, - { label: 'Demo', value: 'demo', color: '#9370DB' }, - { label: 'Webinar', value: 'webinar', color: '#FFA500' }, - { label: 'Onsite Visit', value: 'onsite_visit', color: '#0EA5E9' }, - { label: 'Other', value: 'other', color: '#808080' }, -]; - -/** - * Event Status — crm_event.status. - * - * The `planned` → `held` transition is what separates "a meeting is booked" - * from "an interaction happened", and only the second one bumps - * `crm_account.last_activity_date` (see `event.hook.ts`). Without that split a - * meeting booked for next quarter would reset the churn clock today. - */ -export const EVENT_STATUS_OPTIONS: SelectOption[] = [ - { label: 'Planned', value: 'planned', color: '#4169E1', default: true }, - { label: 'Held', value: 'held', color: '#00AA00' }, - { label: 'Cancelled', value: 'cancelled', color: '#999999' }, - { label: 'No Show', value: 'no_show', color: '#FF4500' }, -]; - -/** - * Attendee Response — crm_event_attendee.response. - * - * The reason attendees are RECORDS rather than a JSON string on the activity - * (#592 acceptance): a per-attendee response only exists if the attendee does. - */ -export const ATTENDEE_RESPONSE_OPTIONS: SelectOption[] = [ - { label: 'No Response', value: 'no_response', color: '#808080', default: true }, - { label: 'Accepted', value: 'accepted', color: '#00AA00' }, - { label: 'Declined', value: 'declined', color: '#FF0000' }, - { label: 'Tentative', value: 'tentative', color: '#FFA500' }, -]; - -/** - * Polymorphic "Related To" type — crm_task.related_to_type and - * crm_event.related_to_type. - * - * Both activity objects carry the same five `related_to_*` lookups, and both - * hooks bubble recency through the same `related_to_type → lookup field` map. - * Declared once so the vocabulary of the discriminator cannot drift from the - * set of lookups that back it — a drift that reads as "the bubble silently - * stopped firing for one object type". - */ -export const RELATED_TO_TYPE_OPTIONS: SelectOption[] = [ - { label: 'Account', value: 'crm_account' }, - { label: 'Contact', value: 'crm_contact' }, - { label: 'Opportunity', value: 'crm_opportunity' }, - { label: 'Lead', value: 'crm_lead' }, - { label: 'Case', value: 'crm_case' }, -]; - -/** - * Duplicate Of — crm_lead.duplicate_of_type, the discriminator half of the - * `duplicate_of_lead` / `duplicate_of_contact` pair (#598). - * - * Two vocabularies, deliberately, and the split is the enforcement: - * - * - {@link DUPLICATE_OF_TYPE_AUTHORABLE_OPTIONS} is what a human may pick. Each - * value names an object, and `lead.object.ts` pairs it with that object's - * lookup through `requiredWhen`, so choosing one demands a record. - * - {@link DUPLICATE_OF_TYPE_OPTIONS} is what the COLUMN may hold. It is the - * authorable set plus {@link DUPLICATE_OF_TYPE_ERASED}, a tombstone no - * author writes. - * - * The form spreads the authorable set (`src/views/lead.view.ts`) and the object - * spreads the full one, so the tombstone is unpickable BY CONSTRUCTION rather - * than by a hand-maintained exclusion list: a fourth authorable object type - * added below reaches the form automatically, and the tombstone never does. - */ -export const DUPLICATE_OF_TYPE_AUTHORABLE_OPTIONS: SelectOption[] = [ - { label: 'Lead', value: 'crm_lead' }, - { label: 'Contact', value: 'crm_contact' }, -]; - -/** - * The tombstone (#1164): "this lead was confirmed a duplicate of a record that - * has since been erased." - * - * It exists because on the record — which is all a validation can see, being - * evaluated against `{...previous, ...data}` — "the pointer was erased" and - * "this claim never named anyone" were the SAME state. Every way of teaching a - * rule to tolerate the first also admitted the second, which is why the erasure - * path could not be cleared by relaxing anything (measured in full on #1164). - * Giving the two states different values makes them different facts, and then - * no rule has to be loosened at all: the `requiredWhen` pairing fires only on - * `crm_lead` / `crm_contact` so it never sees this value, and - * `duplicate_disqualification_requires_survivor` asks for a NON-BLANK type, - * which this is. - * - * Deliberately not an object name: it is the answer to "which object holds the - * survivor" when the answer is "none, any more". - * - * ⚠️ `lead_duplicate_check` (`lead.hook.ts`, job 1c) is the ONLY writer, and it - * spells the value as an inline literal — L2 hook bodies run body-only in the - * QuickJS sandbox, so a module constant resolves at authoring time and arrives - * as `undefined` (same constraint as the SLA matrix in `case.hook.ts`). The two - * spellings are pinned together, and the writer pinned to that one site, by - * `test/lead-duplicate-management.test.ts`. - */ -export const DUPLICATE_OF_TYPE_ERASED = 'erased'; - -/** The full column vocabulary: what an author may pick, plus the tombstone. */ -export const DUPLICATE_OF_TYPE_OPTIONS: SelectOption[] = [ - ...DUPLICATE_OF_TYPE_AUTHORABLE_OPTIONS, - { label: 'Erased Record', value: DUPLICATE_OF_TYPE_ERASED }, -]; - -/** Project a canonical set down to bare `{ label, value }` pairs for flow - * screens and action params, which don't understand color/default keys. */ -export const plainOptions = (options: SelectOption[]): { label: string; value: string }[] => - options.map(({ label, value }) => ({ label, value })); - -/** - * Need Type — `crm_lead.need_type`, the answer to "what is this prospect - * asking for" (REQ-0005 step 6, 需求类型). - * - * A GENERIC STARTER SET, and that is the disposition rather than an apology - * for a short list: REQ-0005 rules the *slot* standard and the *values* - * configuration — "a customer's own demand taxonomy is overlay configuration - * the same way `industry` values are". So these seven are the vocabulary a - * fresh install routes and reports on until an overlay replaces them, and - * replacing them is a one-site edit because the set is declared here. - * - * ⚠️ NOT the same axis as `crm_opportunity.type`, and ⛔ never folded into it. - * That field answers "is this new business, an upgrade, a renewal or an - * expansion" — the customer RELATIONSHIP the deal changes. This one answers - * what the buyer wants delivered, which is orthogonal: a renewal of a support - * contract and a renewal of a consulting engagement differ here and not there. - * They are deliberately not shared, so ⛔ do not spread this set onto - * `crm_opportunity` to make a conversion mapping possible. - * - * SINGLE-valued, for the reason `crm_contact.buying_function` records: a - * grouped list view answers with one server-side aggregate per raw stored - * value, so a `multiple: true` column groups by the COMBINATION and loses the - * groupability REQ-0005 acceptance 1 asks for. - */ -export const LEAD_NEED_TYPE_OPTIONS: SelectOption[] = [ - { label: 'New Implementation', value: 'new_implementation' }, - { label: 'Expansion', value: 'expansion' }, - { label: 'Replacement', value: 'replacement' }, - { label: 'Renewal', value: 'renewal' }, - { label: 'Consulting', value: 'consulting' }, - { label: 'Support & Maintenance', value: 'support' }, - { label: 'Other', value: 'other' }, -]; diff --git a/src/sales/objects/account.object.ts b/src/sales/objects/account.object.ts index 45e43d8da..03e7c7653 100644 --- a/src/sales/objects/account.object.ts +++ b/src/sales/objects/account.object.ts @@ -2,7 +2,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { F } from '@objectstack/spec'; -import { INDUSTRY_OPTIONS } from './_picklists'; import { TERRITORY_OPTIONS } from './_territory'; export const Account = ObjectSchema.create({ @@ -304,10 +303,10 @@ export const Account = ObjectSchema.create({ industry: Field.select({ label: 'Industry', group: 'basic', - // Canonical set shared with Lead (#490): lead_conversion copies + // The list shared with Lead (#490): lead_conversion copies // `leadRecord.industry` onto the account it creates, so this must - // accept every Lead value. - options: [...INDUSTRY_OPTIONS], + // accept every Lead value — one list, referenced by name. + picklist: 'industry', }), description: Field.markdown({ diff --git a/src/sales/objects/contact.object.ts b/src/sales/objects/contact.object.ts index a8d036f5c..7ef795258 100644 --- a/src/sales/objects/contact.object.ts +++ b/src/sales/objects/contact.object.ts @@ -2,7 +2,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { F } from '@objectstack/spec'; -import { SALUTATION_OPTIONS, LEAD_SOURCE_OPTIONS } from './_picklists'; export const Contact = ObjectSchema.create({ name: 'crm_contact', @@ -38,8 +37,8 @@ export const Contact = ObjectSchema.create({ salutation: Field.select({ label: 'Salutation', group: 'identity', - // Canonical set shared with Lead (#490) — see _picklists.ts. - options: [...SALUTATION_OPTIONS], + // The list shared with Lead (#490) — `src/sales/picklists/salutation.picklist.ts`. + picklist: 'salutation', }), first_name: Field.text({ label: 'First Name', @@ -243,9 +242,9 @@ export const Contact = ObjectSchema.create({ lead_source: Field.select({ label: 'Lead Source', group: 'additional', - // Canonical set shared with Lead + Opportunity (#490) — a converted - // lead's source must remain representable on the contact. - options: [...LEAD_SOURCE_OPTIONS], + // The list shared with Lead + Opportunity (#490) — a converted lead's + // source must remain representable on the contact. + picklist: 'lead_source', }), description: Field.markdown({ diff --git a/src/sales/objects/event.object.ts b/src/sales/objects/event.object.ts index d3654351d..8bd5a4c21 100644 --- a/src/sales/objects/event.object.ts +++ b/src/sales/objects/event.object.ts @@ -2,11 +2,6 @@ import { P } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; -import { - EVENT_STATUS_OPTIONS, - EVENT_TYPE_OPTIONS, - RELATED_TO_TYPE_OPTIONS, -} from './_picklists'; /** * Event — a first-class interaction record (#592). @@ -91,7 +86,21 @@ export const Event = ObjectSchema.create({ // related list left this required field blank (same defect crm_task.status // carries a note about). defaultValue: 'meeting', - options: [...EVENT_TYPE_OPTIONS], + // Distinct from crm_task.type on purpose: a Task is something a rep still + // owes someone, an Event is an interaction that occupies a slot on the + // calendar. `email` is therefore absent here (an email is not a meeting + // slot; it stays a `sys_email` + `sys_activity` pair) and `webinar` / + // `onsite_visit` are present, because those are the meeting shapes a rep + // books. Also the `kind` discriminator the per-object activity actions + // stamp on the row they insert (#592). + options: [ + { label: 'Meeting', value: 'meeting', color: '#4169E1', default: true }, + { label: 'Call', value: 'call', color: '#00AA00' }, + { label: 'Demo', value: 'demo', color: '#9370DB' }, + { label: 'Webinar', value: 'webinar', color: '#FFA500' }, + { label: 'Onsite Visit', value: 'onsite_visit', color: '#0EA5E9' }, + { label: 'Other', value: 'other', color: '#808080' }, + ], }), status: Field.select({ @@ -101,7 +110,16 @@ export const Event = ObjectSchema.create({ storage: { notNull: true }, trackHistory: true, defaultValue: 'planned', - options: [...EVENT_STATUS_OPTIONS], + // The `planned` → `held` transition is what separates "a meeting is + // booked" from "an interaction happened", and only the second one bumps + // `crm_account.last_activity_date` (see `event.hook.ts`). Without that + // split a meeting booked for next quarter would reset the churn clock today. + options: [ + { label: 'Planned', value: 'planned', color: '#4169E1', default: true }, + { label: 'Held', value: 'held', color: '#00AA00' }, + { label: 'Cancelled', value: 'cancelled', color: '#999999' }, + { label: 'No Show', value: 'no_show', color: '#FF4500' }, + ], }), description: Field.markdown({ @@ -154,7 +172,8 @@ export const Event = ObjectSchema.create({ related_to_type: Field.select({ group: 'related', label: 'Related To Type', - options: [...RELATED_TO_TYPE_OPTIONS], + // The list shared with crm_task — `src/sales/picklists/related_to_type.picklist.ts`. + picklist: 'related_to_type', }), related_to_account: Field.lookup('crm_account', { diff --git a/src/sales/objects/event_attendee.object.ts b/src/sales/objects/event_attendee.object.ts index f503565ca..a52d91850 100644 --- a/src/sales/objects/event_attendee.object.ts +++ b/src/sales/objects/event_attendee.object.ts @@ -2,7 +2,6 @@ import { expression } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; -import { ATTENDEE_RESPONSE_OPTIONS } from './_picklists'; /** * The ways an attendee row can name a person — ONE declaration, not two lists @@ -325,7 +324,15 @@ export const EventAttendee = ObjectSchema.create({ storage: { notNull: true }, trackHistory: true, defaultValue: 'no_response', - options: [...ATTENDEE_RESPONSE_OPTIONS], + // The reason attendees are RECORDS rather than a JSON string on the + // activity (#592 acceptance): a per-attendee response only exists if the + // attendee does. + options: [ + { label: 'No Response', value: 'no_response', color: '#808080', default: true }, + { label: 'Accepted', value: 'accepted', color: '#00AA00' }, + { label: 'Declined', value: 'declined', color: '#FF0000' }, + { label: 'Tentative', value: 'tentative', color: '#FFA500' }, + ], }), is_organizer: Field.boolean({ diff --git a/src/sales/objects/lead.hook.ts b/src/sales/objects/lead.hook.ts index fb06a3db3..7f2b46e35 100644 --- a/src/sales/objects/lead.hook.ts +++ b/src/sales/objects/lead.hook.ts @@ -668,8 +668,8 @@ const leadDuplicateCheckHook: Hook = { // // ⚠️ `'erased'` is spelled INLINE on purpose. L2 hook bodies run // body-only in the QuickJS sandbox, so `DUPLICATE_OF_TYPE_ERASED` from - // `_picklists.ts` would resolve at authoring time and arrive as - // `undefined`. The canonical constant is the one in `_picklists.ts`; + // `lead.object.ts` would resolve at authoring time and arrive as + // `undefined`. The canonical constant is the one in `lead.object.ts`; // `test/lead-duplicate-management.test.ts` pins this literal to it and // pins this block as its only writer, so the two cannot drift. // diff --git a/src/sales/objects/lead.object.ts b/src/sales/objects/lead.object.ts index 04c31799e..7b9da1711 100644 --- a/src/sales/objects/lead.object.ts +++ b/src/sales/objects/lead.object.ts @@ -1,14 +1,62 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import type { SelectOption } from '@objectstack/spec/data'; import { F, P } from '@objectstack/spec'; -import { - SALUTATION_OPTIONS, - INDUSTRY_OPTIONS, - LEAD_SOURCE_OPTIONS, - LEAD_NEED_TYPE_OPTIONS, - DUPLICATE_OF_TYPE_OPTIONS, -} from './_picklists'; + +/** + * Duplicate Of — `crm_lead.duplicate_of_type`, the discriminator half of the + * `duplicate_of_lead` / `duplicate_of_contact` pair (#598). + * + * Two vocabularies, deliberately, and the split is the enforcement: + * + * - {@link DUPLICATE_OF_TYPE_AUTHORABLE_OPTIONS} is what a human may pick. + * Each value names an object, and the field below pairs it with that + * object's lookup through `requiredWhen`, so choosing one demands a record. + * - The field's own `options` are what the COLUMN may hold: the authorable + * set plus {@link DUPLICATE_OF_TYPE_ERASED}, a tombstone no author writes. + * + * The form spreads the authorable set (`src/sales/views/lead.view.ts`) and the + * field spreads it too, then appends the tombstone, so the tombstone is + * unpickable BY CONSTRUCTION rather than by a hand-maintained exclusion list: a + * third authorable object type added here reaches the form automatically, and + * the tombstone never does. + * + * Not a shared `picklist`: one field holds the full list, and the subset the + * form offers is not a field's option set at all, so neither half has a second + * field to share with (#2000). + */ +export const DUPLICATE_OF_TYPE_AUTHORABLE_OPTIONS: SelectOption[] = [ + { label: 'Lead', value: 'crm_lead' }, + { label: 'Contact', value: 'crm_contact' }, +]; + +/** + * The tombstone (#1164): "this lead was confirmed a duplicate of a record that + * has since been erased." + * + * It exists because on the record — which is all a validation can see, being + * evaluated against `{...previous, ...data}` — "the pointer was erased" and + * "this claim never named anyone" were the SAME state. Every way of teaching a + * rule to tolerate the first also admitted the second, which is why the erasure + * path could not be cleared by relaxing anything (measured in full on #1164). + * Giving the two states different values makes them different facts, and then + * no rule has to be loosened at all: the `requiredWhen` pairing fires only on + * `crm_lead` / `crm_contact` so it never sees this value, and + * `duplicate_disqualification_requires_survivor` asks for a NON-BLANK type, + * which this is. + * + * Deliberately not an object name: it is the answer to "which object holds the + * survivor" when the answer is "none, any more". + * + * ⚠️ `lead_duplicate_check` (`lead.hook.ts`, job 1c) is the ONLY writer, and it + * spells the value as an inline literal — L2 hook bodies run body-only in the + * QuickJS sandbox, so a module constant resolves at authoring time and arrives + * as `undefined` (same constraint as the SLA matrix in `case.hook.ts`). The two + * spellings are pinned together, and the writer pinned to that one site, by + * `test/lead-duplicate-management.test.ts`. + */ +export const DUPLICATE_OF_TYPE_ERASED = 'erased'; export const Lead = ObjectSchema.create({ name: 'crm_lead', @@ -47,8 +95,8 @@ export const Lead = ObjectSchema.create({ salutation: Field.select({ label: 'Salutation', group: 'identity', - // Canonical set shared with Contact (#490) — see _picklists.ts. - options: [...SALUTATION_OPTIONS], + // The list shared with Contact (#490) — `src/sales/picklists/salutation.picklist.ts`. + picklist: 'salutation', }), first_name: Field.text({ @@ -138,9 +186,10 @@ export const Lead = ObjectSchema.create({ industry: Field.select({ label: 'Industry', group: 'company_info', - // Canonical set shared with Account (#490): lead_conversion copies this - // value onto the created Account, so both objects MUST agree. - options: [...INDUSTRY_OPTIONS], + // The list shared with Account (#490): lead_conversion copies this value + // onto the created Account, so both objects MUST agree — one list, + // referenced by name, makes that true by construction. + picklist: 'industry', }), // ⚠️ NOT `unique`. A hard uniqueness constraint on a lead's email says a @@ -206,9 +255,9 @@ export const Lead = ObjectSchema.create({ lead_source: Field.select({ label: 'Lead Source', group: 'qualification', - // Canonical set shared with Contact + Opportunity (#490): lead_conversion + // The list shared with Contact + Opportunity (#490): lead_conversion // copies this value onto the created Opportunity, so all three MUST agree. - options: [...LEAD_SOURCE_OPTIONS], + picklist: 'lead_source', }), // ── Demand: what this prospect wants, and what it is worth ───────── @@ -219,13 +268,39 @@ export const Lead = ObjectSchema.create({ // required: a lead captured off the public form has neither, and saves // exactly as it did before. + // A GENERIC STARTER SET, and that is the disposition rather than an apology + // for a short list: REQ-0005 rules the *slot* standard and the *values* + // configuration — "a customer's own demand taxonomy is overlay + // configuration the same way `industry` values are". So these seven are the + // vocabulary a fresh install routes and reports on until an overlay + // replaces them, and replacing them is a one-site edit: this field. + // + // ⚠️ NOT the same axis as `crm_opportunity.type`, and ⛔ never folded into + // it. That field answers "is this new business, an upgrade, a renewal or an + // expansion" — the customer RELATIONSHIP the deal changes. This one answers + // what the buyer wants delivered, which is orthogonal: a renewal of a + // support contract and a renewal of a consulting engagement differ here and + // not there. They are deliberately not shared, so ⛔ do not turn this list + // into a `picklist` and point `crm_opportunity` at it to make a conversion + // mapping possible. + // + // SINGLE-valued, for the reason `crm_contact.buying_function` records: a + // grouped list view answers with one server-side aggregate per raw stored + // value, so a `multiple: true` column groups by the COMBINATION and loses + // the groupability REQ-0005 acceptance 1 asks for. need_type: Field.select({ label: 'Need Type', group: 'qualification', description: 'What this prospect is asking for. Routing, prioritisation and demand reporting read this.', - // Generic starter vocabulary, declared once in `_picklists.ts` — see the - // note there for why it is NOT shared with `crm_opportunity.type`. - options: [...LEAD_NEED_TYPE_OPTIONS], + options: [ + { label: 'New Implementation', value: 'new_implementation' }, + { label: 'Expansion', value: 'expansion' }, + { label: 'Replacement', value: 'replacement' }, + { label: 'Renewal', value: 'renewal' }, + { label: 'Consulting', value: 'consulting' }, + { label: 'Support & Maintenance', value: 'support' }, + { label: 'Other', value: 'other' }, + ], }), // ⛔ NEVER conflated with `annual_revenue`, which sits two groups below in @@ -435,9 +510,9 @@ export const Lead = ObjectSchema.create({ // one select naming the object, one lookup per object, and the pairing // enforced declaratively. // - // The vocabulary is TWO sets, declared and split in `_picklists.ts`: the two - // object names an author may pick, plus `erased` — a tombstone the form does - // not offer and only `lead_duplicate_check` ever writes. + // The vocabulary is TWO sets, declared and split at the top of this file: the + // two object names an author may pick, plus `erased` — a tombstone the form + // does not offer and only `lead_duplicate_check` ever writes. // // The tombstone is what lets an erasure COMPLETE against a lead a human // confirmed as a duplicate, without relaxing one rule, so read it as a @@ -452,7 +527,10 @@ export const Lead = ObjectSchema.create({ label: 'Duplicate Of', group: 'duplicates', description: 'Which object holds the surviving record this lead repeats.', - options: [...DUPLICATE_OF_TYPE_OPTIONS], + options: [ + ...DUPLICATE_OF_TYPE_AUTHORABLE_OPTIONS, + { label: 'Erased Record', value: DUPLICATE_OF_TYPE_ERASED }, + ], }), // ⚠️ The type↔lookup pairing is `requiredWhen`, not a script validation: diff --git a/src/sales/objects/opportunity.object.ts b/src/sales/objects/opportunity.object.ts index 1a669f0d0..fcef52c14 100644 --- a/src/sales/objects/opportunity.object.ts +++ b/src/sales/objects/opportunity.object.ts @@ -2,7 +2,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { F, P } from '@objectstack/spec'; -import { LEAD_SOURCE_OPTIONS, OPPORTUNITY_STAGE_OPTIONS } from './_picklists'; export const Opportunity = ObjectSchema.create({ name: 'crm_opportunity', @@ -232,9 +231,20 @@ export const Opportunity = ObjectSchema.create({ // ADR-0052 §5b.1 — the platform auto-renders each stage change on the // activity timeline as "Stage: Proposal → Negotiation" (no hook code). trackHistory: true, - // Canonical set (#490) — the mass_update_stage action renders the same - // list; see _picklists.ts. - options: [...OPPORTUNITY_STAGE_OPTIONS], + // The one stage list. The mass_update_stage action + // (`src/sales/actions/opportunity.actions.ts`) renders this field's own + // options rather than a copy, projected to bare `{ label, value }` pairs + // — colors and the default mean something only here. A single field + // owns it, so it is inline rather than a shared `picklist` (#2000). + options: [ + { label: 'Prospecting', value: 'prospecting', color: '#808080', default: true }, + { label: 'Qualification', value: 'qualification', color: '#FFA500' }, + { label: 'Needs Analysis', value: 'needs_analysis', color: '#FFD700' }, + { label: 'Proposal', value: 'proposal', color: '#4169E1' }, + { label: 'Negotiation', value: 'negotiation', color: '#9370DB' }, + { label: 'Closed Won', value: 'closed_won', color: '#00AA00' }, + { label: 'Closed Lost', value: 'closed_lost', color: '#FF0000' }, + ], }), probability: Field.percent({ @@ -339,10 +349,10 @@ export const Opportunity = ObjectSchema.create({ lead_source: Field.select({ label: 'Lead Source', group: 'classification', - // Canonical set shared with Lead + Contact (#490): lead_conversion - // copies `leadRecord.lead_source` onto the opportunity it creates, so - // this must accept every Lead value. - options: [...LEAD_SOURCE_OPTIONS], + // The list shared with Lead + Contact (#490): lead_conversion copies + // `leadRecord.lead_source` onto the opportunity it creates, so this must + // accept every Lead value — one list, referenced by name. + picklist: 'lead_source', }), @@ -398,8 +408,8 @@ export const Opportunity = ObjectSchema.create({ // ⚠️ `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. + // carries. ⛔ Do not "unify" it onto the `payment_terms` picklist — that + // would force a half-agreed term into a closed vocabulary. // // 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 — diff --git a/src/sales/objects/task.object.ts b/src/sales/objects/task.object.ts index dad292c31..65e6fdff7 100644 --- a/src/sales/objects/task.object.ts +++ b/src/sales/objects/task.object.ts @@ -2,7 +2,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { P } from '@objectstack/spec'; -import { TASK_TYPE_OPTIONS } from './_picklists'; export const Task = ObjectSchema.create({ name: 'crm_task', @@ -115,9 +114,19 @@ export const Task = ObjectSchema.create({ type: Field.select({ group: 'basic', label: 'Task Type', - // Canonical set (#490) — the schedule_followup screen renders the same - // list; see _picklists.ts. - options: [...TASK_TYPE_OPTIONS], + // The one task-type list. The schedule_followup screen + // (`src/sales/flows/schedule-followup.flow.ts`) renders this field's own + // options rather than a copy — it had drifted to a 5-value copy missing + // `other` (#490). A single field owns it, so it is inline rather than a + // shared `picklist` (#2000). + options: [ + { label: 'Call', value: 'call' }, + { label: 'Email', value: 'email' }, + { label: 'Meeting', value: 'meeting' }, + { label: 'Follow-up', value: 'follow_up' }, + { label: 'Demo', value: 'demo' }, + { label: 'Other', value: 'other' }, + ], }), // Dates @@ -141,13 +150,9 @@ export const Task = ObjectSchema.create({ related_to_type: Field.select({ group: 'related', label: 'Related To Type', - options: [ - { label: 'Account', value: 'crm_account' }, - { label: 'Contact', value: 'crm_contact' }, - { label: 'Opportunity', value: 'crm_opportunity' }, - { label: 'Lead', value: 'crm_lead' }, - { label: 'Case', value: 'crm_case' }, - ] + // The list shared with crm_event — `src/sales/picklists/related_to_type.picklist.ts`. + // This field used to carry a hand-copied five-value list of its own. + picklist: 'related_to_type', }), related_to_account: Field.lookup('crm_account', { diff --git a/src/sales/picklists/index.ts b/src/sales/picklists/index.ts new file mode 100644 index 000000000..55cd179c3 --- /dev/null +++ b/src/sales/picklists/index.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Sales (the app package) picklists barrel. + * + * A picklist is a shared option list: one `*.picklist.ts` per list, and every + * select field that offers it REFERENCES it by name — `Field.select({ + * picklist: 'industry' })` — instead of carrying a copy (objectstack#18164, + * hotcrm#2000). The platform resolves the reference when it serves the object, + * judges every write against the resolved set, and translates the list once + * under `picklists.` in each locale pack. + * + * A list lives in the lowest package all its fields depend on, which for these + * four is sales: every field that references them is a sales object's. A list + * only ONE field offers is not a picklist at all — it stays inline on that + * field, where the reader of the object finds it. + * + * Registration is explicit, file by file, like every other metadata type: a + * `*.picklist.ts` that is not re-exported here is registered by nothing, and a + * field referencing it is refused by `pnpm validate` by name. The names are + * plain snake_case with no `crm_` prefix — the prefix is the object naming + * rule (`defineStack` enforces it on `objects` only), and a picklist, like a + * flow or a dashboard, is not an object. + */ + +export { IndustryPicklist } from './industry.picklist'; +export { LeadSourcePicklist } from './lead_source.picklist'; +export { RelatedToTypePicklist } from './related_to_type.picklist'; +export { SalutationPicklist } from './salutation.picklist'; diff --git a/src/sales/picklists/industry.picklist.ts b/src/sales/picklists/industry.picklist.ts new file mode 100644 index 000000000..d7e6695c5 --- /dev/null +++ b/src/sales/picklists/industry.picklist.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { definePicklist } from '@objectstack/spec/data'; + +/** + * Industry — `crm_lead.industry` and `crm_account.industry`. + * + * One list, referenced by name from both fields, because `lead_conversion` + * copies `leadRecord.industry` onto the account it creates: the account MUST + * accept every lead value (#490). Before the two were unified the lead offered + * fifteen values and the account six, so a converted `logistics` lead wrote an + * illegal value onto its account. + * + * The import mapping's foreign spellings (`src/sales/mappings/_shared.ts`) map + * onto these values. Option labels translate once, under `picklists.industry` + * in each locale pack. + */ +export const IndustryPicklist = definePicklist({ + name: 'industry', + label: 'Industry', + description: 'The industry a company operates in — shared by leads and accounts.', + options: [ + { label: 'Technology', value: 'technology' }, + { label: 'Software / SaaS', value: 'software' }, + { label: 'Finance', value: 'finance' }, + { label: 'Healthcare', value: 'healthcare' }, + { label: 'Retail', value: 'retail' }, + { label: 'Manufacturing', value: 'manufacturing' }, + { label: 'Education', value: 'education' }, + { label: 'Real Estate', value: 'real_estate' }, + { label: 'Media & Entertainment', value: 'media' }, + { label: 'Logistics', value: 'logistics' }, + { label: 'Hospitality', value: 'hospitality' }, + { label: 'Energy & Utilities', value: 'energy' }, + { label: 'Government', value: 'government' }, + { label: 'Non-profit', value: 'nonprofit' }, + { label: 'Other', value: 'other' }, + ], +}); diff --git a/src/sales/picklists/lead_source.picklist.ts b/src/sales/picklists/lead_source.picklist.ts new file mode 100644 index 000000000..093071aa7 --- /dev/null +++ b/src/sales/picklists/lead_source.picklist.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { definePicklist } from '@objectstack/spec/data'; + +/** + * Lead Source — `crm_lead.lead_source`, `crm_contact.lead_source` and + * `crm_opportunity.lead_source`. + * + * One list, referenced by name from all three fields, because + * `lead_conversion` copies `leadRecord.lead_source` onto the opportunity it + * creates, and a converted lead's source must stay representable on its + * contact (#490). Before the three were unified they offered 12, 5 and 6 + * values, so a converted `webinar` lead wrote an illegal value downstream. + * + * Two non-field readers take the roster from here rather than from a copy: the + * executive dashboard's `lead_source` filter + * (`src/sales/dashboards/executive.dashboard.ts`) and the import mapping's + * foreign spellings (`src/sales/mappings/_shared.ts`). Option labels translate + * once, under `picklists.lead_source` in each locale pack. + */ +export const LeadSourcePicklist = definePicklist({ + name: 'lead_source', + label: 'Lead Source', + description: 'The channel a prospect arrived through — shared by leads, contacts and opportunities.', + options: [ + { label: 'Web', value: 'web' }, + { label: 'Referral', value: 'referral' }, + { label: 'Event / Trade Show', value: 'event' }, + { label: 'Webinar', value: 'webinar' }, + { label: 'Partner', value: 'partner' }, + { label: 'Advertisement', value: 'advertisement' }, + { label: 'Paid Search', value: 'paid_search' }, + { label: 'Social Media', value: 'social' }, + { label: 'Content / Blog', value: 'content' }, + { label: 'Cold Call', value: 'cold_call' }, + { label: 'Email Campaign', value: 'email_campaign' }, + { label: 'Other', value: 'other' }, + ], +}); diff --git a/src/sales/picklists/related_to_type.picklist.ts b/src/sales/picklists/related_to_type.picklist.ts new file mode 100644 index 000000000..9a372219d --- /dev/null +++ b/src/sales/picklists/related_to_type.picklist.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { definePicklist } from '@objectstack/spec/data'; + +/** + * Polymorphic "Related To" type — `crm_task.related_to_type` and + * `crm_event.related_to_type`. + * + * Both activity objects carry the same five `related_to_*` lookups, and both + * hooks bubble recency through the same `related_to_type → lookup field` map. + * One list, referenced by name from both fields, so the vocabulary of the + * discriminator cannot drift from the set of lookups that back it — a drift + * that reads as "the bubble silently stopped firing for one object type". + * (`crm_task` used to carry its own hand-copied five-value list beside the + * shared constant `crm_event` spread; the reference closes that copy.) + * + * Each value is an object name, written out with its `crm_` prefix. Option + * labels translate once, under `picklists.related_to_type` in each locale pack. + */ +export const RelatedToTypePicklist = definePicklist({ + name: 'related_to_type', + label: 'Related To Type', + description: 'Which kind of record an activity is about — shared by tasks and events.', + options: [ + { label: 'Account', value: 'crm_account' }, + { label: 'Contact', value: 'crm_contact' }, + { label: 'Opportunity', value: 'crm_opportunity' }, + { label: 'Lead', value: 'crm_lead' }, + { label: 'Case', value: 'crm_case' }, + ], +}); diff --git a/src/sales/picklists/salutation.picklist.ts b/src/sales/picklists/salutation.picklist.ts new file mode 100644 index 000000000..6caed9dcf --- /dev/null +++ b/src/sales/picklists/salutation.picklist.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { definePicklist } from '@objectstack/spec/data'; + +/** + * Salutation — `crm_lead.salutation` and `crm_contact.salutation`. + * + * One list, referenced by name from both fields (`Field.select({ picklist: + * 'salutation' })`), so the two vocabularies cannot drift apart (#490): a + * converted lead's salutation always has a place on the contact it becomes. + * `prof` was Contact-only before the two lists were unified. + * + * Option labels translate once, under `picklists.salutation` in each locale + * pack (`src/sales/translations//app.ts`); every referencing field + * inherits them. + */ +export const SalutationPicklist = definePicklist({ + name: 'salutation', + label: 'Salutation', + description: 'How a person is addressed — shared by leads and contacts.', + options: [ + { label: 'Mr.', value: 'mr' }, + { label: 'Ms.', value: 'ms' }, + { label: 'Mrs.', value: 'mrs' }, + { label: 'Dr.', value: 'dr' }, + { label: 'Prof.', value: 'prof' }, + ], +}); diff --git a/src/sales/translations/en/app.ts b/src/sales/translations/en/app.ts index 68dd25daf..d0113659c 100644 --- a/src/sales/translations/en/app.ts +++ b/src/sales/translations/en/app.ts @@ -4,7 +4,7 @@ import type { TranslationData } from '@objectstack/spec/system'; /** * English (en) — every translation namespace EXCEPT `objects`: - * `apps`, `messages`, `dashboards`, `pages`. + * `apps`, `messages`, `dashboards`, `pages`, `picklists`. * * SPLIT AXIS (#1311): translation NAMESPACE first, then CRM DOMAIN FAMILY. * Everything that is not `objects` lives in `./app.ts`; `objects` — 69-78% of @@ -228,4 +228,47 @@ export const appSurface: Omit = { }, }, }, + // Shared option lists (`src//picklists/*.picklist.ts`), translated ONCE: + // every field that references a list (`Field.select({ picklist })`) inherits + // these option labels, so a field-level `options` entry exists only for a + // field with inline options of its own. + picklists: { + salutation: { + label: 'Salutation', + options: { mr: 'Mr.', ms: 'Ms.', mrs: 'Mrs.', dr: 'Dr.', prof: 'Prof.' }, + }, + industry: { + label: 'Industry', + options: { + technology: 'Technology', software: 'Software / SaaS', finance: 'Finance', + healthcare: 'Healthcare', retail: 'Retail', manufacturing: 'Manufacturing', + education: 'Education', real_estate: 'Real Estate', media: 'Media & Entertainment', + logistics: 'Logistics', hospitality: 'Hospitality', energy: 'Energy & Utilities', + government: 'Government', nonprofit: 'Non-profit', other: 'Other', + }, + }, + lead_source: { + label: 'Lead Source', + options: { + web: 'Web', referral: 'Referral', event: 'Event / Trade Show', + webinar: 'Webinar', partner: 'Partner', advertisement: 'Advertisement', + paid_search: 'Paid Search', social: 'Social Media', content: 'Content / Blog', + cold_call: 'Cold Call', email_campaign: 'Email Campaign', other: 'Other', + }, + }, + payment_terms: { + label: 'Payment Terms', + options: { + net_15: 'Net 15', net_30: 'Net 30', net_60: 'Net 60', + net_90: 'Net 90', due_on_receipt: 'Due on Receipt', + }, + }, + related_to_type: { + label: 'Related To Type', + options: { + crm_account: 'Account', crm_contact: 'Contact', crm_opportunity: 'Opportunity', + crm_lead: 'Lead', crm_case: 'Case', + }, + }, + }, }; diff --git a/src/sales/translations/en/objects.activity.ts b/src/sales/translations/en/objects.activity.ts index 65de079ac..0b8199206 100644 --- a/src/sales/translations/en/objects.activity.ts +++ b/src/sales/translations/en/objects.activity.ts @@ -45,13 +45,7 @@ export const activity: Record = { reminder_date: { label: 'Reminder Date/Time' }, completed_date: { label: 'Completed Date' }, owner_id: { label: 'Assigned To' }, - related_to_type: { - label: 'Related To Type', - options: { - crm_account: 'Account', crm_contact: 'Contact', crm_opportunity: 'Opportunity', - crm_lead: 'Lead', crm_case: 'Case', - }, - }, + related_to_type: { label: 'Related To Type' }, related_to_account: { label: 'Related Account' }, related_to_contact: { label: 'Related Contact' }, related_to_opportunity: { label: 'Related Opportunity' }, @@ -119,13 +113,7 @@ export const activity: Record = { all_day: { label: 'All Day Event' }, duration_minutes: { label: 'Duration (minutes)' }, location: { label: 'Location', help: 'Room, address, or meeting link' }, - related_to_type: { - label: 'Related To Type', - options: { - crm_account: 'Account', crm_contact: 'Contact', crm_opportunity: 'Opportunity', - crm_lead: 'Lead', crm_case: 'Case', - }, - }, + related_to_type: { label: 'Related To Type' }, related_to_account: { label: 'Related Account' }, related_to_contact: { label: 'Related Contact' }, related_to_opportunity: { label: 'Related Opportunity' }, diff --git a/src/sales/translations/en/objects.commerce.ts b/src/sales/translations/en/objects.commerce.ts index 1d372ffec..1cbbe661e 100644 --- a/src/sales/translations/en/objects.commerce.ts +++ b/src/sales/translations/en/objects.commerce.ts @@ -45,13 +45,7 @@ export const commerce: Record = { tax: { label: 'Tax' }, shipping_handling: { label: 'Shipping & Handling' }, total_price: { label: 'Total Price' }, - payment_terms: { - label: 'Payment Terms', - options: { - net_15: 'Net 15', net_30: 'Net 30', net_60: 'Net 60', - net_90: 'Net 90', due_on_receipt: 'Due on Receipt', - }, - }, + payment_terms: { label: 'Payment Terms' }, shipping_terms: { label: 'Shipping Terms' }, billing_address: { label: 'Billing Address' }, shipping_address: { label: 'Shipping Address' }, @@ -127,13 +121,7 @@ export const commerce: Record = { annually: 'Annually', one_time: 'One-time', }, }, - payment_terms: { - label: 'Payment Terms', - options: { - net_15: 'Net 15', net_30: 'Net 30', net_60: 'Net 60', - net_90: 'Net 90', due_on_receipt: 'Due on Receipt', - }, - }, + payment_terms: { label: 'Payment Terms' }, auto_renewal: { label: 'Auto Renewal' }, renewal_notice_days: { label: 'Renewal Notice (Days)' }, contract_type: { diff --git a/src/sales/translations/en/objects.customer.ts b/src/sales/translations/en/objects.customer.ts index 293f7bd07..98251a212 100644 --- a/src/sales/translations/en/objects.customer.ts +++ b/src/sales/translations/en/objects.customer.ts @@ -49,16 +49,7 @@ export const customer: Record = { label: 'Type', options: { prospect: 'Prospect', customer: 'Customer', partner: 'Partner', former: 'Former Customer' }, }, - industry: { - label: 'Industry', - options: { - technology: 'Technology', software: 'Software / SaaS', finance: 'Finance', - healthcare: 'Healthcare', retail: 'Retail', manufacturing: 'Manufacturing', - education: 'Education', real_estate: 'Real Estate', media: 'Media & Entertainment', - logistics: 'Logistics', hospitality: 'Hospitality', energy: 'Energy & Utilities', - government: 'Government', nonprofit: 'Non-profit', other: 'Other', - }, - }, + industry: { label: 'Industry' }, annual_revenue: { label: 'Annual Revenue' }, child_account_revenue: { label: 'Child Account Revenue', help: 'Sum of the annual revenue of the direct children of this account.' }, number_of_employees: { label: 'Number of Employees' }, @@ -129,10 +120,7 @@ export const customer: Record = { label: 'Contact', pluralLabel: 'Contacts', fields: { - salutation: { - label: 'Salutation', - options: { mr: 'Mr.', ms: 'Ms.', mrs: 'Mrs.', dr: 'Dr.', prof: 'Prof.' }, - }, + salutation: { label: 'Salutation' }, first_name: { label: 'First Name' }, last_name: { label: 'Last Name' }, full_name: { label: 'Full Name' }, @@ -178,15 +166,7 @@ export const customer: Record = { is_primary: { label: 'Primary Contact', help: 'Is this the main contact for the account?' }, avatar: { label: 'Profile Picture' }, mailing_address: { label: 'Mailing Address' }, - lead_source: { - label: 'Lead Source', - options: { - web: 'Web', referral: 'Referral', event: 'Event / Trade Show', - webinar: 'Webinar', partner: 'Partner', advertisement: 'Advertisement', - paid_search: 'Paid Search', social: 'Social Media', content: 'Content / Blog', - cold_call: 'Cold Call', email_campaign: 'Email Campaign', other: 'Other', - }, - }, + lead_source: { label: 'Lead Source' }, do_not_call: { label: 'Do Not Call' }, email_opt_out: { label: 'Email Opt Out' }, last_contacted_date: { label: 'Last Contacted' }, diff --git a/src/sales/translations/en/objects.pipeline.ts b/src/sales/translations/en/objects.pipeline.ts index 588af4d63..9ead09989 100644 --- a/src/sales/translations/en/objects.pipeline.ts +++ b/src/sales/translations/en/objects.pipeline.ts @@ -36,33 +36,13 @@ export const pipeline: Record = { unqualified: 'Unqualified', converted: 'Converted', }, }, - lead_source: { - label: 'Lead Source', - options: { - web: 'Web', referral: 'Referral', event: 'Event / Trade Show', - webinar: 'Webinar', partner: 'Partner', advertisement: 'Advertisement', - paid_search: 'Paid Search', social: 'Social Media', content: 'Content / Blog', - cold_call: 'Cold Call', email_campaign: 'Email Campaign', other: 'Other', - }, - }, + lead_source: { label: 'Lead Source' }, owner_id: { label: 'Lead Owner' }, is_converted: { label: 'Converted' }, description: { label: 'Description' }, - salutation: { - label: 'Salutation', - options: { mr: 'Mr.', ms: 'Ms.', mrs: 'Mrs.', dr: 'Dr.', prof: 'Prof.' }, - }, + salutation: { label: 'Salutation' }, full_name: { label: 'Full Name' }, - industry: { - label: 'Industry', - options: { - technology: 'Technology', software: 'Software / SaaS', finance: 'Finance', - healthcare: 'Healthcare', retail: 'Retail', manufacturing: 'Manufacturing', - education: 'Education', real_estate: 'Real Estate', media: 'Media & Entertainment', - logistics: 'Logistics', hospitality: 'Hospitality', energy: 'Energy & Utilities', - government: 'Government', nonprofit: 'Non-profit', other: 'Other', - }, - }, + industry: { label: 'Industry' }, mobile: { label: 'Mobile' }, website: { label: 'Website' }, rating: { label: 'Lead Score', help: 'Lead quality score (1-5 stars)' }, @@ -246,15 +226,7 @@ export const pipeline: Record = { }, description: { label: 'Description' }, next_step: { label: 'Next Step' }, - lead_source: { - label: 'Lead Source', - options: { - web: 'Web', referral: 'Referral', event: 'Event / Trade Show', - webinar: 'Webinar', partner: 'Partner', advertisement: 'Advertisement', - paid_search: 'Paid Search', social: 'Social Media', content: 'Content / Blog', - cold_call: 'Cold Call', email_campaign: 'Email Campaign', other: 'Other', - }, - }, + lead_source: { label: 'Lead Source' }, crm_campaign: { label: 'Campaign', help: 'Marketing campaign that generated this opportunity' }, days_in_stage: { label: 'Days in Current Stage' }, stage_entry_date: { diff --git a/src/sales/translations/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index 9e6294147..cff08f875 100644 --- a/src/sales/translations/es-ES/app.ts +++ b/src/sales/translations/es-ES/app.ts @@ -6,7 +6,7 @@ import { QUOTE_DISCOUNT_CEILING } from '../../objects/_thresholds'; /** * Español (es-ES) — every translation namespace EXCEPT `objects`: - * `apps`, `messages`, `dashboards`, `datasets`, `pages`, `flows`. + * `apps`, `messages`, `dashboards`, `datasets`, `pages`, `flows`, `picklists`. * * SPLIT AXIS (#1311): translation NAMESPACE first, then CRM DOMAIN FAMILY. * Everything that is not `objects` lives in `./app.ts`; `objects` — 69-78% of @@ -681,4 +681,47 @@ export const appSurface: Omit = { }, }, }, + // Shared option lists (`src//picklists/*.picklist.ts`), translated ONCE: + // every field that references a list (`Field.select({ picklist })`) inherits + // these option labels, so a field-level `options` entry exists only for a + // field with inline options of its own. + picklists: { + salutation: { + label: 'Tratamiento', + options: { mr: 'Sr.', ms: 'Srta.', mrs: 'Sra.', dr: 'Dr.', prof: 'Prof.' }, + }, + industry: { + label: 'Industria', + options: { + technology: 'Tecnología', software: 'Software / SaaS', finance: 'Finanzas', + healthcare: 'Salud', retail: 'Comercio', manufacturing: 'Manufactura', + education: 'Educación', real_estate: 'Inmobiliaria', media: 'Medios y Entretenimiento', + logistics: 'Logística', hospitality: 'Hostelería', energy: 'Energía y Servicios Públicos', + government: 'Gobierno', nonprofit: 'Sin Ánimo de Lucro', other: 'Otro', + }, + }, + lead_source: { + label: 'Origen del Prospecto', + options: { + web: 'Web', referral: 'Referencia', event: 'Evento / Feria', + webinar: 'Seminario Web', partner: 'Socio', advertisement: 'Publicidad', + paid_search: 'Búsqueda de Pago', social: 'Redes Sociales', content: 'Contenido / Blog', + cold_call: 'Llamada en Frío', email_campaign: 'Campaña de Email', other: 'Otro', + }, + }, + payment_terms: { + label: 'Términos de Pago', + options: { + net_15: 'Neto 15', net_30: 'Neto 30', net_60: 'Neto 60', + net_90: 'Neto 90', due_on_receipt: 'Pago a la Recepción', + }, + }, + related_to_type: { + label: 'Tipo de Objeto Relacionado', + options: { + crm_account: 'Cuenta', crm_contact: 'Contacto', crm_opportunity: 'Oportunidad', + crm_lead: 'Prospecto', crm_case: 'Caso', + }, + }, + }, }; diff --git a/src/sales/translations/es-ES/objects.activity.ts b/src/sales/translations/es-ES/objects.activity.ts index d2557ba7d..101991805 100644 --- a/src/sales/translations/es-ES/objects.activity.ts +++ b/src/sales/translations/es-ES/objects.activity.ts @@ -62,13 +62,7 @@ export const activity: Record = { // Los valores son nombres de objeto: se reutilizan las etiquetas ya // traducidas de cada objeto en este mismo paquete (igual que // `crm_event.related_to_type`). - related_to_type: { - label: 'Tipo de Objeto Relacionado', - options: { - crm_account: 'Cuenta', crm_contact: 'Contacto', crm_opportunity: 'Oportunidad', - crm_lead: 'Prospecto', crm_case: 'Caso', - }, - }, + related_to_type: { label: 'Tipo de Objeto Relacionado' }, related_to_account: { label: 'Cuenta Relacionada' }, related_to_contact: { label: 'Contacto Relacionado' }, related_to_opportunity: { label: 'Oportunidad Relacionada' }, @@ -142,13 +136,7 @@ export const activity: Record = { all_day: { label: 'Evento de todo el día' }, duration_minutes: { label: 'Duración (minutos)' }, location: { label: 'Ubicación', help: 'Sala, dirección o enlace de la reunión' }, - related_to_type: { - label: 'Tipo de registro relacionado', - options: { - crm_account: 'Cuenta', crm_contact: 'Contacto', crm_opportunity: 'Oportunidad', - crm_lead: 'Prospecto', crm_case: 'Caso', - }, - }, + related_to_type: { label: 'Tipo de registro relacionado' }, related_to_account: { label: 'Cuenta relacionada' }, related_to_contact: { label: 'Contacto relacionado' }, related_to_opportunity: { label: 'Oportunidad relacionada' }, diff --git a/src/sales/translations/es-ES/objects.commerce.ts b/src/sales/translations/es-ES/objects.commerce.ts index a0790a7b8..6b8f30ae4 100644 --- a/src/sales/translations/es-ES/objects.commerce.ts +++ b/src/sales/translations/es-ES/objects.commerce.ts @@ -59,13 +59,7 @@ export const commerce: Record = { shipping_handling: { label: 'Envío y Manipulación' }, total_price: { label: 'Precio Total' }, // Mismo juego de valores que `crm_contract.payment_terms` — tradúzcanse igual. - payment_terms: { - label: 'Términos de Pago', - options: { - net_15: 'Neto 15', net_30: 'Neto 30', net_60: 'Neto 60', net_90: 'Neto 90', - due_on_receipt: 'Pago a la Recepción', - }, - }, + payment_terms: { label: 'Términos de Pago' }, shipping_terms: { label: 'Términos de Envío' }, billing_address: { label: 'Dirección de Facturación' }, shipping_address: { label: 'Dirección de Envío' }, @@ -154,13 +148,7 @@ export const commerce: Record = { options: { monthly: 'Mensual', quarterly: 'Trimestral', annually: 'Anual', one_time: 'Pago Único' }, }, // Mismo juego de valores que `crm_quote.payment_terms` — tradúzcanse igual. - payment_terms: { - label: 'Términos de Pago', - options: { - net_15: 'Neto 15', net_30: 'Neto 30', net_60: 'Neto 60', net_90: 'Neto 90', - due_on_receipt: 'Pago a la Recepción', - }, - }, + payment_terms: { label: 'Términos de Pago' }, auto_renewal: { label: 'Renovación Automática' }, renewal_notice_days: { label: 'Aviso de Renovación (Días)' }, contract_type: { diff --git a/src/sales/translations/es-ES/objects.customer.ts b/src/sales/translations/es-ES/objects.customer.ts index 89963f4fb..891be9f0c 100644 --- a/src/sales/translations/es-ES/objects.customer.ts +++ b/src/sales/translations/es-ES/objects.customer.ts @@ -59,16 +59,7 @@ export const customer: Record = { // labels that are all nouns. options: { prospect: 'Prospecto', customer: 'Cliente', partner: 'Socio', former: 'Cliente Anterior' }, }, - industry: { - label: 'Industria', - options: { - technology: 'Tecnología', software: 'Software / SaaS', finance: 'Finanzas', - healthcare: 'Salud', retail: 'Comercio', manufacturing: 'Manufactura', - education: 'Educación', real_estate: 'Inmobiliaria', media: 'Medios y Entretenimiento', - logistics: 'Logística', hospitality: 'Hostelería', energy: 'Energía y Servicios Públicos', - government: 'Gobierno', nonprofit: 'Sin Ánimo de Lucro', other: 'Otro', - }, - }, + industry: { label: 'Industria' }, annual_revenue: { label: 'Ingresos Anuales' }, child_account_revenue: { label: 'Ingresos de Cuentas Hijas', help: 'Suma de los ingresos anuales de las cuentas hijas directas.' }, number_of_employees: { label: 'Número de Empleados' }, @@ -172,10 +163,7 @@ export const customer: Record = { description: 'Personas asociadas a las cuentas', fields: { // Mismo juego de valores que `crm_lead.salutation` — tradúzcanse igual. - salutation: { - label: 'Título', - options: { mr: 'Sr.', ms: 'Srta.', mrs: 'Sra.', dr: 'Dr.', prof: 'Prof.' }, - }, + salutation: { label: 'Título' }, first_name: { label: 'Nombre' }, last_name: { label: 'Apellido' }, full_name: { label: 'Nombre Completo' }, @@ -226,15 +214,7 @@ export const customer: Record = { mailing_address: { label: 'Dirección de Correo' }, // Juego compartido con `crm_lead.lead_source` y // `crm_opportunity.lead_source` — los tres deben coincidir literalmente. - lead_source: { - label: 'Origen del Prospecto', - options: { - web: 'Web', referral: 'Referencia', event: 'Evento / Feria', - webinar: 'Seminario Web', partner: 'Socio', advertisement: 'Publicidad', - paid_search: 'Búsqueda de Pago', social: 'Redes Sociales', content: 'Contenido / Blog', - cold_call: 'Llamada en Frío', email_campaign: 'Campaña de Email', other: 'Otro', - }, - }, + lead_source: { label: 'Origen del Prospecto' }, do_not_call: { label: 'No Llamar' }, email_opt_out: { label: 'Excluir de Correos' }, last_contacted_date: { label: 'Último contacto' }, diff --git a/src/sales/translations/es-ES/objects.pipeline.ts b/src/sales/translations/es-ES/objects.pipeline.ts index cd7e8f0dc..483d5cdb3 100644 --- a/src/sales/translations/es-ES/objects.pipeline.ts +++ b/src/sales/translations/es-ES/objects.pipeline.ts @@ -56,36 +56,16 @@ export const pipeline: Record = { unqualified: 'No Calificado', converted: 'Convertido', }, }, - lead_source: { - label: 'Origen del Prospecto', - options: { - web: 'Web', referral: 'Referencia', event: 'Evento / Feria', - webinar: 'Seminario Web', partner: 'Socio', advertisement: 'Publicidad', - paid_search: 'Búsqueda de Pago', social: 'Redes Sociales', content: 'Contenido / Blog', - cold_call: 'Llamada en Frío', email_campaign: 'Campaña de Email', other: 'Otro', - }, - }, + lead_source: { label: 'Origen del Prospecto' }, owner_id: { label: 'Propietario' }, is_converted: { label: 'Convertido' }, description: { label: 'Descripción' }, // Mismo juego de valores que `crm_contact.salutation` — tradúzcanse igual. - salutation: { - label: 'Tratamiento', - options: { mr: 'Sr.', ms: 'Srta.', mrs: 'Sra.', dr: 'Dr.', prof: 'Prof.' }, - }, + salutation: { label: 'Tratamiento' }, full_name: { label: 'Nombre Completo' }, // Los 15 valores son los mismos que en `crm_account.industry` — deben // coincidir literalmente entre ambas pantallas. - industry: { - label: 'Industria', - options: { - technology: 'Tecnología', software: 'Software / SaaS', finance: 'Finanzas', - healthcare: 'Salud', retail: 'Comercio', manufacturing: 'Manufactura', - education: 'Educación', real_estate: 'Inmobiliaria', media: 'Medios y Entretenimiento', - logistics: 'Logística', hospitality: 'Hostelería', energy: 'Energía y Servicios Públicos', - government: 'Gobierno', nonprofit: 'Sin Ánimo de Lucro', other: 'Otro', - }, - }, + industry: { label: 'Industria' }, mobile: { label: 'Móvil' }, website: { label: 'Sitio Web' }, rating: { label: 'Puntuación de Prospecto', help: 'Puntuación de calidad del prospecto (1-5 estrellas)' }, @@ -276,15 +256,7 @@ export const pipeline: Record = { }, description: { label: 'Descripción' }, next_step: { label: 'Próximo Paso' }, - lead_source: { - label: 'Origen del Prospecto', - options: { - web: 'Web', referral: 'Referencia', event: 'Evento / Feria', - webinar: 'Seminario Web', partner: 'Socio', advertisement: 'Publicidad', - paid_search: 'Búsqueda de Pago', social: 'Redes Sociales', content: 'Contenido / Blog', - cold_call: 'Llamada en Frío', email_campaign: 'Campaña de Email', other: 'Otro', - }, - }, + lead_source: { label: 'Origen del Prospecto' }, crm_campaign: { label: 'Campaña', help: 'Campaña de marketing que generó esta oportunidad' }, days_in_stage: { label: 'Días en Etapa Actual' }, stage_entry_date: { label: 'Fecha de Entrada a la Etapa', help: 'Fecha en la que esta oportunidad entró en su etapa actual.' }, diff --git a/src/sales/translations/ja-JP/_shared.ts b/src/sales/translations/ja-JP/_shared.ts index b62ca417d..3864e9428 100644 --- a/src/sales/translations/ja-JP/_shared.ts +++ b/src/sales/translations/ja-JP/_shared.ts @@ -62,26 +62,3 @@ export const activityActions = { }, }, }; - -/** - * 共有ピックリスト(`_picklists.ts`)は複数オブジェクトで同一の値集合を持つ。 - * 訳語がオブジェクトごとにぶれると、同じピックリストが画面によって別の言葉で - * 表示される不具合になる(#494)。ここで一度だけ定義して各所に展開する。 - * キーは表示ラベルではなく **保存値** であることに注意(#494 の主因)。 - */ -export const salutationOptions = { mr: 'Mr.', ms: 'Ms.', mrs: 'Mrs.', dr: '博士', prof: '教授' }; - -export const industryOptions = { - technology: 'テクノロジー', software: 'ソフトウェア / SaaS', finance: '金融', - healthcare: 'ヘルスケア', retail: '小売', manufacturing: '製造', - education: '教育', real_estate: '不動産', media: 'メディア・エンタメ', - logistics: '物流', hospitality: 'ホスピタリティ', energy: 'エネルギー・公益', - government: '政府・行政', nonprofit: '非営利', other: 'その他', -}; - -export const leadSourceOptions = { - web: 'ウェブ', referral: '紹介', event: 'イベント・展示会', - webinar: 'ウェビナー', partner: 'パートナー', advertisement: '広告', - paid_search: '有料検索', social: 'ソーシャルメディア', content: 'コンテンツ・ブログ', - cold_call: 'コールドコール', email_campaign: 'メールキャンペーン', other: 'その他', -}; diff --git a/src/sales/translations/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index 992e88ae9..7de264de1 100644 --- a/src/sales/translations/ja-JP/app.ts +++ b/src/sales/translations/ja-JP/app.ts @@ -6,7 +6,7 @@ import { QUOTE_DISCOUNT_CEILING } from '../../objects/_thresholds'; /** * 日本語 (ja-JP) — every translation namespace EXCEPT `objects`: - * `apps`, `messages`, `dashboards`, `datasets`, `pages`, `flows`. + * `apps`, `messages`, `dashboards`, `datasets`, `pages`, `flows`, `picklists`. * * SPLIT AXIS (#1311): translation NAMESPACE first, then CRM DOMAIN FAMILY. * Everything that is not `objects` lives in `./app.ts`; `objects` — 69-78% of @@ -683,4 +683,47 @@ export const appSurface: Omit = { }, }, }, + // 共有ピックリスト(`src//picklists/*.picklist.ts`)は一度だけ翻訳する。 + // リストを参照するフィールド(`Field.select({ picklist })`)はすべてこの + // 選択肢ラベルを継承するため、フィールド単位の `options` は独自のインライン + // 選択肢を持つフィールドにだけ書く。キーは表示ラベルではなく **保存値**(#494)。 + picklists: { + salutation: { + label: '敬称', + options: { mr: 'Mr.', ms: 'Ms.', mrs: 'Mrs.', dr: '博士', prof: '教授' }, + }, + industry: { + label: '業種', + options: { + technology: 'テクノロジー', software: 'ソフトウェア / SaaS', finance: '金融', + healthcare: 'ヘルスケア', retail: '小売', manufacturing: '製造', + education: '教育', real_estate: '不動産', media: 'メディア・エンタメ', + logistics: '物流', hospitality: 'ホスピタリティ', energy: 'エネルギー・公益', + government: '政府・行政', nonprofit: '非営利', other: 'その他', + }, + }, + lead_source: { + label: 'リードソース', + options: { + web: 'ウェブ', referral: '紹介', event: 'イベント・展示会', + webinar: 'ウェビナー', partner: 'パートナー', advertisement: '広告', + paid_search: '有料検索', social: 'ソーシャルメディア', content: 'コンテンツ・ブログ', + cold_call: 'コールドコール', email_campaign: 'メールキャンペーン', other: 'その他', + }, + }, + payment_terms: { + label: '支払条件', + options: { + net_15: '15 日以内払い', net_30: '30 日以内払い', net_60: '60 日以内払い', + net_90: '90 日以内払い', due_on_receipt: '請求書受領時払い', + }, + }, + related_to_type: { + label: '関連オブジェクト種別', + options: { + crm_account: '取引先', crm_contact: '取引先責任者', crm_opportunity: '商談', + crm_lead: 'リード', crm_case: 'ケース', + }, + }, + }, }; diff --git a/src/sales/translations/ja-JP/objects.activity.ts b/src/sales/translations/ja-JP/objects.activity.ts index 82ad333ec..bb613a729 100644 --- a/src/sales/translations/ja-JP/objects.activity.ts +++ b/src/sales/translations/ja-JP/objects.activity.ts @@ -16,11 +16,6 @@ import type { ObjectTranslationData } from '@objectstack/spec/system'; * advisory band `pnpm hygiene` prints. Full rule and rationale: * `src/translations/ja-JP.ts`. */ -const relatedToTypeOptions = { - crm_account: '取引先', crm_contact: '取引先責任者', crm_opportunity: '商談', - crm_lead: 'リード', crm_case: 'ケース', -}; - export const activity: Record = { crm_task: { _validations: { @@ -64,10 +59,7 @@ export const activity: Record = { reminder_sent: { label: 'リマインダー送信済み' }, completed_date: { label: '完了日' }, owner_id: { label: '担当者' }, - related_to_type: { - label: '関連オブジェクト種別', - options: { ...relatedToTypeOptions }, - }, + related_to_type: { label: '関連オブジェクト種別' }, related_to_account: { label: '関連取引先' }, related_to_contact: { label: '関連取引先責任者' }, related_to_opportunity: { label: '関連商談' }, @@ -141,10 +133,7 @@ export const activity: Record = { all_day: { label: '終日イベント' }, duration_minutes: { label: '所要時間(分)' }, location: { label: '場所', help: '会議室・住所・会議リンク' }, - related_to_type: { - label: '関連レコード種別', - options: { ...relatedToTypeOptions }, - }, + related_to_type: { label: '関連レコード種別' }, related_to_account: { label: '関連取引先' }, related_to_contact: { label: '関連取引先責任者' }, related_to_opportunity: { label: '関連商談' }, diff --git a/src/sales/translations/ja-JP/objects.commerce.ts b/src/sales/translations/ja-JP/objects.commerce.ts index f09c079ff..0fbbd4677 100644 --- a/src/sales/translations/ja-JP/objects.commerce.ts +++ b/src/sales/translations/ja-JP/objects.commerce.ts @@ -16,11 +16,6 @@ import type { ObjectTranslationData } from '@objectstack/spec/system'; * advisory band `pnpm hygiene` prints. Full rule and rationale: * `src/translations/ja-JP.ts`. */ -const paymentTermsOptions = { - net_15: '15 日以内払い', net_30: '30 日以内払い', net_60: '60 日以内払い', - net_90: '90 日以内払い', due_on_receipt: '請求書受領時払い', -}; - export const commerce: Record = { crm_quote: { _validations: { @@ -63,7 +58,7 @@ export const commerce: Record = { tax: { label: '税額' }, shipping_handling: { label: '配送・手数料' }, total_price: { label: '合計金額' }, - payment_terms: { label: '支払条件', options: { ...paymentTermsOptions } }, + payment_terms: { label: '支払条件' }, shipping_terms: { label: '配送条件' }, billing_address: { label: '請求先住所' }, shipping_address: { label: '配送先住所' }, @@ -151,7 +146,7 @@ export const commerce: Record = { label: '請求頻度', options: { monthly: '月次', quarterly: '四半期', annually: '年次', one_time: '一括' }, }, - payment_terms: { label: '支払条件', options: { ...paymentTermsOptions } }, + payment_terms: { label: '支払条件' }, auto_renewal: { label: '自動更新' }, renewal_notice_days: { label: '更新通知(日)' }, contract_type: { diff --git a/src/sales/translations/ja-JP/objects.customer.ts b/src/sales/translations/ja-JP/objects.customer.ts index 5c803390a..eef4b90ae 100644 --- a/src/sales/translations/ja-JP/objects.customer.ts +++ b/src/sales/translations/ja-JP/objects.customer.ts @@ -2,7 +2,7 @@ import type { ObjectTranslationData } from '@objectstack/spec/system'; -import { activityActions, salutationOptions, industryOptions, leadSourceOptions } from './_shared'; +import { activityActions } from './_shared'; /** * 日本語 (ja-JP) — `objects` translations for the CUSTOMER family: @@ -55,10 +55,7 @@ export const customer: Record = { label: 'タイプ', options: { prospect: '見込み客', customer: '顧客', partner: 'パートナー', former: '過去の取引先' }, }, - industry: { - label: '業種', - options: { ...industryOptions }, - }, + industry: { label: '業種' }, annual_revenue: { label: '年間売上' }, child_account_revenue: { label: '子会社年間売上合計', help: '直属の子取引先の年間売上の合計。' }, number_of_employees: { label: '従業員数' }, @@ -155,7 +152,7 @@ export const customer: Record = { pluralLabel: '取引先責任者', description: '取引先に所属する担当者', fields: { - salutation: { label: '敬称', options: { ...salutationOptions } }, + salutation: { label: '敬称' }, first_name: { label: '名' }, last_name: { label: '姓' }, full_name: { label: '氏名' }, @@ -203,7 +200,7 @@ export const customer: Record = { is_primary: { label: '主担当者', help: 'この取引先の主担当者かどうか' }, avatar: { label: 'プロフィール画像' }, mailing_address: { label: '郵送先住所' }, - lead_source: { label: 'リードソース', options: { ...leadSourceOptions } }, + lead_source: { label: 'リードソース' }, do_not_call: { label: '電話拒否' }, email_opt_out: { label: 'メール配信停止' }, last_contacted_date: { label: '最終接触日時' }, diff --git a/src/sales/translations/ja-JP/objects.pipeline.ts b/src/sales/translations/ja-JP/objects.pipeline.ts index 0c766da7a..d67055361 100644 --- a/src/sales/translations/ja-JP/objects.pipeline.ts +++ b/src/sales/translations/ja-JP/objects.pipeline.ts @@ -2,7 +2,7 @@ import type { ObjectTranslationData } from '@objectstack/spec/system'; -import { activityActions, salutationOptions, industryOptions, leadSourceOptions } from './_shared'; +import { activityActions } from './_shared'; /** * 日本語 (ja-JP) — `objects` translations for the PIPELINE family: @@ -44,7 +44,7 @@ export const pipeline: Record = { pluralLabel: 'リード', description: 'まだ適格判定を受けていない見込み客', fields: { - salutation: { label: '敬称', options: { ...salutationOptions } }, + salutation: { label: '敬称' }, first_name: { label: '名' }, last_name: { label: '姓' }, full_name: { label: '氏名' }, @@ -57,10 +57,7 @@ export const pipeline: Record = { title: { label: '役職' }, email: { label: 'メール' }, phone: { label: '電話' }, - industry: { - label: '業種', - options: { ...industryOptions }, - }, + industry: { label: '業種' }, status: { label: 'ステータス', options: { @@ -68,7 +65,7 @@ export const pipeline: Record = { unqualified: '不適格', converted: '変換済み', }, }, - lead_source: { label: 'リードソース', options: { ...leadSourceOptions } }, + lead_source: { label: 'リードソース' }, owner_id: { label: 'リード所有者' }, is_converted: { label: '変換済み' }, description: { label: '説明' }, @@ -255,7 +252,7 @@ export const pipeline: Record = { }, description: { label: '説明' }, next_step: { label: '次のステップ' }, - lead_source: { label: 'リードソース', options: { ...leadSourceOptions } }, + lead_source: { label: 'リードソース' }, crm_campaign: { label: 'キャンペーン', help: 'この商談を生み出したマーケティングキャンペーン' }, days_in_stage: { label: '現ステージ滞在日数' }, stage_entry_date: { label: 'ステージ開始日', help: 'この商談が現在のステージに入った日。' }, diff --git a/src/sales/translations/zh-CN/app.ts b/src/sales/translations/zh-CN/app.ts index 18029d2b5..b674976ce 100644 --- a/src/sales/translations/zh-CN/app.ts +++ b/src/sales/translations/zh-CN/app.ts @@ -6,7 +6,7 @@ import { QUOTE_DISCOUNT_CEILING } from '../../objects/_thresholds'; /** * 简体中文 (zh-CN) — every translation namespace EXCEPT `objects`: - * `apps`, `messages`, `dashboards`, `datasets`, `pages`, `flows`. + * `apps`, `messages`, `dashboards`, `datasets`, `pages`, `flows`, `picklists`. * * SPLIT AXIS (#1311): translation NAMESPACE first, then CRM DOMAIN FAMILY. * Everything that is not `objects` lives in `./app.ts`; `objects` — 69-78% of @@ -682,4 +682,47 @@ export const appSurface: Omit = { }, }, }, + // Shared option lists (`src//picklists/*.picklist.ts`), translated ONCE: + // every field that references a list (`Field.select({ picklist })`) inherits + // these option labels, so a field-level `options` entry exists only for a + // field with inline options of its own. + picklists: { + salutation: { + label: '称谓', + options: { mr: '先生', ms: '女士', mrs: '夫人', dr: '博士', prof: '教授' }, + }, + industry: { + label: '行业', + options: { + technology: '科技', software: '软件 / SaaS', finance: '金融', + healthcare: '医疗', retail: '零售', manufacturing: '制造', + education: '教育', real_estate: '房地产', media: '传媒娱乐', + logistics: '物流', hospitality: '酒店旅游', energy: '能源公用事业', + government: '政府', nonprofit: '非营利', other: '其他', + }, + }, + lead_source: { + label: '线索来源', + options: { + web: '网站', referral: '推荐', event: '活动 / 展会', + webinar: '线上研讨会', partner: '合作伙伴', advertisement: '广告', + paid_search: '付费搜索', social: '社交媒体', content: '内容 / 博客', + cold_call: '陌生拜访', email_campaign: '邮件营销', other: '其他', + }, + }, + payment_terms: { + label: '付款条款', + options: { + net_15: '15 天账期', net_30: '30 天账期', net_60: '60 天账期', + net_90: '90 天账期', due_on_receipt: '货到付款', + }, + }, + related_to_type: { + label: '关联对象类型', + options: { + crm_account: '客户', crm_contact: '联系人', crm_opportunity: '商机', + crm_lead: '线索', crm_case: '工单', + }, + }, + }, }; diff --git a/src/sales/translations/zh-CN/objects.activity.ts b/src/sales/translations/zh-CN/objects.activity.ts index fd3d42525..aabc19ed7 100644 --- a/src/sales/translations/zh-CN/objects.activity.ts +++ b/src/sales/translations/zh-CN/objects.activity.ts @@ -65,13 +65,7 @@ export const activity: Record = { reminder_date: { label: '提醒时间' }, completed_date: { label: '完成日期' }, owner_id: { label: '负责人' }, - related_to_type: { - label: '关联对象类型', - options: { - crm_account: '客户', crm_contact: '联系人', crm_opportunity: '商机', - crm_lead: '线索', crm_case: '工单', - }, - }, + related_to_type: { label: '关联对象类型' }, related_to_account: { label: '关联客户' }, related_to_contact: { label: '关联联系人' }, related_to_opportunity: { label: '关联商机' }, @@ -147,13 +141,7 @@ export const activity: Record = { all_day: { label: '全天活动' }, duration_minutes: { label: '时长(分钟)' }, location: { label: '地点', help: '会议室、地址或会议链接' }, - related_to_type: { - label: '关联对象类型', - options: { - crm_account: '客户', crm_contact: '联系人', crm_opportunity: '商机', - crm_lead: '线索', crm_case: '工单', - }, - }, + related_to_type: { label: '关联对象类型' }, related_to_account: { label: '关联客户' }, related_to_contact: { label: '关联联系人' }, related_to_opportunity: { label: '关联商机' }, diff --git a/src/sales/translations/zh-CN/objects.commerce.ts b/src/sales/translations/zh-CN/objects.commerce.ts index 5e4e25b4d..08c1daffd 100644 --- a/src/sales/translations/zh-CN/objects.commerce.ts +++ b/src/sales/translations/zh-CN/objects.commerce.ts @@ -58,13 +58,7 @@ export const commerce: Record = { discount_amount: { label: '折扣金额' }, tax: { label: '税额' }, shipping_handling: { label: '运费及手续费' }, - payment_terms: { - label: '付款条款', - options: { - net_15: '15 天账期', net_30: '30 天账期', net_60: '60 天账期', - net_90: '90 天账期', due_on_receipt: '货到付款', - }, - }, + payment_terms: { label: '付款条款' }, shipping_terms: { label: '运输条款' }, billing_address: { label: '账单地址' }, shipping_address: { label: '收货地址' }, @@ -153,13 +147,7 @@ export const commerce: Record = { label: '计费周期', options: { monthly: '按月', quarterly: '按季度', annually: '按年', one_time: '一次性' }, }, - payment_terms: { - label: '付款条款', - options: { - net_15: '15 天账期', net_30: '30 天账期', net_60: '60 天账期', - net_90: '90 天账期', due_on_receipt: '货到付款', - }, - }, + payment_terms: { label: '付款条款' }, auto_renewal: { label: '自动续约' }, renewal_notice_days: { label: '续约通知(天)' }, contract_type: { diff --git a/src/sales/translations/zh-CN/objects.customer.ts b/src/sales/translations/zh-CN/objects.customer.ts index 22e47641e..1d4690776 100644 --- a/src/sales/translations/zh-CN/objects.customer.ts +++ b/src/sales/translations/zh-CN/objects.customer.ts @@ -50,16 +50,7 @@ export const customer: Record = { label: '类型', options: { prospect: '潜在客户', customer: '正式客户', partner: '合作伙伴', former: '前客户' }, }, - industry: { - label: '行业', - options: { - technology: '科技', software: '软件 / SaaS', finance: '金融', - healthcare: '医疗', retail: '零售', manufacturing: '制造', - education: '教育', real_estate: '房地产', media: '传媒娱乐', - logistics: '物流', hospitality: '酒店旅游', energy: '能源公用事业', - government: '政府', nonprofit: '非营利', other: '其他', - }, - }, + industry: { label: '行业' }, annual_revenue: { label: '年营收' }, child_account_revenue: { label: '子公司年营收合计', help: '本客户直属子公司的年营收合计。' }, number_of_employees: { label: '员工人数' }, @@ -155,10 +146,7 @@ export const customer: Record = { pluralLabel: '联系人', description: '客户与商机的关键人物联系人', fields: { - salutation: { - label: '称谓', - options: { mr: '先生', ms: '女士', mrs: '夫人', dr: '博士', prof: '教授' }, - }, + salutation: { label: '称谓' }, first_name: { label: '名' }, last_name: { label: '姓' }, full_name: { label: '全名' }, @@ -204,15 +192,7 @@ export const customer: Record = { description: { label: '描述' }, is_primary: { label: '主要联系人', help: '是否为该客户的主要联系人?' }, mailing_address: { label: '邮寄地址' }, - lead_source: { - label: '线索来源', - options: { - web: '网站', referral: '推荐', event: '活动 / 展会', - webinar: '线上研讨会', partner: '合作伙伴', advertisement: '广告', - paid_search: '付费搜索', social: '社交媒体', content: '内容 / 博客', - cold_call: '陌生拜访', email_campaign: '邮件营销', other: '其他', - }, - }, + lead_source: { label: '线索来源' }, do_not_call: { label: '禁止致电' }, email_opt_out: { label: '拒绝邮件' }, last_contacted_date: { label: '最近联系时间' }, diff --git a/src/sales/translations/zh-CN/objects.pipeline.ts b/src/sales/translations/zh-CN/objects.pipeline.ts index 7b1e82c21..2930d7dbd 100644 --- a/src/sales/translations/zh-CN/objects.pipeline.ts +++ b/src/sales/translations/zh-CN/objects.pipeline.ts @@ -38,10 +38,7 @@ export const pipeline: Record = { pluralLabel: '线索', description: '尚未确认的潜在客户', fields: { - salutation: { - label: '称谓', - options: { mr: '先生', ms: '女士', mrs: '夫人', dr: '博士', prof: '教授' }, - }, + salutation: { label: '称谓' }, first_name: { label: '名' }, last_name: { label: '姓' }, full_name: { label: '全名' }, @@ -49,16 +46,7 @@ export const pipeline: Record = { title: { label: '职位' }, email: { label: '邮箱' }, phone: { label: '电话' }, - industry: { - label: '行业', - options: { - technology: '科技', software: '软件 / SaaS', finance: '金融', - healthcare: '医疗', retail: '零售', manufacturing: '制造', - education: '教育', real_estate: '房地产', media: '传媒娱乐', - logistics: '物流', hospitality: '酒店旅游', energy: '能源公用事业', - government: '政府', nonprofit: '非营利', other: '其他', - }, - }, + industry: { label: '行业' }, status: { label: '状态', options: { @@ -68,15 +56,7 @@ export const pipeline: Record = { unqualified: '未通过', converted: '已转化', }, }, - lead_source: { - label: '线索来源', - options: { - web: '网站', referral: '推荐', event: '活动 / 展会', - webinar: '线上研讨会', partner: '合作伙伴', advertisement: '广告', - paid_search: '付费搜索', social: '社交媒体', content: '内容 / 博客', - cold_call: '陌生拜访', email_campaign: '邮件营销', other: '其他', - }, - }, + lead_source: { label: '线索来源' }, owner_id: { label: '线索负责人' }, is_converted: { label: '已转化' }, description: { label: '描述' }, @@ -269,15 +249,7 @@ export const pipeline: Record = { }, description: { label: '描述' }, next_step: { label: '下一步' }, - lead_source: { - label: '线索来源', - options: { - web: '网站', referral: '推荐', event: '活动 / 展会', - webinar: '线上研讨会', partner: '合作伙伴', advertisement: '广告', - paid_search: '付费搜索', social: '社交媒体', content: '内容 / 博客', - cold_call: '陌生拜访', email_campaign: '邮件营销', other: '其他', - }, - }, + lead_source: { label: '线索来源' }, crm_campaign: { label: '营销活动', help: '带来此商机的营销活动' }, days_in_stage: { label: '当前阶段天数' }, stage_entry_date: { label: '进入当前阶段日期', help: '本商机进入当前阶段的日期。' }, diff --git a/src/sales/views/lead.view.ts b/src/sales/views/lead.view.ts index 00a86bc53..ca5b2a864 100644 --- a/src/sales/views/lead.view.ts +++ b/src/sales/views/lead.view.ts @@ -2,7 +2,7 @@ import { P } from '@objectstack/spec'; import { defineView } from '@objectstack/spec/ui'; -import { DUPLICATE_OF_TYPE_AUTHORABLE_OPTIONS } from '../objects/_picklists'; +import { DUPLICATE_OF_TYPE_AUTHORABLE_OPTIONS } from '../objects/lead.object'; /** * ═══ HOUSE RULE: form predicates are `record.`-bound AND TOTAL ═════════════ @@ -83,7 +83,7 @@ const DUPLICATE_LINK_FIELDS = [ // locales, so a tombstoned lead reads as "Erased Record" on the detail page // instead of as a raw enum nobody can explain. // - // Spread from `_picklists.ts` rather than retyped: the authorable set is + // Spread from `lead.object.ts` rather than retyped: the authorable set is // the source both lists derive from, so a future object type added there // reaches this picker automatically and the tombstone never can. options: [...DUPLICATE_OF_TYPE_AUTHORABLE_OPTIONS], @@ -176,8 +176,8 @@ export const LeadViews = defineView({ // list's own filter and grouping controls offer, which is what makes the // two FILTERABLE and GROUPABLE here rather than only readable — the same // reading `contact.view.ts` records for the buying-centre columns. - // `need_type` is single-valued for exactly that reason (see - // `_picklists.ts`): a grouped list answers with one aggregate per raw + // `need_type` is single-valued for exactly that reason (see the field in + // `lead.object.ts`): a grouped list answers with one aggregate per raw // stored value, so a multi-valued column would group by the combination. { field: 'need_type', diff --git a/src/sales/views/opportunity.view.ts b/src/sales/views/opportunity.view.ts index e476d3293..1ed2dd3af 100644 --- a/src/sales/views/opportunity.view.ts +++ b/src/sales/views/opportunity.view.ts @@ -45,7 +45,7 @@ export const OpportunityViews = defineView({ qualification: 'sky', // Teal sits between the cool qualification blue and the warm proposal // amber, keeping the cool→warm funnel ramp readable. NOT the gold - // `#FFD700` this option carries in `_picklists.ts`: gold is one hue + // `#FFD700` this option carries on the `stage` field: gold is one hue // step from proposal's amber, so the two adjacent stages would tint // rows the same. // diff --git a/test/lead-duplicate-management.test.ts b/test/lead-duplicate-management.test.ts index 969820362..c0923c78b 100644 --- a/test/lead-duplicate-management.test.ts +++ b/test/lead-duplicate-management.test.ts @@ -9,10 +9,10 @@ import stack from '../objectstack.config'; import { CrmTranslations } from '../src/sales/translations'; import leadHooks from '../src/sales/objects/lead.hook'; import { + Lead, DUPLICATE_OF_TYPE_AUTHORABLE_OPTIONS, DUPLICATE_OF_TYPE_ERASED, - DUPLICATE_OF_TYPE_OPTIONS, -} from '../src/sales/objects/_picklists'; +} from '../src/sales/objects/lead.object'; import { REPO_ROOT } from './helpers/repo-root'; /** @@ -39,6 +39,13 @@ import { REPO_ROOT } from './helpers/repo-root'; type AnyRec = Record; +/** + * The full COLUMN vocabulary — the authorable set plus the tombstone — read off + * the field itself, which is where it is declared (#2000 retired the separate + * `DUPLICATE_OF_TYPE_OPTIONS` constant: one field owns the list). + */ +const DUPLICATE_OF_TYPE_OPTIONS = (Lead.fields.duplicate_of_type.options ?? []) as AnyRec[]; + const objects: AnyRec[] = (stack as any).objects ?? []; const lead = objects.find((o) => o.name === 'crm_lead') as AnyRec; const contact = objects.find((o) => o.name === 'crm_contact') as AnyRec; @@ -512,8 +519,8 @@ describe('the erased tombstone is unauthorable, and has exactly one writer', () // silent route this pin exists to refuse. expect(hits, `expected 2 occurrences, got:\n${hits.join('\n')}`).toHaveLength(2); expect(hits.map((h) => h.split(':')[0]).sort()).toEqual([ - 'src/sales/objects/_picklists.ts', 'src/sales/objects/lead.hook.ts', + 'src/sales/objects/lead.object.ts', ]); // The hook's one occurrence STAMPS the value; it is not a comparison that // happens to mention it. diff --git a/test/quote-accepted-payment-terms.test.ts b/test/quote-accepted-payment-terms.test.ts index c8560e781..388091a28 100644 --- a/test/quote-accepted-payment-terms.test.ts +++ b/test/quote-accepted-payment-terms.test.ts @@ -6,7 +6,7 @@ import { InMemoryDriver } from '@objectstack/driver-memory'; import quoteHooks from '../src/revenue/objects/quote.hook'; import { Contract } from '../src/revenue/objects/contract.object'; import { Quote } from '../src/revenue/objects/quote.object'; -import { PAYMENT_TERMS_OPTIONS } from '../src/sales/objects/_picklists'; +import { PaymentTermsPicklist } from '../src/revenue/picklists/payment_terms.picklist'; import type { HookApi } from '../src/sales/objects/_hook-api'; import { makeHarness, makeCtx, hookNamed, type Rec } from './helpers/hook-harness'; import { makeSandboxEngine, runHookBody } from './helpers/action-sandbox'; @@ -16,8 +16,9 @@ import { makeSandboxEngine, runHookBody } from './helpers/action-sandbox'; * * ### What was wrong * - * `_picklists.ts` declares `PAYMENT_TERMS_OPTIONS` a set shared by Quote and - * Contract, and justifies the sharing in so many words: *"an accepted quote's + * The `payment_terms` list was declared a set shared by Quote and Contract + * (then `PAYMENT_TERMS_OPTIONS` in `_picklists.ts`, since #2000 the + * `payment_terms` picklist), justified in so many words: *"an accepted quote's * terms carry over to the contract, so the contract vocabulary must cover every * quote value."* `crm_contract.payment_terms` repeats the same rationale. No * such carry-over existed. `quote_on_accepted` drafted the contract from @@ -66,7 +67,7 @@ const hook = hookNamed(quoteHooks, 'quote_on_accepted'); const USER = { id: 'user_1' }; /** Every value a quote can hold, straight from the shared vocabulary. */ -const QUOTE_TERMS = PAYMENT_TERMS_OPTIONS.map((o) => o.value); +const QUOTE_TERMS: string[] = PaymentTermsPicklist.options.map((o) => o.value); /** The value `crm_contract.payment_terms` falls to when nothing is written. */ const CONTRACT_DEFAULT = 'net_30'; @@ -161,6 +162,11 @@ describe('what a real crm_contract does with those documents', () => { sys_user: stub('sys_user'), } as never, }); + // `crm_contract.payment_terms` names the `payment_terms` picklist (#2000); + // the engine resolves it from the registry, so the list is registered the + // way the stack's `picklists` collection registers it on boot. Without it + // the field resolves to no options and refuses every value. + ql.registry.registerItem('picklist', PaymentTermsPicklist); api = ql.createContext({ isSystem: true }); }, 60_000); @@ -245,19 +251,20 @@ describe('the rationale the shared vocabulary is justified by', () => { * with nothing holding it up. */ it('both objects really do declare the same vocabulary', () => { - const values = (o: AnyRec): string[] => o.fields.payment_terms.options.map((x: AnyRec) => x.value); - expect(values(Contract as AnyRec)).toEqual(QUOTE_TERMS); - expect(values(Quote as AnyRec)).toEqual(QUOTE_TERMS); + // Since #2000 this is structural: both fields REFERENCE one picklist and + // carry no options of their own, so there is no second copy to drift. + for (const o of [Contract, Quote] as AnyRec[]) { + expect(o.fields.payment_terms.picklist, `${o.name}.payment_terms`).toBe(PaymentTermsPicklist.name); + expect(o.fields.payment_terms.options, `${o.name}.payment_terms carries a copy`).toBeUndefined(); + } }); it('names the hook that performs the carry-over, in both places that claim it', async () => { const { readFileSync } = await import('node:fs'); const { join } = await import('node:path'); - // The two files sit in different packages since the ADR-0130 layout: the - // picklist vocabulary is a shared source and lives in the app package, - // `crm_contract` is revenue's. Both still make the claim, so both are read. + // The picklist and `crm_contract` both make the claim, so both are read. for (const file of [ - 'src/sales/objects/_picklists.ts', + 'src/revenue/picklists/payment_terms.picklist.ts', 'src/revenue/objects/contract.object.ts', ]) { expect( diff --git a/test/view-references.test.ts b/test/view-references.test.ts index 2f06f3a22..52202b98f 100644 --- a/test/view-references.test.ts +++ b/test/view-references.test.ts @@ -3,12 +3,13 @@ import { describe, it, expect } from 'vitest'; import { isDateMacroToken } from '@objectstack/spec/data'; import stack from '../objectstack.config'; -import { OPPORTUNITY_STAGE_OPTIONS } from '../src/sales/objects/_picklists'; +import { Opportunity } from '../src/sales/objects/opportunity.object'; import { type AnyRec, objects, pages, views, + stackActions, objectNames, fieldsOf, walk, @@ -270,21 +271,22 @@ describe('row colors and kanban groups key off real option values', () => { * The two guards above answer "is every value written here a real option?" — * a subset check. Nothing asked the converse, "is every real option written * here?", and that is the half that broke (#759): `needs_analysis` is one of - * the seven canonical `OPPORTUNITY_STAGE_OPTIONS` and was absent from BOTH the + * the seven canonical stages and was absent from BOTH the * detail page's stage path and the Open Deals `rowColor` map. Six of seven * stages worked, so both surfaces looked fine on every deal that happened not * to be in Needs Analysis — the deal that WAS lit up no step on the path and * got no row tint, which reads to a user as corrupted data rather than as * missing metadata. * - * The expectation is derived from `OPPORTUNITY_STAGE_OPTIONS` itself — the - * single source `crm_opportunity.stage` and the `mass_update_stage` action are - * both built from — so an EIGHTH stage cannot ship half-covered. A hand-copied - * list here would need the same edit as the metadata it guards, and would - * therefore be forgotten in the same commit. + * The expectation is derived from `crm_opportunity.stage`'s own options — the + * single source the `mass_update_stage` action is built from too (#2000 moved + * the list inline onto the field, the one field that owns it; it used to be + * `OPPORTUNITY_STAGE_OPTIONS` in `_picklists.ts`) — so an EIGHTH stage cannot + * ship half-covered. A hand-copied list here would need the same edit as the + * metadata it guards, and would therefore be forgotten in the same commit. */ describe('every canonical opportunity stage reaches the UI that enumerates stages', () => { - const canonical = OPPORTUNITY_STAGE_OPTIONS.map((o) => String(o.value)); + const canonical = (Opportunity.fields.stage.options ?? []).map((o) => String(o.value)); /** Every `record:path` bound to `crm_opportunity.stage`, as [pageName, values]. */ const stagePaths = (): [string, string[]][] => @@ -313,17 +315,26 @@ describe('every canonical opportunity stage reaches the UI that enumerates stage ); it('the canonical stage list is the set the object validates against', () => { - // Guards the guard twice over: an empty or renamed constant would make - // every assertion below pass by comparing against nothing, and a `stage` - // field that stopped being built from this constant would leave the two - // sites below chasing a list the runtime no longer enforces. - expect(canonical.length, 'OPPORTUNITY_STAGE_OPTIONS is empty').toBeGreaterThan(0); + // Guards the guard twice over: an empty list would make every assertion + // below pass by comparing against nothing, and a registered `stage` field + // that stopped matching the authored one would leave the two sites below + // chasing a list the runtime no longer enforces. + expect(canonical.length, 'crm_opportunity.stage declares no options').toBeGreaterThan(0); const objDef = objects.find((o) => o.name === 'crm_opportunity'); const fieldValues = (objDef?.fields?.stage?.options ?? []).map((o: AnyRec) => String(o.value)); - expect([...fieldValues].sort(), 'crm_opportunity.stage no longer mirrors OPPORTUNITY_STAGE_OPTIONS') + expect([...fieldValues].sort(), 'the registered crm_opportunity.stage no longer mirrors the authored one') .toEqual([...canonical].sort()); }); + it('mass_update_stage offers exactly the canonical stages, in order', () => { + // The action param is projected from the field's own options (#2000); a + // hand-typed list there is the drift this suite exists to refuse. + const action = stackActions.find((a) => a.name === 'mass_update_stage'); + const param = (action?.params ?? []).find((p: AnyRec) => p.name === 'stage'); + expect(param, 'mass_update_stage has no stage param').toBeTruthy(); + expect((param?.options ?? []).map((o: AnyRec) => String(o.value))).toEqual(canonical); + }); + it('every stage has a step on the opportunity stage path', () => { const sites = stagePaths(); // A renamed component type or statusField would silently empty this list @@ -363,8 +374,8 @@ describe('every canonical opportunity stage reaches the UI that enumerates stage it('stage colours stay distinguishable from one another', () => { // The point of the map is that a rep can tell two stages apart at a // glance; two stages sharing a hex make the tint say nothing. (#759 was - // nearly fixed by copying `needs_analysis`'s `#FFD700` out of - // _picklists.ts, one hue step from proposal's `#f59e0b`.) + // nearly fixed by copying `needs_analysis`'s `#FFD700` off the stage + // field, one hue step from proposal's `#f59e0b`.) const bad: string[] = []; for (const [site, colors] of stageRowColors()) { const seen = new Map(); From 97eda1774152881ec950ed027cd1c94a0c3996f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:57:23 +0000 Subject: [PATCH 2/6] test(picklists): suites resolve picklist-bound fields through the shared lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engines built with `ObjectQL.create({ objects })` register the stack's picklists (the factory takes none, so every picklist-bound field refused every value), and the static option readers read a field's offered options — its inline list, or the picklist it names — so the #490/#531 superset guard and the import-mapping legality check keep reading the industry and lead_source copies instead of skipping them. The placeholder and translation-key guards follow the option labels to `picklists.`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3 --- test/actions-flows-integrity.test.ts | 18 ++++-- test/attendee-type-resolution.test.ts | 2 + test/campaign-member-cascade.test.ts | 2 + test/event-attendee-cascade.test.ts | 2 + test/flow-variable-conditions.test.ts | 2 + test/global-actions.test.ts | 6 +- test/helpers/stack-picklists.ts | 55 +++++++++++++++++++ test/hook-org-inheritance.test.ts | 2 + test/i18n-references.test.ts | 27 +++++++++ test/import-mappings.test.ts | 4 +- test/line-item-cascade.test.ts | 2 + test/opportunity-line-item-add-picker.test.ts | 2 + test/placeholder-picklist-options.test.ts | 21 +++++++ test/quote-accepted-payment-terms.test.ts | 3 +- test/win-loss-capture.test.ts | 7 +++ 15 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 test/helpers/stack-picklists.ts diff --git a/test/actions-flows-integrity.test.ts b/test/actions-flows-integrity.test.ts index b9cfaa66a..2c1948a39 100644 --- a/test/actions-flows-integrity.test.ts +++ b/test/actions-flows-integrity.test.ts @@ -7,6 +7,7 @@ import { flowNodesDeep } from './helpers/flow-regions'; import { join } from 'node:path'; import { REPO_ROOT } from './helpers/repo-root'; import { metadataFiles } from './helpers/src-roster'; +import { offeredOptions } from './helpers/stack-picklists'; /** * Action & flow contract guards — the CI net for the class of defect that @@ -115,10 +116,13 @@ describe('lead_conversion flow contracts', () => { // creates. When the two objects declared their own picklists, half the // Lead industries (`media`, `logistics`, …) were illegal enum values on // crm_account and conversion died in create_account (#490/#531). The - // vocabularies are unified in src/sales/objects/_picklists.ts; this pins the - // superset relation itself so drift in either object re-fails CI. + // vocabularies are shared picklists since #2000 (`industry`, `lead_source`), + // so the two sides offer one list; this pins the superset relation itself, + // read through each field's offered options, so drift in either object — + // a field moved back to an inline copy — re-fails CI. const leadFields = objects.find((o) => o.name === 'crm_lead')?.fields ?? {}; const bad: string[] = []; + const checked: string[] = []; for (const node of f?.nodes ?? []) { if (node.type !== 'create_record') continue; const targetObject: string = node.config?.objectName ?? ''; @@ -126,10 +130,11 @@ describe('lead_conversion flow contracts', () => { for (const [target, template] of Object.entries(node.config?.fields ?? {})) { const src = typeof template === 'string' && /^\{leadRecord\.(\w+)\}$/.exec(template)?.[1]; if (!src) continue; - const srcOptions = leadFields[src]?.options; - const dstOptions = targetFields[target]?.options; + const srcOptions = offeredOptions(leadFields[src]); + const dstOptions = offeredOptions(targetFields[target]); // Only select→select copies carry enum constraints on both sides. if (!Array.isArray(srcOptions) || !Array.isArray(dstOptions)) continue; + checked.push(`${targetObject}.${target}`); const legal = new Set(dstOptions.map((o: AnyRec) => o.value)); for (const o of srcOptions) { if (!legal.has(o.value)) { @@ -138,6 +143,11 @@ describe('lead_conversion flow contracts', () => { } } } + // Guard the guard: a resolution that stopped reading the shared lists would + // skip every copy and pass over nothing. + expect(checked, 'no select→select copy was checked').toEqual( + expect.arrayContaining(['crm_account.industry', 'crm_opportunity.lead_source']), + ); expect(bad, bad.join('\n')).toEqual([]); }); }); diff --git a/test/attendee-type-resolution.test.ts b/test/attendee-type-resolution.test.ts index 4ecc6db7c..8c5099b76 100644 --- a/test/attendee-type-resolution.test.ts +++ b/test/attendee-type-resolution.test.ts @@ -7,6 +7,7 @@ import { SysUser } from '@objectstack/platform-objects'; import stack from '../objectstack.config'; import { ATTENDEE_RESOLUTIONS } from '../src/sales/objects/event_attendee.object'; import { eventAttendeesFromContacts, eventAttendeesFromLeads } from '../src/sales/data/activity.seed'; +import { registerStackPicklists } from './helpers/stack-picklists'; /** * `attendee_type` and the column it names must agree (#740). @@ -160,6 +161,7 @@ describe('the acceptance surface, on a real engine', () => { sys_user: SysUser, } as never, })) as never; + registerStackPicklists(ql); api = ql.createContext({ isSystem: true }); const event = await api.object('crm_event').insert({ diff --git a/test/campaign-member-cascade.test.ts b/test/campaign-member-cascade.test.ts index 7f28b0322..5ee4096cb 100644 --- a/test/campaign-member-cascade.test.ts +++ b/test/campaign-member-cascade.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { InMemoryDriver } from '@objectstack/driver-memory'; import stack from '../objectstack.config'; +import { registerStackPicklists } from './helpers/stack-picklists'; /** * Deleting an enrolled person must work (#696). @@ -129,6 +130,7 @@ describe('deleting an enrolled person', () => { crm_account: byName('crm_account'), } as never, })) as never; + registerStackPicklists(ql); api = ql.createContext({ isSystem: true }); }); afterEach(async () => { diff --git a/test/event-attendee-cascade.test.ts b/test/event-attendee-cascade.test.ts index 2826f09fa..688373dd6 100644 --- a/test/event-attendee-cascade.test.ts +++ b/test/event-attendee-cascade.test.ts @@ -5,6 +5,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { InMemoryDriver } from '@objectstack/driver-memory'; import { SysUser } from '@objectstack/platform-objects'; import stack from '../objectstack.config'; +import { registerStackPicklists } from './helpers/stack-picklists'; /** * Deleting a person who attended a meeting must work (#711). @@ -199,6 +200,7 @@ describe('deleting a person who attended a meeting', () => { sys_user: SysUser, } as never, })) as never; + registerStackPicklists(ql); api = ql.createContext({ isSystem: true }); }); afterEach(async () => { diff --git a/test/flow-variable-conditions.test.ts b/test/flow-variable-conditions.test.ts index 892971cd2..64c30b01c 100644 --- a/test/flow-variable-conditions.test.ts +++ b/test/flow-variable-conditions.test.ts @@ -6,6 +6,7 @@ import { InMemoryDriver } from '@objectstack/driver-memory'; import { AutomationEngine, installBuiltinNodes } from '@objectstack/service-automation'; import stack from '../objectstack.config'; import { flowGraphDeep, regionsOf } from './helpers/flow-regions'; +import { registerStackPicklists } from './helpers/stack-picklists'; /** * ═══ HOUSE RULE: flow conditions over FLOW VARIABLES ═══════════════════════ @@ -945,6 +946,7 @@ describe('the two defects, reproduced end-to-end', () => { // type — without it this suite prints every engine INFO line. ...({ logger: silent } as object), })) as never; + registerStackPicklists(ql); const engine = new AutomationEngine(silent); installBuiltinNodes(engine, { diff --git a/test/global-actions.test.ts b/test/global-actions.test.ts index ff305f302..ddec3b2ed 100644 --- a/test/global-actions.test.ts +++ b/test/global-actions.test.ts @@ -7,6 +7,7 @@ import { ACTIVITY_TARGETS } from '../src/sales/actions/activity-actions'; import eventHooks from '../src/sales/objects/event.hook'; import { runActionBody, makeSandboxEngine, type ActionRunOpts } from './helpers/action-sandbox'; import { makeHarness, makeCtx, hookNamed } from './helpers/hook-harness'; +import { offeredOptions } from './helpers/stack-picklists'; /** * Behavioral guards for the activity actions in `src/actions/global.actions.ts`. @@ -109,9 +110,12 @@ describe('a rep can log an interaction on everything they sell to (#509 / #592)' // The map in `global.actions.ts` decides which `crm_event` column records // the link. A stale entry writes an event linked to nothing at all. const eventFields = objectByName.get('crm_event')?.fields ?? {}; + // `related_to_type` references the shared `related_to_type` picklist (#2000), + // so its options are the list's, not a copy on the field. const typeOptions = new Set( - (eventFields.related_to_type?.options ?? []).map((o: AnyRec) => o.value), + (offeredOptions(eventFields.related_to_type) ?? []).map((o: AnyRec) => o.value), ); + expect(typeOptions.size, 'crm_event.related_to_type offers no options').toBeGreaterThan(0); const bad: string[] = []; for (const [objectName, field] of Object.entries(ACTIVITY_TARGETS)) { if (!eventFields[field]) bad.push(`crm_event has no field "${field}" (for ${objectName})`); diff --git a/test/helpers/stack-picklists.ts b/test/helpers/stack-picklists.ts new file mode 100644 index 000000000..133c3f3d5 --- /dev/null +++ b/test/helpers/stack-picklists.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import stack from '../../objectstack.config'; + +type AnyRec = Record; + +/** + * The stack's shared option lists — `defineStack({ picklists })` (#2000). + * + * A select field authored `Field.select({ picklist: '' })` carries NO + * options of its own: the platform resolves them from the list it names when it + * serves or validates the object. Two consequences for suites, one helper each. + * + * This module holds no assertions and is not named `*.test.ts`, so vitest does + * not collect it as a suite. + */ +export const stackPicklists: AnyRec[] = (stack as any).picklists ?? []; + +/** + * Register the stack's picklists on an engine a suite built with + * `ObjectQL.create({ objects })`. + * + * That factory registers datasources, objects and hooks and nothing else, so + * handing it the stack's objects without the lists builds an engine on which + * every picklist-bound field refuses every value ("takes its values from + * picklist "industry", which no loaded package declares") — not the app. Boot + * registers `defineStack({ picklists })` through this same door + * (`registry.registerItem('picklist', …)`), and the engine resolves a list + * lazily, so calling this after `create()` is enough. + */ +export function registerStackPicklists(ql: AnyRec): void { + if (stackPicklists.length === 0) { + throw new Error('the stack declares no picklists — registering them would register nothing'); + } + for (const picklist of stackPicklists) ql.registry.registerItem('picklist', picklist); +} + +/** + * The options a select field OFFERS, read off the authored metadata: its own + * inline `options`, or the options of the picklist its `picklist` names. + * + * Not a fallback — the spec refuses a field that declares both, so exactly one + * source exists and this reads it. A `picklist` naming no list the stack + * declares throws rather than reading as "no options", which would turn a + * legality check into a silent pass. + */ +export function offeredOptions(field: AnyRec | undefined): AnyRec[] | undefined { + if (!field) return undefined; + if (typeof field.picklist === 'string') { + const list = stackPicklists.find((p) => p.name === field.picklist); + if (!list) throw new Error(`field names picklist "${field.picklist}", which the stack does not declare`); + return list.options; + } + return field.options; +} diff --git a/test/hook-org-inheritance.test.ts b/test/hook-org-inheritance.test.ts index 058c667a7..bcc48a72d 100644 --- a/test/hook-org-inheritance.test.ts +++ b/test/hook-org-inheritance.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL, bindHooksToEngine, applySystemFields } from '@objectstack/objectql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import stack from '../objectstack.config'; +import { registerStackPicklists } from './helpers/stack-picklists'; /** * Records this app's HOOKS create inherit the triggering caller's organization @@ -127,6 +128,7 @@ beforeAll(async () => { datasources: { default: driver as never }, objects: objectMap as never, } as never)) as AnyRec; + registerStackPicklists(ql); await driver.initObjects( objects.map((o) => { diff --git a/test/i18n-references.test.ts b/test/i18n-references.test.ts index 2dd442384..5b3db016f 100644 --- a/test/i18n-references.test.ts +++ b/test/i18n-references.test.ts @@ -13,6 +13,7 @@ import { packFor, fieldsOf, } from './helpers/metadata-fixtures'; +import { stackPicklists } from './helpers/stack-picklists'; /** * Translation-completeness guards: every authored surface is translated in @@ -87,6 +88,32 @@ describe('picklist values never reach the UI unresolved', () => { expect(bad, `option translations that resolve to nothing:\n ${bad.join('\n ')}`).toEqual([]); }); + it('shared picklist translations are keyed by the list\'s option VALUES (#2000)', () => { + // A field that references a picklist inherits `picklists..options`, + // matched by value exactly as a field's own options are — so the same two + // misses resolve to nothing: a key that is not one of the list's values, + // and a list name the stack does not declare. + expect( + localePacks.every(([, pack]) => Object.keys(pack?.picklists ?? {}).length > 0), + 'a locale pack carries no picklist translations', + ).toBe(true); + const bad: string[] = []; + for (const [locale, pack] of localePacks) { + for (const [name, entry] of Object.entries(pack?.picklists ?? {})) { + const list = stackPicklists.find((p) => p.name === name); + if (!list) { + bad.push(`${locale}: translates picklist "${name}", which the stack does not declare`); + continue; + } + const values = (list.options as AnyRec[]).map((o) => String(o.value)); + for (const key of Object.keys(entry?.options ?? {})) { + if (!values.includes(key)) bad.push(`${locale}: picklists.${name} option key "${key}" is not an option value`); + } + } + } + expect(bad, `picklist translations that resolve to nothing:\n ${bad.join('\n ')}`).toEqual([]); + }); + it('translated object and field keys name real objects and fields', () => { // A translation keyed to a renamed/removed field is dead weight that reads // as coverage — the bundle looks complete while the screen stays English. diff --git a/test/import-mappings.test.ts b/test/import-mappings.test.ts index e935390eb..d87b437e1 100644 --- a/test/import-mappings.test.ts +++ b/test/import-mappings.test.ts @@ -7,6 +7,7 @@ import { unknownImportMappingTargets } from '@objectstack/spec/data'; import { applyMappingToRows, buildFieldMetaMap, coerceRow, type MappingArtifactLike } from '@objectstack/core'; import { REPO_ROOT } from './helpers/repo-root'; import stack from '../objectstack.config'; +import { offeredOptions } from './helpers/stack-picklists'; /** * Import-mapping integrity guards (#603). @@ -178,7 +179,8 @@ describe('import mappings — transforms the import path can execute', () => { for (const entry of m.fieldMapping as AnyRec[]) { if (entry.transform !== 'map') continue; const target = first(entry.target); - const options: AnyRec[] = fields[target]?.options ?? []; + // A shared-list field (`industry`, `lead_source`, #2000) offers its picklist's options. + const options: AnyRec[] = offeredOptions(fields[target]) ?? []; expect(options.length, `${target} is mapped with 'map' but declares no options`).toBeGreaterThan(0); const legal = new Set(options.map((o) => o.value)); for (const [source, mapped] of Object.entries(entry.params?.valueMap ?? {})) { diff --git a/test/line-item-cascade.test.ts b/test/line-item-cascade.test.ts index a96efb487..dab304b7a 100644 --- a/test/line-item-cascade.test.ts +++ b/test/line-item-cascade.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { InMemoryDriver } from '@objectstack/driver-memory'; import stack from '../objectstack.config'; +import { registerStackPicklists } from './helpers/stack-picklists'; /** * Deleting an itemised opportunity or quote must work (#727). @@ -138,6 +139,7 @@ describe('deleting an itemised parent', () => { crm_product: byName('crm_product'), } as never, })) as never; + registerStackPicklists(ql); api = ql.createContext({ isSystem: true }); }); afterEach(async () => { diff --git a/test/opportunity-line-item-add-picker.test.ts b/test/opportunity-line-item-add-picker.test.ts index fbb901be4..04455b26f 100644 --- a/test/opportunity-line-item-add-picker.test.ts +++ b/test/opportunity-line-item-add-picker.test.ts @@ -6,6 +6,7 @@ import { InMemoryDriver } from '@objectstack/driver-memory'; import stack from '../objectstack.config'; import { products } from '../src/revenue/data/catalog.seed'; import { type AnyRec, objects, pages, walk } from './helpers/metadata-fixtures'; +import { registerStackPicklists } from './helpers/stack-picklists'; /** * The opportunity Products panel offers a route to add a line item (#1731). @@ -152,6 +153,7 @@ describe('the row a picker-shaped insert actually produces', () => { crm_product: PRODUCT, } as never, })) as never; + registerStackPicklists(ql); if (withHooks) { const hooks: AnyRec[] = (stack as never as AnyRec).hooks ?? []; bindHooksToEngine( diff --git a/test/placeholder-picklist-options.test.ts b/test/placeholder-picklist-options.test.ts index 511517d39..f0199e53a 100644 --- a/test/placeholder-picklist-options.test.ts +++ b/test/placeholder-picklist-options.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { type AnyRec, objects, localePacks } from './helpers/metadata-fixtures'; +import { stackPicklists } from './helpers/stack-picklists'; /** * Placeholder picklists never ship as production metadata (#1061). @@ -49,8 +50,18 @@ type Hit = { where: string; label: string }; */ const PLACEHOLDER_SERIAL = /(?:^|[^A-Za-z])([A-Z])\s*$/; +/** + * Option labels authored in metadata: every field's inline `options`, and every + * shared picklist's (`*.picklist.ts`, #2000) — a field that references a list + * carries no options of its own, so the list is where its labels are written. + */ const optionLabelsFromObjects = (): Hit[] => { const out: Hit[] = []; + for (const list of stackPicklists) { + for (const opt of (list.options ?? []) as AnyRec[]) { + if (typeof opt?.label === 'string') out.push({ where: `picklist ${list.name}`, label: opt.label }); + } + } for (const obj of objects) { for (const [fieldName, def] of Object.entries((obj.fields ?? {}) as Record)) { const options = def?.options; @@ -65,9 +76,15 @@ const optionLabelsFromObjects = (): Hit[] => { return out; }; +/** Option labels in the locale packs: per field, and per shared picklist (`picklists.`). */ const optionLabelsFromPacks = (): Hit[] => { const out: Hit[] = []; for (const [locale, pack] of localePacks) { + for (const [name, entry] of Object.entries((pack.picklists ?? {}) as Record)) { + for (const [value, label] of Object.entries((entry?.options ?? {}) as Record)) { + if (typeof label === 'string') out.push({ where: `${locale}: picklists.${name}.${value}`, label }); + } + } for (const [objectName, objEntry] of Object.entries((pack.objects ?? {}) as Record)) { for (const [fieldName, fieldEntry] of Object.entries( (objEntry?.fields ?? {}) as Record, @@ -90,6 +107,10 @@ describe('no picklist ships placeholder option labels (#1061)', () => { // `Competitor A/B/C` sit in production metadata unnoticed in the first place. expect(optionLabelsFromObjects().length, 'no option labels read off the objects').toBeGreaterThan(80); expect(optionLabelsFromPacks().length, 'no option labels read off the locale packs').toBeGreaterThan(200); + // The shared lists (#2000) are read on both surfaces too, not only the + // fields that still carry inline options. + expect(optionLabelsFromObjects().filter((h) => h.where.startsWith('picklist ')).length).toBeGreaterThan(30); + expect(optionLabelsFromPacks().filter((h) => h.where.includes(': picklists.')).length).toBeGreaterThan(120); }); it('no option label is a serial placeholder ("Competitor A")', () => { diff --git a/test/quote-accepted-payment-terms.test.ts b/test/quote-accepted-payment-terms.test.ts index 388091a28..9058f89cf 100644 --- a/test/quote-accepted-payment-terms.test.ts +++ b/test/quote-accepted-payment-terms.test.ts @@ -202,7 +202,8 @@ describe('what a real crm_contract does with those documents', () => { // would be true of any string and would prove nothing about the superset. const doc = { ...(await draftFor({}, {})), payment_terms: 'net_45' }; await expect(api.object('crm_contract').insert(doc)).rejects.toThrow( - /Payment Terms must be one of: net_15, net_30, net_60, net_90, due_on_receipt/, + // Since #2000 the refusal names the picklist the field takes its values from. + /Payment Terms must be one of the values of picklist "payment_terms": net_15, net_30, net_60, net_90, due_on_receipt/, ); }); }); diff --git a/test/win-loss-capture.test.ts b/test/win-loss-capture.test.ts index ebe5fbcdb..6eafbc675 100644 --- a/test/win-loss-capture.test.ts +++ b/test/win-loss-capture.test.ts @@ -7,6 +7,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AnalyticsService } from '@objectstack/service-analytics'; import stack from '../objectstack.config'; import { OpportunityDataset } from '../src/sales/datasets/opportunity.dataset'; +import { registerStackPicklists } from './helpers/stack-picklists'; /** * Win/loss reasons are CAPTURED, and win rate is MEASURED (#593). @@ -131,6 +132,7 @@ describe('the write is REJECTED, not warned about (in-memory driver)', () => { datasources: { default: new InMemoryDriver({ persistence: false }) }, objects: { crm_opportunity: opportunity } as never, })) as never; + registerStackPicklists(ql); }); afterAll(async () => { await ql?.close(); @@ -256,6 +258,7 @@ describe('the write is REJECTED on a real SQLite database too', () => { datasources: { default: driver }, objects: { crm_opportunity: opportunity } as never, })) as never; + registerStackPicklists(ql); }, 60_000); afterAll(async () => { await ql?.close(); @@ -305,6 +308,7 @@ describe('crm_case.resolution_required_for_closed is still live', () => { datasources: { default: new InMemoryDriver({ persistence: false }) }, objects: { crm_case: kase } as never, })) as never; + registerStackPicklists(ql); }); afterAll(async () => { await ql?.close(); @@ -385,6 +389,7 @@ describe('every settled seed carries its reason', () => { datasources: { default: new InMemoryDriver({ persistence: false }) }, objects: { crm_opportunity: opportunity } as never, })) as AnyRec; + registerStackPicklists(ql); try { const api = ql.createContext({ isSystem: true }); const isCel = (v: unknown) => !!v && typeof v === 'object' && 'source' in (v as AnyRec); @@ -455,6 +460,7 @@ describe('win rate is measured, and both halves are load-bearing', () => { }, } as never, })) as never; + registerStackPicklists(ql); analytics = new AnalyticsService({ // The same bridge `AnalyticsServicePlugin` wires at boot. @@ -742,6 +748,7 @@ describe('the shipped seeds produce a real win rate and a real loss breakdown', }, } as never, })) as never; + registerStackPicklists(ql); const api = ql.createContext({ isSystem: true }); for (const rec of seedRecords) { From 1d70cd667241cc3246ab903c6b6e649bb2e25b3c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:58:12 +0000 Subject: [PATCH 3/6] docs(picklists): point the stage docs at the stage field, add the changeset The product pages named `OPPORTUNITY_STAGE_OPTIONS` in the deleted `src/sales/objects/_picklists.ts`; the stage list is now the `stage` field's own options in `opportunity.object.ts`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3 --- .changeset/2000-shared-picklists.md | 36 +++++++++++++++++++ content/docs/administration/setup.mdx | 2 +- content/docs/administration/setup.zh-Hans.mdx | 2 +- content/docs/administration/setup.zh-Hant.mdx | 2 +- content/docs/sales/opportunities.mdx | 2 +- content/docs/sales/opportunities.zh-Hans.mdx | 2 +- content/docs/sales/opportunities.zh-Hant.mdx | 2 +- content/docs/sales/pipeline-management.mdx | 2 +- .../sales/pipeline-management.zh-Hans.mdx | 2 +- .../sales/pipeline-management.zh-Hant.mdx | 2 +- 10 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 .changeset/2000-shared-picklists.md diff --git a/.changeset/2000-shared-picklists.md b/.changeset/2000-shared-picklists.md new file mode 100644 index 000000000..5a64bf562 --- /dev/null +++ b/.changeset/2000-shared-picklists.md @@ -0,0 +1,36 @@ +--- +'hotcrm': minor +--- + +Shared option lists are now platform picklists: Industry, Lead Source, Salutation, Payment Terms and Related To Type are each declared once + +Five option lists that several fields offer are now declared once, as platform +`picklist` metadata, and every field that offers one references it by name +instead of carrying its own copy: + +| Picklist | Fields that offer it | +|:--|:--| +| `industry` | Lead and Account **Industry** | +| `lead_source` | Lead, Contact and Opportunity **Lead Source** | +| `salutation` | Lead and Contact **Salutation** | +| `payment_terms` | Quote and Contract **Payment Terms** | +| `related_to_type` | Task and Event **Related To Type** | + +Nothing a user picks or stores changes: every value, label, color and default is +the same, and so is the order. What changes is that each list can no longer +drift between the objects that share it, because there is only one. A value +converted from a lead onto an account or an opportunity is always a value the +target accepts, and a value outside the list is refused on every object that +offers it, with a message that names the list. + +**FROM → TO for integrations that read metadata.** A picklist-bound field is +served with its resolved `options` as before, and now also carries +`picklist: ''`. The option labels of these five lists are translated once +per locale, under `picklists.` in the language packs, instead of under +each field. + +Lists that only one field offers (Opportunity **Stage**, Task and Event **Type**, +Event **Status**, Attendee **Response**, Lead **Need Type** and Lead +**Duplicate Of**) moved back onto that field as its own options. The +**Update Stage** action and the **Schedule Follow-up** screen now read +their option lists from the field they mirror. diff --git a/content/docs/administration/setup.mdx b/content/docs/administration/setup.mdx index de32ff9c5..6335e9714 100644 --- a/content/docs/administration/setup.mdx +++ b/content/docs/administration/setup.mdx @@ -92,7 +92,7 @@ Confirm the 7-stage default pipeline matches your sales process. Stages and prob | Closed Won | 100% | | Closed Lost | 0% | -If your process differs, that is a source edit rather than a screen: **there is no Setup → Opportunity → Stages page**. The seven stages are the `stage` field's own options (`OPPORTUNITY_STAGE_OPTIONS` in `src/sales/objects/_picklists.ts`), and the probabilities are the `STAGE_PROBABILITY` map in `src/sales/objects/opportunity.hook.ts` — which re-derives **Probability** and **Expected Revenue** from the stage on every save, so a probability typed onto a deal by hand does not survive the next one. Changing either is a code change plus a redeploy. +If your process differs, that is a source edit rather than a screen: **there is no Setup → Opportunity → Stages page**. The seven stages are the `stage` field's own options (in `src/sales/objects/opportunity.object.ts`), and the probabilities are the `STAGE_PROBABILITY` map in `src/sales/objects/opportunity.hook.ts` — which re-derives **Probability** and **Expected Revenue** from the stage on every save, so a probability typed onto a deal by hand does not survive the next one. Changing either is a code change plus a redeploy. ### 11. Case SLAs diff --git a/content/docs/administration/setup.zh-Hans.mdx b/content/docs/administration/setup.zh-Hans.mdx index 7bcb6e24c..1f58942a4 100644 --- a/content/docs/administration/setup.zh-Hans.mdx +++ b/content/docs/administration/setup.zh-Hans.mdx @@ -92,7 +92,7 @@ description: 让 HotCRM 为你的团队做好生产准备的第一周任务。 | Closed Won | 100% | | Closed Lost | 0% | -如果你的流程不同,那是一次源码修改,不是在某个屏上调整:**并不存在「系统设置 → 商机 → 阶段」这个页面**。七个阶段是 `stage` 字段自己的选项(`src/sales/objects/_picklists.ts` 里的 `OPPORTUNITY_STAGE_OPTIONS`),概率则是 `src/sales/objects/opportunity.hook.ts` 里的 `STAGE_PROBABILITY` 映射 —— 它在每次保存时都按阶段重新推导 **Probability** 与 **Expected Revenue**,所以手工填在商机上的概率撑不过下一次保存。改动其中任何一项都是代码改动加一次重新部署。 +如果你的流程不同,那是一次源码修改,不是在某个屏上调整:**并不存在「系统设置 → 商机 → 阶段」这个页面**。七个阶段是 `stage` 字段自己的选项(在 `src/sales/objects/opportunity.object.ts` 里),概率则是 `src/sales/objects/opportunity.hook.ts` 里的 `STAGE_PROBABILITY` 映射 —— 它在每次保存时都按阶段重新推导 **Probability** 与 **Expected Revenue**,所以手工填在商机上的概率撑不过下一次保存。改动其中任何一项都是代码改动加一次重新部署。 ### 11. 工单 SLA diff --git a/content/docs/administration/setup.zh-Hant.mdx b/content/docs/administration/setup.zh-Hant.mdx index db7d58df6..eb8089b64 100644 --- a/content/docs/administration/setup.zh-Hant.mdx +++ b/content/docs/administration/setup.zh-Hant.mdx @@ -94,7 +94,7 @@ description: 讓 HotCRM 為你的團隊做好生產準備的第一週任務。 | Closed Won | 100% | | Closed Lost | 0% | -如果你的流程不同,那是一次原始碼修改,不是在某個畫面上調整:**並不存在「Setup → Opportunity → Stages」這個頁面**。七個階段是 `stage` 欄位自己的選項(`src/sales/objects/_picklists.ts` 裡的 `OPPORTUNITY_STAGE_OPTIONS`),機率則是 `src/sales/objects/opportunity.hook.ts` 裡的 `STAGE_PROBABILITY` 對應表 —— 它在每次儲存時都按階段重新推導 **Probability** 與 **Expected Revenue**,所以手工填在商機上的機率撐不過下一次儲存。改動其中任何一項都是程式碼改動加一次重新部署。 +如果你的流程不同,那是一次原始碼修改,不是在某個畫面上調整:**並不存在「Setup → Opportunity → Stages」這個頁面**。七個階段是 `stage` 欄位自己的選項(在 `src/sales/objects/opportunity.object.ts` 裡),機率則是 `src/sales/objects/opportunity.hook.ts` 裡的 `STAGE_PROBABILITY` 對應表 —— 它在每次儲存時都按階段重新推導 **Probability** 與 **Expected Revenue**,所以手工填在商機上的機率撐不過下一次儲存。改動其中任何一項都是程式碼改動加一次重新部署。 ### 11. 工單 SLA diff --git a/content/docs/sales/opportunities.mdx b/content/docs/sales/opportunities.mdx index 9320f22b3..65fb2f9fc 100644 --- a/content/docs/sales/opportunities.mdx +++ b/content/docs/sales/opportunities.mdx @@ -205,6 +205,6 @@ When you open an opportunity, ask: ## Tips for admins -- The stage list, the default probabilities and the legal transitions live in three places in the source, not on a Setup screen: the shared `OPPORTUNITY_STAGE_OPTIONS` picklist (`src/sales/objects/_picklists.ts`), which the `stage` field renders; the `STAGE_PROBABILITY` map inside the `opportunity_lifecycle` hook (`src/sales/objects/opportunity.hook.ts`), which re-syncs *Probability* on every save; and the `opportunity_stage_progression` state-machine validation rule on the opportunity object (`src/sales/objects/opportunity.object.ts`). Change the three in lockstep. See [Customization › Extending Objects](/docs/customization/extending-objects). +- The stage list, the default probabilities and the legal transitions live in three places in the source, not on a Setup screen: the `stage` field's own options (`src/sales/objects/opportunity.object.ts`); the `STAGE_PROBABILITY` map inside the `opportunity_lifecycle` hook (`src/sales/objects/opportunity.hook.ts`), which re-syncs *Probability* on every save; and the `opportunity_stage_progression` state-machine validation rule on the opportunity object (`src/sales/objects/opportunity.object.ts`). Change the three in lockstep. See [Customization › Extending Objects](/docs/customization/extending-objects). - The $500K discount approval threshold can be changed in the [approval process configuration](/docs/revenue/approvals). - The $100K large-deal notification threshold is not a Setup field either: it is the CEL start condition of the `opportunity_won_alert` flow — `record.amount >= 100000` in `src/sales/flows/opportunity-won-alert.flow.ts`. Who gets the alert is that flow's `notify` node, whose `recipients` list is the single entry `{record.owner_id}` — the deal owner alone. diff --git a/content/docs/sales/opportunities.zh-Hans.mdx b/content/docs/sales/opportunities.zh-Hans.mdx index 5dbd67cdf..eb64c158b 100644 --- a/content/docs/sales/opportunities.zh-Hans.mdx +++ b/content/docs/sales/opportunities.zh-Hans.mdx @@ -205,6 +205,6 @@ description: 活跃的销售交易——销售管道的核心,包含 7 个阶 ## 给管理员的提示 -- 阶段列表、默认概率和合法的状态转换分散在源码的三处,而不是某个 Setup 界面:共享选择列表 `OPPORTUNITY_STAGE_OPTIONS`(`src/sales/objects/_picklists.ts`),由 `stage` 字段渲染;`opportunity_lifecycle` 钩子里的 `STAGE_PROBABILITY` 映射(`src/sales/objects/opportunity.hook.ts`),每次保存都据此重新同步*成交概率*;以及商机对象上名为 `opportunity_stage_progression` 的状态机校验规则(`src/sales/objects/opportunity.object.ts`)。三处需同步修改。参见 [定制化 › 扩展对象](/zh-Hans/docs/customization/extending-objects)。 +- 阶段列表、默认概率和合法的状态转换分散在源码的三处,而不是某个 Setup 界面:`stage` 字段自己的选项(`src/sales/objects/opportunity.object.ts`);`opportunity_lifecycle` 钩子里的 `STAGE_PROBABILITY` 映射(`src/sales/objects/opportunity.hook.ts`),每次保存都据此重新同步*成交概率*;以及商机对象上名为 `opportunity_stage_progression` 的状态机校验规则(`src/sales/objects/opportunity.object.ts`)。三处需同步修改。参见 [定制化 › 扩展对象](/zh-Hans/docs/customization/extending-objects)。 - $500K 折扣审批阈值可以在 [审批流程配置](/zh-Hans/docs/revenue/approvals) 中更改。 - $100K 大单通知阈值同样不是 Setup 界面上的字段:它是 `opportunity_won_alert` 流程的 CEL 起始条件——`src/sales/flows/opportunity-won-alert.flow.ts` 里的 `record.amount >= 100000`。收件人则由该流程 `notify` 节点的 `recipients` 决定,列表里只有 `{record.owner_id}` 一项,即商机负责人本人。 diff --git a/content/docs/sales/opportunities.zh-Hant.mdx b/content/docs/sales/opportunities.zh-Hant.mdx index 96f3da813..147277eee 100644 --- a/content/docs/sales/opportunities.zh-Hant.mdx +++ b/content/docs/sales/opportunities.zh-Hant.mdx @@ -207,6 +207,6 @@ description: 活躍的銷售交易——銷售管道的核心,包含 7 個階 ## 給管理員的提示 -- 階段列表、預設機率和合法的狀態轉換分散在原始碼的三處,而不是某個 Setup 介面:共享選擇列表 `OPPORTUNITY_STAGE_OPTIONS`(`src/sales/objects/_picklists.ts`),由 `stage` 欄位算繪;`opportunity_lifecycle` 鉤子裡的 `STAGE_PROBABILITY` 對映(`src/sales/objects/opportunity.hook.ts`),每次儲存都據此重新同步*成交機率*;以及商機物件上名為 `opportunity_stage_progression` 的狀態機驗證規則(`src/sales/objects/opportunity.object.ts`)。三處需同步修改。參見 [客製化 › 擴展物件](/zh-Hant/docs/customization/extending-objects)。 +- 階段列表、預設機率和合法的狀態轉換分散在原始碼的三處,而不是某個 Setup 介面:`stage` 欄位自己的選項(`src/sales/objects/opportunity.object.ts`);`opportunity_lifecycle` 鉤子裡的 `STAGE_PROBABILITY` 對映(`src/sales/objects/opportunity.hook.ts`),每次儲存都據此重新同步*成交機率*;以及商機物件上名為 `opportunity_stage_progression` 的狀態機驗證規則(`src/sales/objects/opportunity.object.ts`)。三處需同步修改。參見 [客製化 › 擴展物件](/zh-Hant/docs/customization/extending-objects)。 - $500K 折扣審批閾值可以在 [審批流程設定](/zh-Hant/docs/revenue/approvals) 中更改。 - $100K 大單通知閾值同樣不是 Setup 介面上的欄位:它是 `opportunity_won_alert` 流程的 CEL 起始條件——`src/sales/flows/opportunity-won-alert.flow.ts` 裡的 `record.amount >= 100000`。收件人則由該流程 `notify` 節點的 `recipients` 決定,列表裡只有 `{record.owner_id}` 一項,即商機負責人本人。 diff --git a/content/docs/sales/pipeline-management.mdx b/content/docs/sales/pipeline-management.mdx index 7812035ec..172ad093d 100644 --- a/content/docs/sales/pipeline-management.mdx +++ b/content/docs/sales/pipeline-management.mdx @@ -116,5 +116,5 @@ What that dashboard will not give you is a *top deals* ranking, and no renaming ## Tips for admins -- Add or remove stages? You need to update three places in the source, none of them a Setup screen: the `OPPORTUNITY_STAGE_OPTIONS` picklist the `stage` field renders (`src/sales/objects/_picklists.ts`), the `STAGE_PROBABILITY` map that sets probabilities inside the `opportunity_lifecycle` hook (`src/sales/objects/opportunity.hook.ts`), and the `opportunity_stage_progression` state-machine validation rule that controls transitions (`src/sales/objects/opportunity.object.ts`). See [Customization › Extending Objects](/docs/customization/extending-objects). +- Add or remove stages? You need to update three places in the source, none of them a Setup screen: the `stage` field's own options (`src/sales/objects/opportunity.object.ts`), the `STAGE_PROBABILITY` map that sets probabilities inside the `opportunity_lifecycle` hook (`src/sales/objects/opportunity.hook.ts`), and the `opportunity_stage_progression` state-machine validation rule that controls transitions (`src/sales/objects/opportunity.object.ts`). See [Customization › Extending Objects](/docs/customization/extending-objects). - The $100K *large deal* notification threshold is not a Setup field either: it is the CEL start condition of the `opportunity_won_alert` flow — `record.amount >= 100000` in `src/sales/flows/opportunity-won-alert.flow.ts`. Who gets the alert is that flow's `notify` node, whose `recipients` list is the single entry `{record.owner_id}` — the deal owner alone. diff --git a/content/docs/sales/pipeline-management.zh-Hans.mdx b/content/docs/sales/pipeline-management.zh-Hans.mdx index 03286212f..4b9585370 100644 --- a/content/docs/sales/pipeline-management.zh-Hans.mdx +++ b/content/docs/sales/pipeline-management.zh-Hans.mdx @@ -116,5 +116,5 @@ description: 销售管道的日常运作方式——推进交易、自动概率 ## 给管理员的提示 -- 添加或移除阶段?你需要更新源码里的三处,没有一处是 Setup 界面:`stage` 字段所渲染的 `OPPORTUNITY_STAGE_OPTIONS` 选择列表(`src/sales/objects/_picklists.ts`)、`opportunity_lifecycle` 钩子里设定概率的 `STAGE_PROBABILITY` 映射(`src/sales/objects/opportunity.hook.ts`),以及控制状态转换的 `opportunity_stage_progression` 状态机校验规则(`src/sales/objects/opportunity.object.ts`)。参见 [定制化 › 扩展对象](/zh-Hans/docs/customization/extending-objects)。 +- 添加或移除阶段?你需要更新源码里的三处,没有一处是 Setup 界面:`stage` 字段自己的选项(`src/sales/objects/opportunity.object.ts`)、`opportunity_lifecycle` 钩子里设定概率的 `STAGE_PROBABILITY` 映射(`src/sales/objects/opportunity.hook.ts`),以及控制状态转换的 `opportunity_stage_progression` 状态机校验规则(`src/sales/objects/opportunity.object.ts`)。参见 [定制化 › 扩展对象](/zh-Hans/docs/customization/extending-objects)。 - $100K *大单*通知阈值同样不是 Setup 界面上的字段:它是 `opportunity_won_alert` 流程的 CEL 起始条件——`src/sales/flows/opportunity-won-alert.flow.ts` 里的 `record.amount >= 100000`。收件人则由该流程 `notify` 节点的 `recipients` 决定,列表里只有 `{record.owner_id}` 一项,即商机负责人本人。 diff --git a/content/docs/sales/pipeline-management.zh-Hant.mdx b/content/docs/sales/pipeline-management.zh-Hant.mdx index fcba13559..11c05021c 100644 --- a/content/docs/sales/pipeline-management.zh-Hant.mdx +++ b/content/docs/sales/pipeline-management.zh-Hant.mdx @@ -118,5 +118,5 @@ description: 銷售管道的日常運作方式——推進交易、自動機率 ## 給管理員的提示 -- 新增或移除階段?你需要更新原始碼裡的三處,沒有一處是 Setup 介面:`stage` 欄位所算繪的 `OPPORTUNITY_STAGE_OPTIONS` 選擇列表(`src/sales/objects/_picklists.ts`)、`opportunity_lifecycle` 鉤子裡設定機率的 `STAGE_PROBABILITY` 對映(`src/sales/objects/opportunity.hook.ts`),以及控制狀態轉換的 `opportunity_stage_progression` 狀態機驗證規則(`src/sales/objects/opportunity.object.ts`)。參見 [客製化 › 擴展物件](/zh-Hant/docs/customization/extending-objects)。 +- 新增或移除階段?你需要更新原始碼裡的三處,沒有一處是 Setup 介面:`stage` 欄位自己的選項(`src/sales/objects/opportunity.object.ts`)、`opportunity_lifecycle` 鉤子裡設定機率的 `STAGE_PROBABILITY` 對映(`src/sales/objects/opportunity.hook.ts`),以及控制狀態轉換的 `opportunity_stage_progression` 狀態機驗證規則(`src/sales/objects/opportunity.object.ts`)。參見 [客製化 › 擴展物件](/zh-Hant/docs/customization/extending-objects)。 - $100K *大單*通知閾值同樣不是 Setup 介面上的欄位:它是 `opportunity_won_alert` 流程的 CEL 起始條件——`src/sales/flows/opportunity-won-alert.flow.ts` 裡的 `record.amount >= 100000`。收件人則由該流程 `notify` 節點的 `recipients` 決定,列表裡只有 `{record.owner_id}` 一項,即商機負責人本人。 From c8f331cfadeba6ab153eec6bcab65e792c076b6e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:05:10 +0000 Subject: [PATCH 4/6] docs(changeset): spell the picklist placeholders without angle brackets Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3 --- .changeset/2000-shared-picklists.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/2000-shared-picklists.md b/.changeset/2000-shared-picklists.md index 5a64bf562..e2e6818b5 100644 --- a/.changeset/2000-shared-picklists.md +++ b/.changeset/2000-shared-picklists.md @@ -25,8 +25,8 @@ offers it, with a message that names the list. **FROM → TO for integrations that read metadata.** A picklist-bound field is served with its resolved `options` as before, and now also carries -`picklist: ''`. The option labels of these five lists are translated once -per locale, under `picklists.` in the language packs, instead of under +`picklist: 'NAME'`. The option labels of these five lists are translated once +per locale, under `picklists.NAME` in the language packs, instead of under each field. Lists that only one field offers (Opportunity **Stage**, Task and Event **Type**, From 2b4eb38b4d02973d919899b1a7e0a6a99d06a189 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 23:50:23 +0000 Subject: [PATCH 5/6] docs(picklists): repoint two comments at the shared picklists and the projected task type lead-conversion.flow.ts (arrived with #2068) still said the two selects a converted contact copies live in _picklists.ts; they reference the salutation and lead_source picklists. do-not-call-enforcement.test.ts named plainOptions(TASK_TYPE_OPTIONS), retired by this migration; the screen projects crm_task.type's own options. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv --- src/sales/flows/lead-conversion.flow.ts | 7 ++++--- test/do-not-call-enforcement.test.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/sales/flows/lead-conversion.flow.ts b/src/sales/flows/lead-conversion.flow.ts index e81cbd926..d52d8e7d7 100644 --- a/src/sales/flows/lead-conversion.flow.ts +++ b/src/sales/flows/lead-conversion.flow.ts @@ -513,9 +513,10 @@ export const LeadConversionFlow: Flow = { objectName: 'crm_contact', fields: { // The person's own facts travel with them (#2068). `crm_contact` - // declares each of these on the lead's vocabulary (`_picklists.ts` - // for the two selects, pinned by `test/actions-flows-integrity`), so - // a straight copy is always legal. The two opt-outs are copied, + // declares each of these on the lead's vocabulary (the two selects + // reference the same `salutation` and `lead_source` picklists, + // pinned by `test/actions-flows-integrity`), so a straight copy is + // always legal. The two opt-outs are copied, // never defaulted: a lead that opted out must not become a contact // that has not — `email_opt_out` hides Send Email and silences // `contact_welcome`, `do_not_call` refuses a scheduled call. A diff --git a/test/do-not-call-enforcement.test.ts b/test/do-not-call-enforcement.test.ts index 5b6958875..9240915a3 100644 --- a/test/do-not-call-enforcement.test.ts +++ b/test/do-not-call-enforcement.test.ts @@ -340,7 +340,7 @@ describe('the schedule_followup screen flow is covered by the task guard', () => /** * The card asked whether the phone OPTION could be gated in the follow-up * screen instead. It cannot: a screen field's `options` are static metadata - * (`plainOptions(TASK_TYPE_OPTIONS)`), evaluated with no record in scope, so + * (`crm_task.type`'s own options, projected), evaluated with no record in scope, so * there is no expression in which "hide Call for THIS lead" can be written. * Refusing at the point the phone type is chosen is what the metadata can * express — and it costs the flow no change at all, because the flow reaches From 05f36d10dc95f9a3992ca21c22140135e219babe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 00:29:38 +0000 Subject: [PATCH 6/6] docs(changeset): chart and report categories keep their translated labels Measured on the live boot (18.0.0-next.1): POST /api/v1/analytics/dataset/query relabels a picklist-bound dimension from picklists.NAME in zh-CN and ja-JP, like the inline-options control dimension beside it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv --- .changeset/2000-shared-picklists.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/2000-shared-picklists.md b/.changeset/2000-shared-picklists.md index e2e6818b5..8930ea4b9 100644 --- a/.changeset/2000-shared-picklists.md +++ b/.changeset/2000-shared-picklists.md @@ -27,7 +27,9 @@ offers it, with a message that names the list. served with its resolved `options` as before, and now also carries `picklist: 'NAME'`. The option labels of these five lists are translated once per locale, under `picklists.NAME` in the language packs, instead of under -each field. +each field. Chart and report categories over these fields keep their +translated labels: the pinned platform (18.0.0-next.1) resolves a +picklist-bound dimension through the same `picklists.NAME` translations. Lists that only one field offers (Opportunity **Stage**, Task and Event **Type**, Event **Status**, Attendee **Response**, Lead **Need Type** and Lead