R47 A1-A8:完成薪资管理模块
这个提交包含在:
@@ -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",
|
||||
{
|
||||
|
||||
在新工单中引用
屏蔽一个用户