文件
hyb-oa/server/src/services/payroll.js
T

277 行
7.4 KiB
JavaScript

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,
};