const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { currentDateString } = require("../utils/date"); const { sendData } = require("../utils/reply"); const { hasPermission } = require("../constants/permissions"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { fetchWecomCheckinData, normalizeCheckinRecord, } = require("../services/integrations/wecomCheckin"); const { buildAttendanceMetrics, buildAttendanceValidationError, buildMyMonthAttendance, buildTeamAttendance, detectAttendanceSource, getAttendancePolicy, getExemptionForDate, loadAttendanceExemptions, loadAttendanceRecord, mapAttendanceRecord, parseTimeToMinutes, upsertAttendancePolicy, } = require("../services/attendance"); const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const DATE_TIME_SCHEMA = z.string().datetime({ offset: true }).or(z.string().datetime()); const CLOCK_BODY_SCHEMA = z .object({ note: z.string().trim().max(300).nullable().optional(), }) .strict(); const MONTH_QUERY_SCHEMA = z.object({ year: z.coerce.number().int().min(2000).max(2100).optional(), month: z.coerce.number().int().min(1).max(12).optional(), }); const TEAM_QUERY_SCHEMA = z.object({ date: DATE_SCHEMA.optional(), departmentId: z.string().uuid().optional(), }); const POLICY_BODY_SCHEMA = z .object({ workStartTime: z.string().trim(), workEndTime: z.string().trim(), lunchStartTime: z.string().trim(), lunchEndTime: z.string().trim(), lateToleranceMinutes: z.coerce.number().int().min(0).max(600), enabled: z.boolean().optional(), }) .strict() .superRefine((value, ctx) => { try { parseTimeToMinutes(value.workStartTime, "workStartTime"); parseTimeToMinutes(value.workEndTime, "workEndTime"); parseTimeToMinutes(value.lunchStartTime, "lunchStartTime"); parseTimeToMinutes(value.lunchEndTime, "lunchEndTime"); } catch (error) { if (error instanceof AppError) { for (const [key, reason] of Object.entries(error.details ?? {})) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: String(reason), }); } return; } throw error; } }); const ADMIN_BACKFILL_BODY_SCHEMA = z .object({ employeeId: z.string().uuid(), workDate: DATE_SCHEMA, clockInAt: DATE_TIME_SCHEMA.nullable().optional(), clockOutAt: DATE_TIME_SCHEMA.nullable().optional(), note: z.string().trim().max(300).nullable().optional(), }) .strict(); const UNIFIED_MONTH_QUERY_SCHEMA = z.object({ month: z.string().regex(/^\d{4}-\d{2}$/).optional(), employeeId: z.string().uuid().optional(), }); const WECOM_SYNC_BODY_SCHEMA = z .object({ startDate: DATE_SCHEMA, endDate: DATE_SCHEMA, employeeIds: z.array(z.string().uuid()).max(50).optional(), }) .strict() .superRefine((value, ctx) => { const start = parseDateOnly(value.startDate); const end = parseDateOnly(value.endDate); if (!start || !end) { return; } if (end < start) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["endDate"], message: "must_be_after_or_equal_startDate", }); return; } const days = Math.floor((end.getTime() - start.getTime()) / 86400000) + 1; if (days > 31) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["endDate"], message: "range_too_large", }); } }); const SYNC_RUN_QUERY_SCHEMA = z.object({ page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), }); function parseInput(schema, value) { const parsed = schema.safeParse(value ?? {}); if (!parsed.success) { throw buildAttendanceValidationError(parsed.error.flatten()); } return parsed.data; } async function ensureFeatureEnabled(fastify, request) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.attendance, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { throw buildAttendanceValidationError( { featureFlag: "attendance_v1_disabled", reason: gate.reason }, 409, ); } } async function ensureUnifiedFeatureEnabled(fastify, request) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.attendanceUnified, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { throw buildAttendanceValidationError( { featureFlag: "attendance_unified_v1_disabled", reason: gate.reason }, 409, ); } } async function loadEmployeeForAttendance(prisma, employeeId) { return prisma.employee.findUnique({ where: { id: employeeId }, select: { id: true, status: true, department_id: true, legal_entity_id: true, name: true, }, }); } async function resolveTodayState(fastify, employeeId) { const workDate = currentDateString(fastify.config.appTimezone); const employee = await loadEmployeeForAttendance(fastify.prisma, employeeId); if (!employee || employee.status !== "active") { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } const [record, exemptions] = await Promise.all([ loadAttendanceRecord(fastify.prisma, employeeId, workDate), loadAttendanceExemptions(fastify.prisma, { employees: [{ id: employee.id, legal_entity_id: employee.legal_entity_id }], dateStrings: [workDate], }), ]); const exemption = getExemptionForDate(exemptions, employeeId, workDate); return { employee, workDate, record, exemption, }; } function parseOptionalDateTime(value) { if (!value) { return null; } const date = new Date(value); if (Number.isNaN(date.getTime())) { throw buildAttendanceValidationError({ datetime: "invalid" }); } return date; } function parseDateOnly(value) { if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { return null; } const date = new Date(`${value}T00:00:00.000Z`); return Number.isNaN(date.getTime()) ? null : date; } function formatDateOnlyUtc(value) { if (!value) return null; return new Date(value).toISOString().slice(0, 10); } function addDays(value, days) { const next = new Date(value); next.setUTCDate(next.getUTCDate() + days); return next; } function listDateStrings(startDate, endDate) { const items = []; for (let cursor = new Date(startDate); cursor <= endDate; cursor = addDays(cursor, 1)) { items.push(formatDateOnlyUtc(cursor)); } return items; } function parseMonthRange(monthValue, timeZone) { const now = new Date(); const fallback = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", }).format(now); const month = monthValue || fallback; const [year, monthNo] = month.split("-").map(Number); if (!Number.isInteger(year) || !Number.isInteger(monthNo) || monthNo < 1 || monthNo > 12) { throw buildAttendanceValidationError({ month: "invalid" }); } const startDate = new Date(Date.UTC(year, monthNo - 1, 1)); const endDate = new Date(Date.UTC(year, monthNo, 0)); return { month, year, monthNo, startDate, endDate }; } function isWorkday(dateString) { const day = new Date(`${dateString}T00:00:00.000Z`).getUTCDay(); return day !== 0 && day !== 6; } function groupBy(items, keyFn) { const map = new Map(); for (const item of items) { const key = keyFn(item); if (!map.has(key)) { map.set(key, []); } map.get(key).push(item); } return map; } function mapEmployeeSummary(employee) { if (!employee) return null; return { id: employee.id, employeeNo: employee.employee_no, name: employee.name, title: employee.title ?? null, departmentId: employee.department_id ?? null, departmentName: employee.department?.name ?? null, }; } function mapWecomCheckinRecord(record) { return { id: record.id, checkinAt: record.checkin_time?.toISOString?.() ?? null, checkinDate: formatDateOnlyUtc(record.checkin_date), checkinType: record.checkin_type, result: record.result ?? null, exceptionType: record.exception_type ?? null, sourceType: record.source_type ?? null, }; } function mapAttendanceCorrection(record) { return { id: record.id, kind: record.kind, status: record.status, workDate: formatDateOnlyUtc(record.work_date), startAt: record.start_at?.toISOString?.() ?? null, endAt: record.end_at?.toISOString?.() ?? null, missedPunchAt: record.missed_punch_at?.toISOString?.() ?? null, reason: record.reason, locationSummary: record.location_summary ?? null, compensationPolicy: record.compensation_policy ?? null, owner: mapEmployeeSummary(record.owner), createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, }; } function correctionKindLabel(kind) { if (kind === "missed_punch") return "补卡"; if (kind === "out") return "外出"; if (kind === "overtime") return "加班"; return "考勤修正"; } function resolveDailyAttendanceStatus({ dateString, checkins, corrections, leaveRequests }) { const approvedCorrections = corrections.filter((item) => item.status === "approved"); if (leaveRequests.length > 0) { return { status: "leave", explanation: "OA 已批准请假", }; } if (approvedCorrections.length > 0) { return { status: approvedCorrections.some((item) => item.kind === "overtime") ? "overtime" : "corrected", explanation: approvedCorrections.map((item) => correctionKindLabel(item.kind)).join("、"), }; } if (!isWorkday(dateString)) { return { status: "weekend", explanation: "周末", }; } if (checkins.length > 0) { const hasException = checkins.some((item) => item.exception_type || item.result); return { status: hasException ? "exception" : "normal", explanation: hasException ? "企微打卡存在异常摘要" : "企微打卡已同步", }; } return { status: "missing", explanation: "未同步到企微打卡摘要", }; } function mapSyncRun(record) { return { id: record.id, startDate: formatDateOnlyUtc(record.start_date), endDate: formatDateOnlyUtc(record.end_date), status: record.status, employeeCount: record.employee_count, recordCount: record.record_count, errorCode: record.error_code ?? null, errorMessage: record.error_message ?? null, triggeredBy: record.trigger ? { id: record.trigger.id, employeeNo: record.trigger.employee_no, name: record.trigger.name, } : null, createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, }; } function hasGlobalAttendanceScope(request) { const roleCodes = request.employee.roleCodes ?? []; const permissionCodes = request.employee.permissionCodes ?? []; return ( roleCodes.includes("admin") || roleCodes.includes("hr") || hasPermission(permissionCodes, "*") ); } async function loadMonthlyEmployees(fastify, request, employeeId) { const permissionCodes = request.employee.permissionCodes ?? []; const canViewTeam = hasPermission(permissionCodes, "attendance.view_all"); const canViewGlobal = hasGlobalAttendanceScope(request); if (employeeId) { const isSelf = employeeId === request.employee.id; if (!isSelf && !canViewTeam && !canViewGlobal) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } const employee = await fastify.prisma.employee.findUnique({ where: { id: employeeId }, select: { id: true, employee_no: true, name: true, title: true, status: true, department_id: true, direct_manager_id: true, department: { select: { id: true, name: true } }, }, }); if (!employee || employee.status !== "active") { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (!isSelf && !canViewGlobal && employee.direct_manager_id !== request.employee.id) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } return [employee]; } if (!canViewTeam && !canViewGlobal) { const employee = await fastify.prisma.employee.findUnique({ where: { id: request.employee.id }, select: { id: true, employee_no: true, name: true, title: true, status: true, department_id: true, direct_manager_id: true, department: { select: { id: true, name: true } }, }, }); return employee ? [employee] : []; } const teamScopeWhere = canViewGlobal ? {} : { OR: [ { id: request.employee.id }, { direct_manager_id: request.employee.id }, ], }; return fastify.prisma.employee.findMany({ where: { status: "active", ...teamScopeWhere }, orderBy: [{ department_id: "asc" }, { employee_no: "asc" }], take: 200, select: { id: true, employee_no: true, name: true, title: true, status: true, department_id: true, direct_manager_id: true, department: { select: { id: true, name: true } }, }, }); } async function attendanceRoutes(fastify) { fastify.post("/attendance/clock-in", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const body = parseInput(CLOCK_BODY_SCHEMA, request.body); const { workDate, record, exemption } = await resolveTodayState(fastify, request.employee.id); if (record?.clock_in_at) { return sendData(reply, request, { item: mapAttendanceRecord(record) }); } if (exemption) { throw buildAttendanceValidationError( { attendance: "exempt_day", exemptionReason: exemption.exemptionReason }, 409, ); } const policy = await getAttendancePolicy(fastify.prisma); const clockInAt = new Date(); const clockOutAt = record?.clock_out_at && record.clock_out_at > clockInAt ? record.clock_out_at : null; const nextNote = body.note || record?.note || null; const metrics = buildAttendanceMetrics({ clockInAt, clockOutAt, workDate, timeZone: fastify.config.appTimezone, policy, }); const saved = record ? await fastify.prisma.attendanceRecord.update({ where: { id: record.id }, data: { clock_in_at: clockInAt, clock_out_at: clockOutAt, work_minutes: metrics.workMinutes, is_late: metrics.isLate, late_minutes: metrics.lateMinutes, is_early_leave: metrics.isEarlyLeave, early_minutes: metrics.earlyMinutes, note: nextNote, source: detectAttendanceSource(request), updated_by: request.employee.id, }, }) : await fastify.prisma.attendanceRecord.create({ data: { employee_id: request.employee.id, work_date: new Date(`${workDate}T00:00:00.000Z`), clock_in_at: clockInAt, clock_out_at: null, work_minutes: null, is_late: metrics.isLate, late_minutes: metrics.lateMinutes, is_early_leave: false, early_minutes: 0, note: body.note || null, source: detectAttendanceSource(request), updated_by: request.employee.id, }, }); return sendData(reply, request, { item: mapAttendanceRecord(saved) }); }); fastify.post("/attendance/clock-out", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const body = parseInput(CLOCK_BODY_SCHEMA, request.body); const { workDate, record, exemption } = await resolveTodayState(fastify, request.employee.id); if (!record?.clock_in_at) { if (exemption) { throw buildAttendanceValidationError( { attendance: "exempt_day", exemptionReason: exemption.exemptionReason }, 409, ); } throw buildAttendanceValidationError({ attendance: "missing_clock_in" }); } const clockOutAt = new Date(); const policy = await getAttendancePolicy(fastify.prisma); const nextNote = body.note || record.note || null; const metrics = buildAttendanceMetrics({ clockInAt: record.clock_in_at, clockOutAt, workDate, timeZone: fastify.config.appTimezone, policy, }); const saved = await fastify.prisma.attendanceRecord.update({ where: { id: record.id }, data: { clock_out_at: clockOutAt, work_minutes: metrics.workMinutes, is_early_leave: metrics.isEarlyLeave, early_minutes: metrics.earlyMinutes, is_late: metrics.isLate, late_minutes: metrics.lateMinutes, note: nextNote, source: detectAttendanceSource(request), updated_by: request.employee.id, }, }); return sendData(reply, request, { item: mapAttendanceRecord(saved) }); }); fastify.get("/attendance/me/today", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const { workDate, record, exemption } = await resolveTodayState(fastify, request.employee.id); return sendData(reply, request, { workDate, needClockIn: !exemption, exemptionReason: exemption?.exemptionReason ?? null, item: mapAttendanceRecord(record), }); }); fastify.get("/attendance/me/month", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const query = parseInput(MONTH_QUERY_SCHEMA, request.query); const now = new Date(); const parts = new Intl.DateTimeFormat("en-CA", { timeZone: fastify.config.appTimezone, year: "numeric", month: "2-digit", }).formatToParts(now); const partMap = {}; for (const part of parts) { if (part.type !== "literal") { partMap[part.type] = part.value; } } const payload = await buildMyMonthAttendance(fastify.prisma, { employeeId: request.employee.id, timeZone: fastify.config.appTimezone, year: query.year ?? Number(partMap.year), month: query.month ?? Number(partMap.month), }); return sendData(reply, request, payload); }); fastify.get( "/attendance/team", { preHandler: fastify.requirePermission("system.users") }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const query = parseInput(TEAM_QUERY_SCHEMA, request.query); const dateString = query.date ?? currentDateString(fastify.config.appTimezone); const items = await buildTeamAttendance(fastify.prisma, { dateString, departmentId: query.departmentId, timeZone: fastify.config.appTimezone, }); return sendData(reply, request, { date: dateString, items, }); }, ); fastify.get("/attendance/policy", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const item = await getAttendancePolicy(fastify.prisma); return sendData(reply, request, { item }); }); fastify.put( "/attendance/policy", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const body = parseInput(POLICY_BODY_SCHEMA, request.body); const item = await upsertAttendancePolicy(fastify.prisma, request.employee.id, body); return sendData(reply, request, { item }); }, ); fastify.post( "/attendance/admin/backfill", { preHandler: fastify.requirePermission("system.users") }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const body = parseInput(ADMIN_BACKFILL_BODY_SCHEMA, request.body); const employee = await loadEmployeeForAttendance(fastify.prisma, body.employeeId); if (!employee || employee.status !== "active") { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } const before = await loadAttendanceRecord(fastify.prisma, body.employeeId, body.workDate); const nextClockInAt = body.clockInAt !== undefined ? parseOptionalDateTime(body.clockInAt) : before?.clock_in_at ?? null; const nextClockOutAt = body.clockOutAt !== undefined ? parseOptionalDateTime(body.clockOutAt) : before?.clock_out_at ?? null; if (!nextClockInAt && !nextClockOutAt) { throw buildAttendanceValidationError({ attendance: "missing_backfill_values" }); } if (nextClockInAt && nextClockOutAt && nextClockOutAt < nextClockInAt) { throw buildAttendanceValidationError({ clockOutAt: "must_be_after_or_equal_clockInAt" }); } const policy = await getAttendancePolicy(fastify.prisma); const nextNote = body.note || before?.note || null; const metrics = buildAttendanceMetrics({ clockInAt: nextClockInAt, clockOutAt: nextClockOutAt, workDate: body.workDate, timeZone: fastify.config.appTimezone, policy, }); const saved = before ? await fastify.prisma.attendanceRecord.update({ where: { id: before.id }, data: { clock_in_at: nextClockInAt, clock_out_at: nextClockOutAt, work_minutes: metrics.workMinutes, is_late: metrics.isLate, late_minutes: metrics.lateMinutes, is_early_leave: metrics.isEarlyLeave, early_minutes: metrics.earlyMinutes, note: nextNote, source: "admin", updated_by: request.employee.id, }, }) : await fastify.prisma.attendanceRecord.create({ data: { employee_id: body.employeeId, work_date: new Date(`${body.workDate}T00:00:00.000Z`), clock_in_at: nextClockInAt, clock_out_at: nextClockOutAt, work_minutes: metrics.workMinutes, is_late: metrics.isLate, late_minutes: metrics.lateMinutes, is_early_leave: metrics.isEarlyLeave, early_minutes: metrics.earlyMinutes, note: nextNote, source: "admin", updated_by: request.employee.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ATTENDANCE_ADMIN_BACKFILLED", targetType: "attendance_record", targetId: saved.id, extra: { employeeId: body.employeeId, workDate: body.workDate, note: nextNote, before: mapAttendanceRecord(before), after: mapAttendanceRecord(saved), }, }), }); return sendData(reply, request, { item: mapAttendanceRecord(saved) }); }, ); fastify.post( "/attendance/wecom/sync", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureUnifiedFeatureEnabled(fastify, request); fastify.assertEntityPermission(request.employee, "attendance", "sync"); const body = parseInput(WECOM_SYNC_BODY_SCHEMA, request.body); const startDate = parseDateOnly(body.startDate); const endDate = parseDateOnly(body.endDate); const employeeWhere = { status: "active", ...(body.employeeIds?.length ? { id: { in: Array.from(new Set(body.employeeIds)) } } : {}), }; const employees = await fastify.prisma.employee.findMany({ where: employeeWhere, select: { id: true, employee_no: true, name: true, wecom_identity_binding: { select: { external_user_id: true, }, }, }, }); const externalUserIds = employees .map((employee) => employee.wecom_identity_binding?.external_user_id) .filter(Boolean); const employeeByExternalUserId = new Map( employees .filter((employee) => employee.wecom_identity_binding?.external_user_id) .map((employee) => [employee.wecom_identity_binding.external_user_id, employee]), ); const syncRun = await fastify.prisma.wecomCheckinSyncRun.create({ data: { start_date: startDate, end_date: endDate, status: "running", employee_count: employees.length, record_count: 0, triggered_by: request.employee.id, }, }); let result; let normalizedRecords = []; try { result = await fetchWecomCheckinData({ config: fastify.config, startDate: body.startDate, endDate: body.endDate, externalUserIds, }); normalizedRecords = result.records .map((raw) => normalizeCheckinRecord(raw, { employeeByExternalUserId, timeZone: fastify.config.appTimezone, }), ) .filter(Boolean); if (result.ok && normalizedRecords.length > 0) { await fastify.prisma.wecomCheckinRecord.createMany({ data: normalizedRecords.map((record) => ({ sync_run_id: syncRun.id, employee_id: record.employeeId, external_user_id: record.externalUserId, checkin_time: record.checkinTime, checkin_date: record.checkinDate, checkin_type: record.checkinType, exception_type: record.exceptionType, source_type: record.sourceType, result: record.result, raw_hash: record.rawHash, })), skipDuplicates: true, }); } } catch (error) { result = { ok: false, skipped: false, code: "wecom_checkin_unhandled_error", message: error instanceof Error ? error.message.slice(0, 500) : "unknown_error", }; } const status = result.skipped ? "skipped" : result.ok ? "success" : "failed"; const updatedRun = await fastify.prisma.wecomCheckinSyncRun.update({ where: { id: syncRun.id }, data: { status, employee_count: employees.length, record_count: normalizedRecords.length, error_code: result.ok && !result.skipped ? null : result.code, error_message: result.ok && !result.skipped ? null : result.message ?? null, }, include: { trigger: { select: { id: true, employee_no: true, name: true, }, }, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "WECOM_CHECKIN_SYNC_RUN", targetType: "wecom_checkin_sync_run", targetId: updatedRun.id, extra: { startDate: body.startDate, endDate: body.endDate, status, employeeCount: employees.length, boundUserCount: externalUserIds.length, recordCount: normalizedRecords.length, resultCode: result.code, }, }), }); return sendData(reply, request, { item: mapSyncRun(updatedRun), skipped: Boolean(result.skipped), code: result.code, employeeCount: employees.length, boundUserCount: externalUserIds.length, recordCount: normalizedRecords.length, }); }, ); fastify.get( "/attendance/wecom/sync-runs", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureUnifiedFeatureEnabled(fastify, request); fastify.assertEntityPermission(request.employee, "attendance", "sync"); const query = parseInput(SYNC_RUN_QUERY_SCHEMA, request.query); const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where = {}; const [items, total] = await Promise.all([ fastify.prisma.wecomCheckinSyncRun.findMany({ where, orderBy: { created_at: "desc" }, skip: (page - 1) * pageSize, take: pageSize, include: { trigger: { select: { id: true, employee_no: true, name: true, }, }, }, }), fastify.prisma.wecomCheckinSyncRun.count({ where }), ]); return sendData(reply, request, { page, pageSize, total, items: items.map(mapSyncRun), }); }, ); fastify.get( "/attendance/monthly", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureUnifiedFeatureEnabled(fastify, request); fastify.assertEntityPermission(request.employee, "attendance", "view_own"); const query = parseInput(UNIFIED_MONTH_QUERY_SCHEMA, request.query); const { month, startDate, endDate } = parseMonthRange(query.month, fastify.config.appTimezone); const employees = await loadMonthlyEmployees(fastify, request, query.employeeId); const employeeIds = employees.map((employee) => employee.id); const dates = listDateStrings(startDate, endDate); if (!employeeIds.length) { return sendData(reply, request, { month, summary: { employeeCount: 0, workDays: dates.filter(isWorkday).length, checkinDays: 0, missingDays: 0, approvedCorrectionCount: 0, }, employees: [], }); } const [checkins, corrections, leaveRequests] = await Promise.all([ fastify.prisma.wecomCheckinRecord.findMany({ where: { employee_id: { in: employeeIds }, checkin_date: { gte: startDate, lte: endDate }, }, orderBy: [{ checkin_date: "asc" }, { checkin_time: "asc" }], }), fastify.prisma.attendanceCorrection.findMany({ where: { owner_id: { in: employeeIds }, work_date: { gte: startDate, lte: endDate }, }, orderBy: [{ work_date: "asc" }, { created_at: "asc" }], include: { owner: { select: { id: true, employee_no: true, name: true, title: true, department_id: true, department: { select: { id: true, name: true } }, }, }, }, }), fastify.prisma.leaveRequest.findMany({ where: { owner_id: { in: employeeIds }, status: "approved", start_date: { lte: endDate }, end_date: { gte: startDate }, }, select: { id: true, owner_id: true, start_date: true, end_date: true, requested_days: true, reason: true, leave_type: { select: { name: true } }, }, }), ]); const checkinsByEmployeeDate = groupBy( checkins, (item) => `${item.employee_id}|${formatDateOnlyUtc(item.checkin_date)}`, ); const correctionsByEmployeeDate = groupBy( corrections, (item) => `${item.owner_id}|${formatDateOnlyUtc(item.work_date)}`, ); const leavesByEmployeeDate = new Map(); for (const leave of leaveRequests) { const leaveStart = leave.start_date > startDate ? leave.start_date : startDate; const leaveEnd = leave.end_date < endDate ? leave.end_date : endDate; for (const dateString of listDateStrings(leaveStart, leaveEnd)) { const key = `${leave.owner_id}|${dateString}`; if (!leavesByEmployeeDate.has(key)) { leavesByEmployeeDate.set(key, []); } leavesByEmployeeDate.get(key).push({ id: leave.id, leaveTypeName: leave.leave_type?.name ?? "请假", requestedDays: Number(leave.requested_days), reason: leave.reason ?? null, }); } } let checkinDays = 0; let missingDays = 0; let approvedCorrectionCount = 0; const employeeItems = employees.map((employee) => { const days = dates.map((dateString) => { const key = `${employee.id}|${dateString}`; const dayCheckins = checkinsByEmployeeDate.get(key) ?? []; const dayCorrections = correctionsByEmployeeDate.get(key) ?? []; const dayLeaves = leavesByEmployeeDate.get(key) ?? []; const { status, explanation } = resolveDailyAttendanceStatus({ dateString, checkins: dayCheckins, corrections: dayCorrections, leaveRequests: dayLeaves, }); if (dayCheckins.length > 0) { checkinDays += 1; } if (status === "missing") { missingDays += 1; } approvedCorrectionCount += dayCorrections.filter((item) => item.status === "approved").length; return { date: dateString, status, explanation, firstCheckinAt: dayCheckins[0]?.checkin_time?.toISOString?.() ?? null, lastCheckinAt: dayCheckins[dayCheckins.length - 1]?.checkin_time?.toISOString?.() ?? null, checkins: dayCheckins.map(mapWecomCheckinRecord), corrections: dayCorrections.map(mapAttendanceCorrection), leaveRequests: dayLeaves, }; }); return { employee: mapEmployeeSummary(employee), days, }; }); return sendData(reply, request, { month, summary: { employeeCount: employees.length, workDays: dates.filter(isWorkday).length, checkinDays, missingDays, approvedCorrectionCount, }, employees: employeeItems, }); }, ); } attendanceRoutes.__testables = { ensureFeatureEnabled, ensureUnifiedFeatureEnabled, parseMonthRange, parseOptionalDateTime, resolveTodayState, }; module.exports = attendanceRoutes;