From f7425b02003ca31a0218804c5cff4eb992372962 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 14:57:18 +0000 Subject: [PATCH 1/2] feat(objects): clm_contract.department is a lookup to sys_business_unit (kind department) The requesting department was an eight-option select: organisation structure written as schema, the shape #20's ruling C took away from our_entity. It is now a lookup to the platform's sys_business_unit filtered to active department-kind units, with the field name kept (business_unit is a platform reserved word) and still optional. The demo seeds seven departments under the US parent in the same upsert as the three signing entities, and every contract names its department by code. The intake asks for the department in the launch dialog with the other pickers, the dataset groups by the unit's name, and the legal workbench's filter reads its options from the units. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn --- src/actions/contract-backfill.actions.ts | 3 +- src/actions/contract.actions.ts | 14 +++-- src/apps/clm.app.ts | 14 ++--- src/dashboards/legal.dashboard.ts | 44 ++++++++-------- src/data/business-unit.seed.ts | 50 +++++++++++------- src/data/contract.seed.ts | 3 ++ src/data/demo-en/index.ts | 2 + src/data/demo-en/prose.ts | 14 +++++ src/data/demo-zh/index.ts | 2 + src/data/demo-zh/prose.ts | 11 ++++ src/data/index.ts | 10 ++-- src/data/plan.ts | 60 ++++++++++++++++++---- src/data/strings.ts | 5 ++ src/datasets/contract.dataset.ts | 13 +++-- src/flows/contract-intake.flow.ts | 28 +++++----- src/objects/contract.object.ts | 34 +++++++----- src/translations/en/flows.ts | 1 - src/translations/en/objects.contract.ts | 13 +---- src/translations/zh-CN/flows.ts | 1 - src/translations/zh-CN/objects.contract.ts | 13 +---- 20 files changed, 215 insertions(+), 120 deletions(-) diff --git a/src/actions/contract-backfill.actions.ts b/src/actions/contract-backfill.actions.ts index 880a5f4..15e4808 100644 --- a/src/actions/contract-backfill.actions.ts +++ b/src/actions/contract-backfill.actions.ts @@ -50,7 +50,8 @@ export const ExecutedUploadAction: Action = { params: [ // Field-backed where a picker is wanted: the param inherits the field's // type, its label and its `lookupFilters`, so inactive types, blocked - // counterparties and non-company business units are already out of the + // counterparties, and business units of the wrong kind (a non-company as + // the entity, a non-department as the department) are already out of the // pickers. { field: 'contract_type', required: true, helpText: 'The workflow this contract would have run. It still stamps the number, the category and the execution formalities.' }, { field: 'party', required: true, helpText: 'The counterparty on the executed document.' }, diff --git a/src/actions/contract.actions.ts b/src/actions/contract.actions.ts index 3d5e2df..96a30f8 100644 --- a/src/actions/contract.actions.ts +++ b/src/actions/contract.actions.ts @@ -8,11 +8,13 @@ import { P } from '@objectstack/spec'; * ## Why the action carries params at all * * The flat screens of a flow have no record picker (measured on the pinned - * console — see the flow's header), so the three pickers the intake needs are + * console — see the flow's header), so the four pickers the intake needs are * collected HERE, by the action's param dialog: field-backed lookup params * render the real pickers and inherit the fields' `lookupFilters` (active - * types only; blocked parties hidden; company-kind business units only). The console posts them as the flow's - * `params`, which the engine seeds into the input variables of the same name. + * types only; blocked parties hidden; company-kind business units for the + * entity, department-kind ones for the department). The console posts them + * as the flow's `params`, which the engine seeds into the input variables of + * the same name. * * ## The headless half * @@ -55,16 +57,18 @@ export const LaunchContractAction: Action = { target: 'contract_intake', locations: ['list_toolbar'], params: [ - // The three pickers a flat screen cannot render. + // The four pickers a flat screen cannot render. { field: 'contract_type', label: 'Contract type', required: true, helpText: 'The workflow this contract runs: which fields are asked, whether legal reviews it, how it is executed.' }, { field: 'party', label: 'Counterparty', required: false, helpText: 'Pick an existing counterparty, or leave empty to create one on the next step.' }, // Required here because it is required on the contract: a signing entity // is a business-unit row, so the action has no default to fall back to. { field: 'our_entity', label: 'Our signing entity', required: true, helpText: 'Which of our legal entities signs.' }, + // Optional, as the field is: a business-unit row of kind department + // (#109). Before #109 it was a select on the flow's core screen. + { field: 'department', label: 'Requesting department', required: false, helpText: 'The department launching this contract.' }, // Everything else is answered on the flow's screens; declared here for // the headless doors only. headless('title', 'text', 'Title'), - headless('department', 'text', 'Requesting department'), headless('amount', 'number', 'Contract amount'), headless('currency_code', 'text', 'Currency (ISO 4217, lowercase)'), headless('is_amount_estimated', 'boolean', 'Amount is an estimate'), diff --git a/src/apps/clm.app.ts b/src/apps/clm.app.ts index caf04ce..bcb79d3 100644 --- a/src/apps/clm.app.ts +++ b/src/apps/clm.app.ts @@ -57,13 +57,13 @@ import { App } from '@objectstack/spec/ui'; * switcher builds a tab per named `listViews` entry, so `status_kanban` is * one click from 全部合同 under its own label and zero clicks further than a * second sidebar row would be. - * - **签约主体与部门** (管理) is not a row here yet. The signing entities are - * rows now — `our_entity` is a lookup to the platform's `sys_business_unit` - * (kind `company`, #20 ruling C), maintained in Setup → Business Units, the - * platform's own org-chart surface — but `department` is still a - * `Field.select` option list on `clm_contract` (schema, not rows) until #109 - * moves it onto the same object. The sidebar row for the pair is decided - * once both halves are data, rather than half a row now. + * - **签约主体与部门** (管理) is not a row here yet. Both halves are rows of + * the platform's `sys_business_unit` now: `our_entity` is a lookup to the + * company-kind units (#20 ruling C) and `department` to the + * department-kind ones (#109). Both are maintained in Setup → Business + * Units, the platform's own org-chart surface. Whether the 管理 group also + * gets a row pointing there is a §05 navigation decision of its own, not + * part of either card. * - **报表** (管理) is still absent. Card 10 landed §09's four datasets and * three DASHBOARDS, which the 分析 group below points at with * `type: 'dashboard'` items; no `reports:` metadata exists yet, and a diff --git a/src/dashboards/legal.dashboard.ts b/src/dashboards/legal.dashboard.ts index 3d28ec0..15a9af2 100644 --- a/src/dashboards/legal.dashboard.ts +++ b/src/dashboards/legal.dashboard.ts @@ -61,11 +61,14 @@ import type { Dashboard } from '@objectstack/spec/ui'; * inline map produces none. A filter added later with a plain-string label * ships English on a zh-CN board with `pnpm lint:i18n-gate` still green. * - * ⚠️ These option labels DUPLICATE `objects..fields..options.*` - * in the bundle, which is not ideal and is not avoidable here: the filter - * declares its own option list (the schema requires it for a `select` filter), - * and the two are separate authored strings that happen to say the same thing. - * If they drift, the bundle's copy is the one the grid and the record page use. + * ⚠️ The `category` filter's option labels DUPLICATE + * `objects..fields..options.*` in the bundle, which is not + * ideal and is not avoidable here: the filter declares its own option list + * (the schema requires it for a `select` filter), and the two are separate + * authored strings that happen to say the same thing. If they drift, the + * bundle's copy is the one the grid and the record page use. The `department` + * filter has no such copy since #109: its options are business-unit rows, and + * a row's name is data, shown as written. */ export const LegalDashboard: Dashboard = { name: 'legal_workbench', @@ -75,10 +78,10 @@ export const LegalDashboard: Dashboard = { columns: 12, gap: 4, - // Both are persisted `Field.select` columns on `clm_contract`, and every - // widget on this dashboard is bound to `contract_metrics` (base object - // `clm_contract`), so neither filter can reach a widget whose object lacks - // the column. + // Both are persisted columns on `clm_contract` (`category` a select, + // `department` a lookup), and every widget on this dashboard is bound to + // `contract_metrics` (base object `clm_contract`), so neither filter can + // reach a widget whose object lacks the column. globalFilters: [ { name: 'category', @@ -100,23 +103,24 @@ export const LegalDashboard: Dashboard = { { value: 'other', label: { en: 'Other', 'zh-CN': '其他' } }, ], }, + // `department` is a lookup to `sys_business_unit` since #109, so the + // filter's options are the department-kind units themselves, read at run + // time, and the value it applies is the unit's id, which is what the + // column holds. Retired departments stay in the list on purpose: their + // contracts are still on the board. { name: 'department', field: 'department', object: 'clm_contract', label: { en: 'Requesting Department', 'zh-CN': '发起部门' }, - type: 'select', + type: 'lookup', scope: 'dashboard', - options: [ - { value: 'sales', label: { en: 'Sales', 'zh-CN': '销售' } }, - { value: 'procurement', label: { en: 'Procurement', 'zh-CN': '采购' } }, - { value: 'legal', label: { en: 'Legal', 'zh-CN': '法务' } }, - { value: 'finance', label: { en: 'Finance', 'zh-CN': '财务' } }, - { value: 'operations', label: { en: 'Operations', 'zh-CN': '运营' } }, - { value: 'people', label: { en: 'People / HR', 'zh-CN': '人力资源' } }, - { value: 'it', label: { en: 'IT', 'zh-CN': '信息技术' } }, - { value: 'other', label: { en: 'Other', 'zh-CN': '其他' } }, - ], + optionsFrom: { + object: 'sys_business_unit', + valueField: 'id', + labelField: 'name', + filter: { kind: 'department' }, + }, }, ], diff --git a/src/data/business-unit.seed.ts b/src/data/business-unit.seed.ts index 0c884bb..6bec6b3 100644 --- a/src/data/business-unit.seed.ts +++ b/src/data/business-unit.seed.ts @@ -3,19 +3,22 @@ import { SeedSchema } from '@objectstack/spec/data'; import { STRINGS } from './demo-locale.js'; -import { SIGNING_ENTITY_PLAN } from './plan.js'; +import { DEPARTMENT_PLAN, SIGNING_ENTITY_PLAN } from './plan.js'; /** * The group's three legal entities — DESIGN.md §10's US parent with a European - * and an Asia-Pacific subsidiary — as rows of the platform's organisation - * object, `sys_business_unit`. + * and an Asia-Pacific subsidiary — and the seven departments that launch its + * contracts, as rows of the platform's organisation object, + * `sys_business_unit`. * * `clm_contract.our_entity` is a lookup to that object filtered to * `kind: 'company'` (#20, ruling C), so a signing entity is DATA: a customer * adds theirs in Setup → Business Units, and this demo plays the admin's part - * by seeding the fictional group's. The parent/child link is the platform's - * own `parent_business_unit_id`, so the org-chart view in Setup shows the - * group as a tree. + * by seeding the fictional group's. `clm_contract.department` is the same + * shape filtered to `kind: 'department'` (#109), so the departments are rows + * of the same object, under the US parent (`plan.ts` says why). The + * parent/child link is the platform's own `parent_business_unit_id`, so the + * org-chart view in Setup shows the group as one tree. * * ## Why `SeedSchema.parse` and not `defineSeed` * @@ -24,26 +27,37 @@ import { SIGNING_ENTITY_PLAN } from './plan.js'; * depends on the package that does. Every key below is a declared field of * the installed `@objectstack/platform-objects` 18.1.0 definition (`name`, * `code`, `kind`, `parent_business_unit_id`, `active`), read there rather - * than assumed. + * than assumed. `department` is one of that definition's `kind` options + * (`company · division · department · office · cost_center`). * * ## The key is `code` * * `code` is the platform's own natural key for a unit ("Short stable code … * Unique within tenant") and it is locale-blind, so `demo-en` and `demo-zh` - * upsert the same three rows and every contract resolves its `our_entity` - * against the same codes in either language. The subsidiaries name their - * parent by code too; the loader writes a self-referencing dataset row by row, - * so the parent exists by the time a child names it. + * upsert the same ten rows and every contract resolves its `our_entity` and + * its `department` against the same codes in either language. The + * subsidiaries and the departments name their parent by code too; the loader + * writes a self-referencing dataset row by row, in this order, so the parent + * exists by the time a child names it — the companies come first. */ export const businessUnitSeed = SeedSchema.parse({ object: 'sys_business_unit', mode: 'upsert', externalId: 'code', - records: SIGNING_ENTITY_PLAN.map((entity, index) => ({ - code: entity.code, - name: STRINGS.signingEntities[index]!, - kind: 'company', - parent_business_unit_id: entity.parentCode, - active: true, - })), + records: [ + ...SIGNING_ENTITY_PLAN.map((entity, index) => ({ + code: entity.code, + name: STRINGS.signingEntities[index]!, + kind: 'company', + parent_business_unit_id: entity.parentCode, + active: true, + })), + ...DEPARTMENT_PLAN.map((department, index) => ({ + code: department.code, + name: STRINGS.departments[index]!, + kind: 'department', + parent_business_unit_id: department.parentCode, + active: true, + })), + ], }); diff --git a/src/data/contract.seed.ts b/src/data/contract.seed.ts index ae90d4e..e7623c1 100644 --- a/src/data/contract.seed.ts +++ b/src/data/contract.seed.ts @@ -73,6 +73,9 @@ export const contractSeed = defineSeed(Contract, { // England and Wales → the APAC subsidiary. A code, resolved against the // `sys_business_unit` rows `business-unit.seed.ts` upserts on `code`. our_entity: SIGNING_ENTITY_PLAN[law.entityIndex]!.code, + // The requesting department follows the contract type (`plan.ts`): a + // code, resolved against the department-kind `sys_business_unit` rows + // `business-unit.seed.ts` upserts on `code`, as `our_entity` is. department: type.department, // `owner_id` names a user the way `legal_owner` on the next line does — // a `lookup('sys_user')` resolved against `sys_user.name` by the two-boot diff --git a/src/data/demo-en/index.ts b/src/data/demo-en/index.ts index c28f1b0..cd7f443 100644 --- a/src/data/demo-en/index.ts +++ b/src/data/demo-en/index.ts @@ -4,6 +4,7 @@ import type { DemoStrings } from '../strings.js'; import { APPROVAL_RULES, CLAUSES, CONTRACT_TYPES } from './catalog.js'; import { PARTIES } from './parties.js'; import { + DEPARTMENTS, DEVIATION_JUSTIFICATIONS, DEVIATION_TEXTS, JURISDICTIONS, @@ -28,6 +29,7 @@ import { */ export const EN: DemoStrings = { signingEntities: SIGNING_ENTITIES, + departments: DEPARTMENTS, contractTypes: CONTRACT_TYPES, clauses: CLAUSES, approvalRules: APPROVAL_RULES, diff --git a/src/data/demo-en/prose.ts b/src/data/demo-en/prose.ts index 498a663..c69f4f7 100644 --- a/src/data/demo-en/prose.ts +++ b/src/data/demo-en/prose.ts @@ -21,6 +21,20 @@ export const SIGNING_ENTITIES: readonly string[] & { length: 3 } = [ 'Arcadia Asia Pacific Pte. Ltd.', ] as const; +/** + * The departments that launch contracts, in `DEPARTMENT_PLAN`'s order + * (`../plan.ts`). Each is a department-kind business unit under the US parent. + */ +export const DEPARTMENTS: readonly string[] & { length: 7 } = [ + 'Sales', + 'Procurement', + 'Legal', + 'Finance', + 'Operations', + 'People & HR', + 'IT', +] as const; + /** Distinguishes a second contract of the same type with the same counterparty. */ export const TITLE_QUALIFIERS: readonly string[] & { length: 8 } = [ 'Renewal', diff --git a/src/data/demo-zh/index.ts b/src/data/demo-zh/index.ts index fb0dd0f..2112aa4 100644 --- a/src/data/demo-zh/index.ts +++ b/src/data/demo-zh/index.ts @@ -4,6 +4,7 @@ import type { DemoStrings } from '../strings.js'; import { APPROVAL_RULES, CLAUSES, CONTRACT_TYPES } from './catalog.js'; import { PARTIES } from './parties.js'; import { + DEPARTMENTS, DEVIATION_JUSTIFICATIONS, DEVIATION_TEXTS, JURISDICTIONS, @@ -27,6 +28,7 @@ import { */ export const ZH_CN: DemoStrings = { signingEntities: SIGNING_ENTITIES, + departments: DEPARTMENTS, contractTypes: CONTRACT_TYPES, clauses: CLAUSES, approvalRules: APPROVAL_RULES, diff --git a/src/data/demo-zh/prose.ts b/src/data/demo-zh/prose.ts index 4919471..807afa8 100644 --- a/src/data/demo-zh/prose.ts +++ b/src/data/demo-zh/prose.ts @@ -15,6 +15,17 @@ export const SIGNING_ENTITIES: readonly string[] & { length: 3 } = [ '阿卡迪亚亚太私人有限公司', ] as const; +/** 发起合同的七个部门,顺序同 `../plan.ts` 的 `DEPARTMENT_PLAN`;都是美国母公司之下 kind 为部门的业务单元。 */ +export const DEPARTMENTS: readonly string[] & { length: 7 } = [ + '销售部', + '采购部', + '法务部', + '财务部', + '运营部', + '人力资源部', + '信息技术部', +] as const; + /** 同一相对方、同一类型的第二份合同用它区分。 */ export const TITLE_QUALIFIERS: readonly string[] & { length: 8 } = [ '续签', diff --git a/src/data/index.ts b/src/data/index.ts index 3cb4d43..8d76698 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -25,9 +25,9 @@ import { obligationSeed, paymentPlanSeed } from './post-signature.seed.js'; * anyway: * * - **The configuration objects come first.** `clm_contract.contract_type` - * resolves against a type's `name`, `clm_contract.our_entity` against a - * business unit's `code` and `clm_deviation.clause` against a clause's - * `title`; a reference that resolves to nothing on a `required` column + * resolves against a type's `name`, `clm_contract.our_entity` and + * `clm_contract.department` against a business unit's `code`, and + * `clm_deviation.clause` against a clause's `title`; a reference that resolves to nothing on a `required` column * takes the whole row with it. * - **`clm_contract` comes before its five children**, all of which reference * it by title through the one function in `keys.ts`. @@ -53,8 +53,8 @@ import { obligationSeed, paymentPlanSeed } from './post-signature.seed.js'; */ export const demoSeeds: Seed[] = [ // 1. Configuration first — every contract resolves its type here, its - // signing entity (a `sys_business_unit` of kind `company`), and every - // deviation its clause. + // signing entity (a `sys_business_unit` of kind `company`) and its + // department (one of kind `department`), and every deviation its clause. businessUnitSeed, contractTypeSeed, clauseSeed, diff --git a/src/data/plan.ts b/src/data/plan.ts index 016477e..9c7b91c 100644 --- a/src/data/plan.ts +++ b/src/data/plan.ts @@ -75,6 +75,45 @@ export const SIGNING_ENTITY_PLAN: readonly SigningEntityPlan[] & { length: 3 } = ] as const; assertCount('sys_business_unit (signing entities)', SIGNING_ENTITY_PLAN.length, 3); +/** + * The departments that launch contracts, as `sys_business_unit` rows of kind + * `department` (#109, the same ruling as the signing entities: organisation + * structure is data, and the data is the platform's organisation tree). + * `clm_contract.department` is a lookup to them. + * + * Every department sits under the US parent, `ARC-US`. A requesting + * department is a group function, and it is a separate axis from the entity + * that signs. Contracts are spread across entities by governing law + * ({@link JURISDICTION_BY_COUNTRY}) and across departments by contract type + * ({@link CONTRACT_TYPE_PLAN}). If Operations hung under the EU subsidiary, a + * New York lease would be requested by a European department. Under the group + * parent, the org chart in Setup shows the three entities and the seven + * departments as one tree, and no contract tells two stories. + * + * `code` is the natural key the seed upserts on and the value every contract's + * `department` names, locale-blind for the same reason as the entities' codes. + * The index of each entry is the index into the locale bundle's `departments` + * tuple. The old option list's `other` has no row: "other" is not a unit of + * the organisation, and a contract with no department leaves the field empty. + */ +export type DepartmentCode = 'ARC-SALES' | 'ARC-PROC' | 'ARC-LEGAL' | 'ARC-FIN' | 'ARC-OPS' | 'ARC-PEOPLE' | 'ARC-IT'; + +export interface DepartmentPlan { + readonly code: DepartmentCode; + /** The `code` of the company the department sits under. */ + readonly parentCode: string; +} + +export const DEPARTMENT_PLAN: readonly DepartmentPlan[] & { length: 7 } = [ + { code: 'ARC-SALES', parentCode: 'ARC-US' }, + { code: 'ARC-PROC', parentCode: 'ARC-US' }, + { code: 'ARC-LEGAL', parentCode: 'ARC-US' }, + { code: 'ARC-FIN', parentCode: 'ARC-US' }, + { code: 'ARC-OPS', parentCode: 'ARC-US' }, + { code: 'ARC-PEOPLE', parentCode: 'ARC-US' }, + { code: 'ARC-IT', parentCode: 'ARC-US' }, +] as const; + export interface Jurisdiction { readonly governingLaw: string; /** Index into the locale bundle's `jurisdictions` tuple. */ @@ -123,19 +162,20 @@ export interface ContractTypePlan { readonly amountBand: readonly [number, number] | null; /** Whether contracts of this type carry an instalment schedule. */ readonly hasPaymentSchedule: boolean; - readonly department: 'sales' | 'procurement' | 'legal' | 'finance' | 'operations' | 'people' | 'it' | 'other'; + /** The requesting department: a {@link DEPARTMENT_PLAN} code. */ + readonly department: DepartmentCode; } export const CONTRACT_TYPE_PLAN: readonly ContractTypePlan[] = [ - { code: 'NDA', category: 'nda', direction: 'other', requiresLegalReview: true, executionFormalities: [], signMethod: 'esign', reviewSlaDays: 3, defaultTermMonths: 24, retentionYears: 7, intakeFields: ['governing_law', 'confidentiality_term_months'], amountBand: null, hasPaymentSchedule: false, department: 'legal' }, - { code: 'MSA', category: 'framework', direction: 'sales', requiresLegalReview: true, executionFormalities: ['countersigned_copy'], signMethod: 'esign', reviewSlaDays: 5, defaultTermMonths: 36, retentionYears: 10, intakeFields: ['governing_law', 'payment_terms', 'auto_renew'], amountBand: [250_000, 4_000_000], hasPaymentSchedule: true, department: 'sales' }, - { code: 'SOW', category: 'service', direction: 'sales', requiresLegalReview: false, executionFormalities: [], signMethod: 'esign', reviewSlaDays: 3, defaultTermMonths: 12, retentionYears: 10, intakeFields: ['payment_terms', 'parent_contract'], amountBand: [40_000, 700_000], hasPaymentSchedule: true, department: 'sales' }, - { code: 'ORD', category: 'sales', direction: 'sales', requiresLegalReview: false, executionFormalities: [], signMethod: 'esign', reviewSlaDays: 2, defaultTermMonths: 12, retentionYears: 7, intakeFields: ['payment_terms'], amountBand: [6_000, 95_000], hasPaymentSchedule: true, department: 'sales' }, - { code: 'SUP', category: 'purchase', direction: 'purchase', requiresLegalReview: true, executionFormalities: ['countersigned_copy'], signMethod: 'either', reviewSlaDays: 5, defaultTermMonths: 24, retentionYears: 10, intakeFields: ['governing_law', 'payment_terms', 'auto_renew'], amountBand: [30_000, 1_400_000], hasPaymentSchedule: true, department: 'procurement' }, - { code: 'DPA', category: 'dpa', direction: 'other', requiresLegalReview: true, executionFormalities: ['countersigned_copy'], signMethod: 'esign', reviewSlaDays: 5, defaultTermMonths: 36, retentionYears: 10, intakeFields: ['governing_law'], amountBand: null, hasPaymentSchedule: false, department: 'legal' }, - { code: 'LSE', category: 'lease', direction: 'purchase', requiresLegalReview: true, executionFormalities: ['notarized', 'witnessed'], signMethod: 'wet_ink', reviewSlaDays: 10, defaultTermMonths: 60, retentionYears: 15, intakeFields: ['governing_law', 'auto_renew'], amountBand: [400_000, 2_600_000],hasPaymentSchedule: true, department: 'operations' }, - { code: 'ICA', category: 'employment',direction: 'purchase', requiresLegalReview: true, executionFormalities: ['countersigned_copy'], signMethod: 'esign', reviewSlaDays: 5, defaultTermMonths: 12, retentionYears: 7, intakeFields: ['payment_terms', 'confidentiality_term_months'], amountBand: [18_000, 140_000], hasPaymentSchedule: true, department: 'people' }, - { code: 'AMD', category: 'amendment', direction: 'other', requiresLegalReview: true, executionFormalities: ['countersigned_copy'], signMethod: 'esign', reviewSlaDays: 3, defaultTermMonths: 12, retentionYears: 10, intakeFields: ['parent_contract'], amountBand: [12_000, 260_000], hasPaymentSchedule: true, department: 'legal' }, + { code: 'NDA', category: 'nda', direction: 'other', requiresLegalReview: true, executionFormalities: [], signMethod: 'esign', reviewSlaDays: 3, defaultTermMonths: 24, retentionYears: 7, intakeFields: ['governing_law', 'confidentiality_term_months'], amountBand: null, hasPaymentSchedule: false, department: 'ARC-LEGAL' }, + { code: 'MSA', category: 'framework', direction: 'sales', requiresLegalReview: true, executionFormalities: ['countersigned_copy'], signMethod: 'esign', reviewSlaDays: 5, defaultTermMonths: 36, retentionYears: 10, intakeFields: ['governing_law', 'payment_terms', 'auto_renew'], amountBand: [250_000, 4_000_000], hasPaymentSchedule: true, department: 'ARC-SALES' }, + { code: 'SOW', category: 'service', direction: 'sales', requiresLegalReview: false, executionFormalities: [], signMethod: 'esign', reviewSlaDays: 3, defaultTermMonths: 12, retentionYears: 10, intakeFields: ['payment_terms', 'parent_contract'], amountBand: [40_000, 700_000], hasPaymentSchedule: true, department: 'ARC-SALES' }, + { code: 'ORD', category: 'sales', direction: 'sales', requiresLegalReview: false, executionFormalities: [], signMethod: 'esign', reviewSlaDays: 2, defaultTermMonths: 12, retentionYears: 7, intakeFields: ['payment_terms'], amountBand: [6_000, 95_000], hasPaymentSchedule: true, department: 'ARC-SALES' }, + { code: 'SUP', category: 'purchase', direction: 'purchase', requiresLegalReview: true, executionFormalities: ['countersigned_copy'], signMethod: 'either', reviewSlaDays: 5, defaultTermMonths: 24, retentionYears: 10, intakeFields: ['governing_law', 'payment_terms', 'auto_renew'], amountBand: [30_000, 1_400_000], hasPaymentSchedule: true, department: 'ARC-PROC' }, + { code: 'DPA', category: 'dpa', direction: 'other', requiresLegalReview: true, executionFormalities: ['countersigned_copy'], signMethod: 'esign', reviewSlaDays: 5, defaultTermMonths: 36, retentionYears: 10, intakeFields: ['governing_law'], amountBand: null, hasPaymentSchedule: false, department: 'ARC-LEGAL' }, + { code: 'LSE', category: 'lease', direction: 'purchase', requiresLegalReview: true, executionFormalities: ['notarized', 'witnessed'], signMethod: 'wet_ink', reviewSlaDays: 10, defaultTermMonths: 60, retentionYears: 15, intakeFields: ['governing_law', 'auto_renew'], amountBand: [400_000, 2_600_000],hasPaymentSchedule: true, department: 'ARC-OPS' }, + { code: 'ICA', category: 'employment',direction: 'purchase', requiresLegalReview: true, executionFormalities: ['countersigned_copy'], signMethod: 'esign', reviewSlaDays: 5, defaultTermMonths: 12, retentionYears: 7, intakeFields: ['payment_terms', 'confidentiality_term_months'], amountBand: [18_000, 140_000], hasPaymentSchedule: true, department: 'ARC-PEOPLE' }, + { code: 'AMD', category: 'amendment', direction: 'other', requiresLegalReview: true, executionFormalities: ['countersigned_copy'], signMethod: 'esign', reviewSlaDays: 3, defaultTermMonths: 12, retentionYears: 10, intakeFields: ['parent_contract'], amountBand: [12_000, 260_000], hasPaymentSchedule: true, department: 'ARC-LEGAL' }, ]; assertCount('clm_contract_type', CONTRACT_TYPE_PLAN.length, 9); diff --git a/src/data/strings.ts b/src/data/strings.ts index b9c2b41..cbeafbd 100644 --- a/src/data/strings.ts +++ b/src/data/strings.ts @@ -59,6 +59,11 @@ export interface DemoStrings { * order (`plan.ts`). Their `sys_business_unit.name`. */ readonly signingEntities: Strings<3>; + /** + * The seven departments that launch contracts, in `DEPARTMENT_PLAN`'s order + * (`plan.ts`). Their `sys_business_unit.name`. + */ + readonly departments: Strings<7>; /** DESIGN.md §10: nine contract types. */ readonly contractTypes: readonly ContractTypeStrings[] & { length: 9 }; /** DESIGN.md §10: thirty playbook clauses. */ diff --git a/src/datasets/contract.dataset.ts b/src/datasets/contract.dataset.ts index 30a0b9d..5c79367 100644 --- a/src/datasets/contract.dataset.ts +++ b/src/datasets/contract.dataset.ts @@ -31,15 +31,20 @@ export const ContractDataset = defineDataset({ object: 'clm_contract', // Joins are COMPILED from these names; no ON clause is ever written - // (ADR-0021). Both are to-one lookups on `clm_contract`, so both are legal - // one-hop paths. - include: ['contract_type', 'party'], + // (ADR-0021). All three are to-one lookups on `clm_contract`, so all three + // are legal one-hop paths. `department` joined in #109, when the field + // became a lookup to `sys_business_unit`. + include: ['contract_type', 'party', 'department'], dimensions: [ { name: 'status', label: 'Status', field: 'status', type: 'string' }, { name: 'category', label: 'Category', field: 'category', type: 'string' }, { name: 'direction', label: 'Direction', field: 'direction', type: 'string' }, - { name: 'department', label: 'Requesting Department', field: 'department', type: 'string' }, + // The department's NAME, one hop through the lookup (#109). The column + // holds the business unit's id, and a group-by on it would put ids on the + // axis. The dimension keeps its name, so a widget or a query that selects + // `department` is unchanged. + { name: 'department', label: 'Requesting Department', field: 'department.name', type: 'string' }, { name: 'currency_code', label: 'Currency', field: 'currency_code', type: 'string' }, { name: 'governing_law', label: 'Governing Law', field: 'governing_law', type: 'string' }, { name: 'risk_level', label: 'Risk Level', field: 'risk_level', type: 'string' }, diff --git a/src/flows/contract-intake.flow.ts b/src/flows/contract-intake.flow.ts index f42fd5d..6dc96f8 100644 --- a/src/flows/contract-intake.flow.ts +++ b/src/flows/contract-intake.flow.ts @@ -18,19 +18,22 @@ import { must, ref } from './_values.js'; * - A flat screen renders text / number / currency / date / email / textarea * / boolean, and a select when `options` are declared. It has NO record * picker and NO file input: a `lookup`-typed field falls through to a plain - * text box. So the three pickers the intake needs — the contract type, the - * counterparty and our signing entity — are collected by the - * `launch_contract` ACTION's param dialog (`src/actions/contract.actions.ts`), - * whose field-backed lookup params render the real record pickers. The - * flow receives them as the input variables `contract_type`, `party` and - * `our_entity`. On 17.3.0 those pickers did NOT inherit the fields' + * text box. So the four pickers the intake needs — the contract type, the + * counterparty, our signing entity and the requesting department — are + * collected by the `launch_contract` ACTION's param dialog + * (`src/actions/contract.actions.ts`), whose field-backed lookup params + * render the real record pickers. The flow receives them as the input + * variables `contract_type`, `party`, `our_entity` and `department`. The + * department was a select on the core screen while it was an option list; + * since #109 it is a business-unit row, so it moved to the dialog with the + * other pickers. On 17.3.0 those pickers did NOT inherit the fields' * `lookupFilters`; re-measured on 18.1.0 they do — the dialog's * counterparty picker fetches * `GET /api/v1/data/clm_party?top=50&filter=["risk_flag","!=","blocked"]` - * (38 of 40), and the entity picker filters to company-kind business units - * the same way. `refuse_blocked` below still stands: a headless caller - * passes an id and never meets a picker, so the picker is convenience and - * the refusal is the rule. + * (38 of 40), and the entity and department pickers filter to company-kind + * and department-kind business units the same way. `refuse_blocked` below + * still stands: a headless caller passes an id and never meets a picker, + * so the picker is convenience and the refusal is the rule. * - A screen field's `visibleWhen` is bare CEL the CLIENT evaluates against * the screen's OWN field values only — flow variables are not in scope * there (the server re-checks `required` under it with the run's variables @@ -151,13 +154,13 @@ export const ContractIntakeFlow: Flow = { successMessage: 'Contract launched.', variables: [ - // The three pickers, supplied by the launch action's dialog (or headlessly). + // The four pickers, supplied by the launch action's dialog (or headlessly). { name: 'contract_type', type: 'text', isInput: true, isOutput: false }, { name: 'party', type: 'text', isInput: true, isOutput: false }, { name: 'our_entity', type: 'text', isInput: true, isOutput: false }, + { name: 'department', type: 'text', isInput: true, isOutput: false }, // Core fields. `title` doubles as the headless marker — see the header. { name: 'title', type: 'text', isInput: true, isOutput: false }, - { name: 'department', type: 'text', isInput: true, isOutput: false }, { name: 'amount', type: 'number', isInput: true, isOutput: false }, { name: 'currency_code', type: 'text', isInput: true, isOutput: false, defaultValue: 'usd' }, { name: 'is_amount_estimated', type: 'boolean', isInput: true, isOutput: false, defaultValue: false }, @@ -248,7 +251,6 @@ export const ContractIntakeFlow: Flow = { // raw list, hidden by its own predicate, read by the predicates below. { name: 'intakeFields', label: 'Intake fields', type: 'text', defaultValue: ref('intakeFields'), visibleWhen: 'intakeFields == null' }, { name: 'title', label: 'Title', type: 'text', required: true, placeholder: 'e.g. Mutual NDA with Acme' }, - { name: 'department', label: 'Requesting department', type: 'select', options: optionsOf(Contract, 'department') }, { name: 'amount', label: 'Contract amount', type: 'currency' }, { name: 'currency_code', label: 'Currency', type: 'select', options: optionsOf(Contract, 'currency_code'), defaultValue: ref('currency_code') }, { name: 'is_amount_estimated', label: 'Amount is an estimate', type: 'boolean', defaultValue: ref('is_amount_estimated') }, diff --git a/src/objects/contract.object.ts b/src/objects/contract.object.ts index 2468c78..c9dd0e3 100644 --- a/src/objects/contract.object.ts +++ b/src/objects/contract.object.ts @@ -191,21 +191,29 @@ export const Contract = ObjectSchema.create({ { field: 'active', operator: 'eq', value: true }, ], }), - department: Field.select({ + // Departments are organisation structure, so they are data too: the + // platform's `sys_business_unit` rows of kind `department` (#109, under + // #20's ruling C), maintained in Setup → Business Units next to the + // signing entities. The field keeps its name: `business_unit` is a + // platform reserved word. + // + // Still a redundant scalar on the contract, because RLS cannot cross + // objects (DESIGN.md §04); department-level sharing is a customer overlay + // (§13 Q2). It now stores the unit's id rather than an option value. + // + // Optional, as it always was. A fresh install has no department until an + // admin adds one, and a contract must not wait for the org chart to be + // written; a backfilled or renewed contract may have none either. + // `storage.notNull` is therefore absent on purpose. + department: Field.lookup('sys_business_unit', { label: 'Requesting Department', group: 'parties', - // A redundant scalar on the contract because RLS cannot cross objects - // (DESIGN.md §04); department-level sharing is a customer overlay (§13 Q2). - description: 'The business unit that launched the contract. Sharing contracts by department is a customer-specific extension, not part of the standard product.', - options: [ - { label: 'Sales', value: 'sales' }, - { label: 'Procurement', value: 'procurement' }, - { label: 'Legal', value: 'legal' }, - { label: 'Finance', value: 'finance' }, - { label: 'Operations', value: 'operations' }, - { label: 'People / HR', value: 'people' }, - { label: 'IT', value: 'it' }, - { label: 'Other', value: 'other' }, + description: 'The department that launched the contract. The choices are the department-kind business units in Setup → Business Units. Sharing contracts by department is a customer-specific extension, not part of the standard product.', + // The picker offers active departments only, never a company, division, + // office or cost centre. + lookupFilters: [ + { field: 'kind', operator: 'eq', value: 'department' }, + { field: 'active', operator: 'eq', value: true }, ], }), // `owner_id` is the platform ownership anchor — the one column the private diff --git a/src/translations/en/flows.ts b/src/translations/en/flows.ts index 28e3df0..8c9bb6a 100644 --- a/src/translations/en/flows.ts +++ b/src/translations/en/flows.ts @@ -28,7 +28,6 @@ export const flowSurface: Pick = { fields: { intakeFields: { label: 'Intake fields' }, title: { label: 'Title', placeholder: 'e.g. Mutual NDA with Acme' }, - department: { label: 'Requesting department', options: contractOptions('department') }, amount: { label: 'Contract amount' }, currency_code: { label: 'Currency', options: contractOptions('currency_code') }, is_amount_estimated: { label: 'Amount is an estimate' }, diff --git a/src/translations/en/objects.contract.ts b/src/translations/en/objects.contract.ts index e778ccf..e0750e2 100644 --- a/src/translations/en/objects.contract.ts +++ b/src/translations/en/objects.contract.ts @@ -96,17 +96,7 @@ export const contract: Record = { }, department: { label: 'Requesting Department', - help: 'The business unit that launched the contract. Sharing contracts by department is a customer-specific extension, not part of the standard product.', - options: { - sales: 'Sales', - procurement: 'Procurement', - legal: 'Legal', - finance: 'Finance', - operations: 'Operations', - people: 'People / HR', - it: 'IT', - other: 'Other', - }, + help: 'The department that launched the contract. The choices are the department-kind business units in Setup → Business Units. Sharing contracts by department is a customer-specific extension, not part of the standard product.', }, owner_id: { label: 'Business Owner', @@ -370,6 +360,7 @@ export const contract: Record = { }, department: { label: 'Requesting department', + helpText: 'The department launching this contract.', }, amount: { label: 'Contract amount', diff --git a/src/translations/zh-CN/flows.ts b/src/translations/zh-CN/flows.ts index 90d6d96..056ec02 100644 --- a/src/translations/zh-CN/flows.ts +++ b/src/translations/zh-CN/flows.ts @@ -28,7 +28,6 @@ export const flowSurface: Pick = { fields: { intakeFields: { label: '发起时填写的字段' }, title: { label: '标题', placeholder: '例如:与 Acme 的双向保密协议' }, - department: { label: '发起部门', options: contractOptions('department') }, amount: { label: '合同金额' }, currency_code: { label: '币种', options: contractOptions('currency_code') }, is_amount_estimated: { label: '金额为估算值' }, diff --git a/src/translations/zh-CN/objects.contract.ts b/src/translations/zh-CN/objects.contract.ts index 9bc3a8a..711e69c 100644 --- a/src/translations/zh-CN/objects.contract.ts +++ b/src/translations/zh-CN/objects.contract.ts @@ -96,17 +96,7 @@ export const contract: Record = { }, department: { label: '发起部门', - help: '发起这份合同的业务部门。按部门共享合同属于客户定制,标准品不提供。', - options: { - sales: '销售', - procurement: '采购', - legal: '法务', - finance: '财务', - operations: '运营', - people: '人力资源', - it: '信息技术', - other: '其他', - }, + help: '发起这份合同的业务部门。可选项是「设置 → 业务单元」中类型为部门的业务单元。按部门共享合同属于客户定制,标准品不提供。', }, owner_id: { label: '业务承办人', @@ -370,6 +360,7 @@ export const contract: Record = { }, department: { label: '发起部门', + helpText: '发起这份合同的部门。', }, amount: { label: '合同金额', From f65f9be1448c043fc558f3a5c9059bfa54946815 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 15:14:22 +0000 Subject: [PATCH 2/2] fix(datasets): name the department dimension department_name so the workbench filter reaches the column Measured on 18.1.0: a widget's runtime filter key is resolved through a dimension of the same name first. With a dimension called `department` over `department.name`, the legal workbench's department filter, which applies the business unit's id, compiled to `department.name = ` and every widget read zero. Named like `contract_type_name`, the dimension still groups by the department's name, and the filter reaches the `department` column. Also states on the field that the picker is the only check on a unit's kind. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn --- src/datasets/contract.dataset.ts | 12 ++++++++---- src/objects/contract.object.ts | 6 +++++- src/translations/en/app.ts | 2 +- src/translations/zh-CN/app.ts | 2 +- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/datasets/contract.dataset.ts b/src/datasets/contract.dataset.ts index 5c79367..01491de 100644 --- a/src/datasets/contract.dataset.ts +++ b/src/datasets/contract.dataset.ts @@ -40,11 +40,15 @@ export const ContractDataset = defineDataset({ { name: 'status', label: 'Status', field: 'status', type: 'string' }, { name: 'category', label: 'Category', field: 'category', type: 'string' }, { name: 'direction', label: 'Direction', field: 'direction', type: 'string' }, - // The department's NAME, one hop through the lookup (#109). The column + // The department's NAME, one hop through the lookup (#109): the column // holds the business unit's id, and a group-by on it would put ids on the - // axis. The dimension keeps its name, so a widget or a query that selects - // `department` is unchanged. - { name: 'department', label: 'Requesting Department', field: 'department.name', type: 'string' }, + // axis. Named `department_name`, like `contract_type_name`, and NOT + // `department`, on purpose: a widget's runtime filter is resolved through + // a dimension of the same name first (measured on 18.1.0), so a dimension + // called `department` would turn the legal workbench's filter, which + // applies the unit's id, into `department.name = ` and match nothing. + // Without it, `department` reaches the column, which holds the id. + { name: 'department_name', label: 'Requesting Department', field: 'department.name', type: 'string' }, { name: 'currency_code', label: 'Currency', field: 'currency_code', type: 'string' }, { name: 'governing_law', label: 'Governing Law', field: 'governing_law', type: 'string' }, { name: 'risk_level', label: 'Risk Level', field: 'risk_level', type: 'string' }, diff --git a/src/objects/contract.object.ts b/src/objects/contract.object.ts index c9dd0e3..057aefd 100644 --- a/src/objects/contract.object.ts +++ b/src/objects/contract.object.ts @@ -210,7 +210,11 @@ export const Contract = ObjectSchema.create({ group: 'parties', description: 'The department that launched the contract. The choices are the department-kind business units in Setup → Business Units. Sharing contracts by department is a customer-specific extension, not part of the standard product.', // The picker offers active departments only, never a company, division, - // office or cost centre. + // office or cost centre. The picker is the only check on kind: a write + // that bypasses it may name a unit of another kind (measured on 18.1.0, + // 200), while an id that names no unit is refused by the engine + // (`reference_not_found`). No write-side rule like `our_entity`'s + // `contract_entity_is_active_company` exists for it (#109 reports why). lookupFilters: [ { field: 'kind', operator: 'eq', value: 'department' }, { field: 'active', operator: 'eq', value: true }, diff --git a/src/translations/en/app.ts b/src/translations/en/app.ts index efae19a..6db7179 100644 --- a/src/translations/en/app.ts +++ b/src/translations/en/app.ts @@ -219,7 +219,7 @@ export const appSurface: Pick