fix: 完善动态错误指引与发票日期校验(09-05-error-explainability-payment-readiness)

这个提交包含在:
2026-09-16 12:56:27 +08:00
父节点 b7d2285d8d
当前提交 3516d7ec04
共修改 28 个文件,包含 791 行新增和 616 行删除
@@ -84,7 +84,7 @@ function buildPrisma(invoiceOverrides = {}) {
return { prisma, state };
}
async function buildApp(invoiceOverrides = {}) {
async function buildApp(invoiceOverrides = {}, options = {}) {
const { prisma, state } = buildPrisma(invoiceOverrides);
const app = Fastify();
@@ -94,7 +94,7 @@ async function buildApp(invoiceOverrides = {}) {
appOrigin: "http://localhost:5173",
maxUploadSizeBytes: 10 * 1024 * 1024,
invoiceArchiveUnusedDays: 30,
ocrProvider: "mock",
ocrProvider: options.ocrProvider ?? "mock",
});
app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) {
@@ -102,7 +102,7 @@ async function buildApp(invoiceOverrides = {}) {
}
request.employee = {
id: OWNER_ID,
permissionCodes: ["invoice.upload"],
permissionCodes: options.permissionCodes ?? ["invoice.upload"],
roleCodes: ["employee"],
};
});
@@ -141,6 +141,53 @@ async function buildApp(invoiceOverrides = {}) {
}
describe("PATCH /api/invoice-pool/:id", () => {
it.each(["2024-02-29", "2026-04-30"])("preserves valid calendar date %s", async (invoiceDate) => {
const { app, state } = await buildApp();
try {
const response = await app.inject({ method: "PATCH", url: `/api/invoice-pool/${INVOICE_ID}`, headers: { authorization: "Bearer test" }, payload: { invoiceDate } });
expect(response.statusCode).toBe(200);
expect(state.invoice.invoice_date.toISOString().slice(0, 10)).toBe(invoiceDate);
} finally {
await app.close();
}
});
it.each([
["PATCH", "", {}, {}, "edit_empty", 400],
["PATCH", "", { amount: 12 }, {}, "edit_input_invalid", 400],
["PATCH", "", { invoiceDate: "2026-02-30" }, {}, "date_invalid", 400],
["PATCH", "", { invoiceDate: "2026-02-29" }, {}, "date_invalid", 400],
["PATCH", "", { invoiceDate: "2026-13-01" }, {}, "date_invalid", 400],
["PATCH", "", { invoiceDate: "2026-04-31" }, {}, "date_invalid", 400],
["PATCH", "", { amount: "invalid" }, {}, "amount_invalid", 400],
["PATCH", "", { taxAmount: "invalid" }, {}, "tax_amount_invalid", 400],
["PATCH", "", { totalAmount: "invalid" }, {}, "total_amount_invalid", 400],
["PATCH", "", { remarks: "synthetic" }, { status: "bound" }, "not_editable", 409],
["PATCH", "", { status: "available" }, { status: "available" }, "status_transition_invalid", 409],
["PATCH", "", { status: "available" }, {}, "ocr_fields_missing", 409],
["POST", "/archive", {}, { linked_reimbursement_id: "occupied" }, "archive_bound", 409],
["POST", "/archive", {}, { status: "pending_ocr" }, "archive_status_invalid", 409],
["POST", "/retry-ocr", {}, { status: "available" }, "ocr_retry_status_invalid", 409],
["POST", "/retry-ocr", {}, {}, "ocr_unavailable", 409],
["POST", "/override-duplicate", {}, {}, "override_input_invalid", 400],
])("explains %s %s %s without writes", async (method, suffix, payload, invoiceOverrides, reason, statusCode) => {
const { app, prisma, state } = await buildApp(invoiceOverrides, {
ocrProvider: "off",
permissionCodes: ["invoice.upload", "expense.finance.override_duplicate"],
});
const before = JSON.stringify(state.invoice);
try {
const response = await app.inject({ method, url: `/api/invoice-pool/${INVOICE_ID}${suffix}`, headers: { authorization: "Bearer test" }, payload });
expect(response.statusCode).toBe(statusCode);
expect(response.json().error.details.reasonCode).toBe(`invoice_pool.${reason}`);
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(JSON.stringify(state.invoice)).toBe(before);
} finally {
await app.close();
}
});
it("accepts valid invoiceDate and allows marking OCR-failed VAT invoice as available", async () => {
const { app, state } = await buildApp();