外包风险评估系统:领域引擎+前端+服务端持久化与生产部署

- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解
- 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护)
- 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理
- 专业图标体系替换全部emoji、看板美化
- 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC
- 444单测+204前端测试+51 e2e
This commit is contained in:
freedakgmail
2026-06-13 01:06:39 +08:00
commit c670b9e454
404 changed files with 61820 additions and 0 deletions
+352
View File
@@ -0,0 +1,352 @@
/**
* 费率管理页面 — 地域费率套维护(与评估引擎对齐)。
*
* 专业设计:
* - 以「地域费率套」为单位维护(一套 = 五险单位费率 + 公积金 + 增值税 + 附加税)
* - 展示引擎内置默认费率(全国/上海/北京/广东)作为对照基准
* - 与默认对比:偏离默认值的项高亮
* - 复核流程:编辑即重置待复核,复核后驱动评估盈利测算
* - 实时显示社保单位合计、用工成本加载估算
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { colorVar, FONT_FAMILY, RADIUS, space, typographyStyle } from '../design-system/components/styles.js';
import { Card, Icon } from '../design-system/index.js';
import {
fetchEngineDefaults,
fetchRegionRates,
saveRegionRate,
reviewRegionRate,
deleteRegionRate,
fetchMinWages,
saveMinWage,
deleteMinWageApi,
type RegionRates,
type RegionRateRecord,
type MinWageItem,
} from '../api/client.js';
import { useAuthStore } from '../stores/authStore.js';
/** 费率项定义(路径 + 标签 + 分组 + 单位/个人说明)。 */
const RATE_FIELDS: ReadonlyArray<{ path: string; label: string; group: string; hint?: string }> = [
{ path: 'socialInsurance.pension', label: '养老保险(单位)', group: '社会保险(单位部分)', hint: '通常 14%~16%' },
{ path: 'socialInsurance.medical', label: '医疗保险(单位)', group: '社会保险(单位部分)', hint: '通常 4.5%~10%' },
{ path: 'socialInsurance.unemployment', label: '失业保险(单位)', group: '社会保险(单位部分)', hint: '通常 0.5%~0.8%' },
{ path: 'socialInsurance.injury', label: '工伤保险(单位)', group: '社会保险(单位部分)', hint: '按行业风险 0.16%~1.9%' },
{ path: 'socialInsurance.maternity', label: '生育保险(单位)', group: '社会保险(单位部分)', hint: '与医疗并轨地区填 0' },
{ path: 'housingFund', label: '住房公积金(单位)', group: '公积金', hint: '5%~12%,按属地' },
{ path: 'vatGeneralRate', label: '增值税(一般计税)', group: '税率', hint: '现代服务业 6%' },
{ path: 'vatSimplifiedRate', label: '增值税(简易/差额)', group: '税率', hint: '劳务派遣差额 5%' },
{ path: 'surchargeRate', label: '附加税费(占增值税)', group: '税率', hint: '城建+教育附加,约 12%' },
];
const REGIONS = ['全国默认', '上海', '北京', '广东', '深圳', '江苏', '浙江', '河北', '四川', '重庆', '湖北', '天津'];
function getPath(obj: RegionRates, path: string): number {
const parts = path.split('.');
let cur: unknown = obj;
for (const p of parts) cur = (cur as Record<string, unknown>)?.[p];
return typeof cur === 'number' ? cur : 0;
}
function setPath(obj: RegionRates, path: string, value: number): RegionRates {
const clone: RegionRates = JSON.parse(JSON.stringify(obj));
const parts = path.split('.');
let cur: Record<string, unknown> = clone as unknown as Record<string, unknown>;
for (let i = 0; i < parts.length - 1; i += 1) cur = cur[parts[i]!] as Record<string, unknown>;
cur[parts[parts.length - 1]!] = value;
return clone;
}
function emptyRates(regionName: string): RegionRates {
return {
regionName,
socialInsurance: { pension: 0, medical: 0, unemployment: 0, injury: 0, maternity: 0 },
housingFund: 0, vatGeneralRate: 0.06, vatSimplifiedRate: 0.05, surchargeRate: 0.12,
};
}
function socialTotal(r: RegionRates): number {
const s = r.socialInsurance;
return s.pension + s.medical + s.unemployment + s.injury + s.maternity;
}
/** 全成本加载系数估算(应发=1,加社保+公积金)。 */
function loadingFactor(r: RegionRates): number {
return 1 + socialTotal(r) + r.housingFund;
}
export function RateManagement(): JSX.Element {
const { user } = useAuthStore();
const [defaults, setDefaults] = useState<{ national: RegionRates; regions: Record<string, RegionRates> } | null>(null);
const [records, setRecords] = useState<RegionRateRecord[]>([]);
const [loading, setLoading] = useState(true);
const [editRegion, setEditRegion] = useState('');
const [editRates, setEditRates] = useState<RegionRates | null>(null);
const load = useCallback(() => {
setLoading(true);
Promise.all([fetchEngineDefaults(), fetchRegionRates()])
.then(([d, r]) => { setDefaults(d); setRecords(r); })
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
// 当前编辑地域的"引擎默认"基准(用于对比高亮)
const baseline = useMemo<RegionRates | null>(() => {
if (defaults === null || editRegion === '') return null;
const key = ['上海', '北京', '广东'].find((k) => editRegion.includes(k));
return key ? (defaults.regions[key] ?? defaults.national) : defaults.national;
}, [defaults, editRegion]);
function startEdit(region: string): void {
const existing = records.find((r) => r.region === region);
if (existing) {
setEditRates(existing.rates);
} else {
// 以引擎默认为初始值
const key = ['上海', '北京', '广东'].find((k) => region.includes(k));
const init = defaults ? (key ? (defaults.regions[key] ?? defaults.national) : defaults.national) : emptyRates(region);
setEditRates({ ...JSON.parse(JSON.stringify(init)), regionName: region });
}
setEditRegion(region);
}
async function handleSave(): Promise<void> {
if (editRates === null || editRegion === '') return;
await saveRegionRate(editRegion, { ...editRates, regionName: editRegion }, user?.username);
setEditRegion('');
setEditRates(null);
load();
}
const inputStyle: React.CSSProperties = {
padding: `${space(1)}px ${space(2)}px`,
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.sm}px`,
fontFamily: FONT_FAMILY, ...typographyStyle('body'),
backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'),
width: 90, textAlign: 'right',
};
const groups = [...new Set(RATE_FIELDS.map((f) => f.group))];
return (
<div style={{ fontFamily: FONT_FAMILY, maxWidth: 1200, margin: '0 auto' }}>
<div style={{ marginBottom: `${space(4)}px` }}>
<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` }}>
<strong></strong>
</p>
</div>
{loading ? <p style={{ color: colorVar('color.text.secondary') }}></p> : (
<>
{/* 最低工资标准(驱动"低于最低工资"红线) */}
<div style={{ marginBottom: `${space(4)}px` }}>
<MinWagePanel />
</div>
{/* 已维护的地域费率套 */}
<Card title={`已维护地域费率套(${records.length}`}>
{records.length === 0 ? (
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
使
</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
{['地域', '社保单位合计', '公积金', '增值税(一般)', '附加税', '加载系数', '复核状态', '更新', '操作'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: `${space(2)}px`, borderBottom: `2px solid ${colorVar('color.border.default')}`, color: colorVar('color.text.secondary'), ...typographyStyle('caption'), fontWeight: 700 }}>{h}</th>
))}
</tr></thead>
<tbody>
{records.map((rec) => (
<tr key={rec.region}>
<td style={tdStyle}>{rec.region}</td>
<td style={tdStyle}>{(socialTotal(rec.rates) * 100).toFixed(2)}%</td>
<td style={tdStyle}>{(rec.rates.housingFund * 100).toFixed(1)}%</td>
<td style={tdStyle}>{(rec.rates.vatGeneralRate * 100).toFixed(1)}%</td>
<td style={tdStyle}>{(rec.rates.surchargeRate * 100).toFixed(0)}%</td>
<td style={{ ...tdStyle, fontWeight: 700, color: colorVar('color.brand.primary') }}>{loadingFactor(rec.rates).toFixed(3)}×</td>
<td style={tdStyle}>{rec.reviewed ? <span style={{ color: '#15803D', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="check-circle" size={14} /> </span> : <span style={{ color: '#B45309', display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="clock" size={14} /> </span>}</td>
<td style={{ ...tdStyle, ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{rec.updatedBy ?? '—'}</td>
<td style={tdStyle}>
<div style={{ display: 'flex', gap: `${space(2)}px` }}>
<button onClick={() => startEdit(rec.region)} style={linkBtn(colorVar('color.brand.primary'))}></button>
{!rec.reviewed && <button onClick={() => reviewRegionRate(rec.region).then(load)} style={linkBtn('#15803D')}></button>}
<button onClick={() => deleteRegionRate(rec.region).then(load)} style={linkBtn(colorVar('color.risk.critical'))}></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ marginTop: `${space(3)}px`, display: 'flex', gap: `${space(2)}px`, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>/</span>
{REGIONS.filter((r) => !records.some((rec) => rec.region === r)).map((r) => (
<button key={r} onClick={() => startEdit(r)} style={{ padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: `1px dashed ${colorVar('color.brand.primary')}`, background: 'transparent', color: colorVar('color.brand.primary'), cursor: 'pointer', ...typographyStyle('caption') }}>+ {r}</button>
))}
</div>
</Card>
{/* 编辑表单 */}
{editRates !== null && (
<div style={{ marginTop: `${space(4)}px` }}>
<Card title={`维护「${editRegion}」费率套`}>
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `0 0 ${space(3)}px` }}>
0.16 16%<span style={{ color: '#B45309' }}></span>
</p>
{groups.map((g) => (
<div key={g} style={{ marginBottom: `${space(3)}px` }}>
<div style={{ ...typographyStyle('caption'), fontWeight: 700, color: colorVar('color.text.secondary'), marginBottom: `${space(1)}px` }}>{g}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: `${space(2)}px` }}>
{RATE_FIELDS.filter((f) => f.group === g).map((f) => {
const val = getPath(editRates, f.path);
const baseVal = baseline ? getPath(baseline, f.path) : val;
const deviated = Math.abs(val - baseVal) > 1e-6;
return (
<div key={f.path} style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<label style={{ ...typographyStyle('caption'), color: colorVar('color.text.primary') }}>{f.label}</label>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(1)}px` }}>
<input
style={{ ...inputStyle, borderColor: deviated ? '#B45309' : colorVar('color.border.default'), color: deviated ? '#B45309' : colorVar('color.text.primary'), fontWeight: deviated ? 700 : 400 }}
value={val}
onChange={(e) => setEditRates((r) => r ? setPath(r, f.path, Number(e.target.value) || 0) : r)}
inputMode="decimal"
/>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{(val * 100).toFixed(2)}%</span>
</div>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontSize: '11px' }}>
{f.hint}{deviated ? ` · 默认 ${(baseVal * 100).toFixed(2)}%` : ''}
</span>
</div>
);
})}
</div>
</div>
))}
{/* 汇总 */}
<div style={{ display: 'flex', gap: `${space(4)}px`, padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px`, flexWrap: 'wrap' }}>
<Summary label="社保单位合计" value={`${(socialTotal(editRates) * 100).toFixed(2)}%`} />
<Summary label="公积金" value={`${(editRates.housingFund * 100).toFixed(1)}%`} />
<Summary label="全成本加载系数" value={`${loadingFactor(editRates).toFixed(3)}×`} highlight />
<Summary label="说明" value="加载系数 = 1 + 社保 + 公积金(不含福利/摊销)" small />
</div>
<div style={{ display: 'flex', gap: `${space(2)}px` }}>
<button onClick={handleSave} style={{ padding: `${space(2)}px ${space(5)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600 }}></button>
<button onClick={() => { setEditRegion(''); setEditRates(null); }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.border.default')}`, background: 'transparent', cursor: 'pointer' }}></button>
</div>
</Card>
</div>
)}
{/* 引擎内置默认费率(只读对照) */}
{defaults !== null && (
<div style={{ marginTop: `${space(4)}px` }}>
<Card title="引擎内置默认费率(只读对照基准)">
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `0 0 ${space(2)}px` }}>
</p>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
{['地域', '养老', '医疗', '失业', '工伤', '生育', '公积金', '增值税', '附加税', '加载系数'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, color: colorVar('color.text.secondary'), ...typographyStyle('caption'), fontWeight: 700 }}>{h}</th>
))}
</tr></thead>
<tbody>
{[defaults.national, ...Object.values(defaults.regions).filter((r) => r.regionName !== '全国(默认)')].map((r) => (
<tr key={r.regionName}>
<td style={defTd}>{r.regionName}</td>
<td style={defTd}>{(r.socialInsurance.pension * 100).toFixed(1)}%</td>
<td style={defTd}>{(r.socialInsurance.medical * 100).toFixed(1)}%</td>
<td style={defTd}>{(r.socialInsurance.unemployment * 100).toFixed(2)}%</td>
<td style={defTd}>{(r.socialInsurance.injury * 100).toFixed(2)}%</td>
<td style={defTd}>{(r.socialInsurance.maternity * 100).toFixed(2)}%</td>
<td style={defTd}>{(r.housingFund * 100).toFixed(1)}%</td>
<td style={defTd}>{(r.vatGeneralRate * 100).toFixed(1)}%</td>
<td style={defTd}>{(r.surchargeRate * 100).toFixed(0)}%</td>
<td style={{ ...defTd, fontWeight: 700, color: colorVar('color.brand.primary') }}>{loadingFactor(r).toFixed(3)}×</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
</>
)}
</div>
);
}
const tdStyle: React.CSSProperties = { padding: `${space(2)}px`, borderBottom: '1px solid var(--color-border-default)', fontSize: '14px' };
const defTd: React.CSSProperties = { padding: `${space(1)}px ${space(2)}px`, borderBottom: '1px solid var(--color-border-default)', fontSize: '13px', whiteSpace: 'nowrap' };
function linkBtn(color: string): React.CSSProperties {
return { cursor: 'pointer', border: 'none', background: 'none', color, fontWeight: 600, fontSize: '12px' };
}
function Summary({ label, value, highlight, small }: { label: string; value: string; highlight?: boolean; small?: boolean }): JSX.Element {
return (
<div>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{label}</div>
<div style={{ ...(small ? typographyStyle('caption') : typographyStyle('title')), fontWeight: small ? 400 : 700, color: highlight ? colorVar('color.brand.primary') : colorVar('color.text.primary'), marginTop: 2 }}>{value}</div>
</div>
);
}
/** 最低工资标准维护面板:驱动"低于最低工资"红线自动比对。 */
function MinWagePanel(): JSX.Element {
const [items, setItems] = useState<MinWageItem[]>([]);
const [region, setRegion] = useState('');
const [wage, setWage] = useState('');
const reload = useCallback(() => {
fetchMinWages().then(setItems).catch(() => setItems([]));
}, []);
useEffect(() => { reload(); }, [reload]);
const inputStyle: 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'),
};
async function handleSave(): Promise<void> {
const w = Number(wage);
if (region.trim() === '' || !Number.isFinite(w) || w <= 0) return;
await saveMinWage(region.trim(), w);
setRegion(''); setWage('');
reload();
}
return (
<Card title={`各地域最低工资标准(${items.length}`}>
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `0 0 ${space(2)}px` }}>
线 HR/
</p>
<div style={{ display: 'flex', gap: `${space(2)}px`, flexWrap: 'wrap', alignItems: 'center', marginBottom: `${space(2)}px` }}>
<input style={{ ...inputStyle, width: 120 }} value={region} onChange={(e) => setRegion(e.target.value)} placeholder="地域(如 北京)" />
<input style={{ ...inputStyle, width: 140 }} value={wage} onChange={(e) => setWage(e.target.value)} placeholder="月最低工资(元)" inputMode="decimal" />
<button type="button" onClick={handleSave} style={{ padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600 }}>/</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: `${space(2)}px` }}>
{items.map((m) => (
<div key={m.region} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: `${space(1)}px ${space(2)}px`, border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.md}px`, ...typographyStyle('caption') }}>
<span style={{ color: colorVar('color.text.primary') }}>{m.region}</span>
<div style={{ display: 'flex', gap: `${space(2)}px`, alignItems: 'center' }}>
<span style={{ fontWeight: 700 }}>{m.monthlyWage.toLocaleString('zh-CN')} </span>
<button type="button" onClick={() => { void deleteMinWageApi(m.region).then(reload); }} style={{ display: 'inline-flex', alignItems: 'center', border: 'none', background: 'transparent', color: colorVar('color.risk.critical'), cursor: 'pointer' }} aria-label="删除"><Icon name="close" size={15} /></button>
</div>
</div>
))}
</div>
</Card>
);
}