文件
hyb-oa/server/src/services/employeeStats.js
T

258 行
7.9 KiB
JavaScript

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