R47 A1-A8:完成薪资管理模块
这个提交包含在:
@@ -6,6 +6,10 @@ function addDays(date, 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);
|
||||
}
|
||||
@@ -24,13 +28,29 @@ function buildPerformanceDistribution(rows) {
|
||||
|
||||
async function buildEmployeeStats(prisma, options = {}) {
|
||||
const timeZone = options.timeZone ?? "Asia/Shanghai";
|
||||
const includeSalarySummary = options.includeSalarySummary ?? 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] = await Promise.all([
|
||||
const [
|
||||
employmentStatusRows,
|
||||
departmentRows,
|
||||
departments,
|
||||
recentHires,
|
||||
recentDepartures,
|
||||
expiringContracts,
|
||||
validCertificateHolders,
|
||||
expiringCertificates,
|
||||
activeCycle,
|
||||
completedCycle,
|
||||
currentMonthSalaryAggregate,
|
||||
previousMonthSalaryAggregate,
|
||||
] = await Promise.all([
|
||||
prisma.employee.groupBy({
|
||||
by: ["employment_status"],
|
||||
where: activeEmployeeWhere,
|
||||
@@ -102,6 +122,28 @@ async function buildEmployeeStats(prisma, options = {}) {
|
||||
where: { status: "completed" },
|
||||
orderBy: [{ completed_at: "desc" }, { end_date: "desc" }],
|
||||
}),
|
||||
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]));
|
||||
@@ -142,6 +184,29 @@ async function buildEmployeeStats(prisma, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -177,6 +242,7 @@ async function buildEmployeeStats(prisma, options = {}) {
|
||||
validHolderCount: validCertificateHolders.length,
|
||||
expiringSoonCount: expiringCertificates,
|
||||
},
|
||||
salarySummary,
|
||||
performanceSummary,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
function parseDateOnly(value) {
|
||||
return value ? new Date(`${value}T00:00:00.000Z`) : null;
|
||||
}
|
||||
|
||||
function formatDateOnly(value) {
|
||||
return value?.toISOString?.()?.slice(0, 10) ?? null;
|
||||
}
|
||||
|
||||
function parsePayMonth(value) {
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}$/.test(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [yearText, monthText] = value.split("-");
|
||||
const year = Number(yearText);
|
||||
const month = Number(monthText);
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startDate = new Date(Date.UTC(year, month - 1, 1));
|
||||
const endDate = new Date(Date.UTC(year, month, 0, 23, 59, 59, 999));
|
||||
return {
|
||||
value,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
function formatPayMonth(value) {
|
||||
return value?.toISOString?.()?.slice(0, 7) ?? null;
|
||||
}
|
||||
|
||||
function shiftMonth(startDate, delta) {
|
||||
return new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth() + delta, 1));
|
||||
}
|
||||
|
||||
function sortSalaryItems(items) {
|
||||
return [...(items ?? [])].sort((left, right) => {
|
||||
if ((left.sort_order ?? 0) !== (right.sort_order ?? 0)) {
|
||||
return (left.sort_order ?? 0) - (right.sort_order ?? 0);
|
||||
}
|
||||
return String(left.name ?? "").localeCompare(String(right.name ?? ""));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMoneyInput(value) {
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
return null;
|
||||
}
|
||||
const rounded = Number(value.toFixed(2));
|
||||
if (Math.abs(value - rounded) > 1e-9) {
|
||||
return null;
|
||||
}
|
||||
return rounded.toFixed(2);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return Number(trimmed).toFixed(2);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function moneyTextToCents(value) {
|
||||
const normalized = normalizeMoneyInput(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const [integerPart, decimalPart] = normalized.split(".");
|
||||
return Number(integerPart) * 100 + Number(decimalPart);
|
||||
}
|
||||
|
||||
function centsToMoneyText(value) {
|
||||
return (value / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function centsToNumber(value) {
|
||||
return Number(centsToMoneyText(value));
|
||||
}
|
||||
|
||||
function toNumber(value) {
|
||||
return value == null ? null : Number(value);
|
||||
}
|
||||
|
||||
function mapSalaryItem(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
itemType: item.item_type,
|
||||
sortOrder: item.sort_order,
|
||||
enabled: item.enabled,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
createdBy: item.created_by ?? null,
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
updatedBy: item.updated_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapEmployeeSalarySnapshotRows(rows) {
|
||||
const snapshots = new Map();
|
||||
|
||||
for (const row of rows) {
|
||||
const key = formatDateOnly(row.effective_date);
|
||||
if (!snapshots.has(key)) {
|
||||
snapshots.set(key, {
|
||||
effectiveDate: key,
|
||||
createdAt: row.created_at?.toISOString?.() ?? null,
|
||||
createdBy: row.created_by ?? null,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
||||
updatedBy: row.updated_by ?? null,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
snapshots.get(key).items.push({
|
||||
salaryItemId: row.salary_item.id,
|
||||
name: row.salary_item.name,
|
||||
itemType: row.salary_item.item_type,
|
||||
sortOrder: row.salary_item.sort_order,
|
||||
monthlyAmount: toNumber(row.monthly_amount) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return [...snapshots.values()]
|
||||
.map((snapshot) => ({
|
||||
...snapshot,
|
||||
items: snapshot.items.sort((left, right) => left.sortOrder - right.sortOrder || left.name.localeCompare(right.name)),
|
||||
}))
|
||||
.sort((left, right) => right.effectiveDate.localeCompare(left.effectiveDate));
|
||||
}
|
||||
|
||||
function calculatePayslipTotals(items) {
|
||||
let grossCents = 0;
|
||||
let deductionCents = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const cents = moneyTextToCents(item.amount);
|
||||
if (cents == null) {
|
||||
throw new Error("invalid_money_value");
|
||||
}
|
||||
|
||||
if (item.itemType === "deduction") {
|
||||
deductionCents += cents;
|
||||
} else {
|
||||
grossCents += cents;
|
||||
}
|
||||
}
|
||||
|
||||
const netCents = grossCents - deductionCents;
|
||||
return {
|
||||
grossAmount: centsToNumber(grossCents),
|
||||
deductionAmount: centsToNumber(deductionCents),
|
||||
netAmount: centsToNumber(netCents),
|
||||
grossAmountText: centsToMoneyText(grossCents),
|
||||
deductionAmountText: centsToMoneyText(deductionCents),
|
||||
netAmountText: centsToMoneyText(netCents),
|
||||
};
|
||||
}
|
||||
|
||||
function mapPayslipItem(item) {
|
||||
return {
|
||||
salaryItemId: item.salary_item.id,
|
||||
name: item.salary_item.name,
|
||||
itemType: item.salary_item.item_type,
|
||||
sortOrder: item.salary_item.sort_order,
|
||||
amount: toNumber(item.amount) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mapPayslip(payslip) {
|
||||
return {
|
||||
id: payslip.id,
|
||||
employeeId: payslip.employee_id,
|
||||
payMonth: formatPayMonth(payslip.pay_month),
|
||||
status: payslip.status,
|
||||
grossAmount: toNumber(payslip.gross_amount) ?? 0,
|
||||
deductionAmount: toNumber(payslip.deduction_amount) ?? 0,
|
||||
netAmount: toNumber(payslip.net_amount) ?? 0,
|
||||
issuedAt: payslip.issued_at?.toISOString?.() ?? null,
|
||||
createdAt: payslip.created_at?.toISOString?.() ?? null,
|
||||
createdBy: payslip.created_by ?? null,
|
||||
updatedAt: payslip.updated_at?.toISOString?.() ?? null,
|
||||
updatedBy: payslip.updated_by ?? null,
|
||||
employee: payslip.employee
|
||||
? {
|
||||
id: payslip.employee.id,
|
||||
employeeNo: payslip.employee.employee_no,
|
||||
name: payslip.employee.name,
|
||||
title: payslip.employee.title,
|
||||
department: payslip.employee.department
|
||||
? {
|
||||
id: payslip.employee.department.id,
|
||||
name: payslip.employee.department.name,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
items: [...(payslip.items ?? [])]
|
||||
.sort((left, right) => {
|
||||
const leftOrder = left.salary_item?.sort_order ?? 0;
|
||||
const rightOrder = right.salary_item?.sort_order ?? 0;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left.salary_item?.name ?? "").localeCompare(String(right.salary_item?.name ?? ""));
|
||||
})
|
||||
.map(mapPayslipItem),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadMonthlyPayslips(prisma, monthStart) {
|
||||
const payslips = await prisma.payslip.findMany({
|
||||
where: { pay_month: monthStart },
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
title: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
items: {
|
||||
include: {
|
||||
salary_item: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const salaryItemMap = new Map();
|
||||
for (const payslip of payslips) {
|
||||
for (const item of payslip.items ?? []) {
|
||||
if (item.salary_item && !salaryItemMap.has(item.salary_item.id)) {
|
||||
salaryItemMap.set(item.salary_item.id, item.salary_item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
salaryItems: sortSalaryItems([...salaryItemMap.values()]).map(mapSalaryItem),
|
||||
payslips: payslips
|
||||
.map(mapPayslip)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
String(left.employee?.employeeNo ?? "").localeCompare(String(right.employee?.employeeNo ?? "")) ||
|
||||
String(left.id).localeCompare(String(right.id)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
calculatePayslipTotals,
|
||||
centsToMoneyText,
|
||||
formatDateOnly,
|
||||
formatPayMonth,
|
||||
loadMonthlyPayslips,
|
||||
mapEmployeeSalarySnapshotRows,
|
||||
mapPayslip,
|
||||
mapSalaryItem,
|
||||
moneyTextToCents,
|
||||
normalizeMoneyInput,
|
||||
parseDateOnly,
|
||||
parsePayMonth,
|
||||
shiftMonth,
|
||||
sortSalaryItems,
|
||||
toNumber,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户