R71-A4 澄清报销缺票提示并修复发票上传挂起

这个提交包含在:
2026-04-28 19:24:51 +08:00
父节点 a5eac04a06
当前提交 1c2aaedd78
共修改 5 个文件,包含 235 行新增和 44 行删除
+4
查看文件
@@ -173,6 +173,10 @@ export function mapApiErrorMessage(
return "该任务已被处理,请刷新列表。"; return "该任务已被处理,请刷新列表。";
} }
if (details?.invoices === "bound_invoice_required") {
return "提交前请先绑定至少 1 张发票。";
}
if (details?.invoice === "not_bound") { if (details?.invoice === "not_bound") {
return "该票据未绑定报销单,无法解绑。"; return "该票据未绑定报销单,无法解绑。";
} }
@@ -578,6 +578,8 @@ export function ReimbursementDetailPage() {
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? []; const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block"); const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn"); 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 selectedInvoiceEntries = Object.entries(selectedInvoices);
const expenseTypes = expenseTypesQuery.data?.items ?? []; const expenseTypes = expenseTypesQuery.data?.items ?? [];
@@ -627,6 +629,11 @@ export function ReimbursementDetailPage() {
try { try {
setActionError(""); setActionError("");
if (detail.invoices.length < 1) {
setActionError(missingInvoiceSubmitMessage);
toast.error(missingInvoiceSubmitMessage);
return;
}
const precheck = await precheckReimbursement(reimbursementId); const precheck = await precheckReimbursement(reimbursementId);
const message = buildPrecheckDialogMessage(precheck); const message = buildPrecheckDialogMessage(precheck);
@@ -1214,6 +1221,7 @@ export function ReimbursementDetailPage() {
actionError={actionError} actionError={actionError}
submitDisabled={blockStandardChecks.length > 0} submitDisabled={blockStandardChecks.length > 0}
submitHint={blockStandardChecks.length > 0 ? "请修改超标明细后提交" : ""} submitHint={blockStandardChecks.length > 0 ? "请修改超标明细后提交" : ""}
submitNotice={submitInvoiceNotice}
isPending={mutation.isPending} isPending={mutation.isPending}
onSubmit={() => void handleSubmitAction()} onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
@@ -18,6 +18,7 @@ export interface ReimbursementActionsProps {
actionError: string; actionError: string;
submitDisabled?: boolean; submitDisabled?: boolean;
submitHint?: string; submitHint?: string;
submitNotice?: string;
isPending: boolean; isPending: boolean;
onSubmit: () => void; onSubmit: () => void;
onWithdraw: (comment: string) => void; onWithdraw: (comment: string) => void;
@@ -30,6 +31,7 @@ export function ReimbursementActions({
actionError, actionError,
submitDisabled = false, submitDisabled = false,
submitHint = "", submitHint = "",
submitNotice = "",
isPending, isPending,
onSubmit, onSubmit,
onWithdraw, onWithdraw,
@@ -64,6 +66,11 @@ export function ReimbursementActions({
</Button> </Button>
</div> </div>
{submitHint ? <p className="text-sm text-zinc-500">{submitHint}</p> : null} {submitHint ? <p className="text-sm text-zinc-500">{submitHint}</p> : null}
{submitNotice ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
{submitNotice}
</p>
) : null}
<div className="space-y-2 rounded-lg border border-zinc-200 p-4"> <div className="space-y-2 rounded-lg border border-zinc-200 p-4">
<p className="text-sm font-medium text-zinc-800">撤回报销</p> <p className="text-sm font-medium text-zinc-800">撤回报销</p>
+49 -44
查看文件
@@ -203,13 +203,16 @@ async function invoicePoolRoutes(fastify) {
preHandler: fastify.requirePermission("invoice.upload"), preHandler: fastify.requirePermission("invoice.upload"),
}, },
async (request, reply) => { async (request, reply) => {
let filePart = null; let fileBuffer = null;
let fileName = null;
let fileMimeType = null;
let fileSize = 0;
const fields = {}; const fields = {};
try { try {
for await (const part of request.parts()) { for await (const part of request.parts()) {
if (part.type === "file") { if (part.type === "file") {
if (filePart) { if (fileBuffer) {
throw new AppError({ throw new AppError({
code: "VALID_001", code: "VALID_001",
message: "参数校验失败", message: "参数校验失败",
@@ -217,10 +220,46 @@ async function invoicePoolRoutes(fastify) {
details: { file: "only_single_file_supported" }, details: { file: "only_single_file_supported" },
}); });
} }
filePart = part; fileName = part.filename ?? "upload";
} else { fileMimeType = part.mimetype;
fields[part.fieldname] = part.value;
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) { } catch (error) {
if (error?.code === "FST_REQ_FILE_TOO_LARGE") { if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
@@ -234,7 +273,7 @@ async function invoicePoolRoutes(fastify) {
throw error; throw error;
} }
if (!filePart) { if (!fileBuffer) {
throw new AppError({ throw new AppError({
code: "VALID_001", code: "VALID_001",
message: "参数校验失败", 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 = const ownerEmployeeId =
typeof fields.owner_employee_id === "string" && fields.owner_employee_id.trim() typeof fields.owner_employee_id === "string" && fields.owner_employee_id.trim()
? 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 fileSha256 = sha256Hex(fileBuffer);
const datePrefix = compactDateString(fastify.config.appTimezone); 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 objectKey = path.posix.join(datePrefix, `${crypto.randomUUID()}${extension}`);
const storagePath = await writeBuffer({ const storagePath = await writeBuffer({
config: fastify.config, config: fastify.config,
key: objectKey, key: objectKey,
buffer: fileBuffer, buffer: fileBuffer,
contentType: filePart.mimetype, contentType: fileMimeType,
}); });
const status = isOcrEnabledForType(fastify.config, ocrType) ? "pending_ocr" : "available"; const status = isOcrEnabledForType(fastify.config, ocrType) ? "pending_ocr" : "available";
@@ -360,8 +365,8 @@ async function invoicePoolRoutes(fastify) {
owner_employee_id: ownerEmployeeId, owner_employee_id: ownerEmployeeId,
uploaded_by_employee_id: request.employee.id, uploaded_by_employee_id: request.employee.id,
storage_path: storagePath, storage_path: storagePath,
original_name: filePart.filename ?? "upload", original_name: fileName ?? "upload",
mime_type: filePart.mimetype, mime_type: fileMimeType,
file_size: fileSize, file_size: fileSize,
file_sha256: fileSha256, file_sha256: fileSha256,
ocr_type: ocrType, ocr_type: ocrType,
@@ -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 });
}
});
});