H1-H10 优化OCR实时体验闭环

这个提交包含在:
2026-05-09 11:48:57 +08:00
父节点 549bd81e30
当前提交 c26cf9b954
共修改 34 个文件,包含 1217 行新增和 184 行删除
+52
查看文件
@@ -0,0 +1,52 @@
const { canReceiveEvent, toClientEvent } = require("../services/events");
const HEARTBEAT_INTERVAL_MS = 25_000;
function writeSseEvent(raw, event) {
const clientEvent = toClientEvent(event);
if (!clientEvent) {
return;
}
raw.write(`id: ${clientEvent.id}\n`);
raw.write(`event: ${clientEvent.type}\n`);
raw.write(`data: ${JSON.stringify(clientEvent)}\n\n`);
}
async function eventsRoutes(fastify) {
fastify.get(
"/events",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const canManageInvoice = fastify.hasPermission(request.employee, "invoice.manage");
reply.hijack();
reply.raw.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
"connection": "keep-alive",
"x-accel-buffering": "no",
});
reply.raw.write(": connected\n\n");
const unsubscribe = fastify.appEvents.subscribe((event) => {
if (!canReceiveEvent(request.employee, event, { canManageInvoice })) {
return;
}
writeSseEvent(reply.raw, event);
});
const heartbeat = setInterval(() => {
reply.raw.write(": heartbeat\n\n");
}, HEARTBEAT_INTERVAL_MS);
request.raw.on("close", () => {
clearInterval(heartbeat);
unsubscribe();
});
},
);
}
module.exports = eventsRoutes;
+18
查看文件
@@ -10,6 +10,7 @@ const { sha256Hex } = require("../utils/crypto");
const { toMoneyString } = require("../utils/workflow");
const { findActiveDelegationForDelegate } = require("../utils/delegation");
const { getMissingRequiredOcrFields, isOcrEnabledForType } = require("../services/ocr");
const { enqueueOcrInvoice } = require("../jobs/pgBossJobs");
const { registerInvoiceVerificationRoutes } = require("./invoicePool/verification");
const { registerInvoicePoolReadRoutes } = require("./invoicePool/read");
const { normalizeInvoiceResponse } = require("./invoicePool/shared/mappers");
@@ -17,6 +18,7 @@ const { buildInvoiceDetailSelect } = require("./invoicePool/shared/selects");
const { writeBuffer } = require("../services/storage");
const {
mapInvoiceUnavailableReason,
resolveInvoiceAvailability,
resolveInvoiceDedupBlocker,
resolveUploadDedupDecision,
} = require("../services/invoiceGovernance");
@@ -509,6 +511,8 @@ async function invoicePoolRoutes(fastify) {
uploadAction: uploadDedupDecision.action,
unavailableReason: uploadDedupDecision.reason,
unavailableMessage: message,
availabilityReason: uploadDedupDecision.reason,
availabilityMessage: message,
});
}
@@ -570,10 +574,20 @@ async function invoicePoolRoutes(fastify) {
return created;
});
if (invoice.status === "pending_ocr") {
enqueueOcrInvoice(fastify, invoice.id).catch((error) => {
request.log.warn({ err: error, invoiceId: invoice.id }, "Failed to enqueue OCR invoice job");
});
}
const availability = resolveInvoiceAvailability(invoice);
return sendData(reply, request, {
invoicePoolId: invoice.id,
status: invoice.status,
fileSha256: invoice.file_sha256,
uploadAction: "create",
availabilityReason: availability.reason,
availabilityMessage: availability.message,
});
},
);
@@ -1231,6 +1245,10 @@ async function invoicePoolRoutes(fastify) {
return updated;
});
enqueueOcrInvoice(fastify, invoice.id).catch((error) => {
request.log.warn({ err: error, invoiceId: invoice.id }, "Failed to enqueue retry OCR invoice job");
});
return sendData(reply, request, normalizeInvoiceResponse(invoice));
},
);
+4 -43
查看文件
@@ -6,7 +6,7 @@ const { buildAuditLogData } = require("../../utils/audit");
const { signPayload, verifyToken } = require("../../utils/signedUrl");
const { openReadStream, StorageNotFoundError } = require("../../services/storage");
const { toMoneyString } = require("../../utils/workflow");
const { mapInvoiceUnavailableReason } = require("../../services/invoiceGovernance");
const { resolveInvoiceAvailability } = require("../../services/invoiceGovernance");
const {
mapInvoiceSummary,
normalizeInvoiceResponse,
@@ -106,48 +106,7 @@ function parseInvoiceStatusQuery(query) {
}
function resolveInvoiceStatusReason(invoice) {
if (invoice.status === "pending_ocr") {
return {
reason: "pending_ocr",
message: "发票正在识别,请稍后刷新",
};
}
if (invoice.status === "ocr_failed") {
const rawError = String(invoice.ocr_last_error ?? "");
if (rawError === "duplicate_invoice_code_number" || rawError.includes("duplicate_invoice")) {
return {
reason: "duplicate_invoice_code_number",
message: mapInvoiceUnavailableReason("used_by_company") ?? "发票重复,不能添加",
};
}
return {
reason: "ocr_failed",
message: rawError || "发票识别失败,请重新识别或手动处理",
};
}
if (invoice.status === "bound") {
return {
reason: "already_added",
message: "发票已添加到报销单",
};
}
if (invoice.status === "archived") {
return {
reason: invoice.archived_reason === "used_complete" ? "used_by_company" : "archived",
message:
invoice.archived_reason === "used_complete"
? "该发票已用于报销,不能重复添加"
: "发票已归档,不可添加",
};
}
return {
reason: "available",
message: "发票可添加",
};
return resolveInvoiceAvailability(invoice);
}
function ensureInvoiceReadableByUser(request, invoice) {
@@ -254,6 +213,8 @@ function registerInvoicePoolReadRoutes(fastify) {
status: invoice.status,
reason: statusReason.reason,
message: statusReason.message,
availabilityReason: statusReason.reason,
availabilityMessage: statusReason.message,
invoiceCode: invoice.invoice_code ?? null,
invoiceNumber: invoice.invoice_number ?? null,
invoiceDate: invoice.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
@@ -1,4 +1,5 @@
const { toMoneyString } = require("../../../utils/workflow");
const { resolveInvoiceAvailability } = require("../../../services/invoiceGovernance");
function mapVerificationRecord(item) {
if (!item) {
@@ -26,10 +27,13 @@ function mapInvoiceSummary(item) {
const latestVerification = Array.isArray(item.verification_records)
? item.verification_records[0] ?? null
: null;
const availability = resolveInvoiceAvailability(item);
return {
id: item.id,
status: item.status,
availabilityReason: availability.reason,
availabilityMessage: availability.message,
ocrType: item.ocr_type,
ocrProvider: item.ocr_provider,
ocrAttempts: item.ocr_attempts,
@@ -71,10 +75,13 @@ function normalizeInvoiceResponse(invoice) {
const latestVerification = Array.isArray(invoice.verification_records)
? invoice.verification_records[0] ?? null
: null;
const availability = resolveInvoiceAvailability(invoice);
return {
id: invoice.id,
status: invoice.status,
availabilityReason: availability.reason,
availabilityMessage: availability.message,
archivedReason: invoice.archived_reason ?? null,
archivedAt: invoice.archived_at?.toISOString?.() ?? null,
ownerEmployeeId: invoice.owner_employee_id,
@@ -28,6 +28,7 @@ function buildInvoiceSummarySelect() {
return {
id: true,
status: true,
archived_reason: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,