Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 47 additions & 41 deletions src/dashboards/legal.dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
},
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 },
},
],
};
25 changes: 25 additions & 0 deletions src/datasets/contract.dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } } },
},
},
],
});
15 changes: 6 additions & 9 deletions src/datasets/cycle-time.dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
41 changes: 39 additions & 2 deletions src/objects/contract.object.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { P } from '@objectstack/spec';
import { F, P } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';

/**
Expand All @@ -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`,
Expand Down Expand Up @@ -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',
Expand Down
15 changes: 11 additions & 4 deletions src/translations/en/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export const appSurface: Pick<TranslationData, 'apps' | 'dashboards' | 'datasets
},
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.',
widgets: {
awaiting_intake: {
title: 'Awaiting Intake',
Expand All @@ -186,9 +186,9 @@ export const appSurface: Pick<TranslationData, 'apps' | 'dashboards' | 'datasets
title: 'In Review',
description: 'Contracts a lawyer has accepted and not yet routed',
},
review_ageing: {
title: 'In Review Over 30 Days',
description: 'A fixed 30-day threshold, not each contract type\'s own review SLA.',
review_over_sla: {
title: 'In Review Over SLA',
description: 'Contracts in review for longer than their contract type\'s review SLA.',
},
negotiation_stalled: {
title: 'Waiting on Counterparty',
Expand Down Expand Up @@ -218,6 +218,10 @@ export const appSurface: Pick<TranslationData, 'apps' | 'dashboards' | 'datasets
title: 'Signed → Active (Avg Days)',
description: 'Average days from signature to the contract coming into force.',
},
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.',
},
},
},
},
Expand Down Expand Up @@ -297,6 +301,9 @@ export const appSurface: Pick<TranslationData, 'apps' | 'dashboards' | 'datasets
route_gm_count: {
label: 'Routes: General Manager',
},
review_over_sla_count: {
label: 'In Review Over SLA',
},
},
},
contract_cycle_time: {
Expand Down
4 changes: 4 additions & 0 deletions src/translations/en/objects.contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export const contract: Record<string, ObjectTranslationData> = {
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.',
Expand Down
15 changes: 11 additions & 4 deletions src/translations/zh-CN/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export const appSurface: Pick<TranslationData, 'apps' | 'dashboards' | 'datasets
},
legal_workbench: {
label: '法务工作台',
description: '面向法务团队的待受理队列、审查负荷、谈判停滞、各阶段合同分布与各段时长。',
description: '面向法务团队的待受理队列、审查负荷与超时限、谈判停滞、各阶段合同分布与各段时长。',
widgets: {
awaiting_intake: {
title: '待受理',
Expand All @@ -186,9 +186,9 @@ export const appSurface: Pick<TranslationData, 'apps' | 'dashboards' | 'datasets
title: '审查中',
description: '已被法务受理、尚未送审的合同',
},
review_ageing: {
title: '审查超过 30 天',
description: '固定的 30 天阈值,不是各合同类型自己的审查时限。',
review_over_sla: {
title: '审查超时限',
description: '审查用时已超出本类型时限的合同。',
},
negotiation_stalled: {
title: '等待对方',
Expand Down Expand Up @@ -218,6 +218,10 @@ export const appSurface: Pick<TranslationData, 'apps' | 'dashboards' | 'datasets
title: '签署 → 生效(平均天数)',
description: '从签署到合同生效,平均用了多少天。',
},
review_over_sla_by_type: {
title: '审查超时限(按合同类型)',
description: '按合同类型统计审查时间已超过该类型审查时限的合同。没有超时限合同的类型不显示。',
},
},
},
},
Expand Down Expand Up @@ -297,6 +301,9 @@ export const appSurface: Pick<TranslationData, 'apps' | 'dashboards' | 'datasets
route_gm_count: {
label: '需经:总经理',
},
review_over_sla_count: {
label: '审查超时限',
},
},
},
contract_cycle_time: {
Expand Down
4 changes: 4 additions & 0 deletions src/translations/zh-CN/objects.contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export const contract: Record<string, ObjectTranslationData> = {
other: '其他',
},
},
review_sla_days_offset: {
label: '审查时限偏移(天)',
help: '合同类型的审查时限,取负的天数,供「审查超时限」统计使用。每次保存时按合同类型重新计算。',
},
status: {
label: '状态',
help: '合同所处的生命周期环节。状态只能按规定的路径流转;已到期、已终止、已作废是终态。',
Expand Down
Loading