修复报销票据门禁提示与 OCR 降级(08-30-b2-submit-error-ocr-fallback)
这个提交包含在:
@@ -33,6 +33,10 @@ const {
|
||||
resolveEntityPrecheck,
|
||||
} = require("../services/feeControl");
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
const {
|
||||
INVOICE_AMOUNT_EVIDENCE_STATUS,
|
||||
resolveInvoiceAmountEvidence,
|
||||
} = require("../services/invoiceAmountEvidence");
|
||||
const {
|
||||
PAYMENT_PROCESSING_STAGE_DEFINITIONS,
|
||||
buildPaymentProcessingSemantics,
|
||||
@@ -103,6 +107,9 @@ const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREV
|
||||
const REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA = z
|
||||
.object({ projectId: z.string().uuid().nullable() })
|
||||
.strict();
|
||||
const REIMBURSEMENT_INVOICE_ITEM_LINK_BODY_SCHEMA = z
|
||||
.object({ linkedItemId: z.string().uuid() })
|
||||
.strict();
|
||||
|
||||
function resolveSignedFileOrigin(config, request) {
|
||||
const configured = String(config.appOrigin ?? "")
|
||||
@@ -2467,6 +2474,10 @@ async function reimbursementRoutes(fastify) {
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
linked_item_id: true,
|
||||
ocr_attempts: true,
|
||||
ocr_last_requested_at: true,
|
||||
ocr_last_error: true,
|
||||
ocr_completed_at: true,
|
||||
ocr_result: { select: { result_json: true } },
|
||||
},
|
||||
},
|
||||
@@ -2657,6 +2668,28 @@ async function reimbursementRoutes(fastify) {
|
||||
departmentId: reimbursement.department_id ?? null,
|
||||
strictDepartment: true,
|
||||
});
|
||||
const requiredInvoiceItemIds = new Set(
|
||||
reimbursement.items
|
||||
.filter((item) => item.expense_type?.requires_invoice)
|
||||
.map((item) => item.id),
|
||||
);
|
||||
const invoiceAmountEvidenceById = new Map(
|
||||
reimbursement.invoice_pool_items.map((invoice) => [
|
||||
invoice.id,
|
||||
resolveInvoiceAmountEvidence({
|
||||
totalAmount: invoice.total_amount,
|
||||
ocrAttempts: invoice.ocr_attempts,
|
||||
ocrLastRequestedAt: invoice.ocr_last_requested_at,
|
||||
ocrCompletedAt: invoice.ocr_completed_at,
|
||||
ocrLastError: invoice.ocr_last_error,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const invoiceAmountManualReviewRequired = reimbursement.invoice_pool_items.some(
|
||||
(invoice) =>
|
||||
requiredInvoiceItemIds.has(invoice.linked_item_id) &&
|
||||
invoiceAmountEvidenceById.get(invoice.id) === INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview,
|
||||
);
|
||||
|
||||
return sendData(reply, request, {
|
||||
id: reimbursement.id,
|
||||
@@ -2736,6 +2769,7 @@ async function reimbursementRoutes(fastify) {
|
||||
mimeType: item.mime_type ?? null,
|
||||
fileSize: item.file_size ?? null,
|
||||
linkedItemId: item.linked_item_id ?? null,
|
||||
amountEvidenceStatus: invoiceAmountEvidenceById.get(item.id),
|
||||
})),
|
||||
pendingWorkflow: pendingInstance
|
||||
? {
|
||||
@@ -2755,6 +2789,7 @@ async function reimbursementRoutes(fastify) {
|
||||
approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows),
|
||||
riskFlags: riskFlags.map(buildPersistedRiskFlag),
|
||||
invoiceGuardEnabled: invoiceGuardGate.enabled,
|
||||
invoiceAmountManualReviewRequired,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -3941,6 +3976,117 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
"/reimbursements/:id/invoices/:invoiceId/item-link",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.edit_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const parsedInvoiceId = UUID_SCHEMA.safeParse(request.params?.invoiceId);
|
||||
if (!parsedInvoiceId.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { invoiceId: "invalid_uuid" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsedBody = REIMBURSEMENT_INVOICE_ITEM_LINK_BODY_SCHEMA.safeParse(request.body ?? {});
|
||||
if (!parsedBody.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsedBody.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const invoiceId = parsedInvoiceId.data;
|
||||
const linkedItemId = parsedBody.data.linkedItemId;
|
||||
const response = await fastify.prisma.$transaction(async (tx) => {
|
||||
const invoiceRows = await tx.$queryRaw`
|
||||
SELECT id, status, linked_reimbursement_id, linked_item_id
|
||||
FROM "invoice_pool"
|
||||
WHERE id = ${invoiceId}::uuid
|
||||
FOR UPDATE
|
||||
`;
|
||||
const invoice = Array.isArray(invoiceRows) ? invoiceRows[0] ?? null : null;
|
||||
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, owner_id, submitter_id, status
|
||||
FROM "reimbursement_forms"
|
||||
WHERE id = ${reimbursementId}::uuid
|
||||
FOR UPDATE
|
||||
`;
|
||||
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureReimbursementParticipant(request, reimbursement);
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { reimbursement: "invalid_status_for_invoice_item_link" },
|
||||
});
|
||||
}
|
||||
if (!invoice) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (
|
||||
invoice.status !== "bound" ||
|
||||
invoice.linked_reimbursement_id !== reimbursementId ||
|
||||
invoice.linked_item_id != null
|
||||
) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { invoice: "invalid_unlinked_item_state" },
|
||||
});
|
||||
}
|
||||
|
||||
const linkedItem = await tx.reimbursementItem.findFirst({
|
||||
where: { id: linkedItemId, reimbursement_id: reimbursementId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!linkedItem) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { linkedItemId: "item_not_in_reimbursement" },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.invoicePool.update({
|
||||
where: { id: invoiceId },
|
||||
data: {
|
||||
linked_item_id: linkedItem.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "REIMBURSEMENT_INVOICE_ITEM_LINKED",
|
||||
targetType: "invoice_pool",
|
||||
targetId: invoiceId,
|
||||
extra: { reimbursementId, linkedItemId: linkedItem.id },
|
||||
}),
|
||||
});
|
||||
|
||||
return { invoiceId, reimbursementId, linkedItemId: linkedItem.id };
|
||||
});
|
||||
|
||||
return sendData(reply, request, response);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements/:id/precheck",
|
||||
{
|
||||
@@ -4016,7 +4162,14 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
invoice_pool_items: {
|
||||
where: { status: "bound" },
|
||||
select: { id: true, total_amount: true },
|
||||
select: {
|
||||
id: true,
|
||||
total_amount: true,
|
||||
ocr_attempts: true,
|
||||
ocr_last_requested_at: true,
|
||||
ocr_last_error: true,
|
||||
ocr_completed_at: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -4046,26 +4199,61 @@ async function reimbursementRoutes(fastify) {
|
||||
departmentId: reimbursement.department_id ?? null,
|
||||
strictDepartment: true,
|
||||
});
|
||||
const manualReviewItemIds = reimbursementItems
|
||||
.filter((item) => item.expense_type.requires_invoice)
|
||||
.filter((item) => item.invoice_pool_items.some((invoice) => (
|
||||
resolveInvoiceAmountEvidence({
|
||||
totalAmount: invoice.total_amount,
|
||||
ocrAttempts: invoice.ocr_attempts,
|
||||
ocrLastRequestedAt: invoice.ocr_last_requested_at,
|
||||
ocrCompletedAt: invoice.ocr_completed_at,
|
||||
ocrLastError: invoice.ocr_last_error,
|
||||
}) === INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview
|
||||
)))
|
||||
.map((item) => item.id);
|
||||
if (invoiceGuardGate.enabled) {
|
||||
const unlinkedBoundInvoiceCount = await tx.invoicePool.count({
|
||||
const unlinkedBoundInvoices = await tx.invoicePool.findMany({
|
||||
where: {
|
||||
linked_reimbursement_id: reimbursementId,
|
||||
linked_item_id: null,
|
||||
status: "bound",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const missingInvoiceItemIds = [];
|
||||
const amountUnavailableItems = [];
|
||||
const amountExceededItems = [];
|
||||
for (const item of reimbursementItems) {
|
||||
if (!item.expense_type.requires_invoice) continue;
|
||||
const validInvoices = item.invoice_pool_items.filter(
|
||||
(invoice) => invoice.total_amount != null,
|
||||
);
|
||||
if (!validInvoices.length) {
|
||||
if (!item.invoice_pool_items.length) {
|
||||
missingInvoiceItemIds.push(item.id);
|
||||
continue;
|
||||
}
|
||||
const invoiceTotal = validInvoices.reduce(
|
||||
|
||||
const evidenceByInvoice = item.invoice_pool_items.map((invoice) => ({
|
||||
invoice,
|
||||
status: resolveInvoiceAmountEvidence({
|
||||
totalAmount: invoice.total_amount,
|
||||
ocrAttempts: invoice.ocr_attempts,
|
||||
ocrLastRequestedAt: invoice.ocr_last_requested_at,
|
||||
ocrCompletedAt: invoice.ocr_completed_at,
|
||||
ocrLastError: invoice.ocr_last_error,
|
||||
}),
|
||||
}));
|
||||
const failedInvoiceIds = evidenceByInvoice
|
||||
.filter(({ status }) => status === INVOICE_AMOUNT_EVIDENCE_STATUS.ocrFailed)
|
||||
.map(({ invoice }) => invoice.id);
|
||||
if (failedInvoiceIds.length) {
|
||||
amountUnavailableItems.push({ itemId: item.id, invoiceIds: failedInvoiceIds });
|
||||
continue;
|
||||
}
|
||||
if (evidenceByInvoice.some(
|
||||
({ status }) => status === INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const invoiceTotal = item.invoice_pool_items.reduce(
|
||||
(sum, invoice) => sum.plus(invoice.total_amount),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
@@ -4077,15 +4265,22 @@ async function reimbursementRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (unlinkedBoundInvoiceCount || missingInvoiceItemIds.length || amountExceededItems.length) {
|
||||
if (
|
||||
unlinkedBoundInvoices.length ||
|
||||
missingInvoiceItemIds.length ||
|
||||
amountUnavailableItems.length ||
|
||||
amountExceededItems.length
|
||||
) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: {
|
||||
items: "invoice_item_guard_failed",
|
||||
unlinkedInvoiceCount: unlinkedBoundInvoiceCount,
|
||||
unlinkedInvoiceCount: unlinkedBoundInvoices.length,
|
||||
unlinkedInvoiceIds: unlinkedBoundInvoices.map((invoice) => invoice.id),
|
||||
missingInvoiceItemIds,
|
||||
amountUnavailableItems,
|
||||
amountExceededItems,
|
||||
},
|
||||
});
|
||||
@@ -4420,6 +4615,8 @@ async function reimbursementRoutes(fastify) {
|
||||
precheckDecision: authoritativePrecheck.decision,
|
||||
standardWarnings,
|
||||
openedRiskFlagCount: openedRiskFlags.length,
|
||||
invoiceAmountManualReviewRequired: manualReviewItemIds.length > 0,
|
||||
manualReviewItemCount: manualReviewItemIds.length,
|
||||
},
|
||||
sideEffectsContext: {
|
||||
reimbursementId,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
const INVOICE_AMOUNT_EVIDENCE_STATUS = Object.freeze({
|
||||
verified: "verified",
|
||||
manualReview: "manual_review",
|
||||
ocrFailed: "ocr_failed",
|
||||
});
|
||||
|
||||
function resolveInvoiceAmountEvidence({
|
||||
totalAmount,
|
||||
ocrAttempts,
|
||||
ocrLastRequestedAt,
|
||||
ocrCompletedAt,
|
||||
ocrLastError,
|
||||
}) {
|
||||
if (totalAmount != null) {
|
||||
return INVOICE_AMOUNT_EVIDENCE_STATUS.verified;
|
||||
}
|
||||
|
||||
const hasOcrExecutionEvidence =
|
||||
Number(ocrAttempts ?? 0) > 0 ||
|
||||
ocrLastRequestedAt != null ||
|
||||
ocrCompletedAt != null ||
|
||||
(typeof ocrLastError === "string" && ocrLastError.trim().length > 0);
|
||||
|
||||
return hasOcrExecutionEvidence
|
||||
? INVOICE_AMOUNT_EVIDENCE_STATUS.ocrFailed
|
||||
: INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
INVOICE_AMOUNT_EVIDENCE_STATUS,
|
||||
resolveInvoiceAmountEvidence,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户