实现腾讯通用票据OCR安全适配(07-28-travel-reimbursement-guide)
这个提交包含在:
+39
-12
@@ -1,6 +1,11 @@
|
||||
const cron = require("node-cron");
|
||||
|
||||
const { buildMissingRequiredOcrError, runOcrForInvoice } = require("../services/ocr");
|
||||
const {
|
||||
buildMissingRequiredOcrError,
|
||||
runOcrForInvoice,
|
||||
sanitizeOcrResultForPersistence,
|
||||
shouldDeduplicateByInvoiceNumberOnly,
|
||||
} = require("../services/ocr");
|
||||
const { publishInvoiceUpdated } = require("../services/events");
|
||||
const { resolveInvoiceDedupBlocker } = require("../services/invoiceGovernance");
|
||||
const { readFileBuffer } = require("../services/storage");
|
||||
@@ -117,18 +122,29 @@ async function getOrCreateOcrResult(prisma, config, claimedInvoice, imageBase64)
|
||||
});
|
||||
|
||||
if (cached) {
|
||||
const normalized = sanitizeOcrResultForPersistence(ocrProvider, cached.result_json);
|
||||
if (ocrProvider === "tencent") {
|
||||
await prisma.ocrResult.update({
|
||||
where: { id: cached.id },
|
||||
data: { result_json: normalized },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ocrResultId: cached.id,
|
||||
normalized: cached.result_json,
|
||||
normalized,
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = await runOcrForInvoice({
|
||||
config,
|
||||
ocrType: claimedInvoice.ocr_type,
|
||||
imageBase64,
|
||||
sourceMimeType: claimedInvoice.mime_type,
|
||||
});
|
||||
const normalized = sanitizeOcrResultForPersistence(
|
||||
ocrProvider,
|
||||
await runOcrForInvoice({
|
||||
config,
|
||||
ocrType: claimedInvoice.ocr_type,
|
||||
imageBase64,
|
||||
sourceMimeType: claimedInvoice.mime_type,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const created = await prisma.ocrResult.create({
|
||||
@@ -164,9 +180,17 @@ async function getOrCreateOcrResult(prisma, config, claimedInvoice, imageBase64)
|
||||
throw error;
|
||||
}
|
||||
|
||||
const racedNormalized = sanitizeOcrResultForPersistence(ocrProvider, raced.result_json);
|
||||
if (ocrProvider === "tencent") {
|
||||
await prisma.ocrResult.update({
|
||||
where: { id: raced.id },
|
||||
data: { result_json: racedNormalized },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ocrResultId: raced.id,
|
||||
normalized: raced.result_json,
|
||||
normalized: racedNormalized,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -246,10 +270,10 @@ async function markInvoiceAsQualityFailed(prisma, config, invoiceId, normalized,
|
||||
});
|
||||
}
|
||||
|
||||
async function markInvoiceAsFailed(prisma, claimedInvoice, errorMessage, config) {
|
||||
async function markInvoiceAsFailed(prisma, claimedInvoice, errorMessage, config, { terminal = false } = {}) {
|
||||
const now = new Date();
|
||||
const nextAttempts = Number(claimedInvoice.ocr_attempts || 0) + 1;
|
||||
const isTerminal = nextAttempts >= config.ocrMaxAttempts;
|
||||
const isTerminal = terminal || nextAttempts >= config.ocrMaxAttempts;
|
||||
const nextStatus = isTerminal ? "ocr_failed" : "pending_ocr";
|
||||
|
||||
await prisma.invoicePool.update({
|
||||
@@ -313,6 +337,7 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l
|
||||
fileSha256: claimedInvoice.file_sha256,
|
||||
invoiceCode: normalized.invoiceCode,
|
||||
invoiceNumber: normalized.invoiceNumber,
|
||||
deduplicateByInvoiceNumberOnly: shouldDeduplicateByInvoiceNumberOnly(normalized),
|
||||
ownerEmployeeId: claimedInvoice.owner_employee_id,
|
||||
},
|
||||
);
|
||||
@@ -336,7 +361,9 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l
|
||||
return { outcome: "available", status: "available" };
|
||||
} catch (error) {
|
||||
const message = trimErrorMessage(error);
|
||||
const status = await markInvoiceAsFailed(prisma, claimedInvoice, message, config);
|
||||
const status = await markInvoiceAsFailed(prisma, claimedInvoice, message, config, {
|
||||
terminal: error?.terminal === true,
|
||||
});
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
|
||||
@@ -9,7 +9,11 @@ const { compactDateString } = require("../utils/date");
|
||||
const { sha256Hex } = require("../utils/crypto");
|
||||
const { toMoneyString } = require("../utils/workflow");
|
||||
const { findActiveDelegationForDelegate } = require("../utils/delegation");
|
||||
const { getMissingRequiredOcrFields, isOcrEnabledForType } = require("../services/ocr");
|
||||
const {
|
||||
OCR_PDF_MAX_BYTES,
|
||||
getMissingRequiredOcrFields,
|
||||
isOcrEnabledForType,
|
||||
} = require("../services/ocr");
|
||||
const { enqueueOcrInvoice } = require("../jobs/pgBossJobs");
|
||||
const { registerInvoiceVerificationRoutes } = require("./invoicePool/verification");
|
||||
const { registerInvoicePoolReadRoutes } = require("./invoicePool/read");
|
||||
@@ -353,6 +357,11 @@ async function invoicePoolRoutes(fastify) {
|
||||
let fileMimeType = null;
|
||||
let fileSize = 0;
|
||||
const fields = {};
|
||||
const configuredUploadMaxBytes = Number(fastify.config.maxUploadSizeBytes);
|
||||
const invoiceUploadMaxBytes =
|
||||
Number.isFinite(configuredUploadMaxBytes) && configuredUploadMaxBytes > 0
|
||||
? Math.min(configuredUploadMaxBytes, OCR_PDF_MAX_BYTES)
|
||||
: OCR_PDF_MAX_BYTES;
|
||||
|
||||
try {
|
||||
for await (const part of request.parts()) {
|
||||
@@ -380,12 +389,21 @@ async function invoicePoolRoutes(fastify) {
|
||||
const chunks = [];
|
||||
for await (const chunk of part.file) {
|
||||
fileSize += chunk.length;
|
||||
if (fileSize > fastify.config.maxUploadSizeBytes) {
|
||||
if (fileSize > invoiceUploadMaxBytes) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
message:
|
||||
invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES
|
||||
? "PDF 发票不能超过 7 MiB"
|
||||
: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { file: "file_too_large" },
|
||||
details: {
|
||||
file:
|
||||
invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES
|
||||
? "ocr_pdf_too_large"
|
||||
: "file_too_large",
|
||||
maxBytes: invoiceUploadMaxBytes,
|
||||
},
|
||||
});
|
||||
}
|
||||
chunks.push(chunk);
|
||||
|
||||
@@ -14,9 +14,15 @@ const OCR_FIELD_LABELS = {
|
||||
invoiceDate: "开票日期",
|
||||
totalAmount: "总金额",
|
||||
sellerName: "销售方",
|
||||
buyerName: "购买方",
|
||||
};
|
||||
|
||||
function buildInvoiceDedupFilters({ fileSha256 = null, invoiceCode = null, invoiceNumber = null }) {
|
||||
function buildInvoiceDedupFilters({
|
||||
fileSha256 = null,
|
||||
invoiceCode = null,
|
||||
invoiceNumber = null,
|
||||
deduplicateByInvoiceNumberOnly = false,
|
||||
}) {
|
||||
const filters = [];
|
||||
if (fileSha256) {
|
||||
filters.push({ file_sha256: fileSha256 });
|
||||
@@ -26,6 +32,11 @@ function buildInvoiceDedupFilters({ fileSha256 = null, invoiceCode = null, invoi
|
||||
invoice_code: invoiceCode,
|
||||
invoice_number: invoiceNumber,
|
||||
});
|
||||
} else if (deduplicateByInvoiceNumberOnly && invoiceNumber) {
|
||||
filters.push({
|
||||
invoice_code: null,
|
||||
invoice_number: invoiceNumber,
|
||||
});
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
@@ -217,6 +228,39 @@ function formatInvoiceOcrFailureMessage(rawError) {
|
||||
return "票据识别失败,请重试或人工补齐关键信息。";
|
||||
}
|
||||
|
||||
if (rawError === "invoice_not_detected") {
|
||||
return "未识别到支持的发票,请确认 PDF 第一页只有一张有效票据。";
|
||||
}
|
||||
|
||||
if (rawError === "multiple_invoices_not_supported") {
|
||||
return "一个 PDF 第一页只能包含一张发票,请拆分后重新上传。";
|
||||
}
|
||||
|
||||
if (rawError === "unsupported_invoice_subtype") {
|
||||
return "当前仅支持住宿增值税发票和铁路电子客票。";
|
||||
}
|
||||
|
||||
if (rawError === "invoice_recognition_failed") {
|
||||
return "腾讯 OCR 未能识别该发票,请检查 PDF 清晰度后重试。";
|
||||
}
|
||||
|
||||
if (
|
||||
rawError.includes("ResourceUnavailable.ResourcePackageRunOut") ||
|
||||
rawError.includes("ResourceUnavailable.InArrears") ||
|
||||
rawError.includes("ResourcesSoldOut.ChargeStatusException")
|
||||
) {
|
||||
return "票据识别额度或计费状态暂不可用,请联系管理员。";
|
||||
}
|
||||
|
||||
if (
|
||||
rawError.includes("ocr_credentials_missing") ||
|
||||
rawError.includes("AuthFailure") ||
|
||||
rawError.includes("UnauthorizedOperation") ||
|
||||
rawError.includes("FailedOperation.UnOpenError")
|
||||
) {
|
||||
return "票据识别服务配置暂不可用,请联系管理员。";
|
||||
}
|
||||
|
||||
if (!rawError) {
|
||||
return "票据识别失败,请稍后重试。";
|
||||
}
|
||||
|
||||
@@ -1,10 +1,47 @@
|
||||
const { runTencentVatOcr } = require("./tencent");
|
||||
const { runTencentGeneralInvoiceOcr } = require("./tencent");
|
||||
const { runMockOcr } = require("./mock");
|
||||
|
||||
const OCR_PDF_MAX_BYTES = 7 * 1024 * 1024;
|
||||
|
||||
const REQUIRED_OCR_FIELDS_BY_TYPE = {
|
||||
vat: ["invoiceCode", "invoiceNumber", "invoiceDate", "totalAmount", "sellerName"],
|
||||
};
|
||||
|
||||
const REQUIRED_OCR_FIELDS_BY_DOCUMENT_CATEGORY = {
|
||||
vat_invoice: ["invoiceCode", "invoiceNumber", "invoiceDate", "totalAmount", "sellerName"],
|
||||
full_vat_invoice: ["invoiceNumber", "invoiceDate", "totalAmount", "sellerName"],
|
||||
electronic_train_ticket: ["invoiceNumber", "invoiceDate", "totalAmount", "buyerName"],
|
||||
};
|
||||
|
||||
const NUMBER_ONLY_DEDUP_DOCUMENT_CATEGORIES = new Set([
|
||||
"full_vat_invoice",
|
||||
"electronic_train_ticket",
|
||||
]);
|
||||
|
||||
const SAFE_DOCUMENT_CATEGORIES = new Set([
|
||||
"vat_invoice",
|
||||
"full_vat_invoice",
|
||||
"electronic_train_ticket",
|
||||
]);
|
||||
|
||||
const SAFE_QUALITY_ERRORS = new Set([
|
||||
"invoice_not_detected",
|
||||
"multiple_invoices_not_supported",
|
||||
"invoice_recognition_failed",
|
||||
"unsupported_invoice_subtype",
|
||||
]);
|
||||
|
||||
const SAFE_NORMALIZED_FIELDS = [
|
||||
"invoiceCode",
|
||||
"invoiceNumber",
|
||||
"invoiceDate",
|
||||
"amount",
|
||||
"taxAmount",
|
||||
"totalAmount",
|
||||
"sellerName",
|
||||
"buyerName",
|
||||
];
|
||||
|
||||
function isOcrEnabledForType(config, ocrType) {
|
||||
if (config.ocrProvider === "mock") {
|
||||
return ocrType === "vat";
|
||||
@@ -14,7 +51,10 @@ function isOcrEnabledForType(config, ocrType) {
|
||||
}
|
||||
|
||||
function getMissingRequiredOcrFields(ocrType, normalized) {
|
||||
const requiredFields = REQUIRED_OCR_FIELDS_BY_TYPE[ocrType] ?? [];
|
||||
const requiredFields =
|
||||
REQUIRED_OCR_FIELDS_BY_DOCUMENT_CATEGORY[normalized?.documentCategory] ??
|
||||
REQUIRED_OCR_FIELDS_BY_TYPE[ocrType] ??
|
||||
[];
|
||||
if (!requiredFields.length) {
|
||||
return [];
|
||||
}
|
||||
@@ -26,6 +66,10 @@ function getMissingRequiredOcrFields(ocrType, normalized) {
|
||||
}
|
||||
|
||||
function buildMissingRequiredOcrError(ocrType, normalized) {
|
||||
if (typeof normalized?.qualityError === "string" && normalized.qualityError) {
|
||||
return normalized.qualityError;
|
||||
}
|
||||
|
||||
const missingFields = getMissingRequiredOcrFields(ocrType, normalized);
|
||||
if (!missingFields.length) {
|
||||
return null;
|
||||
@@ -47,12 +91,63 @@ async function runOcrForInvoice({ config, ocrType, imageBase64, sourceMimeType }
|
||||
throw new Error(`Unsupported OCR type: ${ocrType}`);
|
||||
}
|
||||
|
||||
return runTencentVatOcr(config, { imageBase64, sourceMimeType });
|
||||
return runTencentGeneralInvoiceOcr(config, { imageBase64, sourceMimeType });
|
||||
}
|
||||
|
||||
function shouldDeduplicateByInvoiceNumberOnly(normalized) {
|
||||
return Boolean(
|
||||
normalized?.invoiceNumber &&
|
||||
!normalized?.invoiceCode &&
|
||||
NUMBER_ONLY_DEDUP_DOCUMENT_CATEGORIES.has(normalized?.documentCategory),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSafeString(value, maxLength) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized ? normalized.slice(0, maxLength) : null;
|
||||
}
|
||||
|
||||
function sanitizeOcrResultForPersistence(ocrProvider, normalized) {
|
||||
if (ocrProvider !== "tencent") {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const safe = {
|
||||
documentSubtype: normalizeSafeString(normalized?.documentSubtype, 64),
|
||||
documentCategory: SAFE_DOCUMENT_CATEGORIES.has(normalized?.documentCategory)
|
||||
? normalized.documentCategory
|
||||
: null,
|
||||
documentCount:
|
||||
Number.isInteger(normalized?.documentCount) && normalized.documentCount >= 0
|
||||
? Math.min(normalized.documentCount, 100)
|
||||
: 1,
|
||||
};
|
||||
|
||||
for (const field of SAFE_NORMALIZED_FIELDS) {
|
||||
safe[field] = normalizeSafeString(normalized?.[field], field.endsWith("Name") ? 256 : 64);
|
||||
}
|
||||
|
||||
if (SAFE_QUALITY_ERRORS.has(normalized?.qualityError)) {
|
||||
safe.qualityError = normalized.qualityError;
|
||||
}
|
||||
|
||||
if (normalized?.transportMode === "pdf_native") {
|
||||
safe.transportMode = "pdf_native";
|
||||
}
|
||||
|
||||
return safe;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OCR_PDF_MAX_BYTES,
|
||||
buildMissingRequiredOcrError,
|
||||
getMissingRequiredOcrFields,
|
||||
isOcrEnabledForType,
|
||||
runOcrForInvoice,
|
||||
sanitizeOcrResultForPersistence,
|
||||
shouldDeduplicateByInvoiceNumberOnly,
|
||||
};
|
||||
|
||||
+174
-67
@@ -1,6 +1,32 @@
|
||||
const tencentcloud = require("tencentcloud-sdk-nodejs");
|
||||
const PDF_MIME_TYPE = "application/pdf";
|
||||
|
||||
const VAT_INVOICE_SUBTYPE_FIELDS = new Map([
|
||||
["VatSpecialInvoice", "VatSpecialInvoice"],
|
||||
["VatCommonInvoice", "VatCommonInvoice"],
|
||||
["VatElectronicCommonInvoice", "VatElectronicCommonInvoice"],
|
||||
["VatElectronicSpecialInvoice", "VatElectronicSpecialInvoice"],
|
||||
["VatElectronicInvoiceBlockchain", "VatElectronicInvoiceBlockchain"],
|
||||
["VatElectronicInvoiceToll", "VatElectronicInvoiceToll"],
|
||||
]);
|
||||
|
||||
const FULL_VAT_INVOICE_SUBTYPE_FIELDS = new Map([
|
||||
["VatElectronicSpecialInvoiceFull", "VatElectronicSpecialInvoiceFull"],
|
||||
["VatElectronicInvoiceFull", "VatElectronicInvoiceFull"],
|
||||
]);
|
||||
|
||||
const ELECTRONIC_TRAIN_TICKET_SUBTYPE = "ElectronicTrainTicketFull";
|
||||
const TERMINAL_TENCENT_ERROR_CODE_PATTERNS = [
|
||||
/^AuthFailure(?:\.|$)/,
|
||||
/^UnauthorizedOperation(?:\.|$)/,
|
||||
/^InvalidParameter(?:\.|$)/,
|
||||
/^InvalidParameterValue(?:\.|$)/,
|
||||
/^FailedOperation\.(?:Arrears|ImageBlur|ImageDecodeFailed|ImageSizeTooLarge|NoChargePermission|OcrFailed|ServiceIsolate|UnOpenError)$/,
|
||||
/^LimitExceeded\.TooLargeFileError$/,
|
||||
/^ResourceUnavailable\.(?:InArrears|ResourcePackageRunOut)$/,
|
||||
/^ResourcesSoldOut\.ChargeStatusException$/,
|
||||
];
|
||||
|
||||
function normalizeDate(value) {
|
||||
if (!value || typeof value !== "string") {
|
||||
return null;
|
||||
@@ -34,7 +60,7 @@ function toNumberString(value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stripped = value.replace(/[,¥¥\s]/g, "").trim();
|
||||
const stripped = value.replace(/[,¥¥元\s]/g, "").trim();
|
||||
if (!stripped) {
|
||||
return null;
|
||||
}
|
||||
@@ -85,38 +111,33 @@ 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 isTerminalTencentErrorCode(code) {
|
||||
return (
|
||||
code === "ocr_credentials_missing" ||
|
||||
code === "unsupported_source_mime_type" ||
|
||||
TERMINAL_TENCENT_ERROR_CODE_PATTERNS.some((pattern) => pattern.test(code))
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
wrapped.terminal = isTerminalTencentErrorCode(code);
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
function createTencentOcrClient(config) {
|
||||
if (!config.tencentSecretId || !config.tencentSecretKey) {
|
||||
throw new Error("Tencent OCR credentials are missing");
|
||||
const error = new Error("Tencent OCR credentials are missing");
|
||||
error.code = "ocr_credentials_missing";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const OcrClient = tencentcloud.ocr.v20181119.Client;
|
||||
@@ -134,66 +155,150 @@ 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 resolvedAmount = amount ?? amountFromInfo;
|
||||
const resolvedTaxAmount = taxAmount ?? taxAmountFromInfo;
|
||||
const resolvedTotalAmountFromSource = totalAmountFromSource ?? totalAmountFromInfo;
|
||||
const derivedAmount =
|
||||
resolvedAmount ?? subtractNumberStrings(resolvedTotalAmountFromSource, resolvedTaxAmount);
|
||||
const derivedTotalAmount =
|
||||
resolvedTotalAmountFromSource ?? addNumberStrings(derivedAmount, resolvedTaxAmount);
|
||||
|
||||
function buildBaseNormalizedResult({ documentSubtype = null, documentCategory = null, documentCount = 1 } = {}) {
|
||||
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 ?? {},
|
||||
documentSubtype,
|
||||
documentCategory,
|
||||
documentCount,
|
||||
invoiceCode: null,
|
||||
invoiceNumber: null,
|
||||
invoiceDate: null,
|
||||
amount: null,
|
||||
taxAmount: null,
|
||||
totalAmount: null,
|
||||
sellerName: null,
|
||||
buyerName: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function runTencentVatOcr(config, { imageBase64, sourceMimeType }) {
|
||||
const client = createTencentOcrClient(config);
|
||||
function normalizeVatInvoiceInfo(info, documentSubtype) {
|
||||
const amount = toNumberString(info?.PretaxAmount);
|
||||
const taxAmount = toNumberString(info?.Tax);
|
||||
const totalAmount = toNumberString(info?.Total);
|
||||
|
||||
return {
|
||||
...buildBaseNormalizedResult({ documentSubtype, documentCategory: "vat_invoice" }),
|
||||
invoiceCode: normalizeIdentifier(info?.Code),
|
||||
invoiceNumber: normalizeIdentifier(info?.Number ?? info?.ElectronicFullNumber),
|
||||
invoiceDate: normalizeDate(info?.Date),
|
||||
amount: amount ?? subtractNumberStrings(totalAmount, taxAmount),
|
||||
taxAmount,
|
||||
totalAmount: totalAmount ?? addNumberStrings(amount, taxAmount),
|
||||
sellerName: normalizeString(info?.Seller),
|
||||
buyerName: normalizeString(info?.Buyer),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFullVatInvoiceInfo(info, documentSubtype) {
|
||||
const amount = toNumberString(info?.PretaxAmount ?? info?.SubTotal);
|
||||
const taxAmount = toNumberString(info?.Tax ?? info?.SubTax);
|
||||
const totalAmount = toNumberString(info?.Total);
|
||||
|
||||
return {
|
||||
...buildBaseNormalizedResult({ documentSubtype, documentCategory: "full_vat_invoice" }),
|
||||
invoiceNumber: normalizeIdentifier(info?.Number),
|
||||
invoiceDate: normalizeDate(info?.Date),
|
||||
amount: amount ?? subtractNumberStrings(totalAmount, taxAmount),
|
||||
taxAmount,
|
||||
totalAmount: totalAmount ?? addNumberStrings(amount, taxAmount),
|
||||
sellerName: normalizeString(info?.Seller),
|
||||
buyerName: normalizeString(info?.Buyer),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeElectronicTrainTicket(info) {
|
||||
const taxAmount = toNumberString(info?.Tax);
|
||||
const totalAmount = toNumberString(info?.Total ?? info?.Fare);
|
||||
|
||||
return {
|
||||
...buildBaseNormalizedResult({
|
||||
documentSubtype: ELECTRONIC_TRAIN_TICKET_SUBTYPE,
|
||||
documentCategory: "electronic_train_ticket",
|
||||
}),
|
||||
invoiceNumber: normalizeIdentifier(info?.Number),
|
||||
invoiceDate: normalizeDate(info?.Date),
|
||||
amount: subtractNumberStrings(totalAmount, taxAmount),
|
||||
taxAmount,
|
||||
totalAmount,
|
||||
buyerName: normalizeString(info?.Buyer),
|
||||
};
|
||||
}
|
||||
|
||||
function buildQualityFailure(qualityError, documentCount, documentSubtype = null) {
|
||||
return {
|
||||
...buildBaseNormalizedResult({ documentSubtype, documentCount }),
|
||||
qualityError,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGeneralInvoiceResult(result) {
|
||||
const invoiceItems = Array.isArray(result?.MixedInvoiceItems) ? result.MixedInvoiceItems : [];
|
||||
if (invoiceItems.length === 0) {
|
||||
return buildQualityFailure("invoice_not_detected", 0);
|
||||
}
|
||||
|
||||
if (invoiceItems.length !== 1) {
|
||||
return buildQualityFailure("multiple_invoices_not_supported", invoiceItems.length);
|
||||
}
|
||||
|
||||
const invoiceItem = invoiceItems[0];
|
||||
const documentSubtype = normalizeIdentifier(invoiceItem?.SubType);
|
||||
if (invoiceItem?.Code !== "OK") {
|
||||
return buildQualityFailure("invoice_recognition_failed", 1, documentSubtype);
|
||||
}
|
||||
|
||||
const invoiceInfo = invoiceItem?.SingleInvoiceInfos;
|
||||
const vatInfoField = VAT_INVOICE_SUBTYPE_FIELDS.get(documentSubtype);
|
||||
if (vatInfoField && invoiceInfo?.[vatInfoField]) {
|
||||
return normalizeVatInvoiceInfo(invoiceInfo[vatInfoField], documentSubtype);
|
||||
}
|
||||
|
||||
const fullVatInfoField = FULL_VAT_INVOICE_SUBTYPE_FIELDS.get(documentSubtype);
|
||||
if (fullVatInfoField && invoiceInfo?.[fullVatInfoField]) {
|
||||
return normalizeFullVatInvoiceInfo(invoiceInfo[fullVatInfoField], documentSubtype);
|
||||
}
|
||||
|
||||
if (
|
||||
documentSubtype === ELECTRONIC_TRAIN_TICKET_SUBTYPE &&
|
||||
invoiceInfo?.ElectronicTrainTicketFull
|
||||
) {
|
||||
return normalizeElectronicTrainTicket(invoiceInfo.ElectronicTrainTicketFull);
|
||||
}
|
||||
|
||||
return buildQualityFailure("unsupported_invoice_subtype", 1, documentSubtype);
|
||||
}
|
||||
|
||||
function buildGeneralInvoiceRequest(imageBase64) {
|
||||
return {
|
||||
ImageBase64: imageBase64,
|
||||
EnableOther: false,
|
||||
EnablePdf: true,
|
||||
PdfPageNumber: 1,
|
||||
EnableMultiplePage: false,
|
||||
EnableCutImage: false,
|
||||
EnableItemPolygon: false,
|
||||
EnableQRCode: false,
|
||||
EnableSeal: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function runTencentGeneralInvoiceOcr(config, { imageBase64, sourceMimeType }) {
|
||||
const transportMode = resolveTransportMode(sourceMimeType);
|
||||
|
||||
try {
|
||||
const request = {
|
||||
ImageBase64: imageBase64,
|
||||
};
|
||||
|
||||
if (transportMode === "pdf_native") {
|
||||
request.IsPdf = true;
|
||||
request.PdfPageNumber = 1;
|
||||
if (transportMode !== "pdf_native") {
|
||||
const error = new Error("unsupported_source_mime_type");
|
||||
error.code = "unsupported_source_mime_type";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = await client.VatInvoiceOCR(request);
|
||||
const normalized = normalizeVatInvoiceResult(result);
|
||||
const client = createTencentOcrClient(config);
|
||||
const result = await client.RecognizeGeneralInvoice(buildGeneralInvoiceRequest(imageBase64));
|
||||
const normalized = normalizeGeneralInvoiceResult(result);
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
transportMode,
|
||||
raw: {
|
||||
...(normalized.raw ?? {}),
|
||||
transportMode,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
throw wrapTencentError(error, transportMode);
|
||||
@@ -201,6 +306,8 @@ async function runTencentVatOcr(config, { imageBase64, sourceMimeType }) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeVatInvoiceResult,
|
||||
runTencentVatOcr,
|
||||
buildGeneralInvoiceRequest,
|
||||
isTerminalTencentErrorCode,
|
||||
normalizeGeneralInvoiceResult,
|
||||
runTencentGeneralInvoiceOcr,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户