diff --git a/src/dashboards/legal.dashboard.ts b/src/dashboards/legal.dashboard.ts index 058a9dd..22d065c 100644 --- a/src/dashboards/legal.dashboard.ts +++ b/src/dashboards/legal.dashboard.ts @@ -73,7 +73,7 @@ import type { Dashboard } from '@objectstack/spec/ui'; export const LegalDashboard: Dashboard = { name: 'legal_workbench', label: 'Legal Workbench', - description: 'Intake queue, review load, negotiation stalls, pipeline shape and stage durations for the legal team.', + description: 'Intake queue, review load and SLA breaches, negotiation stalls, pipeline shape and stage durations for the legal team.', columns: 12, gap: 4, @@ -152,49 +152,27 @@ export const LegalDashboard: Dashboard = { }, { /** - * The review-ageing tile, at a FIXED 30-day threshold written into its - * own title — which is the brick §09 prescribes here, not a degraded - * stand-in for one. §09 places the per-type「超 SLA」 outside the V1.0 - * delivery surface (维护者裁定 2026-09-09, PR #40 `1127e52`; §09 as - * merged, PR #70 `30fd863`) and asks in its place for exactly this: a - * fixed threshold, self-declared in the brick's title. + * DESIGN.md §09's「审查超 SLA」 — contracts in review past their OWN + * type's review SLA (`clm_contract_type.review_sla_days`: 2, 3, 5 or 10 + * days across the nine seeded types). It replaces the fixed 30-day tile + * that stood in this slot while the per-type comparison could not be + * written (decision #31: 2B held the stand-in, ruling C kept the goal). * - * The SLA is PER TYPE (`clm_contract_type.review_sla_days`: 2, 3, 5 or - * 10 days across the nine seeded types), and a breach is a row-wise - * comparison of `review_started_at` against "now minus that type's SLA". - * 18.2.0 added the now-anchored comparand - * (`{ $now: true, addDays: { $field } }`, objectstack#25808), but its - * offset reads a numeric column of the SAME record only. Measured on - * 18.2.0 as the head of legal, `addDays: { $field: - * 'contract_type.review_sla_days' }` answers `400 INVALID_FILTER` — as a - * runtime filter "the addDays offset … is a dotted path, and SQL - * push-down compiles same-table column references only", and as a - * measure filter the same code with the reason withheld — while - * `os validate` passes it. The same anchor with a literal `-30` answers - * 200. So the per-type breach is still not expressible, and this tile - * stays. - * - * ⛔ The remaining route — a persisted `review_due_at` stamped by a daily - * job, the §12 gap-#7 shape — is closed for this metric, and not merely - * unbuilt: §09 forbids filling the per-type breach from an - * application-side job because that replicates a platform rule inside the - * application, and decision #31 ruled the same way on the notification - * side (1C + 2B, 2026-09-09 — see `legal-review-sla.flow.ts`, whose F3 - * reminder fires on this same fixed 30 days so the tile and the reminder - * cannot tell two stories). - * - * So the threshold is FIXED, stated in the title, and above every seeded - * SLA: a review older than 30 days is late under any of the nine. The - * title says "Over 30 Days" and not "Over SLA" precisely so the tile - * cannot be read as the per-type breach it is not. + * The count is `review_over_sla_count` on `contract_metrics`, one + * definition for this tile and the per-type chart below. It compares + * `review_started_at` with the platform's `$now` anchor shifted by the + * contract's own `review_sla_days_offset`, which the engine stores from + * the contract type on every write (platform 18.3.0; the anchor's offset + * reads a column of the same record only, objectstack#25808 / #26601). + * Nothing in the app stamps it: no hook, no job (§09's ⛔ on app-side + * stamping holds). */ - id: 'review_ageing', - title: 'In Review Over 30 Days', - description: 'A fixed 30-day threshold, not each contract type\'s own review SLA.', + id: 'review_over_sla', + title: 'In Review Over SLA', + description: 'Contracts in review for longer than their contract type\'s review SLA.', type: 'metric', dataset: 'contract_metrics', - values: ['contract_count'], - filter: { status: 'in_review', review_started_at: { $lt: '{30_days_ago}' } }, + values: ['review_over_sla_count'], colorVariant: 'danger', layout: { x: 6, y: 0, w: 3, h: 2 }, }, @@ -279,7 +257,7 @@ export const LegalDashboard: Dashboard = { * The criterion that does discriminate, and the one this filter now * states: WHETHER LEGAL HAS WORK TO DO ON THE STAGE. This is a work-queue * board — its first five widgets are `awaiting_intake`, `in_review`, - * `review_ageing`, `negotiation_stalled` and `approval_turnaround`, each + * `review_over_sla`, `negotiation_stalled` and `approval_turnaround`, each * one a queue or a turnaround. `draft` … `signing` are contracts legal is * moving; `active`, `expired`, `terminated`, `cancelled` and `rejected` * are contracts legal has finished moving. Legal's work on an in-force @@ -462,5 +440,33 @@ export const LegalDashboard: Dashboard = { values: ['avg_days_to_activation'], layout: { x: 9, y: 6, w: 3, h: 2 }, }, + + // ─── Row 4: 超 SLA by contract type ────────────────────────────────── + { + /** + * The over-SLA count split by contract type: each type is judged + * against its own SLA, so this is where a reader sees WHICH SLA is being + * missed. A type with no contract over its SLA is not drawn — measured: + * selected alone, the measure's filter narrows the rows, so such a type + * returns no row rather than a zero. Largest count first, like the + * pipeline chart, so no label decides the order in any locale. + */ + // `chart-config-missing` suppressed for the reason measured on + // `stage_funnel` above: a dataset-bound widget derives its series from + // `dimensions` and `values`. + id: 'review_over_sla_by_type', + title: 'In Review Over SLA by Contract Type', + description: 'Contracts in review for longer than their type\'s review SLA, per contract type. A type with none over its SLA is not shown.', + type: 'horizontal-bar', + dataset: 'contract_metrics', + dimensions: ['contract_type_name'], + values: ['review_over_sla_count'], + options: { + sortBy: 'review_over_sla_count', + sortOrder: 'desc', + }, + suppressWarnings: ['chart-config-missing'], + layout: { x: 0, y: 8, w: 12, h: 4 }, + }, ], }; diff --git a/src/datasets/contract.dataset.ts b/src/datasets/contract.dataset.ts index 01491de..97e8cf6 100644 --- a/src/datasets/contract.dataset.ts +++ b/src/datasets/contract.dataset.ts @@ -83,5 +83,30 @@ export const ContractDataset = defineDataset({ { name: 'route_finance_count', label: 'Routes: Finance Controller', aggregate: 'count', filter: { route_finance: true } }, { name: 'route_executive_count', label: 'Routes: Executive', aggregate: 'count', filter: { route_executive: true } }, { name: 'route_gm_count', label: 'Routes: General Manager', aggregate: 'count', filter: { route_gm: true } }, + + // DESIGN.md §09's「审查超 SLA」, per contract type (#31): contracts in review + // whose review started more than their type's `review_sla_days` ago. The + // comparand is the platform's `$now` anchor (objectstack#25808): the instant + // the query runs, shifted by the contract's own `review_sla_days_offset`, + // the type's SLA as a negative number of days, stored on the contract by + // the engine from its type (see the field). Its offset may only name a + // column of the SAME record — a `contract_type.review_sla_days` path is + // refused at `os validate` (objectstack#26601) — hence the stored copy. + // + // A measure filter, not a widget filter, so that every widget counting it + // reads one definition (ADR-0021). The anchor is a cross-field comparand, + // which the native-SQL strategy declines, so this measure is served by the + // engine path, where the SQL driver compiles it. The datetime target keeps + // its time of day: a review started at 09:00 three days ago is over a + // three-day SLA from 09:00 today. + { + name: 'review_over_sla_count', + label: 'In Review Over SLA', + aggregate: 'count', + filter: { + status: 'in_review', + review_started_at: { $lt: { $now: true, addDays: { $field: 'review_sla_days_offset' } } }, + }, + }, ], }); diff --git a/src/datasets/cycle-time.dataset.ts b/src/datasets/cycle-time.dataset.ts index 23a9f21..9cb01af 100644 --- a/src/datasets/cycle-time.dataset.ts +++ b/src/datasets/cycle-time.dataset.ts @@ -42,16 +42,13 @@ import { defineDataset } from '@objectstack/spec/ui'; * per-row difference. ⛔ Do not reintroduce a `derived` difference of two * stamp aggregates. * - * ## What it does NOT carry: the per-type「超 SLA」 + * ## Where the per-type「超 SLA」 lives instead * - * The review SLA is per contract TYPE (`clm_contract_type.review_sla_days`). - * Comparing `review_started_at` against "now minus that type's SLA" needs the - * `$now` anchor's offset to read a column across the `contract_type` lookup, - * and 18.2.0's anchor (`objectstack-ai/objectstack#25808`, ruling A) reads a - * numeric column of the SAME record only — a dotted path is refused. The one - * remaining route, a per-contract SLA column the app stamps, is the route - * DESIGN.md §09 forbids. The legal workbench therefore keeps its fixed 30-day - * tile, threshold in the title. + * Not here. It is a COUNT of contracts, not a duration, so it is + * `review_over_sla_count` on `contract_metrics` (#31): `review_started_at` + * against the `$now` anchor shifted by the contract's stored + * `review_sla_days_offset`. The `review_sla_days` dimension below still reads + * the type's SLA through the join, which a dimension may do. */ export const CycleTimeDataset = defineDataset({ name: 'contract_cycle_time', diff --git a/src/objects/contract.object.ts b/src/objects/contract.object.ts index c843f42..42e0562 100644 --- a/src/objects/contract.object.ts +++ b/src/objects/contract.object.ts @@ -1,4 +1,4 @@ -import { P } from '@objectstack/spec'; +import { F, P } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; /** @@ -18,7 +18,9 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * `archived_at`) — the state machine and the F2/F5/F9–F14 flows; * - the four `ai_*` fields — the "adopt suggestion" action only (DESIGN.md * §07: AI proposes, a person adopts, the field is written on adoption); - * - `is_backfilled` — the F16 `executed_upload` action only (§13 Q8). + * - `is_backfilled` — the F16 `executed_upload` action only (§13 Q8); + * - `review_sla_days_offset` — no app writer at all: a stored `expression` + * the ENGINE computes from the contract type on every write (#31). * * The eight roll-ups (`version_count`, `open_deviation_count`, * `approved_legal_review_count`, `current_clean_version_count`, @@ -142,6 +144,41 @@ export const Contract = ObjectSchema.create({ { label: 'Other', value: 'other', color: '#94A3B8' }, ], }), + // The contract type's review SLA, as the NEGATIVE whole-day offset the + // legal workbench's over-SLA count reads (#31). Two platform facts shape it, + // both measured on 18.3.0: + // + // - The analytics `$now` anchor (`{ $now: true, addDays: { $field } }`, + // objectstack#25808, ruling A; #26601 / #26753 declined the one-hop + // offset) reads its offset from a numeric column of the SAME record, so + // the type's `review_sla_days` has to be on the contract. + // - `addDays` ADDS the column's value and has no `subDays`, so "now minus + // the SLA" is a column holding the negative number — the platform's own + // documented spelling for exactly this count (filter.zod.ts, the `$now` + // example, `review_sla_days_offset`). + // + // A stored `expression`, not a hook stamp: the ENGINE computes it from the + // row each write stores — on every insert, and on every update by id, so + // re-picking the type recomputes it — and a client-sent value never stands. + // It reads one hop through `contract_type`. It does NOT follow an edit of + // the type's own SLA: contracts already in flight keep the SLA they were + // filed under until the contract itself is next written. + // + // `hidden`: a negative number of days means nothing on the record page. + // Identity group, beside the other type-derived fields, and outside the + // `lifecycle`/`routing`/`ai` groups the FLS stamp lock is derived from: a + // stored expression needs no lock (the engine replaces any sent value). + review_sla_days_offset: Field.number({ + label: 'Review SLA Offset (days)', + group: 'identity', + readonly: true, + hidden: true, + scale: 0, + min: -90, + max: 0, + expression: F`0 - record.contract_type.review_sla_days`, + description: 'The contract type\'s review SLA as a negative number of days, for the over-SLA count. Computed from the contract type on every save.', + }), status: Field.select({ label: 'Status', group: 'identity', diff --git a/src/translations/en/app.ts b/src/translations/en/app.ts index 9dbf57b..9bf1515 100644 --- a/src/translations/en/app.ts +++ b/src/translations/en/app.ts @@ -176,7 +176,7 @@ export const appSurface: Pick = { other: 'Other', }, }, + review_sla_days_offset: { + label: 'Review SLA Offset (days)', + help: 'The contract type\'s review SLA as a negative number of days, for the over-SLA count. Computed from the contract type on every save.', + }, status: { label: 'Status', help: 'Where the contract is in its lifecycle. Status changes follow fixed rules; Expired, Terminated and Cancelled are final.', diff --git a/src/translations/zh-CN/app.ts b/src/translations/zh-CN/app.ts index b58cf45..d70b452 100644 --- a/src/translations/zh-CN/app.ts +++ b/src/translations/zh-CN/app.ts @@ -176,7 +176,7 @@ export const appSurface: Pick = { other: '其他', }, }, + review_sla_days_offset: { + label: '审查时限偏移(天)', + help: '合同类型的审查时限,取负的天数,供「审查超时限」统计使用。每次保存时按合同类型重新计算。', + }, status: { label: '状态', help: '合同所处的生命周期环节。状态只能按规定的路径流转;已到期、已终止、已作废是终态。',