From 11997e61044230f758fceea896cc28c9fde9785d Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Sat, 13 Jun 2026 18:26:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E6=B5=81=E7=A8=8B=EF=BC=9A?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=90=8D=E6=94=B9=E4=B8=BA=E5=B8=B8=E8=A7=81?= =?UTF-8?q?=E4=BA=BA=E5=90=8D=20+=20=E6=8C=89=E9=94=80=E5=94=AE=E5=BD=92?= =?UTF-8?q?=E5=B1=9E=E7=9A=84=E5=AE=A1=E6=89=B9=E7=BA=BF=E6=8C=87=E6=B4=BE?= =?UTF-8?q?=EF=BC=88=E8=BD=AF=E7=BA=A6=E6=9D=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用户名改为常见人名(张伟/王芳/李娜/刘洋/陈静/赵磊/孙莉/周强) - 审批线模型:每个销售→指定风控+管理层审批人(含默认线兜底),resolveAssignees 纯函数+4单测 - 提交时按销售归属计算并落库指派(assessment_assignments 表) - 软约束:待办默认只看分给我的(含未指派),同角色他人仍可代审;详情页显示指派审批人 - 审批流程页新增「审批人指派·审批线」配置区(启用/默认线/按销售配线) - 配置 GET/PUT 扩展 assignment;getApprovalConfig 向后兼容回填 --- .../1730000025000_assessment_assignments.cjs | 22 ++++++ src/persistence/approvalConfig.ts | 9 ++- src/persistence/assignments.ts | 52 ++++++++++++++ src/persistence/index.ts | 1 + src/server/index.ts | 55 ++++++++++++++- src/strategy/__tests__/approvalRules.test.ts | 30 +++++++- src/strategy/approvalRules.ts | 39 +++++++++++ web/src/api/client.ts | 28 ++++++++ web/src/pages/AssessmentDetail.tsx | 14 ++++ web/src/pages/Dashboard.tsx | 61 +++++++++++++--- web/src/pages/WorkflowManagement.tsx | 69 ++++++++++++++++++- 11 files changed, 364 insertions(+), 16 deletions(-) create mode 100644 migrations/1730000025000_assessment_assignments.cjs create mode 100644 src/persistence/assignments.ts diff --git a/migrations/1730000025000_assessment_assignments.cjs b/migrations/1730000025000_assessment_assignments.cjs new file mode 100644 index 0000000..37307dd --- /dev/null +++ b/migrations/1730000025000_assessment_assignments.cjs @@ -0,0 +1,22 @@ +/* eslint-disable */ +/** + * 审批人指派:记录每个评估被指派的风控审批人与管理层审批人(按审批线在提交时计算)。 + * 软约束——仅作展示/过滤与审计,不强制拦截同角色其他人代审。 + */ + +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS assessment_assignments ( + assessment_id TEXT PRIMARY KEY, + risk_reviewer_id TEXT, + risk_reviewer_name TEXT, + manager_id TEXT, + manager_name TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); +}; + +exports.down = (pgm) => { + pgm.sql(`DROP TABLE IF EXISTS assessment_assignments;`); +}; diff --git a/src/persistence/approvalConfig.ts b/src/persistence/approvalConfig.ts index 3e449b6..183ee56 100644 --- a/src/persistence/approvalConfig.ts +++ b/src/persistence/approvalConfig.ts @@ -4,12 +4,17 @@ 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; + const stored = row.config as Partial; + return { + ...DEFAULT_APPROVAL_CONFIG, + ...stored, + assignment: { ...DEFAULT_APPROVAL_CONFIG.assignment, ...(stored.assignment ?? {}) }, + } as ApprovalConfig; } /** 保存审批配置(upsert 单行)。 */ diff --git a/src/persistence/assignments.ts b/src/persistence/assignments.ts new file mode 100644 index 0000000..8cd09ab --- /dev/null +++ b/src/persistence/assignments.ts @@ -0,0 +1,52 @@ +/** + * 审批人指派持久化:评估 → 指定风控/管理层审批人。 + */ +import type pg from 'pg'; + +export interface AssignmentRecord { + assessmentId: string; + riskReviewerId: string | null; + riskReviewerName: string | null; + managerId: string | null; + managerName: string | null; +} + +function mapRow(r: Record): AssignmentRecord { + return { + assessmentId: String(r.assessment_id), + riskReviewerId: r.risk_reviewer_id != null ? String(r.risk_reviewer_id) : null, + riskReviewerName: r.risk_reviewer_name != null ? String(r.risk_reviewer_name) : null, + managerId: r.manager_id != null ? String(r.manager_id) : null, + managerName: r.manager_name != null ? String(r.manager_name) : null, + }; +} + +/** 写入/更新某评估的指派。 */ +export async function setAssignment(pool: pg.Pool, a: AssignmentRecord): Promise { + await pool.query( + `INSERT INTO assessment_assignments(assessment_id, risk_reviewer_id, risk_reviewer_name, manager_id, manager_name, updated_at) + VALUES($1,$2,$3,$4,$5,now()) + ON CONFLICT(assessment_id) DO UPDATE SET + risk_reviewer_id=EXCLUDED.risk_reviewer_id, risk_reviewer_name=EXCLUDED.risk_reviewer_name, + manager_id=EXCLUDED.manager_id, manager_name=EXCLUDED.manager_name, updated_at=now()`, + [a.assessmentId, a.riskReviewerId, a.riskReviewerName, a.managerId, a.managerName], + ); +} + +/** 取某评估的指派。 */ +export async function getAssignment(pool: pg.Pool, assessmentId: string): Promise { + const res = await pool.query('SELECT * FROM assessment_assignments WHERE assessment_id=$1', [assessmentId]); + const r = (res.rows as Array>)[0]; + return r ? mapRow(r) : null; +} + +/** 全部指派(assessmentId → 记录),供列表/看板合并。 */ +export async function loadAllAssignments(pool: pg.Pool): Promise> { + const res = await pool.query('SELECT * FROM assessment_assignments'); + const out: Record = {}; + for (const r of res.rows as Array>) { + const rec = mapRow(r); + out[rec.assessmentId] = rec; + } + return out; +} diff --git a/src/persistence/index.ts b/src/persistence/index.ts index d37cd71..831fee6 100644 --- a/src/persistence/index.ts +++ b/src/persistence/index.ts @@ -21,6 +21,7 @@ export * from './minWages.js'; export * from './drafts.js'; export * from './users.js'; export * from './approvalConfig.js'; +export * from './assignments.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 458e865..5fb6e3a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -75,6 +75,9 @@ import { getApprovalConfig, saveApprovalConfig, ensureApprovalConfig, + setAssignment, + getAssignment, + loadAllAssignments, getSetting, setSetting, loadAllRegionRates, @@ -104,7 +107,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, evaluateApproval, DEFAULT_APPROVAL_CONFIG, type ApprovalConfig, type ApprovalRule, type ApprovalCondition } from '../strategy/index.js'; +import { combineRecommendation, DEFAULT_TARGET_NET_MARGIN, buildOperatingControls, decide, hasRedlineHit, targetMarginForGrade, evaluateApproval, resolveAssignees, DEFAULT_APPROVAL_CONFIG, type ApprovalConfig, type ApprovalRule, type ApprovalCondition, type ApprovalLine } 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'; @@ -1326,11 +1329,31 @@ app.put('/api/approval-config', requireRole('系统管理员'), async (c) => { value: cd.value, })) : [], })), + assignment: { + enabled: body.assignment?.enabled === true, + defaultRiskReviewerId: body.assignment?.defaultRiskReviewerId ?? null, + defaultManagerId: body.assignment?.defaultManagerId ?? null, + lines: Array.isArray(body.assignment?.lines) + ? body.assignment.lines + .filter((l: ApprovalLine) => typeof l.salesId === 'string' && l.salesId !== '') + .map((l: ApprovalLine) => ({ + salesId: l.salesId, + riskReviewerId: l.riskReviewerId ?? null, + managerId: l.managerId ?? null, + })) + : [], + }, }; await saveApprovalConfig(pool, cfg); return c.json(cfg); }); +/** 全部评估的审批人指派(assessmentId → 风控/管理层审批人),供看板与详情合并展示。 */ +app.get('/api/assignments', async (c) => { + if (pool === null) return c.json({}); + return c.json(await loadAllAssignments(pool)); +}); + /** * GET /api/region-rates/engine-defaults * 引擎内置默认费率(全国 + 上海/北京/广东),作为维护对照基准。 @@ -1670,6 +1693,8 @@ app.get('/api/assessments/:id', async (c) => { const ev = (er.rows[0] as { expires_at?: unknown } | undefined)?.expires_at; if (ev != null) expiresAt = ev instanceof Date ? ev.toISOString() : String(ev); } + // 审批人指派(按审批线,软约束)。 + const assignment = pool !== null ? await getAssignment(pool, id).catch(() => null) : null; return c.json({ assessment: record.assessment, report: record.report, @@ -1684,6 +1709,7 @@ app.get('/api/assessments/:id', async (c) => { redlineTitles, profitabilityInputs: profitabilityInputsById.get(id) ?? null, expiresAt, + assignment, }); }); @@ -2221,6 +2247,32 @@ app.post('/api/assessments/:id/redline-verdict', requireRole('风控', '管理 return c.json({ redlineId: body.redlineId, status: body.status, acceptability: a.acceptability }); }); +/** 申报:销售将「草稿待申报」评估报送风控审核。 */ +/** + * 按审批线为评估指派审批人(软约束,仅展示/过滤/审计)。 + * 据提交销售解析审批线 → 指定风控/管理层;写入 assessment_assignments。 + */ +async function assignApprovers(id: string, salesUsername: string): Promise { + if (pool === null) return; + const cfg = await getApprovalConfig(pool); + if (!cfg.assignment.enabled) return; + const sales = await getUserByUsername(pool, salesUsername); + const { riskReviewerId, managerId } = resolveAssignees(cfg.assignment, sales?.id); + const users = await listUsers(pool); + const nameOf = (uid: string | null): string | null => { + if (uid === null) return null; + const u = users.find((x) => x.id === uid); + return u ? u.username : null; + }; + await setAssignment(pool, { + assessmentId: id, + riskReviewerId, + riskReviewerName: nameOf(riskReviewerId), + managerId, + managerName: nameOf(managerId), + }); +} + /** 申报:销售将「草稿待申报」评估报送风控审核。 */ app.post('/api/assessments/:id/submit', async (c) => { const id = c.req.param('id'); @@ -2244,6 +2296,7 @@ app.post('/api/assessments/:id/submit', async (c) => { if (pool !== null) { await persistStatus(pool, id, 'pending_risk_review'); await persistAudit(pool, id, submitEntry); + await assignApprovers(id, body.user).catch(() => undefined); } return c.json({ status: 'pending_risk_review' }); }); diff --git a/src/strategy/__tests__/approvalRules.test.ts b/src/strategy/__tests__/approvalRules.test.ts index 8f862b4..d2dae04 100644 --- a/src/strategy/__tests__/approvalRules.test.ts +++ b/src/strategy/__tests__/approvalRules.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { evaluateApproval, DEFAULT_APPROVAL_CONFIG, type ApprovalConfig } from '../approvalRules.js'; +import { evaluateApproval, resolveAssignees, DEFAULT_APPROVAL_CONFIG, type ApprovalConfig } from '../approvalRules.js'; describe('审批规则引擎 evaluateApproval', () => { it('命中红线 → 需管理层', () => { @@ -63,3 +63,31 @@ describe('审批规则引擎 evaluateApproval', () => { expect(d.requireManagement).toBe(false); }); }); + +describe('审批线指派 resolveAssignees', () => { + const base = { + enabled: true, + defaultRiskReviewerId: 'r-default', + defaultManagerId: 'm-default', + lines: [ + { salesId: 's1', riskReviewerId: 'r1', managerId: 'm1' }, + { salesId: 's2', riskReviewerId: 'r2', managerId: null }, + ], + }; + + it('匹配到销售审批线 → 用线上指定审批人', () => { + expect(resolveAssignees(base, 's1')).toEqual({ riskReviewerId: 'r1', managerId: 'm1' }); + }); + + it('线上某项为空 → 回退默认', () => { + expect(resolveAssignees(base, 's2')).toEqual({ riskReviewerId: 'r2', managerId: 'm-default' }); + }); + + it('未匹配销售 → 用默认审批线', () => { + expect(resolveAssignees(base, 'sX')).toEqual({ riskReviewerId: 'r-default', managerId: 'm-default' }); + }); + + it('未启用 → 不指派', () => { + expect(resolveAssignees({ ...base, enabled: false }, 's1')).toEqual({ riskReviewerId: null, managerId: null }); + }); +}); diff --git a/src/strategy/approvalRules.ts b/src/strategy/approvalRules.ts index d33133c..431a723 100644 --- a/src/strategy/approvalRules.ts +++ b/src/strategy/approvalRules.ts @@ -35,6 +35,24 @@ export interface ApprovalRule { readonly conditions: readonly ApprovalCondition[]; } +/** 审批人指派——单条审批线(某销售 → 指定风控/管理层)。 */ +export interface ApprovalLine { + readonly salesId: string; + readonly riskReviewerId: string | null; + readonly managerId: string | null; +} + +/** 审批人指派配置(按销售归属)。 */ +export interface ApprovalAssignment { + /** 是否启用指派(关闭时不指派,任何同角色都可处理)。 */ + readonly enabled: boolean; + /** 默认审批线(未匹配到销售时兜底)。 */ + readonly defaultRiskReviewerId: string | null; + readonly defaultManagerId: string | null; + /** 按销售归属的审批线。 */ + readonly lines: readonly ApprovalLine[]; +} + /** 审批流程配置。 */ export interface ApprovalConfig { /** 无规则命中时是否需要管理层审批(兜底)。 */ @@ -47,6 +65,8 @@ export interface ApprovalConfig { readonly rejectTo: 'origin' | 'risk'; /** 有序规则集(按顺序匹配,第一条命中生效)。 */ readonly rules: readonly ApprovalRule[]; + /** 审批人指派(按销售归属审批线,软约束)。 */ + readonly assignment: ApprovalAssignment; } /** 评估上下文(供规则匹配)。 */ @@ -83,8 +103,27 @@ export const DEFAULT_APPROVAL_CONFIG: ApprovalConfig = { { 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: '中' }] }, ], + assignment: { + enabled: false, + defaultRiskReviewerId: null, + defaultManagerId: null, + lines: [], + }, }; +/** 按销售归属解析该项目的指定审批人(风控/管理层);未匹配用默认线。 */ +export function resolveAssignees( + assignment: ApprovalAssignment, + salesId: string | undefined, +): { riskReviewerId: string | null; managerId: string | null } { + if (!assignment.enabled) return { riskReviewerId: null, managerId: null }; + const line = salesId !== undefined ? assignment.lines.find((l) => l.salesId === salesId) : undefined; + return { + riskReviewerId: line?.riskReviewerId ?? assignment.defaultRiskReviewerId, + managerId: line?.managerId ?? assignment.defaultManagerId, + }; +} + /** 取条件左值(从上下文)。riskGrade 转为序号以支持数值比较。 */ function leftValue(field: ApprovalField, ctx: ApprovalContext): string | number | boolean | undefined { switch (field) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index eabbbe3..679e201 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -471,6 +471,8 @@ export interface AssessmentDetailResponse { readonly profitabilityInputs?: ProfitabilityInputs | null; /** 评估有效期(到期需重新评估)。 */ readonly expiresAt?: string | null; + /** 审批人指派(按审批线,软约束)。 */ + readonly assignment?: { riskReviewerName: string | null; managerName: string | null } | null; } /** 获取单条评估详情。 */ @@ -861,12 +863,38 @@ export interface ApprovalRule { requireManagement: boolean; conditions: ApprovalCondition[]; } +export interface ApprovalLine { + salesId: string; + riskReviewerId: string | null; + managerId: string | null; +} +export interface ApprovalAssignment { + enabled: boolean; + defaultRiskReviewerId: string | null; + defaultManagerId: string | null; + lines: ApprovalLine[]; +} export interface ApprovalConfig { defaultRequireManagement: boolean; slaRiskHours: number; slaMgmtHours: number; rejectTo: 'origin' | 'risk'; rules: ApprovalRule[]; + assignment: ApprovalAssignment; +} + +/** 审批人指派记录。 */ +export interface AssignmentRecord { + assessmentId: string; + riskReviewerId: string | null; + riskReviewerName: string | null; + managerId: string | null; + managerName: string | null; +} + +/** 全部评估的审批人指派(assessmentId → 记录)。 */ +export async function fetchAssignments(): Promise> { + return request>('GET', '/api/assignments'); } /** 读取审批流程配置。 */ diff --git a/web/src/pages/AssessmentDetail.tsx b/web/src/pages/AssessmentDetail.tsx index da4affe..981cf58 100644 --- a/web/src/pages/AssessmentDetail.tsx +++ b/web/src/pages/AssessmentDetail.tsx @@ -214,6 +214,7 @@ export function AssessmentDetail(): JSX.Element { } const [status, setStatus] = useState('pending_risk_review'); const [auditLog, setAuditLog] = useState([]); + const [assignment, setAssignment] = useState<{ riskReviewerName: string | null; managerName: string | null } | null>(null); const [actionLoading, setActionLoading] = useState(false); const [reloadKey, setReloadKey] = useState(0); const [comment, setComment] = useState(''); @@ -248,6 +249,7 @@ export function AssessmentDetail(): JSX.Element { if (cancelled) return; setStatus(data.status); setAuditLog(data.auditLog); + setAssignment(data.assignment ?? null); setProfitability(data.profitability ?? null); setProfitabilityInputs(data.profitabilityInputs ?? null); setExpiresAt(data.expiresAt ?? null); @@ -630,6 +632,18 @@ export function AssessmentDetail(): JSX.Element { ); })()} + {/* 指派审批人(按审批线) */} + {assignment !== null && (assignment.riskReviewerName !== null || assignment.managerName !== null) && ( +
+ + 指派审批人 + + 风控:{assignment.riskReviewerName ?? '未指派'} + 管理层:{assignment.managerName ?? '未指派'} + (软约束:同角色其他人亦可代审) +
+ )} + {/* 操作记录(时间线,默认折叠) */} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 00fc737..3588ce5 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -17,8 +17,8 @@ import { space, typographyStyle, } from '../design-system/components/styles.js'; -import { fetchAssessmentsPage, fetchSummary, archiveAssessment, applyCalibration, listDrafts, deleteDraftApi, API_BASE } from '../api/client.js'; -import type { AssessmentListItem, WorkflowStatus, DraftItem } from '../api/client.js'; +import { fetchAssessmentsPage, fetchSummary, archiveAssessment, applyCalibration, listDrafts, deleteDraftApi, fetchAssignments, API_BASE } from '../api/client.js'; +import type { AssessmentListItem, WorkflowStatus, DraftItem, AssignmentRecord } from '../api/client.js'; import { useAuthStore } from '../stores/authStore.js'; import { GuideBanner } from '../app/Guidance.js'; import { RiskBadge } from '../charts/index.js'; @@ -104,6 +104,9 @@ export function Dashboard(): JSX.Element { // 待办列表 const [todoItems, setTodoItems] = useState([]); + // 审批人指派(assessmentId → 记录);待办软过滤用。 + const [assignments, setAssignments] = useState>({}); + const [onlyMine, setOnlyMine] = useState(true); // 草稿箱(销售:未运行/未提交的向导进度,服务端持久化) const [drafts, setDrafts] = useState([]); // 统计 @@ -160,6 +163,12 @@ export function Dashboard(): JSX.Element { } else { setDrafts([]); } + // 审批人指派(风控/管理层待办软过滤用)。 + if (role === '风控' || role === '管理层') { + fetchAssignments().then(setAssignments).catch(() => setAssignments({})); + } else { + setAssignments({}); + } }, [role, user?.username]); /** 删除一条草稿并刷新草稿箱。 */ @@ -507,13 +516,47 @@ export function Dashboard(): JSX.Element { )} - {todoItems.length > 0 && ( -
- - row.id} caption={`${role} 待处理列表`} emptyMessage="当前没有需要你处理的评估" /> - - - )} + {todoItems.length > 0 && (() => { + const myName = user?.username; + const assignedToMe = (id: string): boolean => { + const a = assignments[id]; + if (a === undefined) return false; + return role === '风控' ? a.riskReviewerName === myName : role === '管理层' ? a.managerName === myName : false; + }; + const isAssigned = (id: string): boolean => { + const a = assignments[id]; + if (a === undefined) return false; + return role === '风控' ? a.riskReviewerName !== null : role === '管理层' ? a.managerName !== null : false; + }; + // 软约束:默认只看分给我的;未指派的也展示(避免遗漏)。 + const shown = onlyMine ? todoItems.filter((t) => assignedToMe(t.id) || !isAssigned(t.id)) : todoItems; + const assignCol: TableColumn = { + key: 'assignee', + header: '指派审批人', + render: (r) => { + const a = assignments[r.id]; + const name = a !== undefined ? (role === '管理层' ? a.managerName : a.riskReviewerName) : null; + if (name === null || name === undefined) return 未指派; + const mine = name === myName; + return {name}{mine ? '(我)' : ''}; + }, + }; + const todoColumns = [...columns.slice(0, -1), assignCol, columns[columns.length - 1]!]; + return ( +
+ + 待处理 ({shown.length}) + +
+ }> +
row.id} caption={`${role} 待处理列表`} emptyMessage={onlyMine ? '没有分给你的待处理项(可取消勾选查看全部)' : '当前没有需要你处理的评估'} /> + + + ); + })()} {role === '商务/销售' && drafts.length > 0 && (
diff --git a/web/src/pages/WorkflowManagement.tsx b/web/src/pages/WorkflowManagement.tsx index 3aa0599..e35dc50 100644 --- a/web/src/pages/WorkflowManagement.tsx +++ b/web/src/pages/WorkflowManagement.tsx @@ -9,8 +9,9 @@ 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, + fetchApprovalConfig, saveApprovalConfig, listUsers, type ApprovalConfig, type ApprovalRule, type ApprovalCondition, type ApprovalField, type ApprovalOp, + type ApprovalLine, type UserItem, } from '../api/client.js'; const FIELD_META: Record = { @@ -39,6 +40,7 @@ function defaultValueFor(field: ApprovalField): string | number | boolean { export function WorkflowManagement(): JSX.Element { const [config, setConfig] = useState(null); + const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -46,7 +48,10 @@ export function WorkflowManagement(): JSX.Element { const load = useCallback(() => { setLoading(true); - fetchApprovalConfig().then(setConfig).catch((e: unknown) => setError(e instanceof Error ? e.message : '加载失败')).finally(() => setLoading(false)); + Promise.all([fetchApprovalConfig(), listUsers().catch(() => [])]) + .then(([cfg, us]) => { setConfig(cfg); setUsers(us); }) + .catch((e: unknown) => setError(e instanceof Error ? e.message : '加载失败')) + .finally(() => setLoading(false)); }, []); useEffect(() => { load(); }, [load]); @@ -82,6 +87,21 @@ export function WorkflowManagement(): JSX.Element { setConfig((c) => c ? { ...c, rules: c.rules.map((r, i) => i !== ri ? r : { ...r, conditions: r.conditions.filter((_, j) => j !== ci) }) } : c); } + /* ---- 审批人指派(审批线) ---- */ + function patchAssign(p: Partial): void { + setConfig((c) => (c ? { ...c, assignment: { ...c.assignment, ...p } } : c)); + } + function addLine(): void { + const firstSales = users.find((u) => u.role === '商务/销售'); + setConfig((c) => c ? { ...c, assignment: { ...c.assignment, lines: [...c.assignment.lines, { salesId: firstSales?.id ?? '', riskReviewerId: null, managerId: null }] } } : c); + } + function patchLine(idx: number, p: Partial): void { + setConfig((c) => c ? { ...c, assignment: { ...c.assignment, lines: c.assignment.lines.map((l, i) => i === idx ? { ...l, ...p } : l) } } : c); + } + function removeLine(idx: number): void { + setConfig((c) => c ? { ...c, assignment: { ...c.assignment, lines: c.assignment.lines.filter((_, i) => i !== idx) } } : c); + } + async function handleSave(): Promise { if (config === null) return; setSaving(true); setError(null); @@ -96,6 +116,12 @@ export function WorkflowManagement(): JSX.Element { backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'), }; + const lbl: React.CSSProperties = { ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginBottom: 2 }; + function userOpts(role: UserItem['role']): JSX.Element[] { + const opts = users.filter((u) => u.role === role && u.active).map((u) => ); + return [, ...opts]; + } + function valueEditor(cd: ApprovalCondition, onChange: (v: string | number | boolean) => void): JSX.Element { const kind = FIELD_META[cd.field].kind; if (kind === 'grade') return ; @@ -150,7 +176,44 @@ export function WorkflowManagement(): JSX.Element {
- {/* 规则列表 */} + {/* 审批人指派(审批线,软约束) */} +
+ + 审批人指派 · 审批线(按销售归属) + +
+ }> +

+ 为每个销售指定固定的风控审批人与管理层审批人——提交时按此指派。软约束:待办默认只显示分给本人的,同角色其他人仍可代审(互为备份)。未配置的销售走「默认审批线」。 + {!config.assignment.enabled && (当前未启用:任何同角色都可处理,不指派。)} +

+ + {/* 默认审批线 */} +
+ 默认审批线 +
风控审批人
+
管理层审批人
+
+ + {/* 每个销售一条线 */} +
+ {config.assignment.lines.map((ln, i) => ( +
+
销售
+
风控审批人
+
管理层审批人
+ +
+ ))} +
+ + +

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