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..01491de 100644 --- a/src/datasets/contract.dataset.ts +++ b/src/datasets/contract.dataset.ts @@ -31,15 +31,24 @@ 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. 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/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..057aefd 100644 --- a/src/objects/contract.object.ts +++ b/src/objects/contract.object.ts @@ -191,21 +191,33 @@ 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. 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 }, ], }), // `owner_id` is the platform ownership anchor — the one column the private 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 = { 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/app.ts b/src/translations/zh-CN/app.ts index 30c292f..ddec8aa 100644 --- a/src/translations/zh-CN/app.ts +++ b/src/translations/zh-CN/app.ts @@ -219,7 +219,7 @@ export const appSurface: 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: '合同金额',