import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { validateNightWindow, validateRoundingDirections, validateTimeEntryCodeReferences, type WorkRulePremiumRatePercentInput, } from "../lib/_workRuleValidation"; import { EffectiveDateOverlapError, WorkRuleNotFoundError } from "../lib/errors.generated"; export type UpdateWorkRuleInput = { id: string; /** Date the revision takes effect; becomes the new generation's effectiveStart. */ effectiveStart: Date; displayName?: string; /** Optional HolidayCalendar this rule uses for holiday classification; null clears it. */ holidayCalendarId?: string | null; clockInRoundingUnitMinutes?: number; clockInRoundingDirection?: string; clockOutRoundingUnitMinutes?: number; clockOutRoundingDirection?: string; breakRoundingUnitMinutes?: number; breakRoundingDirection?: string; breakDeductionMinutes?: number; dailyOvertimeThresholdMinutes?: number; nightWindowStart?: number; nightWindowEnd?: number; premiumRatePercent?: WorkRulePremiumRatePercentInput[]; }; /** * Function: updateWorkRule * Description: Records a parameter change — such as a revised premium-category pin * or overtime threshold — as a new effective-dated WorkRule generation, * closing the current generation and inserting a new one sharing versionOf. */ export async function run(db: Transaction, input: UpdateWorkRuleInput, _ctx: CommandContext) { const current = await db .selectFrom("WorkRule") .selectAll() .where("id", "=", input.id) .forUpdate() .executeTakeFirst(); if (!current) { return err(new WorkRuleNotFoundError(input.id)); } const revised = { displayName: input.displayName ?? current.displayName, holidayCalendarId: input.holidayCalendarId !== undefined ? input.holidayCalendarId : current.holidayCalendarId, clockInRoundingUnitMinutes: input.clockInRoundingUnitMinutes ?? current.clockInRoundingUnitMinutes, clockInRoundingDirection: input.clockInRoundingDirection ?? current.clockInRoundingDirection, clockOutRoundingUnitMinutes: input.clockOutRoundingUnitMinutes ?? current.clockOutRoundingUnitMinutes, clockOutRoundingDirection: input.clockOutRoundingDirection ?? current.clockOutRoundingDirection, breakRoundingUnitMinutes: input.breakRoundingUnitMinutes ?? current.breakRoundingUnitMinutes, breakRoundingDirection: input.breakRoundingDirection ?? current.breakRoundingDirection, breakDeductionMinutes: input.breakDeductionMinutes ?? current.breakDeductionMinutes, dailyOvertimeThresholdMinutes: input.dailyOvertimeThresholdMinutes ?? current.dailyOvertimeThresholdMinutes, nightWindowStart: input.nightWindowStart ?? current.nightWindowStart, nightWindowEnd: input.nightWindowEnd ?? current.nightWindowEnd, premiumRatePercent: input.premiumRatePercent ?? current.premiumRatePercent, }; const roundingDirectionError = validateRoundingDirections(revised); if (roundingDirectionError) { return err(roundingDirectionError); } const nightWindowError = validateNightWindow(revised); if (nightWindowError) { return err(nightWindowError); } const timeEntryCodeReferenceError = await validateTimeEntryCodeReferences( db, revised.premiumRatePercent, ); if (timeEntryCodeReferenceError) { return err(timeEntryCodeReferenceError); } if (input.effectiveStart.getTime() <= current.effectiveStart.getTime()) { return err(new EffectiveDateOverlapError(input.id)); } const previousGenerationEnd = new Date(input.effectiveStart); previousGenerationEnd.setUTCDate(previousGenerationEnd.getUTCDate() - 1); await db .updateTable("WorkRule") .set({ effectiveEnd: previousGenerationEnd }) .where("id", "=", current.id) .execute(); const nextGenerationId = crypto.randomUUID(); const workRule = await db .insertInto("WorkRule") .values({ id: nextGenerationId, displayName: revised.displayName, holidayCalendarId: revised.holidayCalendarId, clockInRoundingUnitMinutes: revised.clockInRoundingUnitMinutes, clockInRoundingDirection: revised.clockInRoundingDirection as "UP" | "DOWN" | "NEAREST", clockOutRoundingUnitMinutes: revised.clockOutRoundingUnitMinutes, clockOutRoundingDirection: revised.clockOutRoundingDirection as "UP" | "DOWN" | "NEAREST", breakRoundingUnitMinutes: revised.breakRoundingUnitMinutes, breakRoundingDirection: revised.breakRoundingDirection as "UP" | "DOWN" | "NEAREST", breakDeductionMinutes: revised.breakDeductionMinutes, dailyOvertimeThresholdMinutes: revised.dailyOvertimeThresholdMinutes, nightWindowStart: revised.nightWindowStart, nightWindowEnd: revised.nightWindowEnd, premiumRatePercent: revised.premiumRatePercent, effectiveStart: input.effectiveStart, effectiveEnd: null, versionOf: current.versionOf, }) .returningAll() .executeTakeFirstOrThrow(); return ok({ workRule }); }