H1-H10 优化OCR实时体验闭环
这个提交包含在:
@@ -0,0 +1,104 @@
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(Array.isArray(values) ? values : [])
|
||||
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function createAppEventBus() {
|
||||
const emitter = new EventEmitter();
|
||||
emitter.setMaxListeners(500);
|
||||
let sequence = 0;
|
||||
|
||||
return {
|
||||
publish(event) {
|
||||
const payload = {
|
||||
id: `${Date.now()}-${++sequence}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
...event,
|
||||
audienceEmployeeIds: uniqueStrings(event?.audienceEmployeeIds),
|
||||
};
|
||||
emitter.emit("event", payload);
|
||||
return payload;
|
||||
},
|
||||
subscribe(handler) {
|
||||
emitter.on("event", handler);
|
||||
return () => emitter.off("event", handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildInvoiceUpdatedEvent({
|
||||
invoiceId,
|
||||
status,
|
||||
updatedAt = null,
|
||||
audienceEmployeeIds = [],
|
||||
}) {
|
||||
return {
|
||||
type: "invoice.updated",
|
||||
invoiceId,
|
||||
status,
|
||||
updatedAt: updatedAt ?? new Date().toISOString(),
|
||||
audienceEmployeeIds: uniqueStrings(audienceEmployeeIds),
|
||||
};
|
||||
}
|
||||
|
||||
function publishInvoiceUpdated(fastify, event) {
|
||||
if (!fastify?.appEvents || typeof fastify.appEvents.publish !== "function") {
|
||||
return null;
|
||||
}
|
||||
return fastify.appEvents.publish(buildInvoiceUpdatedEvent(event));
|
||||
}
|
||||
|
||||
function canReceiveEvent(employee, event, options = {}) {
|
||||
if (!employee) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event?.type === "invoice.updated" && options.canManageInvoice) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const audience = uniqueStrings(event?.audienceEmployeeIds);
|
||||
if (!audience.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return audience.includes(employee.id);
|
||||
}
|
||||
|
||||
function toClientEvent(event) {
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (event.type === "invoice.updated") {
|
||||
return {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
invoiceId: event.invoiceId,
|
||||
status: event.status ?? null,
|
||||
updatedAt: event.updatedAt ?? event.createdAt ?? null,
|
||||
createdAt: event.createdAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
createdAt: event.createdAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildInvoiceUpdatedEvent,
|
||||
canReceiveEvent,
|
||||
createAppEventBus,
|
||||
publishInvoiceUpdated,
|
||||
toClientEvent,
|
||||
};
|
||||
@@ -8,6 +8,14 @@ const INVOICE_UNAVAILABLE_REASONS = {
|
||||
usedByCompany: "used_by_company",
|
||||
};
|
||||
|
||||
const OCR_FIELD_LABELS = {
|
||||
invoiceCode: "发票代码",
|
||||
invoiceNumber: "发票号码",
|
||||
invoiceDate: "开票日期",
|
||||
totalAmount: "总金额",
|
||||
sellerName: "销售方",
|
||||
};
|
||||
|
||||
function buildInvoiceDedupFilters({ fileSha256 = null, invoiceCode = null, invoiceNumber = null }) {
|
||||
const filters = [];
|
||||
if (fileSha256) {
|
||||
@@ -182,13 +190,100 @@ function mapInvoiceUnavailableReason(reason) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseMissingInvoiceOcrFields(rawError) {
|
||||
if (typeof rawError !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matched = rawError.match(/^missing_required_fields:(.+)$/);
|
||||
if (!matched?.[1]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return matched[1]
|
||||
.split(",")
|
||||
.map((field) => field.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function formatInvoiceOcrFailureMessage(rawError) {
|
||||
const missingFields = parseMissingInvoiceOcrFields(rawError);
|
||||
if (missingFields.length) {
|
||||
const labels = missingFields.map((field) => OCR_FIELD_LABELS[field] ?? field);
|
||||
return `票据识别缺少关键字段:${labels.join("、")}。`;
|
||||
}
|
||||
|
||||
if (rawError === "mock_ocr_failed") {
|
||||
return "票据识别失败,请重试或人工补齐关键信息。";
|
||||
}
|
||||
|
||||
if (!rawError) {
|
||||
return "票据识别失败,请稍后重试。";
|
||||
}
|
||||
|
||||
return "票据识别失败,请重新识别或手动处理。";
|
||||
}
|
||||
|
||||
function resolveInvoiceAvailability(invoice) {
|
||||
if (!invoice) {
|
||||
return {
|
||||
reason: "not_found",
|
||||
message: "未找到发票",
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
message: "已识别出相同发票,不能重复添加",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reason: "ocr_failed",
|
||||
message: formatInvoiceOcrFailureMessage(rawError),
|
||||
};
|
||||
}
|
||||
|
||||
if (isUsedInvoice(invoice)) {
|
||||
return {
|
||||
reason: INVOICE_UNAVAILABLE_REASONS.usedByCompany,
|
||||
message: mapInvoiceUnavailableReason(INVOICE_UNAVAILABLE_REASONS.usedByCompany),
|
||||
};
|
||||
}
|
||||
|
||||
if (invoice.status === "archived") {
|
||||
return {
|
||||
reason: "archived",
|
||||
message: "发票已归档,不可添加",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reason: "available",
|
||||
message: "发票可添加",
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
INVOICE_UNAVAILABLE_REASONS,
|
||||
buildInvoiceDedupFilters,
|
||||
classifyInvoiceConflict,
|
||||
findInvoiceDedupConflicts,
|
||||
formatInvoiceOcrFailureMessage,
|
||||
isUsedInvoice,
|
||||
mapInvoiceUnavailableReason,
|
||||
parseMissingInvoiceOcrFields,
|
||||
resolveInvoiceAvailability,
|
||||
resolveInvoiceDedupBlocker,
|
||||
resolveUploadDedupDecision,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户