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, };