207 行
5.9 KiB
JavaScript
207 行
5.9 KiB
JavaScript
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,
|
|
};
|