R92 A2 修复发票 OCR provider 与二进制写库
这个提交包含在:
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "OcrProvider" ADD VALUE IF NOT EXISTS 'mock';
|
||||||
@@ -160,6 +160,7 @@ enum InvoiceArchivedReason {
|
|||||||
|
|
||||||
enum OcrProvider {
|
enum OcrProvider {
|
||||||
tencent
|
tencent
|
||||||
|
mock
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OcrType {
|
enum OcrType {
|
||||||
|
|||||||
+25
-8
@@ -4,6 +4,16 @@ const { buildMissingRequiredOcrError, runOcrForInvoice } = require("../services/
|
|||||||
const { readFileBuffer } = require("../services/storage");
|
const { readFileBuffer } = require("../services/storage");
|
||||||
|
|
||||||
const OCR_DUPLICATE_ERROR = "duplicate_invoice_code_number";
|
const OCR_DUPLICATE_ERROR = "duplicate_invoice_code_number";
|
||||||
|
const SUPPORTED_OCR_PROVIDERS = new Set(["tencent", "mock"]);
|
||||||
|
|
||||||
|
function getEffectiveOcrProvider(config) {
|
||||||
|
const provider = config?.ocrProvider;
|
||||||
|
if (!SUPPORTED_OCR_PROVIDERS.has(provider)) {
|
||||||
|
throw new Error(`Unsupported OCR provider: ${provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
function trimErrorMessage(error) {
|
function trimErrorMessage(error) {
|
||||||
const code = typeof error?.code === "string" ? error.code : null;
|
const code = typeof error?.code === "string" ? error.code : null;
|
||||||
@@ -59,7 +69,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.mime_type, i.ocr_attempts
|
RETURNING i.id, i.file_sha256, i.ocr_provider, i.ocr_type, i.storage_path, i.mime_type, i.ocr_attempts
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,10 +99,11 @@ async function findDuplicateInvoice(prisma, invoiceId, invoiceCode, invoiceNumbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getOrCreateOcrResult(prisma, config, claimedInvoice, imageBase64) {
|
async function getOrCreateOcrResult(prisma, config, claimedInvoice, imageBase64) {
|
||||||
|
const ocrProvider = getEffectiveOcrProvider(config);
|
||||||
const where = {
|
const where = {
|
||||||
file_sha256_ocr_provider_ocr_type: {
|
file_sha256_ocr_provider_ocr_type: {
|
||||||
file_sha256: claimedInvoice.file_sha256,
|
file_sha256: claimedInvoice.file_sha256,
|
||||||
ocr_provider: "tencent",
|
ocr_provider: ocrProvider,
|
||||||
ocr_type: claimedInvoice.ocr_type,
|
ocr_type: claimedInvoice.ocr_type,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -123,7 +134,7 @@ async function getOrCreateOcrResult(prisma, config, claimedInvoice, imageBase64)
|
|||||||
const created = await prisma.ocrResult.create({
|
const created = await prisma.ocrResult.create({
|
||||||
data: {
|
data: {
|
||||||
file_sha256: claimedInvoice.file_sha256,
|
file_sha256: claimedInvoice.file_sha256,
|
||||||
ocr_provider: "tencent",
|
ocr_provider: ocrProvider,
|
||||||
ocr_type: claimedInvoice.ocr_type,
|
ocr_type: claimedInvoice.ocr_type,
|
||||||
result_json: normalized,
|
result_json: normalized,
|
||||||
},
|
},
|
||||||
@@ -160,12 +171,13 @@ async function getOrCreateOcrResult(prisma, config, claimedInvoice, imageBase64)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markInvoiceAsDuplicate(prisma, invoiceId, normalized, ocrResultId) {
|
async function markInvoiceAsDuplicate(prisma, config, invoiceId, normalized, ocrResultId) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.invoicePool.update({
|
await prisma.invoicePool.update({
|
||||||
where: { id: invoiceId },
|
where: { id: invoiceId },
|
||||||
data: {
|
data: {
|
||||||
status: "ocr_failed",
|
status: "ocr_failed",
|
||||||
|
ocr_provider: getEffectiveOcrProvider(config),
|
||||||
ocr_result_id: ocrResultId,
|
ocr_result_id: ocrResultId,
|
||||||
invoice_code: normalized.invoiceCode ?? null,
|
invoice_code: normalized.invoiceCode ?? null,
|
||||||
invoice_number: normalized.invoiceNumber ?? null,
|
invoice_number: normalized.invoiceNumber ?? null,
|
||||||
@@ -184,12 +196,13 @@ async function markInvoiceAsDuplicate(prisma, invoiceId, normalized, ocrResultId
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markInvoiceAsAvailable(prisma, invoiceId, normalized, ocrResultId) {
|
async function markInvoiceAsAvailable(prisma, config, invoiceId, normalized, ocrResultId) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.invoicePool.update({
|
await prisma.invoicePool.update({
|
||||||
where: { id: invoiceId },
|
where: { id: invoiceId },
|
||||||
data: {
|
data: {
|
||||||
status: "available",
|
status: "available",
|
||||||
|
ocr_provider: getEffectiveOcrProvider(config),
|
||||||
ocr_result_id: ocrResultId,
|
ocr_result_id: ocrResultId,
|
||||||
invoice_code: normalized.invoiceCode ?? null,
|
invoice_code: normalized.invoiceCode ?? null,
|
||||||
invoice_number: normalized.invoiceNumber ?? null,
|
invoice_number: normalized.invoiceNumber ?? null,
|
||||||
@@ -208,12 +221,13 @@ async function markInvoiceAsAvailable(prisma, invoiceId, normalized, ocrResultId
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markInvoiceAsQualityFailed(prisma, invoiceId, normalized, ocrResultId, errorMessage) {
|
async function markInvoiceAsQualityFailed(prisma, config, invoiceId, normalized, ocrResultId, errorMessage) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.invoicePool.update({
|
await prisma.invoicePool.update({
|
||||||
where: { id: invoiceId },
|
where: { id: invoiceId },
|
||||||
data: {
|
data: {
|
||||||
status: "ocr_failed",
|
status: "ocr_failed",
|
||||||
|
ocr_provider: getEffectiveOcrProvider(config),
|
||||||
ocr_result_id: ocrResultId,
|
ocr_result_id: ocrResultId,
|
||||||
invoice_code: normalized.invoiceCode ?? null,
|
invoice_code: normalized.invoiceCode ?? null,
|
||||||
invoice_number: normalized.invoiceNumber ?? null,
|
invoice_number: normalized.invoiceNumber ?? null,
|
||||||
@@ -241,6 +255,7 @@ async function markInvoiceAsFailed(prisma, claimedInvoice, errorMessage, config)
|
|||||||
where: { id: claimedInvoice.id },
|
where: { id: claimedInvoice.id },
|
||||||
data: {
|
data: {
|
||||||
status: isTerminal ? "ocr_failed" : "pending_ocr",
|
status: isTerminal ? "ocr_failed" : "pending_ocr",
|
||||||
|
ocr_provider: getEffectiveOcrProvider(config),
|
||||||
ocr_attempts: nextAttempts,
|
ocr_attempts: nextAttempts,
|
||||||
ocr_last_error: errorMessage,
|
ocr_last_error: errorMessage,
|
||||||
ocr_completed_at: isTerminal ? now : null,
|
ocr_completed_at: isTerminal ? now : null,
|
||||||
@@ -269,6 +284,7 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l
|
|||||||
if (qualityError) {
|
if (qualityError) {
|
||||||
await markInvoiceAsQualityFailed(
|
await markInvoiceAsQualityFailed(
|
||||||
prisma,
|
prisma,
|
||||||
|
config,
|
||||||
claimedInvoice.id,
|
claimedInvoice.id,
|
||||||
normalized,
|
normalized,
|
||||||
ocrResultId,
|
ocrResultId,
|
||||||
@@ -295,7 +311,7 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
await markInvoiceAsDuplicate(prisma, claimedInvoice.id, normalized, ocrResultId);
|
await markInvoiceAsDuplicate(prisma, config, claimedInvoice.id, normalized, ocrResultId);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{
|
{
|
||||||
invoiceId: claimedInvoice.id,
|
invoiceId: claimedInvoice.id,
|
||||||
@@ -308,7 +324,7 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l
|
|||||||
return { outcome: "duplicate" };
|
return { outcome: "duplicate" };
|
||||||
}
|
}
|
||||||
|
|
||||||
await markInvoiceAsAvailable(prisma, claimedInvoice.id, normalized, ocrResultId);
|
await markInvoiceAsAvailable(prisma, config, claimedInvoice.id, normalized, ocrResultId);
|
||||||
return { outcome: "available" };
|
return { outcome: "available" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = trimErrorMessage(error);
|
const message = trimErrorMessage(error);
|
||||||
@@ -427,6 +443,7 @@ function startOcrJob(fastify) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
OCR_DUPLICATE_ERROR,
|
OCR_DUPLICATE_ERROR,
|
||||||
claimPendingInvoices,
|
claimPendingInvoices,
|
||||||
|
getEffectiveOcrProvider,
|
||||||
markInvoiceAsQualityFailed,
|
markInvoiceAsQualityFailed,
|
||||||
runOcrOnce,
|
runOcrOnce,
|
||||||
startOcrJob,
|
startOcrJob,
|
||||||
|
|||||||
@@ -356,7 +356,8 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
contentType: fileMimeType,
|
contentType: fileMimeType,
|
||||||
});
|
});
|
||||||
|
|
||||||
const status = isOcrEnabledForType(fastify.config, ocrType) ? "pending_ocr" : "available";
|
const ocrEnabled = isOcrEnabledForType(fastify.config, ocrType);
|
||||||
|
const status = ocrEnabled ? "pending_ocr" : "available";
|
||||||
|
|
||||||
const invoice = await fastify.prisma.$transaction(async (tx) => {
|
const invoice = await fastify.prisma.$transaction(async (tx) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -369,6 +370,7 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
mime_type: fileMimeType,
|
mime_type: fileMimeType,
|
||||||
file_size: fileSize,
|
file_size: fileSize,
|
||||||
file_sha256: fileSha256,
|
file_sha256: fileSha256,
|
||||||
|
ocr_provider: ocrEnabled ? fastify.config.ocrProvider : undefined,
|
||||||
ocr_type: ocrType,
|
ocr_type: ocrType,
|
||||||
status,
|
status,
|
||||||
ocr_last_requested_at: status === "pending_ocr" ? now : null,
|
ocr_last_requested_at: status === "pending_ocr" ? now : null,
|
||||||
@@ -1036,6 +1038,7 @@ async function invoicePoolRoutes(fastify) {
|
|||||||
where: { id: invoiceId },
|
where: { id: invoiceId },
|
||||||
data: {
|
data: {
|
||||||
status: "pending_ocr",
|
status: "pending_ocr",
|
||||||
|
ocr_provider: fastify.config.ocrProvider,
|
||||||
ocr_last_requested_at: now,
|
ocr_last_requested_at: now,
|
||||||
ocr_last_error: null,
|
ocr_last_error: null,
|
||||||
ocr_completed_at: null,
|
ocr_completed_at: null,
|
||||||
|
|||||||
@@ -1,20 +1,39 @@
|
|||||||
const crypto = require("node:crypto");
|
const crypto = require("node:crypto");
|
||||||
|
|
||||||
function decodeMarker(imageBase64) {
|
function decodeImageBase64(imageBase64) {
|
||||||
try {
|
try {
|
||||||
return Buffer.from(imageBase64, "base64").toString("utf8");
|
return Buffer.from(imageBase64, "base64");
|
||||||
} catch {
|
} catch {
|
||||||
return "";
|
return Buffer.alloc(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInvoiceKey(markerText) {
|
function isTextLike(value) {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sample = value.slice(0, 256);
|
||||||
|
const controlChars = sample.match(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g) ?? [];
|
||||||
|
return controlChars.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeMarker(buffer) {
|
||||||
|
const text = buffer.toString("utf8");
|
||||||
|
if (!isTextLike(text)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.replace(/[\u0000-\u001F\u007F]/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInvoiceKey(buffer, markerText) {
|
||||||
const duplicateMatch = markerText.match(/MOCK_DUP:([A-Za-z0-9_-]+)/);
|
const duplicateMatch = markerText.match(/MOCK_DUP:([A-Za-z0-9_-]+)/);
|
||||||
if (duplicateMatch?.[1]) {
|
if (duplicateMatch?.[1]) {
|
||||||
return `dup:${duplicateMatch[1]}`;
|
return `dup:${duplicateMatch[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = crypto.createHash("sha256").update(markerText || "mock-empty").digest("hex");
|
const hash = crypto.createHash("sha256").update(buffer.length ? buffer : "mock-empty").digest("hex");
|
||||||
return `hash:${hash.slice(0, 20)}`;
|
return `hash:${hash.slice(0, 20)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,12 +59,13 @@ function toAmount(seedHex, fallback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runMockOcr({ ocrType, imageBase64 }) {
|
async function runMockOcr({ ocrType, imageBase64 }) {
|
||||||
const markerText = decodeMarker(imageBase64);
|
const buffer = decodeImageBase64(imageBase64);
|
||||||
|
const markerText = decodeMarker(buffer);
|
||||||
if (markerText.includes("MOCK_OCR_FAIL")) {
|
if (markerText.includes("MOCK_OCR_FAIL")) {
|
||||||
throw new Error("mock_ocr_failed");
|
throw new Error("mock_ocr_failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoiceKey = buildInvoiceKey(markerText);
|
const invoiceKey = buildInvoiceKey(buffer, markerText);
|
||||||
const hash = crypto.createHash("sha256").update(invoiceKey).digest("hex");
|
const hash = crypto.createHash("sha256").update(invoiceKey).digest("hex");
|
||||||
const invoiceCode = `MOCK${hash.slice(0, 8).toUpperCase()}`;
|
const invoiceCode = `MOCK${hash.slice(0, 8).toUpperCase()}`;
|
||||||
const invoiceNumber = hash.slice(8, 16).toUpperCase();
|
const invoiceNumber = hash.slice(8, 16).toUpperCase();
|
||||||
@@ -62,7 +82,8 @@ async function runMockOcr({ ocrType, imageBase64 }) {
|
|||||||
raw: {
|
raw: {
|
||||||
provider: "mock",
|
provider: "mock",
|
||||||
invoiceKey,
|
invoiceKey,
|
||||||
marker: markerText.slice(0, 128),
|
marker: markerText ? markerText.slice(0, 128) : null,
|
||||||
|
sourceSha256: crypto.createHash("sha256").update(buffer).digest("hex"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ function buildClaimedInvoice(overrides = {}) {
|
|||||||
return {
|
return {
|
||||||
id: "11111111-1111-4111-8111-111111111111",
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
file_sha256: "sha256-1",
|
file_sha256: "sha256-1",
|
||||||
|
ocr_provider: "mock",
|
||||||
ocr_type: "vat",
|
ocr_type: "vat",
|
||||||
storage_path: "20260428/invoice.txt",
|
storage_path: "20260428/invoice.txt",
|
||||||
|
mime_type: "text/plain",
|
||||||
ocr_attempts: 0,
|
ocr_attempts: 0,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -74,10 +76,18 @@ describe("ocrJob quality gate", () => {
|
|||||||
where: { id: "11111111-1111-4111-8111-111111111111" },
|
where: { id: "11111111-1111-4111-8111-111111111111" },
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
status: "available",
|
status: "available",
|
||||||
|
ocr_provider: "mock",
|
||||||
ocr_last_error: null,
|
ocr_last_error: null,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(prisma.ocrResult.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
ocr_provider: "mock",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks invoice ocr_failed when required VAT fields are missing", async () => {
|
it("marks invoice ocr_failed when required VAT fields are missing", async () => {
|
||||||
@@ -112,6 +122,7 @@ describe("ocrJob quality gate", () => {
|
|||||||
where: { id: "11111111-1111-4111-8111-111111111111" },
|
where: { id: "11111111-1111-4111-8111-111111111111" },
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
status: "ocr_failed",
|
status: "ocr_failed",
|
||||||
|
ocr_provider: "mock",
|
||||||
ocr_last_error: "missing_required_fields:invoiceCode,sellerName",
|
ocr_last_error: "missing_required_fields:invoiceCode,sellerName",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -124,4 +135,45 @@ describe("ocrJob quality gate", () => {
|
|||||||
"OCR invoice marked as failed for missing required fields",
|
"OCR invoice marked as failed for missing required fields",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stores binary PDF mock OCR metadata without unsafe null marker text", async () => {
|
||||||
|
const pdfPayload = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00, 0xff, 0x10, 0x20, 0x41]);
|
||||||
|
await fs.writeFile(path.join(tempDir, "20260428/invoice.pdf"), pdfPayload);
|
||||||
|
|
||||||
|
const prisma = buildPrisma([
|
||||||
|
buildClaimedInvoice({
|
||||||
|
storage_path: "20260428/invoice.pdf",
|
||||||
|
mime_type: "application/pdf",
|
||||||
|
file_sha256: "sha256-pdf-binary",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const logger = {
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await runOcrOnce(
|
||||||
|
prisma,
|
||||||
|
{
|
||||||
|
storageDir: tempDir,
|
||||||
|
ocrBatchSize: 1,
|
||||||
|
ocrLockTtlSeconds: 300,
|
||||||
|
ocrMaxAttempts: 3,
|
||||||
|
ocrProvider: "mock",
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
"ocr-test-worker",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ claimed: 1, available: 1, duplicate: 0, failed: 0 });
|
||||||
|
const createdPayload = prisma.ocrResult.create.mock.calls[0][0].data.result_json;
|
||||||
|
expect(createdPayload.raw).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: "mock",
|
||||||
|
marker: null,
|
||||||
|
sourceSha256: expect.any(String),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(createdPayload)).not.toContain("\\u0000");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ describe("invoicePool upload route", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
ocr_type: "vat",
|
ocr_type: "vat",
|
||||||
|
ocr_provider: "mock",
|
||||||
status: "pending_ocr",
|
status: "pending_ocr",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户