R111/A7-A10 统一报销双端发票附件体验
这个提交包含在:
@@ -1,9 +1,12 @@
|
||||
const { Prisma } = require("@prisma/client");
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const { sha256Hex } = require("../utils/crypto");
|
||||
const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const { findActiveDelegationForDelegate, listActiveDelegationPrincipals } = require("../utils/delegation");
|
||||
const { hasPermission, resolveEntityPermission } = require("../constants/permissions");
|
||||
const {
|
||||
@@ -27,6 +30,7 @@ const {
|
||||
resolveEntityPrecheck,
|
||||
} = require("../services/feeControl");
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const {
|
||||
buildWorkflowStepsFromApprovalRule,
|
||||
getWorkflowStepsSnapshot,
|
||||
@@ -137,6 +141,12 @@ const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = {
|
||||
other: "transport",
|
||||
};
|
||||
const PRINTABLE_WORKFLOW_ACTIONS = new Set(["start", "approve", "reject", "return", "transfer", "withdraw"]);
|
||||
const ATTACHMENT_MIME_ALLOWLIST = new Set([
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
]);
|
||||
|
||||
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||
@@ -257,6 +267,29 @@ function ensureApplicantOrAdmin(request, reimbursement) {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeAttachmentFilename(fileName) {
|
||||
if (!fileName) return "file";
|
||||
return fileName.replace(/[\r\n"]/g, "_");
|
||||
}
|
||||
|
||||
function mapReimbursementAttachment(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
originalName: item.original_name,
|
||||
mimeType: item.mime_type,
|
||||
fileSize: item.file_size,
|
||||
fileSha256: item.file_sha256,
|
||||
uploadedBy: item.uploader
|
||||
? {
|
||||
id: item.uploader.id,
|
||||
name: item.uploader.name,
|
||||
employeeNo: item.uploader.employee_no,
|
||||
}
|
||||
: null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWithdrawBody(body) {
|
||||
const bodySchema = z.object({
|
||||
comment: z.string().trim().min(1).max(500).optional(),
|
||||
@@ -1969,6 +2002,24 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
},
|
||||
},
|
||||
attachments: {
|
||||
orderBy: { created_at: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
file_sha256: true,
|
||||
created_at: true,
|
||||
uploader: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invoice_pool_items: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -2129,6 +2180,7 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
attachments: reimbursement.attachments.map(mapReimbursementAttachment),
|
||||
budgetHint,
|
||||
invoices: reimbursement.invoice_pool_items.map((item) => ({
|
||||
id: item.id,
|
||||
@@ -2298,6 +2350,310 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements/:id/attachments",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.edit_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
|
||||
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
|
||||
where: { id: reimbursementId },
|
||||
select: {
|
||||
id: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
if (!reimbursement) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_attachment_edit_status" },
|
||||
});
|
||||
}
|
||||
|
||||
const files = [];
|
||||
try {
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type !== "file") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalName = part.filename ?? "attachment";
|
||||
const mimeType = part.mimetype;
|
||||
if (!ATTACHMENT_MIME_ALLOWLIST.has(mimeType)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { file: "unsupported_mime_type" },
|
||||
});
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
let fileSize = 0;
|
||||
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" },
|
||||
});
|
||||
}
|
||||
|
||||
files.push({
|
||||
buffer: Buffer.concat(chunks),
|
||||
originalName,
|
||||
mimeType,
|
||||
fileSize,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { file: "file_too_large" },
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!files.length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { file: "missing" },
|
||||
});
|
||||
}
|
||||
|
||||
const datePrefix = compactDateString(fastify.config.appTimezone);
|
||||
const createdItems = [];
|
||||
for (const file of files) {
|
||||
const fileSha256 = sha256Hex(file.buffer);
|
||||
const extension = path.extname(file.originalName ?? "").toLowerCase();
|
||||
const objectKey = path.posix.join("reimbursement-attachments", datePrefix, `${crypto.randomUUID()}${extension}`);
|
||||
const storagePath = await writeBuffer({
|
||||
config: fastify.config,
|
||||
key: objectKey,
|
||||
buffer: file.buffer,
|
||||
contentType: file.mimeType,
|
||||
});
|
||||
|
||||
const created = await fastify.prisma.reimbursementAttachment.create({
|
||||
data: {
|
||||
reimbursement_id: reimbursementId,
|
||||
storage_path: storagePath,
|
||||
original_name: file.originalName,
|
||||
mime_type: file.mimeType,
|
||||
file_size: file.fileSize,
|
||||
file_sha256: fileSha256,
|
||||
uploaded_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
file_sha256: true,
|
||||
created_at: true,
|
||||
uploader: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
createdItems.push(created);
|
||||
}
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "REIMBURSEMENT_ATTACHMENTS_UPLOADED",
|
||||
targetType: "reimbursement",
|
||||
targetId: reimbursementId,
|
||||
extra: {
|
||||
attachmentIds: createdItems.map((item) => item.id),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
items: createdItems.map(mapReimbursementAttachment),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete(
|
||||
"/reimbursements/:id/attachments/:attachmentId",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.edit_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const parsedAttachmentId = UUID_SCHEMA.safeParse(request.params?.attachmentId);
|
||||
if (!parsedAttachmentId.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { attachmentId: "invalid_uuid" },
|
||||
});
|
||||
}
|
||||
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, owner_id, submitter_id, status
|
||||
FROM "reimbursement_forms"
|
||||
WHERE id = ${reimbursementId}::uuid
|
||||
FOR UPDATE
|
||||
`;
|
||||
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_attachment_edit_status" },
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await tx.reimbursementAttachment.deleteMany({
|
||||
where: {
|
||||
id: parsedAttachmentId.data,
|
||||
reimbursement_id: reimbursementId,
|
||||
},
|
||||
});
|
||||
if (deleted.count === 0) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return { attachmentId: parsedAttachmentId.data };
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
attachmentId: result.attachmentId,
|
||||
outcome: "deleted",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/reimbursements/:id/attachments/:attachmentId/file",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const parsedAttachmentId = UUID_SCHEMA.safeParse(request.params?.attachmentId);
|
||||
if (!parsedAttachmentId.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { attachmentId: "invalid_uuid" },
|
||||
});
|
||||
}
|
||||
|
||||
const attachment = await fastify.prisma.reimbursementAttachment.findFirst({
|
||||
where: {
|
||||
id: parsedAttachmentId.data,
|
||||
reimbursement_id: reimbursementId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
storage_path: true,
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
reimbursement_form: {
|
||||
select: {
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!attachment) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
ensureOwnOrCanViewAll(fastify, request, attachment.reimbursement_form);
|
||||
|
||||
let stream = null;
|
||||
try {
|
||||
const opened = await openReadStream({
|
||||
config: fastify.config,
|
||||
storagePath: attachment.storage_path,
|
||||
});
|
||||
stream = opened.stream;
|
||||
} catch (error) {
|
||||
if (error instanceof StorageNotFoundError) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
reply.type(attachment.mime_type || "application/octet-stream");
|
||||
reply.header(
|
||||
"content-disposition",
|
||||
`inline; filename=\"${sanitizeAttachmentFilename(attachment.original_name)}\"`,
|
||||
);
|
||||
return reply.send(stream);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements/:id/items",
|
||||
{
|
||||
|
||||
在新工单中引用
屏蔽一个用户