实现腾讯通用票据OCR安全适配(07-28-travel-reimbursement-guide)
这个提交包含在:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户