实现腾讯通用票据OCR安全适配(07-28-travel-reimbursement-guide)

这个提交包含在:
2026-07-28 22:02:11 +08:00
父节点 b4bd484260
当前提交 ac17092fa0
共修改 19 个文件,包含 1277 行新增和 127 行删除
+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,
+22 -4
查看文件
@@ -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);
+45 -1
查看文件
@@ -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 "票据识别失败,请稍后重试。";
}
+98 -3
查看文件
@@ -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,
};
@@ -1,6 +1,7 @@
const {
INVOICE_UNAVAILABLE_REASONS,
classifyInvoiceConflict,
buildInvoiceDedupFilters,
formatInvoiceOcrFailureMessage,
resolveInvoiceAvailability,
resolveUploadDedupDecision,
@@ -32,6 +33,17 @@ function buildTx(conflicts) {
}
describe("invoiceGovernance dedup", () => {
it("uses invoice number alone only when the caller marks a known no-code subtype", () => {
expect(
buildInvoiceDedupFilters({
invoiceNumber: "251234",
deduplicateByInvoiceNumberOnly: true,
}),
).toEqual([{ invoice_code: null, invoice_number: "251234" }]);
expect(buildInvoiceDedupFilters({ invoiceNumber: "251234" })).toEqual([]);
});
it("ignores manually archived invoices when re-uploading the same file", async () => {
const archivedManual = buildInvoice({
status: "archived",
@@ -122,5 +134,13 @@ describe("invoiceGovernance dedup", () => {
expect(formatInvoiceOcrFailureMessage("mock_ocr_failed")).toBe(
"票据识别失败,请重试或人工补齐关键信息。",
);
expect(formatInvoiceOcrFailureMessage("multiple_invoices_not_supported")).toBe(
"一个 PDF 第一页只能包含一张发票,请拆分后重新上传。",
);
expect(
formatInvoiceOcrFailureMessage(
"[pdf_native][ResourceUnavailable.ResourcePackageRunOut] resource package run out",
),
).toBe("票据识别额度或计费状态暂不可用,请联系管理员。");
});
});
+144 -3
查看文件
@@ -19,15 +19,16 @@ function buildClaimedInvoice(overrides = {}) {
};
}
function buildPrisma(claimedInvoices) {
function buildPrisma(claimedInvoices, { cachedOcrResult = null, invoiceConflicts = [] } = {}) {
return {
$queryRaw: vi.fn(async () => claimedInvoices),
ocrResult: {
findUnique: vi.fn(async () => null),
findUnique: vi.fn(async () => cachedOcrResult),
create: vi.fn(async () => ({ id: "ocr-result-1" })),
update: vi.fn(async ({ data }) => ({ id: cachedOcrResult?.id, ...data })),
},
invoicePool: {
findMany: vi.fn(async () => []),
findMany: vi.fn(async () => invoiceConflicts),
findFirst: vi.fn(async () => null),
update: vi.fn(async () => ({ id: "invoice-1" })),
},
@@ -218,4 +219,144 @@ describe("ocrJob quality gate", () => {
);
expect(JSON.stringify(createdPayload)).not.toContain("\\u0000");
});
it("accepts a cached no-code electronic train ticket and checks number-only duplicates", async () => {
await fs.writeFile(path.join(tempDir, "20260428/invoice.pdf"), "%PDF-1.4 synthetic");
const cachedOcrResult = {
id: "ocr-result-train",
result_json: {
documentSubtype: "ElectronicTrainTicketFull",
documentCategory: "electronic_train_ticket",
documentCount: 1,
transportMode: "pdf_native",
invoiceCode: null,
invoiceNumber: "251234567890",
invoiceDate: "2026-07-26",
amount: "95.00",
taxAmount: "5.00",
totalAmount: "100.00",
sellerName: null,
buyerName: "合成公司",
raw: {
UserName: "SENSITIVE-CACHED-PASSENGER",
UserID: "SENSITIVE-CACHED-ID",
},
},
};
const prisma = buildPrisma(
[
buildClaimedInvoice({
ocr_provider: "tencent",
storage_path: "20260428/invoice.pdf",
mime_type: "application/pdf",
}),
],
{ cachedOcrResult },
);
const logger = { warn: vi.fn(), error: vi.fn() };
const result = await runOcrOnce(
prisma,
{
storageDir: tempDir,
ocrBatchSize: 1,
ocrLockTtlSeconds: 300,
ocrMaxAttempts: 3,
ocrProvider: "tencent",
},
logger,
"ocr-test-worker",
);
expect(result).toEqual({ claimed: 1, available: 1, duplicate: 0, failed: 0 });
expect(prisma.invoicePool.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: "available",
invoice_code: null,
invoice_number: "251234567890",
seller_name: null,
buyer_name: "合成公司",
}),
}),
);
expect(prisma.invoicePool.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
AND: expect.arrayContaining([
expect.objectContaining({
OR: expect.arrayContaining([
{ invoice_code: null, invoice_number: "251234567890" },
]),
}),
]),
}),
}),
);
expect(prisma.ocrResult.create).not.toHaveBeenCalled();
expect(prisma.ocrResult.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "ocr-result-train" },
data: expect.objectContaining({
result_json: expect.not.objectContaining({ raw: expect.anything() }),
}),
}),
);
expect(JSON.stringify(prisma.ocrResult.update.mock.calls[0][0])).not.toContain(
"SENSITIVE-CACHED",
);
});
it("marks a cached multiple-invoice result as a quality failure without provider retry", async () => {
await fs.writeFile(path.join(tempDir, "20260428/invoice.pdf"), "%PDF-1.4 synthetic");
const prisma = buildPrisma(
[
buildClaimedInvoice({
ocr_provider: "tencent",
storage_path: "20260428/invoice.pdf",
mime_type: "application/pdf",
}),
],
{
cachedOcrResult: {
id: "ocr-result-multiple",
result_json: {
documentSubtype: null,
documentCategory: null,
documentCount: 2,
qualityError: "multiple_invoices_not_supported",
transportMode: "pdf_native",
},
},
},
);
const logger = { warn: vi.fn(), error: vi.fn() };
const result = await runOcrOnce(
prisma,
{
storageDir: tempDir,
ocrBatchSize: 1,
ocrLockTtlSeconds: 300,
ocrMaxAttempts: 3,
ocrProvider: "tencent",
},
logger,
"ocr-test-worker",
);
expect(result).toEqual({ claimed: 1, available: 0, duplicate: 0, failed: 1 });
expect(prisma.invoicePool.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: "ocr_failed",
ocr_last_error: "multiple_invoices_not_supported",
ocr_attempts: { increment: 1 },
}),
}),
);
expect(logger.error).not.toHaveBeenCalled();
});
});
@@ -43,6 +43,7 @@ async function buildApp({
invoiceConflicts = [],
statusRows = [],
permissionCodes = ["invoice.upload"],
maxUploadSizeBytes = 5 * 1024 * 1024,
} = {}) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r71-invoice-upload-"));
const invoicePoolCreate = vi.fn(async ({ data }) => ({
@@ -74,7 +75,7 @@ async function buildApp({
app.decorate("config", {
appTimezone: "Asia/Shanghai",
storageDir: tempDir,
maxUploadSizeBytes: 5 * 1024 * 1024,
maxUploadSizeBytes,
invoiceArchiveUnusedDays: 30,
ocrProvider,
jobDriver,
@@ -121,7 +122,7 @@ async function buildApp({
await app.register(multipart, {
attachFieldsToBody: false,
limits: { files: 1, fileSize: 5 * 1024 * 1024 },
limits: { files: 1, fileSize: maxUploadSizeBytes },
});
await app.register(invoicePoolRoutes, { prefix: "/api" });
@@ -271,6 +272,48 @@ describe("invoicePool upload route", () => {
}
});
it("rejects a PDF above the 7 MiB Tencent OCR input boundary before storage", async () => {
const { app, prisma, tempDir } = await buildApp({
maxUploadSizeBytes: 8 * 1024 * 1024,
});
try {
const requestPayload = buildMultipartRequest({
filename: "invoice.pdf",
contentType: "application/pdf",
content: Buffer.alloc(7 * 1024 * 1024 + 1, 0x20),
fields: [["ocr_type", "vat"]],
});
const response = await app.inject({
method: "POST",
url: "/api/invoice-pool/upload",
headers: {
authorization: "Bearer test",
...requestPayload.headers,
},
payload: requestPayload.payload,
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toEqual(
expect.objectContaining({
code: "VALID_001",
message: "PDF 发票不能超过 7 MiB",
details: {
file: "ocr_pdf_too_large",
maxBytes: 7 * 1024 * 1024,
},
}),
);
expect(prisma.invoicePool.create).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("reuses same-owner existing invoice instead of creating a duplicate file record", async () => {
const existingInvoiceId = "22222222-2222-4222-8222-222222222222";
const { app, prisma, tempDir } = await buildApp({
+208 -35
查看文件
@@ -1,51 +1,224 @@
const {
normalizeVatInvoiceResult,
buildGeneralInvoiceRequest,
isTerminalTencentErrorCode,
normalizeGeneralInvoiceResult,
} = require("../../../src/services/ocr/tencent");
const {
buildMissingRequiredOcrError,
getMissingRequiredOcrFields,
sanitizeOcrResultForPersistence,
shouldDeduplicateByInvoiceNumberOnly,
} = require("../../../src/services/ocr");
describe("tencent VAT OCR normalization", () => {
it("normalizes identifiers, date and amounts from Tencent result", () => {
const normalized = normalizeVatInvoiceResult({
InvoiceCode: " 0440 0320 4111 ",
InvoiceNum: " 1234 5678 ",
InvoiceDate: "2026年4月28日",
AmountWithoutTax: "1,488.50",
TaxAmount: "194.50",
AmountInFiguers: "¥1683.00",
SellerName: " 北京悦海堂餐饮管理有限公司 ",
BuyerName: " 华印公司 ",
function buildInvoiceItem(subType, fieldName, payload) {
return {
Code: "OK",
SubType: subType,
SingleInvoiceInfos: {
[fieldName]: payload,
},
};
}
describe("Tencent general invoice OCR", () => {
it("builds a single-page PDF request without cut images or sensitive extras", () => {
expect(buildGeneralInvoiceRequest("cGRm")).toEqual({
ImageBase64: "cGRm",
EnableOther: false,
EnablePdf: true,
PdfPageNumber: 1,
EnableMultiplePage: false,
EnableCutImage: false,
EnableItemPolygon: false,
EnableQRCode: false,
EnableSeal: false,
});
});
it("marks credential, billing and invalid-input provider errors as terminal", () => {
expect(isTerminalTencentErrorCode("AuthFailure.SecretIdNotFound")).toBe(true);
expect(isTerminalTencentErrorCode("FailedOperation.Arrears")).toBe(true);
expect(isTerminalTencentErrorCode("FailedOperation.NoChargePermission")).toBe(true);
expect(isTerminalTencentErrorCode("InvalidParameterValue.InvalidImageContent")).toBe(true);
expect(isTerminalTencentErrorCode("LimitExceeded.TooLargeFileError")).toBe(true);
expect(isTerminalTencentErrorCode("ResourceUnavailable.InArrears")).toBe(true);
expect(isTerminalTencentErrorCode("ResourceUnavailable.ResourcePackageRunOut")).toBe(true);
expect(isTerminalTencentErrorCode("ResourcesSoldOut.ChargeStatusException")).toBe(true);
expect(isTerminalTencentErrorCode("ocr_credentials_missing")).toBe(true);
expect(isTerminalTencentErrorCode("unsupported_source_mime_type")).toBe(true);
expect(isTerminalTencentErrorCode("RequestLimitExceeded")).toBe(false);
expect(isTerminalTencentErrorCode("InternalError")).toBe(false);
});
it("normalizes a VAT electronic common invoice without preserving the provider payload", () => {
const normalized = normalizeGeneralInvoiceResult({
MixedInvoiceItems: [
buildInvoiceItem("VatElectronicCommonInvoice", "VatElectronicCommonInvoice", {
Code: " 0440 0320 4111 ",
Number: " 1234 5678 ",
Date: "2026年4月28日",
PretaxAmount: "1,488.50",
Tax: "194.50",
Total: "¥1683.00",
Seller: " 合成酒店 ",
Buyer: " 合成公司 ",
SellerBankAccount: "SENSITIVE-BANK-ACCOUNT",
}),
],
RequestId: "SENSITIVE-REQUEST-ID",
});
expect(normalized).toEqual({
documentSubtype: "VatElectronicCommonInvoice",
documentCategory: "vat_invoice",
documentCount: 1,
invoiceCode: "044003204111",
invoiceNumber: "12345678",
invoiceDate: "2026-04-28",
amount: "1488.50",
taxAmount: "194.50",
totalAmount: "1683.00",
sellerName: "合成酒店",
buyerName: "合成公司",
});
expect(JSON.stringify(normalized)).not.toContain("SENSITIVE-");
});
it("accepts a full electronic VAT invoice without fabricating an invoice code", () => {
const normalized = normalizeGeneralInvoiceResult({
MixedInvoiceItems: [
buildInvoiceItem("VatElectronicInvoiceFull", "VatElectronicInvoiceFull", {
Number: " 25 12345678901234567890 ",
Date: "2026/04/28",
PretaxAmount: "450.00",
Tax: "27.00",
Total: "477.00",
Seller: "合成住宿服务商",
Buyer: "合成公司",
}),
],
});
expect(normalized).toEqual(
expect.objectContaining({
invoiceCode: "044003204111",
invoiceNumber: "12345678",
invoiceDate: "2026-04-28",
amount: "1488.50",
taxAmount: "194.50",
totalAmount: "1683.00",
sellerName: "北京悦海堂餐饮管理有限公司",
buyerName: "华印公司",
documentCategory: "full_vat_invoice",
invoiceCode: null,
invoiceNumber: "2512345678901234567890",
totalAmount: "477.00",
}),
);
expect(getMissingRequiredOcrFields("vat", normalized)).toEqual([]);
expect(shouldDeduplicateByInvoiceNumberOnly(normalized)).toBe(true);
});
it("derives amount from total and tax when Tencent omits AmountWithoutTax", () => {
const result = normalizeVatInvoiceResult({
InvoiceCode: "044003204111",
InvoiceNum: "12345678",
InvoiceDate: "2026/04/28",
TaxAmount: 194.5,
TotalAmount: 1683,
SellerName: "北京悦海堂餐饮管理有限公司",
BuyerName: "华印公司",
it("normalizes an electronic train ticket without retaining passenger identity fields", () => {
const normalized = normalizeGeneralInvoiceResult({
MixedInvoiceItems: [
buildInvoiceItem("ElectronicTrainTicketFull", "ElectronicTrainTicketFull", {
Number: " 25 98765432109876543210 ",
Date: "2026年7月26日",
Total: "553.00元",
Tax: "4.06",
Buyer: "合成公司",
UserName: "SENSITIVE-PASSENGER-NAME",
UserID: "SENSITIVE-PASSENGER-ID",
ElectronicTicketNum: "SENSITIVE-TICKET-NUMBER",
}),
],
});
expect(result).toEqual(
expect.objectContaining({
amount: "1488.50",
taxAmount: "194.50",
totalAmount: "1683.00",
}),
expect(normalized).toEqual({
documentSubtype: "ElectronicTrainTicketFull",
documentCategory: "electronic_train_ticket",
documentCount: 1,
invoiceCode: null,
invoiceNumber: "2598765432109876543210",
invoiceDate: "2026-07-26",
amount: "548.94",
taxAmount: "4.06",
totalAmount: "553.00",
sellerName: null,
buyerName: "合成公司",
});
expect(buildMissingRequiredOcrError("vat", normalized)).toBeNull();
expect(shouldDeduplicateByInvoiceNumberOnly(normalized)).toBe(true);
expect(JSON.stringify(normalized)).not.toContain("SENSITIVE-");
});
it("returns safe terminal quality results for zero, multiple and unsupported invoices", () => {
expect(normalizeGeneralInvoiceResult({ MixedInvoiceItems: [] })).toEqual(
expect.objectContaining({ qualityError: "invoice_not_detected", documentCount: 0 }),
);
const multiple = normalizeGeneralInvoiceResult({
MixedInvoiceItems: [
buildInvoiceItem("ElectronicTrainTicketFull", "ElectronicTrainTicketFull", {
UserID: "SENSITIVE-FIRST-ID",
}),
buildInvoiceItem("ElectronicTrainTicketFull", "ElectronicTrainTicketFull", {
UserID: "SENSITIVE-SECOND-ID",
}),
],
});
expect(multiple).toEqual(
expect.objectContaining({ qualityError: "multiple_invoices_not_supported", documentCount: 2 }),
);
expect(JSON.stringify(multiple)).not.toContain("SENSITIVE-");
const unsupported = normalizeGeneralInvoiceResult({
MixedInvoiceItems: [buildInvoiceItem("TaxiTicket", "TaxiTicket", { Number: "SENSITIVE-NO" })],
});
expect(buildMissingRequiredOcrError("vat", unsupported)).toBe("unsupported_invoice_subtype");
expect(JSON.stringify(unsupported)).not.toContain("SENSITIVE-");
});
it("requires buyer name for electronic train tickets but not invoice code or seller", () => {
const normalized = {
documentCategory: "electronic_train_ticket",
invoiceCode: null,
invoiceNumber: "251234",
invoiceDate: "2026-07-26",
totalAmount: "100.00",
sellerName: null,
buyerName: null,
};
expect(getMissingRequiredOcrFields("vat", normalized)).toEqual(["buyerName"]);
expect(buildMissingRequiredOcrError("vat", normalized)).toBe(
"missing_required_fields:buyerName",
);
});
it("strips raw and unknown fields from historical Tencent cache payloads", () => {
const sanitized = sanitizeOcrResultForPersistence("tencent", {
invoiceCode: "044001",
invoiceNumber: "12345678",
invoiceDate: "2026-07-26",
totalAmount: "100.00",
sellerName: "合成销售方",
buyerName: "合成购买方",
transportMode: "pdf_native",
raw: {
UserName: "SENSITIVE-CACHED-NAME",
UserID: "SENSITIVE-CACHED-ID",
},
RequestId: "SENSITIVE-CACHED-REQUEST",
});
expect(sanitized).toEqual({
documentSubtype: null,
documentCategory: null,
documentCount: 1,
invoiceCode: "044001",
invoiceNumber: "12345678",
invoiceDate: "2026-07-26",
amount: null,
taxAmount: null,
totalAmount: "100.00",
sellerName: "合成销售方",
buyerName: "合成购买方",
transportMode: "pdf_native",
});
expect(JSON.stringify(sanitized)).not.toContain("SENSITIVE-");
});
});