R47 A1-A8:完成薪资管理模块

这个提交包含在:
2026-03-29 19:05:55 +08:00
父节点 c0384b5bbf
当前提交 7ba72ca87d
共修改 25 个文件,包含 3598 行新增和 4 行删除
+106
查看文件
@@ -14,6 +14,7 @@ const {
buildTeamPresenceHistory,
resolvePresenceHistoryRange,
} = require("../services/presenceHistory");
const { loadMonthlyPayslips, parsePayMonth } = require("../services/payroll");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
@@ -74,6 +75,11 @@ const EMPLOYEE_EXPORT_QUERY_SCHEMA = z.object({
limit: z.coerce.number().int().min(1).max(20000).optional(),
});
const PAYSLIP_EXPORT_QUERY_SCHEMA = z.object({
format: EXPORT_FORMAT_SCHEMA.optional(),
month: z.string().regex(/^\d{4}-\d{2}$/),
});
const PRESENCE_HISTORY_EXPORT_QUERY_SCHEMA = z.object({
format: EXPORT_FORMAT_SCHEMA.optional(),
dateFrom: DATE_ONLY_SCHEMA.optional(),
@@ -809,6 +815,61 @@ async function buildEmployeesXlsx(prisma, where, limit) {
return workbook.xlsx.writeBuffer();
}
function buildPayslipExportHeader(salaryItems) {
return [
"工号",
"姓名",
"部门",
...salaryItems.map((item) => item.name),
"应发合计",
"应扣合计",
"实发合计",
];
}
function buildPayslipExportRow(payslip, salaryItems) {
const amountByItemId = new Map((payslip.items ?? []).map((item) => [item.salaryItemId, item.amount]));
return [
payslip.employee?.employeeNo ?? "",
payslip.employee?.name ?? "",
payslip.employee?.department?.name ?? "",
...salaryItems.map((item) => String(amountByItemId.get(item.id) ?? 0)),
String(payslip.grossAmount ?? 0),
String(payslip.deductionAmount ?? 0),
String(payslip.netAmount ?? 0),
];
}
function buildPayslipsCsv(dataset) {
const rows = ["\ufeff", csvRow(buildPayslipExportHeader(dataset.salaryItems))];
for (const payslip of dataset.payslips) {
rows.push(csvRow(buildPayslipExportRow(payslip, dataset.salaryItems)));
}
return rows.join("");
}
async function buildPayslipsXlsx(dataset) {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("payslips");
sheet.columns = buildPayslipExportHeader(dataset.salaryItems).map((header) => ({
header,
key: header,
width: header.length > 8 ? 18 : 14,
}));
for (const payslip of dataset.payslips) {
const row = buildPayslipExportRow(payslip, dataset.salaryItems);
const rowObject = {};
buildPayslipExportHeader(dataset.salaryItems).forEach((header, index) => {
rowObject[header] = row[index];
});
sheet.addRow(rowObject);
}
return workbook.xlsx.writeBuffer();
}
function presenceStatusLabel(status) {
if (status === "office") return "在岗";
if (status === "remote") return "居家";
@@ -938,6 +999,51 @@ async function exportsRoutes(fastify) {
},
);
fastify.get(
"/exports/payslips",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const query = parseQuery(PAYSLIP_EXPORT_QUERY_SCHEMA, request.query);
const month = parsePayMonth(query.month);
if (!month) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { month: "invalid_month" },
});
}
const dataset = await loadMonthlyPayslips(fastify.prisma, month.startDate);
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EXPORT_PAYSLIPS",
targetType: "export",
extra: {
format: resolveExportFormat(query),
month: query.month,
matchedTotal: dataset.payslips.length,
},
}),
});
const format = resolveExportFormat(query);
const filename = exportFilename(`payslips-${query.month}`, format);
setExportHeaders(reply, format, filename);
if (format === "xlsx") {
const buffer = await buildPayslipsXlsx(dataset);
return reply.send(Buffer.from(buffer));
}
return reply.send(buildPayslipsCsv(dataset));
},
);
fastify.get(
"/exports/presence-history",
{