const { AppError } = require("../utils/AppError"); const { resolveApplicableBudgetPoolByContext } = require("./budgetPool"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags"); const { buildApprovalWorkflowSnapshot, parseWorkflowSteps, resolveActionableStep, } = require("../utils/workflow"); const PRECHECK_DECISIONS = Object.freeze(["pass", "warn", "manual_review", "block"]); const PRECHECK_PERMISSION_CODES = Object.freeze({ reimbursement: "expense.submit", purchase_request: "procurement.submit", travel_request: "travel.submit", }); const BUDGET_RISK_CODES = Object.freeze(["budget_exceeded", "budget_threshold_exceeded"]); const RISK_LEVELS = Object.freeze({ low: "low", medium: "medium", high: "high", }); function buildNotFoundError() { return new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function toMoneyNumber(value) { const numeric = Number(value ?? 0); if (!Number.isFinite(numeric)) { return 0; } return Number(numeric.toFixed(2)); } function toMoneyString(value) { return toMoneyNumber(value).toFixed(2); } function buildBasePrecheck(entityType, entityId) { return { entityType, entityId, decision: "pass", matchedBudgetControlRule: null, budgetHint: null, standardChecks: [], warnings: [], blockingReasons: [], riskFlags: [], nextActions: ["可直接提交"], }; } function buildDisabledPrecheck(entityType, entityId) { return { entityType, entityId, decision: "pass", matchedBudgetControlRule: null, budgetHint: null, standardChecks: [], warnings: [], blockingReasons: [], riskFlags: [], nextActions: ["功能未开启,沿用当前提交流程"], }; } function buildIssue(code, title, detail) { return { code, title, detail, }; } function buildRiskFlagPreview(code, level, title, detail) { return { code, level, title, detail, willOpenOnSubmit: true, }; } function buildPersistedRiskFlag(record) { return { id: record.id, entityType: record.entity_type, entityId: record.entity_id, riskCode: record.risk_code, level: record.level, title: record.title, detail: record.detail ?? null, source: record.source, status: record.status, payloadJson: record.payload_json ?? null, createdAt: record.created_at?.toISOString?.() ?? null, createdBy: record.created_by ?? null, resolvedAt: record.resolved_at?.toISOString?.() ?? null, resolvedBy: record.resolved_by ?? null, }; } function normalizeFallbackControlMode(config) { const mode = String(config?.budgetControlMode ?? "warn").trim().toLowerCase(); if (mode === "block" || mode === "manual_review") { return mode; } return "warn"; } function computeRuleScopeRank(rule) { if (rule.cost_center_id && rule.legal_entity_id) { return 0; } if (rule.department_id) { return 1; } return 2; } function matchesBudgetControlRule(rule, context) { if (!rule?.enabled || rule?.archived_at) { return false; } if (rule.entity_type !== "all" && rule.entity_type !== context.entityType) { return false; } if (rule.department_id && rule.department_id !== context.departmentId) { return false; } if (rule.legal_entity_id && rule.legal_entity_id !== context.legalEntityId) { return false; } if (rule.cost_center_id && rule.cost_center_id !== context.costCenterId) { return false; } if (context.entityType !== "reimbursement" && rule.expense_type_id) { return false; } if (context.entityType === "reimbursement" && rule.expense_type_id) { return context.expenseTypeIds.includes(rule.expense_type_id); } return true; } async function resolveBudgetControlRule(tx, context) { const rules = await tx.budgetControlRule.findMany({ where: { enabled: true, archived_at: null, entity_type: { in: ["all", context.entityType] }, }, orderBy: [{ priority: "asc" }, { created_at: "asc" }], }); const matched = rules .filter((rule) => matchesBudgetControlRule(rule, context)) .sort((left, right) => { const scopeDiff = computeRuleScopeRank(left) - computeRuleScopeRank(right); if (scopeDiff !== 0) { return scopeDiff; } if (left.priority !== right.priority) { return left.priority - right.priority; } return new Date(left.created_at).getTime() - new Date(right.created_at).getTime(); })[0] ?? null; if (!matched) { return null; } return { id: matched.id, name: matched.name, controlMode: matched.control_mode, record: matched, }; } function buildBudgetHint(budgetPool, currentAmount, controlMode) { const allocatedAmount = toMoneyNumber(budgetPool?.allocated_amount); const usedAmount = toMoneyNumber(budgetPool?.used_amount); const amount = toMoneyNumber(currentAmount); const nextUsedAmount = toMoneyNumber(usedAmount + amount); const nextUsagePercent = allocatedAmount > 0 ? Number(((nextUsedAmount / allocatedAmount) * 100).toFixed(2)) : 0; return { budgetId: budgetPool.id, budgetCode: budgetPool.code, budgetName: budgetPool.name, controlMode, warningThresholdPercent: Number(budgetPool.warning_threshold_percent ?? 80), allocatedAmount: toMoneyString(allocatedAmount), usedAmount: toMoneyString(usedAmount), remainingAmount: toMoneyString(Math.max(0, allocatedAmount - usedAmount)), currentAmount: toMoneyString(amount), nextUsedAmount: toMoneyString(nextUsedAmount), nextUsagePercent, exceedsThreshold: nextUsagePercent >= Number(budgetPool.warning_threshold_percent ?? 80), exceedsBudget: nextUsedAmount > allocatedAmount, }; } function applyDecisionByIssues(response, issues, matchedRule, config) { if (!issues.length) { response.nextActions = ["可直接提交"]; return response; } const controlMode = matchedRule?.controlMode ?? normalizeFallbackControlMode(config); if (response.budgetHint) { response.budgetHint.controlMode = controlMode; } response.matchedBudgetControlRule = matchedRule ? { id: matchedRule.id, name: matchedRule.name, controlMode: matchedRule.controlMode, } : null; if (controlMode === "block") { response.decision = "block"; response.blockingReasons.push(...issues.map((item) => buildIssue(item.code, item.title, item.detail))); response.nextActions = ["修正阻断项后再提交"]; return response; } if (controlMode === "manual_review") { response.decision = "manual_review"; response.riskFlags.push( ...issues.map((item) => buildRiskFlagPreview(item.code, item.level, item.title, item.detail)), ); response.nextActions = ["允许提交,但会生成待复核风险项"]; return response; } response.decision = "warn"; response.warnings.push(...issues.map((item) => buildIssue(item.code, item.title, item.detail))); response.nextActions = ["确认提示后可继续提交"]; return response; } function appendBlockingReasons(response, issues) { if (!issues.length) { return response; } response.decision = "block"; response.blockingReasons.push(...issues.map((item) => buildIssue(item.code, item.title, item.detail))); response.nextActions = ["修正阻断项后再提交"]; return response; } function buildStandardIssueDetail(check) { return `标准:${check.standardName},上限 ¥${check.maxAmount}/${check.perUnit},当前 ¥${check.amount},超出 ¥${check.exceededAmount}`; } function buildStandardIssue(check) { return buildIssue( `expense_standard_${check.controlMode}_${check.itemId}`, `${check.expenseTypeName} 超出费用标准`, buildStandardIssueDetail(check), ); } async function checkExpenseStandards(tx, { reimbursementId }) { const reimbursement = await tx.reimbursementForm.findUnique({ where: { id: reimbursementId }, select: { id: true, department_id: true, items: { orderBy: [{ created_at: "asc" }, { id: "asc" }], select: { id: true, expense_type_id: true, amount: true, expense_type: { select: { id: true, name: true, }, }, }, }, }, }); if (!reimbursement) { throw buildNotFoundError(); } const expenseTypeIds = Array.from( new Set(reimbursement.items.map((item) => item.expense_type_id).filter(Boolean)), ); if (!expenseTypeIds.length) { return []; } const standards = await tx.expenseStandard.findMany({ where: { enabled: true, expense_type_id: { in: expenseTypeIds }, OR: [ { scope_type: "global" }, ...(reimbursement.department_id ? [{ scope_type: "department", scope_key: reimbursement.department_id }] : []), ], }, orderBy: [{ created_at: "asc" }, { id: "asc" }], select: { id: true, expense_type_id: true, name: true, max_amount: true, per_unit: true, control_mode: true, scope_type: true, scope_key: true, }, }); const standardMap = new Map(); for (const standard of standards) { const current = standardMap.get(standard.expense_type_id); if (!current) { standardMap.set(standard.expense_type_id, standard); continue; } const currentPriority = current.scope_type === "department" ? 2 : 1; const nextPriority = standard.scope_type === "department" ? 2 : 1; if (nextPriority > currentPriority) { standardMap.set(standard.expense_type_id, standard); } } return reimbursement.items .map((item) => { const standard = standardMap.get(item.expense_type_id); if (!standard) { return null; } const amount = toMoneyNumber(item.amount); const maxAmount = toMoneyNumber(standard.max_amount); const exceededAmount = Math.max(0, toMoneyNumber(amount - maxAmount)); return { itemId: item.id, expenseTypeId: item.expense_type_id, expenseTypeName: item.expense_type?.name ?? item.expense_type_id, amount: toMoneyString(amount), standardId: standard.id, standardName: standard.name, maxAmount: toMoneyString(maxAmount), perUnit: standard.per_unit, controlMode: standard.control_mode, scopeType: standard.scope_type, scopeKey: standard.scope_key ?? null, exceeded: amount > maxAmount, exceededAmount: toMoneyString(exceededAmount), }; }) .filter(Boolean); } function mergeReimbursementPrecheck(precheck, standardChecks) { const response = { ...precheck, warnings: [...precheck.warnings], blockingReasons: [...precheck.blockingReasons], riskFlags: [...precheck.riskFlags], nextActions: [...precheck.nextActions], standardChecks, }; const warnChecks = standardChecks.filter((item) => item.exceeded && item.controlMode === "warn"); const blockChecks = standardChecks.filter((item) => item.exceeded && item.controlMode === "block"); if (warnChecks.length) { response.warnings.push(...warnChecks.map((item) => buildStandardIssue(item))); } if (blockChecks.length) { response.blockingReasons.push(...blockChecks.map((item) => buildStandardIssue(item))); } if (response.blockingReasons.length) { response.decision = "block"; response.nextActions = ["修正阻断项后再提交"]; return response; } if (response.riskFlags.length) { response.decision = "manual_review"; response.nextActions = ["允许提交,但会生成待复核风险项"]; return response; } if (response.warnings.length) { response.decision = "warn"; response.nextActions = ["确认提示后可继续提交"]; return response; } response.decision = "pass"; response.nextActions = ["可直接提交"]; return response; } async function createRiskFlagsFromPrecheck(tx, { entityType, entityId, actorId, precheck, source = "precheck" }) { if (precheck.decision !== "manual_review" || !precheck.riskFlags.length) { return []; } const created = []; for (const item of precheck.riskFlags) { const existing = await tx.entityRiskFlag.findFirst({ where: { entity_type: entityType, entity_id: entityId, risk_code: item.code, source, status: "open", }, }); if (existing) { created.push(existing); continue; } const record = await tx.entityRiskFlag.create({ data: { entity_type: entityType, entity_id: entityId, risk_code: item.code, level: item.level, title: item.title, detail: item.detail, source, status: "open", payload_json: { openedByDecision: precheck.decision, matchedBudgetControlRule: precheck.matchedBudgetControlRule, budgetHint: precheck.budgetHint, standardChecks: precheck.standardChecks ?? [], }, created_by: actorId ?? null, }, }); created.push(record); } return created; } async function ensureRiskFlag(tx, input) { const existing = await tx.entityRiskFlag.findFirst({ where: { entity_type: input.entityType, entity_id: input.entityId, risk_code: input.riskCode, source: input.source, status: "open", }, }); if (existing) { return existing; } return tx.entityRiskFlag.create({ data: { entity_type: input.entityType, entity_id: input.entityId, risk_code: input.riskCode, level: input.level, title: input.title, detail: input.detail ?? null, source: input.source, status: "open", payload_json: input.payloadJson ?? null, created_by: input.actorId ?? null, }, }); } async function listRiskFlags(tx, filters = {}) { const rows = await tx.entityRiskFlag.findMany({ where: { ...(filters.entityType ? { entity_type: filters.entityType } : {}), ...(filters.entityId ? { entity_id: filters.entityId } : {}), ...(filters.status ? { status: filters.status } : {}), ...(filters.source ? { source: filters.source } : {}), }, orderBy: [{ created_at: "desc" }, { id: "desc" }], }); return rows.map(buildPersistedRiskFlag); } async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger }) { const reimbursement = await tx.reimbursementForm.findUnique({ where: { id: reimbursementId }, select: { id: true, department_id: true, reimbursement_date: true, total_amount: true, status: true, owner_id: true, items: { select: { id: true, expense_type_id: true, }, }, }, }); if (!reimbursement) { throw buildNotFoundError(); } const response = buildBasePrecheck("reimbursement", reimbursementId); const blocking = []; if (!["draft", "rejected"].includes(reimbursement.status)) { blocking.push(buildRiskFlagPreview("invalid_submit_status", RISK_LEVELS.high, "当前状态不可提交", "只有草稿或已驳回报销单可提交审批。")); } const activeInstance = await tx.workflowInstance.findFirst({ where: { entity_type: "reimbursement", entity_id: reimbursementId, current_status: "pending", }, select: { id: true }, }); if (activeInstance) { blocking.push(buildRiskFlagPreview("already_in_review", RISK_LEVELS.high, "报销单已在审批中", "当前报销单已有进行中的审批实例。")); } const boundInvoiceCount = await tx.invoicePool.count({ where: { linked_reimbursement_id: reimbursementId, status: "bound", }, }); if (boundInvoiceCount < 1) { blocking.push(buildRiskFlagPreview("bound_invoice_required", RISK_LEVELS.high, "缺少已绑定票据", "报销提交前至少需要绑定一张票据。")); } const amountString = toMoneyString(reimbursement.total_amount); 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, name: true, level1_role: true, level2_role: true, level3_role: true, owner_self_fallback_role: true, priority: true, }, }); if (!approvalRule) { blocking.push(buildRiskFlagPreview("missing_approval_rule", RISK_LEVELS.high, "缺少审批规则", "当前金额范围未匹配到有效报销审批规则。")); } else { let actionable = null; let skipped = []; try { const workflowSteps = await buildApprovalWorkflowSnapshot({ tx, approvalRule, ownerId: reimbursement.owner_id, logger, }); const resolved = await resolveActionableStep({ tx, steps: workflowSteps, startIndex: 0, ownerId: reimbursement.owner_id, logger, blockOnUnresolvedAssignee: true, }); actionable = resolved.actionable; skipped = resolved.skipped; } catch (error) { if (error instanceof AppError && error.details?.workflow === "no_actionable_approver") { actionable = null; skipped = [{ reason: error.details.reason ?? "no_actionable_approver" }]; } else { throw error; } } if (!actionable || skipped.length) { blocking.push(buildRiskFlagPreview("no_actionable_approver", RISK_LEVELS.high, "缺少可执行审批人", "当前审批链未解析出有效审批人。")); } } appendBlockingReasons(response, blocking); if (response.decision === "block") { return response; } const matchedRule = await resolveBudgetControlRule(tx, { entityType: "reimbursement", departmentId: reimbursement.department_id, legalEntityId: null, costCenterId: null, expenseTypeIds: Array.from(new Set(reimbursement.items.map((item) => item.expense_type_id).filter(Boolean))), }); const budgetPool = await resolveApplicableBudgetPoolByContext(tx, { departmentId: reimbursement.department_id, effectiveDate: reimbursement.reimbursement_date, }); const budgetIssues = []; if (!budgetPool) { budgetIssues.push({ code: "budget_pool_missing", level: RISK_LEVELS.medium, title: "未找到可用预算池", detail: "当前报销日期和部门范围未匹配到有效预算池。", }); } else { response.budgetHint = buildBudgetHint( budgetPool, reimbursement.total_amount, matchedRule?.controlMode ?? normalizeFallbackControlMode(config), ); if (response.budgetHint.exceedsBudget) { budgetIssues.push({ code: "budget_exceeded", level: RISK_LEVELS.high, title: "预算将被超用", detail: "当前报销金额会导致预算池超出已分配额度。", }); } else if (response.budgetHint.exceedsThreshold) { budgetIssues.push({ code: "budget_threshold_exceeded", level: RISK_LEVELS.medium, title: "预算达到预警阈值", detail: "本次提交后预算使用率将达到或超过预警阈值。", }); } } return applyDecisionByIssues(response, budgetIssues, matchedRule, config); } async function buildPurchaseRequestPrecheck(tx, { purchaseRequestId, config, logger }) { const purchaseRequest = await tx.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, select: { id: true, applicant_id: true, department_id: true, legal_entity_id: true, cost_center_id: true, supplier_id: true, document_category_id: true, budget_pool_id: true, total_amount: true, status: true, items: { select: { id: true } }, }, }); if (!purchaseRequest) { throw buildNotFoundError(); } const response = buildBasePrecheck("purchase_request", purchaseRequestId); const blocking = []; if (purchaseRequest.status !== "draft") { blocking.push(buildRiskFlagPreview("only_draft_submittable", RISK_LEVELS.high, "当前状态不可提交", "采购申请只有草稿状态允许提交。")); } if (!purchaseRequest.legal_entity_id) { blocking.push(buildRiskFlagPreview("legal_entity_required", RISK_LEVELS.high, "缺少法体", "提交前必须选择法体。")); } if (!purchaseRequest.cost_center_id) { blocking.push(buildRiskFlagPreview("cost_center_required", RISK_LEVELS.high, "缺少成本中心", "提交前必须选择成本中心。")); } if (!purchaseRequest.supplier_id) { blocking.push(buildRiskFlagPreview("supplier_required", RISK_LEVELS.high, "缺少供应商", "提交前必须选择供应商。")); } if (!purchaseRequest.document_category_id) { blocking.push(buildRiskFlagPreview("document_category_required", RISK_LEVELS.high, "缺少单据类型", "提交前必须选择采购单据类型。")); } if (!purchaseRequest.items.length) { blocking.push(buildRiskFlagPreview("items_required", RISK_LEVELS.high, "缺少采购明细", "提交前至少需要一条采购明细。")); } const workflowDefinition = await tx.workflowDefinition.findFirst({ where: { entity_type: "purchase_request", is_active: true, archived_at: null, }, orderBy: { version: "desc" }, select: { id: true, definition_json: true }, }); if (!workflowDefinition) { blocking.push(buildRiskFlagPreview("workflow_definition_missing", RISK_LEVELS.high, "缺少流程定义", "采购申请缺少有效流程定义。")); } else { const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json); const { actionable, skipped } = await resolveActionableStep({ tx, steps: workflowSteps, startIndex: 0, ownerId: purchaseRequest.applicant_id, logger, }); if (!actionable || skipped.length) { blocking.push(buildRiskFlagPreview("no_actionable_approver", RISK_LEVELS.high, "缺少可执行审批人", "当前采购流程未解析出有效审批人。")); } } appendBlockingReasons(response, blocking); if (response.decision === "block") { return response; } const matchedRule = await resolveBudgetControlRule(tx, { entityType: "purchase_request", departmentId: purchaseRequest.department_id, legalEntityId: purchaseRequest.legal_entity_id, costCenterId: purchaseRequest.cost_center_id, expenseTypeIds: [], }); let resolvedBudgetPool = null; if (purchaseRequest.legal_entity_id && purchaseRequest.cost_center_id) { resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, { departmentId: purchaseRequest.department_id, legalEntityId: purchaseRequest.legal_entity_id, costCenterId: purchaseRequest.cost_center_id, effectiveDate: new Date(), }); } const budgetIssues = []; const selectedBudgetPoolId = purchaseRequest.budget_pool_id ?? resolvedBudgetPool?.id ?? null; if (purchaseRequest.budget_pool_id && resolvedBudgetPool && purchaseRequest.budget_pool_id !== resolvedBudgetPool.id) { budgetIssues.push({ code: "budget_context_mismatch", level: RISK_LEVELS.high, title: "预算池与业务上下文不匹配", detail: "手工选择的预算池与当前法体/成本中心范围不一致。", }); } const budgetPool = selectedBudgetPoolId ? await tx.budgetPool.findUnique({ where: { id: selectedBudgetPoolId } }) : null; if (!budgetPool) { budgetIssues.push({ code: "budget_pool_missing", level: RISK_LEVELS.medium, title: "未找到可用预算池", detail: "当前采购申请未匹配到可用预算池。", }); } else { response.budgetHint = buildBudgetHint( budgetPool, purchaseRequest.total_amount, matchedRule?.controlMode ?? normalizeFallbackControlMode(config), ); if (response.budgetHint.exceedsBudget) { budgetIssues.push({ code: "budget_exceeded", level: RISK_LEVELS.high, title: "预算将被超用", detail: "当前采购金额会导致预算池超出已分配额度。", }); } else if (response.budgetHint.exceedsThreshold) { budgetIssues.push({ code: "budget_threshold_exceeded", level: RISK_LEVELS.medium, title: "预算达到预警阈值", detail: "本次提交后预算使用率将达到或超过预警阈值。", }); } } return applyDecisionByIssues(response, budgetIssues, matchedRule, config); } async function buildTravelRequestPrecheck(tx, { travelRequestId, config, logger }) { const travelRequest = await tx.travelRequest.findUnique({ where: { id: travelRequestId }, select: { id: true, applicant_employee_id: true, department_id: true, legal_entity_id: true, cost_center_id: true, start_date: true, estimated_amount: true, status: true, segments: { select: { id: true } }, }, }); if (!travelRequest) { throw buildNotFoundError(); } const response = buildBasePrecheck("travel_request", travelRequestId); const blocking = []; if (travelRequest.status !== "draft") { blocking.push(buildRiskFlagPreview("only_draft_submittable", RISK_LEVELS.high, "当前状态不可提交", "差旅申请只有草稿状态允许提交。")); } if (!travelRequest.legal_entity_id) { blocking.push(buildRiskFlagPreview("legal_entity_required", RISK_LEVELS.high, "缺少法体", "提交前必须选择法体。")); } if (!travelRequest.cost_center_id) { blocking.push(buildRiskFlagPreview("cost_center_required", RISK_LEVELS.high, "缺少成本中心", "提交前必须选择成本中心。")); } if (!travelRequest.segments.length) { blocking.push(buildRiskFlagPreview("segments_required", RISK_LEVELS.high, "缺少行程段", "提交前至少需要一个行程段。")); } const workflowDefinition = await tx.workflowDefinition.findFirst({ where: { entity_type: "travel_request", is_active: true, archived_at: null, }, orderBy: { version: "desc" }, select: { id: true, definition_json: true }, }); if (!workflowDefinition) { blocking.push(buildRiskFlagPreview("workflow_definition_missing", RISK_LEVELS.high, "缺少流程定义", "差旅申请缺少有效流程定义。")); } else { const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json); const { actionable, skipped } = await resolveActionableStep({ tx, steps: workflowSteps, startIndex: 0, ownerId: travelRequest.applicant_employee_id, logger, }); if (!actionable || skipped.length) { blocking.push(buildRiskFlagPreview("no_actionable_approver", RISK_LEVELS.high, "缺少可执行审批人", "当前差旅流程未解析出有效审批人。")); } } appendBlockingReasons(response, blocking); if (response.decision === "block") { return response; } const matchedRule = await resolveBudgetControlRule(tx, { entityType: "travel_request", departmentId: travelRequest.department_id, legalEntityId: travelRequest.legal_entity_id, costCenterId: travelRequest.cost_center_id, expenseTypeIds: [], }); const budgetPool = travelRequest.legal_entity_id && travelRequest.cost_center_id ? await resolveApplicableBudgetPoolByContext(tx, { departmentId: travelRequest.department_id, legalEntityId: travelRequest.legal_entity_id, costCenterId: travelRequest.cost_center_id, effectiveDate: travelRequest.start_date, }) : null; const budgetIssues = []; if (!budgetPool) { budgetIssues.push({ code: "budget_pool_missing", level: RISK_LEVELS.medium, title: "未找到可用预算池", detail: "当前差旅申请未匹配到可用预算池。", }); } else { response.budgetHint = buildBudgetHint( budgetPool, travelRequest.estimated_amount, matchedRule?.controlMode ?? normalizeFallbackControlMode(config), ); if (response.budgetHint.exceedsBudget) { budgetIssues.push({ code: "budget_exceeded", level: RISK_LEVELS.high, title: "预算将被超用", detail: "当前差旅申请金额会导致预算池超出已分配额度。", }); } else if (response.budgetHint.exceedsThreshold) { budgetIssues.push({ code: "budget_threshold_exceeded", level: RISK_LEVELS.medium, title: "预算达到预警阈值", detail: "本次提交后预算使用率将达到或超过预警阈值。", }); } } return applyDecisionByIssues(response, budgetIssues, matchedRule, config); } function getPrecheckPermissionCode(entityType) { return PRECHECK_PERMISSION_CODES[entityType] ?? null; } async function loadPrecheckEntityContext(tx, { entityType, entityId }) { if (entityType === "reimbursement") { const item = await tx.reimbursementForm.findUnique({ where: { id: entityId }, select: { id: true, department_id: true }, }); if (!item) { throw buildNotFoundError(); } return { id: item.id, departmentId: item.department_id ?? null }; } if (entityType === "purchase_request") { const item = await tx.purchaseRequest.findUnique({ where: { id: entityId }, select: { id: true, department_id: true }, }); if (!item) { throw buildNotFoundError(); } return { id: item.id, departmentId: item.department_id ?? null }; } if (entityType === "travel_request") { const item = await tx.travelRequest.findUnique({ where: { id: entityId }, select: { id: true, department_id: true }, }); if (!item) { throw buildNotFoundError(); } return { id: item.id, departmentId: item.department_id ?? null }; } throw buildValidationError({ entityType: "unsupported" }); } async function buildEntityPrecheck(tx, { entityType, entityId, config, logger }) { if (entityType === "reimbursement") { return buildReimbursementPrecheck(tx, { reimbursementId: entityId, config, logger, }); } if (entityType === "purchase_request") { return buildPurchaseRequestPrecheck(tx, { purchaseRequestId: entityId, config, logger, }); } if (entityType === "travel_request") { return buildTravelRequestPrecheck(tx, { travelRequestId: entityId, config, logger, }); } throw buildValidationError({ entityType: "unsupported" }); } async function resolveEntityPrecheck({ prisma, config, logger, entityType, entityId, actorDepartmentId = null, }) { const entity = await loadPrecheckEntityContext(prisma, { entityType, entityId }); const gate = await evaluateFeatureFlag({ prisma, config, flagKey: FEATURE_FLAG_KEYS.budgetPrecheck, departmentId: entity.departmentId ?? actorDepartmentId ?? null, strictDepartment: false, }); if (!gate.enabled) { const disabledPrecheck = buildDisabledPrecheck(entityType, entityId); if (entityType !== "reimbursement") { return disabledPrecheck; } const standardChecks = await checkExpenseStandards(prisma, { reimbursementId: entityId }); return mergeReimbursementPrecheck(disabledPrecheck, standardChecks); } const precheck = await buildEntityPrecheck(prisma, { entityType, entityId, config, logger, }); if (entityType !== "reimbursement") { return precheck; } const standardChecks = await checkExpenseStandards(prisma, { reimbursementId: entityId }); return mergeReimbursementPrecheck(precheck, standardChecks); } module.exports = { BUDGET_RISK_CODES, PRECHECK_DECISIONS, buildDisabledPrecheck, RISK_LEVELS, getPrecheckPermissionCode, toMoneyNumber, toMoneyString, listRiskFlags, ensureRiskFlag, buildPersistedRiskFlag, createRiskFlagsFromPrecheck, buildEntityPrecheck, buildReimbursementPrecheck, buildPurchaseRequestPrecheck, buildTravelRequestPrecheck, checkExpenseStandards, loadPrecheckEntityContext, mergeReimbursementPrecheck, resolveEntityPrecheck, };