修复报销票据门禁提示与 OCR 降级(08-30-b2-submit-error-ocr-fallback)
这个提交包含在:
@@ -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" });
|
||||
|
||||
在新工单中引用
屏蔽一个用户