const { AppError } = require("../utils/AppError"); const { currentDateString, parseDateOnlyString } = require("../utils/date"); const HISTORY_STATUS_VALUES = ["office", "remote", "out", "travel", "leave", "unspecified"]; const PRESENCE_WRITE_STATUS_VALUES = ["office", "remote", "out", "travel"]; const PRESENCE_TIME_RANGE_VALUES = ["full_day", "morning", "afternoon"]; const HISTORY_DEFAULT_DAYS = 7; const HISTORY_MAX_DAYS = 31; function buildPresenceValidationError(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 resolvePresenceHistoryRange(query, timeZone) { const todayString = currentDateString(timeZone ?? "Asia/Shanghai"); let dateFrom = query.dateFrom ?? null; let dateTo = query.dateTo ?? null; if (!dateFrom && !dateTo) { dateTo = todayString; dateFrom = formatDateOnly(addUtcDays(parseDateOnlyString(dateTo), -(HISTORY_DEFAULT_DAYS - 1))); } else if (!dateFrom && dateTo) { dateFrom = formatDateOnly(addUtcDays(parseDateOnlyString(dateTo), -(HISTORY_DEFAULT_DAYS - 1))); } else if (dateFrom && !dateTo) { dateTo = todayString; } const start = parseDateOnlyString(dateFrom); const end = parseDateOnlyString(dateTo); if (start > end) { throw buildPresenceValidationError({ dateTo: "must_be_after_or_equal_dateFrom" }); } const totalDays = Math.floor((end.getTime() - start.getTime()) / 86400000) + 1; if (totalDays > HISTORY_MAX_DAYS) { throw buildPresenceValidationError({ dateRange: "too_large" }); } return { dateFrom, dateTo, dateStrings: buildDateStrings(start, end), }; } function computeLeaveTimeRange(leave, dateString) { const startDate = formatDateOnly(leave.start_date); const endDate = formatDateOnly(leave.end_date); if (startDate === dateString && endDate === dateString) { if (leave.start_session === "pm" && leave.end_session === "pm") return "afternoon"; if (leave.start_session === "am" && leave.end_session === "am") return "morning"; return "full_day"; } if (startDate === dateString && leave.start_session === "pm") { return "afternoon"; } if (endDate === dateString && leave.end_session === "am") { return "morning"; } return "full_day"; } function mapPresenceRow(row) { if (!row) { return null; } return { id: row.id, employeeId: row.employee_id, date: formatDateOnly(row.date), status: row.status, timeRange: row.time_range, note: row.note ?? null, source: row.source, updatedAt: row.updated_at?.toISOString?.() ?? null, }; } function mapPresenceEmployee(row) { return { id: row.id, employeeNo: row.employee_no, name: row.name, title: row.title, avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null, employmentStatus: row.employment_status, department: row.department ? { id: row.department.id, name: row.department.name } : null, }; } function comparePresenceEmployees(left, right) { const departmentCompare = (left.department?.name ?? "").localeCompare(right.department?.name ?? "", "zh-CN"); if (departmentCompare !== 0) { return departmentCompare; } return left.employeeNo.localeCompare(right.employeeNo, "en"); } function buildHistoryKey(employeeId, dateString) { return `${employeeId}:${dateString}`; } function buildTravelPresence(row, dateString) { return { id: `travel-${row.id}`, employeeId: row.applicant_employee_id, date: dateString, status: "travel", timeRange: "full_day", note: row.purpose ?? null, source: "leave_sync", updatedAt: null, }; } function buildLeavePresence(row, dateString) { return { id: `leave-${row.id}`, employeeId: row.owner_id, date: dateString, status: "leave", timeRange: computeLeaveTimeRange(row, dateString), note: row.reason ?? null, source: "leave_sync", updatedAt: null, }; } async function buildPresenceHistoryState(prisma, employeeIds, dateStrings) { if (!employeeIds.length || !dateStrings.length) { return new Map(); } const dateFrom = dateStrings[0]; const dateTo = dateStrings[dateStrings.length - 1]; const [presenceRows, leaveRows, travelRows] = await Promise.all([ prisma.employeePresence.findMany({ where: { employee_id: { in: employeeIds }, date: { gte: parseDateOnlyString(dateFrom), lte: parseDateOnlyString(dateTo), }, }, }), prisma.leaveRequest.findMany({ where: { owner_id: { in: employeeIds }, status: "approved", start_date: { lte: parseDateOnlyString(dateTo) }, end_date: { gte: parseDateOnlyString(dateFrom) }, }, select: { id: true, owner_id: true, start_date: true, end_date: true, start_session: true, end_session: 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: { id: true, applicant_employee_id: true, start_date: true, end_date: true, purpose: true, }, }), ]); const result = new Map(); const adminOverrideMap = new Map(); for (const row of presenceRows) { const key = buildHistoryKey(row.employee_id, formatDateOnly(row.date)); const mapped = mapPresenceRow(row); if (row.source === "admin_override") { adminOverrideMap.set(key, mapped); } else { result.set(key, mapped); } } for (const row of travelRows) { const startDate = formatDateOnly(row.start_date); const endDate = formatDateOnly(row.end_date); for (const dateString of dateStrings) { if (dateString < startDate || dateString > endDate) { continue; } const key = buildHistoryKey(row.applicant_employee_id, dateString); if (!result.has(key) || result.get(key)?.source !== "leave_sync") { result.set(key, buildTravelPresence(row, dateString)); } } } for (const row of leaveRows) { const startDate = formatDateOnly(row.start_date); const endDate = formatDateOnly(row.end_date); for (const dateString of dateStrings) { if (dateString < startDate || dateString > endDate) { continue; } const key = buildHistoryKey(row.owner_id, dateString); result.set(key, buildLeavePresence(row, dateString)); } } for (const [key, value] of adminOverrideMap.entries()) { result.set(key, value); } return result; } async function buildPresenceState(prisma, employeeIds, dateString) { const historyMap = await buildPresenceHistoryState(prisma, employeeIds, [dateString]); const result = new Map(); for (const employeeId of employeeIds) { const key = buildHistoryKey(employeeId, dateString); if (historyMap.has(key)) { result.set(employeeId, historyMap.get(key)); } } return result; } async function resolvePresenceForEmployee(prisma, employeeId, dateString) { const map = await buildPresenceState(prisma, [employeeId], dateString); return map.get(employeeId) ?? null; } function buildPresenceSummary(items) { const summary = { office: 0, remote: 0, out: 0, travel: 0, leave: 0, unspecified: 0, total: items.length, }; for (const item of items) { const status = item.presence?.status; if (!status || !(status in summary)) { summary.unspecified += 1; continue; } summary[status] += 1; } return summary; } function filterHistoryItemsByStatus(items, status) { if (!status) { return items; } if (status === "unspecified") { return items.filter((item) => !item.presence); } return items.filter((item) => item.presence?.status === status); } async function listActivePresenceEmployees(prisma, filters = {}) { const employees = 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, avatar_path: true, employment_status: true, department: { select: { id: true, name: true } }, }, }); return employees.map(mapPresenceEmployee).sort(comparePresenceEmployees); } function buildTeamHistoryItems(employees, dateStrings, historyMap) { return [...dateStrings] .reverse() .flatMap((date) => employees.map((employee) => ({ date, employee, presence: historyMap.get(buildHistoryKey(employee.id, date)) ?? null, })), ); } async function buildMyPresenceHistory(prisma, employeeId, dateStrings) { const historyMap = await buildPresenceHistoryState(prisma, [employeeId], dateStrings); const items = [...dateStrings].reverse().map((date) => ({ date, presence: historyMap.get(buildHistoryKey(employeeId, date)) ?? null, })); return { items, summary: buildPresenceSummary(items), }; } async function buildTeamPresenceHistory(prisma, options) { const employees = await listActivePresenceEmployees(prisma, { departmentId: options.departmentId, employeeId: options.employeeId, }); const historyMap = await buildPresenceHistoryState( prisma, employees.map((item) => item.id), options.dateStrings, ); const items = filterHistoryItemsByStatus( buildTeamHistoryItems(employees, options.dateStrings, historyMap), options.status, ); return { employees, items, summary: buildPresenceSummary(items), }; } async function buildTeamPresenceSummary(prisma, dateString) { const employees = await listActivePresenceEmployees(prisma); const map = await buildPresenceState( prisma, employees.map((item) => item.id), dateString, ); const items = employees.map((employee) => ({ employee, presence: map.get(employee.id) ?? null, })); return buildPresenceSummary(items); } module.exports = { HISTORY_DEFAULT_DAYS, HISTORY_MAX_DAYS, HISTORY_STATUS_VALUES, PRESENCE_TIME_RANGE_VALUES, PRESENCE_WRITE_STATUS_VALUES, addUtcDays, buildHistoryKey, buildMyPresenceHistory, buildPresenceHistoryState, buildPresenceState, buildPresenceSummary, buildPresenceValidationError, buildTeamPresenceHistory, buildTeamPresenceSummary, filterHistoryItemsByStatus, formatDateOnly, listActivePresenceEmployees, mapPresenceEmployee, mapPresenceRow, resolvePresenceForEmployee, resolvePresenceHistoryRange, };