1072 行
32 KiB
JavaScript
1072 行
32 KiB
JavaScript
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,
|
|
};
|