const { z } = require("zod"); const { AppError } = require("../../utils/AppError"); 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 { resolveInvoiceAvailability } = require("../../services/invoiceGovernance"); const { mapInvoiceSummary, normalizeInvoiceResponse, } = require("./shared/mappers"); const { buildInvoiceDetailSelect, buildInvoiceSummarySelect, } = require("./shared/selects"); const invoiceListQuerySchema = z.object({ page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), 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(), }); const invoiceStatusQuerySchema = z.object({ ids: z.string().trim().min(1).max(2000), }); function sanitizeFilename(fileName) { if (!fileName) return "file"; return fileName.replace(/[\r\n"]/g, "_"); } function parseInvoiceListQuery(query) { const parsed = invoiceListQuerySchema.safeParse(query ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return { page: parsed.data.page ?? 1, pageSize: parsed.data.pageSize ?? 20, status: parsed.data.status, ownerScope: parsed.data.ownerScope, }; } 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 parseInvoiceStatusQuery(query) { const parsed = invoiceStatusQuerySchema.safeParse(query ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } const ids = Array.from( new Set( parsed.data.ids .split(",") .map((item) => item.trim()) .filter(Boolean), ), ); if (!ids.length || ids.length > 50 || ids.some((id) => !z.string().uuid().safeParse(id).success)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { ids: "invalid_uuid_list" }, }); } return { ids }; } function resolveInvoiceStatusReason(invoice) { return resolveInvoiceAvailability(invoice); } function ensureInvoiceReadableByUser(request, invoice) { const roleCodes = request.employee.roleCodes; const permissionCodes = request.employee.permissionCodes ?? []; if ( roleCodes.includes("admin") || roleCodes.includes("finance") || roleCodes.includes("finance_mgr") || permissionCodes.includes("travel.payment.manage") ) { return true; } if ( invoice.owner_employee_id === request.employee.id || invoice.uploaded_by_employee_id === request.employee.id ) { return true; } if (invoice.linked_reimbursement) { const reimbursement = invoice.linked_reimbursement; if ( reimbursement.owner_id === request.employee.id || reimbursement.submitter_id === request.employee.id ) { return true; } } return false; } async function hasPendingTaskAccess(fastify, request, linkedReimbursementId) { if (!linkedReimbursementId) { return false; } const pendingTask = await fastify.prisma.workflowTask.findFirst({ where: { status: "pending", workflow_instance: { entity_type: "reimbursement", entity_id: linkedReimbursementId, current_status: "pending", }, OR: [ { assignee_id: request.employee.id }, { assignee_role: { in: request.employee.roleCodes } }, ], }, select: { id: true }, }); return Boolean(pendingTask); } function registerInvoicePoolReadRoutes(fastify) { fastify.get( "/invoice-pool/status", { schema: { tags: ["Invoice Pool"], summary: "Get Invoice Status Batch" }, preHandler: fastify.requirePermission("invoice.view_own"), }, async (request, reply) => { const query = parseInvoiceStatusQuery(request.query); const rows = await fastify.prisma.invoicePool.findMany({ where: { id: { in: query.ids } }, select: { id: true, status: true, archived_reason: true, owner_employee_id: true, uploaded_by_employee_id: true, linked_reimbursement_id: true, linked_item_id: true, travel_order_id: true, invoice_code: true, invoice_number: true, invoice_date: true, seller_name: true, total_amount: true, original_name: true, ocr_attempts: true, ocr_last_error: true, ocr_completed_at: true, updated_at: true, linked_reimbursement: { select: { owner_id: true, submitter_id: true, }, }, }, }); const readableRows = rows.filter((invoice) => ensureInvoiceReadableByUser(request, invoice)); return sendData(reply, request, { items: readableRows.map((invoice) => { const statusReason = resolveInvoiceStatusReason(invoice); return { id: invoice.id, status: invoice.status, reason: statusReason.reason, message: statusReason.message, availabilityReason: statusReason.reason, availabilityMessage: statusReason.message, invoiceCode: invoice.invoice_code ?? null, invoiceNumber: invoice.invoice_number ?? null, invoiceDate: invoice.invoice_date?.toISOString?.()?.slice(0, 10) ?? null, sellerName: invoice.seller_name ?? null, totalAmount: toMoneyString(invoice.total_amount), originalName: invoice.original_name ?? null, linkedReimbursementId: invoice.linked_reimbursement_id ?? null, linkedItemId: invoice.linked_item_id ?? null, linkedTravelOrderId: invoice.travel_order_id ?? null, ocrAttempts: invoice.ocr_attempts ?? 0, ocrLastError: invoice.ocr_last_error ?? null, ocrCompletedAt: invoice.ocr_completed_at?.toISOString?.() ?? null, updatedAt: invoice.updated_at?.toISOString?.() ?? null, }; }), missingIds: query.ids.filter((id) => !readableRows.some((invoice) => invoice.id === id)), }); }, ); 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", { schema: { tags: ["Invoice Pool"], summary: "List Invoices" }, preHandler: fastify.authenticate, }, async (request, reply) => { const query = parseInvoiceListQuery(request.query); const canViewOwn = fastify.hasPermission(request.employee, "invoice.view_own"); const canManage = fastify.hasPermission(request.employee, "invoice.manage") || fastify.hasPermission(request.employee, "travel.payment.manage"); if (!canViewOwn && !canManage) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const ownerScope = query.ownerScope ?? (canManage ? "all" : "own"); if (ownerScope === "all" && !canManage) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const skip = (query.page - 1) * query.pageSize; const where = { ...(query.status ? { status: query.status } : {}), ...(ownerScope === "all" ? {} : { OR: [ { owner_employee_id: request.employee.id }, { uploaded_by_employee_id: request.employee.id }, { linked_reimbursement: { OR: [ { owner_id: request.employee.id }, { submitter_id: request.employee.id }, ], }, }, ], }), }; const [items, total] = await Promise.all([ fastify.prisma.invoicePool.findMany({ where, orderBy: { created_at: "desc" }, skip, take: query.pageSize, select: buildInvoiceSummarySelect(), }), fastify.prisma.invoicePool.count({ where }), ]); return sendData(reply, request, { page: query.page, pageSize: query.pageSize, total, ownerScope, items: items.map(mapInvoiceSummary), }); }, ); fastify.get( "/invoice-pool/:id", { schema: { tags: ["Invoice Pool"], summary: "Get Invoice Detail" }, preHandler: fastify.authenticate, }, async (request, reply) => { const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); if (!parsedInvoiceId.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } const invoice = await fastify.prisma.invoicePool.findUnique({ where: { id: parsedInvoiceId.data }, select: buildInvoiceDetailSelect({ includeLinkedReimbursement: true }), }); if (!invoice) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } let canAccess = ensureInvoiceReadableByUser(request, invoice); if (!canAccess) { canAccess = await hasPendingTaskAccess(fastify, request, invoice.linked_reimbursement_id); } if (!canAccess) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } return sendData(reply, request, normalizeInvoiceResponse(invoice)); }, ); fastify.post( "/invoice-pool/:id/signed-url", { preHandler: fastify.authenticate, }, async (request, reply) => { const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); if (!parsedInvoiceId.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } const invoice = await fastify.prisma.invoicePool.findUnique({ where: { id: parsedInvoiceId.data }, select: { id: true, owner_employee_id: true, uploaded_by_employee_id: true, linked_reimbursement_id: true, linked_reimbursement: { select: { owner_id: true, submitter_id: true, }, }, }, }); if (!invoice) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } let canAccess = ensureInvoiceReadableByUser(request, invoice); if (!canAccess) { canAccess = await hasPendingTaskAccess(fastify, request, invoice.linked_reimbursement_id); } if (!canAccess) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const expiresAt = Date.now() + fastify.config.signedUrlTtlSeconds * 1000; const token = signPayload( { invoiceId: invoice.id, actorId: request.employee.id, exp: expiresAt, }, fastify.config.jwtSecret, ); const origin = fastify.config.appOrigin || `${request.protocol}://${request.headers.host ?? "localhost:3000"}`; const signedUrl = `${origin}/api/invoice-pool/${invoice.id}/file?token=${encodeURIComponent(token)}`; await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVOICE_SIGNED_URL_ISSUED", targetType: "invoice_pool", targetId: invoice.id, extra: { invoiceId: invoice.id, expiresAt, }, }), }); return sendData(reply, request, { signedUrl, expiresAt: new Date(expiresAt).toISOString(), }); }, ); fastify.get("/invoice-pool/:id/file", async (request, reply) => { const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); const token = request.query?.token; if (!parsedInvoiceId.success || typeof token !== "string") { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const verified = verifyToken(token, fastify.config.jwtSecret); if (!verified.ok) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const payload = verified.payload; const parsedActorId = z.string().uuid().safeParse(payload?.actorId); if ( payload?.invoiceId !== parsedInvoiceId.data || typeof payload?.exp !== "number" || payload.exp <= Date.now() || !parsedActorId.success ) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const actorId = parsedActorId.data; const invoice = await fastify.prisma.invoicePool.findUnique({ where: { id: parsedInvoiceId.data }, select: { storage_path: true, mime_type: true, original_name: true, }, }); if (!invoice) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } let stream = null; try { const opened = await openReadStream({ config: fastify.config, storagePath: invoice.storage_path, }); stream = opened.stream; } catch (error) { if (error instanceof StorageNotFoundError) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } throw new AppError({ code: "SYS_001", message: "服务器内部错误", statusCode: 500, details: {}, }); } try { await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId, action: "INVOICE_FILE_DOWNLOADED", targetType: "invoice_pool", targetId: parsedInvoiceId.data, extra: { invoiceId: parsedInvoiceId.data, exp: payload.exp, }, }), }); } catch (error) { fastify.log.error({ err: error, invoiceId: parsedInvoiceId.data }, "Audit log write failed"); } reply.type(invoice.mime_type || "application/octet-stream"); reply.header( "content-disposition", `inline; filename=\"${sanitizeFilename(invoice.original_name)}\"`, ); return reply.send(stream); }); } module.exports = { registerInvoicePoolReadRoutes, };