R77-A1: 修复腾讯OCR的PDF识别链路

这个提交包含在:
2026-04-29 09:38:31 +08:00
父节点 80b1c31037
当前提交 b892f2f5bf
共修改 3 个文件,包含 106 行新增和 19 行删除
+23 -3
查看文件
@@ -6,7 +6,13 @@ const { readFileBuffer } = require("../services/storage");
const OCR_DUPLICATE_ERROR = "duplicate_invoice_code_number"; const OCR_DUPLICATE_ERROR = "duplicate_invoice_code_number";
function trimErrorMessage(error) { function trimErrorMessage(error) {
const message = error?.message ?? "ocr_failed"; const code = typeof error?.code === "string" ? error.code : null;
const transportMode = typeof error?.transportMode === "string" ? error.transportMode : null;
const baseMessage = error?.message ?? "ocr_failed";
const message =
transportMode || code
? `[${transportMode ?? "unknown"}][${code ?? "ocr_failed"}] ${baseMessage}`
: baseMessage;
if (message.length > 500) { if (message.length > 500) {
return message.slice(0, 500); return message.slice(0, 500);
} }
@@ -53,7 +59,7 @@ async function claimPendingInvoices(prisma, config, workerId) {
ocr_lock_owner = ${workerId} ocr_lock_owner = ${workerId}
FROM candidates FROM candidates
WHERE i.id = candidates.id WHERE i.id = candidates.id
RETURNING i.id, i.file_sha256, i.ocr_type, i.storage_path, i.ocr_attempts RETURNING i.id, i.file_sha256, i.ocr_type, i.storage_path, i.mime_type, i.ocr_attempts
`; `;
} }
@@ -110,6 +116,7 @@ async function getOrCreateOcrResult(prisma, config, claimedInvoice, imageBase64)
config, config,
ocrType: claimedInvoice.ocr_type, ocrType: claimedInvoice.ocr_type,
imageBase64, imageBase64,
sourceMimeType: claimedInvoice.mime_type,
}); });
try { try {
@@ -271,6 +278,8 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l
{ {
invoiceId: claimedInvoice.id, invoiceId: claimedInvoice.id,
ocrType: claimedInvoice.ocr_type, ocrType: claimedInvoice.ocr_type,
mimeType: claimedInvoice.mime_type,
transportMode: normalized?.transportMode ?? normalized?.raw?.transportMode ?? null,
qualityError, qualityError,
}, },
"OCR invoice marked as failed for missing required fields", "OCR invoice marked as failed for missing required fields",
@@ -291,6 +300,8 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l
{ {
invoiceId: claimedInvoice.id, invoiceId: claimedInvoice.id,
duplicateInvoiceId: duplicate.id, duplicateInvoiceId: duplicate.id,
mimeType: claimedInvoice.mime_type,
transportMode: normalized?.transportMode ?? normalized?.raw?.transportMode ?? null,
}, },
"OCR invoice marked as duplicate", "OCR invoice marked as duplicate",
); );
@@ -302,7 +313,16 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l
} catch (error) { } catch (error) {
const message = trimErrorMessage(error); const message = trimErrorMessage(error);
await markInvoiceAsFailed(prisma, claimedInvoice, message, config); await markInvoiceAsFailed(prisma, claimedInvoice, message, config);
logger.error({ err: error, invoiceId: claimedInvoice.id, workerId }, "OCR processing failed"); logger.error(
{
err: error,
invoiceId: claimedInvoice.id,
mimeType: claimedInvoice.mime_type,
workerId,
transportMode: error?.transportMode ?? null,
},
"OCR processing failed",
);
return { outcome: "failed" }; return { outcome: "failed" };
} }
} }
+2 -2
查看文件
@@ -34,7 +34,7 @@ function buildMissingRequiredOcrError(ocrType, normalized) {
return `missing_required_fields:${missingFields.join(",")}`; return `missing_required_fields:${missingFields.join(",")}`;
} }
async function runOcrForInvoice({ config, ocrType, imageBase64 }) { async function runOcrForInvoice({ config, ocrType, imageBase64, sourceMimeType }) {
if (config.ocrProvider === "mock") { if (config.ocrProvider === "mock") {
return runMockOcr({ ocrType, imageBase64 }); return runMockOcr({ ocrType, imageBase64 });
} }
@@ -47,7 +47,7 @@ async function runOcrForInvoice({ config, ocrType, imageBase64 }) {
throw new Error(`Unsupported OCR type: ${ocrType}`); throw new Error(`Unsupported OCR type: ${ocrType}`);
} }
return runTencentVatOcr(config, imageBase64); return runTencentVatOcr(config, { imageBase64, sourceMimeType });
} }
module.exports = { module.exports = {
+81 -14
查看文件
@@ -1,4 +1,5 @@
const tencentcloud = require("tencentcloud-sdk-nodejs"); const tencentcloud = require("tencentcloud-sdk-nodejs");
const PDF_MIME_TYPE = "application/pdf";
function normalizeDate(value) { function normalizeDate(value) {
if (!value || typeof value !== "string") { if (!value || typeof value !== "string") {
@@ -33,7 +34,7 @@ function toNumberString(value) {
return null; return null;
} }
const stripped = value.replace(/[,¥\s]/g, "").trim(); const stripped = value.replace(/[,¥¥\s]/g, "").trim();
if (!stripped) { if (!stripped) {
return null; return null;
} }
@@ -84,6 +85,35 @@ function normalizeString(value) {
return normalized || null; return normalized || null;
} }
function normalizeInvoiceInfoMap(result) {
if (!Array.isArray(result?.VatInvoiceInfos)) {
return null;
}
return result.VatInvoiceInfos.reduce((acc, item) => {
if (typeof item?.Name !== "string") {
return acc;
}
acc[item.Name.trim()] = item?.Value;
return acc;
}, {});
}
function resolveTransportMode(sourceMimeType) {
return sourceMimeType === PDF_MIME_TYPE ? "pdf_native" : "image_native";
}
function wrapTencentError(error, transportMode) {
const code = typeof error?.code === "string" ? error.code : "ocr_failed";
const message = error?.message ?? "ocr_failed";
const wrapped = new Error(message);
wrapped.code = code;
wrapped.transportMode = transportMode;
wrapped.cause = error;
return wrapped;
}
function createTencentOcrClient(config) { function createTencentOcrClient(config) {
if (!config.tencentSecretId || !config.tencentSecretKey) { if (!config.tencentSecretId || !config.tencentSecretKey) {
throw new Error("Tencent OCR credentials are missing"); throw new Error("Tencent OCR credentials are missing");
@@ -105,32 +135,69 @@ function createTencentOcrClient(config) {
} }
function normalizeVatInvoiceResult(result) { function normalizeVatInvoiceResult(result) {
const infoMap = normalizeInvoiceInfoMap(result);
const invoiceCodeFromInfo =
normalizeIdentifier(infoMap?.["发票代码"]) ?? normalizeIdentifier(infoMap?.["发票号码"]);
const invoiceNumberFromInfo =
normalizeIdentifier(infoMap?.["发票号码"]) ?? normalizeIdentifier(infoMap?.["发票代码"]);
const invoiceDateFromInfo = normalizeDate(infoMap?.["开票日期"]);
const amountFromInfo = toNumberString(infoMap?.["合计金额"] ?? infoMap?.["小计金额"]);
const taxAmountFromInfo = toNumberString(infoMap?.["合计税额"] ?? infoMap?.["小计税额"]);
const totalAmountFromInfo = toNumberString(infoMap?.["价税合计(小写)"]);
const sellerNameFromInfo = normalizeString(infoMap?.["销售方名称"]);
const buyerNameFromInfo = normalizeString(infoMap?.["购买方名称"]);
const amount = toNumberString(result?.AmountWithoutTax); const amount = toNumberString(result?.AmountWithoutTax);
const taxAmount = toNumberString(result?.TaxAmount); const taxAmount = toNumberString(result?.TaxAmount);
const totalAmountFromSource = toNumberString(result?.TotalAmount ?? result?.AmountInFiguers); const totalAmountFromSource = toNumberString(result?.TotalAmount ?? result?.AmountInFiguers);
const derivedAmount = amount ?? subtractNumberStrings(totalAmountFromSource, taxAmount); const resolvedAmount = amount ?? amountFromInfo;
const derivedTotalAmount = totalAmountFromSource ?? addNumberStrings(derivedAmount, taxAmount); const resolvedTaxAmount = taxAmount ?? taxAmountFromInfo;
const resolvedTotalAmountFromSource = totalAmountFromSource ?? totalAmountFromInfo;
const derivedAmount =
resolvedAmount ?? subtractNumberStrings(resolvedTotalAmountFromSource, resolvedTaxAmount);
const derivedTotalAmount =
resolvedTotalAmountFromSource ?? addNumberStrings(derivedAmount, resolvedTaxAmount);
return { return {
invoiceCode: normalizeIdentifier(result?.InvoiceCode), invoiceCode: normalizeIdentifier(result?.InvoiceCode) ?? invoiceCodeFromInfo,
invoiceNumber: normalizeIdentifier(result?.InvoiceNum), invoiceNumber: normalizeIdentifier(result?.InvoiceNum) ?? invoiceNumberFromInfo,
invoiceDate: normalizeDate(result?.InvoiceDate), invoiceDate: normalizeDate(result?.InvoiceDate) ?? invoiceDateFromInfo,
amount: derivedAmount, amount: derivedAmount,
taxAmount, taxAmount: resolvedTaxAmount,
totalAmount: derivedTotalAmount, totalAmount: derivedTotalAmount,
sellerName: normalizeString(result?.SellerName), sellerName: normalizeString(result?.SellerName) ?? sellerNameFromInfo,
buyerName: normalizeString(result?.BuyerName), buyerName: normalizeString(result?.BuyerName) ?? buyerNameFromInfo,
raw: result ?? {}, raw: result ?? {},
}; };
} }
async function runTencentVatOcr(config, imageBase64) { async function runTencentVatOcr(config, { imageBase64, sourceMimeType }) {
const client = createTencentOcrClient(config); const client = createTencentOcrClient(config);
const result = await client.VatInvoiceOCR({ const transportMode = resolveTransportMode(sourceMimeType);
ImageBase64: imageBase64,
});
return normalizeVatInvoiceResult(result); try {
const request = {
ImageBase64: imageBase64,
};
if (transportMode === "pdf_native") {
request.IsPdf = true;
request.PdfPageNumber = 1;
}
const result = await client.VatInvoiceOCR(request);
const normalized = normalizeVatInvoiceResult(result);
return {
...normalized,
transportMode,
raw: {
...(normalized.raw ?? {}),
transportMode,
},
};
} catch (error) {
throw wrapTencentError(error, transportMode);
}
} }
module.exports = { module.exports = {