From b892f2f5bfbea7a6a90a8da62988b1ebcb803d76 Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 29 Apr 2026 09:38:31 +0800 Subject: [PATCH] =?UTF-8?q?R77-A1:=20=E4=BF=AE=E5=A4=8D=E8=85=BE=E8=AE=AFO?= =?UTF-8?q?CR=E7=9A=84PDF=E8=AF=86=E5=88=AB=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/jobs/ocrJob.js | 26 +++++++- server/src/services/ocr/index.js | 4 +- server/src/services/ocr/tencent.js | 95 +++++++++++++++++++++++++----- 3 files changed, 106 insertions(+), 19 deletions(-) diff --git a/server/src/jobs/ocrJob.js b/server/src/jobs/ocrJob.js index b96686ed..ac7bc7fb 100644 --- a/server/src/jobs/ocrJob.js +++ b/server/src/jobs/ocrJob.js @@ -6,7 +6,13 @@ const { readFileBuffer } = require("../services/storage"); const OCR_DUPLICATE_ERROR = "duplicate_invoice_code_number"; 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) { return message.slice(0, 500); } @@ -53,7 +59,7 @@ async function claimPendingInvoices(prisma, config, workerId) { ocr_lock_owner = ${workerId} FROM candidates 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, ocrType: claimedInvoice.ocr_type, imageBase64, + sourceMimeType: claimedInvoice.mime_type, }); try { @@ -271,6 +278,8 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l { invoiceId: claimedInvoice.id, ocrType: claimedInvoice.ocr_type, + mimeType: claimedInvoice.mime_type, + transportMode: normalized?.transportMode ?? normalized?.raw?.transportMode ?? null, qualityError, }, "OCR invoice marked as failed for missing required fields", @@ -291,6 +300,8 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l { invoiceId: claimedInvoice.id, duplicateInvoiceId: duplicate.id, + mimeType: claimedInvoice.mime_type, + transportMode: normalized?.transportMode ?? normalized?.raw?.transportMode ?? null, }, "OCR invoice marked as duplicate", ); @@ -302,7 +313,16 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l } catch (error) { const message = trimErrorMessage(error); 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" }; } } diff --git a/server/src/services/ocr/index.js b/server/src/services/ocr/index.js index c69346bb..93c5d991 100644 --- a/server/src/services/ocr/index.js +++ b/server/src/services/ocr/index.js @@ -34,7 +34,7 @@ function buildMissingRequiredOcrError(ocrType, normalized) { return `missing_required_fields:${missingFields.join(",")}`; } -async function runOcrForInvoice({ config, ocrType, imageBase64 }) { +async function runOcrForInvoice({ config, ocrType, imageBase64, sourceMimeType }) { if (config.ocrProvider === "mock") { return runMockOcr({ ocrType, imageBase64 }); } @@ -47,7 +47,7 @@ async function runOcrForInvoice({ config, ocrType, imageBase64 }) { throw new Error(`Unsupported OCR type: ${ocrType}`); } - return runTencentVatOcr(config, imageBase64); + return runTencentVatOcr(config, { imageBase64, sourceMimeType }); } module.exports = { diff --git a/server/src/services/ocr/tencent.js b/server/src/services/ocr/tencent.js index ee63e3a3..3f47bd70 100644 --- a/server/src/services/ocr/tencent.js +++ b/server/src/services/ocr/tencent.js @@ -1,4 +1,5 @@ const tencentcloud = require("tencentcloud-sdk-nodejs"); +const PDF_MIME_TYPE = "application/pdf"; function normalizeDate(value) { if (!value || typeof value !== "string") { @@ -33,7 +34,7 @@ function toNumberString(value) { return null; } - const stripped = value.replace(/[,¥\s]/g, "").trim(); + const stripped = value.replace(/[,¥¥\s]/g, "").trim(); if (!stripped) { return null; } @@ -84,6 +85,35 @@ function normalizeString(value) { 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) { if (!config.tencentSecretId || !config.tencentSecretKey) { throw new Error("Tencent OCR credentials are missing"); @@ -105,32 +135,69 @@ function createTencentOcrClient(config) { } 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 taxAmount = toNumberString(result?.TaxAmount); const totalAmountFromSource = toNumberString(result?.TotalAmount ?? result?.AmountInFiguers); - const derivedAmount = amount ?? subtractNumberStrings(totalAmountFromSource, taxAmount); - const derivedTotalAmount = totalAmountFromSource ?? addNumberStrings(derivedAmount, taxAmount); + const resolvedAmount = amount ?? amountFromInfo; + const resolvedTaxAmount = taxAmount ?? taxAmountFromInfo; + const resolvedTotalAmountFromSource = totalAmountFromSource ?? totalAmountFromInfo; + const derivedAmount = + resolvedAmount ?? subtractNumberStrings(resolvedTotalAmountFromSource, resolvedTaxAmount); + const derivedTotalAmount = + resolvedTotalAmountFromSource ?? addNumberStrings(derivedAmount, resolvedTaxAmount); return { - invoiceCode: normalizeIdentifier(result?.InvoiceCode), - invoiceNumber: normalizeIdentifier(result?.InvoiceNum), - invoiceDate: normalizeDate(result?.InvoiceDate), + invoiceCode: normalizeIdentifier(result?.InvoiceCode) ?? invoiceCodeFromInfo, + invoiceNumber: normalizeIdentifier(result?.InvoiceNum) ?? invoiceNumberFromInfo, + invoiceDate: normalizeDate(result?.InvoiceDate) ?? invoiceDateFromInfo, amount: derivedAmount, - taxAmount, + taxAmount: resolvedTaxAmount, totalAmount: derivedTotalAmount, - sellerName: normalizeString(result?.SellerName), - buyerName: normalizeString(result?.BuyerName), + sellerName: normalizeString(result?.SellerName) ?? sellerNameFromInfo, + buyerName: normalizeString(result?.BuyerName) ?? buyerNameFromInfo, raw: result ?? {}, }; } -async function runTencentVatOcr(config, imageBase64) { +async function runTencentVatOcr(config, { imageBase64, sourceMimeType }) { const client = createTencentOcrClient(config); - const result = await client.VatInvoiceOCR({ - ImageBase64: imageBase64, - }); + const transportMode = resolveTransportMode(sourceMimeType); - 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 = {