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

这个提交包含在:
2026-08-31 20:20:20 +08:00
父节点 8b19538584
当前提交 f57e2236f2
共修改 21 个文件,包含 1829 行新增和 49 行删除
+206 -9
查看文件
@@ -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,
};