258 行
7.9 KiB
JavaScript
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,
|
|
};
|