diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index 035181be..9bf59074 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -173,6 +173,10 @@ export function mapApiErrorMessage( return "该任务已被处理,请刷新列表。"; } + if (details?.invoices === "bound_invoice_required") { + return "提交前请先绑定至少 1 张发票。"; + } + if (details?.invoice === "not_bound") { return "该票据未绑定报销单,无法解绑。"; } diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 90152249..7416bafc 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -578,6 +578,8 @@ export function ReimbursementDetailPage() { const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? []; const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block"); const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn"); + const missingInvoiceSubmitMessage = "提交前需先绑定至少 1 张发票,可在“关联票据”区域添加。"; + const submitInvoiceNotice = canEditItems && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : ""; const selectedInvoiceEntries = Object.entries(selectedInvoices); const expenseTypes = expenseTypesQuery.data?.items ?? []; @@ -627,6 +629,11 @@ export function ReimbursementDetailPage() { try { setActionError(""); + if (detail.invoices.length < 1) { + setActionError(missingInvoiceSubmitMessage); + toast.error(missingInvoiceSubmitMessage); + return; + } const precheck = await precheckReimbursement(reimbursementId); const message = buildPrecheckDialogMessage(precheck); @@ -1214,6 +1221,7 @@ export function ReimbursementDetailPage() { actionError={actionError} submitDisabled={blockStandardChecks.length > 0} submitHint={blockStandardChecks.length > 0 ? "请修改超标明细后提交" : ""} + submitNotice={submitInvoiceNotice} isPending={mutation.isPending} onSubmit={() => void handleSubmitAction()} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} diff --git a/client/src/pages/reimbursement-detail/ReimbursementActions.tsx b/client/src/pages/reimbursement-detail/ReimbursementActions.tsx index 4a59a49c..6c2739ef 100644 --- a/client/src/pages/reimbursement-detail/ReimbursementActions.tsx +++ b/client/src/pages/reimbursement-detail/ReimbursementActions.tsx @@ -18,6 +18,7 @@ export interface ReimbursementActionsProps { actionError: string; submitDisabled?: boolean; submitHint?: string; + submitNotice?: string; isPending: boolean; onSubmit: () => void; onWithdraw: (comment: string) => void; @@ -30,6 +31,7 @@ export function ReimbursementActions({ actionError, submitDisabled = false, submitHint = "", + submitNotice = "", isPending, onSubmit, onWithdraw, @@ -64,6 +66,11 @@ export function ReimbursementActions({ {submitHint ?

{submitHint}

: null} + {submitNotice ? ( +

+ {submitNotice} +

+ ) : null}

撤回报销

diff --git a/server/src/routes/invoicePool.js b/server/src/routes/invoicePool.js index fa84aa7c..255002b0 100644 --- a/server/src/routes/invoicePool.js +++ b/server/src/routes/invoicePool.js @@ -203,13 +203,16 @@ async function invoicePoolRoutes(fastify) { preHandler: fastify.requirePermission("invoice.upload"), }, async (request, reply) => { - let filePart = null; + let fileBuffer = null; + let fileName = null; + let fileMimeType = null; + let fileSize = 0; const fields = {}; try { for await (const part of request.parts()) { if (part.type === "file") { - if (filePart) { + if (fileBuffer) { throw new AppError({ code: "VALID_001", message: "参数校验失败", @@ -217,10 +220,46 @@ async function invoicePoolRoutes(fastify) { details: { file: "only_single_file_supported" }, }); } - filePart = part; - } else { - fields[part.fieldname] = part.value; + fileName = part.filename ?? "upload"; + fileMimeType = part.mimetype; + + if (!MIME_ALLOWLIST.has(fileMimeType)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { file: "unsupported_mime_type" }, + }); + } + + const chunks = []; + for await (const chunk of part.file) { + fileSize += chunk.length; + if (fileSize > fastify.config.maxUploadSizeBytes) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { file: "file_too_large" }, + }); + } + chunks.push(chunk); + } + + if (fileSize === 0) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { file: "empty_file" }, + }); + } + + fileBuffer = Buffer.concat(chunks); + continue; } + + fields[part.fieldname] = part.value; } } catch (error) { if (error?.code === "FST_REQ_FILE_TOO_LARGE") { @@ -234,7 +273,7 @@ async function invoicePoolRoutes(fastify) { throw error; } - if (!filePart) { + if (!fileBuffer) { throw new AppError({ code: "VALID_001", message: "参数校验失败", @@ -243,15 +282,6 @@ async function invoicePoolRoutes(fastify) { }); } - if (!MIME_ALLOWLIST.has(filePart.mimetype)) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { file: "unsupported_mime_type" }, - }); - } - const ownerEmployeeId = typeof fields.owner_employee_id === "string" && fields.owner_employee_id.trim() ? fields.owner_employee_id.trim() @@ -315,40 +345,15 @@ async function invoicePoolRoutes(fastify) { }); } - const chunks = []; - let fileSize = 0; - for await (const chunk of filePart.file) { - fileSize += chunk.length; - if (fileSize > fastify.config.maxUploadSizeBytes) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { file: "file_too_large" }, - }); - } - chunks.push(chunk); - } - - if (fileSize === 0) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { file: "empty_file" }, - }); - } - - const fileBuffer = Buffer.concat(chunks); const fileSha256 = sha256Hex(fileBuffer); const datePrefix = compactDateString(fastify.config.appTimezone); - const extension = path.extname(filePart.filename ?? "").toLowerCase(); + const extension = path.extname(fileName ?? "").toLowerCase(); const objectKey = path.posix.join(datePrefix, `${crypto.randomUUID()}${extension}`); const storagePath = await writeBuffer({ config: fastify.config, key: objectKey, buffer: fileBuffer, - contentType: filePart.mimetype, + contentType: fileMimeType, }); const status = isOcrEnabledForType(fastify.config, ocrType) ? "pending_ocr" : "available"; @@ -360,8 +365,8 @@ async function invoicePoolRoutes(fastify) { owner_employee_id: ownerEmployeeId, uploaded_by_employee_id: request.employee.id, storage_path: storagePath, - original_name: filePart.filename ?? "upload", - mime_type: filePart.mimetype, + original_name: fileName ?? "upload", + mime_type: fileMimeType, file_size: fileSize, file_sha256: fileSha256, ocr_type: ocrType, diff --git a/server/tests/unit/routes/invoicePool.upload.test.js b/server/tests/unit/routes/invoicePool.upload.test.js new file mode 100644 index 00000000..b9e4b987 --- /dev/null +++ b/server/tests/unit/routes/invoicePool.upload.test.js @@ -0,0 +1,167 @@ +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() { + 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 auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" }); + + const prisma = { + invoicePool: { + 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: "off", + }); + 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: ["invoice.upload"], + }; + }); + 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 }); + } + }); +});