449 行
14 KiB
JavaScript
449 行
14 KiB
JavaScript
const fs = require("node:fs/promises");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
|
|
const Fastify = require("fastify");
|
|
const multipart = require("@fastify/multipart");
|
|
|
|
const { AppError } = require("../../../src/utils/AppError");
|
|
const invoicePoolRoutes = require("../../../src/routes/invoicePool");
|
|
|
|
function buildMultipartRequest({ filename, contentType, content, fields = [] }) {
|
|
const boundary = "----codex-invoice-upload-boundary";
|
|
const segments = [
|
|
Buffer.from(
|
|
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`,
|
|
),
|
|
Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"),
|
|
Buffer.from("\r\n"),
|
|
];
|
|
|
|
for (const [fieldName, value] of fields) {
|
|
segments.push(
|
|
Buffer.from(
|
|
`--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"\r\n\r\n${value}\r\n`,
|
|
),
|
|
);
|
|
}
|
|
|
|
segments.push(Buffer.from(`--${boundary}--\r\n`));
|
|
|
|
return {
|
|
payload: Buffer.concat(segments),
|
|
headers: {
|
|
"content-type": `multipart/form-data; boundary=${boundary}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function buildApp({
|
|
ocrProvider = "off",
|
|
jobDriver = "cron",
|
|
bossSend = null,
|
|
invoiceConflicts = [],
|
|
statusRows = [],
|
|
permissionCodes = ["invoice.upload"],
|
|
} = {}) {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r71-invoice-upload-"));
|
|
const invoicePoolCreate = vi.fn(async ({ data }) => ({
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
status: data.status,
|
|
file_sha256: data.file_sha256,
|
|
}));
|
|
const invoicePoolFindMany = vi.fn(async (args) => {
|
|
if (args?.where?.id?.in) {
|
|
return statusRows;
|
|
}
|
|
return invoiceConflicts;
|
|
});
|
|
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
|
|
|
|
const prisma = {
|
|
invoicePool: {
|
|
findMany: invoicePoolFindMany,
|
|
create: invoicePoolCreate,
|
|
},
|
|
auditLog: {
|
|
create: auditCreate,
|
|
},
|
|
$transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)),
|
|
};
|
|
|
|
const app = Fastify();
|
|
app.decorate("prisma", prisma);
|
|
app.decorate("config", {
|
|
appTimezone: "Asia/Shanghai",
|
|
storageDir: tempDir,
|
|
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 });
|
|
}
|
|
request.employee = {
|
|
id: "actor-1",
|
|
roleCodes: ["employee"],
|
|
permissionCodes,
|
|
};
|
|
});
|
|
app.decorate("hasPermission", (actor, permissionCode) => {
|
|
return (actor?.permissionCodes ?? []).includes("*") || (actor?.permissionCodes ?? []).includes(permissionCode);
|
|
});
|
|
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
|
await app.authenticate(request);
|
|
if (!app.hasPermission(request.employee, permissionCode)) {
|
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
|
}
|
|
});
|
|
app.setErrorHandler((error, request, reply) => {
|
|
if (error instanceof AppError) {
|
|
return reply.status(error.statusCode).send({
|
|
requestId: request.id,
|
|
error: {
|
|
code: error.code,
|
|
message: error.message,
|
|
details: error.details ?? {},
|
|
},
|
|
});
|
|
}
|
|
|
|
return reply.status(500).send({
|
|
requestId: request.id,
|
|
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
|
|
});
|
|
});
|
|
|
|
await app.register(multipart, {
|
|
attachFieldsToBody: false,
|
|
limits: { files: 1, fileSize: 5 * 1024 * 1024 },
|
|
});
|
|
await app.register(invoicePoolRoutes, { prefix: "/api" });
|
|
|
|
return { app, prisma, tempDir };
|
|
}
|
|
|
|
describe("invoicePool upload route", () => {
|
|
it("consumes the file stream before reading trailing multipart fields", async () => {
|
|
const { app, prisma, tempDir } = await buildApp();
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/invoice-pool/upload",
|
|
headers: {
|
|
authorization: "Bearer test",
|
|
...buildMultipartRequest({
|
|
filename: "invoice.pdf",
|
|
contentType: "application/pdf",
|
|
content: "%PDF-1.4 invoice payload",
|
|
fields: [["ocr_type", "vat"]],
|
|
}).headers,
|
|
},
|
|
payload: buildMultipartRequest({
|
|
filename: "invoice.pdf",
|
|
contentType: "application/pdf",
|
|
content: "%PDF-1.4 invoice payload",
|
|
fields: [["ocr_type", "vat"]],
|
|
}).payload,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toEqual(
|
|
expect.objectContaining({
|
|
invoicePoolId: "11111111-1111-4111-8111-111111111111",
|
|
status: "available",
|
|
}),
|
|
);
|
|
|
|
expect(prisma.invoicePool.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
owner_employee_id: "actor-1",
|
|
original_name: "invoice.pdf",
|
|
mime_type: "application/pdf",
|
|
ocr_type: "vat",
|
|
status: "available",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const storedPath = prisma.invoicePool.create.mock.calls[0][0].data.storage_path;
|
|
const storedBuffer = await fs.readFile(path.join(tempDir, storedPath));
|
|
expect(storedBuffer.toString("utf8")).toBe("%PDF-1.4 invoice payload");
|
|
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
await app.close();
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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({
|
|
filename: "invoice.pdf",
|
|
contentType: "application/pdf",
|
|
content: "%PDF-1.4 mock invoice",
|
|
fields: [["ocr_type", "vat"]],
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/invoice-pool/upload",
|
|
headers: {
|
|
authorization: "Bearer test",
|
|
...requestPayload.headers,
|
|
},
|
|
payload: requestPayload.payload,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toEqual(
|
|
expect.objectContaining({
|
|
invoicePoolId: "11111111-1111-4111-8111-111111111111",
|
|
status: "pending_ocr",
|
|
}),
|
|
);
|
|
expect(prisma.invoicePool.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
ocr_type: "vat",
|
|
ocr_provider: "mock",
|
|
status: "pending_ocr",
|
|
}),
|
|
}),
|
|
);
|
|
expect(bossSend).toHaveBeenCalledWith("ocr.invoice", {
|
|
invoiceId: "11111111-1111-4111-8111-111111111111",
|
|
});
|
|
} finally {
|
|
await app.close();
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects image invoice uploads because formal invoice OCR only accepts PDF", async () => {
|
|
const { app, prisma, tempDir } = await buildApp();
|
|
|
|
try {
|
|
const requestPayload = buildMultipartRequest({
|
|
filename: "invoice.png",
|
|
contentType: "image/png",
|
|
content: "mock-image",
|
|
fields: [["ocr_type", "vat"]],
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/invoice-pool/upload",
|
|
headers: {
|
|
authorization: "Bearer test",
|
|
...requestPayload.headers,
|
|
},
|
|
payload: requestPayload.payload,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error).toEqual(
|
|
expect.objectContaining({
|
|
code: "VALID_001",
|
|
message: "请上传 PDF 发票文件",
|
|
details: { file: "pdf_invoice_required" },
|
|
}),
|
|
);
|
|
expect(prisma.invoicePool.create).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("reuses same-owner existing invoice instead of creating a duplicate file record", async () => {
|
|
const existingInvoiceId = "22222222-2222-4222-8222-222222222222";
|
|
const { app, prisma, tempDir } = await buildApp({
|
|
invoiceConflicts: [
|
|
{
|
|
id: existingInvoiceId,
|
|
owner_employee_id: "actor-1",
|
|
uploaded_by_employee_id: "actor-1",
|
|
status: "available",
|
|
archived_reason: null,
|
|
linked_reimbursement_id: null,
|
|
linked_item_id: null,
|
|
travel_order_id: null,
|
|
invoice_code: null,
|
|
invoice_number: null,
|
|
file_sha256: "unused-in-test",
|
|
linked_reimbursement: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
try {
|
|
const requestPayload = buildMultipartRequest({
|
|
filename: "same.pdf",
|
|
contentType: "application/pdf",
|
|
content: "%PDF-1.4 same invoice payload",
|
|
fields: [["ocr_type", "vat"]],
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/invoice-pool/upload",
|
|
headers: {
|
|
authorization: "Bearer test",
|
|
...requestPayload.headers,
|
|
},
|
|
payload: requestPayload.payload,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toEqual(
|
|
expect.objectContaining({
|
|
invoicePoolId: existingInvoiceId,
|
|
status: "available",
|
|
uploadAction: "reuse_existing",
|
|
unavailableReason: "duplicate_available_in_company",
|
|
}),
|
|
);
|
|
expect(prisma.invoicePool.create).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("blocks upload when the same company invoice has already been used", async () => {
|
|
const { app, prisma, tempDir } = await buildApp({
|
|
invoiceConflicts: [
|
|
{
|
|
id: "33333333-3333-4333-8333-333333333333",
|
|
owner_employee_id: "actor-1",
|
|
uploaded_by_employee_id: "actor-1",
|
|
status: "bound",
|
|
archived_reason: null,
|
|
linked_reimbursement_id: "44444444-4444-4444-8444-444444444444",
|
|
linked_item_id: "55555555-5555-4555-8555-555555555555",
|
|
travel_order_id: null,
|
|
invoice_code: null,
|
|
invoice_number: null,
|
|
file_sha256: "unused-in-test",
|
|
linked_reimbursement: {
|
|
form_number: "BX20260508001",
|
|
status: "reviewing",
|
|
owner_id: "actor-1",
|
|
submitter_id: "actor-1",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
try {
|
|
const requestPayload = buildMultipartRequest({
|
|
filename: "used.pdf",
|
|
contentType: "application/pdf",
|
|
content: "%PDF-1.4 used invoice payload",
|
|
fields: [["ocr_type", "vat"]],
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/invoice-pool/upload",
|
|
headers: {
|
|
authorization: "Bearer test",
|
|
...requestPayload.headers,
|
|
},
|
|
payload: requestPayload.payload,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toEqual(
|
|
expect.objectContaining({
|
|
invoicePoolId: null,
|
|
status: "bound",
|
|
uploadAction: "blocked",
|
|
unavailableReason: "used_by_company",
|
|
unavailableMessage: "该发票已用于报销,不能重复添加",
|
|
}),
|
|
);
|
|
expect(prisma.invoicePool.create).not.toHaveBeenCalled();
|
|
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("returns readable invoice statuses for upload polling", async () => {
|
|
const invoiceId = "66666666-6666-4666-8666-666666666666";
|
|
const { app, tempDir } = await buildApp({
|
|
permissionCodes: ["invoice.view_own"],
|
|
statusRows: [
|
|
{
|
|
id: invoiceId,
|
|
status: "archived",
|
|
archived_reason: "used_complete",
|
|
owner_employee_id: "actor-1",
|
|
uploaded_by_employee_id: "actor-1",
|
|
linked_reimbursement_id: "77777777-7777-4777-8777-777777777777",
|
|
linked_item_id: null,
|
|
travel_order_id: null,
|
|
invoice_code: "044001900111",
|
|
invoice_number: "12345678",
|
|
invoice_date: new Date("2026-05-08T00:00:00.000Z"),
|
|
seller_name: "北京测试有限公司",
|
|
total_amount: "1683.00",
|
|
original_name: "invoice.pdf",
|
|
ocr_attempts: 1,
|
|
ocr_last_error: null,
|
|
ocr_completed_at: new Date("2026-05-08T01:00:00.000Z"),
|
|
updated_at: new Date("2026-05-08T01:01:00.000Z"),
|
|
linked_reimbursement: {
|
|
owner_id: "actor-1",
|
|
submitter_id: "actor-1",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: `/api/invoice-pool/status?ids=${invoiceId}`,
|
|
headers: { authorization: "Bearer test" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toEqual({
|
|
items: [
|
|
expect.objectContaining({
|
|
id: invoiceId,
|
|
status: "archived",
|
|
reason: "used_by_company",
|
|
message: "该发票已用于报销,不能重复添加",
|
|
totalAmount: "1683.00",
|
|
}),
|
|
],
|
|
missingIds: [],
|
|
});
|
|
} finally {
|
|
await app.close();
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|