632 行
16 KiB
JavaScript
632 行
16 KiB
JavaScript
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,
|
|
};
|