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

这个提交包含在:
2026-08-31 20:20:20 +08:00
父节点 8b19538584
当前提交 f57e2236f2
共修改 21 个文件,包含 1829 行新增和 49 行删除
@@ -41,6 +41,7 @@ function buildReimbursementRecord(overrides = {}) {
name: "打车费",
category: "travel",
enabled: true,
requires_invoice: true,
},
},
],
@@ -56,6 +57,10 @@ function buildReimbursementRecord(overrides = {}) {
total_amount: "188.80",
original_name: "receipt.pdf",
linked_item_id: "item-1",
ocr_attempts: 1,
ocr_last_requested_at: new Date("2026-04-09T01:00:00.000Z"),
ocr_last_error: null,
ocr_completed_at: new Date("2026-04-09T01:00:01.000Z"),
},
],
workflow_instances: [
@@ -358,8 +363,10 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
totalAmount: "188.80",
originalName: "receipt.pdf",
linkedItemId: "item-1",
amountEvidenceStatus: "verified",
},
],
invoiceAmountManualReviewRequired: false,
approvalHistory: [
{
id: "history-start",
@@ -394,6 +401,47 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
await app.close();
});
it("keeps the manual amount-review marker visible to a finance detail reader", async () => {
const reimbursement = buildReimbursementRecord({
invoice_pool_items: [{
id: "invoice-off",
status: "bound",
invoice_code: null,
invoice_number: null,
invoice_date: null,
seller_name: null,
total_amount: null,
original_name: "manual-review.pdf",
linked_item_id: "item-1",
ocr_attempts: 0,
ocr_last_requested_at: null,
ocr_last_error: null,
ocr_completed_at: null,
}],
});
const { app } = await buildApp({
employeeId: "finance-reader",
permissionCodes: ["expense.view_all"],
reimbursement,
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode, response.body).toBe(200);
expect(response.json().data).toEqual(expect.objectContaining({
invoiceAmountManualReviewRequired: true,
invoices: [expect.objectContaining({
id: "invoice-off",
amountEvidenceStatus: "manual_review",
})],
}));
await app.close();
});
it("returns null submittedAt and empty approvalHistory when reimbursement has never been submitted", async () => {
const reimbursement = buildReimbursementRecord({
status: "draft",
@@ -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" });
@@ -0,0 +1,180 @@
const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const reimbursementRoutes = require("../../../src/routes/reimbursements");
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_REIMBURSEMENT_ID = "22222222-2222-4222-8222-222222222222";
const INVOICE_ID = "33333333-3333-4333-8333-333333333333";
const ITEM_ID = "44444444-4444-4444-8444-444444444444";
const ACTOR_ID = "55555555-5555-4555-8555-555555555555";
const OTHER_EMPLOYEE_ID = "66666666-6666-4666-8666-666666666666";
function sqlText(strings) {
return Array.isArray(strings) ? strings.join("?") : String(strings);
}
async function buildApp(options = {}) {
const actor = options.actor ?? {
id: ACTOR_ID,
permissionCodes: ["expense.edit_own"],
roleCodes: [],
};
const invoice = {
id: INVOICE_ID,
status: "bound",
linked_reimbursement_id: REIMBURSEMENT_ID,
linked_item_id: null,
...options.invoice,
};
const reimbursement = {
id: REIMBURSEMENT_ID,
owner_id: ACTOR_ID,
submitter_id: ACTOR_ID,
status: "draft",
...options.reimbursement,
};
const tx = {
$queryRaw: vi.fn(async (strings) => {
const sql = sqlText(strings);
if (sql.includes('FROM "invoice_pool"')) return options.invoiceMissing ? [] : [invoice];
if (sql.includes('FROM "reimbursement_forms"')) return options.reimbursementMissing ? [] : [reimbursement];
return [];
}),
reimbursementItem: {
findFirst: vi.fn(async () => options.itemMissing ? null : { id: ITEM_ID }),
},
invoicePool: {
update: vi.fn(async ({ data }) => ({ ...invoice, ...data })),
},
auditLog: {
create: vi.fn(async () => ({ id: "audit-1" })),
},
};
const prisma = {
$transaction: vi.fn(async (callback) => callback(tx)),
};
const app = Fastify();
app.decorate("prisma", prisma);
app.decorate("config", {
appTimezone: "Asia/Shanghai",
reimbursementInvoiceGuardEnabled: true,
});
app.decorate("hasPermission", (employee, permissionCode) => (
employee.permissionCodes.includes("*") || employee.permissionCodes.includes(permissionCode)
));
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
if (!app.hasPermission(actor, permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({
requestId: request.id,
error: {
code: error.code ?? "SYS_001",
message: error.message,
details: error.details ?? {},
},
}));
await app.register(reimbursementRoutes, { prefix: "/api" });
return { app, prisma, tx };
}
function requestPayload() {
return {
method: "PATCH",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/invoices/${INVOICE_ID}/item-link`,
payload: { linkedItemId: ITEM_ID },
};
}
describe("PATCH reimbursement invoice item link", () => {
it("atomically links an unassigned bound invoice to an item in the same reimbursement", async () => {
const { app, tx } = await buildApp();
const response = await app.inject(requestPayload());
expect(response.statusCode, response.body).toBe(200);
expect(response.json().data).toEqual({
invoiceId: INVOICE_ID,
reimbursementId: REIMBURSEMENT_ID,
linkedItemId: ITEM_ID,
});
expect(tx.reimbursementItem.findFirst).toHaveBeenCalledWith({
where: { id: ITEM_ID, reimbursement_id: REIMBURSEMENT_ID },
select: { id: true },
});
expect(tx.invoicePool.update).toHaveBeenCalledWith({
where: { id: INVOICE_ID },
data: { linked_item_id: ITEM_ID, updated_by: ACTOR_ID },
});
expect(tx.auditLog.create).toHaveBeenCalledTimes(1);
await app.close();
});
it.each([
["submitter participant", { owner_id: OTHER_EMPLOYEE_ID, submitter_id: ACTOR_ID, status: "draft" }],
["rejected form", { owner_id: ACTOR_ID, submitter_id: ACTOR_ID, status: "rejected" }],
])("allows %s to restore the item link", async (_label, reimbursement) => {
const { app, tx } = await buildApp({ reimbursement });
const response = await app.inject(requestPayload());
expect(response.statusCode, response.body).toBe(200);
expect(tx.invoicePool.update).toHaveBeenCalledTimes(1);
expect(tx.auditLog.create).toHaveBeenCalledTimes(1);
await app.close();
});
it.each([
["nonparticipant", { reimbursement: { owner_id: OTHER_EMPLOYEE_ID, submitter_id: OTHER_EMPLOYEE_ID } }, 403],
["nonparticipant with a foreign invoice", {
reimbursement: { owner_id: OTHER_EMPLOYEE_ID, submitter_id: OTHER_EMPLOYEE_ID },
invoice: { linked_reimbursement_id: OTHER_REIMBURSEMENT_ID },
}, 403],
["submitted reimbursement", { reimbursement: { status: "reviewing" } }, 409],
["item from another reimbursement", { itemMissing: true }, 409],
["invoice already assigned", { invoice: { linked_item_id: ITEM_ID } }, 409],
["invoice belongs to another reimbursement", { invoice: { linked_reimbursement_id: OTHER_REIMBURSEMENT_ID } }, 409],
["invoice is no longer bound", { invoice: { status: "available" } }, 409],
])("rejects %s with zero writes", async (_label, options, expectedStatus) => {
const { app, tx } = await buildApp(options);
const response = await app.inject(requestPayload());
expect(response.statusCode).toBe(expectedStatus);
expect(tx.invoicePool.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
it("returns not found for a missing invoice only after validating the reimbursement participant", async () => {
const { app, tx } = await buildApp({ invoiceMissing: true });
const response = await app.inject(requestPayload());
expect(response.statusCode).toBe(404);
expect(tx.$queryRaw).toHaveBeenCalledTimes(2);
expect(tx.invoicePool.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
it("rejects an actor without the own-expense edit permission before reading rows", async () => {
const { app, tx } = await buildApp({
actor: { id: ACTOR_ID, permissionCodes: [], roleCodes: [] },
});
const response = await app.inject(requestPayload());
expect(response.statusCode).toBe(403);
expect(tx.$queryRaw).not.toHaveBeenCalled();
expect(tx.invoicePool.update).not.toHaveBeenCalled();
await app.close();
});
});
@@ -0,0 +1,42 @@
const {
INVOICE_AMOUNT_EVIDENCE_STATUS,
resolveInvoiceAmountEvidence,
} = require("../../../src/services/invoiceAmountEvidence");
describe("resolveInvoiceAmountEvidence", () => {
it("treats a present amount, including zero, as verified", () => {
expect(resolveInvoiceAmountEvidence({
totalAmount: "0.00",
ocrAttempts: 0,
ocrLastRequestedAt: null,
ocrCompletedAt: null,
ocrLastError: null,
})).toBe(INVOICE_AMOUNT_EVIDENCE_STATUS.verified);
});
it("requires manual review when no amount and no OCR execution evidence exist", () => {
expect(resolveInvoiceAmountEvidence({
totalAmount: null,
ocrAttempts: 0,
ocrLastRequestedAt: null,
ocrCompletedAt: null,
ocrLastError: null,
})).toBe(INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview);
});
it.each([
["attempt count", { ocrAttempts: 1 }],
["request time", { ocrLastRequestedAt: new Date("2026-08-30T10:00:00.000Z") }],
["completion time", { ocrCompletedAt: new Date("2026-08-30T10:00:01.000Z") }],
["last error", { ocrLastError: "amount_missing" }],
])("treats missing amount with %s as OCR failed", (_label, evidence) => {
expect(resolveInvoiceAmountEvidence({
totalAmount: null,
ocrAttempts: 0,
ocrLastRequestedAt: null,
ocrCompletedAt: null,
ocrLastError: null,
...evidence,
})).toBe(INVOICE_AMOUNT_EVIDENCE_STATUS.ocrFailed);
});
});