diff --git a/src/data/contract.seed.ts b/src/data/contract.seed.ts index a4d39f9..592a808 100644 --- a/src/data/contract.seed.ts +++ b/src/data/contract.seed.ts @@ -7,7 +7,7 @@ import { Contract } from '../objects/contract.object.js'; import { dayOffset } from './_shared.js'; import { STRINGS } from './demo-locale.js'; import { contractTitle, legalOwnerOf, ownerOf, titleOfIndex } from './keys.js'; -import { CONTRACT_PLAN, lawOf, typeOf, type ContractPlan } from './plan-contracts.js'; +import { CONTRACT_PLAN, lawOf, renewalFlagged, typeOf, type ContractPlan } from './plan-contracts.js'; /** * The 120 contracts of DESIGN.md §10, in the status spread its table pins. @@ -34,8 +34,10 @@ import { CONTRACT_PLAN, lawOf, typeOf, type ContractPlan } from './plan-contract * * - `route_*` comes from {@link routeFlagsFor}, which re-implements F2's * matching rules against the same six rules this demo installs; - * - `is_expiring` is F12's own predicate — `active`, and `end_date` inside - * the renewal notice window — evaluated against the seeded dates; + * - `is_expiring` is the flag F12 itself left on its last run — `active`, + * and a notice deadline on a day BEFORE boot day — evaluated against the + * seeded dates by {@link renewalFlagged}, which says why a deadline of + * today is still unflagged; * - `approval_status` mirrors the contract's own status, so every surface * inside the app agrees. The surface that does not is the platform's: * NO `sys_approval_request` exists behind any of these rows, because F5 @@ -43,13 +45,6 @@ import { CONTRACT_PLAN, lawOf, typeOf, type ContractPlan } from './plan-contract * for what card 10's dashboards will therefore show. */ -/** F12's predicate, evaluated against the seeded dates rather than guessed. */ -const isExpiring = (contract: ContractPlan): boolean => - contract.status === 'active' && - contract.timeline.endDate !== null && - contract.renewalNoticeDays !== null && - contract.timeline.endDate - contract.renewalNoticeDays <= 0; - /** * Six of the fourteen contracts in a terminal state have been through the * records desk; the other eight are what §05's "待归档" list is for. @@ -121,7 +116,7 @@ export const contractSeed = defineSeed(Contract, { auto_renew: contract.autoRenew, renewal_notice_days: contract.renewalNoticeDays, parent_contract: contract.parentIndex === null ? null : titleOfIndex(contract.parentIndex), - is_expiring: isExpiring(contract), + is_expiring: renewalFlagged(contract), // Governing law is on the intake form of six of the nine types, and the // submission guard refuses a contract whose type asks for an intake diff --git a/src/data/plan-contracts.ts b/src/data/plan-contracts.ts index 388ac1d..bf1965a 100644 --- a/src/data/plan-contracts.ts +++ b/src/data/plan-contracts.ts @@ -616,7 +616,169 @@ const handBackInReview = (contracts: readonly ContractPlan[]): ContractPlan[] => indexes.has(contract.index) ? { ...contract, hasLegalOwner: false } : contract); }; -export const CONTRACT_PLAN: readonly ContractPlan[] = handBackInReview(buildContracts()); +// ─────────────────────────────────────── the renewal and expiry windows ── + +/** + * Has F12 (`renewal_notice`) already flagged this contract `is_expiring`? + * + * For F12 and F13 this fixture is the book as a daily run leaves it at the + * START of boot day: every earlier day's run has happened, today's has not + * (claimed for these two jobs only — F11's seeded `partial` instalments past + * their planned date are not read this way). F12 flags a contract + * on the first run on or after its notice deadline (`end_date - + * renewal_notice_days`), so a deadline on an EARLIER day was flagged by that + * day's run, and a deadline of today is still waiting for today's. `< 0`, not + * `<= 0`, is that distinction — and it is what the seeded `is_expiring` says, + * so the column holds the value F12 itself left there (`_shared.ts`, rule 2). + * + * The flag is also F12's once-per-contract key (`is_expiring: { $ne: true }` + * in its filter): a flagged row is one whose owner the job has already told. + */ +export const renewalFlagged = (contract: ContractPlan): boolean => + contract.status === 'active' && + contract.timeline.endDate !== null && + contract.renewalNoticeDays !== null && + contract.timeline.endDate - contract.renewalNoticeDays < 0; + +/** + * How many active contracts reach their renewal-notice deadline ON boot day — + * the rows F12 finds unflagged inside their window on its first run. + * + * ## Why the corpus needs them — measured, #97 + * + * `main` @ `46e65f0`, 17.7.0, fresh `pnpm demo`, one manual trigger of each + * job: `renewal_notice` selected 25 and acted on 0; `expiration_sweep` + * selected 0 and 0. Both zeroes are structural, not a bad day. Every active + * contract inside its window (11) is seeded already flagged, which is the key + * that takes it out of F12's query; the 25 the query does read are all outside + * their windows, the nearest by six days. And no active contract's term has + * ended (the nearest ends in four days), so F13 has nothing to read at all. + * The jobs work — PR #42 measured both write paths on probe rows — and a + * fresh demo still never sends a renewal or an expiry reminder, which is half + * of what M3's acceptance asks to see in the inbox (DESIGN.md §11: + * 「到期、逾期提醒在收件箱可见」). + * + * ## Where the row goes, and why that is honest + * + * Under {@link renewalFlagged}'s reading, the ONE unflagged place inside a + * window is a deadline of today. So the row is moved there: its `end_date` + * becomes exactly `renewal_notice_days` from boot, and its seeded + * `is_expiring` is the `false` F12 computed yesterday. + * + * It is drawn from the backfilled corpus, as §10's ten expiring contracts are, + * and it must carry a legal owner, because F12 tells the business owner AND + * legal. The band keeps every dated count where it was: a notice period over + * 30 days keeps it out of §10's "10 within 30 days", and an end date already + * inside 90 keeps the 管理层 board's 90-day expiry tile (`expiring_90_days`) + * unchanged. Nearest to its window first — the smallest move. + * + * One row is what coverage costs, as for {@link UNASSIGNED_IN_REVIEW}. + */ +export const RENEWAL_DEADLINE_TODAY = 1; + +/** + * How many active contracts per branch of F13 (`expiration_sweep`) ended their + * term YESTERDAY — one that does not auto-renew (F13 expires it) and one that + * does (F13 drafts its renewal). Both branches notify, and neither is reachable + * on the corpus without this (measured above). + * + * Yesterday for the same reason as {@link RENEWAL_DEADLINE_TODAY}'s today: F13 + * selects `end_date < today`, so yesterday's run did not select a term that + * ended yesterday, and today's run will. Drawn from the backfilled corpus with + * a legal owner and an end date beyond 90 days, so neither §10's 30-day count + * nor the 90-day tile moves; soonest-ending first. + * + * ⚠️ The non-renewing row is one more contract in #41's set: once F13 has + * expired it, re-running `pnpm demo` re-asserts `active` on a terminal + * contract — measured on this fixture: `819 ok / 1 error`, that row refused + * by `contract_state_machine`, loudly. #41 is the open decision on exactly + * that and covers every contract the jobs move; this row makes it reachable + * on boot day instead of five days after it. + */ +export const TERM_ENDED_YESTERDAY_PER_BRANCH = 1; + +/** §10's "10 contracts ending within 30 days", as a date band. */ +const EXPIRING_BAND_DAYS = 30; +/** The 管理层 board's `expiring_90_days` tile, as a date band. */ +const EXPIRY_TILE_DAYS = 90; + +/** Move a contract's whole timeline so its term ends on `endDate`, every stage keeping its distance. */ +const endingOn = (contract: ContractPlan, endDate: number): ContractPlan => { + const delta = endDate - (contract.timeline.endDate as number); + const shift = (day: number | null) => (day === null ? null : day + delta); + const t = contract.timeline; + return { + ...contract, + 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), + }, + }; +}; + +/** A count this placement owns (not one §10 pins), refused with what it would cost. */ +const assertPlaced = (rows: string, actual: number, expected: number, cost: string): void => { + if (actual !== expected) { + throw new Error(`demo fixture: ${actual} ${rows} after placing the sweep-window rows, expected ${expected} — ${cost} (#97).`); + } +}; + +const activeEndingIn = (contracts: readonly ContractPlan[], low: number, high: number) => + contracts.filter((c) => c.status === 'active' && c.timeline.endDate !== null && + c.timeline.endDate >= low && c.timeline.endDate <= high).length; + +/** + * Place {@link RENEWAL_DEADLINE_TODAY} and {@link TERM_ENDED_YESTERDAY_PER_BRANCH} + * — and prove afterwards that F12 and F13 will select exactly those rows and + * that no dated count of §10 or the boards moved. + */ +const placeInSweepWindows = (contracts: readonly ContractPlan[]): ContractPlan[] => { + const eligible = (c: ContractPlan): boolean => + c.status === 'active' && c.backfilled && !c.expiringSoon && c.hasLegalOwner && c.timeline.endDate !== null; + const end = (c: ContractPlan) => c.timeline.endDate as number; + + const renewal = contracts + .filter((c) => eligible(c) && c.renewalNoticeDays !== null && c.renewalNoticeDays > EXPIRING_BAND_DAYS && + end(c) > c.renewalNoticeDays && end(c) <= EXPIRY_TILE_DAYS) + .sort((a, b) => (end(a) - (a.renewalNoticeDays as number)) - (end(b) - (b.renewalNoticeDays as number))) + .slice(0, RENEWAL_DEADLINE_TODAY); + const ended = [false, true].flatMap((autoRenew) => contracts + .filter((c) => eligible(c) && c.autoRenew === autoRenew && end(c) > EXPIRY_TILE_DAYS) + .sort((a, b) => end(a) - end(b)) + .slice(0, TERM_ENDED_YESTERDAY_PER_BRANCH)); + if (renewal.length < RENEWAL_DEADLINE_TODAY || ended.length < 2 * TERM_ENDED_YESTERDAY_PER_BRANCH) { + throw new Error(`demo fixture: found ${renewal.length} contract(s) to bring to a renewal deadline of today and ${ended.length} to end yesterday — F12 and F13 would have nothing to act on (#97).`); + } + + const moveTo = new Map([ + ...renewal.map((c) => [c.index, c.renewalNoticeDays as number] as const), + ...ended.map((c) => [c.index, -1] as const), + ]); + const placed = contracts.map((c) => (moveTo.has(c.index) ? endingOn(c, moveTo.get(c.index)!) : c)); + + // Re-proved on the result, not assumed from the construction. + const active = placed.filter((c) => c.status === 'active'); + const deadlineToday = active.filter((c) => + c.renewalNoticeDays !== null && c.timeline.endDate !== null && c.timeline.endDate - c.renewalNoticeDays === 0); + assertPlaced('unflagged active contracts with a renewal deadline of today', deadlineToday.filter((c) => !renewalFlagged(c)).length, RENEWAL_DEADLINE_TODAY, + "F12 would select no row inside its window and the demo would send no renewal notice"); + const termEnded = active.filter((c) => c.timeline.endDate !== null && c.timeline.endDate < 0); + assertPlaced('active, non-renewing contracts past their end date', termEnded.filter((c) => !c.autoRenew).length, TERM_ENDED_YESTERDAY_PER_BRANCH, + "F13's expiry branch would have nothing to expire, or more than the fixture means to hand it"); + assertPlaced('active, auto-renewing contracts past their end date', termEnded.filter((c) => c.autoRenew).length, TERM_ENDED_YESTERDAY_PER_BRANCH, + "F13's auto-renew branch would have nothing to draft, or more than the fixture means to hand it"); + assertCount('clm_contract (active, ending within 30 days)', activeEndingIn(placed, 0, EXPIRING_BAND_DAYS), 10); + assertPlaced('active contracts ending within 90 days', activeEndingIn(placed, 0, EXPIRY_TILE_DAYS), activeEndingIn(contracts, 0, EXPIRY_TILE_DAYS), + "the 管理层 board's 90-day expiry tile would move with a fixture change it has nothing to do with"); + if (placed.some((c) => moveTo.has(c.index) && (c.timeline.activatedAt ?? 0) > 0)) { + throw new Error('demo fixture: a contract moved into a sweep window would start in the future while seeded active.'); + } + return placed; +}; + +export const CONTRACT_PLAN: readonly ContractPlan[] = placeInSweepWindows(handBackInReview(buildContracts())); /** The counterparty a contract is with. */ export const partyOf = (contract: ContractPlan) => PARTY_PLAN[contract.partyIndex]!;