审批流程:用户名改为常见人名 + 按销售归属的审批线指派(软约束)

- 用户名改为常见人名(张伟/王芳/李娜/刘洋/陈静/赵磊/孙莉/周强)
- 审批线模型:每个销售→指定风控+管理层审批人(含默认线兜底),resolveAssignees 纯函数+4单测
- 提交时按销售归属计算并落库指派(assessment_assignments 表)
- 软约束:待办默认只看分给我的(含未指派),同角色他人仍可代审;详情页显示指派审批人
- 审批流程页新增「审批人指派·审批线」配置区(启用/默认线/按销售配线)
- 配置 GET/PUT 扩展 assignment;getApprovalConfig 向后兼容回填
This commit is contained in:
freedakgmail
2026-06-13 18:26:48 +08:00
parent 757b9c4a69
commit 11997e6104
11 changed files with 364 additions and 16 deletions
+7 -2
View File
@@ -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<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;
const stored = row.config as Partial<ApprovalConfig>;
return {
...DEFAULT_APPROVAL_CONFIG,
...stored,
assignment: { ...DEFAULT_APPROVAL_CONFIG.assignment, ...(stored.assignment ?? {}) },
} as ApprovalConfig;
}
/** 保存审批配置(upsert 单行)。 */
+52
View File
@@ -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<string, unknown>): 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<void> {
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<AssignmentRecord | null> {
const res = await pool.query('SELECT * FROM assessment_assignments WHERE assessment_id=$1', [assessmentId]);
const r = (res.rows as Array<Record<string, unknown>>)[0];
return r ? mapRow(r) : null;
}
/** 全部指派(assessmentId → 记录),供列表/看板合并。 */
export async function loadAllAssignments(pool: pg.Pool): Promise<Record<string, AssignmentRecord>> {
const res = await pool.query('SELECT * FROM assessment_assignments');
const out: Record<string, AssignmentRecord> = {};
for (const r of res.rows as Array<Record<string, unknown>>) {
const rec = mapRow(r);
out[rec.assessmentId] = rec;
}
return out;
}
+1
View File
@@ -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';
+54 -1
View File
@@ -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<void> {
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' });
});
+29 -1
View File
@@ -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 });
});
});
+39
View File
@@ -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) {