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

这个提交包含在:
2026-03-29 19:05:55 +08:00
父节点 c0384b5bbf
当前提交 7ba72ca87d
共修改 25 个文件,包含 3598 行新增和 4 行删除
+2
查看文件
@@ -32,6 +32,7 @@ const masterDataHolidayCalendarsRoutes = require("./routes/masterDataHolidayCale
const masterDataSuppliersRoutes = require("./routes/masterDataSuppliers");
const employeesRoutes = require("./routes/employees");
const performanceRoutes = require("./routes/performance");
const payrollRoutes = require("./routes/payroll");
const expenseTypesRoutes = require("./routes/expenseTypes");
const configReadRoutes = require("./routes/configRead");
const approvalRulesRoutes = require("./routes/approvalRules");
@@ -315,6 +316,7 @@ async function buildApp() {
await app.register(usersRoutes, { prefix: "/api" });
await app.register(employeesRoutes, { prefix: "/api" });
await app.register(performanceRoutes, { prefix: "/api" });
await app.register(payrollRoutes, { prefix: "/api" });
await app.register(departmentsRoutes, { prefix: "/api" });
await app.register(masterDataRoutes, { prefix: "/api" });
await app.register(masterDataHolidayCalendarsRoutes, { prefix: "/api" });
+1
查看文件
@@ -740,6 +740,7 @@ async function employeesRoutes(fastify) {
const stats = await buildEmployeeStats(fastify.prisma, {
timeZone: fastify.config.appTimezone,
includeSalarySummary: fastify.hasPermission(request.employee, "system.users"),
});
return sendData(reply, request, stats);
});
+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",
{
+817
查看文件
@@ -0,0 +1,817 @@
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 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, "system.users");
}
async function payrollRoutes(fastify) {
fastify.get(
"/payroll/items",
{
preHandler: fastify.requirePermission("system.users"),
},
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("system.users"),
},
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("system.users"),
},
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("system.users"),
},
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("system.users"),
},
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, "system.users");
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("system.users"),
},
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("system.users"),
},
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("system.users"),
},
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("system.users"),
},
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;
+67 -1
查看文件
@@ -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,
};
}
+276
查看文件
@@ -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,
};