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