const tencentcloud = require("tencentcloud-sdk-nodejs"); const PDF_MIME_TYPE = "application/pdf"; function normalizeDate(value) { if (!value || typeof value !== "string") { return null; } const compact = value.trim(); if (!compact) { return null; } const replaced = compact.replace(/年|\//g, "-").replace(/月/g, "-").replace(/日/g, ""); const matched = replaced.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); if (!matched) { return null; } const [, year, month, day] = matched; return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; } function toNumberString(value) { if (value == null) { return null; } if (typeof value === "number" && Number.isFinite(value)) { return value.toFixed(2); } if (typeof value !== "string") { return null; } const stripped = value.replace(/[,¥¥\s]/g, "").trim(); if (!stripped) { return null; } const num = Number(stripped); if (!Number.isFinite(num)) { return null; } return num.toFixed(2); } function addNumberStrings(...values) { const normalized = values.map((value) => toNumberString(value)); if (normalized.some((value) => value == null)) { return null; } const total = normalized.reduce((sum, value) => sum + Number(value), 0); return total.toFixed(2); } function subtractNumberStrings(minuend, subtrahend) { const normalizedMinuend = toNumberString(minuend); const normalizedSubtrahend = toNumberString(subtrahend); if (normalizedMinuend == null || normalizedSubtrahend == null) { return null; } return (Number(normalizedMinuend) - Number(normalizedSubtrahend)).toFixed(2); } function normalizeIdentifier(value) { if (typeof value !== "string") { return null; } const normalized = value.replace(/\s+/g, "").trim(); return normalized || null; } function normalizeString(value) { if (typeof value !== "string") { return null; } const normalized = value.replace(/\s+/g, " ").trim(); 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"); } const OcrClient = tencentcloud.ocr.v20181119.Client; return new OcrClient({ credential: { secretId: config.tencentSecretId, secretKey: config.tencentSecretKey, }, region: config.tencentOcrRegion, profile: { httpProfile: { timeout: config.ocrTimeoutMs, }, }, }); } 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 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) ?? invoiceCodeFromInfo, invoiceNumber: normalizeIdentifier(result?.InvoiceNum) ?? invoiceNumberFromInfo, invoiceDate: normalizeDate(result?.InvoiceDate) ?? invoiceDateFromInfo, amount: derivedAmount, taxAmount: resolvedTaxAmount, totalAmount: derivedTotalAmount, sellerName: normalizeString(result?.SellerName) ?? sellerNameFromInfo, buyerName: normalizeString(result?.BuyerName) ?? buyerNameFromInfo, raw: result ?? {}, }; } async function runTencentVatOcr(config, { imageBase64, sourceMimeType }) { const client = createTencentOcrClient(config); const transportMode = resolveTransportMode(sourceMimeType); 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 = { normalizeVatInvoiceResult, runTencentVatOcr, };