审批流程管理(系统管理员)+ 系统管理员去首页
- 审批规则引擎 evaluateApproval(纯函数+8单测):有序条件规则决定风控通过后是否需管理层终审 - approval_config 表 + 持久化 + 默认规则种子(红线/不可接受/高风险/低毛利/大合同→需管理层;低风险达标→风控终批) - 风控审核接入规则:低风险达标可风控终批,否则转管理层;审计记录命中规则 - GET/PUT /api/approval-config(PUT 限系统管理员);overdue SLA 改为读配置 - 审批流程配置页 WorkflowManagement(全局SLA/默认/驳回去向 + 规则与条件编辑器) - 系统管理员去掉首页,登录落地用户管理;导航=用户管理+审批流程
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 审批流程配置持久化(单行 JSONB)。
|
||||
*/
|
||||
import type pg from 'pg';
|
||||
import { DEFAULT_APPROVAL_CONFIG, type ApprovalConfig } from '../strategy/approvalRules.js';
|
||||
|
||||
/** 读取审批配置;不存在则返回默认配置。 */
|
||||
export async function getApprovalConfig(pool: pg.Pool): Promise<ApprovalConfig> {
|
||||
const res = await pool.query('SELECT config FROM approval_config WHERE id = 1');
|
||||
const row = (res.rows as Array<{ config?: unknown }>)[0];
|
||||
if (!row || row.config == null) return DEFAULT_APPROVAL_CONFIG;
|
||||
return row.config as ApprovalConfig;
|
||||
}
|
||||
|
||||
/** 保存审批配置(upsert 单行)。 */
|
||||
export async function saveApprovalConfig(pool: pg.Pool, config: ApprovalConfig): Promise<void> {
|
||||
await pool.query(
|
||||
`INSERT INTO approval_config(id, config, updated_at) VALUES(1, $1, now())
|
||||
ON CONFLICT(id) DO UPDATE SET config = EXCLUDED.config, updated_at = now()`,
|
||||
[JSON.stringify(config)],
|
||||
);
|
||||
}
|
||||
|
||||
/** 首次启动兜底:无配置时写入默认配置。 */
|
||||
export async function ensureApprovalConfig(pool: pg.Pool): Promise<void> {
|
||||
const res = await pool.query('SELECT 1 FROM approval_config WHERE id = 1');
|
||||
if (res.rowCount === 0) {
|
||||
await saveApprovalConfig(pool, DEFAULT_APPROVAL_CONFIG);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export * from './customers.js';
|
||||
export * from './minWages.js';
|
||||
export * from './drafts.js';
|
||||
export * from './users.js';
|
||||
export * from './approvalConfig.js';
|
||||
export * from './settings.js';
|
||||
export * from './regionRates.js';
|
||||
export * from './rejectReasons.js';
|
||||
|
||||
+90
-5
@@ -72,6 +72,9 @@ import {
|
||||
ensureSeedUsers,
|
||||
USER_ROLES,
|
||||
type UserRole,
|
||||
getApprovalConfig,
|
||||
saveApprovalConfig,
|
||||
ensureApprovalConfig,
|
||||
getSetting,
|
||||
setSetting,
|
||||
loadAllRegionRates,
|
||||
@@ -101,7 +104,7 @@ import { buildMetricRedline, checkRedlines, type ComputableRedlineConfig, type R
|
||||
import { generate, exportReport } from '../report/index.js';
|
||||
import { analyzeProfitability, type ProfitabilityInputs } from '../cost/index.js';
|
||||
import { NATIONAL_DEFAULT_RATES, REGION_RATES } from '../cost/index.js';
|
||||
import { combineRecommendation, DEFAULT_TARGET_NET_MARGIN, buildOperatingControls, decide, hasRedlineHit, targetMarginForGrade } from '../strategy/index.js';
|
||||
import { combineRecommendation, DEFAULT_TARGET_NET_MARGIN, buildOperatingControls, decide, hasRedlineHit, targetMarginForGrade, evaluateApproval, DEFAULT_APPROVAL_CONFIG, type ApprovalConfig, type ApprovalRule, type ApprovalCondition } from '../strategy/index.js';
|
||||
import type { RiskLevel } from '../domain/common.js';
|
||||
import type { KnownData } from '../question/index.js';
|
||||
import type { Region } from '../domain/region.js';
|
||||
@@ -915,9 +918,14 @@ app.get('/api/assessments/overdue', async (c) => {
|
||||
ORDER BY ws.entered_at ASC`,
|
||||
);
|
||||
const now = Date.now();
|
||||
const cfg = await getApprovalConfig(pool).catch(() => null);
|
||||
const slaMap: Record<string, number> = {
|
||||
pending_risk_review: cfg?.slaRiskHours ?? SLA_HOURS.pending_risk_review ?? 24,
|
||||
risk_reviewed: cfg?.slaMgmtHours ?? SLA_HOURS.risk_reviewed ?? 48,
|
||||
};
|
||||
const overdue = (res.rows as Array<Record<string, unknown>>).filter((r) => {
|
||||
const enteredAt = new Date(String(r.entered_at)).getTime();
|
||||
const slaMs = (SLA_HOURS[String(r.status)] ?? 24) * 3600_000;
|
||||
const slaMs = (slaMap[String(r.status)] ?? 24) * 3600_000;
|
||||
return now - enteredAt > slaMs;
|
||||
}).map((r) => ({
|
||||
id: String(r.assessment_id),
|
||||
@@ -1286,6 +1294,43 @@ app.delete('/api/users/:id', requireRole('系统管理员'), async (c) => {
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 审批流程配置(系统管理员):规则 + SLA。
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 读取审批流程配置(只读开放,供看板/详情展示)。 */
|
||||
app.get('/api/approval-config', async (c) => {
|
||||
if (pool === null) return c.json(DEFAULT_APPROVAL_CONFIG);
|
||||
return c.json(await getApprovalConfig(pool));
|
||||
});
|
||||
|
||||
/** 保存审批流程配置(系统管理员)。 */
|
||||
app.put('/api/approval-config', requireRole('系统管理员'), async (c) => {
|
||||
if (pool === null) return c.json({ error: '未配置数据库' }, 400);
|
||||
const body = await c.req.json<ApprovalConfig>();
|
||||
if (!Array.isArray(body.rules)) return c.json({ error: '配置格式错误:rules 必须为数组' }, 400);
|
||||
// 基本校验与归一化。
|
||||
const cfg: ApprovalConfig = {
|
||||
defaultRequireManagement: body.defaultRequireManagement === true,
|
||||
slaRiskHours: Number(body.slaRiskHours) > 0 ? Number(body.slaRiskHours) : 24,
|
||||
slaMgmtHours: Number(body.slaMgmtHours) > 0 ? Number(body.slaMgmtHours) : 48,
|
||||
rejectTo: body.rejectTo === 'origin' ? 'origin' : 'risk',
|
||||
rules: body.rules.map((r: ApprovalRule, i: number) => ({
|
||||
id: typeof r.id === 'string' && r.id !== '' ? r.id : `rule-${Date.now().toString(36)}-${i}`,
|
||||
name: String(r.name ?? `规则${i + 1}`),
|
||||
enabled: r.enabled !== false,
|
||||
requireManagement: r.requireManagement === true,
|
||||
conditions: Array.isArray(r.conditions) ? r.conditions.map((cd: ApprovalCondition) => ({
|
||||
field: cd.field,
|
||||
op: cd.op,
|
||||
value: cd.value,
|
||||
})) : [],
|
||||
})),
|
||||
};
|
||||
await saveApprovalConfig(pool, cfg);
|
||||
return c.json(cfg);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/region-rates/engine-defaults
|
||||
* 引擎内置默认费率(全国 + 上海/北京/广东),作为维护对照基准。
|
||||
@@ -1803,13 +1848,52 @@ app.post('/api/assessments/:id/review', requireRole('风控'), async (c) => {
|
||||
if (current !== 'pending_risk_review') {
|
||||
return c.json({ error: `当前状态为 ${current},无法审核` }, 400);
|
||||
}
|
||||
const newStatus: WorkflowStatus =
|
||||
body.action === 'approve' ? 'risk_reviewed' : 'rejected';
|
||||
let newStatus: WorkflowStatus;
|
||||
let actionText: string;
|
||||
if (body.action === 'approve') {
|
||||
// 按审批规则决定:风控通过后是否还需管理层终审。
|
||||
let requireMgmt = true;
|
||||
let ruleNote = '';
|
||||
if (pool !== null) {
|
||||
try {
|
||||
const cfg = await getApprovalConfig(pool);
|
||||
const prof = profitabilityById.get(id);
|
||||
const inputs = profitabilityInputsById.get(id);
|
||||
const a = record.assessment;
|
||||
const contractAmount = inputs?.contractTotal
|
||||
?? (prof !== undefined ? Math.round(prof.contract.revenueGross) : undefined);
|
||||
const decision = evaluateApproval(cfg, {
|
||||
...(a.riskGrade !== undefined ? { riskGrade: a.riskGrade } : {}),
|
||||
...(prof !== undefined ? { netMarginPct: Math.round(prof.monthly.netMargin * 1000) / 10 } : {}),
|
||||
...(a.acceptability !== undefined ? { acceptability: a.acceptability } : {}),
|
||||
redlineHit: a.redlineResults.some((r) => r.status === '命中'),
|
||||
...(contractAmount !== undefined ? { contractAmount } : {}),
|
||||
...(a.businessType !== undefined ? { businessType: a.businessType } : {}),
|
||||
});
|
||||
requireMgmt = decision.requireManagement;
|
||||
ruleNote = decision.matchedRuleName !== null
|
||||
? `(规则:${decision.matchedRuleName})`
|
||||
: '(默认规则)';
|
||||
} catch {
|
||||
requireMgmt = true; // 规则评估失败则保守要求管理层审批
|
||||
}
|
||||
}
|
||||
if (requireMgmt) {
|
||||
newStatus = 'risk_reviewed';
|
||||
actionText = `风控审核通过(转管理层审批)${ruleNote}`;
|
||||
} else {
|
||||
newStatus = 'approved';
|
||||
actionText = `风控审核通过并终批(免管理层)${ruleNote}`;
|
||||
}
|
||||
} else {
|
||||
newStatus = 'rejected';
|
||||
actionText = '风控驳回(退回销售)';
|
||||
}
|
||||
setStatus(id, newStatus);
|
||||
const reviewEntry: AuditLogEntry = {
|
||||
role: '风控',
|
||||
username: body.user,
|
||||
action: body.action === 'approve' ? '风控审核通过' : '风控驳回(退回销售)',
|
||||
action: actionText,
|
||||
...(body.comment !== undefined && body.comment !== '' ? { comment: body.comment } : {}),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
@@ -2221,6 +2305,7 @@ async function start(): Promise<void> {
|
||||
for (const [id, v] of arc) archivedById.set(id, v);
|
||||
calibratedTargetBase = (await getSetting(pool, 'targetMarginBase')) ?? null;
|
||||
await ensureSeedUsers(pool);
|
||||
await ensureApprovalConfig(pool);
|
||||
console.log(`PostgreSQL 持久化已启用,已加载 ${store.getAll().length} 条评估记录`);
|
||||
} catch (err) {
|
||||
console.error('PostgreSQL 连接失败,回退到进程内存储:', err instanceof Error ? err.message : err);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { evaluateApproval, DEFAULT_APPROVAL_CONFIG, type ApprovalConfig } from '../approvalRules.js';
|
||||
|
||||
describe('审批规则引擎 evaluateApproval', () => {
|
||||
it('命中红线 → 需管理层', () => {
|
||||
const d = evaluateApproval(DEFAULT_APPROVAL_CONFIG, { redlineHit: true, riskGrade: '低', netMarginPct: 20 });
|
||||
expect(d.requireManagement).toBe(true);
|
||||
expect(d.matchedRuleId).toBe('rule-redline');
|
||||
});
|
||||
|
||||
it('高风险 → 需管理层(riskGrade>=高 命中)', () => {
|
||||
const d = evaluateApproval(DEFAULT_APPROVAL_CONFIG, { redlineHit: false, riskGrade: '高', netMarginPct: 20 });
|
||||
expect(d.requireManagement).toBe(true);
|
||||
expect(d.matchedRuleId).toBe('rule-highrisk');
|
||||
});
|
||||
|
||||
it('低风险且高毛利且无红线 → 风控终批(命中低风险规则)', () => {
|
||||
const d = evaluateApproval(DEFAULT_APPROVAL_CONFIG, { redlineHit: false, riskGrade: '低', netMarginPct: 20, acceptability: '可接受' });
|
||||
expect(d.requireManagement).toBe(false);
|
||||
expect(d.matchedRuleId).toBe('rule-lowrisk');
|
||||
});
|
||||
|
||||
it('低净利率 → 需管理层(净利率<5 命中,先于低风险规则)', () => {
|
||||
const d = evaluateApproval(DEFAULT_APPROVAL_CONFIG, { redlineHit: false, riskGrade: '中', netMarginPct: 3 });
|
||||
expect(d.requireManagement).toBe(true);
|
||||
expect(d.matchedRuleId).toBe('rule-lowmargin');
|
||||
});
|
||||
|
||||
it('大合同额 → 需管理层', () => {
|
||||
const d = evaluateApproval(DEFAULT_APPROVAL_CONFIG, { redlineHit: false, riskGrade: '中', netMarginPct: 10, contractAmount: 2_000_000 });
|
||||
expect(d.requireManagement).toBe(true);
|
||||
expect(d.matchedRuleId).toBe('rule-bigcontract');
|
||||
});
|
||||
|
||||
it('无规则命中 → 使用默认值', () => {
|
||||
const cfg: ApprovalConfig = { ...DEFAULT_APPROVAL_CONFIG, defaultRequireManagement: true, rules: [] };
|
||||
const d = evaluateApproval(cfg, { redlineHit: false, riskGrade: '中', netMarginPct: 10 });
|
||||
expect(d.requireManagement).toBe(true);
|
||||
expect(d.matchedRuleId).toBeNull();
|
||||
});
|
||||
|
||||
it('禁用规则不参与匹配', () => {
|
||||
const cfg: ApprovalConfig = {
|
||||
...DEFAULT_APPROVAL_CONFIG,
|
||||
defaultRequireManagement: false,
|
||||
rules: [{ id: 'r1', name: 'x', enabled: false, requireManagement: true, conditions: [{ field: 'redlineHit', op: '==', value: true }] }],
|
||||
};
|
||||
const d = evaluateApproval(cfg, { redlineHit: true });
|
||||
expect(d.requireManagement).toBe(false);
|
||||
expect(d.matchedRuleId).toBeNull();
|
||||
});
|
||||
|
||||
it('按顺序取第一条命中规则', () => {
|
||||
const cfg: ApprovalConfig = {
|
||||
...DEFAULT_APPROVAL_CONFIG,
|
||||
rules: [
|
||||
{ id: 'a', name: 'A', enabled: true, requireManagement: false, conditions: [{ field: 'riskGrade', op: '>=', value: '中' }] },
|
||||
{ id: 'b', name: 'B', enabled: true, requireManagement: true, conditions: [{ field: 'riskGrade', op: '>=', value: '高' }] },
|
||||
],
|
||||
};
|
||||
const d = evaluateApproval(cfg, { redlineHit: false, riskGrade: '高' });
|
||||
expect(d.matchedRuleId).toBe('a'); // A 先命中
|
||||
expect(d.requireManagement).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 审批流程规则引擎(纯函数,确定性)。
|
||||
*
|
||||
* 设计:固定两级审批序列「销售提交 → 风控审核 →(按规则)管理层审批」。
|
||||
* 风控通过后,是否还需管理层终审由**有序规则**决定:按顺序取第一条命中规则的
|
||||
* `requireManagement`;无命中则用 `defaultRequireManagement`。这样既可让低风险达标项目
|
||||
* 风控通过即终批,也可对高风险/命中红线/低毛利/大合同强制管理层把关。规则可由系统管理员配置。
|
||||
*/
|
||||
|
||||
/** 条件字段。 */
|
||||
export type ApprovalField =
|
||||
| 'riskGrade' // 风险等级 低/中/高/极高(按序可做数值比较)
|
||||
| 'netMarginPct' // 月净利率(百分数,如 8 表示 8%)
|
||||
| 'acceptability' // 可接受性 可接受/有条件接受/不可接受
|
||||
| 'redlineHit' // 是否命中红线(布尔)
|
||||
| 'contractAmount' // 合同额(元)
|
||||
| 'businessType'; // 业务类型
|
||||
|
||||
/** 比较运算符。 */
|
||||
export type ApprovalOp = '>=' | '<=' | '>' | '<' | '==' | '!=';
|
||||
|
||||
/** 单个条件。 */
|
||||
export interface ApprovalCondition {
|
||||
readonly field: ApprovalField;
|
||||
readonly op: ApprovalOp;
|
||||
readonly value: string | number | boolean;
|
||||
}
|
||||
|
||||
/** 单条规则:全部条件 AND 命中时,按 requireManagement 决定是否需管理层审批。 */
|
||||
export interface ApprovalRule {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly enabled: boolean;
|
||||
readonly requireManagement: boolean;
|
||||
readonly conditions: readonly ApprovalCondition[];
|
||||
}
|
||||
|
||||
/** 审批流程配置。 */
|
||||
export interface ApprovalConfig {
|
||||
/** 无规则命中时是否需要管理层审批(兜底)。 */
|
||||
readonly defaultRequireManagement: boolean;
|
||||
/** 风控审核 SLA(小时)。 */
|
||||
readonly slaRiskHours: number;
|
||||
/** 管理层审批 SLA(小时)。 */
|
||||
readonly slaMgmtHours: number;
|
||||
/** 管理层驳回默认去向:退回销售(origin) / 退回风控复审(risk)。 */
|
||||
readonly rejectTo: 'origin' | 'risk';
|
||||
/** 有序规则集(按顺序匹配,第一条命中生效)。 */
|
||||
readonly rules: readonly ApprovalRule[];
|
||||
}
|
||||
|
||||
/** 评估上下文(供规则匹配)。 */
|
||||
export interface ApprovalContext {
|
||||
readonly riskGrade?: string;
|
||||
readonly netMarginPct?: number;
|
||||
readonly acceptability?: string;
|
||||
readonly redlineHit: boolean;
|
||||
readonly contractAmount?: number;
|
||||
readonly businessType?: string;
|
||||
}
|
||||
|
||||
/** 规则评估结果。 */
|
||||
export interface ApprovalDecision {
|
||||
readonly requireManagement: boolean;
|
||||
readonly matchedRuleId: string | null;
|
||||
readonly matchedRuleName: string | null;
|
||||
}
|
||||
|
||||
/** 风险等级 → 序号(用于 >=/<= 等数值比较)。 */
|
||||
const GRADE_RANK: Record<string, number> = { 低: 1, 中: 2, 高: 3, 极高: 4 };
|
||||
|
||||
/** 默认审批配置:低风险达标且无红线 → 风控终批;高风险/红线/低毛利/大合同 → 需管理层。 */
|
||||
export const DEFAULT_APPROVAL_CONFIG: ApprovalConfig = {
|
||||
defaultRequireManagement: false,
|
||||
slaRiskHours: 24,
|
||||
slaMgmtHours: 48,
|
||||
rejectTo: 'risk',
|
||||
rules: [
|
||||
{ id: 'rule-redline', name: '命中红线必过管理层', enabled: true, requireManagement: true, conditions: [{ field: 'redlineHit', op: '==', value: true }] },
|
||||
{ id: 'rule-unacceptable', name: '不可接受必过管理层', enabled: true, requireManagement: true, conditions: [{ field: 'acceptability', op: '==', value: '不可接受' }] },
|
||||
{ id: 'rule-highrisk', name: '风险等级≥高必过管理层', enabled: true, requireManagement: true, conditions: [{ field: 'riskGrade', op: '>=', value: '高' }] },
|
||||
{ id: 'rule-lowmargin', name: '净利率<5%必过管理层', enabled: true, requireManagement: true, conditions: [{ field: 'netMarginPct', op: '<', value: 5 }] },
|
||||
{ id: 'rule-bigcontract', name: '合同额≥100万必过管理层', enabled: true, requireManagement: true, conditions: [{ field: 'contractAmount', op: '>=', value: 1000000 }] },
|
||||
{ id: 'rule-lowrisk', name: '低风险且达标→风控终批', enabled: true, requireManagement: false, conditions: [{ field: 'riskGrade', op: '<=', value: '中' }] },
|
||||
],
|
||||
};
|
||||
|
||||
/** 取条件左值(从上下文)。riskGrade 转为序号以支持数值比较。 */
|
||||
function leftValue(field: ApprovalField, ctx: ApprovalContext): string | number | boolean | undefined {
|
||||
switch (field) {
|
||||
case 'riskGrade': return ctx.riskGrade !== undefined ? (GRADE_RANK[ctx.riskGrade] ?? undefined) : undefined;
|
||||
case 'netMarginPct': return ctx.netMarginPct;
|
||||
case 'acceptability': return ctx.acceptability;
|
||||
case 'redlineHit': return ctx.redlineHit;
|
||||
case 'contractAmount': return ctx.contractAmount;
|
||||
case 'businessType': return ctx.businessType;
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 取条件右值。riskGrade 的字符串阈值转序号。 */
|
||||
function rightValue(field: ApprovalField, value: string | number | boolean): string | number | boolean {
|
||||
if (field === 'riskGrade' && typeof value === 'string') return GRADE_RANK[value] ?? Number(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function compare(left: string | number | boolean | undefined, op: ApprovalOp, right: string | number | boolean): boolean {
|
||||
if (left === undefined || left === null) return false;
|
||||
if (op === '==') return String(left) === String(right);
|
||||
if (op === '!=') return String(left) !== String(right);
|
||||
// 数值比较
|
||||
const l = typeof left === 'boolean' ? (left ? 1 : 0) : Number(left);
|
||||
const r = typeof right === 'boolean' ? (right ? 1 : 0) : Number(right);
|
||||
if (Number.isNaN(l) || Number.isNaN(r)) return false;
|
||||
switch (op) {
|
||||
case '>=': return l >= r;
|
||||
case '<=': return l <= r;
|
||||
case '>': return l > r;
|
||||
case '<': return l < r;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 单条规则是否命中(全部条件 AND)。 */
|
||||
function ruleMatches(rule: ApprovalRule, ctx: ApprovalContext): boolean {
|
||||
if (!rule.enabled || rule.conditions.length === 0) return false;
|
||||
return rule.conditions.every((cond) =>
|
||||
compare(leftValue(cond.field, ctx), cond.op, rightValue(cond.field, cond.value)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估某项目风控通过后是否还需管理层审批。
|
||||
* 按规则顺序取第一条命中;无命中用默认值。
|
||||
*/
|
||||
export function evaluateApproval(config: ApprovalConfig, ctx: ApprovalContext): ApprovalDecision {
|
||||
for (const rule of config.rules) {
|
||||
if (ruleMatches(rule, ctx)) {
|
||||
return { requireManagement: rule.requireManagement, matchedRuleId: rule.id, matchedRuleName: rule.name };
|
||||
}
|
||||
}
|
||||
return { requireManagement: config.defaultRequireManagement, matchedRuleId: null, matchedRuleName: null };
|
||||
}
|
||||
@@ -10,3 +10,4 @@ export * from './measures.js';
|
||||
export * from './acceptanceConditions.js';
|
||||
export * from './recommendation.js';
|
||||
export * from './operatingControls.js';
|
||||
export * from './approvalRules.js';
|
||||
|
||||
Reference in New Issue
Block a user