R58 A1-A6:报销详情页内嵌发票绑定与单据打印
这个提交包含在:
@@ -5,6 +5,7 @@ const { sendData } = require("../../utils/reply");
|
||||
const { buildAuditLogData } = require("../../utils/audit");
|
||||
const { signPayload, verifyToken } = require("../../utils/signedUrl");
|
||||
const { openReadStream, StorageNotFoundError } = require("../../services/storage");
|
||||
const { toMoneyString } = require("../../utils/workflow");
|
||||
const {
|
||||
mapInvoiceSummary,
|
||||
normalizeInvoiceResponse,
|
||||
@@ -20,6 +21,11 @@ const invoiceListQuerySchema = z.object({
|
||||
status: z.enum(["pending_ocr", "ocr_failed", "available", "bound", "archived"]).optional(),
|
||||
ownerScope: z.enum(["own", "all"]).optional(),
|
||||
});
|
||||
const bindableInvoiceQuerySchema = z.object({
|
||||
reimbursementId: z.string().uuid(),
|
||||
search: z.string().trim().max(100).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(50).optional(),
|
||||
});
|
||||
|
||||
function sanitizeFilename(fileName) {
|
||||
if (!fileName) return "file";
|
||||
@@ -45,6 +51,24 @@ function parseInvoiceListQuery(query) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseBindableInvoiceQuery(query) {
|
||||
const parsed = bindableInvoiceQuerySchema.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
reimbursementId: parsed.data.reimbursementId,
|
||||
search: parsed.data.search?.trim() || null,
|
||||
limit: parsed.data.limit ?? 20,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureInvoiceReadableByUser(request, invoice) {
|
||||
const roleCodes = request.employee.roleCodes;
|
||||
const permissionCodes = request.employee.permissionCodes ?? [];
|
||||
@@ -102,6 +126,99 @@ async function hasPendingTaskAccess(fastify, request, linkedReimbursementId) {
|
||||
}
|
||||
|
||||
function registerInvoicePoolReadRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/invoice-pool/bindable",
|
||||
{
|
||||
schema: { tags: ["Invoice Pool"], summary: "List Bindable Invoices For Reimbursement" },
|
||||
preHandler: fastify.requirePermission("invoice.view_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseBindableInvoiceQuery(request.query);
|
||||
|
||||
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
|
||||
where: { id: query.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,
|
||||
});
|
||||
}
|
||||
|
||||
const isOwn =
|
||||
reimbursement.owner_id === request.employee.id ||
|
||||
reimbursement.submitter_id === request.employee.id;
|
||||
if (!isOwn) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { reimbursement: "invalid_status_for_bind" },
|
||||
});
|
||||
}
|
||||
|
||||
const where = {
|
||||
owner_employee_id: reimbursement.owner_id,
|
||||
status: "available",
|
||||
linked_reimbursement_id: null,
|
||||
...(query.search
|
||||
? {
|
||||
OR: [
|
||||
{ invoice_code: { contains: query.search, mode: "insensitive" } },
|
||||
{ invoice_number: { contains: query.search, mode: "insensitive" } },
|
||||
{ seller_name: { contains: query.search, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const items = await fastify.prisma.invoicePool.findMany({
|
||||
where,
|
||||
orderBy: { created_at: "desc" },
|
||||
take: query.limit,
|
||||
select: {
|
||||
id: true,
|
||||
invoice_code: true,
|
||||
invoice_number: true,
|
||||
invoice_date: true,
|
||||
seller_name: true,
|
||||
total_amount: true,
|
||||
original_name: true,
|
||||
created_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
items: items.map((item) => ({
|
||||
id: item.id,
|
||||
invoiceCode: item.invoice_code ?? null,
|
||||
invoiceNumber: item.invoice_number ?? null,
|
||||
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
sellerName: item.seller_name ?? null,
|
||||
totalAmount: toMoneyString(item.total_amount),
|
||||
originalName: item.original_name ?? null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/invoice-pool",
|
||||
{
|
||||
|
||||
在新工单中引用
屏蔽一个用户