feat(P25-1/P25-5/P26-1/P26-3/P24-1/P29-4): 落地内部OA够用版后端与试运行能力

关联 Issue: P25-1, P25-2, P25-3, P25-4, P25-5, P26-1, P26-3, P24-1, P24-2, P29-1, P29-2, P29-3, P29-4
这个提交包含在:
2026-03-21 18:03:20 +08:00
父节点 9b7cd818f7
当前提交 e39e41287e
共修改 45 个文件,包含 4571 行新增和 328 行删除
+79
查看文件
@@ -0,0 +1,79 @@
const { AppError } = require("../utils/AppError");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags");
async function ensureCutoverAllowed({
prisma,
config,
request,
domain,
departmentId = null,
legalEntityId = null,
}) {
const gate = await evaluateFeatureFlag({
prisma,
config,
flagKey: FEATURE_FLAG_KEYS.cutoverConsole,
departmentId: departmentId ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return {
checked: false,
allowed: true,
reason: gate.reason,
matchedScope: null,
};
}
const now = new Date();
const scopes = await prisma.cutoverScope.findMany({
where: {
enabled: true,
OR: [{ starts_at: null }, { starts_at: { lte: now } }],
},
orderBy: [{ scope_type: "asc" }, { scope_key: "asc" }],
});
const matchedScope = scopes.find((scope) => {
if (scope.scope_type === "domain") {
return scope.scope_key === domain;
}
if (scope.scope_type === "department") {
return departmentId && scope.scope_key === departmentId;
}
if (scope.scope_type === "legal_entity") {
return legalEntityId && scope.scope_key === legalEntityId;
}
return false;
}) ?? null;
if (!matchedScope) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
cutover: "not_enabled",
domain,
departmentId,
legalEntityId,
},
});
}
return {
checked: true,
allowed: true,
reason: gate.reason,
matchedScope: {
id: matchedScope.id,
scopeType: matchedScope.scope_type,
scopeKey: matchedScope.scope_key,
},
};
}
module.exports = {
ensureCutoverAllowed,
};
+35
查看文件
@@ -3,6 +3,13 @@ const FEATURE_FLAG_KEYS = {
erpSync: "erp_sync",
invoiceVerification: "invoice_verification",
leaveHolidayCalendar: "leave_holiday_calendar",
budgetPrecheck: "budget_precheck_v1",
paymentBatch: "payment_batch_v1",
portalHome: "portal_home_v1",
globalSearch: "global_search_v1",
announcements: "announcements_v1",
workflowDefinitionAdmin: "workflow_definition_admin_v1",
cutoverConsole: "cutover_console_v1",
};
const FEATURE_FLAG_KEY_LIST = Object.freeze(Object.values(FEATURE_FLAG_KEYS));
@@ -72,6 +79,34 @@ function resolveEnvGate(config, flagKey) {
return Boolean(config.leaveHolidayCalendarEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.budgetPrecheck) {
return Boolean(config.budgetPrecheckEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.paymentBatch) {
return Boolean(config.paymentBatchEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.portalHome) {
return Boolean(config.portalHomeEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.globalSearch) {
return Boolean(config.globalSearchEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.announcements) {
return Boolean(config.announcementsEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.workflowDefinitionAdmin) {
return Boolean(config.workflowDefinitionAdminEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.cutoverConsole) {
return Boolean(config.cutoverConsoleEnabled);
}
return false;
}
+741
查看文件
@@ -0,0 +1,741 @@
const { AppError } = require("../utils/AppError");
const { resolveApplicableBudgetPoolByContext } = require("./budgetPool");
const {
buildWorkflowStepsFromApprovalRule,
parseWorkflowSteps,
resolveActionableStep,
} = require("../utils/workflow");
const PRECHECK_DECISIONS = Object.freeze(["pass", "warn", "manual_review", "block"]);
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 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,
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;
}
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,
},
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,
priority: true,
},
});
if (!approvalRule) {
blocking.push(buildRiskFlagPreview("missing_approval_rule", RISK_LEVELS.high, "缺少审批规则", "当前金额范围未匹配到有效报销审批规则。"));
} else {
const workflowSteps = buildWorkflowStepsFromApprovalRule(approvalRule);
const { actionable } = await resolveActionableStep({
tx,
steps: workflowSteps,
startIndex: 0,
ownerId: reimbursement.owner_id,
logger,
});
if (!actionable) {
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 } = await resolveActionableStep({
tx,
steps: workflowSteps,
startIndex: 0,
ownerId: purchaseRequest.applicant_id,
logger,
});
if (!actionable) {
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 } = await resolveActionableStep({
tx,
steps: workflowSteps,
startIndex: 0,
ownerId: travelRequest.applicant_employee_id,
logger,
});
if (!actionable) {
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);
}
module.exports = {
PRECHECK_DECISIONS,
RISK_LEVELS,
toMoneyNumber,
toMoneyString,
listRiskFlags,
ensureRiskFlag,
buildPersistedRiskFlag,
createRiskFlagsFromPrecheck,
buildReimbursementPrecheck,
buildPurchaseRequestPrecheck,
buildTravelRequestPrecheck,
};
+11 -1
查看文件
@@ -7,6 +7,7 @@ const {
matchesNotificationChannel,
normalizeChannelType,
} = require('./imRouting');
const { signPayload } = require('../../utils/signedUrl');
function uniq(values) {
return Array.from(new Set((values ?? []).filter(Boolean)));
@@ -32,7 +33,16 @@ function buildDetailUrl(config, targetPath) {
return path;
}
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const token = signPayload(
{
targetPath: normalizedPath,
exp: Math.floor(Date.now() / 1000) + 15 * 60,
},
config.jwtSecret,
);
return `${baseUrl}/api/auth/deeplink?token=${encodeURIComponent(token)}`;
}
async function resolveRecipientRoleCodes(tx, recipientEmployeeIds) {