FL6 完善财务台账与导出审计

这个提交包含在:
2026-05-12 09:33:45 +08:00
父节点 a0dc6fc408
当前提交 f99fbce3d8
共修改 14 个文件,包含 1005 行新增和 18 行删除
+292
查看文件
@@ -0,0 +1,292 @@
const { toMoneyString } = require("./feeControl");
const REIMBURSEMENT_LEDGER_STATUS_VALUES = Object.freeze([
"draft",
"reviewing",
"approved",
"paid",
"rejected",
"cancelled",
]);
const REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES = Object.freeze(["unpaid", "paid"]);
const REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES = Object.freeze([
"none",
"pending_ocr",
"ocr_failed",
"available",
"bound",
"archived",
]);
function toDateOnlyString(value) {
if (!value) return null;
if (typeof value === "string") return value.slice(0, 10);
return value.toISOString?.().slice(0, 10) ?? null;
}
function toDateTimeString(value) {
if (!value) return null;
if (typeof value === "string") return value;
return value.toISOString?.() ?? null;
}
function dateRange(dateFrom, dateTo) {
return {
...(dateFrom ? { gte: new Date(`${dateFrom}T00:00:00.000Z`) } : {}),
...(dateTo ? { lte: new Date(`${dateTo}T23:59:59.999Z`) } : {}),
};
}
function buildFinanceLedgerWhere(filters = {}) {
const clauses = [];
const query = filters.q?.trim();
if (filters.status) {
clauses.push({ status: filters.status });
}
if (filters.paymentStatus === "paid") {
clauses.push({ status: "paid" });
} else if (filters.paymentStatus === "unpaid") {
clauses.push({ status: { not: "paid" } });
}
if (filters.invoiceStatus === "none") {
clauses.push({ invoice_pool_items: { none: {} } });
} else if (filters.invoiceStatus) {
clauses.push({ invoice_pool_items: { some: { status: filters.invoiceStatus } } });
}
const reimbursementDateRange = dateRange(filters.dateFrom, filters.dateTo);
if (Object.keys(reimbursementDateRange).length > 0) {
clauses.push({ reimbursement_date: reimbursementDateRange });
}
if (query) {
clauses.push({
OR: [
{ form_number: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
{ owner: { name: { contains: query, mode: "insensitive" } } },
{ owner: { employee_no: { contains: query, mode: "insensitive" } } },
{ submitter: { name: { contains: query, mode: "insensitive" } } },
{ submitter: { employee_no: { contains: query, mode: "insensitive" } } },
{ department: { name: { contains: query, mode: "insensitive" } } },
],
});
}
return clauses.length ? { AND: clauses } : {};
}
const FINANCE_LEDGER_SELECT = {
id: true,
form_number: true,
reimbursement_date: true,
status: true,
total_amount: true,
description: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
created_at: true,
updated_at: true,
owner: { select: { id: true, employee_no: true, name: true } },
submitter: { select: { id: true, employee_no: true, name: true } },
department: { select: { id: true, name: true } },
paid_by_employee: { select: { id: true, employee_no: true, name: true } },
invoice_pool_items: {
select: {
id: true,
status: true,
archived_reason: true,
},
},
_count: {
select: {
attachments: true,
invoice_pool_items: true,
},
},
};
function summarizeInvoiceStatus(invoices) {
const counts = {
total: invoices.length,
pendingOcr: 0,
ocrFailed: 0,
available: 0,
bound: 0,
archived: 0,
usedComplete: 0,
};
for (const invoice of invoices) {
if (invoice.status === "pending_ocr") counts.pendingOcr += 1;
if (invoice.status === "ocr_failed") counts.ocrFailed += 1;
if (invoice.status === "available") counts.available += 1;
if (invoice.status === "bound") counts.bound += 1;
if (invoice.status === "archived") counts.archived += 1;
if (invoice.archived_reason === "used_complete") counts.usedComplete += 1;
}
if (counts.total === 0) return { status: "none", counts };
if (counts.ocrFailed > 0) return { status: "ocr_failed", counts };
if (counts.pendingOcr > 0) return { status: "pending_ocr", counts };
if (counts.total === counts.archived) return { status: "archived", counts };
if (counts.bound > 0) return { status: "bound", counts };
if (counts.total === counts.available) return { status: "available", counts };
return { status: "mixed", counts };
}
function mapEmployee(employee) {
return employee
? {
id: employee.id,
employeeNo: employee.employee_no,
name: employee.name,
}
: null;
}
function mapFinanceLedgerItem(item) {
const invoiceSummary = summarizeInvoiceStatus(item.invoice_pool_items ?? []);
return {
id: item.id,
formNumber: item.form_number,
reimbursementDate: toDateOnlyString(item.reimbursement_date),
status: item.status,
totalAmount: toMoneyString(item.total_amount),
description: item.description ?? null,
owner: mapEmployee(item.owner),
submitter: mapEmployee(item.submitter),
department: item.department ? { id: item.department.id, name: item.department.name } : null,
payment: {
status: item.status === "paid" ? "paid" : "unpaid",
paymentVoucherNo: item.payment_voucher_no ?? null,
paymentDate: toDateOnlyString(item.payment_date),
paymentChannel: item.payment_channel ?? null,
paidAt: toDateTimeString(item.paid_at),
paidBy: mapEmployee(item.paid_by_employee),
},
invoice: invoiceSummary,
attachmentCount: item._count?.attachments ?? 0,
createdAt: toDateTimeString(item.created_at),
updatedAt: toDateTimeString(item.updated_at),
};
}
async function listFinanceLedger(prisma, filters = {}) {
const page = Math.max(1, Number(filters.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(filters.pageSize ?? 20)));
const where = buildFinanceLedgerWhere(filters);
const [total, rows] = await Promise.all([
prisma.reimbursementForm.count({ where }),
prisma.reimbursementForm.findMany({
where,
orderBy: [{ updated_at: "desc" }, { id: "desc" }],
skip: (page - 1) * pageSize,
take: pageSize,
select: FINANCE_LEDGER_SELECT,
}),
]);
return {
page,
pageSize,
total,
items: rows.map(mapFinanceLedgerItem),
};
}
async function listFinanceLedgerForExport(prisma, filters = {}, limit = 5000) {
const rows = await prisma.reimbursementForm.findMany({
where: buildFinanceLedgerWhere(filters),
orderBy: [{ updated_at: "desc" }, { id: "desc" }],
take: limit,
select: FINANCE_LEDGER_SELECT,
});
return rows.map(mapFinanceLedgerItem);
}
function paymentStatusLabel(status) {
return status === "paid" ? "已付款" : "未付款";
}
function reimbursementStatusLabel(status) {
const labels = {
draft: "草稿",
reviewing: "审批中",
approved: "已通过",
paid: "已支付",
rejected: "已驳回",
cancelled: "已取消",
};
return labels[status] ?? status ?? "";
}
function invoiceStatusLabel(status) {
const labels = {
none: "无发票",
pending_ocr: "识别中",
ocr_failed: "识别失败",
available: "可添加",
bound: "已绑定",
archived: "已归档",
mixed: "多状态",
};
return labels[status] ?? status ?? "";
}
const FINANCE_LEDGER_EXPORT_HEADERS = Object.freeze([
"报销单号",
"申请人",
"部门",
"金额",
"审批状态",
"付款状态",
"付款日期",
"付款凭证号",
"付款渠道",
"付款操作人",
"发票状态",
"发票数",
"附件数",
"报销日期",
"更新时间",
]);
function mapFinanceLedgerExportRow(item) {
return [
item.formNumber ?? item.id,
item.owner?.name ?? item.submitter?.name ?? "",
item.department?.name ?? "",
item.totalAmount,
reimbursementStatusLabel(item.status),
paymentStatusLabel(item.payment.status),
item.payment.paymentDate ?? "",
item.payment.paymentVoucherNo ?? "",
item.payment.paymentChannel ?? "",
item.payment.paidBy?.name ?? "",
invoiceStatusLabel(item.invoice.status),
item.invoice.counts.total,
item.attachmentCount,
item.reimbursementDate ?? "",
item.updatedAt ?? "",
];
}
module.exports = {
FINANCE_LEDGER_EXPORT_HEADERS,
REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES,
REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES,
REIMBURSEMENT_LEDGER_STATUS_VALUES,
buildFinanceLedgerWhere,
invoiceStatusLabel,
listFinanceLedger,
listFinanceLedgerForExport,
mapFinanceLedgerExportRow,
paymentStatusLabel,
reimbursementStatusLabel,
};