审批流程管理(系统管理员)+ 系统管理员去首页

- 审批规则引擎 evaluateApproval(纯函数+8单测):有序条件规则决定风控通过后是否需管理层终审
- approval_config 表 + 持久化 + 默认规则种子(红线/不可接受/高风险/低毛利/大合同→需管理层;低风险达标→风控终批)
- 风控审核接入规则:低风险达标可风控终批,否则转管理层;审计记录命中规则
- GET/PUT /api/approval-config(PUT 限系统管理员);overdue SLA 改为读配置
- 审批流程配置页 WorkflowManagement(全局SLA/默认/驳回去向 + 规则与条件编辑器)
- 系统管理员去掉首页,登录落地用户管理;导航=用户管理+审批流程
This commit is contained in:
freedakgmail
2026-06-13 17:55:28 +08:00
parent 6562208b13
commit 757b9c4a69
11 changed files with 632 additions and 13 deletions
@@ -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;`);
};
+30
View File
@@ -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);
}
}
+1
View File
@@ -20,6 +20,7 @@ export * from './customers.js';
export * from './minWages.js'; export * from './minWages.js';
export * from './drafts.js'; export * from './drafts.js';
export * from './users.js'; export * from './users.js';
export * from './approvalConfig.js';
export * from './settings.js'; export * from './settings.js';
export * from './regionRates.js'; export * from './regionRates.js';
export * from './rejectReasons.js'; export * from './rejectReasons.js';
+90 -5
View File
@@ -72,6 +72,9 @@ import {
ensureSeedUsers, ensureSeedUsers,
USER_ROLES, USER_ROLES,
type UserRole, type UserRole,
getApprovalConfig,
saveApprovalConfig,
ensureApprovalConfig,
getSetting, getSetting,
setSetting, setSetting,
loadAllRegionRates, loadAllRegionRates,
@@ -101,7 +104,7 @@ import { buildMetricRedline, checkRedlines, type ComputableRedlineConfig, type R
import { generate, exportReport } from '../report/index.js'; import { generate, exportReport } from '../report/index.js';
import { analyzeProfitability, type ProfitabilityInputs } from '../cost/index.js'; import { analyzeProfitability, type ProfitabilityInputs } from '../cost/index.js';
import { NATIONAL_DEFAULT_RATES, REGION_RATES } 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 { RiskLevel } from '../domain/common.js';
import type { KnownData } from '../question/index.js'; import type { KnownData } from '../question/index.js';
import type { Region } from '../domain/region.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`, ORDER BY ws.entered_at ASC`,
); );
const now = Date.now(); 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 overdue = (res.rows as Array<Record<string, unknown>>).filter((r) => {
const enteredAt = new Date(String(r.entered_at)).getTime(); 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; return now - enteredAt > slaMs;
}).map((r) => ({ }).map((r) => ({
id: String(r.assessment_id), id: String(r.assessment_id),
@@ -1286,6 +1294,43 @@ app.delete('/api/users/:id', requireRole('系统管理员'), async (c) => {
return c.json({ deleted: true }); 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 * GET /api/region-rates/engine-defaults
* 引擎内置默认费率(全国 + 上海/北京/广东),作为维护对照基准。 * 引擎内置默认费率(全国 + 上海/北京/广东),作为维护对照基准。
@@ -1803,13 +1848,52 @@ app.post('/api/assessments/:id/review', requireRole('风控'), async (c) => {
if (current !== 'pending_risk_review') { if (current !== 'pending_risk_review') {
return c.json({ error: `当前状态为 ${current},无法审核` }, 400); return c.json({ error: `当前状态为 ${current},无法审核` }, 400);
} }
const newStatus: WorkflowStatus = let newStatus: WorkflowStatus;
body.action === 'approve' ? 'risk_reviewed' : 'rejected'; 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); setStatus(id, newStatus);
const reviewEntry: AuditLogEntry = { const reviewEntry: AuditLogEntry = {
role: '风控', role: '风控',
username: body.user, username: body.user,
action: body.action === 'approve' ? '风控审核通过' : '风控驳回(退回销售)', action: actionText,
...(body.comment !== undefined && body.comment !== '' ? { comment: body.comment } : {}), ...(body.comment !== undefined && body.comment !== '' ? { comment: body.comment } : {}),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
@@ -2221,6 +2305,7 @@ async function start(): Promise<void> {
for (const [id, v] of arc) archivedById.set(id, v); for (const [id, v] of arc) archivedById.set(id, v);
calibratedTargetBase = (await getSetting(pool, 'targetMarginBase')) ?? null; calibratedTargetBase = (await getSetting(pool, 'targetMarginBase')) ?? null;
await ensureSeedUsers(pool); await ensureSeedUsers(pool);
await ensureApprovalConfig(pool);
console.log(`PostgreSQL 持久化已启用,已加载 ${store.getAll().length} 条评估记录`); console.log(`PostgreSQL 持久化已启用,已加载 ${store.getAll().length} 条评估记录`);
} catch (err) { } catch (err) {
console.error('PostgreSQL 连接失败,回退到进程内存储:', err instanceof Error ? err.message : 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);
});
});
+143
View File
@@ -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 };
}
+1
View File
@@ -10,3 +10,4 @@ export * from './measures.js';
export * from './acceptanceConditions.js'; export * from './acceptanceConditions.js';
export * from './recommendation.js'; export * from './recommendation.js';
export * from './operatingControls.js'; export * from './operatingControls.js';
export * from './approvalRules.js';
+11 -2
View File
@@ -12,6 +12,7 @@ import { RateManagement } from './pages/RateManagement.js';
import { RedlineManagement } from './pages/RedlineManagement.js'; import { RedlineManagement } from './pages/RedlineManagement.js';
import { CustomerManagement } from './pages/CustomerManagement.js'; import { CustomerManagement } from './pages/CustomerManagement.js';
import { UserManagement } from './pages/UserManagement.js'; import { UserManagement } from './pages/UserManagement.js';
import { WorkflowManagement } from './pages/WorkflowManagement.js';
/** 路由守卫:未登录重定向到登录页。 */ /** 路由守卫:未登录重定向到登录页。 */
function ProtectedRoute(): JSX.Element { function ProtectedRoute(): JSX.Element {
@@ -26,6 +27,13 @@ function RoleRoute({ allow }: { readonly allow: readonly string[] }): JSX.Elemen
return allow.includes(role) ? <Outlet /> : <Navigate to="/" replace />; return allow.includes(role) ? <Outlet /> : <Navigate to="/" replace />;
} }
/** 首页:系统管理员不关心业务,重定向到用户管理;其余角色看评估看板。 */
function HomeRoute(): JSX.Element {
const { user } = useAuthStore();
if (user?.role === '系统管理员') return <Navigate to="/users" replace />;
return <Dashboard />;
}
export function App(): JSX.Element { export function App(): JSX.Element {
return ( return (
<BrowserRouter <BrowserRouter
@@ -38,7 +46,7 @@ export function App(): JSX.Element {
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route element={<AppShell />}> <Route element={<AppShell />}>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<HomeRoute />} />
<Route path="/new" element={<NewAssessment />} /> <Route path="/new" element={<NewAssessment />} />
<Route path="/assessments/:id" element={<AssessmentDetail />} /> <Route path="/assessments/:id" element={<AssessmentDetail />} />
{/* 费率/红线管理:仅管理层 */} {/* 费率/红线管理:仅管理层 */}
@@ -50,9 +58,10 @@ export function App(): JSX.Element {
<Route element={<RoleRoute allow={['商务/销售', '管理层']} />}> <Route element={<RoleRoute allow={['商务/销售', '管理层']} />}>
<Route path="/customers" element={<CustomerManagement />} /> <Route path="/customers" element={<CustomerManagement />} />
</Route> </Route>
{/* 用户管理:系统管理员 */} {/* 用户管理 + 审批流程:系统管理员 */}
<Route element={<RoleRoute allow={['系统管理员']} />}> <Route element={<RoleRoute allow={['系统管理员']} />}>
<Route path="/users" element={<UserManagement />} /> <Route path="/users" element={<UserManagement />} />
<Route path="/workflow" element={<WorkflowManagement />} />
</Route> </Route>
</Route> </Route>
</Route> </Route>
+42
View File
@@ -844,6 +844,48 @@ export async function deleteUserApi(id: string): Promise<void> {
await fetch(`${API_BASE}/api/users/${encodeURIComponent(id)}`, { method: 'DELETE', headers: authHeader() }); 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<ApprovalConfig> {
return request<ApprovalConfig>('GET', '/api/approval-config');
}
/** 保存审批流程配置(系统管理员)。 */
export async function saveApprovalConfig(config: ApprovalConfig): Promise<ApprovalConfig> {
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 { export interface ScenarioItem {
id: string; id: string;
+13 -6
View File
@@ -141,9 +141,11 @@ export function AppShell(): JSX.Element {
role="group" role="group"
aria-label="导航与用户信息" aria-label="导航与用户信息"
> >
<span data-nav-link style={navLinkStyle('/')} onClick={() => navigate('/')}> {role !== '系统管理员' && (
<span data-nav-link style={navLinkStyle('/')} onClick={() => navigate('/')}>
</span>
</span>
)}
{role === '商务/销售' && ( {role === '商务/销售' && (
<span data-nav-link style={navLinkStyle('/new')} onClick={() => navigate('/new')}> <span data-nav-link style={navLinkStyle('/new')} onClick={() => navigate('/new')}>
@@ -181,9 +183,14 @@ export function AppShell(): JSX.Element {
)} )}
{role === '系统管理员' && ( {role === '系统管理员' && (
<span data-nav-link style={navLinkStyle('/users')} onClick={() => navigate('/users')}> <>
<span data-nav-link style={navLinkStyle('/users')} onClick={() => navigate('/users')}>
</span>
</span>
<span data-nav-link style={navLinkStyle('/workflow')} onClick={() => navigate('/workflow')}>
</span>
</>
)} )}
<GlossaryButton /> <GlossaryButton />
+217
View File
@@ -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<ApprovalField, { label: string; kind: 'grade' | 'number' | 'accept' | 'bool' | 'biz'; unit?: string }> = {
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<ApprovalConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(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<ApprovalConfig>): void { setConfig((c) => (c ? { ...c, ...p } : c)); }
function patchRule(idx: number, p: Partial<ApprovalRule>): 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<ApprovalCondition>): 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<void> {
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 <select style={input} value={String(cd.value)} onChange={(e) => onChange(e.target.value)}>{GRADES.map((g) => <option key={g} value={g}>{g}</option>)}</select>;
if (kind === 'accept') return <select style={input} value={String(cd.value)} onChange={(e) => onChange(e.target.value)}>{ACCEPTS.map((a) => <option key={a} value={a}>{a}</option>)}</select>;
if (kind === 'biz') return <select style={input} value={String(cd.value)} onChange={(e) => onChange(e.target.value)}>{BIZ.map((b) => <option key={b} value={b}>{b}</option>)}</select>;
if (kind === 'bool') return <select style={input} value={String(cd.value)} onChange={(e) => onChange(e.target.value === 'true')}><option value="true"></option><option value="false"></option></select>;
return <input style={{ ...input, width: 110 }} type="number" value={Number(cd.value)} onChange={(e) => onChange(Number(e.target.value))} />;
}
return (
<div style={{ fontFamily: FONT_FAMILY, maxWidth: 1100, margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: `${space(4)}px` }}>
<div>
<h1 style={{ ...typographyStyle('heading'), color: colorVar('color.text.primary'), margin: 0 }}></h1>
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `${space(1)}px 0 0`, maxWidth: 720, lineHeight: 1.7 }}>
<strong> </strong>
</p>
</div>
<button onClick={() => { void handleSave(); }} disabled={saving || config === null}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: saving ? 'not-allowed' : 'pointer', opacity: saving ? 0.6 : 1, fontWeight: 600 }}>
<Icon name="save" size={16} color="#fff" /> {saving ? '保存中…' : '保存规则'}
</button>
</div>
{error !== null && <div style={{ padding: `${space(3)}px`, backgroundColor: 'rgba(190,18,60,0.08)', color: colorVar('color.risk.critical'), borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px` }}>{error}</div>}
{notice !== null && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: `${space(3)}px`, backgroundColor: 'rgba(16,128,61,0.08)', color: '#15803D', borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px`, fontWeight: 600 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><Icon name="check-circle" size={16} /> {notice}</span>
<button type="button" onClick={() => setNotice(null)} style={{ display: 'inline-flex', border: 'none', background: 'transparent', color: '#15803D', cursor: 'pointer' }} aria-label="关闭"><Icon name="close" size={16} /></button>
</div>
)}
{loading || config === null ? <p style={{ color: colorVar('color.text.secondary') }}></p> : (
<>
{/* 全局设置 */}
<Card title="全局设置">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: `${space(3)}px` }}>
<Field label="风控审核 SLA(小时)"><input style={{ ...input, width: '100%' }} type="number" value={config.slaRiskHours} onChange={(e) => patch({ slaRiskHours: Number(e.target.value) })} /></Field>
<Field label="管理层审批 SLA(小时)"><input style={{ ...input, width: '100%' }} type="number" value={config.slaMgmtHours} onChange={(e) => patch({ slaMgmtHours: Number(e.target.value) })} /></Field>
<Field label="默认是否需管理层(无规则命中时)">
<select style={{ ...input, width: '100%' }} value={String(config.defaultRequireManagement)} onChange={(e) => patch({ defaultRequireManagement: e.target.value === 'true' })}>
<option value="true"></option>
<option value="false"></option>
</select>
</Field>
<Field label="管理层驳回默认去向">
<select style={{ ...input, width: '100%' }} value={config.rejectTo} onChange={(e) => patch({ rejectTo: e.target.value as 'origin' | 'risk' })}>
<option value="risk">退</option>
<option value="origin">退</option>
</select>
</Field>
</div>
</Card>
{/* 规则列表 */}
<div style={{ marginTop: `${space(4)}px`, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h2 style={{ ...typographyStyle('title'), margin: 0, color: colorVar('color.text.primary') }}>{config.rules.length}</h2>
<button onClick={addRule} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.brand.primary')}`, background: 'transparent', color: colorVar('color.brand.primary'), cursor: 'pointer', fontWeight: 600, ...typographyStyle('caption') }}>
<Icon name="plus" size={14} />
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(3)}px`, marginTop: `${space(3)}px` }}>
{config.rules.map((r, ri) => (
<div key={r.id} style={{ padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: SHADOW.sm, opacity: r.enabled ? 1 : 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, flexWrap: 'wrap', marginBottom: `${space(2)}px` }}>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: 22, height: 22, borderRadius: 6, backgroundColor: colorVar('color.bg.surface'), ...typographyStyle('caption'), fontWeight: 700, color: colorVar('color.text.secondary') }}>{ri + 1}</span>
<input style={{ ...input, flex: '1 1 200px', ...typographyStyle('body'), fontWeight: 600 }} value={r.name} onChange={(e) => patchRule(ri, { name: e.target.value })} />
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 4, ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
<input type="checkbox" checked={r.enabled} onChange={(e) => patchRule(ri, { enabled: e.target.checked })} />
</label>
<select style={input} value={String(r.requireManagement)} onChange={(e) => patchRule(ri, { requireManagement: e.target.value === 'true' })}>
<option value="true"> </option>
<option value="false"> </option>
</select>
<button onClick={() => moveRule(ri, -1)} title="上移" style={iconBtn}><Icon name="chevron-down" size={14} /></button>
<button onClick={() => moveRule(ri, 1)} title="下移" style={{ ...iconBtn, transform: 'rotate(180deg)' }}><Icon name="chevron-down" size={14} /></button>
<button onClick={() => removeRule(ri)} title="删除规则" style={{ ...iconBtn, color: colorVar('color.risk.critical') }}><Icon name="close" size={15} /></button>
</div>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginBottom: 6 }}><strong></strong></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(1)}px` }}>
{r.conditions.map((cd, ci) => {
const meta = FIELD_META[cd.field];
const ops = meta.kind === 'number' || meta.kind === 'grade' ? OPS_NUM : OPS_EQ;
return (
<div key={ci} style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, flexWrap: 'wrap' }}>
<select style={input} value={cd.field} onChange={(e) => { const f = e.target.value as ApprovalField; patchCond(ri, ci, { field: f, value: defaultValueFor(f) }); }}>
{FIELDS.map((f) => <option key={f} value={f}>{FIELD_META[f].label}</option>)}
</select>
<select style={input} value={cd.op} onChange={(e) => patchCond(ri, ci, { op: e.target.value as ApprovalOp })}>
{ops.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
{valueEditor(cd, (v) => patchCond(ri, ci, { value: v }))}
{meta.unit !== undefined && <span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{meta.unit}</span>}
<button onClick={() => removeCond(ri, ci)} title="删除条件" style={{ ...iconBtn, color: colorVar('color.risk.critical') }}><Icon name="close" size={13} /></button>
</div>
);
})}
<button onClick={() => addCond(ri)} style={{ alignSelf: 'flex-start', display: 'inline-flex', alignItems: 'center', gap: 4, border: 'none', background: 'none', color: colorVar('color.brand.primary'), cursor: 'pointer', ...typographyStyle('caption'), fontWeight: 600 }}>
<Icon name="plus" size={13} />
</button>
</div>
</div>
))}
{config.rules.length === 0 && <p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}></p>}
</div>
</>
)}
</div>
);
}
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 <div><label style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'block', marginBottom: 4 }}>{label}</label>{children}</div>;
}