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 } = require("../utils/delegation"); const { createNotifications, resolveRecipientsForAssignee, } = require("../services/notifications"); const { buildPendingTaskCopy, buildWorkflowEntityMeta, } = require("../services/notifications/workflowCopy"); const { enqueueImNotifications } = require("../services/notifications/imEnqueue"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { sendNotificationEmailSafe } = require("../services/email"); const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { ensureCutoverAllowed } = require("../services/cutover"); const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool"); const { buildPersistedRiskFlag, buildReimbursementPrecheck, createRiskFlagsFromPrecheck, } = 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), }); 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 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, }; } 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 buildDisabledPrecheck(entityType, entityId) { return { entityType, entityId, decision: "pass", matchedBudgetControlRule: null, budgetHint: null, warnings: [], blockingReasons: [], riskFlags: [], nextActions: ["功能未开启,沿用当前提交流程"], }; } function buildPrecheckBlockedError(precheck) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { precheck: precheck.blockingReasons, }, }); } function buildSkippedNotificationResult(reason) { return { skipped: true, reason }; } function buildSkippedImResult(reason) { return { queued: 0, duplicated: 0, matchedChannels: [], skippedReason: reason }; } 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 = createNotifications, enqueueIm = enqueueImNotifications, 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"), im: buildSkippedImResult("recipient_resolution_failed"), email: buildSkippedEmailResult("recipient_resolution_failed"), }; } if (!recipientEmployeeIds.length) { return { recipientEmployeeIds: [], notification: buildSkippedNotificationResult("no_recipients"), im: buildSkippedImResult("no_recipients"), email: buildSkippedEmailResult("no_recipients"), }; } let notification = buildSkippedNotificationResult("not_attempted"); try { notification = await createAppNotifications(prisma, { recipientEmployeeIds, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: "workflow_task", targetId: workflowTaskId, }); } catch (error) { logger?.error?.( { err: error, requestId, reimbursementId, workflowTaskId, recipients: recipientEmployeeIds.length, }, "Failed to create reimbursement submit notifications", ); notification = buildSkippedNotificationResult("notification_failed"); } let im = buildSkippedImResult("not_attempted"); try { im = await enqueueIm(prisma, { config, logger, recipientEmployeeIds, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: "workflow_task", targetId: workflowTaskId, targetPath, entityType: "reimbursement", entityId: reimbursementId, actorId, }); } catch (error) { logger?.error?.( { err: error, requestId, reimbursementId, workflowTaskId, recipients: recipientEmployeeIds.length, }, "Failed to enqueue reimbursement submit IM notifications", ); im = buildSkippedImResult("enqueue_im_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, im, 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 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 }, }, }, }), fastify.prisma.reimbursementForm.count({ where }), ]); return sendData(reply, request, { page: query.page, pageSize: query.pageSize, total, scope, items: items.map(mapReimbursementSummary), }); }, ); 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 reimbursement = await fastify.prisma.reimbursementForm.findUnique({ where: { id: reimbursementId }, select: { id: true, department_id: true }, }); if (!reimbursement) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.budgetPrecheck, departmentId: reimbursement.department_id ?? request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { return sendData(reply, request, buildDisabledPrecheck("reimbursement", reimbursementId)); } const precheck = await buildReimbursementPrecheck(fastify.prisma, { reimbursementId, config: fastify.config, logger: fastify.log, }); 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 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 authoritativePrecheck = budgetPrecheckGate.enabled ? await buildReimbursementPrecheck(tx, { reimbursementId, config: fastify.config, logger: fastify.log, }) : buildDisabledPrecheck("reimbursement", reimbursementId); 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 controlMode = normalizeBudgetControlMode(fastify.config); 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 (controlMode === "block" && nextUsed > allocatedAmount) { 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: controlMode === "block" && nextUsed > allocatedAmount ? "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 = []; 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)), ); await createNotifications(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.reimbursementPaid, title: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`, body: `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`, targetType: "reimbursement_form", targetId: reimbursementId, }); await enqueueImNotifications(tx, { config: fastify.config, logger: fastify.log, recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.reimbursementPaid, title: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`, body: `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`, targetType: "reimbursement_form", targetId: reimbursementId, targetPath: `/reimbursements/${reimbursementId}`, entityType: "reimbursement", entityId: 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, }); } return sendData(reply, request, response); }, ); } module.exports = reimbursementRoutes; module.exports.__testables = { finalizeReimbursementSubmitSideEffects, };