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

这个提交包含在:
2026-05-12 09:33:45 +08:00
父节点 a0dc6fc408
当前提交 f99fbce3d8
共修改 14 个文件,包含 1005 行新增和 18 行删除
+39 -2
查看文件
@@ -2,11 +2,30 @@ const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { BUDGET_RISK_CODES, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const { z } = require("zod");
const {
REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES,
REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES,
REIMBURSEMENT_LEDGER_STATUS_VALUES,
listFinanceLedger,
} = require("../services/financeLedger");
const FINANCE_LEDGER_PERMISSION_CODES = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
const FINANCE_LEDGER_QUERY_SCHEMA = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
status: z.enum(REIMBURSEMENT_LEDGER_STATUS_VALUES).optional(),
paymentStatus: z.enum(REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES).optional(),
invoiceStatus: z.enum(REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES).optional(),
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
q: z.string().trim().max(100).optional(),
});
function ensureReadPermission(fastify, request) {
if (
!fastify.hasPermission(request.employee, "payment.batch.manage") &&
!fastify.hasPermission(request.employee, "risk.review.manage")
!FINANCE_LEDGER_PERMISSION_CODES.some((permission) => fastify.hasPermission(request.employee, permission))
) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
@@ -182,6 +201,24 @@ async function apOperationsRoutes(fastify) {
partialMatchItems: anomalies.map(mapReconciliationAnomaly),
});
});
fastify.get("/ap-operations/reimbursement-ledger", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureReadPermission(fastify, request);
const parsed = FINANCE_LEDGER_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const ledger = await listFinanceLedger(fastify.prisma, parsed.data);
return sendData(reply, request, ledger);
});
}
module.exports = apOperationsRoutes;
+61 -1
查看文件
@@ -19,6 +19,14 @@ const {
} = require("../services/employeeDirectoryFilters");
const { BUDGET_RISK_CODES, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const {
FINANCE_LEDGER_EXPORT_HEADERS,
REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES,
REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES,
REIMBURSEMENT_LEDGER_STATUS_VALUES,
listFinanceLedgerForExport,
mapFinanceLedgerExportRow,
} = require("../services/financeLedger");
const {
HISTORY_STATUS_VALUES,
buildPresenceValidationError,
@@ -109,6 +117,12 @@ const AP_OPERATIONS_EXPORT_BODY_SCHEMA = z.object({
pendingCreatedAtFrom: DATE_ONLY_SCHEMA.optional(),
pendingCreatedAtTo: DATE_ONLY_SCHEMA.optional(),
riskBucket: z.enum(["all", "exception", "budget_overspend"]).optional(),
ledgerStatus: z.enum(REIMBURSEMENT_LEDGER_STATUS_VALUES).optional(),
ledgerPaymentStatus: z.enum(REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES).optional(),
ledgerInvoiceStatus: z.enum(REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES).optional(),
ledgerDateFrom: DATE_ONLY_SCHEMA.optional(),
ledgerDateTo: DATE_ONLY_SCHEMA.optional(),
ledgerSearch: z.string().trim().max(100).optional(),
}).strict();
const PRESENCE_HISTORY_EXPORT_LIMIT = 5000;
@@ -573,8 +587,23 @@ async function loadApOperationsExportDataset(prisma, filters) {
resolution_status: "pending",
match_status: { not: "matched" },
};
const ledgerFilters = {
status: filters.ledgerStatus,
paymentStatus: filters.ledgerPaymentStatus,
invoiceStatus: filters.ledgerInvoiceStatus,
dateFrom: filters.ledgerDateFrom,
dateTo: filters.ledgerDateTo,
q: filters.ledgerSearch,
};
const [pendingPaymentBatches, failedPaymentBatchItems, riskFlags, reconciliationAnomalies] = await Promise.all([
const [
reimbursementLedger,
pendingPaymentBatches,
failedPaymentBatchItems,
riskFlags,
reconciliationAnomalies,
] = await Promise.all([
listFinanceLedgerForExport(prisma, ledgerFilters, 5000),
prisma.paymentBatch.findMany({
where: pendingWhere,
orderBy: [{ created_at: "desc" }, { id: "desc" }],
@@ -617,6 +646,7 @@ async function loadApOperationsExportDataset(prisma, filters) {
]);
return {
reimbursementLedger,
pendingWhere,
riskWhere,
reconciliationWhere,
@@ -630,6 +660,13 @@ async function loadApOperationsExportDataset(prisma, filters) {
async function* streamApOperationsCsv(dataset) {
yield "\ufeff";
yield csvRow(["财务台账"]);
yield csvRow(FINANCE_LEDGER_EXPORT_HEADERS);
for (const item of dataset.reimbursementLedger) {
yield csvRow(mapFinanceLedgerExportRow(item));
}
yield csvRow([]);
yield csvRow(["待付款批次"]);
yield csvRow(["批次编号", "实体类型", "批次名称", "状态", "总金额", "高风险", "创建时间"]);
for (const item of dataset.pendingPaymentBatches) {
@@ -661,6 +698,17 @@ async function* streamApOperationsCsv(dataset) {
async function buildApOperationsXlsx(dataset) {
const workbook = new ExcelJS.Workbook();
const ledgerSheet = workbook.addWorksheet("财务台账");
ledgerSheet.columns = FINANCE_LEDGER_EXPORT_HEADERS.map((header, index) => ({
header,
key: `col${index}`,
width: index === 0 ? 24 : index === 14 ? 24 : 16,
}));
for (const item of dataset.reimbursementLedger) {
const row = mapFinanceLedgerExportRow(item);
ledgerSheet.addRow(Object.fromEntries(row.map((value, index) => [`col${index}`, value])));
}
const pendingSheet = workbook.addWorksheet("待付款批次");
pendingSheet.columns = [
{ header: "批次编号", key: "batchNo", width: 24 },
@@ -1617,6 +1665,12 @@ async function exportsRoutes(fastify) {
pendingCreatedAtFrom: parsedBody.pendingCreatedAtFrom,
pendingCreatedAtTo: parsedBody.pendingCreatedAtTo,
riskBucket: parsedBody.riskBucket ?? "all",
ledgerStatus: parsedBody.ledgerStatus,
ledgerPaymentStatus: parsedBody.ledgerPaymentStatus,
ledgerInvoiceStatus: parsedBody.ledgerInvoiceStatus,
ledgerDateFrom: parsedBody.ledgerDateFrom,
ledgerDateTo: parsedBody.ledgerDateTo,
ledgerSearch: parsedBody.ledgerSearch,
};
const dataset = await loadApOperationsExportDataset(fastify.prisma, filters);
@@ -1635,6 +1689,12 @@ async function exportsRoutes(fastify) {
failedPaymentItemTotal: dataset.failedPaymentBatchItems.length,
openRiskFlagTotal: dataset.riskFlags.length,
reconciliationAnomalyTotal: dataset.reconciliationAnomalies.length,
financeLedgerTotal: dataset.reimbursementLedger.length,
ledgerStatus: filters.ledgerStatus ?? null,
ledgerPaymentStatus: filters.ledgerPaymentStatus ?? null,
ledgerInvoiceStatus: filters.ledgerInvoiceStatus ?? null,
ledgerDateFrom: filters.ledgerDateFrom ?? null,
ledgerDateTo: filters.ledgerDateTo ?? null,
},
}),
});
+49 -3
查看文件
@@ -308,7 +308,7 @@ async function listOpenRiskFlagsForBatch(tx, batch) {
return items.map(buildPersistedRiskFlag);
}
async function executeReimbursementPayment(tx, { batch, item, actorId, index }) {
async function executeReimbursementPayment(tx, { batch, item, actorId, index, request }) {
const current = await tx.reimbursementForm.findUnique({
where: { id: item.entity_id },
select: {
@@ -348,11 +348,31 @@ async function executeReimbursementPayment(tx, { batch, item, actorId, index })
if (conflict) {
throw buildValidationError({ payment: "already_paid_conflict" }, 409);
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId,
action: "REIMBURSEMENT_PAYMENT_MARKED",
targetType: "reimbursement_form",
targetId: current.id,
extra: {
formNumber: current.form_number ?? null,
paymentVoucherNo: current.payment_voucher_no ?? null,
paymentDate: existingPaymentDate,
paymentChannel: current.payment_channel ?? null,
paymentNote: null,
idempotent: true,
archivedInvoiceCount: 0,
source: "payment_batch",
paymentBatchId: batch.id,
paymentBatchItemId: item.id,
},
}),
});
return { idempotent: true };
}
const now = new Date();
await tx.invoicePool.updateMany({
const archivedResult = await tx.invoicePool.updateMany({
where: { linked_reimbursement_id: item.entity_id, status: "bound" },
data: {
status: "archived",
@@ -373,6 +393,26 @@ async function executeReimbursementPayment(tx, { batch, item, actorId, index })
updated_by: actorId,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId,
action: "REIMBURSEMENT_PAYMENT_MARKED",
targetType: "reimbursement_form",
targetId: current.id,
extra: {
formNumber: current.form_number ?? null,
paymentVoucherNo: voucher,
paymentDate,
paymentChannel,
paymentNote: null,
idempotent: false,
archivedInvoiceCount: archivedResult.count,
source: "payment_batch",
paymentBatchId: batch.id,
paymentBatchItemId: item.id,
},
}),
});
return { idempotent: false };
}
@@ -842,7 +882,13 @@ async function paymentBatchesRoutes(fastify) {
try {
if (batch.entity_type === "reimbursement") {
const paymentResult = await executeReimbursementPayment(tx, { batch, item, actorId: request.employee.id, index });
const paymentResult = await executeReimbursementPayment(tx, {
batch,
item,
actorId: request.employee.id,
index,
request,
});
if (!paymentResult.idempotent) {
successfulReimbursementIds.push(item.entity_id);
}
+66
查看文件
@@ -43,6 +43,7 @@ const PAY_BODY_SCHEMA = z.object({
paymentVoucherNo: z.string().trim().min(1).max(50).optional(),
paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
paymentChannel: z.enum(["bank", "alipay", "wechat", "cash", "other"]).optional(),
paymentNote: z.string().trim().max(200).optional(),
});
const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({
@@ -1556,6 +1557,19 @@ async function reimbursementRoutes(fastify) {
reimbursementDate,
status: reimbursement.status,
description: reimbursement.description,
payment: {
paymentVoucherNo: reimbursement.payment_voucher_no ?? null,
paymentDate: toDateOnlyString(reimbursement.payment_date),
paymentChannel: reimbursement.payment_channel ?? null,
paidAt: reimbursement.paid_at?.toISOString?.() ?? null,
paidBy: reimbursement.paid_by_employee
? {
id: reimbursement.paid_by_employee.id,
employeeNo: reimbursement.paid_by_employee.employee_no,
name: reimbursement.paid_by_employee.name,
}
: null,
},
projectId: reimbursement.project_id ?? null,
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
totalAmount: toMoneyString(reimbursement.total_amount),
@@ -1958,8 +1972,19 @@ async function reimbursementRoutes(fastify) {
total_amount: true,
status: true,
description: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
project_id: true,
linked_travel_request_id: true,
paid_by_employee: {
select: {
id: true,
employee_no: true,
name: true,
},
},
department: {
select: {
id: true,
@@ -2158,6 +2183,19 @@ async function reimbursementRoutes(fastify) {
totalAmount: toMoneyString(reimbursement.total_amount),
status: reimbursement.status,
description: reimbursement.description,
payment: {
paymentVoucherNo: reimbursement.payment_voucher_no ?? null,
paymentDate: toDateOnlyString(reimbursement.payment_date),
paymentChannel: reimbursement.payment_channel ?? null,
paidAt: reimbursement.paid_at?.toISOString?.() ?? null,
paidBy: reimbursement.paid_by_employee
? {
id: reimbursement.paid_by_employee.id,
employeeNo: reimbursement.paid_by_employee.employee_no,
name: reimbursement.paid_by_employee.name,
}
: null,
},
projectId: reimbursement.project_id ?? null,
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
@@ -3737,6 +3775,33 @@ async function reimbursementRoutes(fastify) {
}
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_PAYMENT_MARKED",
targetType: "reimbursement_form",
targetId: reimbursementId,
extra: {
formNumber: reimbursement.form_number ?? null,
paymentVoucherNo:
reimbursement.status === "paid"
? reimbursement.payment_voucher_no ?? null
: payInput.paymentVoucherNo ?? null,
paymentDate:
reimbursement.status === "paid"
? existingPaymentDate
: payInput.paymentDate ?? null,
paymentChannel:
reimbursement.status === "paid"
? reimbursement.payment_channel ?? null
: payInput.paymentChannel ?? null,
paymentNote: payInput.paymentNote ?? null,
idempotent: reimbursement.status === "paid",
archivedInvoiceCount: archivedResult.count,
},
}),
});
response = {
reimbursementId,
outcome: "paid",
@@ -3764,6 +3829,7 @@ async function reimbursementRoutes(fastify) {
reimbursement.status === "paid"
? reimbursement.paid_by ?? null
: request.employee.id,
paymentNote: payInput.paymentNote ?? null,
idempotent: reimbursement.status === "paid",
},
};