R73-A2 收敛 VAT OCR 归一化与质量门槛
这个提交包含在:
@@ -0,0 +1,127 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { runOcrOnce } = require("../../../src/jobs/ocrJob");
|
||||
|
||||
function buildClaimedInvoice(overrides = {}) {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
file_sha256: "sha256-1",
|
||||
ocr_type: "vat",
|
||||
storage_path: "20260428/invoice.txt",
|
||||
ocr_attempts: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrisma(claimedInvoices) {
|
||||
return {
|
||||
$queryRaw: vi.fn(async () => claimedInvoices),
|
||||
ocrResult: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({ id: "ocr-result-1" })),
|
||||
},
|
||||
invoicePool: {
|
||||
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_last_error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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_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",
|
||||
);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户