const { Prisma } = require("@prisma/client"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { currentDateString, parseDateOnlyString } = require("../utils/date"); const { 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 { 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 { buildWorkflowStepsFromApprovalRule, getWorkflowStepsSnapshot, resolveActionableStep, 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(), }); 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(), }); 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), }); 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 REIMBURSEMENT_STATUS_VALUES = ["draft", "reviewing", "approved", "paid", "rejected", "cancelled"]; const ROLE_LABELS = { manager: "主管", ceo: "CEO", finance: "财务", finance_mgr: "财务经理", administration: "行政", admin: "管理员", hr: "HR", }; 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, }); } } 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 ensureApplicantOrAdmin(request, reimbursement) { if (request.employee?.roleCodes?.includes("admin")) { return; } 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, }); } } 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 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 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: "invalid" }, }); } 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 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, 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) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { precheck: precheck.blockingReasons, }, }); } 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 createBodySchema = z.object({ ownerEmployeeId: z.string().uuid().optional(), reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), description: z.string().max(500).optional(), }); 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 parsed = createBodySchema.safeParse(request.body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } const reimbursementDate = parsed.data.reimbursementDate ?? currentDateString(fastify.config.appTimezone); const ownerEmployeeId = parsed.data.ownerEmployeeId ?? request.employee.id; const isProxyCreate = ownerEmployeeId !== request.employee.id; const reimbursement = await fastify.prisma.$transaction(async (tx) => { let departmentId = request.employee.department_id; let delegationGrantId = null; if (isProxyCreate) { 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" }, }); } delegationGrantId = delegation.id; departmentId = ownerEmployee.department_id; } const created = await tx.reimbursementForm.create({ data: { submitter_id: request.employee.id, owner_id: ownerEmployeeId, department_id: departmentId, reimbursement_date: parseDateOnlyString(reimbursementDate), description: parsed.data.description ?? null, 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, }, }); if (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, }, }), }); } return created; }); 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, 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), }); }, ); 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/: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, reimbursement_date: true, total_amount: true, status: true, description: true, project_id: 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 }, }, items: { orderBy: { expense_date: "desc" }, 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, }, }, }, }, invoice_pool_items: { select: { id: true, status: true, invoice_code: true, invoice_number: true, total_amount: 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, }); } ensureOwnOrCanViewAll(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 workflowHistory = pendingInstance ? await fastify.prisma.workflowHistory.findMany({ where: { instance_id: pendingInstance.id }, orderBy: { created_at: "asc" }, select: { action: true, payload: true, }, }) : []; const riskFlags = await fastify.prisma.entityRiskFlag.findMany({ where: { entity_type: "reimbursement", entity_id: reimbursementId, status: "open", }, orderBy: [{ created_at: "desc" }, { id: "desc" }], }); return sendData(reply, request, { id: reimbursement.id, formNumber: reimbursement.form_number, submitterId: reimbursement.submitter_id, ownerId: reimbursement.owner_id, departmentId: reimbursement.department_id, reimbursementDate: reimbursement.reimbursement_date.toISOString().slice(0, 10), totalAmount: toMoneyString(reimbursement.total_amount), status: reimbursement.status, description: reimbursement.description, projectId: reimbursement.project_id ?? null, project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null, 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, 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, } : null, })), budgetHint, invoices: reimbursement.invoice_pool_items.map((item) => ({ id: item.id, status: item.status, invoiceCode: item.invoice_code, invoiceNumber: item.invoice_number, totalAmount: toMoneyString(item.total_amount), })), pendingWorkflow: pendingInstance ? { instanceId: pendingInstance.id, stepId: pendingInstance.current_step_id, task: pendingTask, } : null, workflowProgress: pendingInstance ? buildWorkflowProgress(pendingInstance, workflowHistory) : null, riskFlags: riskFlags.map(buildPersistedRiskFlag), }); }, ); 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, status FROM "reimbursement_forms" WHERE id = ${reimbursementId}::uuid FOR UPDATE `; if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const reimbursement = reimbursementRows[0]; ensureApplicantOrAdmin(request, reimbursement); 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; return tx.reimbursementForm.update({ where: { id: reimbursementId }, data: { ...(body.reimbursementDate ? { reimbursement_date: parseDateOnlyString(body.reimbursementDate) } : {}), ...(body.description !== undefined ? { description: nextDescription } : {}), updated_by: request.employee.id, }, select: { id: true, status: true, reimbursement_date: true, description: true, total_amount: 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), }); }, ); 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]; ensureApplicantOrAdmin(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/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]; ensureApplicantOrAdmin(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 }, }); 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, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, expense_type_id: true, expense_date: true, amount: true, description: 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, }, 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: "invalid" }, }); } } const result = await fastify.prisma.$transaction(async (tx) => { const reimbursementRows = await tx.$queryRaw` SELECT id, owner_id, submitter_id, status FROM "reimbursement_forms" WHERE id = ${reimbursementId}::uuid FOR UPDATE `; if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const reimbursement = reimbursementRows[0]; ensureApplicantOrAdmin(request, reimbursement); if (!["draft", "rejected"].includes(reimbursement.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_item_edit_status" }, }); } const item = await tx.reimbursementItem.findFirst({ where: { id: parsedItemId.data, reimbursement_id: reimbursementId, }, select: { id: true }, }); if (!item) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } if (typeof parsedBody.data.expenseTypeId === "string") { const expenseType = await tx.expenseType.findUnique({ where: { id: parsedBody.data.expenseTypeId }, select: { id: true, enabled: true }, }); if (!expenseType || !expenseType.enabled) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { expenseTypeId: "invalid_or_disabled" }, }); } } 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() } : {}), updated_by: request.employee.id, }, select: { id: true, expense_type_id: true, expense_date: true, amount: true, description: 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, }, 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]; ensureApplicantOrAdmin(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.post( "/reimbursements/:id/precheck", { preHandler: fastify.requirePermission("expense.submit"), }, async (request, reply) => { const reimbursementId = parseReimbursementId(request.params?.id); 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 FROM "reimbursement_forms" WHERE id = ${reimbursementId}::uuid FOR UPDATE `; if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const reimbursement = reimbursementRows[0]; ensureApplicantOrAdmin(request, reimbursement); if (!["draft", "rejected"].includes(reimbursement.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_submit_status" }, }); } ensureNonNegativeAmount(reimbursement.total_amount); 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", }, }); if (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 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 = buildWorkflowStepsFromApprovalRule(approvalRule); const { actionable, skipped } = await resolveActionableStep({ tx, steps: workflowSteps, startIndex: 0, ownerId: reimbursement.owner_id, logger: fastify.log, }); 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, }, 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, openedRiskFlagCount: openedRiskFlags.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, }, }); 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", }; }); return sendData(reply, request, response); }, ); fastify.post( "/reimbursements/:id/link-project", { preHandler: fastify.authenticate, }, 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 = parseBody(z.object({ projectId: z.string().uuid().nullable() }).strict(), 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 }); } ensureOwnOrCanViewAll(fastify, request, current); 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, }); 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", ); } } 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, 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, };