diff --git a/src/data/plan-contracts.ts b/src/data/plan-contracts.ts index f3cd734..d42572b 100644 --- a/src/data/plan-contracts.ts +++ b/src/data/plan-contracts.ts @@ -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. @@ -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 ── diff --git a/src/datasets/contract.dataset.ts b/src/datasets/contract.dataset.ts index 97e8cf6..9d10b50 100644 --- a/src/datasets/contract.dataset.ts +++ b/src/datasets/contract.dataset.ts @@ -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' } } }, }, }, diff --git a/src/email-templates/index.ts b/src/email-templates/index.ts index e4a1e3c..597aea7 100644 --- a/src/email-templates/index.ts +++ b/src/email-templates/index.ts @@ -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'; @@ -21,8 +21,8 @@ export const allEmailTemplates: EmailTemplateDefinition[] = [ ...RenewalNoticeDueTemplates, ...ContractExpiredTemplates, ...ContractAutoRenewedTemplates, - ...LegalReviewOver30DaysTemplates, - ...LegalReviewOver60DaysTemplates, + ...LegalReviewOverSlaTemplates, + ...LegalReviewOverDoubleSlaTemplates, ...TurnStalledTemplates, ...ObligationDueSoonTemplates, ...ObligationDueTodayTemplates, diff --git a/src/email-templates/legal-review-sla.email-template.ts b/src/email-templates/legal-review-sla.email-template.ts index ae313c7..33806d9 100644 --- a/src/email-templates/legal-review-sla.email-template.ts +++ b/src/email-templates/legal-review-sla.email-template.ts @@ -1,6 +1,6 @@ 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.' }, @@ -8,34 +8,34 @@ const VARIABLES = [ { 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 }} 起处于法务审查中,已超过该合同类型审查时限的两倍,因此同时抄送法务负责人。', }, }); diff --git a/src/flows/legal-review-sla.flow.ts b/src/flows/legal-review-sla.flow.ts index 9be2432..c330f53 100644 --- a/src/flows/legal-review-sla.flow.ts +++ b/src/flows/legal-review-sla.flow.ts @@ -1,116 +1,137 @@ -import type { Flow } from '@objectstack/spec/automation'; +import type { Flow, FlowEdge, FlowNode } from '@objectstack/spec/automation'; import { holdersPrelude, rowBody, sweepFlow } from './_daily-sweep.js'; import { ref } from './_values.js'; /** * F3 `legal_review_sla` — the daily nudge on contracts sitting in legal review - * (DESIGN.md §06 F3). + * past their contract type's review SLA (DESIGN.md §06 F3: "`in_review` 超过类型 + * `review_sla_days`:提醒 `legal_owner`,超一倍抄送 `clm_legal_head`"). * - * ## The threshold is FIXED, and the wording says which one it is + * ## One rule with the legal workbench * - * §06 F3 reads "`in_review` 超过类型 `review_sla_days`:提醒 `legal_owner`, - * 超一倍抄送 `clm_legal_head`" — a PER-TYPE threshold, read off a row on - * another object. Decision #31 was ruled **1C + 2B** by the maintainer on - * 2026-09-09: the per-type comparison needs date arithmetic the platform does - * not have (§12 gap #7 — no `daysBetween` in CEL; the analytics layer refuses - * a cross-object filter), the blocking defect is a PLATFORM defect - * (`objectstack-ai/objectstack#16737`), and an application repository does not - * replicate a platform rule to route around one. So: + * "Over SLA" is the rule the workbench's `review_over_sla_count` measure counts + * (contract.dataset.ts, #31): in review, and `review_started_at` earlier than + * now shifted by the contract's own `review_sla_days_offset` — the type's + * `review_sla_days` as a NEGATIVE number of days, a stored `expression` the + * engine keeps on the contract (contract.object.ts). The comparand is the + * platform's `$now` anchor (objectstack#25808), written literally in the + * `get_record` filter: a literal operand is not a CEL envelope, so the flow + * executor hands it to the query unchanged, and the ObjectQL read path pins + * `$now` to the run's instant (`resolveFilterTokens`) before the SQL driver + * adds the column's days in the target column's own form. The datetime target + * keeps its time of day, as on the tile: a review started at 09:00 six days + * ago is over a five-day SLA from 09:00 today. * - * - ⛔ **No daily job stamps a computed duration or a `review_due_at` onto a - * field.** That was option 1A/2A and it was explicitly not adopted. - * - The job uses a **fixed threshold**, and every string it writes **states - * that threshold**: "over 30 days", never "over SLA". The nine seeded - * contract types have SLAs of 2, 3, 5 and 10 days, so a notification - * claiming an SLA breach over a 30-day filter would be false — and one - * claiming it over a 2-day type would be true for the wrong reason. 30 days - * is later than every one of them, which makes the reminder conservative: - * it never fires early, and it says what it measured. - * - "超一倍" is doubled the same way: 60 days, and the head of legal is - * copied there. + * ## A NULL offset does not remind * - * The legal dashboard's matching tile (card 10) is titled "In Review Over 30 - * Days" for the same reason and against the same number, so the tile and the - * reminder cannot tell two different stories. + * The driver reads a NULL `addDays` column as ZERO days (18.3.0, + * driver-sql `cross-field-conformance-cases.ts`, the #25808 table, row 4), so + * without a guard a contract whose offset was never stored would be "over" an + * SLA of nothing on the day its review started. The column is NULL on an + * upgraded database until the contract is next written — the platform does + * not backfill a stored expression (objectstack#29961) — and a reminder that + * claims a breach of an SLA the row does not carry would be false. Hence + * `review_sla_days_offset: { $ne: null }`, the filter grammar's has-a-value + * predicate, in the query. * - * The per-type reminder returns when the platform gains the date capability; - * #31 stays open and blocked on #16737, with the unlock criterion "this repo - * upgrades to a version where the wrong path actually errors" — not "the - * upstream PR merged". + * ## Two bands, one notification a row a day * - * ## Two bands, two queries — not one query and a comparison - * - * Splitting "over 30" into `[30, 60)` and `[60, ∞)` keeps ALL of the date - * arithmetic inside `filter` values, where the `{date-macro}` vocabulary is - * resolved server-side. The alternative — one query and a per-row decision — - * would need a date difference on a CEL edge, which is the thing that does not - * exist. It also means a contract is in exactly one band, so it gets exactly - * one notification a day rather than two. - * - * The bands abut on `{60_days_ago}` (`$gte` on the near band, `$lt` on the - * far one), so they tile the timeline with no row in both and none in neither. + * "超一倍" — double the SLA — cannot be a query: `addDays` takes one column, + * and doubling it would need a second stored column holding `-2 × SLA`, which + * this repo does not add. It is a per-row CEL decision instead, over the two + * fields the query already projected: `datetime(review_started_at) < + * addDays(now(), offset + offset)` — the same instant arithmetic, from the + * same fields, one SLA further back. (`offset + offset` rather than + * `2 * offset`: CEL does not mix `int` and `double`, and a number column + * arrives as `double`.) Every row the query selects takes exactly one of the + * two partitioned edges, so a contract gets exactly one notification a day — + * the near one (legal owner) or the doubled one (legal owner + head of legal), + * never both. That is F3's only de-duplication, as before: it is a daily + * nudge, repeated each day the review stays over. */ -/** What both thresholds fill their bundle's holes with (src/email-templates/legal-review-sla.email-template.ts). */ +/** What both bands fill their bundle's holes with (src/email-templates/legal-review-sla.email-template.ts). */ const REVIEW_DATA = { title: ref('review.title'), contract_number: ref('review.contract_number'), review_started_at: ref('review.review_started_at'), }; +/** + * Over DOUBLE the type's SLA. `has()` first, a step at a time: an edge that + * cannot be evaluated aborts the step instead of being skipped. The query + * guarantees both fields, so the guards only keep the edge evaluable. + */ +const OVER_DOUBLE_SLA = + 'has(vars.review) && vars.review != null' + + ' && has(vars.review.review_started_at) && vars.review.review_started_at != null' + + ' && has(vars.review.review_sla_days_offset) && vars.review.review_sla_days_offset != null' + + ' && datetime(vars.review.review_started_at)' + + ' < addDays(now(), vars.review.review_sla_days_offset + vars.review.review_sla_days_offset)'; +/** Its exact complement — the decision is partitioned, never defaulted (see `rowBody`). */ +const NOT_OVER_DOUBLE_SLA = `!(${OVER_DOUBLE_SLA})`; + +const near = rowBody({ + id: 'near', + recipients: { primary: ref('review.legal_owner') }, + notice: { + topic: 'clm_legal_review_over_sla', + severity: 'warning', + sourceObject: 'clm_contract', + sourceId: ref('review.id'), + template: 'clm.legal_review_over_sla', + templateData: REVIEW_DATA, + }, +}); + +const doubled = rowBody({ + id: 'doubled', + recipients: { primary: ref('review.legal_owner'), also: ref('legalHeads.userIds') }, + notice: { + topic: 'clm_legal_review_over_double_sla', + severity: 'critical', + sourceObject: 'clm_contract', + sourceId: ref('review.id'), + template: 'clm.legal_review_over_double_sla', + templateData: REVIEW_DATA, + }, +}); + +/** `band → (near | doubled) → row done`: the two bands as one per-row region. */ +const bandBody = (): { nodes: FlowNode[]; edges: FlowEdge[] } => ({ + nodes: [ + { id: 'band', type: 'decision', label: 'Over Double The SLA?' }, + ...near.nodes, + ...doubled.nodes, + { id: 'band_done', type: 'assignment', label: 'Row Done' }, + ], + edges: [ + { id: 'band_e1', source: 'band', target: near.nodes[0]!.id, type: 'default', condition: NOT_OVER_DOUBLE_SLA, label: 'Over SLA' }, + { id: 'band_e2', source: 'band', target: doubled.nodes[0]!.id, type: 'default', condition: OVER_DOUBLE_SLA, label: 'Over double the SLA' }, + ...near.edges, + ...doubled.edges, + { id: 'band_e3', source: 'near_done', target: 'band_done', type: 'default' }, + { id: 'band_e4', source: 'doubled_done', target: 'band_done', type: 'default' }, + ], +}); + export const LegalReviewSlaFlow: Flow = sweepFlow({ name: 'legal_review_sla', - label: 'Legal Review Running Long', - // A fixed threshold, stated in the message — decision #31 ruled 2B: the - // per-type SLA needs platform date support (objectstack#16737). See the - // header above. - description: 'Daily: remind the legal owner of a contract that has been in review over 30 days, and copy the head of legal once it passes 60. A fixed threshold, not each contract type\'s own review SLA.', + label: 'Legal Review Over SLA', + description: 'Daily: remind the legal owner of a contract that has been in review longer than its contract type\'s review SLA, and copy the head of legal once it passes double that SLA. A contract with no stored SLA is not reminded.', prelude: holdersPrelude('clm_legal_head', 'legalHeads'), stages: [ { - id: 'over30', - label: 'In Review Over 30 Days', - objectName: 'clm_contract', - filter: { - status: 'in_review', - review_started_at: { $lt: '{30_days_ago}', $gte: '{60_days_ago}' }, - }, - fields: ['id', 'title', 'contract_number', 'legal_owner', 'review_started_at'], - item: 'review', - body: rowBody({ - id: 'over30', - recipients: { primary: ref('review.legal_owner') }, - notice: { - topic: 'clm_legal_review_over_30_days', - severity: 'warning', - sourceObject: 'clm_contract', - sourceId: ref('review.id'), - template: 'clm.legal_review_over_30_days', - templateData: REVIEW_DATA, - }, - }), - }, - { - id: 'over60', - label: 'In Review Over 60 Days', + id: 'over_sla', + label: 'In Review Over SLA', objectName: 'clm_contract', filter: { status: 'in_review', - review_started_at: { $lt: '{60_days_ago}' }, + review_sla_days_offset: { $ne: null }, + review_started_at: { $lt: { $now: true, addDays: { $field: 'review_sla_days_offset' } } }, }, - fields: ['id', 'title', 'contract_number', 'legal_owner', 'review_started_at'], + fields: ['id', 'title', 'contract_number', 'legal_owner', 'review_started_at', 'review_sla_days_offset'], item: 'review', - body: rowBody({ - id: 'over60', - recipients: { primary: ref('review.legal_owner'), also: ref('legalHeads.userIds') }, - notice: { - topic: 'clm_legal_review_over_60_days', - severity: 'critical', - sourceObject: 'clm_contract', - sourceId: ref('review.id'), - template: 'clm.legal_review_over_60_days', - templateData: REVIEW_DATA, - }, - }), + body: bandBody(), }, ], }); diff --git a/src/objects/contract-type.object.ts b/src/objects/contract-type.object.ts index 1f17af3..8843914 100644 --- a/src/objects/contract-type.object.ts +++ b/src/objects/contract-type.object.ts @@ -142,11 +142,12 @@ export const ContractType = ObjectSchema.create({ min: 0, max: 90, defaultValue: 5, - // DESIGN.md §06 F3 reads this per type, but the reminder fires at a fixed - // 30 days until the platform can compare dates (decision #31, 1C + 2B — see - // legal-review-sla.flow.ts), so the help text must not promise a per-type - // reminder. - description: 'Calendar days legal has to finish reviewing a contract of this type. The overdue reminder does not use this value: it currently fires at a fixed 30 days.', + // Read per contract, not here: the engine stores it on each contract as + // `review_sla_days_offset` (a stored expression, contract.object.ts) on + // every write of that contract, and both the workbench's over-SLA tile and + // F3 (legal-review-sla.flow.ts) read that copy. So an edit here reaches a + // contract already in review only when that contract is next saved. + description: 'Calendar days legal has to finish reviewing a contract of this type. Past it, the legal owner gets a daily reminder; past twice it, the head of legal is copied. A contract keeps the value it was last saved with.', }), template_file: Field.file({ diff --git a/src/translations/en/objects.catalog.ts b/src/translations/en/objects.catalog.ts index bf7d0a0..0d20dbe 100644 --- a/src/translations/en/objects.catalog.ts +++ b/src/translations/en/objects.catalog.ts @@ -87,7 +87,7 @@ export const catalog: Record = { }, review_sla_days: { label: 'Review SLA (days)', - help: 'Calendar days legal has to finish reviewing a contract of this type. The overdue reminder does not use this value: it currently fires at a fixed 30 days.', + help: 'Calendar days legal has to finish reviewing a contract of this type. Past it, the legal owner gets a daily reminder; past twice it, the head of legal is copied. A contract keeps the value it was last saved with.', }, template_file: { label: 'Template', diff --git a/src/translations/zh-CN/objects.catalog.ts b/src/translations/zh-CN/objects.catalog.ts index 3e16b9d..b9e50de 100644 --- a/src/translations/zh-CN/objects.catalog.ts +++ b/src/translations/zh-CN/objects.catalog.ts @@ -87,7 +87,7 @@ export const catalog: Record = { }, review_sla_days: { label: '审查时限(天)', - help: '法务完成这种类型合同审查的自然日天数。催办提醒目前不按这个值,而是固定在 30 天时触发。', + help: '法务完成这种类型合同审查的自然日天数。超过后每天提醒法务经办人;超过一倍时同时抄送法务负责人。合同沿用它最近一次保存时的值。', }, template_file: { label: '模板',