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",
},
};
+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,
};
@@ -89,6 +89,30 @@ function buildAnomaly(overrides = {}) {
};
}
function buildFinanceLedgerItem(overrides = {}) {
return {
id: "99999999-9999-4999-8999-999999999999",
form_number: "BX-20260406-001",
reimbursement_date: new Date("2026-04-05T00:00:00.000Z"),
status: "paid",
total_amount: "1688.00",
description: "财务台账测试报销",
payment_voucher_no: "PAY-001",
payment_date: new Date("2026-04-06T00:00:00.000Z"),
payment_channel: "bank",
paid_at: new Date("2026-04-06T13:00:00.000Z"),
created_at: new Date("2026-04-05T09:00:00.000Z"),
updated_at: new Date("2026-04-06T13:00:00.000Z"),
owner: { id: ACTOR_ID, employee_no: "E001", name: "张三" },
submitter: { id: ACTOR_ID, employee_no: "E001", name: "张三" },
department: { id: "dept-1", name: "财务部" },
paid_by_employee: { id: ACTOR_ID, employee_no: "F001", name: "财务" },
invoice_pool_items: [{ id: "invoice-1", status: "archived", archived_reason: "used_complete" }],
_count: { attachments: 1, invoice_pool_items: 1 },
...overrides,
};
}
async function buildApp(options = {}) {
const app = Fastify();
const actor = options.actor ?? {
@@ -100,6 +124,8 @@ async function buildApp(options = {}) {
const entityRiskFlagFindMany = options.entityRiskFlagFindMany ?? vi.fn().mockResolvedValue([buildRiskFlag()]);
const travelReconciliationItemFindMany =
options.travelReconciliationItemFindMany ?? vi.fn().mockResolvedValue([buildAnomaly()]);
const reimbursementFormFindMany = options.reimbursementFormFindMany ?? vi.fn().mockResolvedValue([buildFinanceLedgerItem()]);
const reimbursementFormCount = options.reimbursementFormCount ?? vi.fn().mockResolvedValue(1);
const auditCreate = options.auditCreate ?? vi.fn().mockResolvedValue({});
app.decorate("authenticate", async (request) => {
@@ -137,6 +163,10 @@ async function buildApp(options = {}) {
travelReconciliationItem: {
findMany: travelReconciliationItemFindMany,
},
reimbursementForm: {
findMany: reimbursementFormFindMany,
count: reimbursementFormCount,
},
auditLog: {
create: auditCreate,
},
@@ -153,6 +183,8 @@ async function buildApp(options = {}) {
paymentBatchItemFindMany,
entityRiskFlagFindMany,
travelReconciliationItemFindMany,
reimbursementFormFindMany,
reimbursementFormCount,
auditCreate,
};
}
@@ -183,6 +215,8 @@ describe("ap operations export route", () => {
expect(response.headers["content-type"]).toContain("text/csv");
expect(response.headers["content-disposition"]).toContain("ap-operations-");
expect(response.body).toContain("待付款批次");
expect(response.body).toContain("财务台账");
expect(response.body).toContain("BX-20260406-001");
expect(response.body).toContain("异常付款项");
expect(response.body).toContain("风险标记");
expect(response.body).toContain("对账异常");
@@ -230,6 +264,7 @@ describe("ap operations export route", () => {
failedPaymentItemTotal: 1,
openRiskFlagTotal: 1,
reconciliationAnomalyTotal: 1,
financeLedgerTotal: 1,
}),
}),
}),