const crypto = require("node:crypto"); const path = require("node:path"); const { AppError } = require("../utils/AppError"); const { compactDateString } = require("../utils/date"); const { writeBuffer } = require("./storage"); const DEFAULT_ATTACHMENT_MIME_TYPES = [ "application/pdf", "image/jpeg", "image/png", "image/webp", ]; const SESSION_ORDER = { am: 0, pm: 1, }; function buildLeaveValidationError(details, statusCode = 422) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function normalizeDateOnly(value) { if (value instanceof Date) { return new Date(`${value.toISOString().slice(0, 10)}T00:00:00.000Z`); } if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) { return new Date(`${value}T00:00:00.000Z`); } throw buildLeaveValidationError({ date: "invalid" }, 400); } function formatDateOnly(value) { if (!value) { return null; } return normalizeDateOnly(value).toISOString().slice(0, 10); } function toHalfDayUnits(value) { const numeric = Number(value ?? 0); if (!Number.isFinite(numeric)) { return 0; } return Math.round(numeric * 2); } function fromHalfDayUnits(units) { return Number((units / 2).toFixed(1)); } function toHalfDaySlot(dateValue, session) { const date = normalizeDateOnly(dateValue); const sessionIndex = SESSION_ORDER[session]; if (sessionIndex == null) { throw buildLeaveValidationError({ session: "invalid" }, 400); } const dayIndex = Math.floor(date.getTime() / 86400000); return dayIndex * 2 + sessionIndex; } function calculateRequestedDays({ startDate, endDate, startSession, endSession }) { const startSlot = toHalfDaySlot(startDate, startSession); const endSlot = toHalfDaySlot(endDate, endSession); if (endSlot < startSlot) { throw buildLeaveValidationError({ leave: "invalid_range" }); } return fromHalfDayUnits(endSlot - startSlot + 1); } function isWeekendDate(dateValue) { const date = normalizeDateOnly(dateValue); const weekday = date.getUTCDay(); return weekday === 0 || weekday === 6; } function buildHolidayCalendarDayMap(calendarDays = []) { const map = new Map(); for (const item of calendarDays) { const key = formatDateOnly(item.date); if (key) { map.set(key, item.day_type ?? item.dayType ?? null); } } return map; } function isWorkingDateByCalendar(dateValue, calendarDayMap) { const dateKey = formatDateOnly(dateValue); const configuredType = dateKey ? calendarDayMap.get(dateKey) ?? null : null; if (configuredType === "holiday") { return false; } if (configuredType === "workday" || configuredType === "adjusted_workday") { return true; } return !isWeekendDate(dateValue); } function countHalfDayUnitsForDate({ date, startDate, endDate, startSession, endSession }) { const normalizedDate = formatDateOnly(date); const startDateOnly = formatDateOnly(startDate); const endDateOnly = formatDateOnly(endDate); if (normalizedDate === startDateOnly && normalizedDate === endDateOnly) { return toHalfDaySlot(endDate, endSession) - toHalfDaySlot(startDate, startSession) + 1; } if (normalizedDate === startDateOnly) { return startSession === "pm" ? 1 : 2; } if (normalizedDate === endDateOnly) { return endSession === "am" ? 1 : 2; } return 2; } function calculateRequestedDaysPreviewByWorkingCalendar({ startDate, endDate, startSession, endSession, calendarDays = [], allowNonWorkingRange = false, }) { const naturalRequestedDays = calculateRequestedDays({ startDate, endDate, startSession, endSession, }); const calendarDayMap = buildHolidayCalendarDayMap(calendarDays); const current = normalizeDateOnly(startDate); const finalDate = normalizeDateOnly(endDate); let cursor = current; let requestedUnits = 0; let holidayUnits = 0; let weekendUnits = 0; let adjustedWorkdayUnits = 0; while (cursor.getTime() <= finalDate.getTime()) { const units = countHalfDayUnitsForDate({ date: cursor, startDate, endDate, startSession, endSession, }); const dateKey = formatDateOnly(cursor); const configuredType = calendarDayMap.get(dateKey) ?? null; if (configuredType === "holiday") { holidayUnits += units; } else if (!isWorkingDateByCalendar(cursor, calendarDayMap)) { weekendUnits += units; } else { requestedUnits += units; if (configuredType === "adjusted_workday") { adjustedWorkdayUnits += units; } } cursor = new Date(`${formatDateOnly(cursor)}T00:00:00.000Z`); cursor.setUTCDate(cursor.getUTCDate() + 1); } if (requestedUnits <= 0) { if (!allowNonWorkingRange) { throw buildLeaveValidationError({ leave: "non_working_range" }); } return { requestedDays: 0, naturalRequestedDays, holidayDayCount: fromHalfDayUnits(holidayUnits), weekendDayCount: fromHalfDayUnits(weekendUnits), adjustedWorkdayCount: fromHalfDayUnits(adjustedWorkdayUnits), calendarApplied: true, isNonWorkingRange: true, }; } return { requestedDays: fromHalfDayUnits(requestedUnits), naturalRequestedDays, holidayDayCount: fromHalfDayUnits(holidayUnits), weekendDayCount: fromHalfDayUnits(weekendUnits), adjustedWorkdayCount: fromHalfDayUnits(adjustedWorkdayUnits), calendarApplied: true, isNonWorkingRange: false, }; } function calculateRequestedDaysByWorkingCalendar(input) { return calculateRequestedDaysPreviewByWorkingCalendar(input).requestedDays; } function leaveRangesOverlap(left, right) { const leftStart = toHalfDaySlot(left.startDate, left.startSession); const leftEnd = toHalfDaySlot(left.endDate, left.endSession); const rightStart = toHalfDaySlot(right.startDate, right.startSession); const rightEnd = toHalfDaySlot(right.endDate, right.endSession); return leftStart <= rightEnd && rightStart <= leftEnd; } function normalizeAttachmentMimeTypes(value) { if (!Array.isArray(value)) { return [...DEFAULT_ATTACHMENT_MIME_TYPES]; } const items = value .map((item) => (typeof item === "string" ? item.trim() : "")) .filter(Boolean); return items.length ? items : [...DEFAULT_ATTACHMENT_MIME_TYPES]; } function summarizeLeaveLedgerEntries(entries) { const totals = { grant: 0, reserve: 0, consume: 0, release: 0, adjust: 0, }; for (const entry of entries ?? []) { if (!entry?.entry_type || !(entry.entry_type in totals)) { continue; } totals[entry.entry_type] += toHalfDayUnits(entry.days); } const reservedUnits = Math.max(0, totals.reserve - totals.release); const availableUnits = totals.grant + totals.adjust - totals.reserve + totals.release - totals.consume; return { granted: fromHalfDayUnits(totals.grant), adjusted: fromHalfDayUnits(totals.adjust), reserved: fromHalfDayUnits(reservedUnits), used: fromHalfDayUnits(totals.consume), released: fromHalfDayUnits(totals.release), available: fromHalfDayUnits(availableUnits), }; } function currentLeavePeriod(referenceDate = new Date()) { const date = normalizeDateOnly(referenceDate); const year = date.getUTCFullYear(); return { periodStart: new Date(`${year}-01-01T00:00:00.000Z`), periodEnd: new Date(`${year}-12-31T00:00:00.000Z`), }; } function mapLeaveTypeResponse(leaveType) { const policy = leaveType.leave_policy ?? null; return { id: leaveType.id, code: leaveType.code, name: leaveType.name, description: leaveType.description ?? null, enabled: Boolean(leaveType.enabled), requiresAttachment: Boolean(leaveType.requires_attachment), attachmentMimeTypes: normalizeAttachmentMimeTypes(leaveType.attachment_mime_types), sortOrder: leaveType.sort_order, policy: policy ? { id: policy.id, annualQuotaDays: Number(policy.annual_quota_days), enabled: Boolean(policy.enabled), description: policy.description ?? null, } : null, createdAt: leaveType.created_at?.toISOString?.() ?? null, updatedAt: leaveType.updated_at?.toISOString?.() ?? null, }; } async function lockEmployeeForLeave(tx, employeeId) { await tx.$queryRaw` SELECT id FROM "employees" WHERE id = ${employeeId}::uuid FOR UPDATE `; } async function ensureAnnualLeaveGrant(tx, { employeeId, leaveType, actorId, referenceDate = new Date() }) { const policy = leaveType?.leave_policy; if (!leaveType || !leaveType.enabled || !policy || !policy.enabled) { throw buildLeaveValidationError({ leaveTypeId: "missing_active_policy" }, 409); } const { periodStart, periodEnd } = currentLeavePeriod(referenceDate); const existingGrant = await tx.leaveLedger.findFirst({ where: { employee_id: employeeId, leave_type_id: leaveType.id, entry_type: "grant", period_start: periodStart, period_end: periodEnd, }, select: { id: true }, }); if (!existingGrant) { await tx.leaveLedger.create({ data: { employee_id: employeeId, leave_type_id: leaveType.id, leave_policy_id: policy.id, entry_type: "grant", days: policy.annual_quota_days, period_start: periodStart, period_end: periodEnd, note: `auto_grant_${periodStart.getUTCFullYear()}`, payload: { source: "leave_policy", annual_quota_days: Number(policy.annual_quota_days), }, created_by: actorId ?? null, }, }); } return { policy, periodStart, periodEnd }; } async function loadLeaveBalanceSnapshot(tx, { employeeId, leaveType, actorId, referenceDate = new Date() }) { const { policy, periodStart, periodEnd } = await ensureAnnualLeaveGrant(tx, { employeeId, leaveType, actorId, referenceDate, }); const entries = await tx.leaveLedger.findMany({ where: { employee_id: employeeId, leave_type_id: leaveType.id, period_start: periodStart, period_end: periodEnd, }, orderBy: { created_at: "asc" }, select: { id: true, entry_type: true, days: true, leave_request_id: true, created_at: true, }, }); return { policy, periodStart, periodEnd, entries, summary: summarizeLeaveLedgerEntries(entries), }; } async function loadLeaveBalanceItems(tx, { employeeId, actorId, referenceDate = new Date(), includeDisabled = false }) { const leaveTypes = await tx.leaveType.findMany({ where: includeDisabled ? undefined : { enabled: true }, orderBy: [{ sort_order: "asc" }, { code: "asc" }], select: { id: true, code: true, name: true, description: true, enabled: true, requires_attachment: true, attachment_mime_types: true, sort_order: true, created_at: true, updated_at: true, leave_policy: { select: { id: true, annual_quota_days: true, enabled: true, description: true, }, }, }, }); const items = []; for (const leaveType of leaveTypes) { if (!leaveType.leave_policy || !leaveType.leave_policy.enabled) { continue; } const snapshot = await loadLeaveBalanceSnapshot(tx, { employeeId, leaveType, actorId, referenceDate, }); items.push({ leaveType: mapLeaveTypeResponse(leaveType), policy: { id: snapshot.policy.id, annualQuotaDays: Number(snapshot.policy.annual_quota_days), enabled: Boolean(snapshot.policy.enabled), description: snapshot.policy.description ?? null, }, granted: snapshot.summary.granted, adjusted: snapshot.summary.adjusted, reserved: snapshot.summary.reserved, used: snapshot.summary.used, available: snapshot.summary.available, periodStart: formatDateOnly(snapshot.periodStart), periodEnd: formatDateOnly(snapshot.periodEnd), }); } return items; } async function createLeaveLedgerEntry(tx, data) { return tx.leaveLedger.create({ data: { employee_id: data.employeeId, leave_type_id: data.leaveTypeId, leave_policy_id: data.leavePolicyId ?? null, leave_request_id: data.leaveRequestId ?? null, entry_type: data.entryType, days: data.days, period_start: data.periodStart ?? null, period_end: data.periodEnd ?? null, note: data.note ?? null, payload: data.payload ?? null, created_by: data.createdBy ?? null, }, }); } async function loadLeaveLedgerSummaryForRequest(tx, leaveRequestId) { const entries = await tx.leaveLedger.findMany({ where: { leave_request_id: leaveRequestId }, orderBy: { created_at: "asc" }, select: { id: true, entry_type: true, days: true, period_start: true, period_end: true, leave_policy_id: true, }, }); return { entries, summary: summarizeLeaveLedgerEntries(entries), }; } async function releaseReservedLeave(tx, { leaveRequestId, employeeId, leaveTypeId, leavePolicyId, periodStart, periodEnd, note, payload, createdBy, }) { const ledger = await loadLeaveLedgerSummaryForRequest(tx, leaveRequestId); const reservedUnits = toHalfDayUnits(ledger.summary.reserved); if (reservedUnits <= 0) { return null; } return createLeaveLedgerEntry(tx, { employeeId, leaveTypeId, leavePolicyId, leaveRequestId, entryType: "release", days: fromHalfDayUnits(reservedUnits), periodStart, periodEnd, note, payload, createdBy, }); } async function consumeReservedLeave(tx, { leaveRequestId, employeeId, leaveTypeId, leavePolicyId, periodStart, periodEnd, requestedDays, createdBy, }) { const ledger = await loadLeaveLedgerSummaryForRequest(tx, leaveRequestId); const reservedUnits = toHalfDayUnits(ledger.summary.reserved); const requestedUnits = toHalfDayUnits(requestedDays); if (reservedUnits < requestedUnits || requestedUnits <= 0) { throw buildLeaveValidationError({ leave: "missing_reserve_ledger" }, 409); } await createLeaveLedgerEntry(tx, { employeeId, leaveTypeId, leavePolicyId, leaveRequestId, entryType: "release", days: fromHalfDayUnits(requestedUnits), periodStart, periodEnd, note: "approved_release_reserve", payload: { source: "workflow_approve" }, createdBy, }); await createLeaveLedgerEntry(tx, { employeeId, leaveTypeId, leavePolicyId, leaveRequestId, entryType: "consume", days: fromHalfDayUnits(requestedUnits), periodStart, periodEnd, note: "approved_consume_leave", payload: { source: "workflow_approve" }, createdBy, }); } async function persistLeaveAttachment({ config, employeeId, filePart }) { const chunks = []; let fileSize = 0; for await (const chunk of filePart.file) { fileSize += chunk.length; if (fileSize > config.maxUploadSizeBytes) { throw buildLeaveValidationError({ file: "file_too_large" }, 400); } chunks.push(chunk); } if (fileSize <= 0) { throw buildLeaveValidationError({ file: "empty" }, 400); } const buffer = Buffer.concat(chunks); const safeName = (filePart.filename || "attachment") .replace(/[^a-zA-Z0-9._-]+/g, "_") .replace(/^_+|_+$/g, "") || "attachment"; const extension = path.extname(safeName); const basename = extension ? safeName.slice(0, -extension.length) : safeName; const key = path.posix.join( "leave-attachments", compactDateString(config.appTimezone), employeeId, `${basename}-${crypto.randomUUID()}${extension}`, ); const storagePath = await writeBuffer({ config, key, buffer, contentType: filePart.mimetype, }); return { storagePath, originalName: filePart.filename || safeName, mimeType: filePart.mimetype, fileSize, }; } module.exports = { DEFAULT_ATTACHMENT_MIME_TYPES, calculateRequestedDays, calculateRequestedDaysByWorkingCalendar, calculateRequestedDaysPreviewByWorkingCalendar, createLeaveLedgerEntry, consumeReservedLeave, currentLeavePeriod, formatDateOnly, leaveRangesOverlap, loadLeaveBalanceItems, loadLeaveBalanceSnapshot, loadLeaveLedgerSummaryForRequest, lockEmployeeForLeave, mapLeaveTypeResponse, normalizeAttachmentMimeTypes, persistLeaveAttachment, releaseReservedLeave, summarizeLeaveLedgerEntries, toHalfDayUnits, };