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
68 changes: 51 additions & 17 deletions src/data/plan-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,15 +554,16 @@ const buildContracts = (): ContractPlan[] => {
* `loop-node.ts` iterates with a bare `await`, so the first failing row ends
* the whole run.
*
* F3 (`legal_review_sla`), over-30 stage, is the ONLY place this corpus can
* F3 (`legal_review_sla`), over-SLA band, is the ONLY place this corpus can
* exercise that edge, and both halves of that sentence are load-bearing:
*
* - F4 and F11 address `owner_id`, F12 and F13 address `owner_id` with the
* legal owner merely copied, and every contract carries an `owner_id`
* ({@link ../keys.ts}) — so none of them can ever go quiet.
* - F3's over-SIXTY stage copies `clm_legal_head`, so a row that has been in
* review past 60 days still has somebody to tell and takes the notify edge.
* Only the over-30 stage has `legal_owner` as its sole recipient.
* - F3's over-DOUBLE-the-SLA band copies `clm_legal_head`, so a row in review
* past twice its type's `review_sla_days` still has somebody to tell and
* takes the notify edge. Only the over-SLA band has `legal_owner` as its
* sole recipient.
*
* ⇒ Fill every `in_review` contract's legal owner and that edge is never taken
* again by any job on this corpus; a regression in it would ship invisibly.
Expand Down Expand Up @@ -590,28 +591,61 @@ export const UNASSIGNED_IN_REVIEW = 1;

/**
* Hand {@link UNASSIGNED_IN_REVIEW} contracts back to the queue — and prove
* that F3's over-30 stage, and only that stage, will select them.
* that F3's over-SLA band, and only that band, will select them.
*
* The eligible band is `(-60, -30]` days: past F3's 30-day threshold so the
* over-30 stage selects the row, and inside 60 so it is not selected by the
* over-60 stage instead, whose `also: {legalHeads.userIds}` would give the
* notification a recipient and take the notify edge. Longest review first,
* because a file nobody has owned for eight weeks is the case the nudge is for.
* F3 reads each contract's own type SLA (#203): a row is over SLA once its
* review started more than `review_sla_days` ago, and in the doubled band —
* whose `also: legalHeads.userIds` would give the notification a recipient and
* take the notify edge — once it started more than twice that ago. The dealt
* in-review rows all sit past twice their type's SLA (submitted 10–59 days
* ago, SLAs of 2–10 days), so no row is in the band as dealt: the chosen row
* is MOVED there, its whole timeline shifted so every stage keeps its distance
* (as {@link endingOn} moves a term), to `SLA + ⌈SLA / 2⌉` days in review.
* `review_started_at` lands on a calendar day (`dayOffset`), so the row's age
* is that many days plus less than one — strictly over the SLA and strictly
* under twice it for every SLA of 2 days or more, at any time of day.
*
* Shortest review first, so the move is the smallest the fixture can make.
*/
const handBackInReview = (contracts: readonly ContractPlan[]): ContractPlan[] => {
const slaOf = (contract: ContractPlan): number => CONTRACT_TYPE_PLAN[contract.typeIndex]!.reviewSlaDays;
const startedAt = (contract: ContractPlan): number => contract.timeline.reviewStartedAt ?? 0;
const eligible = contracts
.filter((contract) =>
contract.status === 'in_review' &&
contract.hasLegalOwner &&
(contract.timeline.reviewStartedAt ?? 0) <= -30 &&
(contract.timeline.reviewStartedAt ?? 0) > -60)
.sort((a, b) => (a.timeline.reviewStartedAt ?? 0) - (b.timeline.reviewStartedAt ?? 0));
contract.timeline.reviewStartedAt !== null &&
slaOf(contract) >= 2)
.sort((a, b) => startedAt(b) - startedAt(a));
if (eligible.length < UNASSIGNED_IN_REVIEW) {
throw new Error(`demo fixture: ${eligible.length} contracts are in review between 30 and 60 days with a legal owner, so ${UNASSIGNED_IN_REVIEW} cannot be handed back — F3's over-30 stage would select none of them and its "nobody to tell" edge would stop being exercised.`);
throw new Error(`demo fixture: ${eligible.length} contracts are in review with a legal owner and a type SLA of 2 days or more, so ${UNASSIGNED_IN_REVIEW} cannot be handed back — F3's over-SLA band would select none of them and its "nobody to tell" edge would stop being exercised.`);
}
const moveTo = new Map(eligible.slice(0, UNASSIGNED_IN_REVIEW).map((contract) =>
[contract.index, -(slaOf(contract) + Math.ceil(slaOf(contract) / 2))] as const));
const handedBack = contracts.map((contract) => {
const target = moveTo.get(contract.index);
if (target === undefined) return contract;
const delta = target - startedAt(contract);
const shift = (day: number | null) => (day === null ? null : day + delta);
const t = contract.timeline;
return {
...contract,
hasLegalOwner: false,
timeline: {
submittedAt: shift(t.submittedAt), reviewStartedAt: shift(t.reviewStartedAt),
approvedAt: shift(t.approvedAt), signedAt: shift(t.signedAt), executedAt: shift(t.executedAt),
activatedAt: shift(t.activatedAt), closedAt: shift(t.closedAt), startDate: shift(t.startDate),
endDate: shift(t.endDate),
},
};
});
// Re-proved on the result: every unassigned in-review row is inside (SLA, 2 × SLA) days.
const unassigned = handedBack.filter((c) => c.status === 'in_review' && !c.hasLegalOwner);
const inBand = unassigned.filter((c) => -startedAt(c) > slaOf(c) && -startedAt(c) + 1 <= 2 * slaOf(c));
if (unassigned.length !== UNASSIGNED_IN_REVIEW || inBand.length !== UNASSIGNED_IN_REVIEW) {
throw new Error(`demo fixture: ${inBand.length} of ${unassigned.length} unassigned in-review contracts are over their type's review SLA and under twice it, expected ${UNASSIGNED_IN_REVIEW} of ${UNASSIGNED_IN_REVIEW} — F3's "nobody to tell" edge would not be exercised.`);
}
const indexes = new Set(eligible.slice(0, UNASSIGNED_IN_REVIEW).map((contract) => contract.index));
return contracts.map((contract) =>
indexes.has(contract.index) ? { ...contract, hasLegalOwner: false } : contract);
return handedBack;
};

// ─────────────────────────────────────── the renewal and expiry windows ──
Expand Down
8 changes: 8 additions & 0 deletions src/datasets/contract.dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,20 @@ export const ContractDataset = defineDataset({
// 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.
//
// `review_sla_days_offset: { $ne: null }`: the driver reads a NULL offset
// column as ZERO days (18.3.0, the #25808 conformance table), and the
// column is NULL on an upgraded database until the contract is next
// written (the platform does not backfill a stored expression,
// objectstack#29961). A contract with no stored SLA is not counted over
// one — the same rows F3 (legal-review-sla.flow.ts) reminds about (#203).
{
name: 'review_over_sla_count',
label: 'In Review Over SLA',
aggregate: 'count',
filter: {
status: 'in_review',
review_sla_days_offset: { $ne: null },
review_started_at: { $lt: { $now: true, addDays: { $field: 'review_sla_days_offset' } } },
},
},
Expand Down
6 changes: 3 additions & 3 deletions src/email-templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ContractApprovedTemplates, ContractRejectedTemplates } from './contract
import { ExecutionCopyMissingTemplates, ExecutionFormalitiesMissingTemplates } from './signature-record.email-template.js';
import { RenewalNoticeDueTemplates } from './renewal-notice.email-template.js';
import { ContractAutoRenewedTemplates, ContractExpiredTemplates } from './expiration-sweep.email-template.js';
import { LegalReviewOver30DaysTemplates, LegalReviewOver60DaysTemplates } from './legal-review-sla.email-template.js';
import { LegalReviewOverSlaTemplates, LegalReviewOverDoubleSlaTemplates } from './legal-review-sla.email-template.js';
import { TurnStalledTemplates } from './turn-stalled.email-template.js';
import { ObligationDueSoonTemplates, ObligationDueTodayTemplates, ObligationOverdueTemplates } from './obligation-due.email-template.js';
import { PaymentOverdueTemplates } from './payment-overdue.email-template.js';
Expand All @@ -21,8 +21,8 @@ export const allEmailTemplates: EmailTemplateDefinition[] = [
...RenewalNoticeDueTemplates,
...ContractExpiredTemplates,
...ContractAutoRenewedTemplates,
...LegalReviewOver30DaysTemplates,
...LegalReviewOver60DaysTemplates,
...LegalReviewOverSlaTemplates,
...LegalReviewOverDoubleSlaTemplates,
...TurnStalledTemplates,
...ObligationDueSoonTemplates,
...ObligationDueTodayTemplates,
Expand Down
38 changes: 19 additions & 19 deletions src/email-templates/legal-review-sla.email-template.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,41 @@
import { noticeBundle } from './_bundle.js';

/** F3 `legal_review_sla`: a contract sitting in legal review past a fixed threshold. */
/** F3 `legal_review_sla`: a contract sitting in legal review past its contract type's review SLA. */

const VARIABLES = [
{ name: 'title', description: 'The contract title.' },
{ name: 'contract_number', description: 'The contract number.' },
{ name: 'review_started_at', description: 'When legal review started.' },
];

export const LegalReviewOver30DaysTemplates = noticeBundle({
name: 'clm.legal_review_over_30_days',
description: 'F3: in legal review for more than 30 days. Sent to the legal owner.',
export const LegalReviewOverSlaTemplates = noticeBundle({
name: 'clm.legal_review_over_sla',
description: 'F3: in legal review longer than the contract type\'s review SLA. Sent to the legal owner.',
variables: VARIABLES,
en: {
label: 'In legal review over 30 days',
subject: 'In legal review over 30 days: {{ title }}',
body: 'Contract {{ contract_number }} has been in legal review since {{ review_started_at }} — more than 30 days. This is a fixed 30-day threshold, not this contract type\'s own review SLA. Move it on, or record what it is waiting for.',
label: 'Legal review over SLA',
subject: 'Legal review over SLA: {{ title }}',
body: 'Contract {{ contract_number }} has been in legal review since {{ review_started_at }}, longer than the review SLA of its contract type. Move it on, or record what it is waiting for.',
},
zhCN: {
label: '法务审查超过 30 天',
subject: '法务审查超过 30 天:{{ title }}',
body: '合同 {{ contract_number }} 自 {{ review_started_at }} 起处于法务审查中,已超过 30 天。这是固定的 30 天阈值,不是该合同类型自己的审查时限。请推进审查,或记录它在等什么。',
label: '法务审查超时限',
subject: '法务审查超时限:{{ title }}',
body: '合同 {{ contract_number }} 自 {{ review_started_at }} 起处于法务审查中,已超过该合同类型的审查时限。请推进审查,或记录它在等什么。',
},
});

export const LegalReviewOver60DaysTemplates = noticeBundle({
name: 'clm.legal_review_over_60_days',
description: 'F3: in legal review for more than 60 days. Sent to the legal owner, with the head of legal copied.',
export const LegalReviewOverDoubleSlaTemplates = noticeBundle({
name: 'clm.legal_review_over_double_sla',
description: 'F3: in legal review longer than twice the contract type\'s review SLA. Sent to the legal owner, with the head of legal copied.',
variables: VARIABLES,
en: {
label: 'In legal review over 60 days',
subject: 'In legal review over 60 days: {{ title }}',
body: 'Contract {{ contract_number }} has been in legal review since {{ review_started_at }} — more than 60 days, twice the 30-day reminder threshold, so the head of legal is copied. This is a fixed threshold, not this contract type\'s own review SLA.',
label: 'Legal review over twice the SLA',
subject: 'Legal review over twice the SLA: {{ title }}',
body: 'Contract {{ contract_number }} has been in legal review since {{ review_started_at }}, longer than twice the review SLA of its contract type, so the head of legal is copied.',
},
zhCN: {
label: '法务审查超过 60 天',
subject: '法务审查超过 60 天:{{ title }}',
body: '合同 {{ contract_number }} 自 {{ review_started_at }} 起处于法务审查中,已超过 60 天,是 30 天提醒阈值的两倍,因此同时抄送法务负责人。这是固定阈值,不是该合同类型自己的审查时限。',
label: '法务审查超时限一倍',
subject: '法务审查超时限一倍:{{ title }}',
body: '合同 {{ contract_number }} 自 {{ review_started_at }} 起处于法务审查中,已超过该合同类型审查时限的两倍,因此同时抄送法务负责人。',
},
});
Loading
Loading