const crypto = require("node:crypto"); const fs = require("node:fs/promises"); const fsSync = require("node:fs"); 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 { compactDateString } = require("../utils/date"); const { sha256Hex } = require("../utils/crypto"); const { signPayload, verifyToken } = require("../utils/signedUrl"); const { toMoneyString } = require("../utils/workflow"); const { findActiveDelegationForDelegate } = require("../utils/delegation"); const { isOcrEnabledForType } = require("../services/ocr"); const MIME_ALLOWLIST = new Set([ "application/pdf", "image/jpeg", "image/png", "image/webp", ]); const OCR_TYPE_ALLOWLIST = new Set(["vat", "train", "flight", "taxi", "general"]); const bindBodySchema = z.object({ reimbursementId: z.string().uuid(), linkedItemId: z.string().uuid().optional(), }); const invoiceEditBodySchema = z .object({ status: z.enum(["available"]).optional(), remarks: z.string().max(500).nullable().optional(), invoiceCode: z.string().trim().min(1).max(64).nullable().optional(), invoiceNumber: z.string().trim().min(1).max(64).nullable().optional(), invoiceDate: z .string() .regex(/^\\d{4}-\\d{2}-\\d{2}$/) .nullable() .optional(), sellerName: z.string().trim().min(1).max(256).nullable().optional(), buyerName: z.string().trim().min(1).max(256).nullable().optional(), amount: z.string().trim().min(1).max(32).nullable().optional(), taxAmount: z.string().trim().min(1).max(32).nullable().optional(), totalAmount: z.string().trim().min(1).max(32).nullable().optional(), }) .strict(); const overrideDuplicateBodySchema = z .object({ refFormNumber: z.string().trim().min(1).max(64), reason: z.string().trim().min(1).max(200), }) .strict(); 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 DEDUP_CONSTRAINT_NAMES = new Set([ "invoice_pool_file_sha256_dedup_ux", "invoice_pool_invoice_code_number_dedup_ux", ]); const DEDUP_FIELD_NAMES = new Set(["file_sha256", "invoice_code", "invoice_number"]); function tokenizeMetaString(value) { if (typeof value !== "string") { return []; } const normalized = value.trim().toLowerCase(); if (!normalized) { return []; } const splitTokens = normalized .split(/[^a-z0-9_]+/i) .map((item) => item.trim().toLowerCase()) .filter(Boolean); return [normalized, ...splitTokens]; } function normalizeConstraintMeta(value) { if (Array.isArray(value)) { return value .flatMap((item) => tokenizeMetaString(String(item))) .filter(Boolean); } if (typeof value === "string") { return tokenizeMetaString(value); } return []; } function collectFieldTokens(meta) { return new Set( [ ...normalizeConstraintMeta(meta.target), ...normalizeConstraintMeta(meta?.driverAdapterError?.cause?.constraint?.fields), ].filter((token) => DEDUP_FIELD_NAMES.has(token)), ); } function containsDedupConstraintName(metaTokens) { const constraints = Array.from(DEDUP_CONSTRAINT_NAMES); return metaTokens.some((token) => constraints.some((constraint) => token.includes(constraint))); } function isFieldBasedDedup(fieldTokens) { if (fieldTokens.size === 1 && fieldTokens.has("file_sha256")) { return true; } if ( fieldTokens.size === 2 && fieldTokens.has("invoice_code") && fieldTokens.has("invoice_number") ) { return true; } return false; } function isInvoiceDedupConflict(error) { if (!error || error.code !== "P2002") { return false; } const meta = error.meta ?? {}; const metaTokens = [ ...normalizeConstraintMeta(meta.constraint), ...normalizeConstraintMeta(meta.target), ...normalizeConstraintMeta(meta?.driverAdapterError?.cause?.originalMessage), ]; if (containsDedupConstraintName(metaTokens)) { return true; } const fieldTokens = collectFieldTokens(meta); return isFieldBasedDedup(fieldTokens); } function ensureInvoiceReadableByUser(request, invoice) { const roleCodes = request.employee.roleCodes; if ( roleCodes.includes("admin") || roleCodes.includes("finance") || roleCodes.includes("finance_mgr") ) { 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; } function normalizeMoneyInput(value) { if (value == null) { return null; } const input = String(value).trim(); if (!input) { return null; } if (!/^\\d+(?:\\.\\d{1,2})?$/.test(input)) { return undefined; } const [intPart, fracPartRaw = ""] = input.split("."); const fracPart = `${fracPartRaw}00`.slice(0, 2); return `${intPart}.${fracPart}`; } function parseDateOnlyString(value) { if (value == null) { return null; } if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); if (!trimmed) { return null; } if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(trimmed)) { return undefined; } return new Date(`${trimmed}T00:00:00.000Z`); } 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 mapInvoiceSummary(item) { return { id: item.id, status: item.status, ocrType: item.ocr_type, ocrProvider: item.ocr_provider, ocrAttempts: item.ocr_attempts, ocrLastError: item.ocr_last_error, ocrCompletedAt: item.ocr_completed_at?.toISOString?.() ?? null, invoiceCode: item.invoice_code, invoiceNumber: item.invoice_number, invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null, amount: toMoneyString(item.amount), taxAmount: toMoneyString(item.tax_amount), totalAmount: toMoneyString(item.total_amount), linkedReimbursementId: item.linked_reimbursement_id, linkedItemId: item.linked_item_id, originalName: item.original_name, mimeType: item.mime_type, fileSize: item.file_size, owner: item.owner_employee ? { id: item.owner_employee.id, employeeNo: item.owner_employee.employee_no, name: item.owner_employee.name, } : null, uploadedBy: item.uploaded_by_employee ? { id: item.uploaded_by_employee.id, employeeNo: item.uploaded_by_employee.employee_no, name: item.uploaded_by_employee.name, } : null, createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, }; } function normalizeInvoiceResponse(invoice) { return { id: invoice.id, status: invoice.status, archivedReason: invoice.archived_reason ?? null, archivedAt: invoice.archived_at?.toISOString?.() ?? null, ownerEmployeeId: invoice.owner_employee_id, uploadedByEmployeeId: invoice.uploaded_by_employee_id, originalName: invoice.original_name ?? null, mimeType: invoice.mime_type ?? null, fileSize: invoice.file_size ?? null, fileSha256: invoice.file_sha256 ?? null, remarks: invoice.remarks ?? null, ocrType: invoice.ocr_type, ocrProvider: invoice.ocr_provider, ocrAttempts: invoice.ocr_attempts, ocrLastRequestedAt: invoice.ocr_last_requested_at?.toISOString?.() ?? null, ocrLastError: invoice.ocr_last_error, ocrCompletedAt: invoice.ocr_completed_at?.toISOString?.() ?? null, invoiceCode: invoice.invoice_code, invoiceNumber: invoice.invoice_number, invoiceDate: invoice.invoice_date ? invoice.invoice_date.toISOString().slice(0, 10) : null, sellerName: invoice.seller_name ?? null, buyerName: invoice.buyer_name ?? null, amount: toMoneyString(invoice.amount), taxAmount: toMoneyString(invoice.tax_amount), totalAmount: toMoneyString(invoice.total_amount), linkedReimbursementId: invoice.linked_reimbursement_id, linkedItemId: invoice.linked_item_id, isDuplicateOverride: invoice.is_duplicate_override ?? false, overrideRefFormNumber: invoice.override_ref_form_number ?? null, overrideReason: invoice.override_reason ?? null, overriddenBy: invoice.overridden_by ?? null, createdAt: invoice.created_at?.toISOString?.() ?? null, updatedAt: invoice.updated_at?.toISOString?.() ?? null, }; } async function invoicePoolRoutes(fastify) { fastify.get( "/invoice-pool", { 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"); 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: { id: true, status: true, ocr_type: true, ocr_provider: true, ocr_attempts: true, ocr_last_error: true, ocr_completed_at: true, invoice_code: true, invoice_number: true, invoice_date: true, amount: true, tax_amount: true, total_amount: true, linked_reimbursement_id: true, linked_item_id: true, original_name: true, mime_type: true, file_size: true, created_at: true, updated_at: true, owner_employee: { select: { id: true, employee_no: true, name: true }, }, uploaded_by_employee: { select: { id: true, employee_no: true, name: true }, }, }, }), fastify.prisma.invoicePool.count({ where }), ]); return sendData(reply, request, { page: query.page, pageSize: query.pageSize, total, ownerScope, items: items.map(mapInvoiceSummary), }); }, ); fastify.post( "/invoice-pool/upload", { preHandler: fastify.requirePermission("invoice.upload"), }, async (request, reply) => { let filePart = null; const fields = {}; try { for await (const part of request.parts()) { if (part.type === "file") { if (filePart) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { file: "only_single_file_supported" }, }); } filePart = part; } else { fields[part.fieldname] = part.value; } } } 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 (!filePart) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { file: "missing" }, }); } 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() : request.employee.id; const isCrossOwner = ownerEmployeeId !== request.employee.id; let delegationGrantId = null; if (isCrossOwner) { const canManageUsers = fastify.hasPermission(request.employee, "system.users"); if (!canManageUsers) { const isAdministration = request.employee.roleCodes?.includes("administration"); const canProxySubmit = fastify.hasPermission(request.employee, "expense.proxy_submit"); if (!isAdministration || !canProxySubmit) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { owner_employee_id: "cross_owner_upload_not_allowed" }, }); } const delegation = await findActiveDelegationForDelegate(fastify.prisma, { principalEmployeeId: ownerEmployeeId, delegateEmployeeId: request.employee.id, allowedScopes: ["reimbursement_submit", "all"], now: new Date(), }); if (!delegation) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { delegation: "missing_active_grant" }, }); } delegationGrantId = delegation.id; } const ownerEmployee = await fastify.prisma.employee.findUnique({ where: { id: ownerEmployeeId }, select: { id: true, status: true }, }); if (!ownerEmployee || ownerEmployee.status !== "active") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { owner_employee_id: "invalid_owner" }, }); } } const ocrType = fields.ocr_type ?? "general"; if (!OCR_TYPE_ALLOWLIST.has(ocrType)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { ocr_type: "invalid" }, }); } 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 storagePath = path.join(datePrefix, `${crypto.randomUUID()}${extension}`); const absoluteStoragePath = path.resolve(fastify.config.storageDir, storagePath); await fs.mkdir(path.dirname(absoluteStoragePath), { recursive: true }); await fs.writeFile(absoluteStoragePath, fileBuffer); const status = isOcrEnabledForType(fastify.config, ocrType) ? "pending_ocr" : "available"; const invoice = await fastify.prisma.$transaction(async (tx) => { const now = new Date(); const created = await tx.invoicePool.create({ data: { owner_employee_id: ownerEmployeeId, uploaded_by_employee_id: request.employee.id, storage_path: storagePath, original_name: filePart.filename ?? "upload", mime_type: filePart.mimetype, file_size: fileSize, file_sha256: fileSha256, ocr_type: ocrType, status, ocr_last_requested_at: status === "pending_ocr" ? now : null, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, status: true, file_sha256: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVOICE_POOL_UPLOADED", targetType: "invoice_pool", targetId: created.id, extra: { ownerEmployeeId, uploadedByEmployeeId: request.employee.id, crossOwner: isCrossOwner, delegationGrantId, ocrType, fileSha256, }, }), }); return created; }); return sendData(reply, request, { invoicePoolId: invoice.id, status: invoice.status, fileSha256: invoice.file_sha256, }); }, ); fastify.get( "/invoice-pool/:id", { 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, status: true, archived_reason: true, archived_at: true, owner_employee_id: true, uploaded_by_employee_id: true, original_name: true, mime_type: true, file_size: true, file_sha256: true, remarks: true, ocr_type: true, ocr_provider: true, ocr_attempts: true, ocr_last_requested_at: true, ocr_last_error: true, ocr_completed_at: true, invoice_code: true, invoice_number: true, invoice_date: true, seller_name: true, buyer_name: true, amount: true, tax_amount: true, total_amount: true, linked_reimbursement_id: true, linked_item_id: true, is_duplicate_override: true, override_ref_form_number: true, override_reason: true, overridden_by: true, created_at: true, updated_at: 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 && invoice.linked_reimbursement_id) { const pendingTask = await fastify.prisma.workflowTask.findFirst({ where: { status: "pending", workflow_instance: { entity_type: "reimbursement", entity_id: invoice.linked_reimbursement_id, current_status: "pending", }, OR: [ { assignee_id: request.employee.id }, { assignee_role: { in: request.employee.roleCodes } }, ], }, select: { id: true }, }); canAccess = Boolean(pendingTask); } if (!canAccess) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } return sendData(reply, request, normalizeInvoiceResponse(invoice)); }, ); fastify.patch( "/invoice-pool/:id", { 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 parsedBody = invoiceEditBodySchema.safeParse(request.body ?? {}); if (!parsedBody.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsedBody.error.flatten(), }); } if (Object.keys(parsedBody.data).length === 0) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { body: "empty_patch" }, }); } const invoiceId = parsedInvoiceId.data; const canManage = fastify.hasPermission(request.employee, "invoice.manage"); const invoice = await fastify.prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw` SELECT id FROM "invoice_pool" WHERE id = ${invoiceId}::uuid FOR UPDATE `; if (!Array.isArray(locked) || locked.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const current = await tx.invoicePool.findUnique({ where: { id: invoiceId }, select: { id: true, status: true, archived_reason: true, archived_at: true, owner_employee_id: true, uploaded_by_employee_id: true, original_name: true, mime_type: true, file_size: true, file_sha256: true, remarks: true, ocr_type: true, ocr_provider: true, ocr_attempts: true, ocr_last_requested_at: true, ocr_last_error: true, ocr_completed_at: true, invoice_code: true, invoice_number: true, invoice_date: true, seller_name: true, buyer_name: true, amount: true, tax_amount: true, total_amount: true, linked_reimbursement_id: true, linked_item_id: true, is_duplicate_override: true, override_ref_form_number: true, override_reason: true, overridden_by: true, created_at: true, updated_at: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const canEdit = canManage || current.owner_employee_id === request.employee.id || current.uploaded_by_employee_id === request.employee.id; if (!canEdit) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if (!["available", "ocr_failed"].includes(current.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_status_for_edit" }, }); } const input = parsedBody.data; const updateData = {}; if ("remarks" in input) { updateData.remarks = input.remarks; } if ("invoiceCode" in input) { updateData.invoice_code = input.invoiceCode; } if ("invoiceNumber" in input) { updateData.invoice_number = input.invoiceNumber; } if ("invoiceDate" in input) { const parsedDate = parseDateOnlyString(input.invoiceDate); if (parsedDate === undefined) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { invoiceDate: "invalid_date" }, }); } updateData.invoice_date = parsedDate; } if ("sellerName" in input) { updateData.seller_name = input.sellerName; } if ("buyerName" in input) { updateData.buyer_name = input.buyerName; } if ("amount" in input) { const parsedAmount = normalizeMoneyInput(input.amount); if (parsedAmount === undefined) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { amount: "invalid_money" }, }); } updateData.amount = parsedAmount; } if ("taxAmount" in input) { const parsedAmount = normalizeMoneyInput(input.taxAmount); if (parsedAmount === undefined) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { taxAmount: "invalid_money" }, }); } updateData.tax_amount = parsedAmount; } if ("totalAmount" in input) { const parsedAmount = normalizeMoneyInput(input.totalAmount); if (parsedAmount === undefined) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { totalAmount: "invalid_money" }, }); } updateData.total_amount = parsedAmount; } if (input.status) { if (input.status === "available" && current.status === "ocr_failed") { updateData.status = "available"; updateData.ocr_last_error = null; updateData.ocr_completed_at = new Date(); updateData.ocr_locked_at = null; updateData.ocr_lock_owner = null; } else { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_status_transition" }, }); } } if (Object.keys(updateData).length === 0) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { body: "empty_patch" }, }); } updateData.updated_by = request.employee.id; const updated = await tx.invoicePool.update({ where: { id: invoiceId }, data: updateData, select: { id: true, status: true, archived_reason: true, archived_at: true, owner_employee_id: true, uploaded_by_employee_id: true, original_name: true, mime_type: true, file_size: true, file_sha256: true, remarks: true, ocr_type: true, ocr_provider: true, ocr_attempts: true, ocr_last_requested_at: true, ocr_last_error: true, ocr_completed_at: true, invoice_code: true, invoice_number: true, invoice_date: true, seller_name: true, buyer_name: true, amount: true, tax_amount: true, total_amount: true, linked_reimbursement_id: true, linked_item_id: true, is_duplicate_override: true, override_ref_form_number: true, override_reason: true, overridden_by: true, created_at: true, updated_at: true, }, }); const changes = {}; const trackedFields = [ "status", "remarks", "invoice_code", "invoice_number", "invoice_date", "seller_name", "buyer_name", "amount", "tax_amount", "total_amount", ]; for (const field of trackedFields) { const before = current[field]; const after = updated[field]; const beforeValue = before instanceof Date ? before.toISOString().slice(0, 10) : typeof before === "object" && before?.toString ? before.toString() : before ?? null; const afterValue = after instanceof Date ? after.toISOString().slice(0, 10) : typeof after === "object" && after?.toString ? after.toString() : after ?? null; if (beforeValue !== afterValue) { changes[field] = { before: beforeValue, after: afterValue, }; } } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVOICE_POOL_EDITED", targetType: "invoice_pool", targetId: invoiceId, extra: { changes, }, }), }); return updated; }); return sendData(reply, request, normalizeInvoiceResponse(invoice)); }, ); fastify.post( "/invoice-pool/:id/archive", { 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 invoiceId = parsedInvoiceId.data; const canManage = fastify.hasPermission(request.employee, "invoice.manage"); const invoice = await fastify.prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw` SELECT id FROM "invoice_pool" WHERE id = ${invoiceId}::uuid FOR UPDATE `; if (!Array.isArray(locked) || locked.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const current = await tx.invoicePool.findUnique({ where: { id: invoiceId }, select: { id: true, status: true, linked_reimbursement_id: true, owner_employee_id: true, uploaded_by_employee_id: true, archived_reason: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const canEdit = canManage || current.owner_employee_id === request.employee.id || current.uploaded_by_employee_id === request.employee.id; if (!canEdit) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if (current.linked_reimbursement_id) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { invoice: "cannot_archive_bound_invoice" }, }); } if (current.status === "archived" && current.archived_reason === "manual") { return tx.invoicePool.findUnique({ where: { id: invoiceId }, select: { id: true, status: true, archived_reason: true, archived_at: true, owner_employee_id: true, uploaded_by_employee_id: true, original_name: true, mime_type: true, file_size: true, file_sha256: true, remarks: true, ocr_type: true, ocr_provider: true, ocr_attempts: true, ocr_last_requested_at: true, ocr_last_error: true, ocr_completed_at: true, invoice_code: true, invoice_number: true, invoice_date: true, seller_name: true, buyer_name: true, amount: true, tax_amount: true, total_amount: true, linked_reimbursement_id: true, linked_item_id: true, is_duplicate_override: true, override_ref_form_number: true, override_reason: true, overridden_by: true, created_at: true, updated_at: true, }, }); } if (!["available", "ocr_failed"].includes(current.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_status_for_archive" }, }); } const now = new Date(); const updated = await tx.invoicePool.update({ where: { id: invoiceId }, data: { status: "archived", archived_reason: "manual", archived_at: now, updated_by: request.employee.id, }, select: { id: true, status: true, archived_reason: true, archived_at: true, owner_employee_id: true, uploaded_by_employee_id: true, original_name: true, mime_type: true, file_size: true, file_sha256: true, remarks: true, ocr_type: true, ocr_provider: true, ocr_attempts: true, ocr_last_requested_at: true, ocr_last_error: true, ocr_completed_at: true, invoice_code: true, invoice_number: true, invoice_date: true, seller_name: true, buyer_name: true, amount: true, tax_amount: true, total_amount: true, linked_reimbursement_id: true, linked_item_id: true, is_duplicate_override: true, override_ref_form_number: true, override_reason: true, overridden_by: true, created_at: true, updated_at: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVOICE_POOL_ARCHIVED", targetType: "invoice_pool", targetId: invoiceId, extra: { reason: "manual", }, }), }); return updated; }); return sendData(reply, request, normalizeInvoiceResponse(invoice)); }, ); fastify.post( "/invoice-pool/:id/unbind", { preHandler: fastify.requirePermission("invoice.upload"), }, 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 invoiceId = parsedInvoiceId.data; let responseInvoice = null; await fastify.prisma.$transaction(async (tx) => { const invoiceRows = await tx.$queryRaw` SELECT id, status, linked_reimbursement_id FROM "invoice_pool" WHERE id = ${invoiceId}::uuid FOR UPDATE `; if (!Array.isArray(invoiceRows) || invoiceRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const invoice = invoiceRows[0]; if (invoice.status !== "bound" || !invoice.linked_reimbursement_id) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { invoice: "not_bound" }, }); } const reimbursementRows = await tx.$queryRaw` SELECT id, status, owner_id, submitter_id FROM "reimbursement_forms" WHERE id = ${invoice.linked_reimbursement_id}::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]; const isOwn = reimbursement.owner_id === request.employee.id || reimbursement.submitter_id === request.employee.id; const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); if (!isOwn && !canViewAll) { 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_unbind" }, }); } responseInvoice = await tx.invoicePool.update({ where: { id: invoiceId }, data: { status: "available", linked_reimbursement_id: null, linked_item_id: null, updated_by: request.employee.id, }, select: { id: true, status: true, archived_reason: true, archived_at: true, owner_employee_id: true, uploaded_by_employee_id: true, original_name: true, mime_type: true, file_size: true, file_sha256: true, remarks: true, ocr_type: true, ocr_provider: true, ocr_attempts: true, ocr_last_requested_at: true, ocr_last_error: true, ocr_completed_at: true, invoice_code: true, invoice_number: true, invoice_date: true, seller_name: true, buyer_name: true, amount: true, tax_amount: true, total_amount: true, linked_reimbursement_id: true, linked_item_id: true, is_duplicate_override: true, override_ref_form_number: true, override_reason: true, overridden_by: true, created_at: true, updated_at: true, }, }); }); return sendData(reply, request, normalizeInvoiceResponse(responseInvoice)); }, ); fastify.post( "/invoice-pool/:id/retry-ocr", { 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 invoiceId = parsedInvoiceId.data; const canManage = fastify.hasPermission(request.employee, "invoice.manage"); const invoice = await fastify.prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw` SELECT id FROM "invoice_pool" WHERE id = ${invoiceId}::uuid FOR UPDATE `; if (!Array.isArray(locked) || locked.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const current = await tx.invoicePool.findUnique({ where: { id: invoiceId }, select: { id: true, status: true, ocr_type: true, ocr_attempts: true, ocr_last_requested_at: true, owner_employee_id: true, uploaded_by_employee_id: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const canEdit = canManage || current.owner_employee_id === request.employee.id || current.uploaded_by_employee_id === request.employee.id; if (!canEdit) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if (current.status !== "ocr_failed") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_status_for_retry" }, }); } if (!isOcrEnabledForType(fastify.config, current.ocr_type)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { ocr: "disabled" }, }); } const maxAttempts = Number(fastify.config.ocrMaxAttempts ?? 3); if ((current.ocr_attempts ?? 0) >= maxAttempts) { throw new AppError({ code: "VALID_006", message: "OCR 重试次数已达上限", statusCode: 409, details: { ocr: "attempts_exceeded" }, }); } const now = new Date(); if (current.ocr_last_requested_at) { const diffMs = now.getTime() - new Date(current.ocr_last_requested_at).getTime(); if (diffMs < 60_000) { throw new AppError({ code: "VALID_007", message: "OCR 重试过于频繁,请稍后再试", statusCode: 409, details: { ocr: "too_frequent" }, }); } } const updated = await tx.invoicePool.update({ where: { id: invoiceId }, data: { status: "pending_ocr", ocr_last_requested_at: now, ocr_last_error: null, ocr_completed_at: null, ocr_locked_at: null, ocr_lock_owner: null, updated_by: request.employee.id, }, select: { id: true, status: true, archived_reason: true, archived_at: true, owner_employee_id: true, uploaded_by_employee_id: true, original_name: true, mime_type: true, file_size: true, file_sha256: true, remarks: true, ocr_type: true, ocr_provider: true, ocr_attempts: true, ocr_last_requested_at: true, ocr_last_error: true, ocr_completed_at: true, invoice_code: true, invoice_number: true, invoice_date: true, seller_name: true, buyer_name: true, amount: true, tax_amount: true, total_amount: true, linked_reimbursement_id: true, linked_item_id: true, is_duplicate_override: true, override_ref_form_number: true, override_reason: true, overridden_by: true, created_at: true, updated_at: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVOICE_OCR_RETRIED", targetType: "invoice_pool", targetId: invoiceId, extra: { attempts: current.ocr_attempts ?? 0, nextStatus: "pending_ocr", }, }), }); return updated; }); return sendData(reply, request, normalizeInvoiceResponse(invoice)); }, ); fastify.post( "/invoice-pool/:id/override-duplicate", { preHandler: fastify.requirePermission("expense.finance.override_duplicate"), }, 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 parsedBody = overrideDuplicateBodySchema.safeParse(request.body ?? {}); if (!parsedBody.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsedBody.error.flatten(), }); } const invoiceId = parsedInvoiceId.data; const { refFormNumber, reason } = parsedBody.data; const invoice = await fastify.prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw` SELECT id FROM "invoice_pool" WHERE id = ${invoiceId}::uuid FOR UPDATE `; if (!Array.isArray(locked) || locked.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const current = await tx.invoicePool.findUnique({ where: { id: invoiceId }, select: { id: true, status: true, linked_reimbursement_id: true, file_sha256: true, invoice_code: true, invoice_number: true, is_duplicate_override: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } if (current.linked_reimbursement_id) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { invoice: "already_bound" }, }); } if (!["available", "ocr_failed"].includes(current.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_status_for_override" }, }); } if (!current.file_sha256 && !(current.invoice_code && current.invoice_number)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { invoice: "missing_dedup_key" }, }); } const dedupFilters = []; if (current.file_sha256) { dedupFilters.push({ file_sha256: current.file_sha256 }); } if (current.invoice_code && current.invoice_number) { dedupFilters.push({ invoice_code: current.invoice_code, invoice_number: current.invoice_number, }); } const conflicts = await tx.invoicePool.findMany({ where: { id: { not: invoiceId }, is_duplicate_override: false, AND: [ { OR: dedupFilters }, { OR: [ { status: "bound" }, { status: "archived", archived_reason: "used_complete" }, ], }, ], }, select: { id: true, status: true, linked_reimbursement_id: true, linked_reimbursement: { select: { status: true, form_number: true, }, }, }, }); const blocked = conflicts.find((item) => ["approved", "paid"].includes(item.linked_reimbursement?.status), ); if (blocked) { throw new AppError({ code: "VALID_004", message: "原单已审批/已付款,禁止判重例外", statusCode: 409, details: { conflictInvoiceId: blocked.id, conflictFormNumber: blocked.linked_reimbursement?.form_number ?? null, }, }); } const updated = await tx.invoicePool.update({ where: { id: invoiceId }, data: { is_duplicate_override: true, override_ref_form_number: refFormNumber, override_reason: reason, overridden_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, status: true, archived_reason: true, archived_at: true, owner_employee_id: true, uploaded_by_employee_id: true, original_name: true, mime_type: true, file_size: true, file_sha256: true, remarks: true, ocr_type: true, ocr_provider: true, ocr_attempts: true, ocr_last_requested_at: true, ocr_last_error: true, ocr_completed_at: true, invoice_code: true, invoice_number: true, invoice_date: true, seller_name: true, buyer_name: true, amount: true, tax_amount: true, total_amount: true, linked_reimbursement_id: true, linked_item_id: true, is_duplicate_override: true, override_ref_form_number: true, override_reason: true, overridden_by: true, created_at: true, updated_at: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVOICE_DUPLICATE_OVERRIDDEN", targetType: "invoice_pool", targetId: invoiceId, extra: { refFormNumber, reason, conflictInvoiceIds: conflicts.map((item) => item.id), }, }), }); return updated; }); 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 && invoice.linked_reimbursement_id) { const pendingTask = await fastify.prisma.workflowTask.findFirst({ where: { status: "pending", workflow_instance: { entity_type: "reimbursement", entity_id: invoice.linked_reimbursement_id, current_status: "pending", }, OR: [ { assignee_id: request.employee.id }, { assignee_role: { in: request.employee.roleCodes } }, ], }, select: { id: true }, }); canAccess = Boolean(pendingTask); } 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, }); } const storageRoot = path.resolve(fastify.config.storageDir); const absolutePath = path.resolve(storageRoot, invoice.storage_path); if (!absolutePath.startsWith(`${storageRoot}${path.sep}`)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } try { await fs.access(absolutePath); } catch { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } 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(fsSync.createReadStream(absolutePath)); }); fastify.post( "/invoice-pool/:id/bind", { preHandler: fastify.requirePermission("invoice.upload"), }, 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 parsedBody = bindBodySchema.safeParse(request.body ?? {}); if (!parsedBody.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsedBody.error.flatten(), }); } const invoiceId = parsedInvoiceId.data; const { reimbursementId, linkedItemId } = parsedBody.data; try { 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]; const isOwn = reimbursement.owner_id === request.employee.id || reimbursement.submitter_id === request.employee.id; const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); if (!isOwn && !canViewAll) { 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" }, }); } if (linkedItemId) { const linkedItem = await tx.reimbursementItem.findFirst({ where: { id: linkedItemId, reimbursement_id: reimbursementId, }, select: { id: true }, }); if (!linkedItem) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { linkedItemId: "item_not_in_reimbursement" }, }); } } const invoiceRows = await tx.$queryRaw` SELECT id FROM "invoice_pool" WHERE id = ${invoiceId}::uuid FOR UPDATE `; if (!Array.isArray(invoiceRows) || invoiceRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const invoice = await tx.invoicePool.findUnique({ where: { id: invoiceId }, select: { status: true, linked_reimbursement_id: true, }, }); if (!invoice) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } if (invoice.status === "bound" || invoice.linked_reimbursement_id) { throw new AppError({ code: "VALID_005", message: "发票已被其他报销单占用/绑定", statusCode: 409, }); } if (invoice.status !== "available") { throw new AppError({ code: "VALID_005", message: "发票已被其他报销单占用/绑定", statusCode: 409, details: { status: invoice.status }, }); } await tx.invoicePool.update({ where: { id: invoiceId }, data: { status: "bound", linked_reimbursement_id: reimbursementId, linked_item_id: linkedItemId ?? null, updated_by: request.employee.id, }, }); }); } catch (error) { if (isInvoiceDedupConflict(error)) { throw new AppError({ code: "VALID_003", message: "发票判重命中", statusCode: 409, }); } if (error?.code === "P2002") { fastify.log.warn( { meta: error?.meta ?? {} }, "Unexpected P2002 not from invoice dedup constraints", ); } throw error; } const invoice = await fastify.prisma.invoicePool.findUnique({ where: { id: invoiceId }, select: { id: true, status: true, linked_reimbursement_id: true, linked_item_id: true, total_amount: true, }, }); return sendData(reply, request, { id: invoice.id, status: invoice.status, linkedReimbursementId: invoice.linked_reimbursement_id, linkedItemId: invoice.linked_item_id, totalAmount: toMoneyString(invoice.total_amount), }); }, ); } module.exports = invoicePoolRoutes;