R71-A4 澄清报销缺票提示并修复发票上传挂起
这个提交包含在:
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户