import { ok, type CommandContext, type QueryContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { addMonths, computeGrantExpirationDate } from "../lib/grantDates"; import type { AccrualPlan, Schema } from "../lib/types"; /** * Injected cross-module query for the MIN_WORKED_DAYS grant-condition gate (ADR-026 B1): * time-tracking `aggregateWorkedDays` counts distinct worked days over the gate's reference * window. Typed structurally — matching the Result shape returned by the source module's * query — rather than importing the module types, to avoid module-type import cycles in the * command layer; the module wiring (module.ts) pins it to the real `Pick` at the boundary. */ export interface GrantBatchQueries { aggregateWorkedDays: ( db: Transaction, input: { assignmentIds: string[]; startDate: Date; endDate: Date; holidayDates?: Date[] }, ctx: QueryContext, ) => Promise<{ ok: true; value: { workedDays: number } } | { ok: false; error: unknown }>; } type BaseAccrualPlan = AccrualPlan; /** * Result of an application-owned grant policy. NOT_EVALUABLE is counted as a failed candidate so * the application can retry it instead of silently granting or denying an entitlement. */ export type GrantEligibilityEvaluation = | { status: "ELIGIBLE"; /** * Optional override of the generic proposed amount. It must be a non-negative half-day * increment within the plan's `annualCapDays` — an out-of-range amount is a policy bug and * counts the candidate as failed (see `isValidOverriddenGrantDays`). */ grantDays?: number; } | { status: "INELIGIBLE"; reason: string } | { status: "NOT_EVALUABLE"; reason: string }; export interface GrantEligibilityEvaluationInput { candidate: AnniversaryGrantCandidate; accrualPlan: BaseAccrualPlan & CF; /** Amount the generic tenure tiers and annual cap would grant without an app override. */ proposedGrantDays: number; } export type EvaluateGrantEligibility = ( db: Transaction, input: GrantEligibilityEvaluationInput, ctx: CommandContext, ) => Promise; export interface GrantBatchDependencies extends GrantBatchQueries { /** * Optional application policy seam for jurisdiction- or company-specific conditions stored in * AccrualPlan custom fields. Omitted means only the generic kit conditions are evaluated. */ evaluateGrantEligibility?: EvaluateGrantEligibility; } export interface AnniversaryGrantCandidate { workerId: string; leaveTypeKey: string; /** * Workforce EmploymentType catalog id, compared against the effective AccrualPlan's * appliesToEmploymentTypeId (cross-module id; see KNOWN GAP on the input type below). */ employmentTypeId?: string | null; /** * The grant date being evaluated; becomes the LeaveGrant's grantedDate if eligible. For the * first (HIRE) grant this is the eligibility date (hire date + the plan's eligibilityDelayMonths); * for later grants it is the hire anniversary. It is supplied by the caller — see the KNOWN GAP * below on why the offset is applied caller-side in v1. */ grantDate: Date; /** Whole years of tenure as of grantDate: 0 for the eligibility-date HIRE grant, >= 1 for anniversaries. */ yearsOfService: number; /** * The worker's employment start date (workforce WorkerEmployment hire date; caller-supplied, * same stand-in convention as assignmentIds — see the KNOWN GAP on the * input type). Used to clamp the MIN_WORKED_DAYS gate's reference window so it never precedes * employment: without the clamp the first (HIRE, tenure-0) grant — whose grantDate is * hire + eligibilityDelayMonths (e.g. 6 months) — would evaluate a referenceMonths (default * 12) window extending ~6 months BEFORE the hire date. The reference period for the first * grant is [hireDate, grantDate]. */ hireDate: Date; /** * The worker's workforce Assignment ids, used for the MIN_WORKED_DAYS grant-condition gate: * the injected time-tracking `aggregateWorkedDays` counts distinct worked days across these * assignments over the gate's reference window (ADR-026 B1). Caller-supplied — full * workforce-query candidate loading is a follow-up. */ assignmentIds: string[]; } export interface RunAnniversaryLeaveGrantsInput { /** * KNOWN GAP (narrowed by ADR-026 B1, full closure is a follow-up): workforce WorkerEmployment * (cross-module) has no query injection wired yet for this module, so this batch still operates * on an explicit list of worker/grant-date/tenure candidates supplied by the caller — a * simplified stand-in for "load employments whose eligibility date (hire + * eligibilityDelayMonths) or hire anniversary is today" from * docs/command/RunAnniversaryLeaveGrants.md. The plan's `eligibilityDelayMonths` is therefore * applied caller-side when building `grantDate`. B1 does inject the time-tracking * `aggregateWorkedDays` query (MIN_WORKED_DAYS gate) and adds caller-supplied * `assignmentIds` to each candidate; a follow-up should replace * `candidates` with an injected workforce query that computes candidates here once * cross-module workforce query wiring exists for this module. */ candidates: AnniversaryGrantCandidate[]; } /** * An app-overridden grant amount must stay inside the plan it came from: a non-negative half-day * increment (the granularity grantLeave enforces on a manual grant) no larger than the plan's * `annualCapDays`, which the model documents as the upper bound on the annual entitlement. It is * validated rather than clamped so a policy that contradicts its own plan configuration surfaces * as a failed candidate the app can fix, instead of a silently adjusted statutory entitlement. */ function isValidOverriddenGrantDays(grantDays: number, cap: number): boolean { if (!Number.isFinite(grantDays) || grantDays < 0) return false; if (!Number.isInteger(grantDays * 2)) return false; return grantDays <= cap; } export async function run( db: Transaction, input: RunAnniversaryLeaveGrantsInput, ctx: CommandContext, deps: GrantBatchDependencies, ) { let granted = 0; let skipped = 0; let failed = 0; for (const candidate of input.candidates) { try { // Select the effective AccrualPlan by considering ALL candidates for the leaveTypeKey/date, // then resolving employment-type applicability in code — so the outcome never depends on the // DB's return order (M10). A plan applies if it targets this candidate's employment type or is // generic (appliesToEmploymentTypeId IS NULL); a type-specific plan wins over the generic one. const effectivePlans = await db .selectFrom("AccrualPlan") .selectAll() .where("leaveTypeKey", "=", candidate.leaveTypeKey) .where("effectiveStart", "<=", candidate.grantDate) .where((eb) => eb.or([eb("effectiveEnd", "is", null), eb("effectiveEnd", ">=", candidate.grantDate)]), ) .execute(); const applicablePlans = effectivePlans.filter( (p) => p.appliesToEmploymentTypeId === null || p.appliesToEmploymentTypeId === candidate.employmentTypeId, ); const plan = applicablePlans.find((p) => p.appliesToEmploymentTypeId !== null) ?? applicablePlans.find((p) => p.appliesToEmploymentTypeId === null); if (!plan) { // NO_EFFECTIVE_ACCRUAL_PLAN — the worker is skipped and logged, not a fatal batch failure. skipped++; continue; } // Accrual-method dispatch: this batch implements the FRONT_LOAD_TENURE method (front-load at // the eligibility date + tenure-tier escalation on each anniversary). A plan using any other // method is not handled here and is skipped, so future methods can be added with their own // handler without changing this one. if (plan.accrualMethod !== "FRONT_LOAD_TENURE") { skipped++; continue; } // Only STATUTORY plans drive anniversary (HIRE/ANNIVERSARY-source) grants. A MANUAL/other // grantType plan must never be materialized here, or the resulting LeaveGrant would violate // the invariant "HIRE/ANNIVERSARY source ⇒ STATUTORY grantType" (C14). MANUAL grants are // issued only via the explicit grantLeave command. if (plan.grantType !== "STATUTORY") { skipped++; continue; } // First grant (tenure 0, at the eligibility date) is provenance HIRE; later grants are ANNIVERSARY. const grantSource = candidate.yearsOfService === 0 ? "HIRE" : "ANNIVERSARY"; const existing = await db .selectFrom("LeaveGrant") .select("id") .where("workerId", "=", candidate.workerId) .where("leaveTypeKey", "=", candidate.leaveTypeKey) .where("grantedDate", "=", candidate.grantDate) .where("grantSource", "=", grantSource) .forUpdate() .executeTakeFirst(); if (existing) { // DUPLICATE_GRANT_SKIPPED — idempotency skip, not a failure. skipped++; continue; } // Grant-condition gate (ADR-026 C; MIN_WORKED_DAYS per ADR-026 B1). const gc = plan.grantCondition; if (gc && gc.type === "MIN_WORKED_DAYS") { // GRANT_CONDITION_NOT_MET — worked days over the reference window (default 12 months // back from the grant date, clamped to the hire date so it never precedes employment; // for the tenure-0 HIRE grant this makes the window [hireDate, grantDate]) below the // plan's threshold → no grant this period (skip, not a failure); next period's // evaluation is independent. const refMonths = gc.referenceMonths ?? 12; const rawStart = addMonths(candidate.grantDate, -refMonths); const startDate = new Date(Math.max(rawStart.getTime(), candidate.hireDate.getTime())); // No holidayDates here: this gate is raw worked days (including holiday work). const res = await deps.aggregateWorkedDays( db, { assignmentIds: candidate.assignmentIds ?? [], startDate, endDate: candidate.grantDate, }, ctx, ); // Fail-open-to-grant: a transient query failure makes the gate not evaluable — the // grant PROCEEDS rather than being computed from bad data (err→0 would wrongly DENY // a statutory entitlement; over-granting on rare outages is the safer failure mode). if (res.ok && res.value.workedDays < (gc.minWorkedDays ?? 0)) { skipped++; continue; } } // gc.type === "NONE" or gc == null: unconditional grant (v1 behaviour). // The applicable tier's grantDays is the TOTAL entitlement at that tenure (e.g. 11 at 1y). // Below the first tier (incl. the tenure-0 HIRE grant) the amount is baseGrantDays. const tiers = [...plan.tenureTiers].sort((a, b) => a.yearsOfService - b.yearsOfService); const applicableTier = tiers .filter((tier) => tier.yearsOfService <= candidate.yearsOfService) .at(-1); const rawDays = applicableTier?.grantDays ?? Number(plan.baseGrantDays); // annualCapDays is data (null = uncapped), replacing the former hard-coded 20-day cap. const cap = plan.annualCapDays != null ? Number(plan.annualCapDays) : Number.POSITIVE_INFINITY; const proposedGrantDays = Math.min(rawDays, cap); let grantedDays = proposedGrantDays; if (deps.evaluateGrantEligibility) { const evaluation = await deps.evaluateGrantEligibility( db, { candidate, accrualPlan: plan as BaseAccrualPlan & CF, proposedGrantDays, }, ctx, ); if (evaluation.status === "INELIGIBLE") { skipped++; continue; } if (evaluation.status === "NOT_EVALUABLE") { failed++; continue; } if (evaluation.grantDays !== undefined) { if (!isValidOverriddenGrantDays(evaluation.grantDays, cap)) { failed++; continue; } grantedDays = evaluation.grantDays; } } await db .insertInto("LeaveGrant") .values({ workerId: candidate.workerId, leaveTypeKey: candidate.leaveTypeKey, // Guaranteed STATUTORY by the grantType guard above; HIRE/ANNIVERSARY source ⇒ STATUTORY. grantType: "STATUTORY", grantSource, grantedDays: String(grantedDays), remainingDays: String(grantedDays), grantedDate: candidate.grantDate, expirationDate: computeGrantExpirationDate(candidate.grantDate, plan.expirationMonths), expiredAt: null, }) .execute(); granted++; } catch { failed++; } } return ok({ granted, skipped, failed }); }