const { z } = require("zod"); const { calculatePayslipTotals, formatDateOnly, loadMonthlyPayslips, mapEmployeeSalarySnapshotRows, mapPayslip, mapSalaryItem, normalizeMoneyInput, parseDateOnly, parsePayMonth, sortSalaryItems, } = require("../services/payroll"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const UUID_SCHEMA = z.string().uuid(); const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const PAY_MONTH_SCHEMA = z.string().regex(/^\d{4}-\d{2}$/); const MONEY_INPUT_SCHEMA = z.union([z.number(), z.string()]); const SALARY_ITEM_TYPE_SCHEMA = z.enum(["earning", "deduction"]); const PAYROLL_MANAGE_PERMISSION = "payroll.manage"; const SALARY_ITEM_CREATE_SCHEMA = z .object({ name: z.string().trim().min(1).max(100), itemType: SALARY_ITEM_TYPE_SCHEMA, sortOrder: z.number().int().min(0).max(999), enabled: z.boolean().optional(), }) .strict(); const SALARY_ITEM_PATCH_SCHEMA = SALARY_ITEM_CREATE_SCHEMA.partial() .strict() .refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); const SALARY_PROFILE_UPSERT_SCHEMA = z .object({ effectiveDate: DATE_SCHEMA, items: z .array( z .object({ salaryItemId: UUID_SCHEMA, monthlyAmount: MONEY_INPUT_SCHEMA, }) .strict(), ) .min(1), }) .strict() .superRefine((value, ctx) => { const seen = new Set(); value.items.forEach((item, index) => { if (seen.has(item.salaryItemId)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["items", index, "salaryItemId"], message: "duplicate_salary_item", }); return; } seen.add(item.salaryItemId); }); }); const PAYSLIP_QUERY_SCHEMA = z .object({ month: PAY_MONTH_SCHEMA, }) .strict(); const PAYSLIP_GENERATE_SCHEMA = z .object({ month: PAY_MONTH_SCHEMA, }) .strict(); const PAYSLIP_ISSUE_SCHEMA = z .object({ month: PAY_MONTH_SCHEMA, }) .strict(); const PAYSLIP_PATCH_SCHEMA = z .object({ items: z .array( z .object({ salaryItemId: UUID_SCHEMA, amount: MONEY_INPUT_SCHEMA, }) .strict(), ) .min(1), }) .strict() .superRefine((value, ctx) => { const seen = new Set(); value.items.forEach((item, index) => { if (seen.has(item.salaryItemId)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["items", index, "salaryItemId"], message: "duplicate_salary_item", }); return; } seen.add(item.salaryItemId); }); }); function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function parseId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { throw buildValidationError({ id: "invalid_uuid" }); } return parsed.data; } function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function parseQuery(schema, query) { const parsed = schema.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function parseMoneyList(items, fieldName) { return items.map((item, index) => { const normalized = normalizeMoneyInput(item[fieldName]); if (!normalized) { throw buildValidationError({ items: { [index]: { [fieldName]: "invalid_money_amount", }, }, }); } return { ...item, [fieldName]: normalized, }; }); } function parseMonthOrThrow(monthText) { const month = parsePayMonth(monthText); if (!month) { throw buildValidationError({ month: "invalid_month" }); } return month; } function isUniqueConstraintError(error) { return Boolean(error && typeof error === "object" && "code" in error && error.code === "P2002"); } async function ensureEmployeeExists(prisma, employeeId) { const employee = await prisma.employee.findUnique({ where: { id: employeeId }, select: { id: true, employee_no: true, name: true, title: true, status: true, department: { select: { id: true, name: true } }, }, }); if (!employee) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } return employee; } async function ensureSalaryItemExists(prisma, salaryItemId) { const item = await prisma.salaryItem.findUnique({ where: { id: salaryItemId }, }); if (!item) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } return item; } async function ensurePayslipExists(prisma, payslipId) { const payslip = await prisma.payslip.findUnique({ where: { id: payslipId }, include: { employee: { select: { id: true, employee_no: true, name: true, title: true, department: { select: { id: true, name: true } }, }, }, items: { include: { salary_item: true, }, }, }, }); if (!payslip) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } return payslip; } function canViewEmployeePayslips(fastify, request, employeeId) { return request.employee.id === employeeId || fastify.hasPermission(request.employee, PAYROLL_MANAGE_PERMISSION); } async function payrollRoutes(fastify) { fastify.get( "/payroll/items", { preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const items = await fastify.prisma.salaryItem.findMany({ orderBy: [{ sort_order: "asc" }, { name: "asc" }], }); return sendData(reply, request, { items: items.map(mapSalaryItem), }); }, ); fastify.post( "/payroll/items", { preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const body = parseBody(SALARY_ITEM_CREATE_SCHEMA, request.body); try { const item = await fastify.prisma.salaryItem.create({ data: { name: body.name, item_type: body.itemType, sort_order: body.sortOrder, enabled: body.enabled ?? true, created_by: request.employee.id, updated_by: request.employee.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "CREATE_SALARY_ITEM", targetType: "payroll", targetId: item.id, extra: { name: item.name, itemType: item.item_type, sortOrder: item.sort_order, enabled: item.enabled, }, }), }); return sendData(reply, request, mapSalaryItem(item)); } catch (error) { if (isUniqueConstraintError(error)) { throw buildValidationError({ name: "duplicate" }); } throw error; } }, ); fastify.patch( "/payroll/items/:itemId", { preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const itemId = parseId(request.params?.itemId); const body = parseBody(SALARY_ITEM_PATCH_SCHEMA, request.body); await ensureSalaryItemExists(fastify.prisma, itemId); try { const item = await fastify.prisma.salaryItem.update({ where: { id: itemId }, data: { ...(body.name !== undefined ? { name: body.name } : {}), ...(body.itemType !== undefined ? { item_type: body.itemType } : {}), ...(body.sortOrder !== undefined ? { sort_order: body.sortOrder } : {}), ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), updated_by: request.employee.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "UPDATE_SALARY_ITEM", targetType: "payroll", targetId: item.id, extra: { name: item.name, itemType: item.item_type, sortOrder: item.sort_order, enabled: item.enabled, }, }), }); return sendData(reply, request, mapSalaryItem(item)); } catch (error) { if (isUniqueConstraintError(error)) { throw buildValidationError({ name: "duplicate" }); } throw error; } }, ); fastify.get( "/payroll/employees/:employeeId/profile", { preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const employeeId = parseId(request.params?.employeeId); await ensureEmployeeExists(fastify.prisma, employeeId); const rows = await fastify.prisma.employeeSalary.findMany({ where: { employee_id: employeeId }, include: { salary_item: true, }, orderBy: [ { effective_date: "desc" }, { salary_item: { sort_order: "asc" } }, { salary_item: { name: "asc" } }, ], }); return sendData(reply, request, { employeeId, snapshots: mapEmployeeSalarySnapshotRows(rows), }); }, ); fastify.put( "/payroll/employees/:employeeId/profile", { preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const employeeId = parseId(request.params?.employeeId); const body = parseBody(SALARY_PROFILE_UPSERT_SCHEMA, request.body); const normalizedItems = parseMoneyList(body.items, "monthlyAmount"); await ensureEmployeeExists(fastify.prisma, employeeId); const salaryItemIds = normalizedItems.map((item) => item.salaryItemId); const salaryItems = await fastify.prisma.salaryItem.findMany({ where: { id: { in: salaryItemIds }, }, }); if (salaryItems.length !== salaryItemIds.length) { throw buildValidationError({ items: "salary_item_not_found" }); } const effectiveDate = parseDateOnly(body.effectiveDate); const rows = await fastify.prisma.$transaction(async (tx) => { await tx.employeeSalary.deleteMany({ where: { employee_id: employeeId, effective_date: effectiveDate, }, }); if (normalizedItems.length) { await tx.employeeSalary.createMany({ data: normalizedItems.map((item) => ({ employee_id: employeeId, salary_item_id: item.salaryItemId, monthly_amount: item.monthlyAmount, effective_date: effectiveDate, created_by: request.employee.id, updated_by: request.employee.id, })), }); } return tx.employeeSalary.findMany({ where: { employee_id: employeeId }, include: { salary_item: true, }, orderBy: [ { effective_date: "desc" }, { salary_item: { sort_order: "asc" } }, { salary_item: { name: "asc" } }, ], }); }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "UPSERT_EMPLOYEE_SALARY_PROFILE", targetType: "payroll", targetId: employeeId, extra: { effectiveDate: body.effectiveDate, itemCount: normalizedItems.length, }, }), }); return sendData(reply, request, { employeeId, snapshots: mapEmployeeSalarySnapshotRows(rows), }); }, ); fastify.get( "/payroll/employees/:employeeId/payslips", { preHandler: fastify.authenticate, }, async (request, reply) => { const employeeId = parseId(request.params?.employeeId); if (!canViewEmployeePayslips(fastify, request, employeeId)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } await ensureEmployeeExists(fastify.prisma, employeeId); const isAdmin = fastify.hasPermission(request.employee, PAYROLL_MANAGE_PERMISSION); const payslips = await fastify.prisma.payslip.findMany({ where: { employee_id: employeeId, ...(isAdmin ? {} : { status: "issued" }), }, orderBy: [{ pay_month: "desc" }, { created_at: "desc" }], include: { employee: { select: { id: true, employee_no: true, name: true, title: true, department: { select: { id: true, name: true } }, }, }, items: { include: { salary_item: true, }, }, }, }); return sendData(reply, request, { employeeId, items: payslips.map(mapPayslip), }); }, ); fastify.get( "/payroll/payslips", { preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const query = parseQuery(PAYSLIP_QUERY_SCHEMA, request.query); const month = parseMonthOrThrow(query.month); const dataset = await loadMonthlyPayslips(fastify.prisma, month.startDate); return sendData(reply, request, { month: month.value, salaryItems: dataset.salaryItems, items: dataset.payslips, summary: { total: dataset.payslips.length, draft: dataset.payslips.filter((item) => item.status === "draft").length, issued: dataset.payslips.filter((item) => item.status === "issued").length, }, }); }, ); fastify.post( "/payroll/payslips/generate", { preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const body = parseBody(PAYSLIP_GENERATE_SCHEMA, request.body); const month = parseMonthOrThrow(body.month); try { const summary = await fastify.prisma.$transaction(async (tx) => { const existingCount = await tx.payslip.count({ where: { pay_month: month.startDate }, }); if (existingCount > 0) { throw buildValidationError({ month: "payslips_month_exists" }, 409); } const employees = await tx.employee.findMany({ where: { status: "active" }, orderBy: [{ employee_no: "asc" }], select: { id: true, employee_no: true, name: true, }, }); if (!employees.length) { throw buildValidationError({ month: "no_active_employees" }, 409); } const salaryItems = sortSalaryItems( await tx.salaryItem.findMany({ where: { enabled: true }, }), ); if (!salaryItems.length) { throw buildValidationError({ items: "salary_items_empty" }, 409); } const salaryRows = await tx.employeeSalary.findMany({ where: { employee_id: { in: employees.map((item) => item.id) }, effective_date: { lte: month.endDate }, }, select: { employee_id: true, salary_item_id: true, monthly_amount: true, effective_date: true, }, }); const latestDateByEmployee = new Map(); for (const row of salaryRows) { const effectiveDate = formatDateOnly(row.effective_date); if (!latestDateByEmployee.has(row.employee_id) || effectiveDate > latestDateByEmployee.get(row.employee_id)) { latestDateByEmployee.set(row.employee_id, effectiveDate); } } const missingEmployees = employees.filter((item) => !latestDateByEmployee.has(item.id)); if (missingEmployees.length) { throw buildValidationError( { month: "salary_profile_missing", employees: missingEmployees.map((item) => ({ id: item.id, employeeNo: item.employee_no, name: item.name, })), }, 409, ); } const snapshotAmountByEmployee = new Map(); for (const row of salaryRows) { const effectiveDate = formatDateOnly(row.effective_date); if (effectiveDate !== latestDateByEmployee.get(row.employee_id)) { continue; } if (!snapshotAmountByEmployee.has(row.employee_id)) { snapshotAmountByEmployee.set(row.employee_id, new Map()); } snapshotAmountByEmployee.get(row.employee_id).set( row.salary_item_id, normalizeMoneyInput(String(row.monthly_amount)) ?? "0.00", ); } for (const employee of employees) { const itemEntries = salaryItems.map((item) => ({ salary_item_id: item.id, amount: snapshotAmountByEmployee.get(employee.id)?.get(item.id) ?? "0.00", itemType: item.item_type, })); const totals = calculatePayslipTotals( itemEntries.map((item) => ({ amount: item.amount, itemType: item.itemType, })), ); await tx.payslip.create({ data: { employee_id: employee.id, pay_month: month.startDate, status: "draft", gross_amount: totals.grossAmountText, deduction_amount: totals.deductionAmountText, net_amount: totals.netAmountText, created_by: request.employee.id, updated_by: request.employee.id, items: { create: itemEntries.map((item) => ({ salary_item_id: item.salary_item_id, amount: item.amount, })), }, }, }); } return { total: employees.length, generated: employees.length, }; }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "GENERATE_PAYSLIPS", targetType: "payroll", extra: { month: month.value, summary, }, }), }); return sendData(reply, request, { month: month.value, summary, }); } catch (error) { if (isUniqueConstraintError(error)) { throw buildValidationError({ month: "payslips_month_exists" }, 409); } throw error; } }, ); fastify.patch( "/payroll/payslips/:payslipId", { preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const payslipId = parseId(request.params?.payslipId); const body = parseBody(PAYSLIP_PATCH_SCHEMA, request.body); const normalizedItems = parseMoneyList(body.items, "amount"); const current = await ensurePayslipExists(fastify.prisma, payslipId); if (current.status !== "draft") { throw buildValidationError({ payslip: "payslip_not_editable" }, 409); } const salaryItemIds = normalizedItems.map((item) => item.salaryItemId); const salaryItems = await fastify.prisma.salaryItem.findMany({ where: { id: { in: salaryItemIds } }, }); if (salaryItems.length !== salaryItemIds.length) { throw buildValidationError({ items: "salary_item_not_found" }); } const salaryItemMap = new Map(salaryItems.map((item) => [item.id, item])); const totals = calculatePayslipTotals( normalizedItems.map((item) => ({ amount: item.amount, itemType: salaryItemMap.get(item.salaryItemId).item_type, })), ); await fastify.prisma.$transaction(async (tx) => { await tx.payslipItem.deleteMany({ where: { payslip_id: payslipId }, }); await tx.payslipItem.createMany({ data: normalizedItems.map((item) => ({ payslip_id: payslipId, salary_item_id: item.salaryItemId, amount: item.amount, })), }); await tx.payslip.update({ where: { id: payslipId }, data: { gross_amount: totals.grossAmountText, deduction_amount: totals.deductionAmountText, net_amount: totals.netAmountText, updated_by: request.employee.id, }, }); }); const updated = await ensurePayslipExists(fastify.prisma, payslipId); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "UPDATE_PAYSLIP", targetType: "payroll", targetId: payslipId, extra: { payMonth: formatDateOnly(updated.pay_month)?.slice(0, 7) ?? null, itemCount: normalizedItems.length, }, }), }); return sendData(reply, request, mapPayslip(updated)); }, ); fastify.post( "/payroll/payslips/issue", { preHandler: fastify.requirePermission(PAYROLL_MANAGE_PERMISSION), }, async (request, reply) => { const body = parseBody(PAYSLIP_ISSUE_SCHEMA, request.body); const month = parseMonthOrThrow(body.month); const summary = await fastify.prisma.$transaction(async (tx) => { const payslips = await tx.payslip.findMany({ where: { pay_month: month.startDate }, select: { id: true, status: true, }, }); if (!payslips.length) { throw buildValidationError({ month: "payslips_not_generated" }, 409); } const draftIds = payslips.filter((item) => item.status === "draft").map((item) => item.id); if (!draftIds.length) { throw buildValidationError({ month: "payslips_already_issued" }, 409); } await tx.payslip.updateMany({ where: { id: { in: draftIds }, }, data: { status: "issued", issued_at: new Date(), updated_by: request.employee.id, }, }); return { total: payslips.length, issued: draftIds.length, }; }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ISSUE_PAYSLIPS", targetType: "payroll", extra: { month: month.value, summary, }, }), }); return sendData(reply, request, { month: month.value, summary, }); }, ); } module.exports = payrollRoutes;