From 757b9c4a6981c3e5ff7502479dbfa0412cc17486 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Sat, 13 Jun 2026 17:55:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E6=B5=81=E7=A8=8B=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=88=E7=B3=BB=E7=BB=9F=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=EF=BC=89+=20=E7=B3=BB=E7=BB=9F=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E5=8E=BB=E9=A6=96=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 审批规则引擎 evaluateApproval(纯函数+8单测):有序条件规则决定风控通过后是否需管理层终审 - approval_config 表 + 持久化 + 默认规则种子(红线/不可接受/高风险/低毛利/大合同→需管理层;低风险达标→风控终批) - 风控审核接入规则:低风险达标可风控终批,否则转管理层;审计记录命中规则 - GET/PUT /api/approval-config(PUT 限系统管理员);overdue SLA 改为读配置 - 审批流程配置页 WorkflowManagement(全局SLA/默认/驳回去向 + 规则与条件编辑器) - 系统管理员去掉首页,登录落地用户管理;导航=用户管理+审批流程 --- migrations/1730000024000_approval_config.cjs | 19 ++ src/persistence/approvalConfig.ts | 30 +++ src/persistence/index.ts | 1 + src/server/index.ts | 95 +++++++- src/strategy/__tests__/approvalRules.test.ts | 65 ++++++ src/strategy/approvalRules.ts | 143 ++++++++++++ src/strategy/index.ts | 1 + web/src/App.tsx | 13 +- web/src/api/client.ts | 42 ++++ web/src/app/AppShell.tsx | 19 +- web/src/pages/WorkflowManagement.tsx | 217 +++++++++++++++++++ 11 files changed, 632 insertions(+), 13 deletions(-) create mode 100644 migrations/1730000024000_approval_config.cjs create mode 100644 src/persistence/approvalConfig.ts create mode 100644 src/strategy/__tests__/approvalRules.test.ts create mode 100644 src/strategy/approvalRules.ts create mode 100644 web/src/pages/WorkflowManagement.tsx diff --git a/migrations/1730000024000_approval_config.cjs b/migrations/1730000024000_approval_config.cjs new file mode 100644 index 0000000..613d5a5 --- /dev/null +++ b/migrations/1730000024000_approval_config.cjs @@ -0,0 +1,19 @@ +/* eslint-disable */ +/** + * 审批流程配置(单行 JSONB):系统管理员可配置的审批规则与 SLA。 + */ + +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS approval_config ( + id INTEGER PRIMARY KEY DEFAULT 1, + config JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT approval_config_singleton CHECK (id = 1) + ); + `); +}; + +exports.down = (pgm) => { + pgm.sql(`DROP TABLE IF EXISTS approval_config;`); +}; diff --git a/src/persistence/approvalConfig.ts b/src/persistence/approvalConfig.ts new file mode 100644 index 0000000..3e449b6 --- /dev/null +++ b/src/persistence/approvalConfig.ts @@ -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 { + 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 { + 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 { + const res = await pool.query('SELECT 1 FROM approval_config WHERE id = 1'); + if (res.rowCount === 0) { + await saveApprovalConfig(pool, DEFAULT_APPROVAL_CONFIG); + } +} diff --git a/src/persistence/index.ts b/src/persistence/index.ts index ff9604a..d37cd71 100644 --- a/src/persistence/index.ts +++ b/src/persistence/index.ts @@ -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'; diff --git a/src/server/index.ts b/src/server/index.ts index dadc55d..458e865 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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 = { + 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>).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(); + 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 { 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); diff --git a/src/strategy/__tests__/approvalRules.test.ts b/src/strategy/__tests__/approvalRules.test.ts new file mode 100644 index 0000000..8f862b4 --- /dev/null +++ b/src/strategy/__tests__/approvalRules.test.ts @@ -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); + }); +}); diff --git a/src/strategy/approvalRules.ts b/src/strategy/approvalRules.ts new file mode 100644 index 0000000..d33133c --- /dev/null +++ b/src/strategy/approvalRules.ts @@ -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 = { 低: 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 }; +} diff --git a/src/strategy/index.ts b/src/strategy/index.ts index b34ba8a..9fc05ef 100644 --- a/src/strategy/index.ts +++ b/src/strategy/index.ts @@ -10,3 +10,4 @@ export * from './measures.js'; export * from './acceptanceConditions.js'; export * from './recommendation.js'; export * from './operatingControls.js'; +export * from './approvalRules.js'; diff --git a/web/src/App.tsx b/web/src/App.tsx index f7fb43d..de1ded0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,7 @@ import { RateManagement } from './pages/RateManagement.js'; import { RedlineManagement } from './pages/RedlineManagement.js'; import { CustomerManagement } from './pages/CustomerManagement.js'; import { UserManagement } from './pages/UserManagement.js'; +import { WorkflowManagement } from './pages/WorkflowManagement.js'; /** 路由守卫:未登录重定向到登录页。 */ function ProtectedRoute(): JSX.Element { @@ -26,6 +27,13 @@ function RoleRoute({ allow }: { readonly allow: readonly string[] }): JSX.Elemen return allow.includes(role) ? : ; } +/** 首页:系统管理员不关心业务,重定向到用户管理;其余角色看评估看板。 */ +function HomeRoute(): JSX.Element { + const { user } = useAuthStore(); + if (user?.role === '系统管理员') return ; + return ; +} + export function App(): JSX.Element { return ( } /> }> }> - } /> + } /> } /> } /> {/* 费率/红线管理:仅管理层 */} @@ -50,9 +58,10 @@ export function App(): JSX.Element { }> } /> - {/* 用户管理:系统管理员 */} + {/* 用户管理 + 审批流程:系统管理员 */} }> } /> + } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 907e47d..eabbbe3 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -844,6 +844,48 @@ export async function deleteUserApi(id: string): Promise { await fetch(`${API_BASE}/api/users/${encodeURIComponent(id)}`, { method: 'DELETE', headers: authHeader() }); } +/* ----------------------------- 审批流程配置 ----------------------------- */ + +export type ApprovalField = 'riskGrade' | 'netMarginPct' | 'acceptability' | 'redlineHit' | 'contractAmount' | 'businessType'; +export type ApprovalOp = '>=' | '<=' | '>' | '<' | '==' | '!='; + +export interface ApprovalCondition { + field: ApprovalField; + op: ApprovalOp; + value: string | number | boolean; +} +export interface ApprovalRule { + id: string; + name: string; + enabled: boolean; + requireManagement: boolean; + conditions: ApprovalCondition[]; +} +export interface ApprovalConfig { + defaultRequireManagement: boolean; + slaRiskHours: number; + slaMgmtHours: number; + rejectTo: 'origin' | 'risk'; + rules: ApprovalRule[]; +} + +/** 读取审批流程配置。 */ +export async function fetchApprovalConfig(): Promise { + return request('GET', '/api/approval-config'); +} + +/** 保存审批流程配置(系统管理员)。 */ +export async function saveApprovalConfig(config: ApprovalConfig): Promise { + const res = await fetch(`${API_BASE}/api/approval-config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...authHeader() }, + body: JSON.stringify(config), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new ApiError(res.status, typeof data.error === 'string' ? data.error : `HTTP ${res.status}`); + return data as ApprovalConfig; +} + /** 方案对比。 */ export interface ScenarioItem { id: string; diff --git a/web/src/app/AppShell.tsx b/web/src/app/AppShell.tsx index 3907734..161a035 100644 --- a/web/src/app/AppShell.tsx +++ b/web/src/app/AppShell.tsx @@ -141,9 +141,11 @@ export function AppShell(): JSX.Element { role="group" aria-label="导航与用户信息" > - navigate('/')}> - 首页 - + {role !== '系统管理员' && ( + navigate('/')}> + 首页 + + )} {role === '商务/销售' && ( navigate('/new')}> @@ -181,9 +183,14 @@ export function AppShell(): JSX.Element { )} {role === '系统管理员' && ( - navigate('/users')}> - 用户管理 - + <> + navigate('/users')}> + 用户管理 + + navigate('/workflow')}> + 审批流程 + + )} diff --git a/web/src/pages/WorkflowManagement.tsx b/web/src/pages/WorkflowManagement.tsx new file mode 100644 index 0000000..3aa0599 --- /dev/null +++ b/web/src/pages/WorkflowManagement.tsx @@ -0,0 +1,217 @@ +/** + * 审批流程管理 — 系统管理员配置审批规则与 SLA。 + * + * 流程固定为「销售提交 → 风控审核 →(按规则)管理层审批」。 + * 规则按顺序匹配,第一条命中的规则决定该项目风控通过后是否仍需管理层终审; + * 无命中则用「默认是否需管理层」。 + */ +import { useCallback, useEffect, useState } from 'react'; +import { colorVar, FONT_FAMILY, RADIUS, SHADOW, space, typographyStyle } from '../design-system/components/styles.js'; +import { Card, Icon } from '../design-system/index.js'; +import { + fetchApprovalConfig, saveApprovalConfig, + type ApprovalConfig, type ApprovalRule, type ApprovalCondition, type ApprovalField, type ApprovalOp, +} from '../api/client.js'; + +const FIELD_META: Record = { + riskGrade: { label: '风险等级', kind: 'grade' }, + netMarginPct: { label: '月净利率', kind: 'number', unit: '%' }, + acceptability: { label: '可接受性', kind: 'accept' }, + redlineHit: { label: '命中红线', kind: 'bool' }, + contractAmount: { label: '合同额', kind: 'number', unit: '元' }, + businessType: { label: '业务类型', kind: 'biz' }, +}; +const FIELDS = Object.keys(FIELD_META) as ApprovalField[]; +const OPS_NUM: ApprovalOp[] = ['>=', '<=', '>', '<', '==', '!=']; +const OPS_EQ: ApprovalOp[] = ['==', '!=']; +const GRADES = ['低', '中', '高', '极高']; +const ACCEPTS = ['可接受', '有条件接受', '不可接受']; +const BIZ = ['岗位外包', '劳务派遣', '业务/服务外包', 'BPO', '项目制外包']; + +function defaultValueFor(field: ApprovalField): string | number | boolean { + const k = FIELD_META[field].kind; + if (k === 'grade') return '高'; + if (k === 'accept') return '不可接受'; + if (k === 'bool') return true; + if (k === 'biz') return '劳务派遣'; + return 0; +} + +export function WorkflowManagement(): JSX.Element { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const load = useCallback(() => { + setLoading(true); + fetchApprovalConfig().then(setConfig).catch((e: unknown) => setError(e instanceof Error ? e.message : '加载失败')).finally(() => setLoading(false)); + }, []); + useEffect(() => { load(); }, [load]); + + function patch(p: Partial): void { setConfig((c) => (c ? { ...c, ...p } : c)); } + function patchRule(idx: number, p: Partial): void { + setConfig((c) => (c ? { ...c, rules: c.rules.map((r, i) => (i === idx ? { ...r, ...p } : r)) } : c)); + } + function patchCond(ri: number, ci: number, p: Partial): void { + setConfig((c) => { + if (!c) return c; + const rules = c.rules.map((r, i) => i !== ri ? r : { ...r, conditions: r.conditions.map((cd, j) => j === ci ? { ...cd, ...p } : cd) }); + return { ...c, rules }; + }); + } + function addRule(): void { + setConfig((c) => c ? { ...c, rules: [...c.rules, { id: `rule-${Date.now().toString(36)}`, name: '新规则', enabled: true, requireManagement: true, conditions: [{ field: 'riskGrade', op: '>=', value: '高' }] }] } : c); + } + function removeRule(idx: number): void { setConfig((c) => c ? { ...c, rules: c.rules.filter((_, i) => i !== idx) } : c); } + function moveRule(idx: number, dir: -1 | 1): void { + setConfig((c) => { + if (!c) return c; + const j = idx + dir; + if (j < 0 || j >= c.rules.length) return c; + const rules = [...c.rules]; + const tmp = rules[idx]!; rules[idx] = rules[j]!; rules[j] = tmp; + return { ...c, rules }; + }); + } + function addCond(ri: number): void { + setConfig((c) => c ? { ...c, rules: c.rules.map((r, i) => i !== ri ? r : { ...r, conditions: [...r.conditions, { field: 'netMarginPct', op: '<', value: 5 }] }) } : c); + } + function removeCond(ri: number, ci: number): void { + setConfig((c) => c ? { ...c, rules: c.rules.map((r, i) => i !== ri ? r : { ...r, conditions: r.conditions.filter((_, j) => j !== ci) }) } : c); + } + + async function handleSave(): Promise { + if (config === null) return; + setSaving(true); setError(null); + try { const saved = await saveApprovalConfig(config); setConfig(saved); setNotice('审批规则已保存并生效'); } + catch (e) { setError(e instanceof Error ? e.message : '保存失败'); } + finally { setSaving(false); } + } + + const input: React.CSSProperties = { + padding: `${space(1)}px ${space(2)}px`, border: `1px solid ${colorVar('color.border.default')}`, + borderRadius: `${RADIUS.md}px`, fontFamily: FONT_FAMILY, ...typographyStyle('caption'), + backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'), + }; + + function valueEditor(cd: ApprovalCondition, onChange: (v: string | number | boolean) => void): JSX.Element { + const kind = FIELD_META[cd.field].kind; + if (kind === 'grade') return ; + if (kind === 'accept') return ; + if (kind === 'biz') return ; + if (kind === 'bool') return ; + return onChange(Number(e.target.value))} />; + } + + return ( +
+
+
+

审批流程管理

+

+ 流程:销售提交 → 风控审核 →(按规则)管理层审批。规则按顺序匹配,第一条命中者决定风控通过后是否需管理层终审;都不命中用「默认」。 +

+
+ +
+ + {error !== null &&
{error}
} + {notice !== null && ( +
+ {notice} + +
+ )} + + {loading || config === null ?

加载中…

: ( + <> + {/* 全局设置 */} + +
+ patch({ slaRiskHours: Number(e.target.value) })} /> + patch({ slaMgmtHours: Number(e.target.value) })} /> + + + + + + +
+
+ + {/* 规则列表 */} +
+

审批规则(按顺序匹配,{config.rules.length})

+ +
+ +
+ {config.rules.map((r, ri) => ( +
+
+ {ri + 1} + patchRule(ri, { name: e.target.value })} /> + + + + + +
+
当以下条件全部满足时命中:
+
+ {r.conditions.map((cd, ci) => { + const meta = FIELD_META[cd.field]; + const ops = meta.kind === 'number' || meta.kind === 'grade' ? OPS_NUM : OPS_EQ; + return ( +
+ + + {valueEditor(cd, (v) => patchCond(ri, ci, { value: v }))} + {meta.unit !== undefined && {meta.unit}} + +
+ ); + })} + +
+
+ ))} + {config.rules.length === 0 &&

暂无规则:所有项目按「默认是否需管理层」处理。

} +
+ + )} +
+ ); +} + +const iconBtn: React.CSSProperties = { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, + border: `1px solid var(--color-border-default)`, borderRadius: 6, background: 'transparent', cursor: 'pointer', color: 'var(--color-text-secondary)', +}; +function Field({ label, children }: { label: string; children: React.ReactNode }): JSX.Element { + return
{children}
; +}