- 新增 AttendanceRecord 与 AttendancePolicy 模型 - 上下班打卡、今日状态、月度统计端点 - 考勤管理页面(我的/团队/设置三 Tab) - 移动端快速打卡入口 - 管理员补卡与审计日志 - 单测覆盖打卡幂等、迟到计算、工时计算、权限边界
847 行
24 KiB
JavaScript
847 行
24 KiB
JavaScript
const { AppError } = require("../utils/AppError");
|
|
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
|
|
|
const ATTENDANCE_STATUS_VALUES = [
|
|
"normal",
|
|
"late",
|
|
"early_leave",
|
|
"late_early_leave",
|
|
"pending_clock_out",
|
|
"missed",
|
|
"leave",
|
|
"travel",
|
|
"holiday",
|
|
"weekend",
|
|
];
|
|
|
|
const EXEMPTION_REASON_VALUES = ["leave", "travel", "holiday", "weekend", "policy_disabled"];
|
|
const ATTENDANCE_SOURCE_VALUES = ["web", "mobile", "admin"];
|
|
const TIME_VALUE_PATTERN = /^\d{2}:\d{2}$/;
|
|
|
|
const DEFAULT_ATTENDANCE_POLICY = Object.freeze({
|
|
workStartTime: "09:00",
|
|
workEndTime: "18:00",
|
|
lunchStartTime: "12:00",
|
|
lunchEndTime: "13:00",
|
|
lateToleranceMinutes: 0,
|
|
enabled: true,
|
|
});
|
|
|
|
function buildAttendanceValidationError(details, statusCode = 400) {
|
|
return new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode,
|
|
details,
|
|
});
|
|
}
|
|
|
|
function formatDateOnly(value) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
return new Date(`${value.toISOString().slice(0, 10)}T00:00:00.000Z`).toISOString().slice(0, 10);
|
|
}
|
|
|
|
function addUtcDays(date, days) {
|
|
const next = new Date(date.getTime());
|
|
next.setUTCDate(next.getUTCDate() + days);
|
|
return next;
|
|
}
|
|
|
|
function buildDateStrings(dateFrom, dateTo) {
|
|
const items = [];
|
|
for (let current = new Date(dateFrom.getTime()); current <= dateTo; current = addUtcDays(current, 1)) {
|
|
items.push(formatDateOnly(current));
|
|
}
|
|
return items;
|
|
}
|
|
|
|
function createZonedFormatter(timeZone) {
|
|
return new Intl.DateTimeFormat("en-CA", {
|
|
timeZone,
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hourCycle: "h23",
|
|
});
|
|
}
|
|
|
|
function getZonedParts(date, timeZone) {
|
|
const formatter = createZonedFormatter(timeZone ?? "Asia/Shanghai");
|
|
const parts = formatter.formatToParts(date);
|
|
const values = {};
|
|
for (const part of parts) {
|
|
if (part.type !== "literal") {
|
|
values[part.type] = part.value;
|
|
}
|
|
}
|
|
|
|
return {
|
|
year: Number(values.year),
|
|
month: Number(values.month),
|
|
day: Number(values.day),
|
|
hour: Number(values.hour),
|
|
minute: Number(values.minute),
|
|
second: Number(values.second),
|
|
dateString: `${values.year}-${values.month}-${values.day}`,
|
|
timeString: `${values.hour}:${values.minute}`,
|
|
};
|
|
}
|
|
|
|
function getZonedMinutes(date, timeZone) {
|
|
const parts = getZonedParts(date, timeZone);
|
|
return parts.hour * 60 + parts.minute;
|
|
}
|
|
|
|
function parseTimeToMinutes(value, fieldName = "time") {
|
|
const input = String(value ?? "").trim();
|
|
if (!TIME_VALUE_PATTERN.test(input)) {
|
|
throw buildAttendanceValidationError({ [fieldName]: "invalid_time" });
|
|
}
|
|
|
|
const [hourValue, minuteValue] = input.split(":").map(Number);
|
|
if (
|
|
!Number.isInteger(hourValue) ||
|
|
!Number.isInteger(minuteValue) ||
|
|
hourValue < 0 ||
|
|
hourValue > 23 ||
|
|
minuteValue < 0 ||
|
|
minuteValue > 59
|
|
) {
|
|
throw buildAttendanceValidationError({ [fieldName]: "invalid_time" });
|
|
}
|
|
|
|
return hourValue * 60 + minuteValue;
|
|
}
|
|
|
|
function getTimeZoneOffsetMs(date, timeZone) {
|
|
const parts = getZonedParts(date, timeZone);
|
|
const zonedAsUtc = Date.UTC(
|
|
parts.year,
|
|
parts.month - 1,
|
|
parts.day,
|
|
parts.hour,
|
|
parts.minute,
|
|
parts.second,
|
|
);
|
|
return zonedAsUtc - date.getTime();
|
|
}
|
|
|
|
function zonedDateTimeToUtc(dateString, timeString, timeZone) {
|
|
const [year, month, day] = String(dateString).split("-").map(Number);
|
|
const [hour, minute] = String(timeString).split(":").map(Number);
|
|
let date = new Date(Date.UTC(year, month - 1, day, hour, minute, 0, 0));
|
|
|
|
for (let index = 0; index < 2; index += 1) {
|
|
const offset = getTimeZoneOffsetMs(date, timeZone);
|
|
date = new Date(Date.UTC(year, month - 1, day, hour, minute, 0, 0) - offset);
|
|
}
|
|
|
|
return date;
|
|
}
|
|
|
|
function isWeekendDate(dateValue) {
|
|
const date = parseDateOnlyString(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(dateString, calendarDayMap) {
|
|
const configuredType = calendarDayMap.get(dateString) ?? null;
|
|
if (configuredType === "holiday") {
|
|
return false;
|
|
}
|
|
if (configuredType === "workday" || configuredType === "adjusted_workday") {
|
|
return true;
|
|
}
|
|
return !isWeekendDate(dateString);
|
|
}
|
|
|
|
function detectMobileUserAgent(userAgent) {
|
|
const source = String(userAgent ?? "");
|
|
return /Android|iPhone|iPad|Mobile|HarmonyOS|MiuiBrowser/i.test(source);
|
|
}
|
|
|
|
function detectAttendanceSource(request) {
|
|
if (detectMobileUserAgent(request.headers?.["user-agent"])) {
|
|
return "mobile";
|
|
}
|
|
return "web";
|
|
}
|
|
|
|
function normalizeAttendancePolicy(record) {
|
|
if (!record) {
|
|
return {
|
|
id: null,
|
|
...DEFAULT_ATTENDANCE_POLICY,
|
|
createdAt: null,
|
|
updatedAt: null,
|
|
updatedBy: null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
id: record.id,
|
|
workStartTime: record.work_start_time,
|
|
workEndTime: record.work_end_time,
|
|
lunchStartTime: record.lunch_start_time,
|
|
lunchEndTime: record.lunch_end_time,
|
|
lateToleranceMinutes: Number(record.late_tolerance_minutes ?? 0),
|
|
enabled: Boolean(record.enabled),
|
|
createdAt: record.created_at?.toISOString?.() ?? null,
|
|
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
|
updatedBy: record.updated_by ?? null,
|
|
};
|
|
}
|
|
|
|
async function getAttendancePolicy(prisma) {
|
|
const record = await prisma.attendancePolicy.findFirst({
|
|
orderBy: { created_at: "asc" },
|
|
});
|
|
return normalizeAttendancePolicy(record);
|
|
}
|
|
|
|
function validateAttendancePolicyInput(input) {
|
|
const workStartMinutes = parseTimeToMinutes(input.workStartTime, "workStartTime");
|
|
const workEndMinutes = parseTimeToMinutes(input.workEndTime, "workEndTime");
|
|
const lunchStartMinutes = parseTimeToMinutes(input.lunchStartTime, "lunchStartTime");
|
|
const lunchEndMinutes = parseTimeToMinutes(input.lunchEndTime, "lunchEndTime");
|
|
const lateToleranceMinutes = Number(input.lateToleranceMinutes ?? 0);
|
|
|
|
if (!Number.isInteger(lateToleranceMinutes) || lateToleranceMinutes < 0 || lateToleranceMinutes > 600) {
|
|
throw buildAttendanceValidationError({ lateToleranceMinutes: "invalid" });
|
|
}
|
|
|
|
if (workStartMinutes >= workEndMinutes) {
|
|
throw buildAttendanceValidationError({ workTime: "invalid_range" });
|
|
}
|
|
|
|
if (lunchStartMinutes >= lunchEndMinutes) {
|
|
throw buildAttendanceValidationError({ lunchTime: "invalid_range" });
|
|
}
|
|
|
|
if (lunchStartMinutes < workStartMinutes || lunchEndMinutes > workEndMinutes) {
|
|
throw buildAttendanceValidationError({ lunchTime: "outside_work_range" });
|
|
}
|
|
|
|
return {
|
|
workStartTime: input.workStartTime,
|
|
workEndTime: input.workEndTime,
|
|
lunchStartTime: input.lunchStartTime,
|
|
lunchEndTime: input.lunchEndTime,
|
|
lateToleranceMinutes,
|
|
enabled: input.enabled !== undefined ? Boolean(input.enabled) : true,
|
|
};
|
|
}
|
|
|
|
async function upsertAttendancePolicy(prisma, actorId, input) {
|
|
const policy = validateAttendancePolicyInput(input);
|
|
const current = await prisma.attendancePolicy.findFirst({
|
|
orderBy: { created_at: "asc" },
|
|
select: { id: true },
|
|
});
|
|
|
|
const data = {
|
|
work_start_time: policy.workStartTime,
|
|
work_end_time: policy.workEndTime,
|
|
lunch_start_time: policy.lunchStartTime,
|
|
lunch_end_time: policy.lunchEndTime,
|
|
late_tolerance_minutes: policy.lateToleranceMinutes,
|
|
enabled: policy.enabled,
|
|
updated_by: actorId ?? null,
|
|
};
|
|
|
|
const record = current
|
|
? await prisma.attendancePolicy.update({
|
|
where: { id: current.id },
|
|
data,
|
|
})
|
|
: await prisma.attendancePolicy.create({
|
|
data,
|
|
});
|
|
|
|
return normalizeAttendancePolicy(record);
|
|
}
|
|
|
|
function mapAttendanceRecord(record) {
|
|
if (!record) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: record.id,
|
|
employeeId: record.employee_id,
|
|
workDate: formatDateOnly(record.work_date),
|
|
clockInAt: record.clock_in_at?.toISOString?.() ?? null,
|
|
clockOutAt: record.clock_out_at?.toISOString?.() ?? null,
|
|
workMinutes: record.work_minutes ?? null,
|
|
isLate: Boolean(record.is_late),
|
|
lateMinutes: Number(record.late_minutes ?? 0),
|
|
isEarlyLeave: Boolean(record.is_early_leave),
|
|
earlyMinutes: Number(record.early_minutes ?? 0),
|
|
note: record.note ?? null,
|
|
source: record.source ?? "web",
|
|
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
|
};
|
|
}
|
|
|
|
function buildEmptyAttendanceRecord(employeeId, workDate) {
|
|
return {
|
|
id: null,
|
|
employeeId,
|
|
workDate,
|
|
clockInAt: null,
|
|
clockOutAt: null,
|
|
workMinutes: null,
|
|
isLate: false,
|
|
lateMinutes: 0,
|
|
isEarlyLeave: false,
|
|
earlyMinutes: 0,
|
|
note: null,
|
|
source: null,
|
|
updatedAt: null,
|
|
};
|
|
}
|
|
|
|
async function loadAttendanceRecord(prisma, employeeId, workDate) {
|
|
return prisma.attendanceRecord.findFirst({
|
|
where: {
|
|
employee_id: employeeId,
|
|
work_date: parseDateOnlyString(workDate),
|
|
},
|
|
});
|
|
}
|
|
|
|
function buildHistoryKey(employeeId, dateString) {
|
|
return `${employeeId}:${dateString}`;
|
|
}
|
|
|
|
function buildExemptionMapForEmployees({
|
|
employees,
|
|
dateStrings,
|
|
leaveRows,
|
|
travelRows,
|
|
holidayCalendarByLegalEntity,
|
|
policyEnabled = true,
|
|
}) {
|
|
const result = new Map();
|
|
|
|
for (const employee of employees) {
|
|
const map = new Map();
|
|
result.set(employee.id, map);
|
|
|
|
if (!policyEnabled) {
|
|
for (const dateString of dateStrings) {
|
|
map.set(dateString, {
|
|
needClockIn: false,
|
|
exemptionReason: "policy_disabled",
|
|
note: "attendance_policy_disabled",
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const calendarDayMap = holidayCalendarByLegalEntity.get(employee.legal_entity_id ?? "") ?? new Map();
|
|
for (const dateString of dateStrings) {
|
|
if (isWorkingDateByCalendar(dateString, calendarDayMap)) {
|
|
continue;
|
|
}
|
|
|
|
const configuredType = calendarDayMap.get(dateString) ?? null;
|
|
map.set(dateString, {
|
|
needClockIn: false,
|
|
exemptionReason: configuredType === "holiday" ? "holiday" : "weekend",
|
|
note: null,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const row of travelRows) {
|
|
const startDate = formatDateOnly(row.start_date);
|
|
const endDate = formatDateOnly(row.end_date);
|
|
const employeeMap = result.get(row.applicant_employee_id);
|
|
if (!employeeMap) {
|
|
continue;
|
|
}
|
|
for (const dateString of dateStrings) {
|
|
if (dateString < startDate || dateString > endDate) {
|
|
continue;
|
|
}
|
|
employeeMap.set(dateString, {
|
|
needClockIn: false,
|
|
exemptionReason: "travel",
|
|
note: row.purpose ?? null,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const row of leaveRows) {
|
|
const startDate = formatDateOnly(row.start_date);
|
|
const endDate = formatDateOnly(row.end_date);
|
|
const employeeMap = result.get(row.owner_id);
|
|
if (!employeeMap) {
|
|
continue;
|
|
}
|
|
for (const dateString of dateStrings) {
|
|
if (dateString < startDate || dateString > endDate) {
|
|
continue;
|
|
}
|
|
employeeMap.set(dateString, {
|
|
needClockIn: false,
|
|
exemptionReason: "leave",
|
|
note: row.reason ?? null,
|
|
});
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function loadAttendanceExemptions(prisma, { employees, dateStrings, policyEnabled = true }) {
|
|
if (!employees.length || !dateStrings.length) {
|
|
return new Map();
|
|
}
|
|
|
|
const dateFrom = dateStrings[0];
|
|
const dateTo = dateStrings[dateStrings.length - 1];
|
|
const employeeIds = employees.map((item) => item.id);
|
|
const legalEntityIds = Array.from(
|
|
new Set(
|
|
employees
|
|
.map((item) => item.legal_entity_id ?? null)
|
|
.filter(Boolean),
|
|
),
|
|
);
|
|
|
|
const [leaveRows, travelRows, legalEntities] = await Promise.all([
|
|
prisma.leaveRequest.findMany({
|
|
where: {
|
|
owner_id: { in: employeeIds },
|
|
status: "approved",
|
|
start_date: { lte: parseDateOnlyString(dateTo) },
|
|
end_date: { gte: parseDateOnlyString(dateFrom) },
|
|
},
|
|
select: {
|
|
owner_id: true,
|
|
start_date: true,
|
|
end_date: true,
|
|
reason: true,
|
|
},
|
|
}),
|
|
prisma.travelRequest.findMany({
|
|
where: {
|
|
applicant_employee_id: { in: employeeIds },
|
|
status: "approved",
|
|
start_date: { lte: parseDateOnlyString(dateTo) },
|
|
end_date: { gte: parseDateOnlyString(dateFrom) },
|
|
},
|
|
select: {
|
|
applicant_employee_id: true,
|
|
start_date: true,
|
|
end_date: true,
|
|
purpose: true,
|
|
},
|
|
}),
|
|
legalEntityIds.length
|
|
? prisma.legalEntity.findMany({
|
|
where: { id: { in: legalEntityIds } },
|
|
select: {
|
|
id: true,
|
|
default_holiday_calendar: {
|
|
select: {
|
|
id: true,
|
|
archived_at: true,
|
|
days: {
|
|
where: {
|
|
date: {
|
|
gte: parseDateOnlyString(dateFrom),
|
|
lte: parseDateOnlyString(dateTo),
|
|
},
|
|
},
|
|
select: {
|
|
date: true,
|
|
day_type: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
: Promise.resolve([]),
|
|
]);
|
|
|
|
const holidayCalendarByLegalEntity = new Map();
|
|
for (const item of legalEntities) {
|
|
const calendar = item.default_holiday_calendar;
|
|
holidayCalendarByLegalEntity.set(
|
|
item.id,
|
|
calendar && !calendar.archived_at ? buildHolidayCalendarDayMap(calendar.days) : new Map(),
|
|
);
|
|
}
|
|
|
|
return buildExemptionMapForEmployees({
|
|
employees,
|
|
dateStrings,
|
|
leaveRows,
|
|
travelRows,
|
|
holidayCalendarByLegalEntity,
|
|
policyEnabled,
|
|
});
|
|
}
|
|
|
|
function getExemptionForDate(exemptionMap, employeeId, dateString) {
|
|
return exemptionMap.get(employeeId)?.get(dateString) ?? null;
|
|
}
|
|
|
|
function resolveAttendanceStatus(record, exemption) {
|
|
if (exemption && !exemption.needClockIn) {
|
|
if (exemption.exemptionReason === "leave") return "leave";
|
|
if (exemption.exemptionReason === "travel") return "travel";
|
|
if (exemption.exemptionReason === "holiday") return "holiday";
|
|
return "weekend";
|
|
}
|
|
|
|
if (!record?.clock_in_at) {
|
|
return "missed";
|
|
}
|
|
|
|
if (!record.clock_out_at) {
|
|
return "pending_clock_out";
|
|
}
|
|
|
|
if (record.is_late && record.is_early_leave) {
|
|
return "late_early_leave";
|
|
}
|
|
|
|
if (record.is_late) {
|
|
return "late";
|
|
}
|
|
|
|
if (record.is_early_leave) {
|
|
return "early_leave";
|
|
}
|
|
|
|
return "normal";
|
|
}
|
|
|
|
function calculateOverlapMinutes(startAt, endAt, rangeStartAt, rangeEndAt) {
|
|
if (!startAt || !endAt || !rangeStartAt || !rangeEndAt) {
|
|
return 0;
|
|
}
|
|
|
|
const start = Math.max(startAt.getTime(), rangeStartAt.getTime());
|
|
const end = Math.min(endAt.getTime(), rangeEndAt.getTime());
|
|
if (end <= start) {
|
|
return 0;
|
|
}
|
|
return Math.floor((end - start) / 60000);
|
|
}
|
|
|
|
function calculateWorkMinutes({ clockInAt, clockOutAt, workDate, timeZone, policy }) {
|
|
if (!clockInAt || !clockOutAt || clockOutAt <= clockInAt) {
|
|
return 0;
|
|
}
|
|
|
|
const totalMinutes = Math.floor((clockOutAt.getTime() - clockInAt.getTime()) / 60000);
|
|
const lunchStartAt = zonedDateTimeToUtc(workDate, policy.lunchStartTime, timeZone);
|
|
const lunchEndAt = zonedDateTimeToUtc(workDate, policy.lunchEndTime, timeZone);
|
|
const lunchOverlap = calculateOverlapMinutes(clockInAt, clockOutAt, lunchStartAt, lunchEndAt);
|
|
return Math.max(0, totalMinutes - lunchOverlap);
|
|
}
|
|
|
|
function calculateLateInfo({ clockInAt, workDate, timeZone, policy }) {
|
|
if (!clockInAt) {
|
|
return { isLate: false, lateMinutes: 0 };
|
|
}
|
|
|
|
const startAt = zonedDateTimeToUtc(workDate, policy.workStartTime, timeZone);
|
|
const toleratedStartAt = new Date(startAt.getTime() + policy.lateToleranceMinutes * 60000);
|
|
if (clockInAt <= toleratedStartAt) {
|
|
return { isLate: false, lateMinutes: 0 };
|
|
}
|
|
|
|
return {
|
|
isLate: true,
|
|
lateMinutes: Math.floor((clockInAt.getTime() - toleratedStartAt.getTime()) / 60000),
|
|
};
|
|
}
|
|
|
|
function calculateEarlyLeaveInfo({ clockOutAt, workDate, timeZone, policy }) {
|
|
if (!clockOutAt) {
|
|
return { isEarlyLeave: false, earlyMinutes: 0 };
|
|
}
|
|
|
|
const endAt = zonedDateTimeToUtc(workDate, policy.workEndTime, timeZone);
|
|
if (clockOutAt >= endAt) {
|
|
return { isEarlyLeave: false, earlyMinutes: 0 };
|
|
}
|
|
|
|
return {
|
|
isEarlyLeave: true,
|
|
earlyMinutes: Math.floor((endAt.getTime() - clockOutAt.getTime()) / 60000),
|
|
};
|
|
}
|
|
|
|
function buildAttendanceMetrics({ clockInAt, clockOutAt, workDate, timeZone, policy }) {
|
|
const late = calculateLateInfo({ clockInAt, workDate, timeZone, policy });
|
|
const earlyLeave = calculateEarlyLeaveInfo({ clockOutAt, workDate, timeZone, policy });
|
|
|
|
return {
|
|
workMinutes: clockInAt && clockOutAt
|
|
? calculateWorkMinutes({ clockInAt, clockOutAt, workDate, timeZone, policy })
|
|
: null,
|
|
isLate: late.isLate,
|
|
lateMinutes: late.lateMinutes,
|
|
isEarlyLeave: earlyLeave.isEarlyLeave,
|
|
earlyMinutes: earlyLeave.earlyMinutes,
|
|
};
|
|
}
|
|
|
|
function summarizeMonthAttendance(items) {
|
|
return items.reduce(
|
|
(summary, item) => {
|
|
if (item.needClockIn) {
|
|
summary.totalWorkDays += 1;
|
|
}
|
|
if (item.clockInAt) {
|
|
summary.clockedDays += 1;
|
|
}
|
|
if (item.attendanceStatus === "late" || item.attendanceStatus === "late_early_leave") {
|
|
summary.lateDays += 1;
|
|
}
|
|
if (item.attendanceStatus === "early_leave" || item.attendanceStatus === "late_early_leave") {
|
|
summary.earlyLeaveDays += 1;
|
|
}
|
|
if (item.attendanceStatus === "missed") {
|
|
summary.missedDays += 1;
|
|
}
|
|
summary.totalWorkMinutes += Number(item.workMinutes ?? 0);
|
|
return summary;
|
|
},
|
|
{
|
|
totalWorkDays: 0,
|
|
clockedDays: 0,
|
|
lateDays: 0,
|
|
earlyLeaveDays: 0,
|
|
missedDays: 0,
|
|
totalWorkMinutes: 0,
|
|
},
|
|
);
|
|
}
|
|
|
|
function mapAttendanceEmployee(row) {
|
|
return {
|
|
id: row.id,
|
|
employeeNo: row.employee_no,
|
|
name: row.name,
|
|
title: row.title,
|
|
departmentId: row.department_id ?? null,
|
|
departmentName: row.department?.name ?? null,
|
|
legalEntityId: row.legal_entity_id ?? null,
|
|
};
|
|
}
|
|
|
|
function compareAttendanceEmployees(left, right) {
|
|
const departmentCompare = (left.departmentName ?? "").localeCompare(right.departmentName ?? "", "zh-CN");
|
|
if (departmentCompare !== 0) {
|
|
return departmentCompare;
|
|
}
|
|
return left.employeeNo.localeCompare(right.employeeNo, "en");
|
|
}
|
|
|
|
async function listActiveAttendanceEmployees(prisma, filters = {}) {
|
|
const rows = await prisma.employee.findMany({
|
|
where: {
|
|
status: "active",
|
|
...(filters.departmentId ? { department_id: filters.departmentId } : {}),
|
|
...(filters.employeeId ? { id: filters.employeeId } : {}),
|
|
},
|
|
select: {
|
|
id: true,
|
|
employee_no: true,
|
|
name: true,
|
|
title: true,
|
|
department_id: true,
|
|
legal_entity_id: true,
|
|
department: { select: { id: true, name: true } },
|
|
},
|
|
});
|
|
|
|
return rows.map(mapAttendanceEmployee).sort(compareAttendanceEmployees);
|
|
}
|
|
|
|
function buildMonthRange(year, month) {
|
|
const normalizedYear = Number(year);
|
|
const normalizedMonth = Number(month);
|
|
if (!Number.isInteger(normalizedYear) || normalizedYear < 2000 || normalizedYear > 2100) {
|
|
throw buildAttendanceValidationError({ year: "invalid" });
|
|
}
|
|
if (!Number.isInteger(normalizedMonth) || normalizedMonth < 1 || normalizedMonth > 12) {
|
|
throw buildAttendanceValidationError({ month: "invalid" });
|
|
}
|
|
|
|
const dateFrom = parseDateOnlyString(`${normalizedYear}-${String(normalizedMonth).padStart(2, "0")}-01`);
|
|
const dateTo = new Date(Date.UTC(normalizedYear, normalizedMonth, 0));
|
|
return {
|
|
year: normalizedYear,
|
|
month: normalizedMonth,
|
|
dateFrom,
|
|
dateTo,
|
|
dateStrings: buildDateStrings(dateFrom, dateTo),
|
|
};
|
|
}
|
|
|
|
function resolveMonthInput(query, timeZone) {
|
|
const today = getZonedParts(new Date(), timeZone ?? "Asia/Shanghai");
|
|
return buildMonthRange(query.year ?? today.year, query.month ?? today.month);
|
|
}
|
|
|
|
async function buildMyMonthAttendance(prisma, { employeeId, timeZone, year, month }) {
|
|
const employeeRows = await listActiveAttendanceEmployees(prisma, { employeeId });
|
|
const employee = employeeRows[0];
|
|
if (!employee) {
|
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
|
}
|
|
|
|
const policy = await getAttendancePolicy(prisma);
|
|
const range = buildMonthRange(year, month);
|
|
const [records, exemptions] = await Promise.all([
|
|
prisma.attendanceRecord.findMany({
|
|
where: {
|
|
employee_id: employeeId,
|
|
work_date: {
|
|
gte: range.dateFrom,
|
|
lte: range.dateTo,
|
|
},
|
|
},
|
|
orderBy: { work_date: "asc" },
|
|
}),
|
|
loadAttendanceExemptions(prisma, {
|
|
employees: [{ id: employee.id, legal_entity_id: employee.legalEntityId }],
|
|
dateStrings: range.dateStrings,
|
|
policyEnabled: policy.enabled,
|
|
}),
|
|
]);
|
|
|
|
const recordMap = new Map(records.map((item) => [formatDateOnly(item.work_date), item]));
|
|
const items = range.dateStrings.map((dateString) => {
|
|
const record = recordMap.get(dateString) ?? null;
|
|
const exemption = getExemptionForDate(exemptions, employeeId, dateString);
|
|
const mapped = mapAttendanceRecord(record) ?? buildEmptyAttendanceRecord(employeeId, dateString);
|
|
return {
|
|
workDate: dateString,
|
|
attendanceStatus: resolveAttendanceStatus(record, exemption),
|
|
needClockIn: exemption ? false : policy.enabled,
|
|
exemptionReason: exemption?.exemptionReason ?? null,
|
|
...mapped,
|
|
};
|
|
});
|
|
|
|
return {
|
|
year: range.year,
|
|
month: range.month,
|
|
summary: summarizeMonthAttendance(items),
|
|
items,
|
|
};
|
|
}
|
|
|
|
async function buildTeamAttendance(prisma, { dateString, departmentId, timeZone }) {
|
|
const employees = await listActiveAttendanceEmployees(prisma, { departmentId });
|
|
const policy = await getAttendancePolicy(prisma);
|
|
const [records, exemptions] = await Promise.all([
|
|
employees.length
|
|
? prisma.attendanceRecord.findMany({
|
|
where: {
|
|
employee_id: { in: employees.map((item) => item.id) },
|
|
work_date: parseDateOnlyString(dateString),
|
|
},
|
|
})
|
|
: Promise.resolve([]),
|
|
loadAttendanceExemptions(prisma, {
|
|
employees: employees.map((item) => ({
|
|
id: item.id,
|
|
legal_entity_id: item.legalEntityId,
|
|
})),
|
|
dateStrings: [dateString],
|
|
policyEnabled: policy.enabled,
|
|
}),
|
|
]);
|
|
|
|
const recordMap = new Map(records.map((item) => [buildHistoryKey(item.employee_id, formatDateOnly(item.work_date)), item]));
|
|
|
|
return employees.map((employee) => {
|
|
const record = recordMap.get(buildHistoryKey(employee.id, dateString)) ?? null;
|
|
const mapped = mapAttendanceRecord(record);
|
|
const exemption = getExemptionForDate(exemptions, employee.id, dateString);
|
|
return {
|
|
employeeId: employee.id,
|
|
employeeName: employee.name,
|
|
employeeNo: employee.employeeNo,
|
|
title: employee.title,
|
|
departmentId: employee.departmentId,
|
|
departmentName: employee.departmentName,
|
|
attendanceStatus: resolveAttendanceStatus(record, exemption),
|
|
needClockIn: exemption ? false : policy.enabled,
|
|
exemptionReason: exemption?.exemptionReason ?? null,
|
|
clockInAt: mapped?.clockInAt ?? null,
|
|
clockOutAt: mapped?.clockOutAt ?? null,
|
|
workMinutes: mapped?.workMinutes ?? null,
|
|
note: mapped?.note ?? exemption?.note ?? null,
|
|
source: mapped?.source ?? null,
|
|
};
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
ATTENDANCE_STATUS_VALUES,
|
|
ATTENDANCE_SOURCE_VALUES,
|
|
DEFAULT_ATTENDANCE_POLICY,
|
|
EXEMPTION_REASON_VALUES,
|
|
addUtcDays,
|
|
buildAttendanceMetrics,
|
|
buildAttendanceValidationError,
|
|
buildDateStrings,
|
|
buildMonthRange,
|
|
buildMyMonthAttendance,
|
|
buildTeamAttendance,
|
|
calculateEarlyLeaveInfo,
|
|
calculateLateInfo,
|
|
calculateOverlapMinutes,
|
|
calculateWorkMinutes,
|
|
currentDateString,
|
|
detectAttendanceSource,
|
|
formatDateOnly,
|
|
getAttendancePolicy,
|
|
getExemptionForDate,
|
|
getZonedMinutes,
|
|
getZonedParts,
|
|
isWorkingDateByCalendar,
|
|
listActiveAttendanceEmployees,
|
|
loadAttendanceExemptions,
|
|
loadAttendanceRecord,
|
|
mapAttendanceRecord,
|
|
buildEmptyAttendanceRecord,
|
|
normalizeAttendancePolicy,
|
|
parseTimeToMinutes,
|
|
resolveAttendanceStatus,
|
|
resolveMonthInput,
|
|
upsertAttendancePolicy,
|
|
validateAttendancePolicyInput,
|
|
zonedDateTimeToUtc,
|
|
};
|