R71-A4 澄清报销缺票提示并修复发票上传挂起
这个提交包含在:
@@ -173,6 +173,10 @@ export function mapApiErrorMessage(
|
||||
return "该任务已被处理,请刷新列表。";
|
||||
}
|
||||
|
||||
if (details?.invoices === "bound_invoice_required") {
|
||||
return "提交前请先绑定至少 1 张发票。";
|
||||
}
|
||||
|
||||
if (details?.invoice === "not_bound") {
|
||||
return "该票据未绑定报销单,无法解绑。";
|
||||
}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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({
|
||||
</Button>
|
||||
</div>
|
||||
{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">
|
||||
<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"),
|
||||
},
|
||||
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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户