修复报销票据门禁提示与 OCR 降级(08-30-b2-submit-error-ocr-fallback)

这个提交包含在:
2026-08-31 20:20:20 +08:00
父节点 8b19538584
当前提交 f57e2236f2
共修改 21 个文件,包含 1829 行新增和 49 行删除
@@ -102,6 +102,14 @@ async function buildApp(options = {}) {
? (options.unlinkedBoundInvoiceCount ?? 0)
: (options.boundInvoiceCount ?? 1)
)),
findMany: vi.fn(async ({ where } = {}) => (
where?.linked_item_id === null
? Array.from(
{ length: options.unlinkedBoundInvoiceCount ?? 0 },
(_unused, index) => ({ id: `unlinked-invoice-${index + 1}` }),
)
: []
)),
},
reimbursementItem: {
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
@@ -409,6 +417,35 @@ describe("reimbursements routes expense standard integration", () => {
await app.close();
});
it("keeps the finance manual-review success marker when the B2 guard is off", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app } = await buildApp({
invoiceGuardEnabled: false,
boundInvoiceCount: 1,
reimbursementItems: [{
id: "manual-review-guard-off",
amount: "180.00",
invoice_pool_items: [{
id: "off-invoice-guard-off",
total_amount: null,
ocr_attempts: 0,
ocr_last_requested_at: null,
ocr_last_error: null,
ocr_completed_at: null,
}],
}],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode, response.body).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
invoiceAmountManualReviewRequired: true,
manualReviewItemCount: 1,
}));
await app.close();
});
it("blocks required-invoice items without linked invoices when the guard is on", async () => {
const { app, tx } = await buildApp({
invoiceGuardEnabled: true,
@@ -441,6 +478,112 @@ describe("reimbursements routes expense standard integration", () => {
await app.close();
});
it("identifies unlinked invoices without exposing only a count", async () => {
const { app } = await buildApp({
invoiceGuardEnabled: true,
unlinkedBoundInvoiceCount: 2,
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual(expect.objectContaining({
unlinkedInvoiceCount: 2,
unlinkedInvoiceIds: ["unlinked-invoice-1", "unlinked-invoice-2"],
}));
await app.close();
});
it("blocks a linked invoice whose OCR ran without producing an amount", async () => {
const { app, tx } = await buildApp({
invoiceGuardEnabled: true,
reimbursementItems: [{
id: "ocr-failed-item",
amount: "180.00",
invoice_pool_items: [{
id: "ocr-failed-invoice",
total_amount: null,
ocr_attempts: 1,
ocr_last_requested_at: new Date("2026-08-30T10:00:00.000Z"),
ocr_last_error: "amount_missing",
ocr_completed_at: new Date("2026-08-30T10:00:01.000Z"),
}],
}],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual(expect.objectContaining({
missingInvoiceItemIds: [],
amountUnavailableItems: [{
itemId: "ocr-failed-item",
invoiceIds: ["ocr-failed-invoice"],
}],
}));
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
});
it("allows a linked PDF with no OCR execution evidence and marks finance manual review", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app } = await buildApp({
invoiceGuardEnabled: true,
reimbursementItems: [{
id: "manual-review-item",
amount: "180.00",
invoice_pool_items: [{
id: "off-invoice",
total_amount: null,
ocr_attempts: 0,
ocr_last_requested_at: null,
ocr_last_error: null,
ocr_completed_at: null,
}],
}],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode, response.body).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
invoiceAmountManualReviewRequired: true,
manualReviewItemCount: 1,
}));
await app.close();
});
it("does not compare a declared amount against only the known part of mixed evidence", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app } = await buildApp({
invoiceGuardEnabled: true,
reimbursementItems: [{
id: "mixed-evidence-item",
amount: "1000.00",
invoice_pool_items: [
{ id: "known-invoice", total_amount: "600.00" },
{
id: "off-invoice",
total_amount: null,
ocr_attempts: 0,
ocr_last_requested_at: null,
ocr_last_error: null,
ocr_completed_at: null,
},
],
}],
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode, response.body).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
invoiceAmountManualReviewRequired: true,
manualReviewItemCount: 1,
}));
await app.close();
});
it("blocks declared amounts above the linked invoice total when the guard is on", async () => {
const { app, tx } = await buildApp({
invoiceGuardEnabled: true,
@@ -462,6 +605,33 @@ describe("reimbursements routes expense standard integration", () => {
await app.close();
});
it("allows five required items when each has one verified bound invoice", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const reimbursementItems = Array.from({ length: 5 }, (_unused, index) => ({
id: `verified-item-${index + 1}`,
amount: "100.00",
invoice_pool_items: [{
id: `verified-invoice-${index + 1}`,
total_amount: "100.00",
}],
}));
const { app, tx } = await buildApp({
invoiceGuardEnabled: true,
reimbursementItems,
authoritativeTotal: "500.00",
});
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode, response.body).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
invoiceAmountManualReviewRequired: false,
manualReviewItemCount: 0,
}));
expect(tx.workflowInstance.create).toHaveBeenCalledTimes(1);
await app.close();
});
it("recomputes a stale total before selecting the approval rule", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app, tx } = await buildApp({ authoritativeTotal: "320.00" });