const { currentDateString, parseDateOnlyString } = require("../utils/date"); function addDays(date, days) { const next = new Date(date); next.setUTCDate(next.getUTCDate() + days); return next; } function shiftMonth(date, months) { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1)); } function toNumber(value) { return value == null ? null : Number(value); } function buildPerformanceDistribution(rows) { const countByScore = new Map(rows.map((item) => [toNumber(item.overall_score), item._count._all])); const items = []; for (let score = 1; score <= 5; score += 0.5) { items.push({ score: Number(score.toFixed(1)), count: countByScore.get(Number(score.toFixed(1))) ?? 0, }); } return items; } async function buildEmployeeStats(prisma, options = {}) { const timeZone = options.timeZone ?? "Asia/Shanghai"; const includeSalarySummary = options.includeSalarySummary ?? true; const includePerformanceSummary = options.includePerformanceSummary ?? true; const todayString = currentDateString(timeZone); const today = parseDateOnlyString(todayString); const last30DateFrom = addDays(today, -29); const next30DateTo = addDays(today, 30); const currentMonthStart = parseDateOnlyString(`${todayString.slice(0, 7)}-01`); const previousMonthStart = shiftMonth(currentMonthStart, -1); const activeEmployeeWhere = { status: "active" }; const [ employmentStatusRows, departmentRows, departments, recentHires, recentDepartures, expiringContracts, validCertificateHolders, expiringCertificates, activeCycle, completedCycle, currentMonthSalaryAggregate, previousMonthSalaryAggregate, ] = await Promise.all([ prisma.employee.groupBy({ by: ["employment_status"], where: activeEmployeeWhere, _count: { _all: true }, }), prisma.employee.groupBy({ by: ["department_id"], where: activeEmployeeWhere, _count: { _all: true }, }), prisma.department.findMany({ where: { archived_at: null }, select: { id: true, name: true }, }), prisma.employee.count({ where: { hire_date: { gte: last30DateFrom, lte: new Date(`${todayString}T23:59:59.999Z`), }, }, }), prisma.employee.count({ where: { last_working_date: { gte: last30DateFrom, lte: new Date(`${todayString}T23:59:59.999Z`), }, }, }), prisma.employeeContract.count({ where: { terminated_date: null, end_date: { gte: today, lte: new Date(`${next30DateTo.toISOString().slice(0, 10)}T23:59:59.999Z`), }, employee: activeEmployeeWhere, }, }), prisma.employeeCertificate.groupBy({ by: ["employee_id"], where: { employee: activeEmployeeWhere, OR: [ { expiry_date: null }, { expiry_date: { gte: today, }, }, ], }, }), prisma.employeeCertificate.count({ where: { employee: activeEmployeeWhere, expiry_date: { gte: today, lte: new Date(`${next30DateTo.toISOString().slice(0, 10)}T23:59:59.999Z`), }, }, }), includePerformanceSummary ? prisma.performanceReviewCycle.findFirst({ where: { status: "active" }, orderBy: [{ activated_at: "desc" }, { start_date: "desc" }], }) : Promise.resolve(null), includePerformanceSummary ? prisma.performanceReviewCycle.findFirst({ where: { status: "completed" }, orderBy: [{ completed_at: "desc" }, { end_date: "desc" }], }) : Promise.resolve(null), includeSalarySummary ? prisma.payslip.aggregate({ where: { pay_month: currentMonthStart, status: "issued", }, _sum: { net_amount: true }, _avg: { net_amount: true }, _count: { _all: true }, }) : Promise.resolve(null), includeSalarySummary ? prisma.payslip.aggregate({ where: { pay_month: previousMonthStart, status: "issued", }, _sum: { net_amount: true }, _avg: { net_amount: true }, _count: { _all: true }, }) : Promise.resolve(null), ]); const departmentMap = new Map(departments.map((item) => [item.id, item.name])); const performanceCycle = activeCycle ?? completedCycle ?? null; let performanceSummary = { cycle: null, averageScore: null, reviewedCount: 0, distribution: [], }; if (includePerformanceSummary && performanceCycle) { const [reviewAggregate, reviewDistribution] = await Promise.all([ prisma.performanceReview.aggregate({ where: { cycle_id: performanceCycle.id }, _avg: { overall_score: true }, _count: { _all: true }, }), prisma.performanceReview.groupBy({ by: ["overall_score"], where: { cycle_id: performanceCycle.id }, _count: { _all: true }, }), ]); performanceSummary = { cycle: { id: performanceCycle.id, name: performanceCycle.name, cycleType: performanceCycle.cycle_type, status: performanceCycle.status, startDate: performanceCycle.start_date?.toISOString?.()?.slice(0, 10) ?? null, endDate: performanceCycle.end_date?.toISOString?.()?.slice(0, 10) ?? null, }, averageScore: toNumber(reviewAggregate._avg.overall_score), reviewedCount: reviewAggregate._count._all, distribution: buildPerformanceDistribution(reviewDistribution), }; } const salarySummary = includeSalarySummary ? { currentMonth: currentMonthSalaryAggregate?._count?._all > 0 ? { month: currentMonthStart.toISOString().slice(0, 7), totalNetAmount: toNumber(currentMonthSalaryAggregate._sum.net_amount) ?? 0, averageNetAmount: toNumber(currentMonthSalaryAggregate._avg.net_amount) ?? 0, issuedCount: currentMonthSalaryAggregate._count._all, } : null, previousMonth: previousMonthSalaryAggregate?._count?._all > 0 ? { month: previousMonthStart.toISOString().slice(0, 7), totalNetAmount: toNumber(previousMonthSalaryAggregate._sum.net_amount) ?? 0, averageNetAmount: toNumber(previousMonthSalaryAggregate._avg.net_amount) ?? 0, issuedCount: previousMonthSalaryAggregate._count._all, } : null, } : null; return { asOfDate: todayString, employmentStatusSummary: { total: employmentStatusRows.reduce((sum, item) => sum + item._count._all, 0), items: employmentStatusRows .map((item) => ({ status: item.employment_status, count: item._count._all, })) .sort((left, right) => right.count - left.count || left.status.localeCompare(right.status)), }, departmentHeadcount: departmentRows .map((item) => ({ departmentId: item.department_id, departmentName: departmentMap.get(item.department_id) ?? "未知部门", count: item._count._all, })) .sort((left, right) => right.count - left.count || left.departmentName.localeCompare(right.departmentName)), recentMovements: { dateFrom: last30DateFrom.toISOString().slice(0, 10), dateTo: todayString, hires: recentHires, departures: recentDepartures, }, expiringContracts: { dateFrom: todayString, dateTo: next30DateTo.toISOString().slice(0, 10), count: expiringContracts, }, certificateSummary: { dateFrom: todayString, dateTo: next30DateTo.toISOString().slice(0, 10), validHolderCount: validCertificateHolders.length, expiringSoonCount: expiringCertificates, }, salarySummary, performanceSummary, }; } module.exports = { buildEmployeeStats, };