363 行
10 KiB
JavaScript
363 行
10 KiB
JavaScript
const fs = require("node:fs/promises");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
|
|
const { runOcrForInvoiceId, runOcrOnce } = require("../../../src/jobs/ocrJob");
|
|
|
|
function buildClaimedInvoice(overrides = {}) {
|
|
return {
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
file_sha256: "sha256-1",
|
|
owner_employee_id: "employee-owner-1",
|
|
uploaded_by_employee_id: "employee-upload-1",
|
|
ocr_provider: "mock",
|
|
ocr_type: "vat",
|
|
storage_path: "20260428/invoice.txt",
|
|
mime_type: "text/plain",
|
|
ocr_attempts: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildPrisma(claimedInvoices, { cachedOcrResult = null, invoiceConflicts = [] } = {}) {
|
|
return {
|
|
$queryRaw: vi.fn(async () => claimedInvoices),
|
|
ocrResult: {
|
|
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 () => invoiceConflicts),
|
|
findFirst: vi.fn(async () => null),
|
|
update: vi.fn(async () => ({ id: "invoice-1" })),
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("ocrJob quality gate", () => {
|
|
let tempDir = "";
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r73-ocr-job-"));
|
|
await fs.mkdir(path.join(tempDir, "20260428"), { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("marks invoice available when OCR returns all required VAT fields", async () => {
|
|
await fs.writeFile(
|
|
path.join(tempDir, "20260428/invoice.txt"),
|
|
"MOCK_SUCCESS_R73",
|
|
"utf8",
|
|
);
|
|
|
|
const prisma = buildPrisma([buildClaimedInvoice()]);
|
|
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 });
|
|
expect(prisma.invoicePool.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: "11111111-1111-4111-8111-111111111111" },
|
|
data: expect.objectContaining({
|
|
status: "available",
|
|
ocr_provider: "mock",
|
|
ocr_last_error: null,
|
|
}),
|
|
}),
|
|
);
|
|
expect(prisma.ocrResult.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
ocr_provider: "mock",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("processes a directed invoice and emits processed metadata", async () => {
|
|
await fs.writeFile(
|
|
path.join(tempDir, "20260428/invoice.txt"),
|
|
"MOCK_DIRECTED_SUCCESS_R73",
|
|
"utf8",
|
|
);
|
|
|
|
const prisma = buildPrisma([buildClaimedInvoice()]);
|
|
const logger = {
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
const onInvoiceProcessed = vi.fn();
|
|
|
|
const result = await runOcrForInvoiceId(
|
|
prisma,
|
|
{
|
|
storageDir: tempDir,
|
|
ocrBatchSize: 10,
|
|
ocrLockTtlSeconds: 300,
|
|
ocrMaxAttempts: 3,
|
|
ocrProvider: "mock",
|
|
},
|
|
logger,
|
|
"11111111-1111-4111-8111-111111111111",
|
|
"ocr-directed-worker",
|
|
{ onInvoiceProcessed },
|
|
);
|
|
|
|
expect(result).toEqual({ claimed: 1, available: 1, duplicate: 0, failed: 0 });
|
|
expect(onInvoiceProcessed).toHaveBeenCalledWith({
|
|
invoiceId: "11111111-1111-4111-8111-111111111111",
|
|
status: "available",
|
|
outcome: "available",
|
|
ownerEmployeeId: "employee-owner-1",
|
|
uploadedByEmployeeId: "employee-upload-1",
|
|
});
|
|
});
|
|
|
|
it("marks invoice ocr_failed when required VAT fields are missing", async () => {
|
|
await fs.writeFile(
|
|
path.join(tempDir, "20260428/invoice.txt"),
|
|
"MOCK_OCR_MISSING:invoiceCode,sellerName",
|
|
"utf8",
|
|
);
|
|
|
|
const prisma = buildPrisma([buildClaimedInvoice()]);
|
|
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: 0, duplicate: 0, failed: 1 });
|
|
expect(prisma.invoicePool.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: "11111111-1111-4111-8111-111111111111" },
|
|
data: expect.objectContaining({
|
|
status: "ocr_failed",
|
|
ocr_provider: "mock",
|
|
ocr_last_error: "missing_required_fields:invoiceCode,sellerName",
|
|
}),
|
|
}),
|
|
);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
invoiceId: "11111111-1111-4111-8111-111111111111",
|
|
qualityError: "missing_required_fields:invoiceCode,sellerName",
|
|
}),
|
|
"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");
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|