H1-H10 优化OCR实时体验闭环
这个提交包含在:
@@ -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(
|
||||
"票据识别失败,请重试或人工补齐关键信息。",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户