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 { sha256Hex } = require("../utils/crypto"); const { buildAttachmentContentDisposition } = require("../utils/contentDisposition"); const { signPayload, verifyToken } = require("../utils/signedUrl"); const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date"); const { findActiveDelegationForDelegate, listActiveDelegationPrincipals } = require("../utils/delegation"); const { hasPermission, resolveEntityPermission } = require("../constants/permissions"); const { createNotificationsWithRecords, resolveRecipientsForAssignee, } = require("../services/notifications"); const { buildPendingTaskCopy, buildWorkflowEntityMeta, } = require("../services/notifications/workflowCopy"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { sendNotificationEmailSafe } = require("../services/email"); const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal"); const { mapInvoiceDocumentMeta } = require("../services/invoiceDocumentMeta"); const { dispatchNotificationBatch, flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { ensureCutoverAllowed } = require("../services/cutover"); const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool"); const { buildPersistedRiskFlag, createRiskFlagsFromPrecheck, resolveEntityPrecheck, } = require("../services/feeControl"); const { issueDocumentNumber } = require("../services/documentSequence"); const { INVOICE_AMOUNT_EVIDENCE_STATUS, resolveInvoiceAmountEvidence, } = require("../services/invoiceAmountEvidence"); const { PAYMENT_PROCESSING_STAGE_DEFINITIONS, buildPaymentProcessingSemantics, requiresBankReceipt, } = require("../services/paymentProcessingProgress"); const { openReadStream, readFileBuffer, StorageNotFoundError, writeBuffer } = require("../services/storage"); const { MaterialPreviewError, createReimbursementMaterialPreviewStore, } = require("../services/reimbursementMaterialPreview"); const { ATTACHMENT_TYPE, DOWNLOAD_POLICY, SENSITIVITY_LEVEL, governanceCreateData, mapGovernanceFields, parseMultipartGovernanceField, requiresDownloadReason, sanitizeDownloadReason, } = require("../services/businessAttachmentGovernance"); const { buildApprovalWorkflowSnapshot, getWorkflowStepsSnapshot, resolveActionableStep, resolveWorkflowStepDisplayName, toMoneyString, } = require("../utils/workflow"); const UUID_SCHEMA = z.string().uuid(); const PAY_BODY_SCHEMA = z.object({ paymentVoucherNo: z.string().trim().min(1).max(50).optional(), paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), paymentChannel: z.enum(["bank", "alipay", "wechat", "cash", "other"]).optional(), paymentNote: z.string().trim().max(200).optional(), }); const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({ page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), status: z .enum(["draft", "reviewing", "approved", "paid", "rejected", "cancelled"]) .optional(), scope: z.enum(["own", "all"]).optional(), }); const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({ reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), description: z.string().trim().max(500).nullable().optional(), travelPurpose: z.string().trim().max(200).nullable().optional(), travelLocation: z.string().trim().max(200).nullable().optional(), travelStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), travelEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), }).superRefine((value, ctx) => { if (value.travelStartDate && value.travelEndDate && value.travelStartDate > value.travelEndDate) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "invalid_date_range", path: ["travelEndDate"] }); } }); const REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA = z .object({ reason: z.string().trim().min(2).max(200), }) .strict(); const REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA = z .object({ reason: z.string().trim().max(200).optional() }) .strict(); const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA; const REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA = z .object({ projectId: z.string().uuid().nullable() }) .strict(); const REIMBURSEMENT_INVOICE_ITEM_LINK_BODY_SCHEMA = z .object({ linkedItemId: z.string().uuid() }) .strict(); function resolveSignedFileOrigin(config, request) { const configured = String(config.appOrigin ?? "") .split(",") .map((item) => item.trim()) .find(Boolean); return configured || `${request.protocol}://${request.headers.host ?? "localhost:3000"}`; } function resolvePreviewUrlTtlMs(config) { const configuredSeconds = Math.max(30, Number(config.signedUrlTtlSeconds ?? 120)); return Math.min(configuredSeconds, 120) * 1000; } function throwMaterialPreviewAppError(error) { if (error instanceof StorageNotFoundError) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (!(error instanceof MaterialPreviewError)) { throw error; } const limitError = /_limit$/.test(error.code); throw new AppError({ code: limitError ? "VALID_001" : "MATERIAL_PREVIEW_001", message: limitError ? "报销材料较多,暂时无法连续预览" : "部分材料暂时无法生成连续预览", statusCode: limitError ? 409 : 422, }); } const REIMBURSEMENT_CREATE_BODY_SCHEMA = z .object({ ownerEmployeeId: z.string().uuid().optional(), reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), description: z.string().max(500).optional(), templateId: z.string().uuid().optional(), linkedTravelRequestId: z.string().uuid().optional(), travelPurpose: z.string().trim().max(200).optional(), travelLocation: z.string().trim().max(200).optional(), travelStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), travelEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), }) .strict() .superRefine((value, ctx) => { if (value.travelStartDate && value.travelEndDate && value.travelStartDate > value.travelEndDate) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "invalid_date_range", path: ["travelEndDate"] }); } if (value.templateId && value.linkedTravelRequestId) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "mutually_exclusive", path: ["templateId"], }); ctx.addIssue({ code: z.ZodIssueCode.custom, message: "mutually_exclusive", path: ["linkedTravelRequestId"], }); } }); const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({ expenseTypeId: z.string().uuid(), expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), amount: z.string().regex(/^\d+(\.\d{1,2})?$/), description: z.string().trim().min(1).max(500), assetPriorApprovalBasis: z.string().trim().max(500).nullable().optional(), }); const DATE_ONLY_QUERY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const REIMBURSEMENT_STATS_QUERY_SCHEMA = z.object({ dateFrom: DATE_ONLY_QUERY_SCHEMA.optional(), dateTo: DATE_ONLY_QUERY_SCHEMA.optional(), departmentId: z.string().uuid().optional(), }); const REIMBURSEMENT_TREND_QUERY_SCHEMA = REIMBURSEMENT_STATS_QUERY_SCHEMA.extend({ granularity: z.enum(["month", "week"]).optional(), }); const REIMBURSEMENT_TOP_EXPENSE_TYPES_QUERY_SCHEMA = REIMBURSEMENT_STATS_QUERY_SCHEMA.extend({ limit: z.coerce.number().int().min(1).max(50).optional(), }); const LINKABLE_TRAVEL_REQUEST_QUERY_SCHEMA = z .object({ ownerEmployeeId: UUID_SCHEMA.optional(), }) .strict(); const REIMBURSEMENT_STATUS_VALUES = ["draft", "reviewing", "approved", "paid", "rejected", "cancelled"]; const ROLE_LABELS = { manager: "主管", ceo: "CEO", finance: "财务", finance_mgr: "财务经理", administration: "行政", admin: "管理员", hr: "HR", }; const TRAVEL_SEGMENT_ROLE_LABELS = { outbound: "去程", return: "返程", connection: "中转", }; const TRAVEL_TRANSPORT_LABELS = { flight: "航班", train: "火车", bus: "大巴", car: "汽车", other: "其他", }; const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = { flight: "travel_airfare", train: "travel_train", bus: "travel_bus", car: "travel_taxi", other: "transport", }; const PRINTABLE_WORKFLOW_ACTIONS = new Set([ "start", "approve", "reject", "return", "transfer", "withdraw", "remind", "overdue_remind", ]); 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"); if (canViewAll) { return; } if (!fastify.hasPermission(request.employee, "expense.view_own")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } 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, }); } } async function ensureOwnCanViewAllOrWorkflowReviewer(fastify, request, reimbursement) { const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); const isOwn = reimbursement.owner_id === request.employee.id || reimbursement.submitter_id === request.employee.id; const canViewOwn = fastify.hasPermission(request.employee, "expense.view_own") && isOwn; if (canViewAll || canViewOwn) return; const delegatedPrincipalIds = await listActiveDelegationPrincipals(fastify.prisma, { delegateEmployeeId: request.employee.id, allowedScopes: ["all"], }); const assigneeFilters = [ { assignee_id: request.employee.id }, ...(request.employee.roleCodes?.length ? [{ assignee_role: { in: request.employee.roleCodes } }] : []), ...(delegatedPrincipalIds.length ? [{ assignee_id: { in: delegatedPrincipalIds } }] : []), ]; const reviewerTask = await fastify.prisma.workflowTask.findFirst({ where: { OR: assigneeFilters, workflow_instance: { entity_type: "reimbursement", entity_id: reimbursement.id, }, }, select: { id: true }, }); if (reviewerTask) return; throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } function buildWorkflowProgress(workflowInstance, historyItems = []) { if (!workflowInstance?.current_step_id) { return null; } const steps = getWorkflowStepsSnapshot(workflowInstance); const currentStepIndex = steps.findIndex((step) => step.id === workflowInstance.current_step_id); if (currentStepIndex < 0) { return null; } const skippedStepIds = new Set( historyItems .filter((item) => item.action === "skip" && item.payload?.step_id) .map((item) => String(item.payload.step_id)), ); return { currentStepIndex, totalSteps: steps.length, steps: steps.map((step, index) => ({ id: step.id, name: step.name ?? step.id, status: skippedStepIds.has(step.id) || index < currentStepIndex ? "completed" : index === currentStepIndex ? "current" : "pending", })), }; } function buildWorkflowStepNameMap(workflowInstance) { const steps = getWorkflowStepsSnapshot(workflowInstance); return new Map(steps.map((step) => [step.id, step.name ?? step.id])); } function resolveApprovalHistoryStepName(historyItem, stepNameMap, fallbackStepId = null) { if (historyItem.workflow_task?.step_name) { return historyItem.workflow_task.step_name; } const payload = historyItem.payload ?? {}; const stepId = payload.from_step_id ?? payload.step_id ?? payload.to_step_id ?? fallbackStepId ?? null; if (!stepId) { return null; } return stepNameMap.get(String(stepId)) ?? String(stepId); } function buildApprovalHistory(workflowInstance, historyItems = []) { if (!workflowInstance) { return []; } const stepNameMap = buildWorkflowStepNameMap(workflowInstance); return historyItems .filter((item) => PRINTABLE_WORKFLOW_ACTIONS.has(item.action)) .map((item) => ({ id: item.id, stepName: resolveApprovalHistoryStepName(item, stepNameMap, workflowInstance.current_step_id), action: item.action, actorName: item.actor?.name ?? "", comment: item.comment ?? null, createdAt: item.created_at?.toISOString?.() ?? null, })); } const PROCESSING_STAGE_DEFINITIONS = Object.freeze([ { key: "submitted", label: "申请提交" }, { key: "manager_approval", label: "主管审批" }, { key: "finance_review", label: "财务复核" }, PAYMENT_PROCESSING_STAGE_DEFINITIONS.review, PAYMENT_PROCESSING_STAGE_DEFINITIONS.payment, ]); function isoDateTimeOrNull(value) { return value?.toISOString?.() ?? null; } function isOrdinalApprovalLabel(value) { const label = String(value ?? "").trim(); if (!label) return true; const ordinal = "(?:\\d+|[一二三四五六七八九十百]+)"; return new RegExp(`^(?:第\\s*)?${ordinal}\\s*(?:步|步骤|节点)$`, "i").test(label) || new RegExp(`^(?:审批)?(?:步骤?|节点|step)\\s*[-_#::]?\\s*${ordinal}$`, "i").test(label); } function resolveApprovalStageLabel(task) { const stepName = resolveWorkflowStepDisplayName(task); const stepId = String(task?.step_id ?? "").trim(); const isTechnicalIdentifier = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)+$/i.test(stepName); const isGenericLabel = ["审批", "审核", "复核"].includes(stepName); if (stepName && stepName !== stepId && !isOrdinalApprovalLabel(stepName) && !isTechnicalIdentifier && !isGenericLabel) { return stepName; } const role = String(task?.assignee_role ?? "").toLowerCase(); if (role === "manager") return "主管审批"; if (["finance", "finance_mgr"].includes(role)) return "财务复核"; if (role === "ceo") return "负责人复核"; return "审批"; } function resolveSnapshotApprovalRole(step) { if (step?.assignee_rule?.type === "department_manager") return "manager"; if (step?.assignee_rule?.type === "role") return step.assignee_rule.value ?? null; return null; } function buildPendingSnapshotApprovalStage(step, index) { return { key: `approval_pending_${index + 1}`, label: resolveApprovalStageLabel({ step_id: step?.id, step_name: step?.name, assignee_role: resolveSnapshotApprovalRole(step), }), state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null, }; } function buildApprovalProcessingStage(task, fallback) { if (!task) { return fallback; } const actorName = task.completer_employee?.name ?? task.assignee_employee?.name ?? null; if (task.outcome === "rejected" || task.status === "rejected") { return { ...fallback, state: "exception", actorName, occurredAt: isoDateTimeOrNull(task.completed_at), result: "驳回", comment: task.comment ?? null, detail: "申请已驳回,待申请人处理", }; } if (task.outcome === "returned") { return { ...fallback, state: "exception", actorName, occurredAt: isoDateTimeOrNull(task.completed_at), result: "退回", comment: task.comment ?? null, detail: "申请已退回,待申请人补充后重新提交", }; } if (task.status === "completed" || task.outcome === "approved") { return { ...fallback, state: "completed", actorName, occurredAt: isoDateTimeOrNull(task.completed_at), result: "通过", comment: task.comment ?? null, detail: null, }; } if (task.status === "pending") { return { ...fallback, state: "current", actorName: task.assignee_employee?.name ?? null, occurredAt: isoDateTimeOrNull(task.created_at), result: "处理中", comment: null, detail: `待${fallback.label}处理`, }; } return fallback; } function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatchItem, paymentActorNames, canReadPaymentBatch }) { const stageByKey = new Map(PROCESSING_STAGE_DEFINITIONS.map((stage) => [stage.key, { ...stage, state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null, }])); const submittedStage = stageByKey.get("submitted"); if (workflowInstance) { Object.assign(submittedStage, { state: "completed", actorName: reimbursement.submitter?.name ?? reimbursement.owner?.name ?? null, occurredAt: isoDateTimeOrNull(workflowInstance.started_at ?? workflowInstance.created_at), result: "已提交", detail: null, }); } else if (["draft", "rejected"].includes(reimbursement.status)) { Object.assign(submittedStage, { state: "current", actorName: reimbursement.owner?.name ?? null, result: "待提交", detail: "待申请人提交", }); } const approvalStages = []; const workflowTasks = workflowInstance?.workflow_tasks ?? []; for (const [index, task] of workflowTasks.entries()) { const fallback = { key: `approval_${task.id ?? index + 1}`, label: resolveApprovalStageLabel(task), state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null, }; approvalStages.push(buildApprovalProcessingStage(task, fallback)); } if (workflowInstance) { const snapshotSteps = getWorkflowStepsSnapshot(workflowInstance); const currentStepIndex = snapshotSteps.findIndex((step) => step.id === workflowInstance.current_step_id); const materializedStepIds = new Set(workflowTasks.map((task) => task.step_id).filter(Boolean)); const pendingStartIndex = workflowTasks.length === 0 ? 0 : currentStepIndex + 1; for (let index = Math.max(pendingStartIndex, 0); index < snapshotSteps.length; index += 1) { const step = snapshotSteps[index]; if (!materializedStepIds.has(step.id)) { approvalStages.push(buildPendingSnapshotApprovalStage(step, index)); } } } const batch = paymentBatchItem?.payment_batch ?? null; const batchTargetHref = batch && canReadPaymentBatch ? `/payment-batches?id=${encodeURIComponent(batch.id)}` : null; const batchReviewStage = stageByKey.get("payment_batch_review"); const paymentStage = stageByKey.get("payment_and_receipt"); if (!batch) { Object.assign(batchReviewStage, reimbursement.status === "approved" ? { state: "current", result: "待安排", detail: "待财务安排付款" } : { detail: "待财务安排付款" }); paymentStage.detail = "待付款复核后由财务登记,非审批环节"; } else { const semantics = buildPaymentProcessingSemantics({ status: batch.status, createdAt: batch.created_at, reviewedAt: batch.reviewed_at, executedAt: batch.executed_at, cancelledAt: batch.cancelled_at, cancelledReason: batch.cancelled_reason, requiresBankReceipt: requiresBankReceipt(batch), receiptCount: batch._count?.receipt_attachments ?? 0, paymentOrderExportCount: batch.payment_order_export_count ?? 0, }); const actorIds = { reviewer: batch.reviewed_by, executor: batch.executed_by, canceller: batch.cancelled_by, }; for (const [target, source] of [ [batchReviewStage, semantics.review], [paymentStage, semantics.payment], ]) { Object.assign(target, { state: source.state, actorName: source.actorSource ? paymentActorNames.get(actorIds[source.actorSource]) ?? null : null, occurredAt: isoDateTimeOrNull(source.occurredAt), result: source.result, comment: source.comment, detail: source.detail, targetHref: null, }); } if (batchTargetHref) { const reviewOwnsBatchLink = ["current", "exception"].includes(batchReviewStage.state) && ["pending", "terminated"].includes(paymentStage.state); (reviewOwnsBatchLink ? batchReviewStage : paymentStage).targetHref = batchTargetHref; } } if (!batch && reimbursement.status === "paid") { Object.assign(paymentStage, { state: "completed", actorName: reimbursement.paid_by_employee?.name ?? null, occurredAt: isoDateTimeOrNull(reimbursement.paid_at), result: "已付款", detail: "付款已完成,回单待留存", }); } return { stages: [ stageByKey.get("submitted"), ...(approvalStages.length > 0 ? approvalStages : [stageByKey.get("manager_approval"), stageByKey.get("finance_review")]), stageByKey.get("payment_batch_review"), stageByKey.get("payment_and_receipt"), ], }; } function ensureReimbursementParticipant(request, reimbursement) { const isParticipant = reimbursement.owner_id === request.employee.id || reimbursement.submitter_id === request.employee.id; if (!isParticipant) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } } function mapReimbursementAttachment(item) { return { id: item.id, originalName: item.original_name, mimeType: item.mime_type, fileSize: item.file_size, fileSha256: item.file_sha256, ...mapGovernanceFields(item, { governanceStatus: "approved", sensitivityLevel: SENSITIVITY_LEVEL.internal, attachmentType: ATTACHMENT_TYPE.supportingMaterial, downloadPolicy: DOWNLOAD_POLICY.standard, }), 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(), }); const parsed = bodySchema.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return parsed.data; } function parseReimbursementId(id) { const parsed = UUID_SCHEMA.safeParse(id); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } return parsed.data; } function parseReimbursementUpdateBody(body) { const parsed = REIMBURSEMENT_UPDATE_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } if (!Object.keys(parsed.data).length) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { body: "empty" }, }); } return parsed.data; } function parseReimbursementProjectLinkBody(body) { const parsed = REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return parsed.data; } function parseReimbursementAttachmentDeleteBody(body) { const parsed = REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return parsed.data; } function parseQuery(schema, query) { const parsed = schema.safeParse(query ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return parsed.data; } function parseStatsQuery(query) { const parsed = parseQuery(REIMBURSEMENT_STATS_QUERY_SCHEMA, query); const dateFrom = parsed.dateFrom ? parseDateOnlyString(parsed.dateFrom) : null; const dateTo = parsed.dateTo ? parseDateOnlyString(parsed.dateTo) : null; if (dateFrom && dateTo && dateFrom > dateTo) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { dateTo: "must_be_after_dateFrom" }, }); } return { dateFrom, dateTo, dateFromRaw: parsed.dateFrom ?? null, dateToRaw: parsed.dateTo ?? null, departmentId: parsed.departmentId ?? null, }; } function parseCreateBody(body) { const parsed = REIMBURSEMENT_CREATE_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return parsed.data; } function parseLinkableTravelRequestsQuery(query) { const parsed = LINKABLE_TRAVEL_REQUEST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return { ownerEmployeeId: parsed.data.ownerEmployeeId ?? null, }; } function parseTrendQuery(query) { const parsed = parseQuery(REIMBURSEMENT_TREND_QUERY_SCHEMA, query); const base = parseStatsQuery(parsed); return { ...base, granularity: parsed.granularity ?? "month", }; } function parseTopExpenseTypesQuery(query) { const parsed = parseQuery(REIMBURSEMENT_TOP_EXPENSE_TYPES_QUERY_SCHEMA, query); const base = parseStatsQuery(parsed); return { ...base, limit: parsed.limit ?? 10, }; } function sqlIdentifier(alias, column) { return Prisma.raw(`"${alias}"."${column}"`); } function buildReimbursementStatsConditions({ request, dateFrom, dateTo, departmentId, formAlias = "rf", ownerAlias = "owner_employee", }) { const conditions = [Prisma.sql`1 = 1`]; if (dateFrom) { conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "reimbursement_date")} >= ${dateFrom}`); } if (dateTo) { conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "reimbursement_date")} <= ${dateTo}`); } if (departmentId) { conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "department_id")} = ${departmentId}`); } if (!isGlobalViewRole(request.employee.roleCodes ?? [])) { conditions.push( Prisma.sql`( ${sqlIdentifier(formAlias, "owner_id")} = ${request.employee.id} OR ${sqlIdentifier(formAlias, "submitter_id")} = ${request.employee.id} OR ${sqlIdentifier(ownerAlias, "direct_manager_id")} = ${request.employee.id} )`, ); } return conditions; } function toNumericValue(value) { const numeric = Number(value ?? 0); return Number.isFinite(numeric) ? numeric : 0; } function toNullableNumber(value) { if (value == null) { return null; } const numeric = Number(value); return Number.isFinite(numeric) ? numeric : null; } function toFixedMoneyString(value) { return toNumericValue(value).toFixed(2); } function formatTrendPeriod(value, granularity) { if (!value) { return null; } const dateValue = value instanceof Date ? value : new Date(value); if (Number.isNaN(dateValue.getTime())) { return String(value); } return granularity === "month" ? dateValue.toISOString().slice(0, 7) : dateValue.toISOString().slice(0, 10); } function formatRoleLabel(roleCode) { if (!roleCode) { return "未分配"; } return ROLE_LABELS[roleCode] ?? roleCode; } function buildAllowedActionsForPendingWorkflow(request, entityType) { const approvePermission = resolveEntityPermission(entityType, "approve"); const canApprove = approvePermission && hasPermission(request.employee.permissionCodes ?? [], approvePermission); if (!canApprove) { return []; } const actions = ["approve", "reject", "return"]; if (hasPermission(request.employee.permissionCodes ?? [], "expense.transfer")) { actions.push("transfer"); } return actions; } function isPendingWorkflowTaskActionable(request, task, delegatedPrincipalIds) { if (!task || task.status !== "pending") { return false; } const roleCodes = request.employee.roleCodes ?? []; if (!task.assignee_id && !task.assignee_role) { return true; } if (task.assignee_id && task.assignee_id === request.employee.id) { return true; } if (task.assignee_role && roleCodes.includes(task.assignee_role)) { return true; } return Boolean(task.assignee_id && delegatedPrincipalIds.includes(task.assignee_id)); } function buildPendingWorkflowSummary(item, request, delegatedPrincipalIds) { const pendingInstance = item.workflow_instances?.[0] ?? null; const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null; if (!pendingInstance || !pendingTask) { return null; } if (!isPendingWorkflowTaskActionable(request, pendingTask, delegatedPrincipalIds)) { return null; } const allowedActions = buildAllowedActionsForPendingWorkflow(request, "reimbursement"); if (!allowedActions.length) { return null; } return { instanceId: pendingInstance.id, stepId: pendingInstance.current_step_id, allowedActions, task: { id: pendingTask.id, stepName: pendingTask.step_name, assigneeId: pendingTask.assignee_id, assigneeRole: pendingTask.assignee_role, }, }; } function parseItemBody(body) { const parsed = REIMBURSEMENT_ITEM_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } const amountNumber = Number(parsed.data.amount); if (!Number.isFinite(amountNumber) || amountNumber <= 0) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { amount: "must_be_positive" }, }); } return parsed.data; } function parsePayBody(body) { const parsed = PAY_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return parsed.data; } function parseReimbursementListQuery(query) { const parsed = REIMBURSEMENT_LIST_QUERY_SCHEMA.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, scope: parsed.data.scope, }; } function buildOwnScopeWhere(employeeId) { return { OR: [{ owner_id: employeeId }, { submitter_id: employeeId }], }; } function isGlobalViewRole(roleCodes) { return roleCodes.some((code) => ["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code), ); } function buildViewAllScopeWhere(request) { if (isGlobalViewRole(request.employee.roleCodes ?? [])) { return {}; } return { OR: [ { owner_id: request.employee.id }, { submitter_id: request.employee.id }, { owner: { direct_manager_id: request.employee.id } }, ], }; } function mapReimbursementSummary(item) { return { id: item.id, formNumber: item.form_number, status: item.status, reimbursementDate: item.reimbursement_date?.toISOString?.()?.slice(0, 10) ?? null, totalAmount: toMoneyString(item.total_amount), description: item.description, payment: { paymentVoucherNo: item.payment_voucher_no, paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null, paymentChannel: item.payment_channel, paidAt: item.paid_at?.toISOString?.() ?? null, paidBy: item.paid_by, }, owner: item.owner ? { id: item.owner.id, employeeNo: item.owner.employee_no, name: item.owner.name, } : null, submitter: item.submitter ? { id: item.submitter.id, employeeNo: item.submitter.employee_no, name: item.submitter.name, } : null, createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, project: item.project ? { id: item.project.id, projectNo: item.project.project_no, name: item.project.name, } : null, pendingWorkflow: item.pending_workflow ?? null, }; } function toDateOnlyString(dateValue) { if (!dateValue) { return null; } if (typeof dateValue === "string") { return dateValue.slice(0, 10); } return new Date(dateValue).toISOString().slice(0, 10); } function mapLinkedTravelRequestSummary(travelRequest) { if (!travelRequest) { return null; } return { id: travelRequest.id, requestNo: travelRequest.request_no ?? null, purpose: travelRequest.purpose ?? null, startDate: toDateOnlyString(travelRequest.start_date), endDate: toDateOnlyString(travelRequest.end_date), estimatedAmount: toMoneyString(travelRequest.estimated_amount ?? 0), }; } function mapCreatedReimbursementItem(item) { return { id: item.id, expenseTypeId: item.expense_type_id, expenseDate: toDateOnlyString(item.expense_date), amount: toMoneyString(item.amount), description: item.description ?? null, assetPriorApprovalBasis: item.asset_prior_approval_basis ?? null, expenseType: item.expense_type ? { id: item.expense_type.id, code: item.expense_type.code, name: item.expense_type.name, category: item.expense_type.category, enabled: item.expense_type.enabled, requiresInvoice: item.expense_type.requires_invoice, } : null, }; } function normalizeTemplateSeedItems(rawItems) { if (!Array.isArray(rawItems)) { return []; } return rawItems.map((item, index) => { if (!item || typeof item !== "object") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { template: { itemIndex: index, reason: "invalid_item_shape" } }, }); } if (typeof item.expenseTypeId !== "string" || typeof item.description !== "string") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { template: { itemIndex: index, reason: "invalid_item_shape" } }, }); } return { expenseTypeId: item.expenseTypeId, description: item.description, defaultAmount: typeof item.defaultAmount === "string" ? item.defaultAmount : null, }; }); } async function resolveEnabledExpenseTypesByIds(tx, expenseTypeIds) { if (!expenseTypeIds.length) { return new Map(); } const items = await tx.expenseType.findMany({ where: { id: { in: expenseTypeIds }, enabled: true, }, select: { id: true, code: true, name: true, category: true, enabled: true, }, }); return new Map(items.map((item) => [item.id, item])); } async function resolveTravelExpenseTypeMap(tx) { const codes = Array.from(new Set(Object.values(TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES))); const items = await tx.expenseType.findMany({ where: { code: { in: codes }, enabled: true, }, select: { id: true, code: true, name: true, category: true, enabled: true, }, }); return new Map(items.map((item) => [item.code, item])); } function buildTravelSegmentDescription(segment) { const segmentRoleLabel = TRAVEL_SEGMENT_ROLE_LABELS[segment.segment_role] ?? segment.segment_role; const transportLabel = TRAVEL_TRANSPORT_LABELS[segment.transport_type] ?? segment.transport_type; return `${segmentRoleLabel} ${transportLabel} ${segment.from_city}→${segment.to_city}`; } async function resolveReimbursementOwnerContext(tx, fastify, request, ownerEmployeeId) { const isProxyCreate = ownerEmployeeId !== request.employee.id; if (!isProxyCreate) { return { ownerEmployeeId, ownerEmployee: { id: request.employee.id, department_id: request.employee.department_id ?? null, status: "active", }, departmentId: request.employee.department_id ?? null, delegationGrantId: null, isProxyCreate: false, }; } 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, }); } const ownerEmployee = await tx.employee.findUnique({ where: { id: ownerEmployeeId }, select: { id: true, status: true, department_id: true }, }); if (!ownerEmployee || ownerEmployee.status !== "active") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { ownerEmployeeId: "invalid_owner" }, }); } const delegation = await findActiveDelegationForDelegate(tx, { 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" }, }); } return { ownerEmployeeId, ownerEmployee, departmentId: ownerEmployee.department_id, delegationGrantId: delegation.id, isProxyCreate: true, }; } async function loadLinkableTravelRequests(tx, ownerEmployeeId) { return tx.travelRequest.findMany({ where: { applicant_employee_id: ownerEmployeeId, status: "approved", linked_reimbursements: { none: {}, }, travel_orders: { none: { linked_reimbursement_id: { not: null, }, }, }, }, orderBy: [{ start_date: "desc" }, { created_at: "desc" }], select: { id: true, request_no: true, purpose: true, start_date: true, end_date: true, estimated_amount: true, }, }); } async function loadTravelRequestForLinking(tx, linkedTravelRequestId) { return tx.travelRequest.findUnique({ where: { id: linkedTravelRequestId }, select: { id: true, request_no: true, purpose: true, start_date: true, end_date: true, estimated_amount: true, applicant_employee_id: true, status: true, segments: { orderBy: [{ sort_order: "asc" }, { created_at: "asc" }], select: { id: true, segment_role: true, transport_type: true, from_city: true, to_city: true, }, }, linked_reimbursements: { take: 1, select: { id: true, }, }, travel_orders: { where: { linked_reimbursement_id: { not: null, }, }, take: 1, select: { id: true, linked_reimbursement_id: true, }, }, }, }); } function ensureNonNegativeAmount(totalAmount) { const asNumber = Number(totalAmount); if (!Number.isFinite(asNumber)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { total_amount: "invalid" }, }); } if (asNumber < 0) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { total_amount: "negative_not_allowed" }, }); } } async function recomputeTotalAmount(tx, reimbursementId, actorId) { const aggregate = await tx.reimbursementItem.aggregate({ where: { reimbursement_id: reimbursementId }, _sum: { amount: true }, }); const nextTotal = aggregate._sum.amount ?? "0"; await tx.reimbursementForm.update({ where: { id: reimbursementId }, data: { total_amount: nextTotal, updated_by: actorId, }, }); return nextTotal; } async function loadApprovalRuleForAmount(tx, totalAmount) { const amountString = toMoneyString(totalAmount); const approvalRule = await tx.approvalRule.findFirst({ where: { enabled: true, min_amount: { lte: amountString }, OR: [{ max_amount: null }, { max_amount: { gt: amountString } }], }, orderBy: { priority: "asc" }, select: { id: true, level1_role: true, level2_role: true, level3_role: true, owner_self_fallback_role: true, priority: true, }, }); if (!approvalRule) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { workflow: "missing_approval_rule" }, }); } return approvalRule; } async function resolveApplicableBudgetPool(tx, reimbursement) { return resolveApplicableBudgetPoolByContext(tx, { departmentId: reimbursement.department_id, effectiveDate: reimbursement.reimbursement_date, }); } function normalizeBudgetControlMode(config) { const mode = String(config.budgetControlMode ?? "warn").toLowerCase(); if (mode === "block") { return "block"; } return "warn"; } function buildBudgetHint(budgetPool, reimbursementAmount, controlMode) { const allocatedAmount = Number(budgetPool.allocated_amount ?? 0); const usedAmount = Number(budgetPool.used_amount ?? 0); const currentAmount = Number(reimbursementAmount ?? 0); const nextUsed = usedAmount + currentAmount; const nextUsagePercent = allocatedAmount > 0 ? (nextUsed / allocatedAmount) * 100 : 0; return { budgetId: budgetPool.id, budgetCode: budgetPool.code, budgetName: budgetPool.name, controlMode, warningThresholdPercent: budgetPool.warning_threshold_percent, allocatedAmount: allocatedAmount.toFixed(2), usedAmount: usedAmount.toFixed(2), remainingAmount: Math.max(0, allocatedAmount - usedAmount).toFixed(2), currentAmount: currentAmount.toFixed(2), nextUsedAmount: nextUsed.toFixed(2), nextUsagePercent: Number(nextUsagePercent.toFixed(2)), exceedsThreshold: nextUsagePercent >= budgetPool.warning_threshold_percent, exceedsBudget: nextUsed > allocatedAmount, }; } function buildPrecheckBlockedError(precheck) { const standardViolations = Array.isArray(precheck?.standardChecks) ? precheck.standardChecks.filter((item) => item.exceeded && item.controlMode === "block") : []; return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: standardViolations.length ? 400 : 409, details: { precheck: precheck.blockingReasons, ...(standardViolations.length ? { standardViolations } : {}), }, }); } function listExceededStandardViolations(precheck, controlMode = null) { if (!Array.isArray(precheck?.standardChecks)) { return []; } return precheck.standardChecks.filter((item) => { if (!item.exceeded) { return false; } if (!controlMode) { return true; } return item.controlMode === controlMode; }); } function buildSkippedNotificationResult(reason) { return { skipped: true, reason }; } function buildSkippedDispatchResult(reason) { return { deliveredChannels: [], skippedReason: reason, notificationCount: 0, eligibleRecipientCount: 0, matchedChannelCount: 0 }; } function buildSkippedEmailResult(reason, failed = 0) { return { skipped: true, reason, sent: 0, failed }; } async function loadReimbursementSubmitSummary(prisma, reimbursementId) { return prisma.reimbursementForm.findUnique({ where: { id: reimbursementId }, select: { id: true, form_number: true, total_amount: true, owner: { select: { id: true, name: true, }, }, }, }); } async function finalizeReimbursementSubmitSideEffects({ prisma, config, logger, requestId, reimbursementId, formNumber, workflowTaskId, assigneeId, assigneeRole, actorId, loadSummary = loadReimbursementSubmitSummary, resolveRecipients = resolveRecipientsForAssignee, createAppNotifications = createNotificationsWithRecords, dispatchNotifications = dispatchNotificationBatch, sendEmailSafe = sendNotificationEmailSafe, }) { let copy = buildPendingTaskCopy( buildWorkflowEntityMeta("reimbursement", { form_number: formNumber }, reimbursementId), ); try { const summary = await loadSummary(prisma, reimbursementId); copy = buildPendingTaskCopy( buildWorkflowEntityMeta( "reimbursement", { ...summary, form_number: summary?.form_number ?? formNumber, }, reimbursementId, ), ); } catch (error) { logger?.error?.( { err: error, requestId, reimbursementId, }, "Failed to load reimbursement submit notification summary", ); } const { title, body } = copy; const targetPath = `/workflow/tasks/${workflowTaskId}`; let recipientEmployeeIds = []; try { recipientEmployeeIds = await resolveRecipients(prisma, { assigneeId, assigneeRole, }); } catch (error) { logger?.error?.( { err: error, requestId, reimbursementId, workflowTaskId, }, "Failed to resolve reimbursement submit notification recipients", ); return { recipientEmployeeIds: [], notification: buildSkippedNotificationResult("recipient_resolution_failed"), dispatch: buildSkippedDispatchResult("recipient_resolution_failed"), email: buildSkippedEmailResult("recipient_resolution_failed"), }; } if (!recipientEmployeeIds.length) { return { recipientEmployeeIds: [], notification: buildSkippedNotificationResult("no_recipients"), dispatch: buildSkippedDispatchResult("no_recipients"), email: buildSkippedEmailResult("no_recipients"), }; } let notification = buildSkippedNotificationResult("not_attempted"); let notificationRecords = []; try { notificationRecords = await createAppNotifications(prisma, { recipientEmployeeIds, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: "workflow_task", targetId: workflowTaskId, }); notification = { count: notificationRecords.length }; } catch (error) { logger?.error?.( { err: error, requestId, reimbursementId, workflowTaskId, recipients: recipientEmployeeIds.length, }, "Failed to create reimbursement submit notifications", ); notification = buildSkippedNotificationResult("notification_failed"); } let dispatch = buildSkippedDispatchResult("not_attempted"); try { dispatch = await dispatchNotifications({ prisma, config, logger, requestId, notifications: notificationRecords, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: "workflow_task", targetId: workflowTaskId, targetPath, actorId, }); } catch (error) { logger?.error?.( { err: error, requestId, reimbursementId, workflowTaskId, recipients: recipientEmployeeIds.length, }, "Failed to dispatch reimbursement submit notifications", ); dispatch = buildSkippedDispatchResult("dispatch_failed"); } let email = buildSkippedEmailResult("not_attempted"); try { const emailResult = await sendEmailSafe({ prisma, config, logger, requestId, recipientEmployeeIds, subject: title, body, type: NOTIFICATION_TYPES.workflowTaskAssigned, targetPath, }); email = emailResult?.email ?? emailResult ?? buildSkippedEmailResult("empty_email_result"); } catch (error) { logger?.error?.( { err: error, requestId, reimbursementId, workflowTaskId, recipients: recipientEmployeeIds.length, }, "Failed to send reimbursement submit notification email", ); email = buildSkippedEmailResult("email_failed", recipientEmployeeIds.length); } return { recipientEmployeeIds, notification, dispatch, email, }; } async function reimbursementRoutes(fastify) { const materialPreviewStore = fastify.reimbursementMaterialPreviewStore ?? createReimbursementMaterialPreviewStore(); fastify.post( "/reimbursements", { schema: { tags: ["Reimbursements"], summary: "Create Reimbursement" }, preHandler: fastify.requirePermission("expense.create"), }, async (request, reply) => { await ensureCutoverAllowed({ prisma: fastify.prisma, config: fastify.config, request, domain: "expense", departmentId: request.employee.department_id ?? null, }); const body = parseCreateBody(request.body); const reimbursementDate = body.reimbursementDate ?? currentDateString(fastify.config.appTimezone); const ownerEmployeeId = body.ownerEmployeeId ?? request.employee.id; const reimbursement = await fastify.prisma.$transaction(async (tx) => { const ownerContext = await resolveReimbursementOwnerContext(tx, fastify, request, ownerEmployeeId); let linkedTravelRequestRecord = null; let generatedItemSeeds = []; let resolvedDescription = body.description ?? null; if (body.templateId) { const template = await tx.reimbursementTemplate.findFirst({ where: { id: body.templateId, enabled: true, }, select: { id: true, name: true, items: true, }, }); if (!template) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { templateId: "invalid_template" }, }); } const templateItems = normalizeTemplateSeedItems(template.items); const expenseTypeMap = await resolveEnabledExpenseTypesByIds( tx, Array.from(new Set(templateItems.map((item) => item.expenseTypeId))), ); const invalidItem = templateItems.find((item) => !expenseTypeMap.has(item.expenseTypeId)); if (invalidItem) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { templateId: "contains_disabled_expense_type" }, }); } generatedItemSeeds = templateItems.map((item) => ({ expense_type_id: item.expenseTypeId, expense_date: parseDateOnlyString(reimbursementDate), amount: item.defaultAmount ?? "0.00", description: item.description, created_by: request.employee.id, updated_by: request.employee.id, })); } if (body.linkedTravelRequestId) { linkedTravelRequestRecord = await loadTravelRequestForLinking(tx, body.linkedTravelRequestId); if (!linkedTravelRequestRecord) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { linkedTravelRequestId: "not_found" }, }); } if (linkedTravelRequestRecord.status !== "approved") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { linkedTravelRequestId: "not_approved" }, }); } if (linkedTravelRequestRecord.applicant_employee_id !== ownerContext.ownerEmployeeId) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { linkedTravelRequestId: "owner_mismatch" }, }); } if ( linkedTravelRequestRecord.linked_reimbursements?.length || linkedTravelRequestRecord.travel_orders?.length ) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { linkedTravelRequestId: "already_linked" }, }); } if (body.description == null) { resolvedDescription = `差旅报销 - ${linkedTravelRequestRecord.request_no ?? linkedTravelRequestRecord.id} ${linkedTravelRequestRecord.purpose}`; } const expenseTypeMap = await resolveTravelExpenseTypeMap(tx); generatedItemSeeds = linkedTravelRequestRecord.segments.map((segment) => { const expenseTypeCode = TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES[segment.transport_type]; const expenseType = expenseTypeMap.get(expenseTypeCode); if (!expenseType) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { linkedTravelRequestId: "missing_expense_type_mapping", transportType: segment.transport_type, expenseTypeCode, }, }); } return { expense_type_id: expenseType.id, expense_date: parseDateOnlyString(reimbursementDate), amount: "0.00", description: buildTravelSegmentDescription(segment), created_by: request.employee.id, updated_by: request.employee.id, }; }); } const issued = await issueDocumentNumber(tx, { documentType: "reimbursement", actorId: request.employee.id, timeZone: fastify.config.appTimezone, }); const linkedTravelCities = linkedTravelRequestRecord ? [...new Set(linkedTravelRequestRecord.segments.flatMap((segment) => [segment.from_city, segment.to_city]).filter(Boolean))] : []; const travelPurpose = body.travelPurpose?.trim() || linkedTravelRequestRecord?.purpose || null; const travelLocation = body.travelLocation?.trim() || linkedTravelCities.join("、") || null; const travelStartDate = body.travelStartDate ? parseDateOnlyString(body.travelStartDate) : linkedTravelRequestRecord?.start_date ?? null; const travelEndDate = body.travelEndDate ? parseDateOnlyString(body.travelEndDate) : linkedTravelRequestRecord?.end_date ?? null; const created = await tx.reimbursementForm.create({ data: { form_number: issued.documentNumber, submitter_id: request.employee.id, owner_id: ownerEmployeeId, department_id: ownerContext.departmentId, reimbursement_date: parseDateOnlyString(reimbursementDate), description: resolvedDescription, linked_travel_request_id: linkedTravelRequestRecord?.id ?? null, travel_purpose: travelPurpose, travel_location: travelLocation, travel_start_date: travelStartDate, travel_end_date: travelEndDate, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, status: true, reimbursement_date: true, description: true, total_amount: true, form_number: true, owner_id: true, submitter_id: true, linked_travel_request_id: true, travel_purpose: true, travel_location: true, travel_start_date: true, travel_end_date: true, travel_request: { select: { id: true, request_no: true, purpose: true, start_date: true, end_date: true, estimated_amount: true, }, }, }, }); if (generatedItemSeeds.length) { await tx.reimbursementItem.createMany({ data: generatedItemSeeds.map((item) => ({ reimbursement_id: created.id, ...item, })), }); created.total_amount = await recomputeTotalAmount(tx, created.id, request.employee.id); } const createdItems = generatedItemSeeds.length ? await tx.reimbursementItem.findMany({ where: { reimbursement_id: created.id }, orderBy: [{ created_at: "asc" }, { id: "asc" }], select: { id: true, expense_type_id: true, expense_date: true, amount: true, description: true, expense_type: { select: { id: true, code: true, name: true, category: true, enabled: true, }, }, }, }) : []; if (ownerContext.isProxyCreate) { await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "REIMBURSEMENT_PROXY_CREATED", targetType: "reimbursement_form", targetId: created.id, extra: { ownerEmployeeId, submitterEmployeeId: request.employee.id, delegationGrantId: ownerContext.delegationGrantId, ...(body.templateId ? { templateId: body.templateId } : {}), ...(linkedTravelRequestRecord ? { linkedTravelRequestId: linkedTravelRequestRecord.id } : {}), }, }), }); } return { ...created, items: createdItems, }; }); return sendData(reply, request, { id: reimbursement.id, formNumber: reimbursement.form_number, submitterId: reimbursement.submitter_id, ownerId: reimbursement.owner_id, reimbursementDate, status: reimbursement.status, description: reimbursement.description, payment: { paymentVoucherNo: reimbursement.payment_voucher_no ?? null, paymentDate: toDateOnlyString(reimbursement.payment_date), paymentChannel: reimbursement.payment_channel ?? null, paidAt: reimbursement.paid_at?.toISOString?.() ?? null, paidBy: reimbursement.paid_by_employee ? { id: reimbursement.paid_by_employee.id, employeeNo: reimbursement.paid_by_employee.employee_no, name: reimbursement.paid_by_employee.name, } : null, }, projectId: reimbursement.project_id ?? null, project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null, totalAmount: toMoneyString(reimbursement.total_amount), linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null, linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request), travelSummary: { purpose: reimbursement.travel_purpose ?? null, location: reimbursement.travel_location ?? null, startDate: toDateOnlyString(reimbursement.travel_start_date), endDate: toDateOnlyString(reimbursement.travel_end_date), }, items: Array.isArray(reimbursement.items) ? reimbursement.items.map(mapCreatedReimbursementItem) : [], }); }, ); fastify.get( "/reimbursements", { schema: { tags: ["Reimbursements"], summary: "List Reimbursements" }, preHandler: fastify.authenticate, }, async (request, reply) => { const query = parseReimbursementListQuery(request.query); const canViewOwn = fastify.hasPermission(request.employee, "expense.view_own"); const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); if (!canViewOwn && !canViewAll) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const scope = query.scope ?? (canViewAll ? "all" : "own"); if (scope === "all" && !canViewAll) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const skip = (query.page - 1) * query.pageSize; const delegatedPrincipalIds = await listActiveDelegationPrincipals(fastify.prisma, { delegateEmployeeId: request.employee.id, allowedScopes: ["all"], }); const where = { ...(query.status ? { status: query.status } : {}), ...(scope === "all" ? buildViewAllScopeWhere(request) : buildOwnScopeWhere(request.employee.id)), }; const [items, total] = await Promise.all([ fastify.prisma.reimbursementForm.findMany({ where, orderBy: { created_at: "desc" }, skip, take: query.pageSize, select: { id: true, form_number: true, status: true, reimbursement_date: true, total_amount: true, description: true, payment_voucher_no: true, payment_date: true, payment_channel: true, paid_at: true, paid_by: true, created_at: true, updated_at: true, project: { select: { id: true, project_no: true, name: true } }, owner: { select: { id: true, employee_no: true, name: true }, }, submitter: { select: { id: true, employee_no: true, name: true }, }, workflow_instances: { where: { current_status: "pending" }, orderBy: { created_at: "desc" }, take: 1, select: { id: true, current_step_id: true, workflow_tasks: { where: { status: "pending" }, take: 1, select: { id: true, step_name: true, assignee_id: true, assignee_role: true, status: true, }, }, }, }, }, }), fastify.prisma.reimbursementForm.count({ where }), ]); return sendData(reply, request, { page: query.page, pageSize: query.pageSize, total, scope, items: items.map((item) => mapReimbursementSummary({ ...item, pending_workflow: buildPendingWorkflowSummary(item, request, delegatedPrincipalIds), }), ), }); }, ); fastify.get( "/reimbursements/stats/summary", { schema: { tags: ["Reimbursements"], summary: "Get reimbursement stats summary" }, preHandler: fastify.requirePermission("expense.view_all"), }, async (request, reply) => { const query = parseStatsQuery(request.query); const conditions = buildReimbursementStatsConditions({ request, dateFrom: query.dateFrom, dateTo: query.dateTo, departmentId: query.departmentId, }); const [statusRows, avgRow] = await Promise.all([ fastify.prisma.$queryRaw( Prisma.sql` SELECT rf.status::text AS status, COUNT(*)::int AS count, COALESCE(SUM(rf.total_amount), 0)::text AS total_amount FROM "reimbursement_forms" rf LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id WHERE ${Prisma.join(conditions, " AND ")} GROUP BY rf.status `, ), fastify.prisma.$queryRaw( Prisma.sql` SELECT ROUND(AVG(EXTRACT(EPOCH FROM (rf.paid_at - submit_info.submitted_at)) / 86400.0)::numeric, 2)::float8 AS avg_processing_days FROM "reimbursement_forms" rf LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id JOIN ( SELECT reimbursement_id, MAX(created_at) AS submitted_at FROM "workflow_instances" WHERE entity_type = 'reimbursement' AND reimbursement_id IS NOT NULL GROUP BY reimbursement_id ) submit_info ON submit_info.reimbursement_id = rf.id WHERE ${Prisma.join( [ ...conditions, Prisma.sql`rf.status = 'paid'`, Prisma.sql`rf.paid_at IS NOT NULL`, ], " AND ", )} `, ), ]); const byStatus = Object.fromEntries( REIMBURSEMENT_STATUS_VALUES.map((status) => [ status, { count: 0, totalAmount: toFixedMoneyString(0), }, ]), ); for (const row of statusRows) { byStatus[row.status] = { count: Number(row.count ?? 0), totalAmount: toFixedMoneyString(row.total_amount ?? 0), }; } const totalCount = REIMBURSEMENT_STATUS_VALUES.reduce( (sum, status) => sum + Number(byStatus[status]?.count ?? 0), 0, ); const totalAmountNumber = REIMBURSEMENT_STATUS_VALUES.reduce( (sum, status) => sum + toNumericValue(byStatus[status]?.totalAmount), 0, ); return sendData(reply, request, { totalCount, totalAmount: toFixedMoneyString(totalAmountNumber), byStatus, avgProcessingDays: toNullableNumber(avgRow?.[0]?.avg_processing_days), }); }, ); fastify.get( "/reimbursements/stats/trend", { schema: { tags: ["Reimbursements"], summary: "Get reimbursement stats trend" }, preHandler: fastify.requirePermission("expense.view_all"), }, async (request, reply) => { const query = parseTrendQuery(request.query); const conditions = buildReimbursementStatsConditions({ request, dateFrom: query.dateFrom, dateTo: query.dateTo, departmentId: query.departmentId, }); const periodExpression = query.granularity === "week" ? Prisma.sql`date_trunc('week', rf.reimbursement_date)` : Prisma.sql`date_trunc('month', rf.reimbursement_date)`; const rows = await fastify.prisma.$queryRaw( Prisma.sql` SELECT ${periodExpression} AS period_start, COUNT(*)::int AS count, COALESCE(SUM(rf.total_amount), 0)::text AS amount, COUNT(*) FILTER (WHERE rf.status = 'paid')::int AS paid_count, COALESCE(SUM(rf.total_amount) FILTER (WHERE rf.status = 'paid'), 0)::text AS paid_amount FROM "reimbursement_forms" rf LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id WHERE ${Prisma.join(conditions, " AND ")} GROUP BY period_start ORDER BY period_start ASC `, ); return sendData(reply, request, { items: rows.map((row) => ({ period: formatTrendPeriod(row.period_start, query.granularity), count: Number(row.count ?? 0), amount: toFixedMoneyString(row.amount ?? 0), paidCount: Number(row.paid_count ?? 0), paidAmount: toFixedMoneyString(row.paid_amount ?? 0), })), }); }, ); fastify.get( "/reimbursements/stats/top-expense-types", { schema: { tags: ["Reimbursements"], summary: "Get reimbursement top expense types" }, preHandler: fastify.requirePermission("expense.view_all"), }, async (request, reply) => { const query = parseTopExpenseTypesQuery(request.query); const conditions = buildReimbursementStatsConditions({ request, dateFrom: query.dateFrom, dateTo: query.dateTo, departmentId: query.departmentId, }); const rows = await fastify.prisma.$queryRaw( Prisma.sql` SELECT et.id::text AS expense_type_id, et.name::text AS expense_type_name, COUNT(ri.id)::int AS count, COALESCE(SUM(ri.amount), 0)::text AS total_amount FROM "reimbursement_items" ri INNER JOIN "reimbursement_forms" rf ON rf.id = ri.reimbursement_id LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id INNER JOIN "expense_types" et ON et.id = ri.expense_type_id WHERE ${Prisma.join(conditions, " AND ")} GROUP BY et.id, et.name ORDER BY SUM(ri.amount) DESC, COUNT(ri.id) DESC, et.name ASC LIMIT ${query.limit} `, ); return sendData(reply, request, { items: rows.map((row) => ({ expenseTypeId: row.expense_type_id, expenseTypeName: row.expense_type_name, count: Number(row.count ?? 0), totalAmount: toFixedMoneyString(row.total_amount ?? 0), })), }); }, ); fastify.get( "/reimbursements/stats/approval-bottleneck", { schema: { tags: ["Reimbursements"], summary: "Get reimbursement approval bottleneck" }, preHandler: fastify.requirePermission("expense.view_all"), }, async (request, reply) => { const query = parseStatsQuery(request.query); const conditions = buildReimbursementStatsConditions({ request, dateFrom: query.dateFrom, dateTo: query.dateTo, departmentId: query.departmentId, }); const rows = await fastify.prisma.$queryRaw( Prisma.sql` SELECT wt.assignee_id::text AS assignee_id, wt.assignee_role::text AS assignee_role, approver.name::text AS assignee_name, COUNT(*)::int AS pending_count, FLOOR(MAX(EXTRACT(EPOCH FROM (NOW() - wt.created_at)) / 86400.0))::int AS oldest_pending_days FROM "workflow_tasks" wt INNER JOIN "workflow_instances" wi ON wi.id = wt.instance_id AND wi.entity_type = 'reimbursement' INNER JOIN "reimbursement_forms" rf ON rf.id = wi.reimbursement_id LEFT JOIN "employees" owner_employee ON owner_employee.id = rf.owner_id LEFT JOIN "employees" approver ON approver.id = wt.assignee_id WHERE ${Prisma.join( [ ...conditions, Prisma.sql`wt.status = 'pending'`, Prisma.sql`rf.status = 'reviewing'`, ], " AND ", )} GROUP BY wt.assignee_id, wt.assignee_role, approver.name ORDER BY pending_count DESC, oldest_pending_days DESC, approver.name ASC NULLS LAST, wt.assignee_role ASC NULLS LAST `, ); return sendData(reply, request, { items: rows.map((row) => ({ assigneeId: row.assignee_id, assigneeName: row.assignee_name ?? formatRoleLabel(row.assignee_role), pendingCount: Number(row.pending_count ?? 0), oldestPendingDays: Number(row.oldest_pending_days ?? 0), })), }); }, ); fastify.get( "/reimbursements/linkable-travel-requests", { schema: { tags: ["Reimbursements"], summary: "List approved travel requests that can be linked to reimbursement" }, preHandler: fastify.requirePermission("expense.create"), }, async (request, reply) => { const query = parseLinkableTravelRequestsQuery(request.query); const ownerEmployeeId = query.ownerEmployeeId ?? request.employee.id; const ownerContext = await resolveReimbursementOwnerContext( fastify.prisma, fastify, request, ownerEmployeeId, ); const items = await loadLinkableTravelRequests(fastify.prisma, ownerContext.ownerEmployeeId); return sendData(reply, request, { ownerEmployeeId: ownerContext.ownerEmployeeId, items: items.map((item) => ({ id: item.id, requestNo: item.request_no ?? null, purpose: item.purpose, startDate: toDateOnlyString(item.start_date), endDate: toDateOnlyString(item.end_date), estimatedAmount: toMoneyString(item.estimated_amount ?? 0), })), }); }, ); fastify.get( "/reimbursements/:id", { schema: { tags: ["Reimbursements"], summary: "Get Reimbursement Detail" }, preHandler: fastify.authenticate, }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); const reimbursement = await fastify.prisma.reimbursementForm.findUnique({ where: { id: reimbursementId }, select: { id: true, form_number: true, submitter_id: true, owner_id: true, department_id: true, created_at: true, reimbursement_date: true, total_amount: true, status: true, description: true, payment_voucher_no: true, payment_date: true, payment_channel: true, paid_at: true, project_id: true, linked_travel_request_id: true, travel_purpose: true, travel_location: true, travel_start_date: true, travel_end_date: true, paid_by_employee: { select: { id: true, employee_no: true, name: true, }, }, department: { select: { id: true, name: true, }, }, project: { select: { id: true, project_no: true, name: true } }, travel_request: { select: { id: true, request_no: true, purpose: true, start_date: true, end_date: true, estimated_amount: true, }, }, owner: { select: { id: true, employee_no: true, name: true }, }, submitter: { select: { id: true, employee_no: true, name: true }, }, items: { orderBy: { expense_date: "desc" }, select: { id: true, expense_type_id: true, expense_date: true, amount: true, description: true, asset_prior_approval_basis: true, expense_type: { select: { id: true, code: true, name: true, category: true, enabled: true, requires_invoice: true, }, }, }, }, attachments: { orderBy: { created_at: "asc" }, select: { id: true, original_name: true, mime_type: true, file_size: true, file_sha256: true, governance_status: true, sensitivity_level: true, attachment_type: true, reviewed_by: true, reviewed_at: true, review_note: true, archived_by: true, archived_at: true, archive_note: true, download_policy: true, created_at: true, uploader: { select: { id: true, employee_no: true, name: true, }, }, }, }, invoice_pool_items: { select: { id: true, status: true, invoice_code: true, invoice_number: true, invoice_date: true, seller_name: true, buyer_name: true, total_amount: true, original_name: true, mime_type: true, file_size: true, linked_item_id: true, ocr_attempts: true, ocr_last_requested_at: true, ocr_last_error: true, ocr_completed_at: true, ocr_result: { select: { result_json: true } }, }, }, workflow_instances: { where: { current_status: "pending" }, orderBy: { created_at: "desc" }, take: 1, select: { id: true, current_step_id: true, current_status: true, context_data: true, workflow_definition: { select: { definition_json: true, }, }, workflow_tasks: { where: { status: "pending" }, take: 1, select: { id: true, step_name: true, assignee_id: true, assignee_role: true, }, }, }, }, }, }); if (!reimbursement) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } await ensureOwnCanViewAllOrWorkflowReviewer(fastify, request, reimbursement); const controlMode = normalizeBudgetControlMode(fastify.config); const budgetPool = await resolveApplicableBudgetPool(fastify.prisma, reimbursement); const budgetHint = budgetPool ? buildBudgetHint(budgetPool, reimbursement.total_amount, controlMode) : null; const pendingInstance = reimbursement.workflow_instances[0] ?? null; const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null; const latestWorkflowInstance = await fastify.prisma.workflowInstance.findFirst({ where: { entity_type: "reimbursement", entity_id: reimbursementId, }, orderBy: { created_at: "desc" }, select: { id: true, current_step_id: true, current_status: true, started_at: true, completed_at: true, created_at: true, context_data: true, workflow_definition: { select: { definition_json: true, }, }, workflow_tasks: { orderBy: [{ created_at: "asc" }, { id: "asc" }], select: { id: true, step_id: true, step_name: true, assignee_id: true, assignee_role: true, status: true, outcome: true, comment: true, created_at: true, completed_at: true, assignee_employee: { select: { name: true } }, completer_employee: { select: { name: true } }, }, }, }, }); const workflowHistory = pendingInstance ? await fastify.prisma.workflowHistory.findMany({ where: { instance_id: pendingInstance.id }, orderBy: { created_at: "asc" }, select: { action: true, payload: true, }, }) : []; const approvalHistoryRows = latestWorkflowInstance ? await fastify.prisma.workflowHistory.findMany({ where: { instance_id: latestWorkflowInstance.id }, orderBy: { created_at: "asc" }, select: { id: true, action: true, comment: true, payload: true, created_at: true, actor: { select: { name: true, }, }, workflow_task: { select: { step_name: true, }, }, }, }) : []; const paymentBatchItem = await fastify.prisma.paymentBatchItem.findFirst({ where: { entity_id: reimbursementId, payment_batch: { entity_type: "reimbursement" }, }, orderBy: [{ created_at: "desc" }, { id: "desc" }], select: { id: true, payment_batch: { select: { id: true, status: true, entity_type: true, default_payment_channel: true, payment_order_export_count: true, created_at: true, created_by: true, reviewed_at: true, reviewed_by: true, executed_at: true, executed_by: true, cancelled_at: true, cancelled_by: true, cancelled_reason: true, items: { select: { status: true, payment_channel_override: true, }, }, _count: { select: { receipt_attachments: { where: { archived_at: null } } }, }, }, }, }, }); const paymentActorIds = Array.from(new Set([ paymentBatchItem?.payment_batch?.created_by, paymentBatchItem?.payment_batch?.reviewed_by, paymentBatchItem?.payment_batch?.executed_by, paymentBatchItem?.payment_batch?.cancelled_by, ].filter(Boolean))); const paymentActors = paymentActorIds.length ? await fastify.prisma.employee.findMany({ where: { id: { in: paymentActorIds } }, select: { id: true, name: true }, }) : []; const paymentActorNames = new Map(paymentActors.map((actor) => [actor.id, actor.name])); const canReadPaymentBatch = ["payment.batch.manage", "risk.review.manage", "payment.batch.review"] .some((permissionCode) => fastify.hasPermission(request.employee, permissionCode)); const riskFlags = await fastify.prisma.entityRiskFlag.findMany({ where: { entity_type: "reimbursement", entity_id: reimbursementId, status: "open", }, orderBy: [{ created_at: "desc" }, { id: "desc" }], }); const travelItems = reimbursement.items.filter((item) => item.expense_type?.category === "travel"); const isTravelReimbursement = Boolean(reimbursement.linked_travel_request_id || travelItems.length); const invoiceGuardGate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.reimbursementInvoiceGuard, departmentId: reimbursement.department_id ?? null, strictDepartment: true, }); const requiredInvoiceItemIds = new Set( reimbursement.items .filter((item) => item.expense_type?.requires_invoice) .map((item) => item.id), ); const invoiceAmountEvidenceById = new Map( reimbursement.invoice_pool_items.map((invoice) => [ invoice.id, resolveInvoiceAmountEvidence({ totalAmount: invoice.total_amount, ocrAttempts: invoice.ocr_attempts, ocrLastRequestedAt: invoice.ocr_last_requested_at, ocrCompletedAt: invoice.ocr_completed_at, ocrLastError: invoice.ocr_last_error, }), ]), ); const invoiceAmountManualReviewRequired = reimbursement.invoice_pool_items.some( (invoice) => requiredInvoiceItemIds.has(invoice.linked_item_id) && invoiceAmountEvidenceById.get(invoice.id) === INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview, ); return sendData(reply, request, { id: reimbursement.id, formNumber: reimbursement.form_number, submitterId: reimbursement.submitter_id, ownerId: reimbursement.owner_id, departmentId: reimbursement.department_id, department: reimbursement.department ? { id: reimbursement.department.id, name: reimbursement.department.name, } : null, createdAt: reimbursement.created_at?.toISOString?.() ?? null, submittedAt: latestWorkflowInstance?.created_at?.toISOString?.() ?? null, reimbursementDate: reimbursement.reimbursement_date.toISOString().slice(0, 10), totalAmount: toMoneyString(reimbursement.total_amount), status: reimbursement.status, description: reimbursement.description, payment: { paymentVoucherNo: reimbursement.payment_voucher_no ?? null, paymentDate: toDateOnlyString(reimbursement.payment_date), paymentChannel: reimbursement.payment_channel ?? null, paidAt: reimbursement.paid_at?.toISOString?.() ?? null, paidBy: reimbursement.paid_by_employee ? { id: reimbursement.paid_by_employee.id, employeeNo: reimbursement.paid_by_employee.employee_no, name: reimbursement.paid_by_employee.name, } : null, }, projectId: reimbursement.project_id ?? null, project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null, linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null, linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request), travelSummary: { isTravel: isTravelReimbursement, purpose: reimbursement.travel_purpose ?? null, location: reimbursement.travel_location ?? null, startDate: toDateOnlyString(reimbursement.travel_start_date), endDate: toDateOnlyString(reimbursement.travel_end_date), }, owner: reimbursement.owner, submitter: reimbursement.submitter, items: reimbursement.items.map((item) => ({ id: item.id, expenseTypeId: item.expense_type_id, expenseDate: item.expense_date?.toISOString?.()?.slice(0, 10) ?? null, amount: toMoneyString(item.amount), description: item.description, assetPriorApprovalBasis: item.asset_prior_approval_basis ?? null, expenseType: item.expense_type ? { id: item.expense_type.id, code: item.expense_type.code, name: item.expense_type.name, category: item.expense_type.category, enabled: item.expense_type.enabled, requiresInvoice: item.expense_type.requires_invoice, } : null, })), attachments: reimbursement.attachments.map(mapReimbursementAttachment), budgetHint, invoices: reimbursement.invoice_pool_items.map((item) => ({ ...mapInvoiceDocumentMeta(item), id: item.id, status: item.status, invoiceCode: item.invoice_code, invoiceNumber: item.invoice_number, invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null, sellerName: item.seller_name ?? null, buyerName: item.buyer_name ?? null, totalAmount: toMoneyString(item.total_amount), originalName: item.original_name ?? null, mimeType: item.mime_type ?? null, fileSize: item.file_size ?? null, linkedItemId: item.linked_item_id ?? null, amountEvidenceStatus: invoiceAmountEvidenceById.get(item.id), })), pendingWorkflow: pendingInstance ? { instanceId: pendingInstance.id, stepId: pendingInstance.current_step_id, task: pendingTask, } : null, workflowProgress: pendingInstance ? buildWorkflowProgress(pendingInstance, workflowHistory) : null, processingProgress: buildProcessingProgress({ reimbursement, workflowInstance: latestWorkflowInstance, paymentBatchItem, paymentActorNames, canReadPaymentBatch, }), approvalHistory: buildApprovalHistory(latestWorkflowInstance, approvalHistoryRows), riskFlags: riskFlags.map(buildPersistedRiskFlag), invoiceGuardEnabled: invoiceGuardGate.enabled, invoiceAmountManualReviewRequired, }); }, ); fastify.patch( "/reimbursements/:id", { preHandler: fastify.requirePermission("expense.edit_own"), }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); const body = parseReimbursementUpdateBody(request.body); const updated = await fastify.prisma.$transaction(async (tx) => { const reimbursementRows = await tx.$queryRaw` SELECT id, owner_id, submitter_id, department_id, status, travel_start_date, travel_end_date 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]; ensureReimbursementParticipant(request, reimbursement); await ensureCutoverAllowed({ prisma: tx, config: fastify.config, request, domain: "expense", departmentId: reimbursement.department_id ?? request.employee.department_id ?? null, }); if (!["draft", "rejected"].includes(reimbursement.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_edit_status" }, }); } const nextDescription = body.description === undefined ? undefined : body.description?.trim() || null; const nextTravelStartDate = body.travelStartDate === undefined ? reimbursement.travel_start_date : body.travelStartDate ? parseDateOnlyString(body.travelStartDate) : null; const nextTravelEndDate = body.travelEndDate === undefined ? reimbursement.travel_end_date : body.travelEndDate ? parseDateOnlyString(body.travelEndDate) : null; if (nextTravelStartDate && nextTravelEndDate && nextTravelStartDate > nextTravelEndDate) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { travelEndDate: "invalid_date_range" }, }); } return tx.reimbursementForm.update({ where: { id: reimbursementId }, data: { ...(body.reimbursementDate ? { reimbursement_date: parseDateOnlyString(body.reimbursementDate) } : {}), ...(body.description !== undefined ? { description: nextDescription } : {}), ...(body.travelPurpose !== undefined ? { travel_purpose: body.travelPurpose?.trim() || null } : {}), ...(body.travelLocation !== undefined ? { travel_location: body.travelLocation?.trim() || null } : {}), ...(body.travelStartDate !== undefined ? { travel_start_date: nextTravelStartDate } : {}), ...(body.travelEndDate !== undefined ? { travel_end_date: nextTravelEndDate } : {}), updated_by: request.employee.id, }, select: { id: true, status: true, reimbursement_date: true, description: true, total_amount: true, travel_purpose: true, travel_location: true, travel_start_date: true, travel_end_date: true, }, }); }); return sendData(reply, request, { id: updated.id, status: updated.status, reimbursementDate: updated.reimbursement_date.toISOString().slice(0, 10), description: updated.description, totalAmount: toMoneyString(updated.total_amount), travelSummary: { purpose: updated.travel_purpose, location: updated.travel_location, startDate: toDateOnlyString(updated.travel_start_date), endDate: toDateOnlyString(updated.travel_end_date), }, }); }, ); fastify.delete( "/reimbursements/:id", { preHandler: fastify.requirePermission("expense.delete_own"), }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); 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]; ensureReimbursementParticipant(request, reimbursement); if (reimbursement.status !== "draft") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_delete_status" }, }); } const unbound = await tx.invoicePool.updateMany({ where: { linked_reimbursement_id: reimbursementId, status: "bound", }, data: { status: "available", linked_reimbursement_id: null, linked_item_id: null, updated_by: request.employee.id, }, }); await tx.reimbursementForm.delete({ where: { id: reimbursementId }, }); return { reimbursementId, unboundInvoiceCount: unbound.count, }; }); return sendData(reply, request, { reimbursementId: result.reimbursementId, outcome: "deleted", unboundInvoiceCount: result.unboundInvoiceCount, }); }, ); 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, }); } ensureReimbursementParticipant(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 = []; let governanceFields = {}; try { for await (const part of request.parts({ limits: { files: 5, fileSize: fastify.config.maxUploadSizeBytes, }, })) { if (part.type === "field") { governanceFields = parseMultipartGovernanceField(part, governanceFields); continue; } 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_FILES_LIMIT") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { file: "too_many_files", maxFiles: 5 }, }); } 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 = []; const governanceDefaults = { governanceStatus: "approved", sensitivityLevel: SENSITIVITY_LEVEL.internal, attachmentType: ATTACHMENT_TYPE.supportingMaterial, downloadPolicy: DOWNLOAD_POLICY.standard, }; 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, ...governanceCreateData(governanceFields, governanceDefaults), }, select: { id: true, original_name: true, mime_type: true, file_size: true, file_sha256: true, governance_status: true, sensitivity_level: true, attachment_type: true, reviewed_by: true, reviewed_at: true, review_note: true, archived_by: true, archived_at: true, archive_note: true, download_policy: true, created_at: true, uploader: { select: { id: true, employee_no: true, name: true, }, }, }, }); createdItems.push(created); } const uploadedGovernance = governanceCreateData(governanceFields, governanceDefaults); 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), attachmentType: uploadedGovernance.attachment_type, sensitivityLevel: uploadedGovernance.sensitivity_level, }, }), }); 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 { reason } = parseReimbursementAttachmentDeleteBody(request.body); 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]; ensureReimbursementParticipant(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, }); } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "REIMBURSEMENT_ATTACHMENT_DELETED", targetType: "reimbursement_attachment", targetId: parsedAttachmentId.data, extra: { reimbursementId, reason, }, }), }); return { attachmentId: parsedAttachmentId.data }; }); return sendData(reply, request, { attachmentId: result.attachmentId, outcome: "deleted", }); }, ); fastify.get( "/reimbursements/:id/attachments/:attachmentId/file", {}, 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 previewToken = typeof request.query?.previewToken === "string" ? request.query.previewToken : ""; let previewGrant = null; if (previewToken) { const verified = verifyToken(previewToken, fastify.config.jwtSecret); const payload = verified.ok ? verified.payload : null; const parsedActorId = UUID_SCHEMA.safeParse(payload?.actorId); if ( !verified.ok || payload?.purpose !== "wecom_preview" || payload?.reimbursementId !== reimbursementId || payload?.attachmentId !== parsedAttachmentId.data || typeof payload?.exp !== "number" || payload.exp <= Date.now() || !parsedActorId.success ) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } previewGrant = { actorId: parsedActorId.data }; } else { await fastify.authenticate(request); } 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, file_size: true, governance_status: true, sensitivity_level: true, attachment_type: true, download_policy: true, reimbursement_form: { select: { owner_id: true, submitter_id: true, }, }, }, }); if (!attachment) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } let actorId; let downloadReason; if (previewGrant) { actorId = previewGrant.actorId; downloadReason = "企业微信在线预览"; } else { await ensureOwnCanViewAllOrWorkflowReviewer(fastify, request, { id: reimbursementId, ...attachment.reimbursement_form, }); actorId = request.employee.id; downloadReason = sanitizeDownloadReason(request.query?.reason); if (requiresDownloadReason(attachment) && !downloadReason) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { reason: "required" }, }); } } 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; } await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId, action: "REIMBURSEMENT_ATTACHMENT_DOWNLOADED", targetType: "reimbursement", targetId: reimbursementId, extra: { attachmentId: attachment.id, attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.supportingMaterial, sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal, governanceStatus: attachment.governance_status ?? "approved", reason: downloadReason, }, }), }); reply.type(attachment.mime_type || "application/octet-stream"); reply.header("cache-control", "private, no-store"); reply.header("referrer-policy", "no-referrer"); reply.header( "content-disposition", buildAttachmentContentDisposition(attachment.original_name, { disposition: "inline" }), ); return reply.send(stream); }, ); fastify.post( "/reimbursements/:id/attachments/:attachmentId/preview-url", { preHandler: fastify.authenticate }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); const parsedAttachmentId = UUID_SCHEMA.safeParse(request.params?.attachmentId); const parsedBody = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA.safeParse(request.body ?? {}); if (!parsedAttachmentId.success || !parsedBody.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400 }); } const attachment = await fastify.prisma.reimbursementAttachment.findFirst({ where: { id: parsedAttachmentId.data, reimbursement_id: reimbursementId }, select: { id: true, original_name: true, mime_type: true, file_size: true, governance_status: true, sensitivity_level: true, attachment_type: true, download_policy: true, reimbursement_form: { select: { owner_id: true, submitter_id: true } }, }, }); if (!attachment) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } await ensureOwnCanViewAllOrWorkflowReviewer(fastify, request, { id: reimbursementId, ...attachment.reimbursement_form, }); const reason = sanitizeDownloadReason(parsedBody.data.reason); if (requiresDownloadReason(attachment) && !reason) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { reason: "required" }, }); } const expiresAt = Date.now() + resolvePreviewUrlTtlMs(fastify.config); const previewToken = signPayload({ purpose: "wecom_preview", reimbursementId, attachmentId: attachment.id, actorId: request.employee.id, exp: expiresAt, }, fastify.config.jwtSecret); const origin = resolveSignedFileOrigin(fastify.config, request); const signedUrl = `${origin}/api/reimbursements/${reimbursementId}/attachments/${attachment.id}/file?previewToken=${encodeURIComponent(previewToken)}`; await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "REIMBURSEMENT_ATTACHMENT_PREVIEW_URL_ISSUED", targetType: "reimbursement", targetId: reimbursementId, extra: { attachmentId: attachment.id, attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.supportingMaterial, sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal, reason, expiresAt, }, }), }); return sendData(reply, request, { signedUrl, expiresAt: new Date(expiresAt).toISOString(), originalName: attachment.original_name, mimeType: attachment.mime_type, fileSize: attachment.file_size, }); }, ); fastify.post( "/reimbursements/:id/materials/preview-bundle", { preHandler: fastify.authenticate }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); const parsedBody = REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA.safeParse(request.body ?? {}); if (!parsedBody.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400 }); } const reimbursement = await fastify.prisma.reimbursementForm.findUnique({ where: { id: reimbursementId }, select: { id: true, owner_id: true, submitter_id: true, invoice_pool_items: { orderBy: [{ created_at: "asc" }, { id: "asc" }], select: { id: true, storage_path: true, mime_type: true, file_size: true, file_sha256: true, }, }, attachments: { orderBy: [{ created_at: "asc" }, { id: "asc" }], select: { id: true, storage_path: true, mime_type: true, file_size: true, file_sha256: true, governance_status: true, sensitivity_level: true, attachment_type: true, download_policy: true, }, }, }, }); if (!reimbursement) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } await ensureOwnCanViewAllOrWorkflowReviewer(fastify, request, reimbursement); const reason = sanitizeDownloadReason(parsedBody.data.reason); if (reimbursement.attachments.some((attachment) => requiresDownloadReason(attachment)) && !reason) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { reason: "required" }, }); } const materials = [ ...reimbursement.invoice_pool_items.map((invoice, index) => ({ id: invoice.id, materialType: "invoice", materialIndex: index + 1, storagePath: invoice.storage_path, mimeType: invoice.mime_type, fileSize: invoice.file_size, fileSha256: invoice.file_sha256, })), ...reimbursement.attachments.map((attachment, index) => ({ id: attachment.id, materialType: "attachment", materialIndex: index + 1, storagePath: attachment.storage_path, mimeType: attachment.mime_type, fileSize: attachment.file_size, fileSha256: attachment.file_sha256, })), ]; if (materials.some((material) => !ATTACHMENT_MIME_ALLOWLIST.has(material.mimeType))) { throw new AppError({ code: "MATERIAL_PREVIEW_001", message: "部分材料格式暂不支持连续预览", statusCode: 422, }); } const expiresAt = Date.now() + resolvePreviewUrlTtlMs(fastify.config); let created; try { created = await materialPreviewStore.createBundle({ actorId: request.employee.id, reimbursementId, materials, expiresAt, readSource: (material) => readFileBuffer({ config: fastify.config, storagePath: material.storagePath, }), }); } catch (error) { throwMaterialPreviewAppError(error); } const origin = resolveSignedFileOrigin(fastify.config, request); const previewToken = signPayload({ purpose: "reimbursement_material_preview", reimbursementId, bundleId: created.bundleId, actorId: request.employee.id, exp: expiresAt, }, fastify.config.jwtSecret); const signedUrl = `${origin}/api/reimbursements/${reimbursementId}/materials/preview/${created.bundleId}?previewToken=${encodeURIComponent(previewToken)}`; try { await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "REIMBURSEMENT_MATERIAL_PREVIEW_BUNDLE_ISSUED", targetType: "reimbursement", targetId: reimbursementId, extra: { invoiceCount: reimbursement.invoice_pool_items.length, attachmentCount: reimbursement.attachments.length, pageCount: created.bundle.pageCount, reason, expiresAt, }, }), }); } catch (error) { materialPreviewStore.deleteBundle?.(created.bundleId); throw error; } return sendData(reply, request, { expiresAt: new Date(expiresAt).toISOString(), signedUrl, originalName: "reimbursement-materials-preview.pdf", mimeType: "application/pdf", fileSize: created.fileSize, pageCount: created.bundle.pageCount, }); }, ); fastify.get( "/reimbursements/:id/materials/preview/:bundleId", {}, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); const parsedBundleId = UUID_SCHEMA.safeParse(request.params?.bundleId); const previewToken = typeof request.query?.previewToken === "string" ? request.query.previewToken : ""; const verified = previewToken ? verifyToken(previewToken, fastify.config.jwtSecret) : { ok: false }; const payload = verified.ok ? verified.payload : null; const parsedActorId = UUID_SCHEMA.safeParse(payload?.actorId); if ( !parsedBundleId.success || !verified.ok || payload?.purpose !== "reimbursement_material_preview" || payload?.reimbursementId !== reimbursementId || payload?.bundleId !== parsedBundleId.data || typeof payload?.exp !== "number" || payload.exp <= Date.now() || !parsedActorId.success ) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } const resolved = materialPreviewStore.getBundle({ bundleId: parsedBundleId.data }); if ( !resolved || resolved.bundle.reimbursementId !== reimbursementId || resolved.bundle.actorId !== parsedActorId.data || resolved.bundle.expiresAt !== payload.exp ) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: parsedActorId.data, action: "REIMBURSEMENT_MATERIAL_PREVIEWED", targetType: "reimbursement", targetId: reimbursementId, extra: { pageCount: resolved.bundle.pageCount, }, }), }); reply.type("application/pdf"); reply.header("cache-control", "private, no-store"); reply.header("referrer-policy", "no-referrer"); reply.header("content-disposition", 'inline; filename="reimbursement-materials-preview.pdf"'); reply.header("content-length", String(resolved.artifact.buffer.length)); return reply.send(resolved.artifact.buffer); }, ); fastify.post( "/reimbursements/:id/items", { preHandler: fastify.requirePermission("expense.edit_own"), }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); const body = parseItemBody(request.body); 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]; ensureReimbursementParticipant(request, reimbursement); if (!["draft", "rejected"].includes(reimbursement.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_item_edit_status" }, }); } const expenseType = await tx.expenseType.findUnique({ where: { id: body.expenseTypeId }, select: { id: true, enabled: true, category: true }, }); if (!expenseType || !expenseType.enabled) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { expenseTypeId: "invalid_or_disabled" }, }); } const created = await tx.reimbursementItem.create({ data: { reimbursement_id: reimbursementId, expense_type_id: body.expenseTypeId, expense_date: parseDateOnlyString(body.expenseDate), amount: body.amount, description: body.description, asset_prior_approval_basis: expenseType.category === "assets" ? body.assetPriorApprovalBasis?.trim() || null : null, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, expense_type_id: true, expense_date: true, amount: true, description: true, asset_prior_approval_basis: true, }, }); const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id); return { item: created, totalAmount: nextTotal, }; }); return sendData(reply, request, { item: { id: result.item.id, expenseTypeId: result.item.expense_type_id, expenseDate: result.item.expense_date.toISOString().slice(0, 10), amount: toMoneyString(result.item.amount), description: result.item.description, assetPriorApprovalBasis: result.item.asset_prior_approval_basis ?? null, }, totalAmount: toMoneyString(result.totalAmount), }); }, ); fastify.patch( "/reimbursements/:id/items/:itemId", { preHandler: fastify.requirePermission("expense.edit_own"), }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); const parsedItemId = UUID_SCHEMA.safeParse(request.params?.itemId); if (!parsedItemId.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { itemId: "invalid_uuid" }, }); } const parsedBody = REIMBURSEMENT_ITEM_BODY_SCHEMA.partial().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) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { body: "empty" }, }); } if (typeof parsedBody.data.amount === "string") { const amountNumber = Number(parsedBody.data.amount); if (!Number.isFinite(amountNumber) || amountNumber <= 0) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { amount: "must_be_positive" }, }); } } 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]; ensureReimbursementParticipant(request, reimbursement); if (!["draft", "rejected"].includes(reimbursement.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_item_edit_status" }, }); } const item = await tx.reimbursementItem.findFirst({ where: { id: parsedItemId.data, reimbursement_id: reimbursementId, }, select: { id: true, expense_type_id: true, asset_prior_approval_basis: true }, }); if (!item) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const targetExpenseTypeId = parsedBody.data.expenseTypeId ?? item.expense_type_id; const expenseType = await tx.expenseType.findUnique({ where: { id: targetExpenseTypeId }, select: { id: true, enabled: true, category: true }, }); if (!expenseType || !expenseType.enabled) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { expenseTypeId: "invalid_or_disabled" }, }); } const targetApprovalBasis = parsedBody.data.assetPriorApprovalBasis === undefined ? item.asset_prior_approval_basis : parsedBody.data.assetPriorApprovalBasis; const updated = await tx.reimbursementItem.update({ where: { id: parsedItemId.data }, data: { ...(typeof parsedBody.data.expenseTypeId === "string" ? { expense_type_id: parsedBody.data.expenseTypeId } : {}), ...(typeof parsedBody.data.expenseDate === "string" ? { expense_date: parseDateOnlyString(parsedBody.data.expenseDate) } : {}), ...(typeof parsedBody.data.amount === "string" ? { amount: parsedBody.data.amount } : {}), ...(typeof parsedBody.data.description === "string" ? { description: parsedBody.data.description.trim() } : {}), asset_prior_approval_basis: expenseType.category === "assets" ? targetApprovalBasis?.trim() || null : null, updated_by: request.employee.id, }, select: { id: true, expense_type_id: true, expense_date: true, amount: true, description: true, asset_prior_approval_basis: true, }, }); const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id); return { item: updated, totalAmount: nextTotal, }; }); return sendData(reply, request, { item: { id: result.item.id, expenseTypeId: result.item.expense_type_id, expenseDate: result.item.expense_date.toISOString().slice(0, 10), amount: toMoneyString(result.item.amount), description: result.item.description, assetPriorApprovalBasis: result.item.asset_prior_approval_basis ?? null, }, totalAmount: toMoneyString(result.totalAmount), }); }, ); fastify.delete( "/reimbursements/:id/items/:itemId", { preHandler: fastify.requirePermission("expense.edit_own"), }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); const parsedItemId = UUID_SCHEMA.safeParse(request.params?.itemId); if (!parsedItemId.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { itemId: "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]; ensureReimbursementParticipant(request, reimbursement); if (!["draft", "rejected"].includes(reimbursement.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_item_edit_status" }, }); } const deleted = await tx.reimbursementItem.deleteMany({ where: { id: parsedItemId.data, reimbursement_id: reimbursementId, }, }); if (deleted.count === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id); return { totalAmount: nextTotal }; }); return sendData(reply, request, { outcome: "deleted", totalAmount: toMoneyString(result.totalAmount), }); }, ); fastify.patch( "/reimbursements/:id/invoices/:invoiceId/item-link", { preHandler: fastify.requirePermission("expense.edit_own"), }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); const parsedInvoiceId = UUID_SCHEMA.safeParse(request.params?.invoiceId); if (!parsedInvoiceId.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { invoiceId: "invalid_uuid" }, }); } const parsedBody = REIMBURSEMENT_INVOICE_ITEM_LINK_BODY_SCHEMA.safeParse(request.body ?? {}); if (!parsedBody.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsedBody.error.flatten(), }); } const invoiceId = parsedInvoiceId.data; const linkedItemId = parsedBody.data.linkedItemId; const response = await fastify.prisma.$transaction(async (tx) => { const invoiceRows = await tx.$queryRaw` SELECT id, status, linked_reimbursement_id, linked_item_id FROM "invoice_pool" WHERE id = ${invoiceId}::uuid FOR UPDATE `; const invoice = Array.isArray(invoiceRows) ? invoiceRows[0] ?? null : null; 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]; ensureReimbursementParticipant(request, reimbursement); if (!["draft", "rejected"].includes(reimbursement.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { reimbursement: "invalid_status_for_invoice_item_link" }, }); } if (!invoice) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if ( invoice.status !== "bound" || invoice.linked_reimbursement_id !== reimbursementId || invoice.linked_item_id != null ) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { invoice: "invalid_unlinked_item_state" }, }); } 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: 409, details: { linkedItemId: "item_not_in_reimbursement" }, }); } await tx.invoicePool.update({ where: { id: invoiceId }, data: { linked_item_id: linkedItem.id, updated_by: request.employee.id, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "REIMBURSEMENT_INVOICE_ITEM_LINKED", targetType: "invoice_pool", targetId: invoiceId, extra: { reimbursementId, linkedItemId: linkedItem.id }, }), }); return { invoiceId, reimbursementId, linkedItemId: linkedItem.id }; }); return sendData(reply, request, response); }, ); fastify.post( "/reimbursements/:id/precheck", { preHandler: fastify.requirePermission("expense.submit"), }, 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 }, }); if (!reimbursement) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } ensureOwnOrCanViewAll(fastify, request, reimbursement); const precheck = await resolveEntityPrecheck({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, entityType: "reimbursement", entityId: reimbursementId, actorDepartmentId: request.employee.department_id ?? null, }); return sendData(reply, request, precheck); }, ); fastify.post( "/reimbursements/:id/submit", { preHandler: fastify.requirePermission("expense.submit"), }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); const submitResult = await fastify.prisma.$transaction(async (tx) => { let budgetCheck = null; const reimbursementRows = await tx.$queryRaw` SELECT id, owner_id, submitter_id, department_id, reimbursement_date, status, form_number, total_amount, linked_travel_request_id, travel_purpose, travel_location, travel_start_date, travel_end_date 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]; ensureReimbursementParticipant(request, reimbursement); if (!["draft", "rejected"].includes(reimbursement.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_submit_status" }, }); } const reimbursementItems = await tx.reimbursementItem.findMany({ where: { reimbursement_id: reimbursementId }, orderBy: [{ created_at: "asc" }, { id: "asc" }], select: { id: true, amount: true, expense_type: { select: { code: true, category: true, requires_invoice: true }, }, invoice_pool_items: { where: { status: "bound" }, select: { id: true, total_amount: true, ocr_attempts: true, ocr_last_requested_at: true, ocr_last_error: true, ocr_completed_at: true, }, }, }, }); if (!reimbursementItems.length) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { items: "at_least_one_required" }, }); } const invalidAmountItems = reimbursementItems .filter((item) => !Number.isFinite(Number(item.amount)) || Number(item.amount) <= 0) .map((item) => item.id); if (invalidAmountItems.length) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { items: "positive_amount_required", itemIds: invalidAmountItems }, }); } const invoiceGuardGate = await evaluateFeatureFlag({ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.reimbursementInvoiceGuard, departmentId: reimbursement.department_id ?? null, strictDepartment: true, }); const manualReviewItemIds = reimbursementItems .filter((item) => item.expense_type.requires_invoice) .filter((item) => item.invoice_pool_items.some((invoice) => ( resolveInvoiceAmountEvidence({ totalAmount: invoice.total_amount, ocrAttempts: invoice.ocr_attempts, ocrLastRequestedAt: invoice.ocr_last_requested_at, ocrCompletedAt: invoice.ocr_completed_at, ocrLastError: invoice.ocr_last_error, }) === INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview ))) .map((item) => item.id); if (invoiceGuardGate.enabled) { const unlinkedBoundInvoices = await tx.invoicePool.findMany({ where: { linked_reimbursement_id: reimbursementId, linked_item_id: null, status: "bound", }, select: { id: true }, }); const missingInvoiceItemIds = []; const amountUnavailableItems = []; const amountExceededItems = []; for (const item of reimbursementItems) { if (!item.expense_type.requires_invoice) continue; if (!item.invoice_pool_items.length) { missingInvoiceItemIds.push(item.id); continue; } const evidenceByInvoice = item.invoice_pool_items.map((invoice) => ({ invoice, status: resolveInvoiceAmountEvidence({ totalAmount: invoice.total_amount, ocrAttempts: invoice.ocr_attempts, ocrLastRequestedAt: invoice.ocr_last_requested_at, ocrCompletedAt: invoice.ocr_completed_at, ocrLastError: invoice.ocr_last_error, }), })); const failedInvoiceIds = evidenceByInvoice .filter(({ status }) => status === INVOICE_AMOUNT_EVIDENCE_STATUS.ocrFailed) .map(({ invoice }) => invoice.id); if (failedInvoiceIds.length) { amountUnavailableItems.push({ itemId: item.id, invoiceIds: failedInvoiceIds }); continue; } if (evidenceByInvoice.some( ({ status }) => status === INVOICE_AMOUNT_EVIDENCE_STATUS.manualReview, )) { continue; } const invoiceTotal = item.invoice_pool_items.reduce( (sum, invoice) => sum.plus(invoice.total_amount), new Prisma.Decimal(0), ); if (new Prisma.Decimal(item.amount).gt(invoiceTotal)) { amountExceededItems.push({ itemId: item.id, declaredAmount: toMoneyString(item.amount), invoiceTotal: invoiceTotal.toFixed(2), }); } } if ( unlinkedBoundInvoices.length || missingInvoiceItemIds.length || amountUnavailableItems.length || amountExceededItems.length ) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { items: "invoice_item_guard_failed", unlinkedInvoiceCount: unlinkedBoundInvoices.length, unlinkedInvoiceIds: unlinkedBoundInvoices.map((invoice) => invoice.id), missingInvoiceItemIds, amountUnavailableItems, amountExceededItems, }, }); } } reimbursement.total_amount = await recomputeTotalAmount( tx, reimbursementId, request.employee.id, ); ensureNonNegativeAmount(reimbursement.total_amount); const travelItem = await tx.reimbursementItem.findFirst({ where: { reimbursement_id: reimbursementId, expense_type: { category: "travel" }, }, select: { id: true }, }); if (travelItem || reimbursement.linked_travel_request_id) { const missingTravelFields = [ ["travelPurpose", reimbursement.travel_purpose], ["travelLocation", reimbursement.travel_location], ["travelStartDate", reimbursement.travel_start_date], ["travelEndDate", reimbursement.travel_end_date], ].filter(([, value]) => !value).map(([field]) => field); if (missingTravelFields.length) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { travelSummary: "incomplete", missingFields: missingTravelFields }, }); } } const activeInstance = await tx.workflowInstance.findFirst({ where: { entity_type: "reimbursement", entity_id: reimbursementId, current_status: "pending", }, select: { id: true }, }); if (activeInstance) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { submit: "already_in_review" }, }); } const boundInvoiceCount = await tx.invoicePool.count({ where: { linked_reimbursement_id: reimbursementId, status: "bound", }, }); const requiresAnyInvoice = reimbursementItems.some( (item) => item.expense_type.requires_invoice, ); if (!invoiceGuardGate.enabled && requiresAnyInvoice && boundInvoiceCount < 1) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { invoices: "bound_invoice_required" }, }); } const authoritativePrecheck = await resolveEntityPrecheck({ prisma: tx, config: fastify.config, logger: fastify.log, entityType: "reimbursement", entityId: reimbursementId, actorDepartmentId: request.employee.department_id ?? null, }); if (authoritativePrecheck.decision === "block") { throw buildPrecheckBlockedError(authoritativePrecheck); } const standardViolations = listExceededStandardViolations(authoritativePrecheck); const standardWarnings = listExceededStandardViolations(authoritativePrecheck, "warn"); const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, { entityType: "reimbursement", entityId: reimbursementId, actorId: request.employee.id, precheck: authoritativePrecheck, }); const budgetPool = await resolveApplicableBudgetPool(tx, reimbursement); if (budgetPool) { const allocatedAmount = Number(budgetPool.allocated_amount ?? 0); const usedAmount = Number(budgetPool.used_amount ?? 0); const currentAmount = Number(reimbursement.total_amount ?? 0); const nextUsed = usedAmount + currentAmount; const usagePercent = allocatedAmount > 0 ? (nextUsed / allocatedAmount) * 100 : 0; const budgetPrecheckGate = await evaluateFeatureFlag({ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.budgetPrecheck, departmentId: reimbursement.department_id ?? request.employee.department_id ?? null, strictDepartment: false, }); const fallbackControlMode = normalizeBudgetControlMode(fastify.config); const controlMode = budgetPrecheckGate.enabled ? authoritativePrecheck.matchedBudgetControlRule?.controlMode ?? authoritativePrecheck.budgetHint?.controlMode ?? fallbackControlMode : fallbackControlMode; const shouldBlockByLegacyMode = !budgetPrecheckGate.enabled && controlMode === "block" && nextUsed > allocatedAmount; budgetCheck = { budgetId: budgetPool.id, budgetCode: budgetPool.code, controlMode, usagePercent: Number(usagePercent.toFixed(2)), exceededThreshold: usagePercent >= budgetPool.warning_threshold_percent, exceededBudget: nextUsed > allocatedAmount, }; if (usagePercent >= budgetPool.warning_threshold_percent) { await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "BUDGET_THRESHOLD_HIT", targetType: "budget_pool", targetId: budgetPool.id, extra: { reimbursementId, formNumber: reimbursement.form_number ?? null, usagePercent: Number(usagePercent.toFixed(2)), threshold: budgetPool.warning_threshold_percent, controlMode, }, }), }); } if (shouldBlockByLegacyMode) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { budget: "exceeded_block_mode" }, }); } await tx.budgetLedgerEntry.create({ data: { budget_pool_id: budgetPool.id, reimbursement_id: reimbursementId, kind: shouldBlockByLegacyMode ? "blocked" : "submit", amount: reimbursement.total_amount, note: `submit:${reimbursement.form_number ?? reimbursementId}`, created_by: request.employee.id, }, }); await tx.budgetPool.update({ where: { id: budgetPool.id }, data: { used_amount: nextUsed.toFixed(2), updated_by: request.employee.id, }, }); } const workflowDefinition = await tx.workflowDefinition.findFirst({ where: { entity_type: "reimbursement", is_active: true, }, orderBy: { version: "desc" }, select: { id: true, }, }); if (!workflowDefinition) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { workflow: "missing_definition" }, }); } const approvalRule = await loadApprovalRuleForAmount(tx, reimbursement.total_amount); const workflowSteps = await buildApprovalWorkflowSnapshot({ tx, approvalRule, ownerId: reimbursement.owner_id, logger: fastify.log, }); const { actionable, skipped } = await resolveActionableStep({ tx, steps: workflowSteps, startIndex: 0, ownerId: reimbursement.owner_id, logger: fastify.log, blockOnUnresolvedAssignee: true, }); if (!actionable) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { workflow: "no_actionable_approver", }, }); } let formNumber = reimbursement.form_number; if (!formNumber) { const issued = await issueDocumentNumber(tx, { documentType: "reimbursement", actorId: request.employee.id, timeZone: fastify.config.appTimezone, }); formNumber = issued.documentNumber; } await tx.reimbursementForm.update({ where: { id: reimbursementId }, data: { form_number: formNumber, status: "reviewing", updated_by: request.employee.id, }, }); const workflowInstance = await tx.workflowInstance.create({ data: { workflow_id: workflowDefinition.id, entity_type: "reimbursement", entity_id: reimbursementId, reimbursement_id: reimbursementId, submitter_id: request.employee.id, current_status: "pending", current_step_id: actionable.step.id, context_data: { owner_id: reimbursement.owner_id, department_id: reimbursement.department_id, workflow_steps_snapshot: workflowSteps, approval_rule_id: approvalRule.id, standardViolations, budgetCheck, budgetCheckRecorded: true, }, context_department_id: reimbursement.department_id, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, current_step_id: true, }, }); for (const skippedStep of skipped) { await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, actor_id: request.employee.id, action: "skip", payload: { step_id: skippedStep.step.id, reason: skippedStep.reason, owner_id: reimbursement.owner_id, depth_checked: skippedStep.depthChecked, }, }, }); } const workflowTask = await tx.workflowTask.create({ data: { instance_id: workflowInstance.id, step_id: actionable.step.id, step_name: actionable.step.name ?? actionable.step.id, assignee_id: actionable.assigneeId, assignee_role: actionable.assigneeRole, }, select: { id: true, assignee_id: true, assignee_role: true, }, }); await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, task_id: workflowTask.id, actor_id: request.employee.id, action: "start", payload: { step_id: actionable.step.id, approval_rule_id: approvalRule.id, skipped_step_count: skipped.length, }, }, }); if (reimbursement.submitter_id !== reimbursement.owner_id) { await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "REIMBURSEMENT_PROXY_SUBMITTED", targetType: "reimbursement_form", targetId: reimbursementId, extra: { ownerEmployeeId: reimbursement.owner_id, submitterEmployeeId: reimbursement.submitter_id, formNumber, }, }), }); } return { response: { reimbursementId, formNumber, workflowInstanceId: workflowInstance.id, currentTaskId: workflowTask.id, status: "reviewing", budget: budgetCheck, precheckDecision: authoritativePrecheck.decision, standardWarnings, openedRiskFlagCount: openedRiskFlags.length, invoiceAmountManualReviewRequired: manualReviewItemIds.length > 0, manualReviewItemCount: manualReviewItemIds.length, }, sideEffectsContext: { reimbursementId, formNumber, workflowTaskId: workflowTask.id, assigneeId: workflowTask.assignee_id, assigneeRole: workflowTask.assignee_role, actorId: request.employee.id, }, }; }); await finalizeReimbursementSubmitSideEffects({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, ...submitResult.sideEffectsContext, }); return sendData(reply, request, submitResult.response); }, ); fastify.post( "/reimbursements/:id/withdraw", { preHandler: fastify.requirePermission("expense.withdraw"), }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); const { comment } = parseWithdrawBody(request.body); let response = null; 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 isApplicant = reimbursement.owner_id === request.employee.id || reimbursement.submitter_id === request.employee.id; if (!isApplicant) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { withdraw: "only_owner_or_submitter" }, }); } if (reimbursement.status !== "reviewing") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_withdraw_status" }, }); } const activeInstance = await tx.workflowInstance.findFirst({ where: { entity_type: "reimbursement", entity_id: reimbursementId, current_status: "pending", }, orderBy: { created_at: "desc" }, select: { id: true }, }); if (!activeInstance) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { withdraw: "no_pending_instance" }, }); } const processedTaskCount = await tx.workflowTask.count({ where: { instance_id: activeInstance.id, status: { in: ["completed", "rejected"] }, }, }); if (processedTaskCount > 0) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { withdraw: "already_processed_by_approver" }, }); } const now = new Date(); await tx.workflowTask.updateMany({ where: { instance_id: activeInstance.id, status: "pending", }, data: { status: "cancelled", completed_at: now, completed_by: request.employee.id, }, }); await tx.workflowInstance.update({ where: { id: activeInstance.id }, data: { current_status: "cancelled", completed_at: now, updated_by: request.employee.id, }, }); const unbound = await tx.invoicePool.updateMany({ where: { linked_reimbursement_id: reimbursementId, status: "bound", }, data: { status: "available", linked_reimbursement_id: null, linked_item_id: null, updated_by: request.employee.id, }, }); await tx.reimbursementForm.update({ where: { id: reimbursementId }, data: { status: "draft", updated_by: request.employee.id, }, }); await tx.workflowHistory.create({ data: { instance_id: activeInstance.id, actor_id: request.employee.id, action: "withdraw", comment: comment ?? null, payload: { reimbursement_id: reimbursementId, }, }, }); response = { reimbursementId, outcome: "withdrawn", workflowInstanceId: activeInstance.id, reimbursementStatus: "draft", unboundInvoiceCount: unbound.count, }; }); return sendData(reply, request, response); }, ); fastify.post( "/reimbursements/:id/link-project", { preHandler: fastify.requirePermission("expense.edit_own"), }, async (request, reply) => { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectLinking, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { featureFlag: "project_linking_v1_disabled", reason: gate.reason } }); } const reimbursementId = parseReimbursementId(request.params?.id); const body = parseReimbursementProjectLinkBody(request.body); const current = await fastify.prisma.reimbursementForm.findUnique({ where: { id: reimbursementId }, select: { id: true, owner_id: true, submitter_id: true, project_id: true, status: true }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } ensureReimbursementParticipant(request, current); if (!["draft", "rejected"].includes(current.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { reimbursement: "invalid_status_for_project_link" }, }); } const updated = await fastify.prisma.reimbursementForm.update({ where: { id: reimbursementId }, data: { project_id: body.projectId ?? null, updated_by: request.employee.id }, select: { id: true, project_id: true, project: { select: { id: true, project_no: true, name: true } } }, }); return sendData(reply, request, { item: { reimbursementId: updated.id, projectId: updated.project_id ?? null, project: updated.project ? { id: updated.project.id, projectNo: updated.project.project_no, name: updated.project.name } : null, }, }); }, ); fastify.post( "/reimbursements/:id/pay", { preHandler: fastify.requirePermission("expense.finance.pay"), }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); const payInput = parsePayBody(request.body); let response = null; const emailTasks = []; const dispatchTasks = []; await fastify.prisma.$transaction(async (tx) => { const reimbursementRows = await tx.$queryRaw` SELECT id, status, form_number, owner_id, submitter_id, department_id, payment_voucher_no, payment_date, payment_channel, paid_at, paid_by 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]; await ensureCutoverAllowed({ prisma: tx, config: fastify.config, request, domain: "expense", departmentId: reimbursement.department_id ?? request.employee.department_id ?? null, }); const paymentBatchGate = await evaluateFeatureFlag({ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.paymentBatch, departmentId: reimbursement.department_id ?? request.employee.department_id ?? null, strictDepartment: true, }); const activePaymentBatchItem = await tx.paymentBatchItem.findFirst({ where: { entity_id: reimbursementId, payment_batch: { entity_type: "reimbursement", status: { not: "cancelled" }, }, }, select: { id: true }, }); if (paymentBatchGate.enabled || activePaymentBatchItem) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { payment: "payment_batch_v1_required", ...(activePaymentBatchItem ? { paymentBatch: "active_batch_exists" } : {}), }, }); } if (!["approved", "paid"].includes(reimbursement.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_pay_status" }, }); } const existingPaymentDate = toDateOnlyString(reimbursement.payment_date); if (reimbursement.status === "paid") { const hasVoucherConflict = payInput.paymentVoucherNo != null && payInput.paymentVoucherNo !== (reimbursement.payment_voucher_no ?? null); const hasPaymentDateConflict = payInput.paymentDate != null && payInput.paymentDate !== existingPaymentDate; const hasChannelConflict = payInput.paymentChannel != null && payInput.paymentChannel !== (reimbursement.payment_channel ?? null); if (hasVoucherConflict || hasPaymentDateConflict || hasChannelConflict) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { payment: "already_paid_conflict" }, }); } } const now = new Date(); const archivedResult = await tx.invoicePool.updateMany({ where: { linked_reimbursement_id: reimbursementId, status: "bound", }, data: { status: "archived", archived_reason: "used_complete", archived_at: now, updated_by: request.employee.id, }, }); if (reimbursement.status === "approved") { await tx.reimbursementForm.update({ where: { id: reimbursementId }, data: { status: "paid", payment_voucher_no: payInput.paymentVoucherNo ?? null, payment_date: payInput.paymentDate ? parseDateOnlyString(payInput.paymentDate) : null, payment_channel: payInput.paymentChannel ?? null, paid_at: now, paid_by: request.employee.id, updated_by: request.employee.id, }, }); const refreshed = await tx.reimbursementForm.findUnique({ where: { id: reimbursementId }, select: { id: true, form_number: true, owner_id: true, submitter_id: true, department_id: true, total_amount: true, payment_voucher_no: true, payment_date: true, paid_at: true, owner: { select: { legal_entity: { select: { code: true, }, }, }, }, submitter: { select: { legal_entity: { select: { code: true, }, }, }, }, }, }); if (refreshed) { const departmentId = refreshed.department_id ?? reimbursement.department_id ?? request.employee.department_id ?? null; const erpGate = await evaluateFeatureFlag({ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.erpSync, departmentId, strictDepartment: true, }); if (erpGate.enabled) { await enqueueReimbursementJournalExport(tx, fastify.config, { reimbursement: refreshed, actorId: request.employee.id, }); } else { await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG", targetType: "reimbursement_form", targetId: refreshed.id, extra: { featureFlag: FEATURE_FLAG_KEYS.erpSync, departmentId, reason: erpGate.reason, }, }), }); } } try { const recipients = Array.from( new Set([reimbursement.owner_id, reimbursement.submitter_id].filter(Boolean)), ); const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.reimbursementPaid, title: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`, body: `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`, targetType: "reimbursement_form", targetId: reimbursementId, }); dispatchTasks.push({ notifications, type: NOTIFICATION_TYPES.reimbursementPaid, title: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`, body: `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`, targetType: "reimbursement_form", targetId: reimbursementId, targetPath: `/reimbursements/${reimbursementId}`, actorId: request.employee.id, }); emailTasks.push({ type: NOTIFICATION_TYPES.reimbursementPaid, recipientEmployeeIds: recipients, subject: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`, body: `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`, targetPath: `/reimbursements/${reimbursementId}`, }); } catch (error) { fastify.log.error( { err: error, reimbursementId }, "Failed to create reimbursement paid notifications", ); } } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "REIMBURSEMENT_PAYMENT_MARKED", targetType: "reimbursement_form", targetId: reimbursementId, extra: { formNumber: reimbursement.form_number ?? null, paymentVoucherNo: reimbursement.status === "paid" ? reimbursement.payment_voucher_no ?? null : payInput.paymentVoucherNo ?? null, paymentDate: reimbursement.status === "paid" ? existingPaymentDate : payInput.paymentDate ?? null, paymentChannel: reimbursement.status === "paid" ? reimbursement.payment_channel ?? null : payInput.paymentChannel ?? null, paymentNote: payInput.paymentNote ?? null, idempotent: reimbursement.status === "paid", archivedInvoiceCount: archivedResult.count, }, }), }); response = { reimbursementId, outcome: "paid", reimbursementStatus: "paid", // archivedInvoiceCount=0 is valid for idempotent pay or when no bound invoices remain. archivedInvoiceCount: archivedResult.count, payment: { paymentVoucherNo: reimbursement.status === "paid" ? reimbursement.payment_voucher_no ?? null : payInput.paymentVoucherNo ?? null, paymentDate: reimbursement.status === "paid" ? existingPaymentDate : payInput.paymentDate ?? null, paymentChannel: reimbursement.status === "paid" ? reimbursement.payment_channel ?? null : payInput.paymentChannel ?? null, paidAt: reimbursement.status === "paid" ? reimbursement.paid_at?.toISOString?.() ?? null : now.toISOString(), paidBy: reimbursement.status === "paid" ? reimbursement.paid_by ?? null : request.employee.id, paymentNote: payInput.paymentNote ?? null, idempotent: reimbursement.status === "paid", }, }; }); for (const task of emailTasks) { void sendNotificationEmailSafe({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, ...task, }); } await flushNotificationDispatchTasks({ prisma: fastify.prisma, config: fastify.config, logger: fastify.log, requestId: request.id, tasks: dispatchTasks, }); return sendData(reply, request, response); }, ); } module.exports = reimbursementRoutes; module.exports.__testables = { finalizeReimbursementSubmitSideEffects, };