R47 A1-A8:完成薪资管理模块
这个提交包含在:
@@ -0,0 +1,108 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SalaryItemType" AS ENUM ('earning', 'deduction');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PayslipStatus" AS ENUM ('draft', 'issued');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "salary_items" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"item_type" "SalaryItemType" NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" UUID,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" UUID,
|
||||
|
||||
CONSTRAINT "salary_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "employee_salaries" (
|
||||
"id" UUID NOT NULL,
|
||||
"employee_id" UUID NOT NULL,
|
||||
"salary_item_id" UUID NOT NULL,
|
||||
"monthly_amount" DECIMAL(12,2) NOT NULL,
|
||||
"effective_date" DATE NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" UUID,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" UUID,
|
||||
|
||||
CONSTRAINT "employee_salaries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "payslips" (
|
||||
"id" UUID NOT NULL,
|
||||
"employee_id" UUID NOT NULL,
|
||||
"pay_month" DATE NOT NULL,
|
||||
"status" "PayslipStatus" NOT NULL DEFAULT 'draft',
|
||||
"gross_amount" DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
"deduction_amount" DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
"net_amount" DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
"issued_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" UUID,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" UUID,
|
||||
|
||||
CONSTRAINT "payslips_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "payslip_items" (
|
||||
"id" UUID NOT NULL,
|
||||
"payslip_id" UUID NOT NULL,
|
||||
"salary_item_id" UUID NOT NULL,
|
||||
"amount" DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "payslip_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "salary_items_name_key" ON "salary_items"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_salary_items_enabled_sort" ON "salary_items"("enabled", "sort_order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "employee_salaries_employee_item_effective_ux" ON "employee_salaries"("employee_id", "salary_item_id", "effective_date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_employee_salaries_employee_effective" ON "employee_salaries"("employee_id", "effective_date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_employee_salaries_salary_item" ON "employee_salaries"("salary_item_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "payslips_employee_month_ux" ON "payslips"("employee_id", "pay_month");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_payslips_month_status" ON "payslips"("pay_month", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_payslips_employee_month" ON "payslips"("employee_id", "pay_month");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "payslip_items_payslip_salary_item_ux" ON "payslip_items"("payslip_id", "salary_item_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_payslip_items_salary_item" ON "payslip_items"("salary_item_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "employee_salaries" ADD CONSTRAINT "employee_salaries_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "employee_salaries" ADD CONSTRAINT "employee_salaries_salary_item_id_fkey" FOREIGN KEY ("salary_item_id") REFERENCES "salary_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "payslips" ADD CONSTRAINT "payslips_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "payslip_items" ADD CONSTRAINT "payslip_items_payslip_id_fkey" FOREIGN KEY ("payslip_id") REFERENCES "payslips"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "payslip_items" ADD CONSTRAINT "payslip_items_salary_item_id_fkey" FOREIGN KEY ("salary_item_id") REFERENCES "salary_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -363,6 +363,16 @@ enum EmployeeTrainingStatus {
|
||||
cancelled
|
||||
}
|
||||
|
||||
enum SalaryItemType {
|
||||
earning
|
||||
deduction
|
||||
}
|
||||
|
||||
enum PayslipStatus {
|
||||
draft
|
||||
issued
|
||||
}
|
||||
|
||||
enum PerformanceReviewCycleType {
|
||||
monthly
|
||||
quarterly
|
||||
@@ -630,6 +640,8 @@ model Employee {
|
||||
contracts EmployeeContract[]
|
||||
trainings EmployeeTraining[]
|
||||
certificates EmployeeCertificate[]
|
||||
salary_profiles EmployeeSalary[]
|
||||
payslips Payslip[]
|
||||
performance_reviews_received PerformanceReview[] @relation("PerformanceReviewEmployee")
|
||||
performance_reviews_authored PerformanceReview[] @relation("PerformanceReviewReviewer")
|
||||
|
||||
@@ -2089,6 +2101,84 @@ model EmployeeCertificate {
|
||||
@@map("employee_certificates")
|
||||
}
|
||||
|
||||
model SalaryItem {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @unique
|
||||
item_type SalaryItemType
|
||||
sort_order Int
|
||||
enabled Boolean @default(true)
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
employee_salaries EmployeeSalary[]
|
||||
payslip_items PayslipItem[]
|
||||
|
||||
@@index([enabled, sort_order], map: "idx_salary_items_enabled_sort")
|
||||
@@map("salary_items")
|
||||
}
|
||||
|
||||
model EmployeeSalary {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
salary_item_id String @db.Uuid
|
||||
monthly_amount Decimal @db.Decimal(12, 2)
|
||||
effective_date DateTime @db.Date
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
salary_item SalaryItem @relation(fields: [salary_item_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([employee_id, salary_item_id, effective_date], map: "employee_salaries_employee_item_effective_ux")
|
||||
@@index([employee_id, effective_date], map: "idx_employee_salaries_employee_effective")
|
||||
@@index([salary_item_id], map: "idx_employee_salaries_salary_item")
|
||||
@@map("employee_salaries")
|
||||
}
|
||||
|
||||
model Payslip {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
pay_month DateTime @db.Date
|
||||
status PayslipStatus @default(draft)
|
||||
gross_amount Decimal @default(0) @db.Decimal(12, 2)
|
||||
deduction_amount Decimal @default(0) @db.Decimal(12, 2)
|
||||
net_amount Decimal @default(0) @db.Decimal(12, 2)
|
||||
issued_at DateTime?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
items PayslipItem[]
|
||||
|
||||
@@unique([employee_id, pay_month], map: "payslips_employee_month_ux")
|
||||
@@index([pay_month, status], map: "idx_payslips_month_status")
|
||||
@@index([employee_id, pay_month], map: "idx_payslips_employee_month")
|
||||
@@map("payslips")
|
||||
}
|
||||
|
||||
model PayslipItem {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payslip_id String @db.Uuid
|
||||
salary_item_id String @db.Uuid
|
||||
amount Decimal @default(0) @db.Decimal(12, 2)
|
||||
|
||||
payslip Payslip @relation(fields: [payslip_id], references: [id], onDelete: Cascade)
|
||||
salary_item SalaryItem @relation(fields: [salary_item_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([payslip_id, salary_item_id], map: "payslip_items_payslip_salary_item_ux")
|
||||
@@index([salary_item_id], map: "idx_payslip_items_salary_item")
|
||||
@@map("payslip_items")
|
||||
}
|
||||
|
||||
model PerformanceReviewCycle {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -53,6 +53,18 @@ async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
|
||||
{ overall_score: 4, _count: { _all: 3 } },
|
||||
{ overall_score: 4.5, _count: { _all: 2 } },
|
||||
]);
|
||||
const payslipAggregate = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_sum: { net_amount: 18800 },
|
||||
_avg: { net_amount: 9400 },
|
||||
_count: { _all: 2 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_sum: { net_amount: 17600 },
|
||||
_avg: { net_amount: 8800 },
|
||||
_count: { _all: 2 },
|
||||
});
|
||||
|
||||
app.decorate("prisma", {
|
||||
employee: {
|
||||
@@ -80,6 +92,9 @@ async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
|
||||
aggregate: stats?.performanceReviewAggregate ?? performanceReviewAggregate,
|
||||
groupBy: stats?.performanceReviewGroupBy ?? performanceReviewGroupBy,
|
||||
},
|
||||
payslip: {
|
||||
aggregate: stats?.payslipAggregate ?? payslipAggregate,
|
||||
},
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = {
|
||||
@@ -123,11 +138,12 @@ async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
|
||||
performanceCycleFindFirst,
|
||||
performanceReviewAggregate,
|
||||
performanceReviewGroupBy,
|
||||
payslipAggregate,
|
||||
};
|
||||
}
|
||||
|
||||
describe("employees stats route", () => {
|
||||
it("returns aggregated employee stats for read-only viewers", async () => {
|
||||
it("returns aggregated employee stats for read-only viewers without salary data", async () => {
|
||||
const {
|
||||
app,
|
||||
employeeGroupBy,
|
||||
@@ -139,6 +155,7 @@ describe("employees stats route", () => {
|
||||
performanceCycleFindFirst,
|
||||
performanceReviewAggregate,
|
||||
performanceReviewGroupBy,
|
||||
payslipAggregate,
|
||||
} = await buildApp({
|
||||
permissionCodes: ["report.dashboard.view"],
|
||||
});
|
||||
@@ -187,6 +204,7 @@ describe("employees stats route", () => {
|
||||
{ score: 5, count: 0 },
|
||||
],
|
||||
});
|
||||
expect(payload.data.salarySummary).toBeNull();
|
||||
expect(employeeGroupBy).toHaveBeenCalledTimes(2);
|
||||
expect(employeeCount).toHaveBeenCalledTimes(2);
|
||||
expect(departmentFindMany).toHaveBeenCalledTimes(1);
|
||||
@@ -196,6 +214,37 @@ describe("employees stats route", () => {
|
||||
expect(performanceCycleFindFirst).toHaveBeenCalledTimes(2);
|
||||
expect(performanceReviewAggregate).toHaveBeenCalledTimes(1);
|
||||
expect(performanceReviewGroupBy).toHaveBeenCalledTimes(1);
|
||||
expect(payslipAggregate).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns salary summary for system.users viewers", async () => {
|
||||
const { app, payslipAggregate } = await buildApp({
|
||||
permissionCodes: ["system.users"],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/employees/stats",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.salarySummary).toEqual({
|
||||
currentMonth: {
|
||||
month: expect.any(String),
|
||||
totalNetAmount: 18800,
|
||||
averageNetAmount: 9400,
|
||||
issuedCount: 2,
|
||||
},
|
||||
previousMonth: {
|
||||
month: expect.any(String),
|
||||
totalNetAmount: 17600,
|
||||
averageNetAmount: 8800,
|
||||
issuedCount: 2,
|
||||
},
|
||||
});
|
||||
expect(payslipAggregate).toHaveBeenCalledTimes(2);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
const ExcelJS = require("exceljs");
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const exportsRoutes = require("../../../src/routes/exports");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const ADMIN_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
|
||||
function buildPayslipRow() {
|
||||
return {
|
||||
id: "payslip-1",
|
||||
employee_id: "employee-1",
|
||||
pay_month: new Date("2026-04-01T00:00:00.000Z"),
|
||||
status: "issued",
|
||||
gross_amount: "10000.00",
|
||||
deduction_amount: "600.00",
|
||||
net_amount: "9400.00",
|
||||
issued_at: new Date("2026-04-30T09:00:00.000Z"),
|
||||
created_at: new Date("2026-04-30T08:00:00.000Z"),
|
||||
created_by: ADMIN_ID,
|
||||
updated_at: new Date("2026-04-30T09:00:00.000Z"),
|
||||
updated_by: ADMIN_ID,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp({
|
||||
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] },
|
||||
} = {}) {
|
||||
const app = Fastify();
|
||||
const salaryItems = [
|
||||
{
|
||||
id: "salary-item-1",
|
||||
name: "基本工资",
|
||||
item_type: "earning",
|
||||
sort_order: 10,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "salary-item-2",
|
||||
name: "社保扣除",
|
||||
item_type: "deduction",
|
||||
sort_order: 20,
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
const payslips = [buildPayslipRow()];
|
||||
const payslipItems = [
|
||||
{
|
||||
id: "item-1",
|
||||
payslip_id: "payslip-1",
|
||||
salary_item_id: "salary-item-1",
|
||||
amount: "10000.00",
|
||||
salary_item: salaryItems[0],
|
||||
},
|
||||
{
|
||||
id: "item-2",
|
||||
payslip_id: "payslip-1",
|
||||
salary_item_id: "salary-item-2",
|
||||
amount: "600.00",
|
||||
salary_item: salaryItems[1],
|
||||
},
|
||||
];
|
||||
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
const codes = actor.permissionCodes ?? [];
|
||||
if (!codes.includes("*") && !codes.includes(permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate("hasPermission", (employee, permissionCode) => {
|
||||
const codes = employee.permissionCodes ?? [];
|
||||
return codes.includes("*") || codes.includes(permissionCode);
|
||||
});
|
||||
app.decorate("prisma", {
|
||||
payslip: {
|
||||
findMany: vi.fn(async () =>
|
||||
payslips.map((item) => ({
|
||||
...item,
|
||||
employee: {
|
||||
id: "employee-1",
|
||||
employee_no: "E001",
|
||||
name: "Alice",
|
||||
title: "专员",
|
||||
department: { id: "dept-1", name: "行政部" },
|
||||
},
|
||||
items: payslipItems.filter((row) => row.payslip_id === item.id),
|
||||
})),
|
||||
),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
reply.status(error.statusCode || 500).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code || "SYS_001",
|
||||
message: error.message,
|
||||
details: error.details ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(exportsRoutes, { prefix: "/api" });
|
||||
return { app };
|
||||
}
|
||||
|
||||
describe("payroll export routes", () => {
|
||||
it("exports monthly payslips in csv and xlsx formats", async () => {
|
||||
const { app } = await buildApp();
|
||||
|
||||
const csvResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/exports/payslips?month=2026-04&format=csv",
|
||||
});
|
||||
|
||||
expect(csvResponse.statusCode).toBe(200);
|
||||
expect(csvResponse.headers["content-type"]).toContain("text/csv");
|
||||
const csvBody = csvResponse.body;
|
||||
expect(csvBody).toContain("工号,姓名,部门,基本工资,社保扣除,应发合计,应扣合计,实发合计");
|
||||
expect(csvBody).toContain("E001,Alice,行政部,10000,600,10000,600,9400");
|
||||
|
||||
const xlsxResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/exports/payslips?month=2026-04&format=xlsx",
|
||||
});
|
||||
|
||||
expect(xlsxResponse.statusCode).toBe(200);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(Buffer.from(xlsxResponse.rawPayload));
|
||||
const sheet = workbook.getWorksheet("payslips");
|
||||
expect(sheet.getRow(1).values.slice(1, 9)).toEqual([
|
||||
"工号",
|
||||
"姓名",
|
||||
"部门",
|
||||
"基本工资",
|
||||
"社保扣除",
|
||||
"应发合计",
|
||||
"应扣合计",
|
||||
"实发合计",
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects non-admin exports", async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: {
|
||||
id: "employee-1",
|
||||
permissionCodes: ["employee"],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/exports/payslips?month=2026-04&format=csv",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const payrollRoutes = require("../../../src/routes/payroll");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const ADMIN_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
|
||||
function buildSalaryItem(overrides = {}) {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
name: "基本工资",
|
||||
item_type: "earning",
|
||||
sort_order: 10,
|
||||
enabled: true,
|
||||
created_at: new Date("2026-03-29T08:00:00.000Z"),
|
||||
created_by: ADMIN_ID,
|
||||
updated_at: new Date("2026-03-29T08:00:00.000Z"),
|
||||
updated_by: ADMIN_ID,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp({
|
||||
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] },
|
||||
} = {}) {
|
||||
const app = Fastify();
|
||||
const state = {
|
||||
salaryItems: [buildSalaryItem()],
|
||||
};
|
||||
|
||||
const salaryItem = {
|
||||
findMany: vi.fn(async () => [...state.salaryItems].sort((left, right) => left.sort_order - right.sort_order)),
|
||||
findUnique: vi.fn(async ({ where }) => state.salaryItems.find((item) => item.id === where.id) ?? null),
|
||||
create: vi.fn(async ({ data }) => {
|
||||
const item = buildSalaryItem({
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
name: data.name,
|
||||
item_type: data.item_type,
|
||||
sort_order: data.sort_order,
|
||||
enabled: data.enabled,
|
||||
created_by: data.created_by,
|
||||
updated_by: data.updated_by,
|
||||
});
|
||||
state.salaryItems.push(item);
|
||||
return item;
|
||||
}),
|
||||
update: vi.fn(async ({ where, data }) => {
|
||||
const index = state.salaryItems.findIndex((item) => item.id === where.id);
|
||||
state.salaryItems[index] = {
|
||||
...state.salaryItems[index],
|
||||
...data,
|
||||
};
|
||||
return state.salaryItems[index];
|
||||
}),
|
||||
};
|
||||
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
const codes = actor.permissionCodes ?? [];
|
||||
if (!codes.includes("*") && !codes.includes(permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate("hasPermission", (employee, permissionCode) => {
|
||||
const codes = employee.permissionCodes ?? [];
|
||||
return codes.includes("*") || codes.includes(permissionCode);
|
||||
});
|
||||
app.decorate("prisma", {
|
||||
salaryItem,
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
reply.status(error.statusCode || 500).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code || "SYS_001",
|
||||
message: error.message,
|
||||
details: error.details ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(payrollRoutes, { prefix: "/api" });
|
||||
return { app, state, salaryItem };
|
||||
}
|
||||
|
||||
describe("payroll salary item routes", () => {
|
||||
it("allows admins to create, list and patch salary items", async () => {
|
||||
const { app, state, salaryItem } = await buildApp();
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/payroll/items",
|
||||
payload: {
|
||||
name: "岗位津贴",
|
||||
itemType: "earning",
|
||||
sortOrder: 20,
|
||||
},
|
||||
});
|
||||
|
||||
expect(createResponse.statusCode).toBe(200);
|
||||
expect(createResponse.json().data).toMatchObject({
|
||||
name: "岗位津贴",
|
||||
itemType: "earning",
|
||||
sortOrder: 20,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const patchResponse = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/payroll/items/${state.salaryItems[1].id}`,
|
||||
payload: {
|
||||
itemType: "deduction",
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(patchResponse.statusCode).toBe(200);
|
||||
expect(patchResponse.json().data).toMatchObject({
|
||||
name: "岗位津贴",
|
||||
itemType: "deduction",
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const listResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/payroll/items",
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json().data.items).toHaveLength(2);
|
||||
expect(salaryItem.create).toHaveBeenCalledTimes(1);
|
||||
expect(salaryItem.update).toHaveBeenCalledTimes(1);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects non-admin users", async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: {
|
||||
id: "employee-1",
|
||||
permissionCodes: ["employee"],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/payroll/items",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const payrollRoutes = require("../../../src/routes/payroll");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const ADMIN_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const EMPLOYEE_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const OTHER_EMPLOYEE_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||
|
||||
function buildEmployee(overrides = {}) {
|
||||
return {
|
||||
id: EMPLOYEE_ID,
|
||||
employee_no: "E001",
|
||||
name: "Alice",
|
||||
title: "专员",
|
||||
status: "active",
|
||||
department: { id: "dept-1", name: "行政部" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSalaryItem(overrides = {}) {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
name: "基本工资",
|
||||
item_type: "earning",
|
||||
sort_order: 10,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSalaryRow(overrides = {}) {
|
||||
return {
|
||||
id: "salary-1",
|
||||
employee_id: EMPLOYEE_ID,
|
||||
salary_item_id: "11111111-1111-4111-8111-111111111111",
|
||||
monthly_amount: "10000.00",
|
||||
effective_date: new Date("2026-04-01T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const PAYSLIP_IDS = [
|
||||
"dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||
"eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
||||
"ffffffff-ffff-4fff-8fff-ffffffffffff",
|
||||
];
|
||||
|
||||
async function buildApp({
|
||||
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] },
|
||||
omitProfileFor = [],
|
||||
} = {}) {
|
||||
const app = Fastify();
|
||||
const state = {
|
||||
employees: [
|
||||
buildEmployee(),
|
||||
buildEmployee({
|
||||
id: OTHER_EMPLOYEE_ID,
|
||||
employee_no: "E002",
|
||||
name: "Bob",
|
||||
}),
|
||||
],
|
||||
salaryItems: [
|
||||
buildSalaryItem(),
|
||||
buildSalaryItem({
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
name: "社保扣除",
|
||||
item_type: "deduction",
|
||||
sort_order: 20,
|
||||
}),
|
||||
],
|
||||
salaryRows: [
|
||||
buildSalaryRow(),
|
||||
buildSalaryRow({
|
||||
id: "salary-2",
|
||||
employee_id: EMPLOYEE_ID,
|
||||
salary_item_id: "22222222-2222-4222-8222-222222222222",
|
||||
monthly_amount: "600.00",
|
||||
}),
|
||||
buildSalaryRow({
|
||||
id: "salary-3",
|
||||
employee_id: OTHER_EMPLOYEE_ID,
|
||||
monthly_amount: "9000.00",
|
||||
}),
|
||||
buildSalaryRow({
|
||||
id: "salary-4",
|
||||
employee_id: OTHER_EMPLOYEE_ID,
|
||||
salary_item_id: "22222222-2222-4222-8222-222222222222",
|
||||
monthly_amount: "500.00",
|
||||
}),
|
||||
].filter((item) => !omitProfileFor.includes(item.employee_id)),
|
||||
payslips: [],
|
||||
payslipItems: [],
|
||||
};
|
||||
|
||||
function hydratePayslip(payslip) {
|
||||
return {
|
||||
...payslip,
|
||||
employee: state.employees.find((item) => item.id === payslip.employee_id),
|
||||
items: state.payslipItems
|
||||
.filter((item) => item.payslip_id === payslip.id)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
salary_item: state.salaryItems.find((salaryItem) => salaryItem.id === item.salary_item_id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const payslip = {
|
||||
count: vi.fn(async ({ where }) => state.payslips.filter((item) => item.pay_month.getTime() === where.pay_month.getTime()).length),
|
||||
findMany: vi.fn(async ({ where, select, include }) => {
|
||||
const filtered = state.payslips.filter((item) => {
|
||||
if (where?.pay_month && item.pay_month.getTime() !== where.pay_month.getTime()) {
|
||||
return false;
|
||||
}
|
||||
if (where?.employee_id && item.employee_id !== where.employee_id) {
|
||||
return false;
|
||||
}
|
||||
if (where?.status && item.status !== where.status) {
|
||||
return false;
|
||||
}
|
||||
if (where?.id?.in && !where.id.in.includes(item.id)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (select) {
|
||||
return filtered.map((item) => ({
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
}));
|
||||
}
|
||||
|
||||
if (include) {
|
||||
return filtered.map(hydratePayslip);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}),
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
const payslipRow = state.payslips.find((item) => item.id === where.id);
|
||||
return payslipRow ? hydratePayslip(payslipRow) : null;
|
||||
}),
|
||||
create: vi.fn(async ({ data }) => {
|
||||
const row = {
|
||||
id: PAYSLIP_IDS[state.payslips.length] ?? PAYSLIP_IDS[0],
|
||||
employee_id: data.employee_id,
|
||||
pay_month: data.pay_month,
|
||||
status: data.status,
|
||||
gross_amount: data.gross_amount,
|
||||
deduction_amount: data.deduction_amount,
|
||||
net_amount: data.net_amount,
|
||||
issued_at: data.issued_at ?? null,
|
||||
created_at: new Date("2026-04-30T08:00:00.000Z"),
|
||||
created_by: data.created_by,
|
||||
updated_at: new Date("2026-04-30T08:00:00.000Z"),
|
||||
updated_by: data.updated_by,
|
||||
};
|
||||
state.payslips.push(row);
|
||||
state.payslipItems.push(
|
||||
...(data.items?.create ?? []).map((item, index) => ({
|
||||
id: `payslip-item-${state.payslipItems.length + index + 1}`,
|
||||
payslip_id: row.id,
|
||||
salary_item_id: item.salary_item_id,
|
||||
amount: item.amount,
|
||||
})),
|
||||
);
|
||||
return row;
|
||||
}),
|
||||
update: vi.fn(async ({ where, data }) => {
|
||||
const index = state.payslips.findIndex((item) => item.id === where.id);
|
||||
state.payslips[index] = {
|
||||
...state.payslips[index],
|
||||
...data,
|
||||
updated_at: new Date("2026-04-30T09:00:00.000Z"),
|
||||
};
|
||||
return state.payslips[index];
|
||||
}),
|
||||
updateMany: vi.fn(async ({ where, data }) => {
|
||||
let count = 0;
|
||||
state.payslips = state.payslips.map((item) => {
|
||||
if (where.id?.in && where.id.in.includes(item.id)) {
|
||||
count += 1;
|
||||
return {
|
||||
...item,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return { count };
|
||||
}),
|
||||
};
|
||||
|
||||
const payslipItem = {
|
||||
deleteMany: vi.fn(async ({ where }) => {
|
||||
state.payslipItems = state.payslipItems.filter((item) => item.payslip_id !== where.payslip_id);
|
||||
return { count: 1 };
|
||||
}),
|
||||
createMany: vi.fn(async ({ data }) => {
|
||||
state.payslipItems.push(
|
||||
...data.map((item, index) => ({
|
||||
id: `payslip-item-${state.payslipItems.length + index + 1}`,
|
||||
payslip_id: item.payslip_id,
|
||||
salary_item_id: item.salary_item_id,
|
||||
amount: item.amount,
|
||||
})),
|
||||
);
|
||||
return { count: data.length };
|
||||
}),
|
||||
};
|
||||
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
const codes = actor.permissionCodes ?? [];
|
||||
if (!codes.includes("*") && !codes.includes(permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate("hasPermission", (employee, permissionCode) => {
|
||||
const codes = employee.permissionCodes ?? [];
|
||||
return codes.includes("*") || codes.includes(permissionCode);
|
||||
});
|
||||
app.decorate("prisma", {
|
||||
employee: {
|
||||
findMany: vi.fn(async ({ where }) => {
|
||||
if (where?.status === "active") {
|
||||
return state.employees.filter((item) => item.status === "active");
|
||||
}
|
||||
return [...state.employees];
|
||||
}),
|
||||
findUnique: vi.fn(async ({ where }) => state.employees.find((item) => item.id === where.id) ?? null),
|
||||
},
|
||||
salaryItem: {
|
||||
findMany: vi.fn(async ({ where }) => {
|
||||
if (where?.enabled === true) {
|
||||
return state.salaryItems.filter((item) => item.enabled);
|
||||
}
|
||||
if (where?.id?.in) {
|
||||
return state.salaryItems.filter((item) => where.id.in.includes(item.id));
|
||||
}
|
||||
return [...state.salaryItems];
|
||||
}),
|
||||
},
|
||||
employeeSalary: {
|
||||
findMany: vi.fn(async ({ where, select }) =>
|
||||
state.salaryRows.filter((item) => {
|
||||
if (where.employee_id?.in && !where.employee_id.in.includes(item.employee_id)) {
|
||||
return false;
|
||||
}
|
||||
if (where.effective_date?.lte && item.effective_date > where.effective_date.lte) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map((item) => (select ? { ...item } : item)),
|
||||
),
|
||||
},
|
||||
payslip,
|
||||
payslipItem,
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$transaction: vi.fn(async (callback) => callback(app.prisma)),
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
reply.status(error.statusCode || 500).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code || "SYS_001",
|
||||
message: error.message,
|
||||
details: error.details ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(payrollRoutes, { prefix: "/api" });
|
||||
return { app, state, payslip, payslipItem };
|
||||
}
|
||||
|
||||
describe("payroll payslip routes", () => {
|
||||
it("generates, edits and issues payslips and limits self view to issued records", async () => {
|
||||
const { app, state, payslipItem } = await buildApp();
|
||||
|
||||
const generateResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/payroll/payslips/generate",
|
||||
payload: { month: "2026-04" },
|
||||
});
|
||||
|
||||
expect(generateResponse.statusCode).toBe(200);
|
||||
expect(generateResponse.json().data.summary).toEqual({
|
||||
total: 2,
|
||||
generated: 2,
|
||||
});
|
||||
expect(state.payslips).toHaveLength(2);
|
||||
|
||||
const patchResponse = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/payroll/payslips/${state.payslips[0].id}`,
|
||||
payload: {
|
||||
items: [
|
||||
{
|
||||
salaryItemId: state.salaryItems[0].id,
|
||||
amount: 11000,
|
||||
},
|
||||
{
|
||||
salaryItemId: state.salaryItems[1].id,
|
||||
amount: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(patchResponse.statusCode).toBe(200);
|
||||
expect(patchResponse.json().data).toMatchObject({
|
||||
grossAmount: 11000,
|
||||
deductionAmount: 500,
|
||||
netAmount: 10500,
|
||||
status: "draft",
|
||||
});
|
||||
expect(payslipItem.createMany).toHaveBeenCalledTimes(1);
|
||||
|
||||
const issueResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/payroll/payslips/issue",
|
||||
payload: { month: "2026-04" },
|
||||
});
|
||||
|
||||
expect(issueResponse.statusCode).toBe(200);
|
||||
expect(issueResponse.json().data.summary).toEqual({
|
||||
total: 2,
|
||||
issued: 2,
|
||||
});
|
||||
|
||||
const frozenResponse = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/payroll/payslips/${state.payslips[0].id}`,
|
||||
payload: {
|
||||
items: [
|
||||
{
|
||||
salaryItemId: state.salaryItems[0].id,
|
||||
amount: 12000,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(frozenResponse.statusCode).toBe(409);
|
||||
|
||||
await app.close();
|
||||
|
||||
const selfApp = await buildApp({
|
||||
actor: {
|
||||
id: EMPLOYEE_ID,
|
||||
permissionCodes: ["employee"],
|
||||
},
|
||||
});
|
||||
selfApp.state.payslips = state.payslips;
|
||||
selfApp.state.payslipItems = state.payslipItems;
|
||||
|
||||
const selfResponse = await selfApp.app.inject({
|
||||
method: "GET",
|
||||
url: `/api/payroll/employees/${EMPLOYEE_ID}/payslips`,
|
||||
});
|
||||
|
||||
expect(selfResponse.statusCode).toBe(200);
|
||||
expect(selfResponse.json().data.items).toHaveLength(1);
|
||||
expect(selfResponse.json().data.items[0].status).toBe("issued");
|
||||
|
||||
await selfApp.app.close();
|
||||
});
|
||||
|
||||
it("fails generation when an active employee has no effective salary profile", async () => {
|
||||
const { app, state } = await buildApp({
|
||||
omitProfileFor: [OTHER_EMPLOYEE_ID],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/payroll/payslips/generate",
|
||||
payload: { month: "2026-04" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.month).toBe("salary_profile_missing");
|
||||
expect(state.payslips).toHaveLength(0);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const payrollRoutes = require("../../../src/routes/payroll");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const ADMIN_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const EMPLOYEE_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
|
||||
function buildEmployee(overrides = {}) {
|
||||
return {
|
||||
id: EMPLOYEE_ID,
|
||||
employee_no: "E001",
|
||||
name: "Alice",
|
||||
title: "专员",
|
||||
status: "active",
|
||||
department: { id: "dept-1", name: "行政部" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSalaryItem(overrides = {}) {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
name: "基本工资",
|
||||
item_type: "earning",
|
||||
sort_order: 10,
|
||||
enabled: true,
|
||||
created_at: new Date("2026-03-29T08:00:00.000Z"),
|
||||
created_by: ADMIN_ID,
|
||||
updated_at: new Date("2026-03-29T08:00:00.000Z"),
|
||||
updated_by: ADMIN_ID,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSalaryRow(overrides = {}) {
|
||||
return {
|
||||
id: "salary-1",
|
||||
employee_id: EMPLOYEE_ID,
|
||||
salary_item_id: "11111111-1111-4111-8111-111111111111",
|
||||
monthly_amount: "10000.00",
|
||||
effective_date: new Date("2026-01-01T00:00:00.000Z"),
|
||||
created_at: new Date("2026-01-01T00:00:00.000Z"),
|
||||
created_by: ADMIN_ID,
|
||||
updated_at: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updated_by: ADMIN_ID,
|
||||
salary_item: buildSalaryItem(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp({
|
||||
actor = { id: ADMIN_ID, permissionCodes: ["system.users"] },
|
||||
} = {}) {
|
||||
const app = Fastify();
|
||||
const state = {
|
||||
employee: buildEmployee(),
|
||||
salaryItems: [
|
||||
buildSalaryItem(),
|
||||
buildSalaryItem({
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
name: "社保扣除",
|
||||
item_type: "deduction",
|
||||
sort_order: 20,
|
||||
}),
|
||||
],
|
||||
salaryRows: [buildSalaryRow()],
|
||||
};
|
||||
|
||||
function attachSalaryItem(row) {
|
||||
return {
|
||||
...row,
|
||||
salary_item: state.salaryItems.find((item) => item.id === row.salary_item_id),
|
||||
};
|
||||
}
|
||||
|
||||
const employeeSalary = {
|
||||
findMany: vi.fn(async ({ where }) =>
|
||||
state.salaryRows
|
||||
.filter((item) => item.employee_id === where.employee_id)
|
||||
.map(attachSalaryItem),
|
||||
),
|
||||
deleteMany: vi.fn(async ({ where }) => {
|
||||
const effectiveDate = where.effective_date.toISOString().slice(0, 10);
|
||||
state.salaryRows = state.salaryRows.filter(
|
||||
(item) => !(item.employee_id === where.employee_id && item.effective_date.toISOString().slice(0, 10) === effectiveDate),
|
||||
);
|
||||
return { count: 1 };
|
||||
}),
|
||||
createMany: vi.fn(async ({ data }) => {
|
||||
state.salaryRows.push(
|
||||
...data.map((item, index) => ({
|
||||
id: `salary-${state.salaryRows.length + index + 1}`,
|
||||
employee_id: item.employee_id,
|
||||
salary_item_id: item.salary_item_id,
|
||||
monthly_amount: item.monthly_amount,
|
||||
effective_date: item.effective_date,
|
||||
created_at: new Date("2026-03-29T08:00:00.000Z"),
|
||||
created_by: item.created_by,
|
||||
updated_at: new Date("2026-03-29T08:00:00.000Z"),
|
||||
updated_by: item.updated_by,
|
||||
})),
|
||||
);
|
||||
return { count: data.length };
|
||||
}),
|
||||
};
|
||||
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
const codes = actor.permissionCodes ?? [];
|
||||
if (!codes.includes("*") && !codes.includes(permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate("hasPermission", (employee, permissionCode) => {
|
||||
const codes = employee.permissionCodes ?? [];
|
||||
return codes.includes("*") || codes.includes(permissionCode);
|
||||
});
|
||||
app.decorate("prisma", {
|
||||
employee: {
|
||||
findUnique: vi.fn(async ({ where }) => (where.id === state.employee.id ? state.employee : null)),
|
||||
},
|
||||
salaryItem: {
|
||||
findMany: vi.fn(async ({ where }) => state.salaryItems.filter((item) => where.id.in.includes(item.id))),
|
||||
findUnique: vi.fn(async ({ where }) => state.salaryItems.find((item) => item.id === where.id) ?? null),
|
||||
},
|
||||
employeeSalary,
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$transaction: vi.fn(async (callback) => callback(app.prisma)),
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
reply.status(error.statusCode || 500).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code || "SYS_001",
|
||||
message: error.message,
|
||||
details: error.details ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(payrollRoutes, { prefix: "/api" });
|
||||
return { app, state, employeeSalary };
|
||||
}
|
||||
|
||||
describe("payroll salary profile routes", () => {
|
||||
it("replaces salary snapshots for the same effective date and preserves history", async () => {
|
||||
const { app, state, employeeSalary } = await buildApp();
|
||||
|
||||
const putResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/payroll/employees/${EMPLOYEE_ID}/profile`,
|
||||
payload: {
|
||||
effectiveDate: "2026-04-01",
|
||||
items: [
|
||||
{
|
||||
salaryItemId: state.salaryItems[0].id,
|
||||
monthlyAmount: 12000,
|
||||
},
|
||||
{
|
||||
salaryItemId: state.salaryItems[1].id,
|
||||
monthlyAmount: 800,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(putResponse.statusCode).toBe(200);
|
||||
expect(putResponse.json().data.snapshots[0]).toMatchObject({
|
||||
effectiveDate: "2026-04-01",
|
||||
items: [
|
||||
{ salaryItemId: state.salaryItems[0].id, monthlyAmount: 12000 },
|
||||
{ salaryItemId: state.salaryItems[1].id, monthlyAmount: 800 },
|
||||
],
|
||||
});
|
||||
expect(putResponse.json().data.snapshots[1]).toMatchObject({
|
||||
effectiveDate: "2026-01-01",
|
||||
});
|
||||
|
||||
const replaceResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/payroll/employees/${EMPLOYEE_ID}/profile`,
|
||||
payload: {
|
||||
effectiveDate: "2026-04-01",
|
||||
items: [
|
||||
{
|
||||
salaryItemId: state.salaryItems[0].id,
|
||||
monthlyAmount: 12500,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(replaceResponse.statusCode).toBe(200);
|
||||
expect(replaceResponse.json().data.snapshots[0]).toMatchObject({
|
||||
effectiveDate: "2026-04-01",
|
||||
items: [{ salaryItemId: state.salaryItems[0].id, monthlyAmount: 12500 }],
|
||||
});
|
||||
expect(replaceResponse.json().data.snapshots).toHaveLength(2);
|
||||
|
||||
const getResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/payroll/employees/${EMPLOYEE_ID}/profile`,
|
||||
});
|
||||
|
||||
expect(getResponse.statusCode).toBe(200);
|
||||
expect(getResponse.json().data.snapshots).toHaveLength(2);
|
||||
expect(employeeSalary.deleteMany).toHaveBeenCalledTimes(2);
|
||||
expect(employeeSalary.createMany).toHaveBeenCalledTimes(2);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects non-admin users", async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: {
|
||||
id: EMPLOYEE_ID,
|
||||
permissionCodes: ["employee"],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/payroll/employees/${EMPLOYEE_ID}/profile`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户