R77-A1: 修复腾讯OCR的PDF识别链路
这个提交包含在:
+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" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户