H1-H10 优化OCR实时体验闭环

这个提交包含在:
2026-05-09 11:48:57 +08:00
父节点 549bd81e30
当前提交 c26cf9b954
共修改 34 个文件,包含 1217 行新增和 184 行删除
@@ -1,6 +1,8 @@
const {
INVOICE_UNAVAILABLE_REASONS,
classifyInvoiceConflict,
formatInvoiceOcrFailureMessage,
resolveInvoiceAvailability,
resolveUploadDedupDecision,
} = require("../../src/services/invoiceGovernance");
@@ -17,6 +19,7 @@ function buildInvoice(overrides = {}) {
file_sha256: overrides.file_sha256 ?? "sha256-a",
invoice_code: overrides.invoice_code ?? null,
invoice_number: overrides.invoice_number ?? null,
ocr_last_error: overrides.ocr_last_error ?? null,
};
}
@@ -90,4 +93,34 @@ describe("invoiceGovernance dedup", () => {
expect(decision.action).toBe("blocked");
expect(decision.reason).toBe(INVOICE_UNAVAILABLE_REASONS.duplicateOtherOwner);
});
it("formats invoice availability without leaking raw OCR error codes", () => {
expect(
resolveInvoiceAvailability(
buildInvoice({
status: "ocr_failed",
ocr_last_error: "missing_required_fields:invoiceCode,sellerName",
}),
),
).toEqual({
reason: "ocr_failed",
message: "票据识别缺少关键字段:发票代码、销售方。",
});
expect(
resolveInvoiceAvailability(
buildInvoice({
status: "ocr_failed",
ocr_last_error: "duplicate_invoice_code_number",
}),
),
).toEqual({
reason: "duplicate_invoice",
message: "已识别出相同发票,不能重复添加",
});
expect(formatInvoiceOcrFailureMessage("mock_ocr_failed")).toBe(
"票据识别失败,请重试或人工补齐关键信息。",
);
});
});
+42 -1
查看文件
@@ -2,12 +2,14 @@ const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { runOcrOnce } = require("../../../src/jobs/ocrJob");
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",
@@ -91,6 +93,45 @@ describe("ocrJob quality gate", () => {
);
});
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"),
+56
查看文件
@@ -0,0 +1,56 @@
const { enqueueOcrInvoice, JOB_OCR_INVOICE } = require("../../../src/jobs/pgBossJobs");
describe("pgBossJobs enqueueOcrInvoice", () => {
it("queues a directed OCR invoice job when pg-boss is available", async () => {
const send = vi.fn(async () => ({ id: "job-1" }));
const result = await enqueueOcrInvoice(
{
config: {
jobDriver: "pgboss",
ocrProvider: "mock",
},
boss: { send },
},
"11111111-1111-4111-8111-111111111111",
);
expect(result).toEqual({ mode: "queued" });
expect(send).toHaveBeenCalledWith(JOB_OCR_INVOICE, {
invoiceId: "11111111-1111-4111-8111-111111111111",
});
});
it("falls back cleanly when pg-boss is unavailable", async () => {
await expect(
enqueueOcrInvoice(
{
config: {
jobDriver: "pgboss",
ocrProvider: "mock",
},
boss: null,
},
"11111111-1111-4111-8111-111111111111",
),
).resolves.toEqual({ mode: "fallback" });
});
it("does not queue OCR when the OCR provider is disabled", async () => {
const send = vi.fn();
const result = await enqueueOcrInvoice(
{
config: {
jobDriver: "pgboss",
ocrProvider: "off",
},
boss: { send },
},
"11111111-1111-4111-8111-111111111111",
);
expect(result).toEqual({ mode: "fallback" });
expect(send).not.toHaveBeenCalled();
});
});
+52
查看文件
@@ -0,0 +1,52 @@
const Fastify = require("fastify");
const eventsRoutes = require("../../../src/routes/events");
const { createAppEventBus } = require("../../../src/services/events");
const { AppError } = require("../../../src/utils/AppError");
async function buildApp() {
const app = Fastify();
app.decorate("appEvents", createAppEventBus());
app.decorate("authenticate", async (request) => {
const auth = request.headers.authorization ?? "";
if (!auth.startsWith("Bearer ")) {
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
}
request.employee = { id: "employee-1", permissionCodes: [] };
});
app.decorate("hasPermission", (employee, permissionCode) =>
(employee?.permissionCodes ?? []).includes(permissionCode),
);
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
error: {
code: error.code,
message: error.message,
details: error.details ?? {},
},
});
}
return reply.status(500).send({ error: { code: "SYS_001", message: "服务异常" } });
});
await app.register(eventsRoutes, { prefix: "/api" });
return app;
}
describe("GET /api/events", () => {
it("requires bearer authentication before opening the event stream", async () => {
const app = await buildApp();
try {
const response = await app.inject({
method: "GET",
url: "/api/events",
});
expect(response.statusCode).toBe(401);
expect(response.json().error.code).toBe("AUTH_001");
} finally {
await app.close();
}
});
});
@@ -38,6 +38,8 @@ function buildMultipartRequest({ filename, contentType, content, fields = [] })
async function buildApp({
ocrProvider = "off",
jobDriver = "cron",
bossSend = null,
invoiceConflicts = [],
statusRows = [],
permissionCodes = ["invoice.upload"],
@@ -75,7 +77,11 @@ async function buildApp({
maxUploadSizeBytes: 5 * 1024 * 1024,
invoiceArchiveUnusedDays: 30,
ocrProvider,
jobDriver,
});
if (bossSend) {
app.decorate("boss", { send: bossSend });
}
app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) {
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
@@ -177,8 +183,13 @@ describe("invoicePool upload route", () => {
}
});
it("puts VAT uploads into pending_ocr when OCR provider is enabled", async () => {
const { app, prisma, tempDir } = await buildApp({ ocrProvider: "mock" });
it("puts VAT uploads into pending_ocr and enqueues a directed OCR job when OCR provider is enabled", async () => {
const bossSend = vi.fn(async () => ({ id: "ocr-job-1" }));
const { app, prisma, tempDir } = await buildApp({
ocrProvider: "mock",
jobDriver: "pgboss",
bossSend,
});
try {
const requestPayload = buildMultipartRequest({
@@ -214,6 +225,9 @@ describe("invoicePool upload route", () => {
}),
}),
);
expect(bossSend).toHaveBeenCalledWith("ocr.invoice", {
invoiceId: "11111111-1111-4111-8111-111111111111",
});
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
@@ -133,6 +133,7 @@ async function buildApp(options = {}) {
],
travelRequests: (options.travelRequests ?? [buildTravelRequest()]).map((item) => cloneTravelRequest(item)),
auditLogs: [],
sequenceNumber: 0,
};
function buildReimbursementSelect(record, select) {
@@ -236,6 +237,28 @@ async function buildApp(options = {}) {
return found ? cloneTravelRequest(found) : null;
}),
},
documentSequence: {
findUnique: vi.fn(async () => null),
upsert: vi.fn(async ({ create, update }) => {
stateRef.sequenceNumber = (stateRef.sequenceNumber ?? 0) + 1;
return {
document_type: create.document_type,
prefix: update.prefix ?? create.prefix,
date_format: update.date_format ?? create.date_format,
separator: update.separator ?? create.separator,
number_length: update.number_length ?? create.number_length,
reset_period: update.reset_period ?? create.reset_period,
current_number: stateRef.sequenceNumber,
last_reset_date: null,
updated_at: new Date("2026-04-08T12:00:00.000Z"),
updated_by: update.updated_by ?? create.updated_by ?? null,
};
}),
},
documentCategory: {
findUnique: vi.fn(async () => null),
findFirst: vi.fn(async () => null),
},
reimbursementForm: {
create: vi.fn(async ({ data, select }) => {
const record = {
@@ -317,6 +340,7 @@ async function buildApp(options = {}) {
expenseTypes: state.expenseTypes.map((item) => ({ ...item })),
travelRequests: state.travelRequests.map((item) => cloneTravelRequest(item)),
auditLogs: state.auditLogs.map((item) => ({ ...item })),
sequenceNumber: state.sequenceNumber,
};
const tx = buildPrismaLike(txState);
const result = await callback(tx);
@@ -327,6 +351,7 @@ async function buildApp(options = {}) {
state.expenseTypes = txState.expenseTypes;
state.travelRequests = txState.travelRequests;
state.auditLogs = txState.auditLogs;
state.sequenceNumber = txState.sequenceNumber;
return result;
});
@@ -44,6 +44,7 @@ function buildReimbursementRecord(overrides = {}) {
},
},
],
attachments: [],
invoice_pool_items: [
{
id: "invoice-1",
+67
查看文件
@@ -0,0 +1,67 @@
const {
buildInvoiceUpdatedEvent,
canReceiveEvent,
createAppEventBus,
toClientEvent,
} = require("../../../src/services/events");
describe("app events", () => {
it("publishes minimal invoice updated events", () => {
const event = buildInvoiceUpdatedEvent({
invoiceId: "11111111-1111-4111-8111-111111111111",
status: "available",
audienceEmployeeIds: ["employee-1", "employee-1", ""],
updatedAt: "2026-05-09T10:00:00.000Z",
});
expect(event).toEqual({
type: "invoice.updated",
invoiceId: "11111111-1111-4111-8111-111111111111",
status: "available",
updatedAt: "2026-05-09T10:00:00.000Z",
audienceEmployeeIds: ["employee-1"],
});
expect(toClientEvent({ id: "evt-1", createdAt: "2026-05-09T10:00:01.000Z", ...event })).toEqual({
id: "evt-1",
type: "invoice.updated",
invoiceId: "11111111-1111-4111-8111-111111111111",
status: "available",
updatedAt: "2026-05-09T10:00:00.000Z",
createdAt: "2026-05-09T10:00:01.000Z",
});
});
it("filters invoice events by employee audience unless invoice manager", () => {
const event = buildInvoiceUpdatedEvent({
invoiceId: "11111111-1111-4111-8111-111111111111",
status: "available",
audienceEmployeeIds: ["employee-1"],
});
expect(canReceiveEvent({ id: "employee-1" }, event)).toBe(true);
expect(canReceiveEvent({ id: "employee-2" }, event)).toBe(false);
expect(canReceiveEvent({ id: "employee-2" }, event, { canManageInvoice: true })).toBe(true);
});
it("supports subscribe and unsubscribe on the in-process event bus", () => {
const bus = createAppEventBus();
const handler = vi.fn();
const unsubscribe = bus.subscribe(handler);
bus.publish({ type: "invoice.updated", invoiceId: "invoice-1", audienceEmployeeIds: ["employee-1"] });
unsubscribe();
bus.publish({ type: "invoice.updated", invoiceId: "invoice-2", audienceEmployeeIds: ["employee-1"] });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0]).toEqual(
expect.objectContaining({
id: expect.any(String),
createdAt: expect.any(String),
type: "invoice.updated",
invoiceId: "invoice-1",
audienceEmployeeIds: ["employee-1"],
}),
);
});
});