import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { MissingRequiredFieldError, ShiftPatternCodeAlreadyExistsError, InvalidKindError, SegmentRequiredError, SegmentKindMismatchError, SegmentTimeInvalidError, SegmentBreakInvalidError, SegmentGapInvalidError, } from "../lib/errors.generated"; const SHIFT_PATTERN_KINDS = ["DAY", "EARLY", "LATE", "NIGHT", "ON_CALL", "OFF"] as const; type ShiftPatternKind = (typeof SHIFT_PATTERN_KINDS)[number]; export interface CreateShiftPatternSegmentInput { startTime: number; endTime: number; breakMinutes: number; } export interface CreateShiftPatternInput { code: string; name: string; kind: string; // Optional: scopes this pattern's code uniqueness to a single organization Site. // null / omitted = tenant-shared pattern. scopeId?: string | null; segments: CreateShiftPatternSegmentInput[]; } /** * Function: createShiftPattern * Description: Defines a reusable シフトパターン with a stable code (unique within its * optional scope), display name, kind, and — except for OFF, which carries none — one or more * ordered embedded segments, so planners can compose shifts from a named template instead of * re-entering times on every shift. */ export async function run>( db: Transaction, input: CreateShiftPatternInput & CF, _ctx: CommandContext, ) { const { code, name, kind: kindInput, scopeId: scopeIdInput, segments: segmentsInput, ...customFields } = input; if (!code || !name || !kindInput) { return err(new MissingRequiredFieldError(!code ? "code" : !name ? "name" : "kind")); } const scopeId = scopeIdInput ?? null; let existingQuery = db.selectFrom("ShiftPattern").selectAll().where("code", "=", code); existingQuery = scopeId === null ? existingQuery.where("scopeId", "is", null) : existingQuery.where("scopeId", "=", scopeId); const existing = await existingQuery.forUpdate().executeTakeFirst(); if (existing) { return err(new ShiftPatternCodeAlreadyExistsError(`${scopeId ?? "shared"}:${code}`)); } if (!(SHIFT_PATTERN_KINDS as readonly string[]).includes(kindInput)) { return err(new InvalidKindError(kindInput)); } const kind = kindInput as ShiftPatternKind; if (kind === "OFF") { if (segmentsInput && segmentsInput.length > 0) { return err(new SegmentKindMismatchError(code)); } } else if (!segmentsInput || segmentsInput.length === 0) { return err(new SegmentRequiredError(code)); } const segments: { startTime: number; endTime: number; breakMinutes: number; spansMidnight: boolean; sequence: number; }[] = []; for (let index = 0; index < (segmentsInput?.length ?? 0); index++) { const { startTime, endTime, breakMinutes } = segmentsInput[index]; if (startTime < 0 || startTime >= 1440 || endTime < 0 || endTime >= 1440) { return err(new SegmentTimeInvalidError(`segment[${index}]`)); } const spansMidnight = endTime <= startTime; const grossSpan = spansMidnight ? 1440 - startTime + endTime : endTime - startTime; if (breakMinutes < 0 || breakMinutes >= grossSpan) { return err(new SegmentBreakInvalidError(`segment[${index}]`)); } segments.push({ startTime, endTime, breakMinutes, spansMidnight, sequence: index + 1 }); } // Walk the segments on a linear (non-wrapping) timeline: each time a segment // spans midnight, subsequent clock readings are offset by one full day so that // "next.startTime - current.endTime" is comparable even across a midnight crossing. let dayOffset = 0; for (let i = 0; i < segments.length - 1; i++) { const current = segments[i]; const next = segments[i + 1]; const currentEffectiveEnd = current.endTime + dayOffset * 1440 + (current.spansMidnight ? 1440 : 0); if (current.spansMidnight) { dayOffset += 1; } const nextEffectiveStart = next.startTime + dayOffset * 1440; const gap = nextEffectiveStart - currentEffectiveEnd; if (gap <= 0) { return err(new SegmentGapInvalidError(`segment[${i}]-segment[${i + 1}]`)); } } const shiftPattern = await db .insertInto("ShiftPattern") .values({ ...(customFields as Record), code, name, kind, scopeId, // Embedded segments (ADR-022); empty when kind = OFF segments: segments.map((segment) => ({ sequence: segment.sequence, startTime: segment.startTime, endTime: segment.endTime, spansMidnight: segment.spansMidnight, breakMinutes: segment.breakMinutes, })), }) .returningAll() .executeTakeFirstOrThrow(); return ok({ shiftPattern }); }