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

- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+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
+17
View File
@@ -0,0 +1,17 @@
# LLM(通义千问 / DashScope 兼容模式)配置模板。
# 复制为 .env 并填入你自己的 API Key。留空则关闭 LLM,回退到确定性规则引擎。
DASHSCOPE_API_KEY=
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
LLM_MODEL=qwen-plus
LLM_TIMEOUT_MS=15000
# PostgreSQL 持久化(留空则用进程内存储)
DATABASE_URL=postgresql://localhost:5432/riskagent
# RBAC 鉴权密钥(JWT 签名)。留空=演示模式(不强制鉴权);
# 配置后敏感操作(风控审核/管理层审批/override/红线裁定/费率红线最低工资配置)按角色强制校验。
# 生产环境务必设置为高强度随机值。
AUTH_SECRET=
# 目标净利率基准(小数,可被预测准确度校准覆盖)。
TARGET_NET_MARGIN=0.05
+117
View File
@@ -0,0 +1,117 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
jobs:
build-test:
name: 类型检查 / 单元测试 / 构建
runs-on: ubuntu-latest
services:
postgres:
# 使用含 pgvector 扩展的镜像(相似项目向量搜索迁移依赖)
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: riskagent
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/riskagent
# CI 不配置 LLM/AUTH:评估自动回退确定性规则引擎,RBAC 为演示模式
DASHSCOPE_API_KEY: ''
AUTH_SECRET: ''
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: 安装依赖
run: npm ci
- name: 运行数据库迁移
run: npm run migrate:up
- name: 类型检查(前后端)
run: npm run typecheck
- name: 单元 / 属性测试
run: npm test
- name: 构建
run: npm run build
e2e:
name: 端到端(API 全流程)
runs-on: ubuntu-latest
needs: build-test
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: riskagent
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/riskagent
DASHSCOPE_API_KEY: ''
AUTH_SECRET: ''
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: 安装依赖
run: npm ci
- name: 运行数据库迁移
run: npm run migrate:up
- name: 构建
run: npm run build
- name: 播种 E2E 所需基础数据(客户档案)
run: node scripts/seed-e2e.mjs
- name: 启动后端
run: |
node dist/server/index.js &
echo $! > server.pid
for i in $(seq 1 30); do
curl -sf http://localhost:3005/api/health && break
sleep 1
done
- name: 运行端到端测试
run: npm run e2e
- name: 关闭后端
if: always()
run: kill "$(cat server.pid)" || true
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
coverage/
*.tsbuildinfo
.DS_Store
*.log
.env
.env.local
@@ -0,0 +1 @@
{"specId": "d7bb36fd-a687-4004-aea0-177620140635", "workflowType": "requirements-first", "specType": "feature"}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,378 @@
# Requirements Document
## Introduction
本功能构建一个**外包项目风险评估 AI 智能体**(以下统称 System)。用户输入外包项目描述后,System 基于结构化、可配置的风险指标体系,通过多轮对话式追问补全关键信息,对项目进行多维度加权评分与分级,校验红线,输出可量化的费用/定价测算,并给出"风险是否可接受 + 如何接受 + 如何应对(管理措施与费用措施)"的结论与建议,最终生成可导出的风险评估报告并持久化存档。
System 覆盖五类外包业务:岗位外包、劳务派遣、业务/服务外包、BPO、项目制外包;面向商务/销售、风控、管理层三类角色提供差异化交互与视图;首版实现中国大陆劳动法合规体系,并在架构上以地域参数预留跨境扩展。
本文档定义可验收的功能需求,遵循 EARS 模式与 INCOSE 质量规则。
## Glossary
- **System**:外包项目风险评估智能体系统整体。
- **Classifier**:业务类型与行业识别组件,负责从项目描述中判定外包业务类型与所属行业。
- **Question_Engine**:自适应追问引擎,根据信息缺口动态生成补全问题。
- **Config_Center**:模型配置中心,供管理员维护维度/指标/权重/模板/红线,并执行配置校验。
- **Scoring_Engine**:评分引擎,读取配置对项目进行加权评分、归一化、分级、红线校验。
- **Cost_Engine**:费用测算引擎,计算风险溢价、垫资利息、保险费用、准备金及风险调整后报价。
- **Strategy_Engine**:应对策略推荐引擎,生成管理措施、费用措施与接受条件清单。
- **External_Data_Adapter**:外部数据适配层,可插拔接入企业资信/征信/涉诉/失信/工商等外部数据。
- **Report_Generator**:报告生成与导出组件。
- **Knowledge_Base**:分行业知识库,按行业分区存储指标、模板、权重、红线、案例与追问话术。
- **Compliance_Rule_Set**:地域化合规规则集(社保基数、经济补偿 N/N+1、派遣比例上限、最低工资等)。
- **Risk_Model**:风险模型,三层结构 模型→维度(Dimension)→指标(Indicator)→评分项(Scoring_Rule)。
- **Dimension**:风险维度,如客户风险、用工与人力风险等,可增删、启停、调权。
- **Indicator**:维度下的风险指标,可增删、启停、调权,含评分标准、证据要求、追问话术。
- **Scoring_Rule**:指标的评分项,定义 1-5 级风险等级的含义与判定标准。
- **Redline**:红线规则,命中即触发一票否决。
- **Template**:风险模型模板,按业务类型与行业组织,支持自定义与继承。
- **Risk_Level**:单项风险等级,取值 1 至 5 的整数。
- **Risk_Score**:归一化后的风险总分,取值范围 0 至 100。
- **Risk_Grade**:风险分级,取值为 低 / 中 / 高 / 极高 四级之一。
- **Region**:地域参数,标识适用的合规规则集,首版取值为中国大陆(CN)。
- **Assessment**:一次完整的项目风险评估记录,含输入、评分、报告与元数据。
- **Administrator**:管理员角色,唯一可修改 Risk_Model 配置的角色。
- **Assessor**:评估者角色,包含商务/销售、风控、管理层,可使用但不可修改 Risk_Model。
- **Data_Provenance**:数据来源标注,取值为 用户输入 / 外部数据 / 智能体假设 之一。
- **Confidence**:置信度,标注某一数据点可信程度的量化指标。
- **UI**System 的 Web 用户界面整体,承载评估流程交互、数据可视化与结果呈现。
- **Design_System**:统一设计系统,定义可复用组件库、排版层级、配色规范、间距标度与图标集,供 UI 全局一致引用。
- **Color_Token**:配色令牌,Design_System 中具名且取值稳定的颜色变量,含风险等级语义化配色(低/中/高/极高)。
- **Theme**:界面主题,取值为 明亮(Light)或 暗黑(Dark)之一,决定 UI 的整体配色方案。
- **Viewport**:浏览器视口,以 CSS 像素表示的可视区域宽度,用于响应式适配判定。
- **Chart**:UI 中的数据可视化图表组件,含风险热力图、风险总分仪表盘、关键风险图、费用对比图与组合对比图。
- **Risk_Badge**:风险分级徽章,以语义化配色与文字标签同时呈现 Risk_Grade 的可视化元素。
- **Wizard**:向导式分步评估流程组件,将评估过程拆分为带进度指示的有序步骤。
- **Draft**:评估草稿,尚未完成的 Assessment 的可保存中间状态,用于断点续评。
- **WCAG**Web 内容无障碍指南(Web Content Accessibility Guidelines2.1 版本 AA 级,作为 UI 可访问性达标基准。
- **Loading_State**:加载态,UI 在数据请求或处理进行中向用户呈现的等待反馈。
- **Empty_State**:空数据态,UI 在无可呈现数据时向用户呈现的占位提示。
## Requirements
### Requirement 1: 业务类型与行业识别
**User Story:** 作为评估者,我希望 System 从项目描述中识别外包业务类型与所属行业,以便加载匹配的风险模型模板。
#### Acceptance Criteria
1. WHEN 评估者提交有效项目描述文本, THE Classifier SHALL 将该项目判定为下列业务类型中 Confidence 最高的唯一一项:岗位外包、劳务派遣、业务/服务外包、BPO、项目制外包。
2. WHEN 评估者提交有效项目描述文本, THE Classifier SHALL 判定该项目所属行业,并在行业无法判定时输出取值为"未识别"的行业标记。
3. THE Classifier SHALL 为业务类型判定结果与行业判定结果分别输出取值范围 0 至 1、保留两位小数的 Confidence。
4. IF 业务类型判定的 Confidence 低于 0.6, THEN THE System SHALL 向评估者展示按 Confidence 由高到低排序、至多 3 项的候选业务类型列表并请求评估者确认。
5. IF 行业判定的 Confidence 低于 0.6 且行业标记不为"未识别", THEN THE System SHALL 向评估者展示按 Confidence 由高到低排序、至多 3 项的候选行业列表并请求评估者确认。
6. IF 评估者提交的项目描述文本为空、仅含空白字符或有效字符数少于 10, THEN THE System SHALL 拒绝执行业务类型与行业判定并返回提示项目描述信息不足的错误。
7. WHEN 评估者修改 System 判定的业务类型或行业, THE System SHALL 采用评估者确认的业务类型与行业作为后续加载模板的依据。
### Requirement 2: 模板加载与风险模型实例化
**User Story:** 作为评估者,我希望 System 根据业务类型与行业自动加载对应的风险模型模板,以便在标准化基线上开展评估。
#### Acceptance Criteria
1. WHEN 评估者确认的业务类型与行业确定, THE System SHALL 从 Knowledge_Base 加载与该业务类型和行业组合精确匹配的 Template,并将其实例化为本次 Assessment 的 Risk_Model。
2. IF 不存在与业务类型和行业组合精确匹配的 Template, THEN THE System SHALL 加载该业务类型的默认 Template 实例化为本次 Assessment 的 Risk_Model,并在本次 Assessment 中输出取值为"未匹配行业专用模板"的标记。
3. WHEN 加载 Template, THE System SHALL 在实例化的 Risk_Model 中保留该 Template 定义的全部 Dimension、Indicator、权重、Scoring_Rule、Redline、追问话术,以及各 Dimension 与 Indicator 的启用/停用状态。
4. IF 待加载的 Template 缺少 Dimension、Indicator、权重或 Scoring_Rule 中任一必填组成项,或其同级 Dimension 权重之和或同级 Indicator 权重之和不等于 100%, THEN THE System SHALL 不实例化 Risk_Model、终止本次 Assessment,并向评估者返回指明模板数据错误的提示。
5. WHERE Template 通过模板继承派生, THE System SHALL 应用父模板的全部组成项配置,并以子模板中存在差异的组成项逐项覆盖父模板的对应组成项。
6. IF 既不存在与业务类型和行业组合精确匹配的 Template、也不存在该业务类型的默认 Template, THEN THE System SHALL 不实例化 Risk_Model、终止本次 Assessment,并向评估者返回指明无可用模板的提示。
7. IF 模板继承链中存在循环引用或继承层级超过 5 层, THEN THE System SHALL 不实例化 Risk_Model、终止本次 Assessment,并向评估者返回指明模板继承错误的提示。
### Requirement 3: 自适应信息追问
**User Story:** 作为评估者,我希望 System 针对信息缺口动态追问,以便补全评分所需关键信息。
#### Acceptance Criteria
1. WHEN Risk_Model 实例化完成, THE Question_Engine SHALL 将每个处于启用状态且已知信息不足以依据其 Scoring_Rule 判定 Risk_Level 的 Indicator 标记为信息缺口。
2. THE Question_Engine SHALL 仅为标记为信息缺口的 Indicator 生成追问问题,并按 Indicator 权重由高到低排序;当 Indicator 权重相同时,按其所属 Dimension 权重由高到低排序消歧。
3. WHEN 评估者回答一个追问问题, THE Question_Engine SHALL 更新对应 Indicator 的已知信息并重新识别剩余信息缺口。
4. IF 评估者对追问问题给出空回答或不满足证据要求的回答, THEN THE Question_Engine SHALL 保持该 Indicator 的信息缺口标记并返回需补充信息的提示。
5. THE Question_Engine SHALL 将单个 Indicator 的追问轮次限制在最大追问轮次以内,最大追问轮次为可配置正整数且默认值为 3。
6. WHERE 某 Indicator 的信息在追问轮次达到上限后仍缺失, THE System SHALL 对该 Indicator 采用行业默认值并将其 Data_Provenance 标注为"智能体假设"。
7. WHERE 某 Indicator 的 Data_Provenance 已被标注为"智能体假设", THE System SHALL 在该标注被应用后永久保留该标注,即使评估者后续补充了对应信息。
8. THE Question_Engine SHALL 为每个追问问题关联其对应的 Dimension 与 Indicator。
### Requirement 4: 风险评分与归一化
**User Story:** 作为风控,我希望 System 基于加权模型计算风险得分,以便获得可量化的风险结果。
#### Acceptance Criteria
1. THE Scoring_Engine SHALL 为每个启用的 Indicator 计算评分项得分,评分项得分等于该 Indicator 的 Risk_Level(取值 1 至 5)乘以该 Indicator 的权重。
2. THE Scoring_Engine SHALL 计算每个启用的 Dimension 的得分为其下各启用 Indicator 评分项得分的加权求和。
3. THE Scoring_Engine SHALL 将各启用 Dimension 的得分加权汇总后线性映射归一化为 Risk_Score,使全部纳入计算的 Indicator 的 Risk_Level 同取最小值 1 时 Risk_Score 映射为 0、同取最大值 5 时 Risk_Score 映射为 100,并将 Risk_Score 四舍五入取整为 0 至 100 的整数。
4. THE Scoring_Engine SHALL 在汇总计算中仅纳入处于启用状态的 Dimension 与 Indicator。
5. IF 本次 Assessment 不存在任何启用的 Indicator 或 Dimension, THEN THE Scoring_Engine SHALL 终止评分、不产生 Risk_Score 并返回评分数据不足的错误。
6. THE Scoring_Engine SHALL 为每个评分项记录其 Data_Provenance(取值为 用户输入 / 外部数据 / 智能体假设 之一)与取值范围 0 至 1 的 Confidence。
### Requirement 5: 风险分级
**User Story:** 作为评估者,我希望 System 将风险总分映射为风险等级,以便快速判断风险高低。
#### Acceptance Criteria
1. WHEN Scoring_Engine 完成 Risk_Score 归一化计算, THE Scoring_Engine SHALL 在 0 ≤ Risk_Score ≤ 25 时将 Risk_Grade 判定为"低"。
2. WHEN Scoring_Engine 完成 Risk_Score 归一化计算, THE Scoring_Engine SHALL 在 25 < Risk_Score ≤ 50 时将 Risk_Grade 判定为"中"。
3. WHEN Scoring_Engine 完成 Risk_Score 归一化计算, THE Scoring_Engine SHALL 在 50 < Risk_Score ≤ 75 时将 Risk_Grade 判定为"高"。
4. WHEN Scoring_Engine 完成 Risk_Score 归一化计算, THE Scoring_Engine SHALL 在 75 < Risk_Score ≤ 100 时将 Risk_Grade 判定为"极高"。
5. THE Scoring_Engine SHALL 为每次 Assessment 输出且仅输出一个 Risk_Grade。
### Requirement 6: 红线一票否决
**User Story:** 作为风控,我希望命中红线的项目被强制判定为不可接受,以便规避不可承受的风险。
#### Acceptance Criteria
1. WHEN Scoring_Engine 完成评分, THE Scoring_Engine SHALL 逐条校验本次 Assessment 是否满足每一个启用的 Redline 的触发条件,并将满足触发条件的 Redline 标记为命中。
2. IF 本次 Assessment 命中任一启用的 Redline, THEN THE System SHALL 将可接受性结论判定为"不可接受",且该判定优先于基于 Risk_Grade 得出的可接受性判定。
3. IF 本次 Assessment 命中任一启用的 Redline, THEN THE System SHALL 在报告中列出每个被命中的 Redline 及其被触发的条件与对应的判定依据数据。
4. THE Scoring_Engine SHALL 独立于 Risk_Score 与 Risk_Grade 的高低执行全部启用 Redline 的校验。
5. IF 某个启用的 Redline 的触发条件所需数据缺失,或其 Data_Provenance 为"智能体假设"以致无法确定是否命中, THEN THE System SHALL 将该 Redline 标记为"待核实"、在报告中列出该 Redline 并说明无法判定的原因,且不将其计为命中。
6. WHEN 本次 Assessment 未命中任何启用的 Redline, THE System SHALL 不因红线校验而改变基于 Risk_Grade 得出的可接受性结论。
### Requirement 7: 风险热力图与关键风险清单
**User Story:** 作为管理层,我希望看到风险的可视化分布与关键风险排序,以便聚焦最重要的风险点。
#### Acceptance Criteria
1. THE System SHALL 输出以 Dimension 为行、以 Indicator 为列、以 Risk_Level(取值 1 至 5)度量严重度的风险热力图数据。
2. THE System SHALL 输出按评分项得分由高到低排序的 Top N 关键风险清单,N 为可配置正整数,取值范围 1 至 50,默认值为 10。
3. WHEN 评分项得分相同, THE System SHALL 先按所属 Dimension 权重由高到低、再按 Indicator 权重由高到低确定其在关键风险清单中的排序。
4. WHERE 启用的评分项数量少于 N, THE System SHALL 在关键风险清单中输出全部启用评分项。
5. THE System SHALL 为关键风险清单中的每个风险项提供其所属 Dimension、Indicator、得分与判定依据。
### Requirement 8: 费用与定价量化测算
**User Story:** 作为商务/销售,我希望 System 输出具体金额的风险溢价与成本测算,以便基于风险定价。
#### Acceptance Criteria
1. WHEN 风险评分完成且成本输入可用, THE Cost_Engine SHALL 依据 Risk_Grade 与风险评分计算取值为具体金额区间或百分比区间的风险溢价加价区间。
2. THE Cost_Engine SHALL 计算垫资利息、保险费用、补偿准备金与坏账准备金各项的具体金额,并为每项标注计算所依据的输入项与所采用的费率或参数来源。
3. THE Cost_Engine SHALL 输出基准报价与风险调整后报价的对比及各项成本拆解。
4. WHERE 测算所需的成本输入项缺失, THE Cost_Engine SHALL 采用行业默认值并将该输入项的 Data_Provenance 标注为"智能体假设"。
5. IF 风险评分尚未完成, THEN THE Cost_Engine SHALL 拒绝执行费用测算并返回评分未完成的提示。
6. THE Cost_Engine SHALL 为每一项测算金额标注其计算所依据的输入项。
### Requirement 9: 可接受性结论与应对策略
**User Story:** 作为评估者,我希望 System 给出明确的可接受性结论与应对方案,以便据此决策与执行。
#### Acceptance Criteria
1. WHEN Scoring_Engine 完成评分与红线校验, THE Strategy_Engine SHALL 输出取值为 可接受 / 有条件接受 / 不可接受 之一的可接受性结论。
2. WHEN 可接受性结论为"有条件接受", THE Strategy_Engine SHALL 输出接受条件清单,且清单中每个条件关联至少一个关键风险清单中的风险项。
3. WHEN 可接受性结论为"可接受"或"有条件接受", THE Strategy_Engine SHALL 输出管理层面应对措施,且为合同条款、用工合规整改、退场预案、过程监控四类中的每一类各输出至少一项措施。
4. WHEN 可接受性结论为"可接受"或"有条件接受", THE Strategy_Engine SHALL 输出费用层面应对措施,且为风险溢价定价、预付/保证金、保险转移、账期成本、准备金计提五类中的每一类各输出至少一项措施。
5. WHEN 可接受性结论为"有条件接受", THE Strategy_Engine SHALL 为接受条件清单中每个条件输出取值为具体金额或金额区间的成本影响测算。
6. WHERE 本次 Assessment 未命中任何启用的 Redline 且 Risk_Grade 为"低"或"中", THE Strategy_Engine SHALL 将可接受性结论判定为"可接受"。
7. WHERE 本次 Assessment 未命中任何启用的 Redline 且 Risk_Grade 为"高", THE Strategy_Engine SHALL 将可接受性结论判定为"有条件接受"。
8. IF 本次 Assessment 命中任一启用的 Redline 或 Risk_Grade 为"极高", THEN THE Strategy_Engine SHALL 将可接受性结论判定为"不可接受"。
### Requirement 10: 评估报告生成与导出
**User Story:** 作为评估者,我希望 System 生成结构化报告并支持导出,以便归档与共享。
#### Acceptance Criteria
1. WHEN 本次 Assessment 的风险评分、红线校验、费用测算与可接受性结论均已生成完成, THE Report_Generator SHALL 生成包含以下内容的报告:项目概要与业务类型判定、风险总分与分级、风险热力图、各维度风险明细、Top 关键风险清单与红线校验结果、可接受性结论、应对方案、假设与信息缺口说明;其中红线校验结果在未命中任一 Redline 时亦明确标注为"无红线命中"。
2. WHEN 评估者在报告已生成后请求导出报告, THE Report_Generator SHALL 在评估者请求后 30 秒内将报告导出为包含第 1 条全部报告内容的完整且自包含的可下载文件。
3. THE Report_Generator SHALL 在各维度风险明细中为每个评分项展示评分、判定依据、风险影响、Data_Provenance 与 Confidence。
4. IF 评估者在第 1 条所述评估流程完成前请求生成或导出报告, THEN THE Report_Generator SHALL 拒绝该请求并返回指示评估流程尚未完成的提示。
5. IF 报告导出过程失败, THEN THE Report_Generator SHALL 中止本次导出、保留已生成的报告内容不变,并向评估者返回指示导出失败的错误。
### Requirement 11: 管理员配置风险模型
**User Story:** 作为管理员,我希望统一配置维度、指标、权重、模板与红线,以便维护风险模型而无需改动代码。
#### Acceptance Criteria
1. WHERE 操作者角色为 Administrator, THE Config_Center SHALL 允许对 Dimension 与 Indicator 执行新增、删除、启用与停用操作,且对停用项保留其配置数据但不纳入评分。
2. WHERE 操作者角色为 Administrator, THE Config_Center SHALL 允许将 Dimension 权重与 Indicator 权重调整为取值 0 至 100 的值,并在保存时自动按比例归一化使同级启用项权重之和等于 100% 且保留两位小数。
3. WHERE 操作者角色为 Administrator, THE Config_Center SHALL 允许自定义 Indicator 的 Scoring_Rule、证据要求与追问话术,且自定义的 Scoring_Rule 须覆盖 Risk_Level 1 至 5 各级的判定标准。
4. WHERE 操作者角色为 Administrator, THE Config_Center SHALL 允许配置 Redline 规则,且每条 Redline 含唯一标识、触发条件与一票否决后果。
5. WHERE 操作者角色为 Administrator, THE Config_Center SHALL 允许将当前配置另存为自定义 Template 并支持基于已有 Template 派生新 Template。
6. WHEN Config_Center 保存配置, THE Config_Center SHALL 校验权重合法性、必填项完整性与红线唯一性。
7. IF 配置校验失败, THEN THE Config_Center SHALL 拒绝保存、保留上次有效配置不变并返回指明失败项的校验错误。
8. IF 某同级启用项权重之和为 0 以致无法归一化, THEN THE Config_Center SHALL 拒绝保存并返回权重校验错误。
### Requirement 12: 配置访问权限控制
**User Story:** 作为系统所有者,我希望仅管理员能修改风险模型,以便保证评估模型的一致性与可信度。
#### Acceptance Criteria
1. IF 操作者角色为 Assessor 且请求修改 Risk_Model 配置, THEN THE System SHALL 拒绝该修改请求、保持当前 Risk_Model 配置不变,并向操作者返回指示权限不足的错误。
2. WHERE 操作者角色为 Assessor, THE System SHALL 允许使用当前 Risk_Model 执行评估。
3. IF 请求修改 Risk_Model 配置的操作者角色既非 Administrator 也非 Assessor(含未认证或未授权操作者), THEN THE System SHALL 拒绝该修改请求、保持当前 Risk_Model 配置不变,并返回指示权限不足的错误。
4. WHEN System 成功提交一次 Risk_Model 配置变更, THE System SHALL 记录该变更的操作者身份、变更时间(精确到秒)与发生变更的配置项标识。
5. WHEN System 拒绝一次 Risk_Model 配置修改请求, THE System SHALL 记录该被拒绝请求的操作者身份与请求时间(精确到秒)。
### Requirement 13: 角色化交互与视图
**User Story:** 作为不同角色的用户,我希望获得与我职责匹配的交互深度与视图,以便高效完成本职工作。
#### Acceptance Criteria
1. WHEN 商务/销售角色用户打开一个已完成的 Assessment, THE System SHALL 展示包含可接受性结论、接受条件清单与风险调整后报价的视图。
2. WHEN 风控角色用户打开一个已完成的 Assessment, THE System SHALL 展示包含评分项明细(含 Risk_Level、判定依据、Data_Provenance)、红线校验结果与信息缺口尽调事项的视图。
3. WHEN 管理层角色用户打开一个已完成的 Assessment, THE System SHALL 展示包含 Risk_Grade、风险热力图、Top N 关键风险与利润对风险对比的高层看板视图。
4. WHEN 管理层角色用户请求组合看板, THE System SHALL 展示跨项目汇总的组合看板视图。
5. IF 用户未分配商务/销售、风控或管理层任一角色, THEN THE System SHALL 拒绝展示评估视图并提示需分配角色。
6. WHERE 管理层请求的组合看板对应的 Assessment 集合为空, THE System SHALL 展示空组合看板并提示无可汇总的评估。
### Requirement 14: 分行业知识库管理
**User Story:** 作为管理员,我希望按行业分区管理知识库,以便针对不同行业提供专属模型与话术。
#### Acceptance Criteria
1. THE Knowledge_Base SHALL 按行业标识分区存储内容,且每个行业分区包含该行业的 Indicator、权重模板、Redline、典型案例与追问话术全部五类内容。
2. WHERE 操作者角色为 Administrator, WHEN 新增一个行业分区, THE System SHALL 在不修改 Scoring_Engine 源代码的前提下使该行业分区的 Template 可被 System 加载。
3. WHEN 业务类型与行业确定, THE System SHALL 依据业务类型与行业的组合从 Knowledge_Base 选择对应行业分区中匹配的 Template。
4. IF 新增的行业分区缺少 Indicator、权重模板、Redline、典型案例或追问话术中的任一类内容, THEN THE System SHALL 拒绝创建该行业分区、返回指明缺失内容类别的校验错误,并保持 Knowledge_Base 中已有分区不变。
5. IF Knowledge_Base 中不存在与已确定行业匹配的行业分区, THEN THE System SHALL 选择默认行业分区的 Template 并标注未匹配到行业专用分区。
### Requirement 15: 外部数据接入与来源标注
**User Story:** 作为风控,我希望可插拔接入外部可信数据并标注来源,以便提升客户风险评估的客观性。
#### Acceptance Criteria
1. WHERE 外部数据源已配置, WHEN External_Data_Adapter 在单次请求 10 秒内收到成功响应, THE External_Data_Adapter SHALL 获取企业资信、征信、涉诉、失信或工商数据用于客户风险相关 Indicator 取值。
2. THE External_Data_Adapter SHALL 为每个数据点标注取值为"外部数据"的 Data_Provenance 与取值范围 0 至 1 的 Confidence。
3. IF 外部数据源连接失败、超过 10 秒未返回或返回错误响应, THEN THE External_Data_Adapter SHALL 回退到用户输入并将受影响数据点的 Data_Provenance 标注为"用户输入"。
4. IF 外部数据回退后用户输入仍不完整, THEN THE System SHALL 将缺失数据点的 Data_Provenance 标注为"智能体假设"并继续执行评估、不中断流程。
5. THE System SHALL 在不修改 Scoring_Engine 源代码的前提下支持新增外部数据源适配器。
### Requirement 16: 地域化合规规则
**User Story:** 作为评估者,我希望 System 依据中国大陆劳动法规则进行合规相关测算,并在架构上预留跨境扩展,以便当前合规且未来可扩展。
#### Acceptance Criteria
1. WHERE Region 取值为中国大陆(CN, THE System SHALL 加载并应用中国大陆 Compliance_Rule_Set 对本次 Assessment 执行合规判定,判定项覆盖社保缴费基数、经济补偿 N 与 N+1、劳务派遣用工比例上限 10% 与当地最低工资标准。
2. WHERE Region 取值为中国大陆(CN, THE System SHALL 依据中国大陆 Compliance_Rule_Set 测算经济补偿(N 与 N+1)金额、社保缴费金额与相关合规费用,并为每项金额标注其所依据的规则项与输入项。
3. IF 本次 Assessment 的合规判定中存在任一不满足项(社保缴费基数低于法定下限、劳务派遣用工比例超过 10% 或约定薪酬低于当地最低工资标准), THEN THE System SHALL 将该项标注为合规不通过,并在报告中列出对应规则项与判定依据。
4. WHEN 创建一次 Assessment, THE System SHALL 记录该 Assessment 所采用的 Region。
5. IF 创建 Assessment 的请求未指定 Region, THEN THE System SHALL 采用中国大陆(CN)作为该 Assessment 的默认 Region 并标注该 Region 为系统默认值。
6. IF 请求的 Region 当前无对应 Compliance_Rule_Set, THEN THE System SHALL 拒绝该 Region 的合规判定与费用测算、不为该 Assessment 生成合规结论,并向评估者返回提示该地域暂不支持的消息。
### Requirement 17: 评估持久化与历史管理
**User Story:** 作为评估者,我希望评估结果被持久化并可检索复用,以便查询、复评与跨项目对比。
#### Acceptance Criteria
1. WHEN 一次 Assessment 完成, THE System SHALL 持久化存储该 Assessment 的输入、评分结果、报告与元数据,且元数据至少包含业务类型、行业、Region、Risk_Score、Risk_Grade、创建时间与评估者身份。
2. IF 持久化存储失败, THEN THE System SHALL 保留本次 Assessment 数据于当前会话并返回存储失败的错误。
3. WHEN 评估者发起对历史 Assessment 的复评, THE System SHALL 基于原 Assessment 的输入创建新的 Assessment 并保留原 Assessment 不变。
4. IF 复评引用的历史 Assessment 不存在, THEN THE System SHALL 拒绝复评并返回该评估不存在的提示。
5. WHEN 评估者请求跨项目对比且选中 2 个及以上 Assessment, THE System SHALL 返回被选中 Assessment 的 Risk_Grade、Risk_Score 与关键风险的对比数据。
6. IF 跨项目对比选中的 Assessment 少于 2 个, THEN THE System SHALL 拒绝对比并提示至少需选择 2 个评估。
7. WHEN 评估者按业务类型、行业、Risk_Grade 或创建时间范围检索历史 Assessment, THE System SHALL 返回匹配的 Assessment,并在无匹配时返回空结果集。
### Requirement 18: 可解释性与证据标注
**User Story:** 作为风控,我希望每个风险项都有可追溯的依据,以便避免黑箱打分并支撑决策。
#### Acceptance Criteria
1. THE Scoring_Engine SHALL 为每个评分项输出非空的判定依据,且该判定依据引用其所属 Dimension、Indicator、Scoring_Rule 及导致该 Risk_Level 的数据点取值。
2. THE Scoring_Engine SHALL 为每个评分项输出非空的风险影响说明。
3. THE Scoring_Engine SHALL 为每个评分项输出非空的建议。
4. THE System SHALL 为每个评分项展示其 Data_Provenance(取值为 用户输入 / 外部数据 / 智能体假设 之一)与取值范围 0 至 1 的 Confidence。
5. WHERE 评分项的 Data_Provenance 为"智能体假设", THE System SHALL 在报告的信息缺口说明中列出该项。
6. WHERE 评分项的 Data_Provenance 为"智能体假设", THE System SHALL 为该项输出关联对应 Indicator 的建议补充尽调事项。
### Requirement 19: 统一设计系统与视觉规范
**User Story:** 作为评估者,我希望 UI 在所有页面采用统一的组件、排版与配色规范,以便获得专业一致的使用体验。
#### Acceptance Criteria
1. THE UI SHALL 在全部页面从同一 Design_System 引用按钮、表单控件、表格、卡片、对话框、导航与提示组件,且同类组件在不同页面的外观与交互行为一致。
2. THE Design_System SHALL 定义不少于 4 级的文本排版层级,每级具有具名标识、固定字号与固定行高,且 UI 的全部文本从该排版层级取值。
3. THE Design_System SHALL 为 Risk_Grade 的 低、中、高、极高 四级各定义一个唯一且稳定的 Color_Token,且 UI 在 Risk_Badge、风险热力图与关键风险图中对同一 Risk_Grade 一致使用其对应 Color_Token。
4. THE Design_System SHALL 定义以 4 像素为基数的间距标度,且 UI 组件间距取值为该基数的整数倍。
5. THE Design_System SHALL 提供单一来源的图标集,且 UI 的全部图标从该图标集引用。
6. THE UI SHALL 提供 明亮(Light)与 暗黑(Dark)两种 Theme,并在切换 Theme 时对全部页面应用所选 Theme 对应的 Color_Token。
7. WHEN 用户在一次会话内切换 Theme, THE UI SHALL 在 1 秒内对当前页面应用所选 Theme 且不丢失当前页面已录入的数据。
### Requirement 20: 数据可视化的专业呈现
**User Story:** 作为管理层,我希望风险与费用数据以专业、易读的图表呈现,以便快速理解评估结论。
#### Acceptance Criteria
1. THE UI SHALL 提供风险热力图、风险总分仪表盘、Risk_Badge、Top N 关键风险图、费用拆解图、基准报价与风险调整后报价对比图,以及跨项目组合对比图。
2. THE UI SHALL 为每个含两个及以上数据系列或类别的 Chart 提供图例,且图例标签与 Chart 中对应数据元素一致。
3. THE UI SHALL 为每个 Chart 的坐标轴、数据点或分区提供文本标签,且标签文本不被相邻元素遮挡。
4. WHILE 某个 Chart 对应的数据为空, THE UI SHALL 为该 Chart 呈现 Empty_State 并提示无可展示数据。
5. WHILE 某个 Chart 对应的数据正在请求或计算中, THE UI SHALL 为该 Chart 呈现 Loading_State。
6. THE UI SHALL 在风险总分仪表盘中同时呈现 Risk_Score 数值与其对应的 Risk_Grade。
7. THE UI SHALL 在费用对比图中同时呈现基准报价金额、风险调整后报价金额及二者差额。
### Requirement 21: 便捷易用的评估流程
**User Story:** 作为评估者,我希望评估流程有清晰引导与顺畅交互,以便高效完成评估而不遗漏关键信息。
#### Acceptance Criteria
1. THE UI SHALL 以 Wizard 形式将评估流程拆分为有序步骤,并在每一步呈现当前步骤序号、步骤总数与已完成步骤的进度指示。
2. WHEN 评估者在 Wizard 中从对话式追问切换至表单录入或从表单录入切换至对话式追问, THE UI SHALL 保留切换前已录入的数据。
3. WHILE 本次 Assessment 存在标记为信息缺口或待补充的项, THE UI SHALL 以区别于常规文本的醒目样式呈现全部该类项并提供定位至对应录入位置的入口。
4. THE UI SHALL 为提交评估、保存草稿与导出报告各提供一个一键触发的操作入口。
5. WHEN 评估者请求保存草稿, THE UI SHALL 将当前 Assessment 持久化为 Draft 并保留全部已录入数据。
6. WHEN 评估者打开一个已保存的 Draft, THE UI SHALL 恢复该 Draft 的全部已录入数据并定位至保存时所处的 Wizard 步骤。
7. IF 评估者在存在未保存修改时请求离开当前评估流程, THEN THE UI SHALL 提示存在未保存修改并请求确认。
### Requirement 22: 响应式与多视口适配
**User Story:** 作为评估者,我希望 UI 在不同分辨率下都能正常使用,以便在多种设备上查看评估结果。
#### Acceptance Criteria
1. THE UI SHALL 在 Viewport 宽度大于等于 1280 CSS 像素时以桌面布局呈现全部功能。
2. WHILE Viewport 宽度处于 768 至 1279 CSS 像素, THE UI SHALL 调整布局使全部内容在不产生横向滚动条的前提下完整可见。
3. WHILE Viewport 宽度小于 768 CSS 像素, THE UI SHALL 在看板视图中保留 Risk_Score、Risk_Grade 与 Top N 关键风险清单的呈现。
4. WHEN Viewport 宽度变化跨越布局断点, THE UI SHALL 应用与当前 Viewport 宽度匹配的布局且不丢失当前页面已录入的数据。
### Requirement 23: 可访问性达标
**User Story:** 作为依赖辅助技术的用户,我希望 UI 满足无障碍标准,以便我能够无障碍地完成评估操作。
#### Acceptance Criteria
1. THE UI SHALL 使全部交互控件可通过键盘获得焦点并触发其操作。
2. WHILE 某交互控件获得键盘焦点, THE UI SHALL 为该控件呈现可见的焦点指示。
3. THE UI SHALL 使正文文本与其背景的对比度不低于 4.5:1,使大号文本与其背景的对比度不低于 3:1。
4. THE UI SHALL 为每个表单输入控件关联可被辅助技术识别的文本标签。
5. IF 表单校验失败, THEN THE UI SHALL 为每个校验未通过的输入控件呈现可被辅助技术识别的错误提示文本。
6. THE UI SHALL 在 Chart 中以颜色之外的文本标签或图案区分各数据类别,使数据类别不依赖颜色即可识别。
### Requirement 24: 操作反馈与状态提示
**User Story:** 作为评估者,我希望每次操作都能得到清晰反馈,以便了解系统状态并在出错时知道如何修正。
#### Acceptance Criteria
1. WHILE 一次用户触发的操作正在处理中, THE UI SHALL 呈现 Loading_State 指示该操作进行中。
2. WHEN 一次用户触发的操作成功完成, THE UI SHALL 呈现指示操作成功的反馈。
3. IF 一次用户触发的操作失败, THEN THE UI SHALL 呈现可读的错误信息,且该错误信息说明失败原因并提供指向修正路径的操作入口。
4. WHILE 报告导出操作正在执行, THE UI SHALL 呈现进度反馈,且在评估者请求导出后 30 秒内呈现导出完成或导出失败的结果。
### Requirement 25: 角色化默认视图
**User Story:** 作为不同角色的用户,我希望登录后直接进入与我职责匹配的视图,以便减少导航成本快速开展工作。
#### Acceptance Criteria
1. WHEN 商务/销售角色用户登录, THE UI SHALL 将该用户默认导航至面向商务/销售的视图。
2. WHEN 风控角色用户登录, THE UI SHALL 将该用户默认导航至面向风控的视图。
3. WHEN 管理层角色用户登录, THE UI SHALL 将该用户默认导航至面向管理层的高层看板视图。
4. THE UI SHALL 在默认视图中将与当前用户角色匹配的功能入口呈现于不需额外导航即可见的位置。
5. IF 登录用户未分配商务/销售、风控或管理层任一角色, THEN THE UI SHALL 呈现提示需分配角色的视图且不呈现评估数据。
@@ -0,0 +1,747 @@
# Implementation Plan: 外包项目风险评估 AI 智能体
## Overview
实现语言为 **TypeScript**,属性化测试库采用 **fast-check**,单元/集成测试运行器采用 **Vitest**
实现策略自底向上、增量推进:先建立项目骨架与核心领域类型 + Data_Provenance 三态工具,再实现配置中心(模板加载/继承/实例化/校验),随后是评分引擎(评分/归一化/分级/红线/热力图/Top N),费用测算引擎、策略引擎、追问引擎、分类器、合规规则集、知识库、外部数据适配层、报告生成、持久化、RBAC 与角色视图,由 Orchestrator 端到端串联;最后实现前端表现层(Design_System 设计系统、数据可视化组件、Wizard 评估流程、响应式适配、可访问性、操作反馈与角色化默认视图),复用领域引擎输出。
每条 Correctness Property(共 81 条:领域逻辑 62 条 + Req 19-25 UI 可属性化 19 条,即 Property 63-81)由单个属性化测试实现,紧邻其实现任务以尽早发现错误。属性测试约束:使用 fast-check、每条属性至少运行 100 次迭代、以注释标注标签 `Feature: outsourcing-risk-assessment, Property {n}: {property_text}`。带 `*` 的子任务为可选测试任务。
## Tasks
- [x] 1. 搭建项目骨架与核心领域类型
- [x] 1.1 初始化 TypeScript 工程与测试框架
- 初始化 `package.json``tsconfig.json`、目录结构(`src/``src/__tests__/`
- 安装并配置 Vitest 与 fast-check(≥100 次迭代默认配置)
- 添加 build / test / lint 脚本
- _Requirements: 基础设施(无直接验收标准)_
- [x] 1.2 定义核心领域类型与接口
- 定义 `RiskModel``Dimension``Indicator``ScoringRule``Redline``Template`
- 定义 `Assessment``ScoringItem``RedlineResult``HeatmapCell``RiskItem`
- 定义 `DataProvenance`(用户输入/外部数据/智能体假设)、`Region``ComplianceRuleSet`
- 定义 `KnowledgeBase``IndustryPartition``ConfigAuditEntry``CostEstimate``Acceptability`
- _Requirements: 2.3, 4.6, 16.4, 17.1, 18.4_
- [x] 1.3 实现 Data_Provenance 三态工具
- 实现 provenance 标注/转移函数,强制"智能体假设"单调永久(一旦标注不可被改回其他取值)
- 实现 Confidence 值域约束([0,1] 两位小数)
- _Requirements: 3.7, 4.6, 18.4_
- [x]* 1.4 编写 Data_Provenance 单调性属性测试
- **Property 16: 智能体假设标注单调永久**
- **Validates: Requirements 3.7**
- [x] 2. 实现 Config_Center 模型配置中心
- [x] 2.1 实现权重归一化 `normalizeWeights`
- 按比例归一化使同级启用项权重之和=100%(两位小数);全零向量拒绝
- _Requirements: 11.2, 11.8_
- [x]* 2.2 编写权重归一化保比例属性测试
- **Property 40: 权重归一化保比例且同级和为 100%**
- **Validates: Requirements 11.2**
- [x]* 2.3 编写权重全零拒绝属性测试
- **Property 44: 同级权重全零拒绝归一化**
- **Validates: Requirements 11.8**
- [x] 2.4 实现模板继承解析 `resolveInheritance`
- 应用父模板全部组成项后以子模板差异项逐项覆盖;检测循环引用与层级>5 报错
- _Requirements: 2.5, 2.7_
- [x]* 2.5 编写模板继承逐项覆盖属性测试
- **Property 9: 模板继承逐项覆盖**
- **Validates: Requirements 2.5**
- [x]* 2.6 编写继承链环与深度防护属性测试
- **Property 10: 继承链环与深度防护**
- **Validates: Requirements 2.7**
- [x] 2.7 实现模板匹配与回退 `loadTemplate`
- 精确匹配优先;无精确匹配回退业务类型默认模板并标注"未匹配行业专用模板";皆无则终止返回无可用模板提示
- _Requirements: 2.1, 2.2, 2.6, 14.3, 14.5_
- [x]* 2.8 编写模板匹配与回退确定性属性测试
- **Property 6: 模板匹配与回退确定性**
- **Validates: Requirements 2.1, 2.2, 2.6, 14.3, 14.5**
- [x] 2.9 实现风险模型实例化与校验 `instantiateRiskModel`
- 保留全部组成项与启停状态;校验必填项完整性与同级权重和=100%,非法则不实例化并终止
- _Requirements: 2.3, 2.4_
- [x]* 2.10 编写实例化结构保持属性测试
- **Property 7: 模板实例化结构保持**
- **Validates: Requirements 2.3**
- [x]* 2.11 编写非法模板拒绝实例化属性测试
- **Property 8: 非法模板必被拒绝实例化**
- **Validates: Requirements 2.4**
- [x] 2.12 实现配置保存、另存模板与启停
- 实现 `saveConfig`(校验 Scoring_Rule 覆盖 1-5、Redline 唯一/必填、权重合法性后保存;失败保留上次有效配置)
- 实现 `saveAsTemplate`/派生;实现 Dimension/Indicator 启用/停用(停用保留配置但不计分)
- _Requirements: 11.1, 11.3, 11.4, 11.5, 11.6, 11.7_
- [x]* 2.13 编写评分规则与红线配置校验属性测试
- **Property 41: 评分规则与红线配置校验**
- **Validates: Requirements 11.3, 11.4**
- [x]* 2.14 编写配置另存为模板可往返属性测试
- **Property 42: 配置另存为模板可往返**
- **Validates: Requirements 11.5**
- [x]* 2.15 编写校验失败保留上次有效配置属性测试
- **Property 43: 校验失败保留上次有效配置**
- **Validates: Requirements 11.6, 11.7**
- [x]* 2.16 编写停用项保留但不计分属性测试
- **Property 39: 停用项保留但不计分**
- **Validates: Requirements 11.1**
- [x] 3. Checkpoint - 确保配置中心测试通过
- Ensure all tests pass, ask the user if questions arise.
- [x] 4. 实现 Scoring_Engine 评分引擎
- [x] 4.1 实现 `scoreIndicator``scoreDimension`
- 评分项得分 = Risk_Level(1-5) × 权重;维度得分 = 启用指标评分项加权求和
- _Requirements: 4.1, 4.2_
- [x]* 4.2 编写评分项得分公式属性测试
- **Property 17: 评分项得分公式**
- **Validates: Requirements 4.1, 4.2**
- [x] 4.3 实现 `computeRiskScore` 归一化
- 线性映射 `round((weightedRaw-1)/4×100)`;仅纳入启用项;无启用项返回评分数据不足错误
- _Requirements: 4.3, 4.4, 4.5_
- [x]* 4.4 编写归一化端点与值域属性测试
- **Property 18: 归一化端点与值域**
- **Validates: Requirements 4.3**
- [x]* 4.5 编写停用项不影响评分属性测试
- **Property 19: 停用项不影响评分**
- **Validates: Requirements 4.4**
- [x]* 4.6 编写无启用项必报错属性测试
- **Property 20: 无启用项必报错**
- **Validates: Requirements 4.5**
- [x] 4.7 实现 `classifyGrade` 风险分级
- 区间 [0,25]→低、(25,50]→中、(50,75]→高、(75,100]→极高,输出且仅输出一个 Risk_Grade
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
- [x]* 4.8 编写分级区间互斥且完备属性测试
- **Property 22: 分级区间互斥且完备**
- **Validates: Requirements 5.1, 5.2, 5.3, 5.4, 5.5**
- [x] 4.9 实现 `explain` 与评分项来源/置信标注
- 为每个评分项输出非空判定依据(引用 Dimension/Indicator/Scoring_Rule/数据点取值)、风险影响、建议
- 记录 Data_Provenance 与 [0,1] Confidence
- _Requirements: 4.6, 18.1, 18.2, 18.3, 18.4_
- [x]* 4.10 编写评分项来源与置信标注属性测试
- **Property 21: 评分项来源与置信标注**
- **Validates: Requirements 4.6, 18.4**
- [x]* 4.11 编写可解释三要素非空属性测试
- **Property 61: 评分项可解释三要素非空**
- **Validates: Requirements 18.1, 18.2, 18.3**
- [x] 4.12 实现 `checkRedlines` 红线校验
- 独立于 Risk_Score/Risk_Grade 校验全部启用红线;满足触发条件标记命中
- 数据缺失或为"智能体假设"标"待核实"且不计命中
- _Requirements: 6.1, 6.4, 6.5_
- [x]* 4.13 编写红线校验正确且独立于分值属性测试
- **Property 23: 红线校验正确且独立于分值**
- **Validates: Requirements 6.1, 6.4**
- [x]* 4.14 编写红线数据不足标待核实属性测试
- **Property 24: 红线数据不足标待核实**
- **Validates: Requirements 6.5**
- [x] 4.15 实现 `buildHeatmap` 风险热力图
- 为每个启用 Indicator 输出 Dimension 行 × Indicator 列 × Risk_Level(1-5) 单元格
- _Requirements: 7.1_
- [x]* 4.16 编写热力图覆盖全部启用指标属性测试
- **Property 29: 热力图覆盖全部启用指标**
- **Validates: Requirements 7.1**
- [x] 4.17 实现 `topKeyRisks` 关键风险清单
- 得分降序 Top N(N 可配 1-50 默认 10);同分按 Dimension 权重→Indicator 权重→稳定标识消歧;少于 N 输出全部;每项含 Dimension/Indicator/得分/判定依据
- _Requirements: 7.2, 7.3, 7.4, 7.5_
- [x]* 4.18 编写 Top N 确定性排序属性测试
- **Property 30: Top N 确定性排序**
- **Validates: Requirements 7.2, 7.3, 7.4, 7.5**
- [x] 5. Checkpoint - 确保评分引擎测试通过
- Ensure all tests pass, ask the user if questions arise.
- [x] 6. 实现 Cost_Engine 费用测算引擎
- [x] 6.1 实现 `estimate` 费用测算
- 依 Risk_Grade+评分计算风险溢价区间(下界≤上界、随分级单调)
- 计算垫资利息/保险费用/补偿准备金/坏账准备金(非负、标注依据输入项与费率来源)
- 输出基准报价与风险调整后报价(后者≥基准)及拆解一致;缺成本输入兜底默认值标"智能体假设";评分未完成拒绝并提示
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6_
- [x]* 6.2 编写风险溢价区间合法且单调属性测试
- **Property 31: 风险溢价区间合法且随分级单调**
- **Validates: Requirements 8.1**
- [x]* 6.3 编写费用项非负且标注依据属性测试
- **Property 32: 费用项非负且标注依据**
- **Validates: Requirements 8.2, 8.6**
- [x]* 6.4 编写报价不低于基准且拆解一致属性测试
- **Property 33: 风险调整后报价不低于基准且拆解一致**
- **Validates: Requirements 8.3**
- [x]* 6.5 编写缺失成本输入兜底为假设属性测试
- **Property 34: 缺失成本输入兜底为假设**
- **Validates: Requirements 8.4**
- [x]* 6.6 编写评分未完成拒绝费用测算属性测试
- **Property 35: 评分未完成拒绝费用测算**
- **Validates: Requirements 8.5**
- [x] 7. 实现 Strategy_Engine 应对策略引擎
- [x] 7.1 实现 `decide` 可接受性决策表
- 红线命中→不可接受(优先于 Grade);未命中时 低/中→可接受、高→有条件接受、极高→不可接受
- _Requirements: 6.2, 6.6, 9.1, 9.6, 9.7, 9.8_
- [x]* 7.2 编写可接受性决策表完备且红线最高优先属性测试
- **Property 26: 可接受性决策表完备且红线最高优先**
- **Validates: Requirements 6.2, 6.6, 9.1, 9.6, 9.7, 9.8**
- [x] 7.3 实现管理层面与费用层面应对措施
- 管理措施覆盖合同条款/用工合规整改/退场预案/过程监控四类各≥1
- 费用措施覆盖风险溢价定价/预付保证金/保险转移/账期成本/准备金计提五类各≥1
- _Requirements: 9.3, 9.4_
- [x]* 7.4 编写应对措施类别全覆盖属性测试
- **Property 27: 应对措施类别全覆盖**
- **Validates: Requirements 9.3, 9.4**
- [x] 7.5 实现接受条件清单
- 有条件接受时每个条件关联≥1 关键风险项并输出具体金额/区间的成本影响测算
- _Requirements: 9.2, 9.5_
- [x]* 7.6 编写有条件接受的条件关联与成本影响属性测试
- **Property 28: 有条件接受的条件关联与成本影响**
- **Validates: Requirements 9.2, 9.5**
- [x] 8. 实现 Question_Engine 自适应追问引擎
- [x] 8.1 实现 `identifyGaps` 信息缺口识别
- 缺口集合恰为 {启用 ∧ 依 Scoring_Rule 无法判定 Risk_Level} 的指标
- _Requirements: 3.1_
- [x]* 8.2 编写信息缺口识别准确属性测试
- **Property 11: 信息缺口识别准确**
- **Validates: Requirements 3.1**
- [x] 8.3 实现 `generateQuestions` 追问问题生成
- 仅覆盖缺口指标;按 Indicator 权重降序、同权按 Dimension 权重降序;每问关联真实 Dimension 与 Indicator
- _Requirements: 3.2, 3.8_
- [x]* 8.4 编写追问仅针对缺口且排序确定属性测试
- **Property 12: 追问仅针对缺口且排序确定**
- **Validates: Requirements 3.2, 3.8**
- [x] 8.5 实现 `answerQuestion` 回答处理
- 满足证据要求→移出缺口并更新已知信息;空/不满足证据→保留缺口并返回需补充信息提示
- _Requirements: 3.3, 3.4_
- [x]* 8.6 编写有效回答移出缺口属性测试
- **Property 13: 有效回答移出缺口**
- **Validates: Requirements 3.3**
- [x]* 8.7 编写无效回答保留缺口属性测试
- **Property 14: 无效回答保留缺口**
- **Validates: Requirements 3.4**
- [x] 8.8 实现 `applyDefaultsOnExhaust` 兜底
- 单指标追问轮次不超过可配上限(默认 3);达上限仍缺失采用行业默认值并标注"智能体假设"
- _Requirements: 3.5, 3.6_
- [x]* 8.9 编写追问轮次不超上限并触发兜底属性测试
- **Property 15: 追问轮次不超上限并触发兜底**
- **Validates: Requirements 3.5, 3.6**
- [x] 9. 实现 Classifier 业务类型与行业识别
- [x] 9.1 实现 `classify` 识别与输入校验
- 输入<10 有效字符/空白拒绝;业务类型取五类中 Confidence 最高唯一项;行业不可判定取"未识别"Confidence∈[0,1] 两位小数
- _Requirements: 1.1, 1.2, 1.3, 1.6_
- [x]* 9.2 编写业务类型判定唯一且取最高置信属性测试
- **Property 1: 业务类型判定唯一且取最高置信**
- **Validates: Requirements 1.1**
- [x]* 9.3 编写置信度恒在有效值域内属性测试
- **Property 2: 置信度恒在有效值域内**
- **Validates: Requirements 1.3**
- [x]* 9.4 编写描述信息不足必被拒绝属性测试
- **Property 4: 描述信息不足必被拒绝**
- **Validates: Requirements 1.6**
- [x] 9.5 实现候选列表与确认标志
- Confidence<0.6(行业附加:行业≠"未识别")时返回按 Confidence 降序≤3 项候选并置确认标志
- _Requirements: 1.4, 1.5_
- [x]* 9.6 编写低置信触发候选确认属性测试
- **Property 3: 低置信触发候选确认**
- **Validates: Requirements 1.4, 1.5**
- [x] 9.7 实现 `confirmClassification` 确认值驱动
- 采用评估者确认/修改后的业务类型与行业作为后续加载模板依据
- _Requirements: 1.7_
- [x]* 9.8 编写确认值驱动后续加载属性测试
- **Property 5: 确认值驱动后续加载**
- **Validates: Requirements 1.7**
- [x]* 9.9 编写行业语义识别单元测试
- 代表性描述验证可识别行业与"未识别"分支
- _Requirements: 1.2_
- [x] 10. 实现 Compliance_Rule_Set 地域合规规则集
- [x] 10.1 实现按 Region 加载规则集与 Region 记录/默认
- 创建 Assessment 记录所采用 Region;未指定时默认 CN 并标注系统默认值;无对应规则集拒绝合规处理并提示暂不支持
- _Requirements: 16.4, 16.5, 16.6_
- [x]* 10.2 编写 Region 记录与默认属性测试
- **Property 55: Region 记录与默认**
- **Validates: Requirements 16.4, 16.5**
- [x]* 10.3 编写无规则集地域拒绝合规处理属性测试
- **Property 56: 无规则集地域拒绝合规处理**
- **Validates: Requirements 16.6**
- [x] 10.4 实现 CN 合规判定与费用测算
- 覆盖社保基数、经济补偿 N/N+1、派遣比例上限 10%、当地最低工资;计算经济补偿/社保金额/合规费用并标注规则项与输入项;不满足项标合规不通过
- _Requirements: 16.1, 16.2, 16.3_
- [x]* 10.5 编写合规判定覆盖与计量标注属性测试
- **Property 53: 合规判定覆盖与计量标注**
- **Validates: Requirements 16.1, 16.2**
- [x]* 10.6 编写合规不满足项标注属性测试
- **Property 54: 合规不满足项标注**
- **Validates: Requirements 16.3**
- [x]* 10.7 编写合规算例单元测试
- 给定社保基数/派遣比例/最低工资代表性数值验证 N/N+1 与社保金额计算正确
- _Requirements: 16.2_
- [x] 11. 实现 Knowledge_Base 分行业知识库
- [x] 11.1 实现行业分区存储与完备性校验
- 按行业分区存储 Indicator/权重模板/Redline/典型案例/追问话术五类;缺任一类拒绝创建并指明缺失类别且保持已有分区不变
- _Requirements: 14.1, 14.4_
- [x]* 11.2 编写行业分区内容完备性校验属性测试
- **Property 50: 行业分区内容完备性校验**
- **Validates: Requirements 14.1, 14.4**
- [x]* 11.3 编写知识库行业分区运行时扩展集成测试
- 运行时新增分区后验证其 Template 可被加载、无需修改 Scoring_Engine 源码或重编译
- _Requirements: 14.2_
- [x] 12. 实现 External_Data_Adapter 外部数据适配层
- [x] 12.1 实现适配器接口与来源标注
- 定义可插拔 `DataSourceAdapter` 接口;成功取数标注 Data_Provenance="外部数据" 与 [0,1] Confidence
- _Requirements: 15.1, 15.2, 15.5_
- [x]* 12.2 编写外部数据点来源与置信标注属性测试
- **Property 51: 外部数据点来源与置信标注**
- **Validates: Requirements 15.2**
- [x] 12.3 实现超时/失败降级回退
- 连接失败/超 10 秒/错误响应回退用户输入标"用户输入";回退后仍缺失标"智能体假设"并继续评估不中断
- _Requirements: 15.3, 15.4_
- [x]* 12.4 编写外部数据失败降级回退属性测试
- **Property 52: 外部数据失败降级回退**
- **Validates: Requirements 15.3, 15.4**
- [x]* 12.5 编写外部数据适配集成测试
- mock 数据源验证成功取数路径;注册新适配器验证无需改 Scoring_Engine 源码即可使用
- _Requirements: 15.1, 15.5_
- [x] 13. 实现 Report_Generator 报告生成与导出
- [x] 13.1 实现 `generate` 报告生成与流程门控
- 评分/红线/费用/可接受性结论均完成方可生成;含全部规定章节;未命中红线明确标注"无红线命中";流程未完成拒绝并提示
- _Requirements: 10.1, 10.4_
- [x]* 13.2 编写报告章节完备属性测试
- **Property 36: 报告章节完备**
- **Validates: Requirements 10.1**
- [x]* 13.3 编写流程未完成拒绝报告属性测试
- **Property 38: 流程未完成拒绝报告**
- **Validates: Requirements 10.4**
- [x] 13.4 实现维度明细字段与信息缺口尽调说明
- 各维度明细每个评分项展示评分/判定依据/风险影响/Data_Provenance/Confidence
- "智能体假设"项列入信息缺口说明并附关联 Indicator 的补充尽调建议
- _Requirements: 10.3, 18.5, 18.6_
- [x]* 13.5 编写维度明细字段齐备属性测试
- **Property 37: 维度明细字段齐备**
- **Validates: Requirements 10.3**
- [x]* 13.6 编写假设项进入缺口说明并附尽调建议属性测试
- **Property 62: 假设项进入缺口说明并附尽调建议**
- **Validates: Requirements 18.5, 18.6**
- [x] 13.7 实现命中红线列入报告
- 命中红线评估报告列出每个被命中红线及其被触发条件与判定依据数据
- _Requirements: 6.3_
- [x]* 13.8 编写命中红线列入报告属性测试
- **Property 25: 命中红线列入报告**
- **Validates: Requirements 6.3**
- [x] 13.9 实现 `export` 报告导出
- 30 秒内导出完整自包含可下载文件;失败中止导出、保留已生成报告不变并返回错误
- _Requirements: 10.2, 10.5_
- [x]* 13.10 编写报告导出集成测试
- 验证导出文件自包含且含全部章节、耗时<30 秒;注入导出失败验证报告内容不变
- _Requirements: 10.2, 10.5_
- [x] 14. 实现评估持久化与历史管理
- [x] 14.1 实现 `save` 持久化与元数据
- 完成即持久化输入/评分结果/报告/元数据(含业务类型/行业/Region/Risk_Score/Risk_Grade/创建时间/评估者身份);失败保留会话数据并返回错误
- _Requirements: 17.1, 17.2_
- [x]* 14.2 编写评估持久化往返属性测试
- **Property 57: 评估持久化往返**
- **Validates: Requirements 17.1**
- [x] 14.3 实现 `reassess` 复评
- 基于原输入创建新 Assessment(新标识)且原评估不变;引用不存在评估拒绝并提示
- _Requirements: 17.3, 17.4_
- [x]* 14.4 编写复评保留原评估属性测试
- **Property 58: 复评保留原评估**
- **Validates: Requirements 17.3, 17.4**
- [x] 14.5 实现 `compare` 跨项目对比
- ≥2 个返回各 Risk_Grade/Risk_Score/关键风险对比数据;<2 个拒绝并提示至少需选 2 个
- _Requirements: 17.5, 17.6_
- [x]* 14.6 编写跨项目对比数量约束与内容属性测试
- **Property 59: 跨项目对比的数量约束与内容**
- **Validates: Requirements 17.5, 17.6**
- [x] 14.7 实现 `search` 历史检索
- 按业务类型/行业/Risk_Grade/创建时间范围检索;返回项均满足条件,无匹配返回空集
- _Requirements: 17.7_
- [x]* 14.8 编写检索结果满足过滤条件属性测试
- **Property 60: 检索结果满足过滤条件**
- **Validates: Requirements 17.7**
- [x]* 14.9 编写持久化失败集成测试
- 注入存储失败验证会话数据保留并返回存储失败错误
- _Requirements: 17.2_
- [x] 15. 实现 RBAC 角色权限与角色视图
- [x] 15.1 实现 `requireRole`、非管理员配置拒绝与审计
- 非 Administrator(含 Assessor/未认证/未授权)配置修改一律拒绝、配置不变、返回权限不足
- 成功变更记录操作者/精确到秒变更时间/变更项标识;拒绝记录操作者与请求时间
- _Requirements: 12.1, 12.3, 12.4, 12.5_
- [x]* 15.2 编写非管理员配置修改一律拒绝属性测试
- **Property 45: 非管理员配置修改一律拒绝且配置不变**
- **Validates: Requirements 12.1, 12.3**
- [x]* 15.3 编写配置变更与拒绝均留痕审计属性测试
- **Property 46: 配置变更与拒绝均留痕审计**
- **Validates: Requirements 12.4, 12.5**
- [x] 15.4 实现 `renderView` 角色化视图
- 商务/销售:结论+接受条件+风险调整后报价;风控:评分明细(Risk_Level/依据/Provenance)+红线+缺口尽调;管理层:Grade+热力图+TopN+利润对风险对比
- _Requirements: 13.1, 13.2, 13.3_
- [x]* 15.5 编写角色化视图内容映射属性测试
- **Property 47: 角色化视图内容映射**
- **Validates: Requirements 13.1, 13.2, 13.3**
- [x] 15.6 实现 `renderPortfolio` 组合看板与无角色拒绝
- 管理层组合看板汇总全部评估,空集展示空看板并提示;无角色用户拒绝展示并提示分配角色
- _Requirements: 13.4, 13.5, 13.6_
- [x]* 15.7 编写组合看板汇总与空集处理属性测试
- **Property 48: 组合看板汇总与空集处理**
- **Validates: Requirements 13.4, 13.6**
- [x]* 15.8 编写无角色拒绝展示属性测试
- **Property 49: 无角色拒绝展示**
- **Validates: Requirements 13.5**
- [x]* 15.9 编写 Assessor 可执行评估单元测试
- 示例验证 Assessor 可使用当前 Risk_Model 执行评估的允许路径
- _Requirements: 12.2_
- [x] 16. 集成与端到端串联
- [x] 16.1 实现 Assessment Orchestrator 端到端编排
- 串联分类→模板加载/实例化→追问(含外部数据降级)→评分/红线→费用→策略→报告→持久化,连接全部组件
- _Requirements: 1.7, 2.1, 3.1, 4.3, 8.1, 9.1, 10.1, 16.4, 17.1_
- [x]* 16.2 编写端到端评估流程集成测试
- 自动化测试覆盖从项目描述输入到报告生成与持久化的完整流程
- _Requirements: 10.1, 17.1_
- [x] 17. Checkpoint - 确保领域引擎与持久化全部测试通过
- Ensure all tests pass, ask the user if questions arise.
- [x] 18. 搭建前端工程脚手架与 Design_System 设计系统
- [x] 18.1 初始化前端工程脚手架与 UI 测试工具链
- 初始化 React + TypeScript 前端工程(与既有 TS 工程共用或独立 `web/` 目录),配置打包/路由依赖
- 安装并配置 UI 测试工具:组件测试(Vitest + React Testing Library)、可视化回归快照、自动化可访问性检查(axe,如 `@axe-core/react`/`jest-axe`)、属性化测试 fast-check
- 引入图表库(如 ECharts 或 Recharts/Visx)依赖
- _Requirements: 基础设施(无直接验收标准)_
- [x] 18.2 实现 Design Tokens(排版/间距/图标/Color_Token
- 定义 ≥4 级具名 Typography 层级(各含固定字号与固定行高)
- 定义以 4 像素为基数的间距标度(取值均为 4 的整数倍)
- 定义单一来源图标集 IconSet
- 定义稳定 Color_Token,含 Risk_Grade 四级语义化配色(`color.risk.low/medium/high/critical`)与热力图顺序色 `color.heat.1..5`,每令牌含 Light/Dark 双主题取值
- _Requirements: 19.2, 19.3, 19.4, 19.5_
- [x]* 18.3 编写排版层级完备且文本取自层级属性测试
- **Property 63: 排版层级完备且文本取自层级**
- **Validates: Requirements 19.2**
- [x]* 18.4 编写 Risk_Grade 配色令牌一致且唯一属性测试
- **Property 64: Risk_Grade 配色令牌一致且唯一**
- **Validates: Requirements 19.3**
- [x]* 18.5 编写间距为 4 像素整数倍属性测试
- **Property 65: 间距为 4 像素整数倍**
- **Validates: Requirements 19.4**
- [x] 18.6 实现 Theme Provider 与 `resolveColorToken`CSS Variables
- 以 CSS 自定义属性承载 Color_Token,按 ThemeLight/Dark)切换变量集
- 实现 `resolveColorToken(token, theme)``riskGradeColorToken(grade)``setTheme` 仅替换令牌取值且保留页面已录入数据
- _Requirements: 19.6, 19.7_
- [x]* 18.7 编写主题配色令牌解析正确属性测试
- **Property 66: 主题配色令牌解析正确**
- **Validates: Requirements 19.6**
- [x] 18.8 实现基础组件库(Button/Input/Table/Card/Dialog/Nav/Toast
- 基于 Design Tokens 封装可复用基础组件,同类组件全局外观与交互行为一致,图标统一引用单一图标集
- _Requirements: 19.1, 19.5_
- [x]* 18.9 编写组件库一致性与单一图标集组件测试
- 验证同类组件在不同页面外观/行为一致、全部图标取自单一图标集
- _Requirements: 19.1, 19.5_
- [x] 19. 实现数据可视化组件库(Charts)
- [x] 19.1 实现通用 Chart 容器与状态/图例/标签/非颜色编码
- 实现 `renderChart(spec)` 通用契约:data 为空→Empty_State 提示无可展示数据;data 请求/计算中→Loading_State
- 系列/类别 ≥2 提供图例(标签与数据元素一致);坐标轴/数据点/分区提供非空文本标签
- 类别以颜色之外的文本标签或图案区分(`categoryEncoding`
- 消费 Scoring_Engine 输出(热力图、Top N 数据,见任务 4.15/4.17
- _Requirements: 20.2, 20.3, 20.4, 20.5, 23.6_
- [x] 19.2 实现风险热力图、Risk_Badge 与 Top N 关键风险图
- 热力图按 Dimension×Indicator×Risk_Level 渲染并附数值标签;Risk_Badge 以 Color_Token + 文字标签呈现 Risk_GradeTop N 条形图
- _Requirements: 20.1_
- [x] 19.3 实现风险总分仪表盘(Risk_Score 数值 + Risk_Grade
- 仪表盘同时呈现 Risk_Score 数值与按分级规则对应的 Risk_Grade(与 `classifyGrade` 输出一致)
- _Requirements: 20.1, 20.6_
- [x] 19.4 实现费用拆解图与基准 vs 风险调整后报价对比图
- 费用拆解图;报价对比图同时呈现基准报价、风险调整后报价与二者差额(差额=风险调整后-基准)
- _Requirements: 20.1, 20.7_
- [x] 19.5 实现跨项目组合对比图
- 跨项目组合对比图,消费持久化层 `compare`/组合看板数据
- _Requirements: 20.1_
- [x]* 19.6 编写图表图例与数据系列一致属性测试
- **Property 68: 图表图例与数据系列一致**
- **Validates: Requirements 20.2**
- [x]* 19.7 编写图表文本标签齐备属性测试
- **Property 69: 图表文本标签齐备**
- **Validates: Requirements 20.3**
- [x]* 19.8 编写图表空态与加载态呈现属性测试
- **Property 70: 图表空态与加载态呈现**
- **Validates: Requirements 20.4, 20.5**
- [x]* 19.9 编写仪表盘同时呈现总分与分级属性测试
- **Property 71: 仪表盘同时呈现总分与分级**
- **Validates: Requirements 20.6**
- [x]* 19.10 编写费用对比图三值并呈且差额一致属性测试
- **Property 72: 费用对比图三值并呈且差额一致**
- **Validates: Requirements 20.7**
- [x]* 19.11 编写图表非纯颜色编码属性测试
- **Property 79: 图表非纯颜色编码**
- **Validates: Requirements 23.6**
- [x]* 19.12 编写全套图表组件渲染单元测试
- 验证 Req 20.1 规定的全套图表组件(热力图/仪表盘/Risk_Badge/Top N/费用拆解/报价对比/组合对比)均可渲染
- _Requirements: 20.1_
- [x] 20. 实现 Wizard 向导与评估流程 UI
- [x] 20.1 实现 Wizard 步骤与进度指示
- 将评估流程拆分为有序步骤,呈现当前步骤序号、步骤总数与已完成步骤进度;`advance` 使已完成数单调非减
- _Requirements: 21.1_
- [x] 20.2 实现录入方式切换与一键操作入口
- 对话式追问↔表单录入切换保留切换前已录入数据;为提交评估/保存草稿/导出报告各提供一键触发入口
- _Requirements: 21.2, 21.4_
- [x] 20.3 实现信息缺口提示面板
- 以区别于常规文本的醒目样式呈现全部信息缺口/待补充项,并为每项提供定位至对应录入位置的入口
- _Requirements: 21.3_
- [x] 20.4 实现 Draft 草稿保存、断点续评与未保存离开确认
- 保存草稿持久化为 Draft 保留全部录入数据;打开 Draft 恢复全部数据并定位至保存时步骤;存在未保存修改离开时弹出确认
- _Requirements: 21.5, 21.6, 21.7_
- [x]* 20.5 编写 Wizard 进度正确且单调属性测试
- **Property 73: Wizard 进度正确且单调**
- **Validates: Requirements 21.1**
- [x]* 20.6 编写缺口项醒目呈现并可定位属性测试
- **Property 74: 缺口项醒目呈现并可定位**
- **Validates: Requirements 21.3**
- [x]* 20.7 编写 Draft 往返保真属性测试
- **Property 75: Draft 往返保真**
- **Validates: Requirements 21.5, 21.6**
- [x]* 20.8 编写一键入口与未保存离开确认组件测试
- 验证提交/保存草稿/导出一键入口存在,且未保存修改离开时呈现确认提示
- _Requirements: 21.4, 21.7_
- [x] 21. 实现响应式布局服务与断点适配
- [x] 21.1 实现 `selectLayout` 断点映射与 `onViewportChange`
- 确定性断点映射:≥1280→桌面布局(全部功能)、768-1279→紧凑布局(无横向滚动、内容完整可见)、<768→移动布局(看板保留 Risk_Score/Risk_Grade/Top N
- 跨断点切换应用匹配布局且不丢失已录入数据
- _Requirements: 22.1, 22.2, 22.3, 22.4_
- [x]* 21.2 编写断点布局映射确定性属性测试
- **Property 76: 断点布局映射确定性**
- **Validates: Requirements 22.1, 22.2, 22.3**
- [x]* 21.3 编写 UI 状态转换保留已录入数据属性测试
- **Property 67: UI 状态转换保留已录入数据**
- 覆盖切换 Theme、对话↔表单录入切换、跨断点布局变更后数据不丢失不篡改
- **Validates: Requirements 19.7, 21.2, 22.4**
- [x] 22. 实现可访问性(WCAG 2.1 AA
- [x] 22.1 实现键盘可达、可见焦点、表单标签与无障碍错误
- 全部交互控件可键盘聚焦并触发、聚焦呈现可见焦点指示;每个表单输入控件关联可被辅助技术识别的非空文本标签;校验失败为每个未通过控件呈现可被辅助技术识别的错误提示
- _Requirements: 23.1, 23.2, 23.4, 23.5_
- [x] 22.2 实现 `contrastRatio` 对比度计算
- 计算 WCAG 相对对比度,供正文 ≥4.5:1、大号文本 ≥3:1 达标校验
- _Requirements: 23.3_
- [x]* 22.3 编写文本对比度达标属性测试
- **Property 77: 文本对比度达标**
- **Validates: Requirements 23.3**
- [x]* 22.4 编写表单可访问标注与错误提示属性测试
- **Property 78: 表单可访问标注与错误提示**
- **Validates: Requirements 23.4, 23.5**
- [x]* 22.5 编写 axe 自动化可访问性检查
- 对全部页面运行 axe 规则集,验证键盘可聚焦与可见焦点指示、标签关联与 ARIA 正确性
- _Requirements: 23.1, 23.2_
- [x] 23. 实现操作反馈与角色化默认视图/路由守卫
- [x] 23.1 实现操作状态反馈与导出进度反馈
- `runOperation` 状态映射:处理中→Loading_State、成功→成功反馈、失败→可读错误信息(说明原因+指向修正路径的操作入口)
- 报告导出执行中呈现进度反馈,30 秒内呈现完成或失败终态
- _Requirements: 24.1, 24.2, 24.3, 24.4_
- [x]* 23.2 编写操作状态反馈映射属性测试
- **Property 80: 操作状态反馈映射**
- **Validates: Requirements 24.1, 24.2, 24.3**
- [x] 23.3 实现 `defaultRoute` 角色化默认视图与 RBAC 路由守卫
- 角色默认导航:商务/销售→SalesView、风控→RiskView、管理层→ManagementDashboard;无角色→需分配角色提示视图且不呈现评估数据
- 默认视图首屏将与角色匹配的功能入口呈现于无需额外导航即可见的位置;复用任务 15 的角色化视图(`renderView`/`renderPortfolio`)作为目标视图
- _Requirements: 25.1, 25.2, 25.3, 25.4, 25.5_
- [x]* 23.4 编写角色默认路由确定性属性测试
- **Property 81: 角色默认路由确定性**
- **Validates: Requirements 25.1, 25.2, 25.3, 25.5**
- [x]* 23.5 编写默认视图首屏角色入口可见组件测试
- 验证各角色默认视图首屏呈现与其角色匹配的功能入口
- _Requirements: 25.4_
- [x] 24. 前端 UI 可视化回归与性能检查(非属性化补充)
- [x]* 24.1 编写可视化回归基线快照测试
- 对关键页面与全套图表建立基线快照,覆盖 Light/Dark 双主题与各断点(≥1280/768-1279/<768)布局,验证排版/配色/间距视觉一致与图表标签不被遮挡
- _Requirements: 19.1, 20.3_
- [x]* 24.2 编写主题切换与导出性能检查
- 验证主题切换在 1 秒内对当前页面生效、报告导出在 30 秒内给出完成或失败终态
- _Requirements: 19.7, 24.4_
- [x] 25. 最终 Checkpoint - 确保领域引擎与前端 UI 全部测试通过
- Ensure all tests pass, ask the user if questions arise.
## Notes
- 标记 `*` 的子任务为可选测试任务,可为更快的 MVP 跳过;核心实现任务不可跳过。
- 每条 Correctness Property 由单个属性化测试实现,共 81 条(领域逻辑 62 条 + Req 19-25 UI 可属性化 19 条,即 Property 63-81),全部映射到对应实现任务并紧邻放置。
- 属性测试使用 fast-check、每条至少运行 100 次迭代、标注标签 `Feature: outsourcing-risk-assessment, Property {n}: {property_text}`
- 非属性类标准由单元测试与集成测试覆盖:领域侧(语义识别、外部 IO、导出时延、知识库分区扩展、持久化失败、架构解耦);前端侧(组件一致性、单一图标集、全套图表渲染、一键入口、未保存离开确认、默认视图首屏角色入口)。
- 前端 UI 不适合属性化的标准由组件测试、可视化回归(Light/Dark + 各断点基线快照)、axe 自动化可访问性检查与性能检查(主题切换 1 秒内、导出 30 秒内)覆盖。
- 前端任务(18-24)自底向上:先 Design_System(令牌/主题/组件库)→ 可视化组件 → Wizard 流程 → 响应式 → 可访问性 → 操作反馈与角色路由,复用领域引擎(任务 4 评分/热力图/Top N、任务 13 报告导出、任务 15 角色化视图)输出,避免重复。
- 每个任务引用其对应需求与设计属性编号以保证可追溯性;Checkpoint 用于增量验证。
## Task Dependency Graph
```json
{
"waves": [
{ "id": 0, "tasks": ["1.1"] },
{ "id": 1, "tasks": ["1.2"] },
{ "id": 2, "tasks": ["1.3", "2.1", "4.1", "9.1", "10.1", "11.1", "12.1", "14.1"] },
{ "id": 3, "tasks": ["1.4", "2.2", "2.3", "2.4", "4.2", "4.3", "9.2", "9.3", "9.4", "9.5", "9.9", "10.2", "10.3", "10.4", "11.2", "11.3", "12.2", "12.3", "14.2", "14.3", "15.1"] },
{ "id": 4, "tasks": ["2.5", "2.6", "2.7", "4.4", "4.5", "4.6", "4.7", "9.6", "9.7", "10.5", "10.6", "10.7", "12.4", "12.5", "14.4", "14.5", "15.2", "15.3", "15.9"] },
{ "id": 5, "tasks": ["2.8", "2.9", "4.8", "4.9", "9.8", "14.6", "14.7"] },
{ "id": 6, "tasks": ["2.10", "2.11", "2.12", "4.10", "4.11", "4.12", "8.1", "14.8", "14.9"] },
{ "id": 7, "tasks": ["2.13", "2.14", "2.15", "2.16", "4.13", "4.14", "4.15", "8.2", "8.3"] },
{ "id": 8, "tasks": ["4.16", "4.17", "8.4", "8.5"] },
{ "id": 9, "tasks": ["4.18", "6.1", "7.1", "8.6", "8.7", "8.8"] },
{ "id": 10, "tasks": ["6.2", "6.3", "6.4", "6.5", "6.6", "7.2", "7.3", "8.9"] },
{ "id": 11, "tasks": ["7.4", "7.5"] },
{ "id": 12, "tasks": ["7.6"] },
{ "id": 13, "tasks": ["13.1"] },
{ "id": 14, "tasks": ["13.2", "13.3", "13.4", "15.4"] },
{ "id": 15, "tasks": ["13.5", "13.6", "13.7", "15.5", "15.6"] },
{ "id": 16, "tasks": ["13.8", "13.9", "15.7", "15.8"] },
{ "id": 17, "tasks": ["13.10"] },
{ "id": 18, "tasks": ["16.1"] },
{ "id": 19, "tasks": ["16.2"] },
{ "id": 20, "tasks": ["18.1"] },
{ "id": 21, "tasks": ["18.2"] },
{ "id": 22, "tasks": ["18.3", "18.4", "18.5", "18.6", "18.8"] },
{ "id": 23, "tasks": ["18.7", "18.9", "19.1"] },
{ "id": 24, "tasks": ["19.2", "19.3", "19.4", "19.5"] },
{ "id": 25, "tasks": ["19.6", "19.7", "19.8", "19.9", "19.10", "19.11", "19.12", "20.1"] },
{ "id": 26, "tasks": ["20.2", "20.3", "20.4", "21.1", "22.1", "22.2", "23.1", "23.3"] },
{ "id": 27, "tasks": ["20.5", "20.6", "20.7", "20.8", "21.2", "21.3", "22.3", "22.4", "22.5", "23.2", "23.4", "23.5"] },
{ "id": 28, "tasks": ["24.1", "24.2"] }
]
}
```
+2
View File
@@ -0,0 +1,2 @@
{
}
+349
View File
@@ -0,0 +1,349 @@
#!/bin/bash
# ============================================================
# 百度网盘备份脚本
# 用法: ./baidu-backup.sh [目标目录]
# 示例: ./baidu-backup.sh /2026/0517
# ./baidu-backup.sh # 自动使用 /年份/月日
# ============================================================
set -e
# ---- 配置 ----
APP_KEY="z3gemBZfg7KYj6U3eHNfIzTs7uYS9OMh"
SECRET_KEY="ptCKj2DfxL0KtGR1pM08c9KO2t2UC7SR"
TOKEN_FILE="$HOME/.baidu_pan_token.json"
BLOCK_SIZE=$((4 * 1024 * 1024)) # 4MB
# ---- 自动识别项目 ----
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_NAME="$(basename "$SCRIPT_DIR")"
PARENT_DIR="$(dirname "$SCRIPT_DIR")"
ZIP_FILE="/tmp/${PROJECT_NAME}.zip"
MD5_FILE="/tmp/baidu_md5_list.txt"
# ---- 目标目录 ----
if [ -n "$1" ]; then
REMOTE_DIR="$1"
else
REMOTE_DIR="/$(date +%Y)/$(date +%m%d)"
fi
REMOTE_PATH="${REMOTE_DIR}/${PROJECT_NAME}.zip"
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║ 📦 百度网盘备份工具 ║"
echo "╚══════════════════════════════════════════╝"
echo ""
echo " 项目: $PROJECT_NAME"
echo " 目标: $REMOTE_PATH"
echo ""
# ============================================================
# 步骤1: 打包
# ============================================================
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "[1/5] 📦 打包项目文件..."
rm -f "$ZIP_FILE"
cd "$PARENT_DIR"
# 先统计文件总数
TOTAL_FILES=$(find "$PROJECT_NAME" -not -path "${PROJECT_NAME}/.git/*" -type f | wc -l | tr -d ' ')
echo " 📊 共 ${TOTAL_FILES} 个文件"
# 打包并实时显示百分比
COUNTER=0
zip -r "$ZIP_FILE" "$PROJECT_NAME" -x "${PROJECT_NAME}/.git/*" 2>&1 | while IFS= read -r line; do
COUNTER=$((COUNTER + 1))
PCT=$((COUNTER * 100 / TOTAL_FILES))
if [ $PCT -gt 100 ]; then PCT=100; fi
# 进度条
BAR_FILLED=$((PCT * 30 / 100))
BAR_EMPTY=$((30 - BAR_FILLED))
BAR=""
for ((b=0; b<BAR_FILLED; b++)); do BAR="${BAR}"; done
for ((b=0; b<BAR_EMPTY; b++)); do BAR="${BAR}"; done
printf "\r %s %3d%% (%d/%d) " "$BAR" "$PCT" "$COUNTER" "$TOTAL_FILES"
done
echo ""
FILE_SIZE=$(stat -f%z "$ZIP_FILE" 2>/dev/null || stat -c%s "$ZIP_FILE" 2>/dev/null)
FILE_SIZE_MB=$((FILE_SIZE / 1024 / 1024))
echo " ✅ 打包完成: ${FILE_SIZE_MB}MB"
# ============================================================
# 步骤2: 获取 Token(缓存 / 刷新 / 设备授权)
# ============================================================
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "[2/5] 🔑 获取百度网盘授权..."
ACCESS_TOKEN=""
# 尝试读取缓存
if [ -f "$TOKEN_FILE" ]; then
CACHED=$(python3 -c "
import json, time, sys
with open('$TOKEN_FILE') as f:
d = json.load(f)
if time.time() < d.get('expires_at', 0):
print('VALID|' + d['access_token'])
else:
print('EXPIRED|' + d.get('refresh_token', ''))
" 2>/dev/null || echo "FAIL|")
STATUS="${CACHED%%|*}"
VALUE="${CACHED#*|}"
if [ "$STATUS" = "VALID" ]; then
ACCESS_TOKEN="$VALUE"
echo " ✅ 使用缓存 Token(有效)"
elif [ "$STATUS" = "EXPIRED" ] && [ -n "$VALUE" ]; then
echo " ⏳ Token 已过期,尝试刷新..."
RESP=$(curl -s -X POST "https://openapi.baidu.com/oauth/2.0/token" \
-d "grant_type=refresh_token&refresh_token=${VALUE}&client_id=${APP_KEY}&client_secret=${SECRET_KEY}")
NEW_TOKEN=$(echo "$RESP" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('access_token',''))" 2>/dev/null)
if [ -n "$NEW_TOKEN" ]; then
ACCESS_TOKEN="$NEW_TOKEN"
# 更新缓存
python3 -c "
import json, time
d = json.loads('$RESP'.replace(\"'\", '\"'))
token = {'access_token': d['access_token'], 'refresh_token': d['refresh_token'], 'expires_at': int(time.time()) + d['expires_in']}
with open('$TOKEN_FILE', 'w') as f: json.dump(token, f)
" 2>/dev/null
echo " ✅ Token 刷新成功"
else
echo " ⚠️ 刷新失败,需要重新授权"
fi
fi
fi
# 如果没有有效 Token,走设备授权流程
if [ -z "$ACCESS_TOKEN" ]; then
echo " 🔐 需要设备授权..."
DEVICE_RESP=$(curl -s -X POST "https://openapi.baidu.com/oauth/2.0/device/code" \
-d "response_type=device_code&client_id=${APP_KEY}&scope=basic,netdisk")
DEVICE_CODE=$(echo "$DEVICE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin)['device_code'])")
USER_CODE=$(echo "$DEVICE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin)['user_code'])")
QRCODE_URL=$(echo "$DEVICE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin)['qrcode_url'])")
echo ""
echo " ┌─────────────────────────────────────┐"
echo " │ 请打开: https://openapi.baidu.com/device"
echo " │ 输入码: $USER_CODE"
echo " │ 或扫码: $QRCODE_URL"
echo " └─────────────────────────────────────┘"
echo ""
read -p " 授权完成后按回车继续..." _
TOKEN_RESP=$(curl -s -X POST "https://openapi.baidu.com/oauth/2.0/token" \
-d "grant_type=device_token&code=${DEVICE_CODE}&client_id=${APP_KEY}&client_secret=${SECRET_KEY}")
ACCESS_TOKEN=$(echo "$TOKEN_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null)
if [ -z "$ACCESS_TOKEN" ]; then
echo " ❌ 授权失败: $TOKEN_RESP"
rm -f "$ZIP_FILE"
exit 1
fi
# 保存缓存
echo "$TOKEN_RESP" | python3 -c "
import sys, json, time
d = json.load(sys.stdin)
token = {'access_token': d['access_token'], 'refresh_token': d['refresh_token'], 'expires_at': int(time.time()) + d['expires_in']}
with open('$TOKEN_FILE', 'w') as f: json.dump(token, f)
"
echo " ✅ 授权成功,Token 已缓存"
fi
# ============================================================
# 步骤3: 预创建文件
# ============================================================
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
BLOCKS=$(( (FILE_SIZE + BLOCK_SIZE - 1) / BLOCK_SIZE ))
echo "[3/5] 📋 预创建文件(${BLOCKS}个分片)..."
BLOCK_LIST=$(python3 -c "import json; print(json.dumps(['0'*32]*$BLOCKS))")
PRE_RESP=$(curl -s "https://pan.baidu.com/rest/2.0/xpan/file?method=precreate&access_token=${ACCESS_TOKEN}" \
-d "path=${REMOTE_PATH}&size=${FILE_SIZE}&isdir=0&autoinit=1&block_list=${BLOCK_LIST}")
UPLOAD_ID=$(echo "$PRE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('uploadid',''))" 2>/dev/null)
if [ -z "$UPLOAD_ID" ]; then
echo " ❌ 预创建失败: $PRE_RESP"
rm -f "$ZIP_FILE"
exit 1
fi
echo " ✅ 预创建成功"
# ============================================================
# 步骤4: 分片上传(并发)
# ============================================================
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
PARALLEL="${BAIDU_PARALLEL:-8}"
if [ "$PARALLEL" -gt "$BLOCKS" ]; then PARALLEL=$BLOCKS; fi
echo "[4/5] 🚀 上传中(${PARALLEL} 路并发)..."
echo ""
UNSORTED_MD5="/tmp/baidu_md5_unsorted_${UPLOAD_ID}.txt"
PROGRESS_FILE="/tmp/baidu_progress_${UPLOAD_ID}.txt"
ERR_FILE="/tmp/baidu_err_${UPLOAD_ID}.txt"
DONE_FLAG="/tmp/baidu_done_${UPLOAD_ID}"
: > "$UNSORTED_MD5"
: > "$PROGRESS_FILE"
: > "$ERR_FILE"
rm -f "$DONE_FLAG"
: > "$MD5_FILE"
START_TIME=$(date +%s)
# 单分片上传 worker(并发安全:单行 echo < PIPE_BUF 是原子写)
upload_part() {
local i=$1
local chunk="/tmp/baidu_chunk_${UPLOAD_ID}_${i}"
dd if="$ZIP_FILE" bs="$BLOCK_SIZE" skip="$i" count=1 2>/dev/null > "$chunk"
local resp md5 attempt
md5=""
for attempt in 1 2 3; do
resp=$(curl -s --max-time 600 \
"https://d.pcs.baidu.com/rest/2.0/pcs/superfile2?method=upload&access_token=${ACCESS_TOKEN}&type=tmpfile&path=${REMOTE_PATH}&uploadid=${UPLOAD_ID}&partseq=${i}" \
-F "file=@${chunk}")
md5=$(echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin).get('md5',''))" 2>/dev/null)
[ -n "$md5" ] && break
sleep $((attempt * 2))
done
rm -f "$chunk"
if [ -z "$md5" ]; then
echo "part ${i} failed after 3 attempts: ${resp}" >> "$ERR_FILE"
return 1
fi
echo "${i}|${md5}" >> "$UNSORTED_MD5"
echo "x" >> "$PROGRESS_FILE"
}
export -f upload_part
export ZIP_FILE BLOCK_SIZE ACCESS_TOKEN REMOTE_PATH UPLOAD_ID
export UNSORTED_MD5 PROGRESS_FILE ERR_FILE
# 后台进度刷新进程(每秒刷新一次)
(
while :; do
DONE=$(wc -l < "$PROGRESS_FILE" 2>/dev/null | tr -d ' ')
DONE=${DONE:-0}
if [ "$DONE" -gt "$BLOCKS" ]; then DONE=$BLOCKS; fi
PCT=$((DONE * 100 / BLOCKS))
UPLOADED_MB=$((DONE * BLOCK_SIZE / 1024 / 1024))
if [ $UPLOADED_MB -gt $FILE_SIZE_MB ]; then UPLOADED_MB=$FILE_SIZE_MB; fi
NOW=$(date +%s)
ELAPSED=$((NOW - START_TIME))
if [ $ELAPSED -gt 0 ] && [ $DONE -gt 0 ]; then
SPEED_KB=$((DONE * BLOCK_SIZE / 1024 / ELAPSED))
SPEED_INT=$((SPEED_KB / 1024))
SPEED_DEC=$(( (SPEED_KB % 1024) * 10 / 1024 ))
if [ "$SPEED_KB" -gt 0 ]; then
REMAINING_KB=$(( (FILE_SIZE - DONE * BLOCK_SIZE) / 1024 ))
if [ $REMAINING_KB -lt 0 ]; then REMAINING_KB=0; fi
ETA=$(( REMAINING_KB / SPEED_KB ))
ETA_MIN=$((ETA / 60))
ETA_SEC=$((ETA % 60))
ETA_STR="${ETA_MIN}m${ETA_SEC}s"
else
ETA_STR="计算中"
fi
else
SPEED_INT=0
SPEED_DEC=0
ETA_STR="计算中"
fi
BAR_FILLED=$((PCT * 30 / 100))
BAR_EMPTY=$((30 - BAR_FILLED))
BAR=""
for ((b=0; b<BAR_FILLED; b++)); do BAR="${BAR}"; done
for ((b=0; b<BAR_EMPTY; b++)); do BAR="${BAR}"; done
printf "\r %s %3d%% | %d/%dMB | %d.%dMB/s | 剩余%s " "$BAR" "$PCT" "$UPLOADED_MB" "$FILE_SIZE_MB" "$SPEED_INT" "$SPEED_DEC" "$ETA_STR"
if [ -f "$DONE_FLAG" ] || [ "$DONE" -ge "$BLOCKS" ]; then
break
fi
sleep 1
done
) &
PROGRESS_PID=$!
# 并发上传:xargs -P 同时跑 PARALLEL 个 worker(绕开百度 PCS 单连接限速)
XARGS_RC=0
seq 0 $((BLOCKS-1)) | xargs -P "$PARALLEL" -I % bash -c 'upload_part "$@"' _ % || XARGS_RC=$?
# 通知进度进程退出并等待,最后重画一行
touch "$DONE_FLAG"
wait "$PROGRESS_PID" 2>/dev/null || true
echo ""
if [ "$XARGS_RC" -ne 0 ]; then
echo ""
echo " ❌ 分片上传失败:"
if [ -s "$ERR_FILE" ]; then
head -n 5 "$ERR_FILE"
fi
rm -f "$ZIP_FILE" "$MD5_FILE" "$UNSORTED_MD5" "$PROGRESS_FILE" "$ERR_FILE" "$DONE_FLAG"
rm -f /tmp/baidu_chunk_${UPLOAD_ID}_*
exit 1
fi
# 校验分片数
DONE_COUNT=$(wc -l < "$UNSORTED_MD5" | tr -d ' ')
if [ "$DONE_COUNT" != "$BLOCKS" ]; then
echo ""
echo " ❌ 分片数不匹配: ${DONE_COUNT}/${BLOCKS}"
if [ -s "$ERR_FILE" ]; then
head -n 5 "$ERR_FILE"
fi
rm -f "$ZIP_FILE" "$MD5_FILE" "$UNSORTED_MD5" "$PROGRESS_FILE" "$ERR_FILE" "$DONE_FLAG"
rm -f /tmp/baidu_chunk_${UPLOAD_ID}_*
exit 1
fi
# 按 partseq 升序生成最终 MD5_FILE
sort -t'|' -k1n "$UNSORTED_MD5" | cut -d'|' -f2 > "$MD5_FILE"
rm -f "$UNSORTED_MD5" "$PROGRESS_FILE" "$ERR_FILE" "$DONE_FLAG"
echo ""
echo " ✅ 所有分片上传完成"
# ============================================================
# 步骤5: 合并文件
# ============================================================
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "[5/5] 🔗 合并文件..."
MD5_LIST=$(awk '{printf "\"%s\",", $0}' "$MD5_FILE" | sed 's/,$//')
CREATE_RESP=$(curl -s "https://pan.baidu.com/rest/2.0/xpan/file?method=create&access_token=${ACCESS_TOKEN}" \
-d "path=${REMOTE_PATH}&size=${FILE_SIZE}&isdir=0&uploadid=${UPLOAD_ID}&block_list=[${MD5_LIST}]")
CREATE_ERRNO=$(echo "$CREATE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('errno',99))" 2>/dev/null)
FINAL_PATH=$(echo "$CREATE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('path',''))" 2>/dev/null)
# 清理
rm -f "$ZIP_FILE" "$MD5_FILE"
rm -f /tmp/baidu_chunk_${UPLOAD_ID}_* 2>/dev/null || true
# 计算耗时
END_TIME=$(date +%s)
TOTAL=$((END_TIME - START_TIME))
T_MIN=$((TOTAL / 60))
T_SEC=$((TOTAL % 60))
# 输出结果
echo ""
echo "╔══════════════════════════════════════════════╗"
if [ "$CREATE_ERRNO" = "0" ]; then
echo "║ 📦 百度网盘备份完成! ║"
echo "╠══════════════════════════════════════════════╣"
echo " 📁 路径: $FINAL_PATH"
echo " 📊 大小: ${FILE_SIZE_MB} MB"
echo " ⏱️ 耗时: ${T_MIN}${T_SEC}"
echo " ✅ 状态: 上传成功"
else
echo "║ ❌ 合并失败 ║"
echo "╠══════════════════════════════════════════════╣"
echo " errno: $CREATE_ERRNO"
echo " 响应: $CREATE_RESP"
fi
echo "╚══════════════════════════════════════════════╝"
echo ""
Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+47
View File
@@ -0,0 +1,47 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactPlugin from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
export default [
{
ignores: ['dist/**', 'coverage/**', 'node_modules/**', 'web/dist/**'],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.ts'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
},
{
files: ['web/**/*.{ts,tsx}'],
plugins: {
react: reactPlugin,
'react-hooks': reactHooks,
},
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
settings: {
react: { version: 'detect' },
},
rules: {
...reactPlugin.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
// The automatic JSX runtime (jsx: "react-jsx") makes the React import
// unnecessary.
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
},
];
+48
View File
@@ -0,0 +1,48 @@
/* eslint-disable */
/**
* 初始 schema:评估记录 / 工作流状态 / 操作记录 / 盈利分析。
* 使用 IF NOT EXISTS 以与早期 initSchema 已建表的环境幂等兼容。
*/
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS assessments (
id TEXT PRIMARY KEY,
assessment JSONB NOT NULL,
report JSONB,
saved_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_assessments_saved_at ON assessments(saved_at DESC);
CREATE TABLE IF NOT EXISTS workflow_status (
assessment_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS audit_logs (
id BIGSERIAL PRIMARY KEY,
assessment_id TEXT NOT NULL,
role TEXT NOT NULL,
username TEXT NOT NULL,
action TEXT NOT NULL,
comment TEXT,
ts TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_assessment ON audit_logs(assessment_id);
CREATE TABLE IF NOT EXISTS profitability (
assessment_id TEXT PRIMARY KEY,
result JSONB NOT NULL
);
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS profitability;
DROP TABLE IF EXISTS audit_logs;
DROP TABLE IF EXISTS workflow_status;
DROP TABLE IF EXISTS assessments;
`);
};
@@ -0,0 +1,20 @@
/* eslint-disable */
/** 综合承接建议持久化表。 */
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS recommendations (
assessment_id TEXT PRIMARY KEY,
level TEXT NOT NULL,
title TEXT NOT NULL,
note TEXT NOT NULL,
target_margin DOUBLE PRECISION NOT NULL,
net_margin DOUBLE PRECISION,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`);
};
exports.down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS recommendations;`);
};
+12
View File
@@ -0,0 +1,12 @@
/* eslint-disable */
/** 评估归档标记:归档项不在主列表显示,可单独查看。 */
exports.up = (pgm) => {
pgm.sql(`ALTER TABLE assessments ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT false;`);
pgm.sql(`CREATE INDEX IF NOT EXISTS idx_assessments_archived ON assessments(archived);`);
};
exports.down = (pgm) => {
pgm.sql(`DROP INDEX IF EXISTS idx_assessments_archived;`);
pgm.sql(`ALTER TABLE assessments DROP COLUMN IF EXISTS archived;`);
};
+21
View File
@@ -0,0 +1,21 @@
/* eslint-disable */
/** 多报价方案对比:每个评估最多 5 套报价方案。 */
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS scenarios (
id TEXT NOT NULL,
assessment_id TEXT NOT NULL REFERENCES assessments(id) ON DELETE CASCADE,
label TEXT NOT NULL,
inputs JSONB NOT NULL,
result JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (assessment_id, id)
);
CREATE INDEX IF NOT EXISTS idx_scenarios_assessment ON scenarios(assessment_id);
`);
};
exports.down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS scenarios;`);
};
+24
View File
@@ -0,0 +1,24 @@
/* eslint-disable */
/** 费率表后台化:消除硬编码,支持按地域/分类管理费率,版本化发布。 */
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS rate_tables (
id BIGSERIAL PRIMARY KEY,
region TEXT NOT NULL,
category TEXT NOT NULL,
key TEXT NOT NULL,
value DOUBLE PRECISION NOT NULL,
effective_date DATE NOT NULL DEFAULT CURRENT_DATE,
version INT NOT NULL DEFAULT 1,
reviewed BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(region, category, key, version)
);
CREATE INDEX IF NOT EXISTS idx_rate_tables_region ON rate_tables(region, category);
`);
};
exports.down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS rate_tables;`);
};
+21
View File
@@ -0,0 +1,21 @@
/* eslint-disable */
/** 运营指标实际值回填(评估闭环)。 */
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS actuals (
id BIGSERIAL PRIMARY KEY,
assessment_id TEXT NOT NULL REFERENCES assessments(id) ON DELETE CASCADE,
month INT NOT NULL,
metric_name TEXT NOT NULL,
actual_value DOUBLE PRECISION NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(assessment_id, month, metric_name)
);
CREATE INDEX IF NOT EXISTS idx_actuals_assessment ON actuals(assessment_id);
`);
};
exports.down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS actuals;`);
};
@@ -0,0 +1,26 @@
/* eslint-disable */
/** 合规红线库(可配置):按地域/业务类型条件启用,版本化管理。 */
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS redline_rules (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
trigger_condition TEXT NOT NULL,
consequence TEXT NOT NULL,
region TEXT,
business_type TEXT,
enabled BOOLEAN NOT NULL DEFAULT true,
version INT NOT NULL DEFAULT 1,
regulation_ref TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_redline_rules_region ON redline_rules(region);
CREATE INDEX IF NOT EXISTS idx_redline_rules_biz ON redline_rules(business_type);
`);
};
exports.down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS redline_rules;`);
};
+23
View File
@@ -0,0 +1,23 @@
/* eslint-disable */
/** 客户信用与集中度风险:客户档案表。 */
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS customers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
credit_rating TEXT NOT NULL DEFAULT '未评级',
avg_overdue_days DOUBLE PRECISION NOT NULL DEFAULT 0,
total_contract_amount DOUBLE PRECISION NOT NULL DEFAULT 0,
assessment_count INT NOT NULL DEFAULT 0,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_customers_name ON customers(name);
`);
};
exports.down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS customers;`);
};
+13
View File
@@ -0,0 +1,13 @@
/* eslint-disable */
/** 评估有效期:默认 6 个月,到期可一键复评。 */
exports.up = (pgm) => {
pgm.sql(`ALTER TABLE assessments ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;`);
pgm.sql(`UPDATE assessments SET expires_at = saved_at + INTERVAL '6 months' WHERE expires_at IS NULL;`);
pgm.sql(`CREATE INDEX IF NOT EXISTS idx_assessments_expires ON assessments(expires_at);`);
};
exports.down = (pgm) => {
pgm.sql(`DROP INDEX IF EXISTS idx_assessments_expires;`);
pgm.sql(`ALTER TABLE assessments DROP COLUMN IF EXISTS expires_at;`);
};
+10
View File
@@ -0,0 +1,10 @@
/* eslint-disable */
/** 审批SLA:记录进入当前状态的时间,供超时计算。 */
exports.up = (pgm) => {
pgm.sql(`ALTER TABLE workflow_status ADD COLUMN IF NOT EXISTS entered_at TIMESTAMPTZ NOT NULL DEFAULT now();`);
};
exports.down = (pgm) => {
pgm.sql(`ALTER TABLE workflow_status DROP COLUMN IF EXISTS entered_at;`);
};
@@ -0,0 +1,20 @@
/* eslint-disable */
/** 驳回原因结构化:枚举表 + 审计关联。 */
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS reject_reasons (
id BIGSERIAL PRIMARY KEY,
assessment_id TEXT NOT NULL,
reason_type TEXT NOT NULL,
detail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_reject_reasons_assessment ON reject_reasons(assessment_id);
CREATE INDEX IF NOT EXISTS idx_reject_reasons_type ON reject_reasons(reason_type);
`);
};
exports.down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS reject_reasons;`);
};
+13
View File
@@ -0,0 +1,13 @@
/* eslint-disable */
/** 全文检索:用 PG tsvector 做相似历史项目检索。 */
exports.up = (pgm) => {
pgm.sql(`ALTER TABLE assessments ADD COLUMN IF NOT EXISTS tsv tsvector;`);
pgm.sql(`UPDATE assessments SET tsv = to_tsvector('simple', COALESCE(assessment->>'projectDescription','') || ' ' || COALESCE(assessment->>'businessType','') || ' ' || COALESCE(assessment->>'industry',''));`);
pgm.sql(`CREATE INDEX IF NOT EXISTS idx_assessments_tsv ON assessments USING gin(tsv);`);
};
exports.down = (pgm) => {
pgm.sql(`DROP INDEX IF EXISTS idx_assessments_tsv;`);
pgm.sql(`ALTER TABLE assessments DROP COLUMN IF EXISTS tsv;`);
};
+23
View File
@@ -0,0 +1,23 @@
/* eslint-disable */
/** 附件管理:挂到评估/风险项,支持上传与审批时查证。 */
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
assessment_id TEXT NOT NULL REFERENCES assessments(id) ON DELETE CASCADE,
risk_item_id TEXT,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size_bytes BIGINT NOT NULL DEFAULT 0,
storage_path TEXT NOT NULL,
uploaded_by TEXT,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_attachments_assessment ON attachments(assessment_id);
`);
};
exports.down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS attachments;`);
};
+26
View File
@@ -0,0 +1,26 @@
/* eslint-disable */
/** LLM 经验库:沉淀综合研判 + 人工修正后的最终结论,供后续 few-shot。 */
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS experience_library (
id BIGSERIAL PRIMARY KEY,
assessment_id TEXT NOT NULL,
business_type TEXT NOT NULL,
industry TEXT NOT NULL,
project_summary TEXT NOT NULL,
risk_grade TEXT,
acceptability TEXT,
recommendation_level TEXT,
lesson TEXT NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_experience_biz ON experience_library(business_type);
CREATE INDEX IF NOT EXISTS idx_experience_tags ON experience_library USING gin(tags);
`);
};
exports.down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS experience_library;`);
};
+13
View File
@@ -0,0 +1,13 @@
/* eslint-disable */
/** 向量搜索:用 pgvector 存储项目描述的 embedding,做语义相似检索。 */
exports.up = (pgm) => {
pgm.sql(`CREATE EXTENSION IF NOT EXISTS vector;`);
pgm.sql(`ALTER TABLE assessments ADD COLUMN IF NOT EXISTS embedding vector(1024);`);
pgm.sql(`CREATE INDEX IF NOT EXISTS idx_assessments_embedding ON assessments USING ivfflat (embedding vector_cosine_ops) WITH (lists = 10);`);
};
exports.down = (pgm) => {
pgm.sql(`DROP INDEX IF EXISTS idx_assessments_embedding;`);
pgm.sql(`ALTER TABLE assessments DROP COLUMN IF EXISTS embedding;`);
};
+18
View File
@@ -0,0 +1,18 @@
/* eslint-disable */
/** 地域费率套:与引擎 RegionRates 结构对齐,复核后驱动评估盈利测算。 */
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS region_rates (
region TEXT PRIMARY KEY,
rates JSONB NOT NULL,
reviewed BOOLEAN NOT NULL DEFAULT false,
updated_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`);
};
exports.down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS region_rates;`);
};
@@ -0,0 +1,37 @@
/* eslint-disable */
/**
* 红线可计算触发条件:将红线绑定到某项度量(月净利率/月毛利率/客户平均逾期天数/单客户集中度),
* 配合比较运算符与阈值,使红线可在评估时自动判定命中(而非一律"待核实")。
*
* - linked_metric: 关联度量键(netMargin / grossMargin / avgOverdueDays / concentration)
* 为空表示该红线仍需人工核实(合规/资质类红线)。
* - compare_op: 比较运算符(>=, <=, >, <)。
* - threshold: 阈值;百分比类度量(净利率/毛利率/集中度)按百分数填写(如 0 表示 0%、5 表示 5%)
* 逾期天数按天填写。
*/
exports.up = (pgm) => {
pgm.sql(`
ALTER TABLE redline_rules ADD COLUMN IF NOT EXISTS linked_metric TEXT;
ALTER TABLE redline_rules ADD COLUMN IF NOT EXISTS compare_op TEXT;
ALTER TABLE redline_rules ADD COLUMN IF NOT EXISTS threshold DOUBLE PRECISION;
`);
// 为内置数值型红线预置可计算条件(仅当字段为空时设置,避免覆盖人工配置)。
pgm.sql(`
UPDATE redline_rules SET linked_metric='netMargin', compare_op='<', threshold=0
WHERE id='negative-margin-3m' AND linked_metric IS NULL;
UPDATE redline_rules SET linked_metric='avgOverdueDays', compare_op='>', threshold=90
WHERE id='overdue-90days' AND linked_metric IS NULL;
UPDATE redline_rules SET linked_metric='concentration', compare_op='>', threshold=50
WHERE id='concentration-50pct' AND linked_metric IS NULL;
`);
};
exports.down = (pgm) => {
pgm.sql(`
ALTER TABLE redline_rules DROP COLUMN IF EXISTS linked_metric;
ALTER TABLE redline_rules DROP COLUMN IF EXISTS compare_op;
ALTER TABLE redline_rules DROP COLUMN IF EXISTS threshold;
`);
};
@@ -0,0 +1,13 @@
/* eslint-disable */
/**
* 保存盈利测算的原始输入(报价模式/合同周期/成本加成率/各项成本参数/岗位明细等),
* 以便编辑评估时完整带入原值(此前仅保存计算结果,导致加成率等输入项丢失)。
*/
exports.up = (pgm) => {
pgm.sql(`ALTER TABLE profitability ADD COLUMN IF NOT EXISTS inputs JSONB;`);
};
exports.down = (pgm) => {
pgm.sql(`ALTER TABLE profitability DROP COLUMN IF EXISTS inputs;`);
};
@@ -0,0 +1,21 @@
/* eslint-disable */
/**
* 红线复合条件:在主条件之外,支持一个可选的第二条件(与主条件 AND 组合)。
* 例如「月毛利率 < 0 且 月净利率 < 0」同时满足才命中。
*/
exports.up = (pgm) => {
pgm.sql(`
ALTER TABLE redline_rules ADD COLUMN IF NOT EXISTS linked_metric2 TEXT;
ALTER TABLE redline_rules ADD COLUMN IF NOT EXISTS compare_op2 TEXT;
ALTER TABLE redline_rules ADD COLUMN IF NOT EXISTS threshold2 DOUBLE PRECISION;
`);
};
exports.down = (pgm) => {
pgm.sql(`
ALTER TABLE redline_rules DROP COLUMN IF EXISTS linked_metric2;
ALTER TABLE redline_rules DROP COLUMN IF EXISTS compare_op2;
ALTER TABLE redline_rules DROP COLUMN IF EXISTS threshold2;
`);
};
+18
View File
@@ -0,0 +1,18 @@
/* eslint-disable */
/**
* 应用级设置(键值):用于持久化可调参数,如目标净利率基准(由预测准确度校准)。
*/
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value DOUBLE PRECISION NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`);
};
exports.down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS app_settings;`);
};
@@ -0,0 +1,24 @@
/* eslint-disable */
/**
* 客户回款记录:记录应收发票的到期日与实际回款日,用于自动计算客户平均逾期天数,
* 替代人工维护的 avg_overdue_days,并驱动"客户逾期超N天"红线。
*/
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS customer_payments (
id SERIAL PRIMARY KEY,
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
invoice_amount DOUBLE PRECISION NOT NULL DEFAULT 0,
due_date DATE NOT NULL,
paid_date DATE,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_customer_payments_cust ON customer_payments(customer_id);
`);
};
exports.down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS customer_payments;`);
};
+24
View File
@@ -0,0 +1,24 @@
/* eslint-disable */
/**
* 各地域月最低工资标准(可后台维护):驱动"低于最低工资"红线自动比对。
* 预置近似默认值,须经 HR/财务按当地官方标准复核更新。
*/
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS min_wages (
region TEXT PRIMARY KEY,
monthly_wage DOUBLE PRECISION NOT NULL,
updated_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO min_wages(region, monthly_wage) VALUES
('北京', 2420), ('上海', 2690), ('广东', 1900), ('深圳', 2360),
('江苏', 2490), ('浙江', 2490), ('四川', 2100), ('河北', 2200), ('中国大陆', 2000)
ON CONFLICT(region) DO NOTHING;
`);
};
exports.down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS min_wages;`);
};
@@ -0,0 +1,25 @@
/* eslint-disable */
/**
* 评估向导草稿(服务端持久化,跨设备):保存未运行/未提交的向导填写进度。
* - source_assessment_id 为空:全新建评估的草稿
* - source_assessment_id 非空:编辑某既有评估时的草稿(中断不丢失)
* form 存完整向导快照(步骤/立项/描述/业务类型/指标作答/岗位/成本参数等)。
*/
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS wizard_drafts (
id TEXT PRIMARY KEY,
assessor_id TEXT,
source_assessment_id TEXT,
project_name TEXT,
form JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_wizard_drafts_assessor ON wizard_drafts(assessor_id);
`);
};
exports.down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS wizard_drafts;`);
};
+7929
View File
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
{
"name": "outsourcing-risk-assessment",
"version": "0.1.0",
"private": true,
"description": "外包项目风险评估 AI 智能体 - 领域引擎与前端表现层",
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsc --build && tsc -p web/tsconfig.json --noEmit",
"dev": "vite",
"build:web": "tsc -p web/tsconfig.json --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit && tsc -p web/tsconfig.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"e2e": "node scripts/e2e-full.mjs",
"migrate": "node-pg-migrate -m migrations",
"migrate:up": "node-pg-migrate -m migrations up",
"migrate:down": "node-pg-migrate -m migrations down",
"lint": "eslint \"src/**/*.ts\" \"web/**/*.{ts,tsx}\"",
"lint:fix": "eslint \"src/**/*.ts\" \"web/**/*.{ts,tsx}\" --fix"
},
"dependencies": {
"@hono/node-server": "^2.0.4",
"@types/pg": "^8.20.0",
"hono": "^4.12.25",
"node-pg-migrate": "^8.0.4",
"nodejieba": "^3.5.8",
"pg": "^8.21.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"recharts": "^2.13.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^25.9.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"axe-core": "^4.10.2",
"eslint": "^9.17.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"fast-check": "^3.23.1",
"jsdom": "^25.0.1",
"playwright": "^1.58.2",
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.0",
"vitest": "^2.1.8",
"vitest-axe": "^0.1.0"
}
}
+155
View File
@@ -0,0 +1,155 @@
/**
* 端到端演示录制:登录 → 6 步评估向导 → 评估详情页。
*
* 产出:
* - docs/demo/NN-*.png 关键步骤截图(deviceScaleFactor=2,高清)
* - docs/demo/walkthrough.webm 全程操作录像(Playwright 原生录制)
*
* 运行:node scripts/demo-record.mjs
*/
import { chromium } from 'playwright';
import fs from 'node:fs';
import path from 'node:path';
const ROOT = '/Users/freedak/Documents/AIDashboard/RiskAgent';
const SHOTS = path.join(ROOT, 'docs/demo');
const VIDEO_DIR = path.join(SHOTS, 'video');
const BASE = 'http://localhost:5173';
const W = 1440;
const H = 900;
fs.mkdirSync(SHOTS, { recursive: true });
fs.mkdirSync(VIDEO_DIR, { recursive: true });
let n = 0;
async function shot(page, label) {
n += 1;
const name = `${String(n).padStart(2, '0')}-${label}.png`;
await page.screenshot({ path: path.join(SHOTS, name) });
console.log('📸', name);
}
async function expandAll(page) {
for (let i = 0; i < 15; i += 1) {
const collapsed = page.locator('button[aria-expanded="false"]');
const c = await collapsed.count();
if (c === 0) break;
try {
await collapsed.first().click({ timeout: 5000 });
} catch {
break;
}
await page.waitForTimeout(150);
}
}
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: W, height: H },
deviceScaleFactor: 2,
locale: 'zh-CN',
recordVideo: { dir: VIDEO_DIR, size: { width: W, height: H } },
});
const page = await context.newPage();
page.setDefaultTimeout(120000);
const video = page.video();
// ---------- 登录 ----------
await page.goto(BASE, { waitUntil: 'networkidle' });
await page.waitForTimeout(800);
const userBox = page.getByPlaceholder('请输入用户名');
if ((await userBox.count()) > 0) {
await shot(page, 'login');
await userBox.fill('销售账号');
await page.getByPlaceholder('请输入密码').fill('123456');
await page.getByRole('button', { name: '登录' }).click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1200);
}
// ---------- 首页:评估历史 ----------
await shot(page, 'dashboard');
// ---------- 进入新建评估 ----------
await page.getByRole('button', { name: /新建评估/ }).first().click();
await page.getByText('① 立项信息').waitFor();
await page.getByPlaceholder('如 某银行信用卡客服 BPO').fill('某全国性股份制银行信用卡中心客服外包项目');
await page.getByPlaceholder('客户公司全称').fill('某全国性股份制商业银行信用卡中心');
await page.locator('select').first().selectOption('上海');
await page.waitForTimeout(300);
await shot(page, 'step1-立项信息');
await page.getByRole('button', { name: '下一步', exact: true }).click();
// ---------- 步骤②:项目描述 ----------
await page.getByText('② 项目描述').waitFor();
const desc =
'为客户信用卡中心提供电话客服与在线客服外包服务,采用业务/服务外包模式,坐席约 80 人,' +
'7×12 小时三班排班;服务质量按接通率、平均处理时长与客户满意度考核;结算账期约 3 个月;' +
'需符合金融数据安全与个人信息保护合规要求,涉及呼叫中心系统对接与驻场管理。';
await page.locator('textarea').fill(desc);
await page.waitForTimeout(300);
await shot(page, 'step2-项目描述');
await page.getByRole('button', { name: /识别业务类型|识别中/ }).click();
// ---------- 步骤③:业务类型确认(LLM 分类) ----------
await page.getByText('③ 业务类型确认').waitFor({ timeout: 120000 });
await page.waitForTimeout(600);
await shot(page, 'step3-业务确认-LLM分类');
await page.getByRole('button', { name: /补全指标|加载指标中/ }).click();
// ---------- 步骤④:指标补全(LLM 预填) ----------
await page.getByText('④ 指标补全', { exact: false }).waitFor({ timeout: 120000 });
await page.waitForTimeout(800);
await shot(page, 'step4-指标补全-LLM预填');
await page.getByRole('button', { name: /费用输入/ }).click();
// ---------- 步骤⑤:报价与成本 ----------
await page.getByText('⑤ 报价与成本').waitFor();
await page.getByPlaceholder('如 运维工程师').fill('信用卡客服坐席');
await page.getByPlaceholder('10', { exact: true }).fill('80');
await page.getByPlaceholder('10000', { exact: true }).fill('6000');
await page.getByPlaceholder('默认=工资', { exact: true }).fill('6000');
await page.getByPlaceholder('0', { exact: true }).fill('500');
await page.getByPlaceholder('如 16000', { exact: true }).fill('11000');
await page.waitForTimeout(300);
await shot(page, 'step5-报价与成本');
await page.getByRole('button', { name: /运行风险与盈利评估|评估运行中/ }).click();
// ---------- 评估详情页 ----------
await page.waitForURL(/\/assessments\/[^/]+$/, { timeout: 180000 });
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
await shot(page, 'detail-顶部结论');
// 等待 AI 综合研判(异步 LLM),最多 ~25s
for (let i = 0; i < 25; i += 1) {
if ((await page.getByText(/综合研判|综合意见|AI 综合/).count()) > 0) break;
await page.waitForTimeout(1000);
}
await expandAll(page);
await page.waitForTimeout(800);
// 逐屏滚动截图,覆盖全部分区
await page.evaluate(() => window.scrollTo(0, 0));
const totalH = await page.evaluate(() => document.body.scrollHeight);
const stepY = H - 140;
for (let y = stepY; y < totalH; y += stepY) {
await page.evaluate((yy) => window.scrollTo(0, yy), y);
await page.waitForTimeout(450);
await shot(page, 'detail-分区');
if (n > 22) break;
}
await context.close();
await browser.close();
const vp = await video.path();
const dest = path.join(SHOTS, 'walkthrough.webm');
fs.renameSync(vp, dest);
console.log('🎬 video saved:', dest);
console.log('✅ done, screenshots:', n);
})().catch((e) => {
console.error('FAILED:', e);
process.exit(1);
});
+269
View File
@@ -0,0 +1,269 @@
/**
* 全功能 E2E 测试(API 层,针对运行中的后端 :3005)。
* 覆盖:分类/问题(差异化)/岗位抽取/盈利预览/创建(草稿)/数据完整度/坏账/目标净利率分层/
* 申报/风控审核/管理层审批/编辑原地重评/可计算红线(数值/指标/派遣/最低工资/AND)/
* 人工裁定/报告导出/校准/客户自动统计/费率红线CRUD/看板/归档。
* 结束时清理所有创建的数据。
*/
import { Client } from 'pg';
const BASE = 'http://localhost:3005';
const SEP = '\u0000';
let pass = 0, fail = 0;
const created = []; // assessment ids
const redlines = []; // redline rule ids
const tokens = { '商务/销售': '', '风控': '', '管理层': '' };
let authMode = false;
function ok(cond, msg) {
if (cond) { pass += 1; console.log(' ✓', msg); }
else { fail += 1; console.log(' ✗ FAIL:', msg); }
}
async function login(role) {
const r = await fetch(BASE + '/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'E2E' + role, role }) });
const d = await r.json();
return typeof d.token === 'string' ? d.token : '';
}
async function j(method, path, body, role) {
const headers = { 'Content-Type': 'application/json' };
const tk = role !== undefined ? tokens[role] : tokens['管理层'];
if (tk) headers.Authorization = 'Bearer ' + tk;
const init = { method, headers };
if (body !== undefined) init.body = JSON.stringify(body);
const r = await fetch(BASE + path, init);
const text = await r.text();
let data; try { data = JSON.parse(text); } catch { data = text; }
return { status: r.status, data, headers: r.headers };
}
async function runAssessment(payload) {
const r = await j('POST', '/api/assessments/run', payload);
if (r.data?.assessmentId) created.push(r.data.assessmentId);
return r;
}
async function addRedline(rule) {
const r = await j('POST', '/api/redline-rules', rule, '管理层');
redlines.push(rule.id);
return r;
}
async function main() {
console.log('\n=== 0. 登录获取角色令牌 ===');
for (const role of ['商务/销售', '风控', '管理层']) tokens[role] = await login(role);
authMode = tokens['管理层'] !== '';
console.log(' 鉴权模式:', authMode ? '已开启(JWT)' : '演示模式(无强制鉴权)');
console.log('\n=== 1. 基础健康 & LLM 状态 ===');
ok((await j('GET', '/api/health')).data.status === 'ok', 'health ok');
const llm = (await j('GET', '/api/llm/status')).data;
console.log(' LLM 启用:', llm.enabled);
console.log('\n=== 2. 业务分类 ===');
const cls = await j('POST', '/api/assessments/classify', { projectDescription: '为某银行提供信用卡客服外包,呼叫中心坐席50人,处理客户咨询与投诉。' });
ok(cls.status === 200 && typeof cls.data.businessType === 'string', '分类返回业务类型: ' + cls.data.businessType);
console.log('\n=== 3. 差异化指标(questions) ===');
const qDispatch = (await j('POST', '/api/assessments/questions', { businessType: '劳务派遣' })).data;
const qBPO = (await j('POST', '/api/assessments/questions', { businessType: 'BPO' })).data;
const dispIds = qDispatch.indicators.map((i) => i.indicatorId);
const bpoIds = qBPO.indicators.map((i) => i.indicatorId);
ok(dispIds.includes('dispatch-ratio'), '劳务派遣含派遣比例指标');
ok(bpoIds.includes('data-security') && bpoIds.includes('capacity-stability'), 'BPO含数据安全+产能指标');
ok(!dispIds.includes('capacity-stability'), '劳务派遣不含BPO专属指标(差异化)');
ok(qDispatch.indicators.every((i) => i.levels.length === 5), '每指标含1-5级锚点');
console.log('\n=== 4. 岗位抽取(LLM, 容错) ===');
const ep = await j('POST', '/api/assessments/extract-positions', { projectDescription: '服务器运维20人、网络安全5人、桌面运维10人' });
ok(ep.status === 200 && Array.isArray(ep.data.positions), '岗位抽取返回数组(' + ep.data.positions.length + '个)');
console.log('\n=== 5. 盈利预览(无状态) ===');
const prev = await j('POST', '/api/assessments/profitability', { businessType: '岗位外包', region: '北京', pricingModel: 'per_head', contractMonths: 12, positions: [{ name: '运维', headcount: 10, monthlyGrossSalary: 10000 }] });
ok(prev.status === 200 && prev.data.positions[0].perHead.fullyLoaded > 10000, '全口径成本 > 应发工资: ' + prev.data.positions[0].perHead.fullyLoaded);
console.log('\n=== 6. 客户(坏账驱动)选取 ===');
const custs = (await j('GET', '/api/customers')).data;
ok(Array.isArray(custs) && custs.length > 0, '客户档案非空(' + custs.length + ')');
const badCust = custs.find((c) => c.creditRating === 'B' || c.creditRating === 'BB') ?? custs[0];
console.log('\n=== 7. 创建评估(草稿) + 完整度/坏账/分层 ===');
const run = await runAssessment({
projectDescription: `【项目】E2E主流程|【客户】${badCust.name}\n岗位外包,运维10人,月薪10000。`,
confirmation: { businessType: '岗位外包' }, region: '北京', assessorId: 'E2E销售',
knownData: [['dim-customer' + SEP + 'customer-credit', 4], ['dim-financial' + SEP + 'gross-margin', 3]],
profitabilityInputs: { businessType: '岗位外包', region: '北京', pricingModel: 'per_head', contractMonths: 12, positions: [{ name: '运维', headcount: 10, monthlyGrossSalary: 10000, unitPrice: 16000 }] },
});
const id = run.data.assessmentId;
ok(run.status === 200 && typeof id === 'string', '创建成功 id=' + id);
let det = (await j('GET', '/api/assessments/' + id)).data;
ok(det.status === 'draft', '初始状态为草稿待申报');
ok(det.profitabilityInputs && det.profitabilityInputs.markupRate === undefined, '原始输入已保存');
ok(det.profitability.monthly.badDebtReserve > 0, '坏账准备金已计提(信用' + badCust.creditRating + '): ' + det.profitability.monthly.badDebtReserve);
ok(det.recommendation.targetMargin > 0, '目标净利率分层: ' + det.recommendation.targetMargin);
ok(typeof det.expiresAt === 'string', '有效期已写入');
console.log('\n=== 8. 申报 → 待风控审核 ===');
const sub = await j('POST', '/api/assessments/' + id + '/submit', { user: 'E2E销售' });
ok(sub.data.status === 'pending_risk_review', '申报后状态 pending_risk_review');
console.log('\n=== 9. 风控审核通过 → 风控已审核 ===');
const rev = await j('POST', '/api/assessments/' + id + '/review', { action: 'approve', user: 'E2E风控', comment: 'E2E通过' }, '风控');
ok(rev.data.status === 'risk_reviewed', '风控审核通过');
console.log('\n=== 10. 管理层审批通过 → 已通过 ===');
const apr = await j('POST', '/api/assessments/' + id + '/approve', { action: 'approve', user: 'E2E管理' }, '管理层');
ok(apr.data.status === 'approved', '管理层审批通过');
console.log('\n=== 11. 报告导出(JSON/HTML) ===');
const expJson = await fetch(`${BASE}/api/assessments/${id}/report/export?format=json`);
const expHtml = await fetch(`${BASE}/api/assessments/${id}/report/export?format=html`);
ok(expJson.status === 200, 'JSON 导出 200');
const htmlText = await expHtml.text();
ok(expHtml.status === 200 && htmlText.includes('<html'), 'HTML 导出为自包含文档');
console.log('\n=== 12. 管理层 override(终态调整) + 归档 ===');
const ov = await j('POST', '/api/assessments/' + id + '/override', { status: 'abandoned', user: 'E2E管理', comment: 'E2E调整' }, '管理层');
ok(ov.data.status === 'abandoned', 'override 调整为已放弃');
const arch = await j('POST', '/api/assessments/' + id + '/archive', { archived: true, user: 'E2E管理' }, '管理层');
ok(arch.data.archived === true, '归档成功');
console.log('\n=== 13. 编辑原地重评(不产生重复) ===');
const beforeTotal = (await j('GET', '/api/assessments?page=1&pageSize=1&archived=all')).data.total;
const edit = await runAssessment({
assessmentId: id, expectedSavedAt: det.savedAt,
projectDescription: `【项目】E2E主流程改|【客户】${badCust.name}\n岗位外包,运维12人。`,
confirmation: { businessType: '岗位外包' }, region: '北京', assessorId: 'E2E销售',
profitabilityInputs: { businessType: '岗位外包', region: '北京', pricingModel: 'per_head', contractMonths: 12, positions: [{ name: '运维', headcount: 12, monthlyGrossSalary: 10000, unitPrice: 16000 }] },
});
const afterTotal = (await j('GET', '/api/assessments?page=1&pageSize=1&archived=all')).data.total;
ok(edit.data.assessmentId === id, '编辑复用同 id');
ok(beforeTotal === afterTotal, '编辑未产生重复记录(' + beforeTotal + '→' + afterTotal + ')');
console.log('\n=== 14. 乐观锁冲突(过期 savedAt) ===');
const conflict = await j('POST', '/api/assessments/run', {
assessmentId: id, expectedSavedAt: '2000-01-01T00:00:00.000Z',
projectDescription: `【项目】冲突测试|【客户】${badCust.name}\nx`, confirmation: { businessType: '岗位外包' }, region: '北京',
profitabilityInputs: { businessType: '岗位外包', region: '北京', pricingModel: 'per_head', contractMonths: 12, positions: [{ name: '运维', headcount: 1, monthlyGrossSalary: 10000 }] },
});
ok(conflict.status === 409, '过期 savedAt 返回 409 冲突');
console.log('\n=== 15. 可计算红线(数值/指标/派遣/最低工资/AND) ===');
await addRedline({ id: 'e2e-margin', title: 'E2E连续亏损', triggerCondition: '净利率<0', consequence: '一票否决', enabled: true, version: 1, linkedMetric: 'netMargin', compareOp: '<', threshold: 0 });
await addRedline({ id: 'e2e-qual', title: 'E2E资质不合规', triggerCondition: '资质=5', consequence: '一票否决', enabled: true, version: 1, linkedMetric: 'ind:qualification', compareOp: '>=', threshold: 5 });
await addRedline({ id: 'e2e-dispatch', title: 'E2E派遣超限', triggerCondition: '派遣>10%', consequence: '一票否决', enabled: true, version: 1, linkedMetric: 'dispatchRatio', compareOp: '>', threshold: 10 });
await addRedline({ id: 'e2e-minwage', title: 'E2E低于最低工资', triggerCondition: '低于最低工资', consequence: '一票否决', enabled: true, version: 1, linkedMetric: 'belowMinWageCount', compareOp: '>=', threshold: 1 });
await addRedline({ id: 'e2e-and', title: 'E2E毛利净利双负', triggerCondition: '毛利<0且净利<0', consequence: '一票否决', enabled: true, version: 1, linkedMetric: 'grossMargin', compareOp: '<', threshold: 0, linkedMetric2: 'netMargin', compareOp2: '<', threshold2: 0 });
const rlRun = await runAssessment({
projectDescription: '【项目】E2E红线|【客户】红线测试客户E2E\n劳务派遣,派遣工30人,月薪2000。',
confirmation: { businessType: '劳务派遣' }, region: '北京', assessorId: 'E2E销售', clientTotalHeadcount: 100,
knownData: [['dim-legal' + SEP + 'qualification', 5]],
profitabilityInputs: { businessType: '劳务派遣', region: '北京', pricingModel: 'per_head', contractMonths: 12, positions: [{ name: '派遣工', headcount: 30, monthlyGrossSalary: 2000, unitPrice: 2200 }] },
});
const rlDet = (await j('GET', '/api/assessments/' + rlRun.data.assessmentId)).data;
const rr = (rid) => rlDet.assessment.redlineResults.find((x) => x.redlineId === rid)?.status;
ok(rr('e2e-margin') === '命中', '净利率红线命中');
ok(rr('e2e-qual') === '命中', '指标等级红线(资质=5)命中');
ok(rr('e2e-dispatch') === '命中', '派遣比例红线命中(30%)');
ok(rr('e2e-minwage') === '命中', '最低工资红线命中(2000<2420)');
ok(rr('e2e-and') === '命中', 'AND 复合红线命中(毛利&净利双负)');
ok(rlDet.assessment.acceptability === '不可接受', '命中红线→不可接受');
console.log('\n=== 16. 红线人工裁定(待核实→命中) ===');
const pend = rlDet.assessment.redlineResults.find((x) => x.status === '待核实');
if (pend) {
const vd = await j('POST', '/api/assessments/' + rlRun.data.assessmentId + '/redline-verdict', { redlineId: pend.redlineId, status: '命中', user: 'E2E风控', role: '风控', title: '人工核实项' }, '风控');
ok(vd.data.status === '命中', '人工裁定命中成功');
} else { ok(true, '无待核实红线(跳过裁定)'); }
console.log('\n=== 17. 客户自动统计(集中度基于真实数据) ===');
const custDet = (await j('GET', '/api/assessments/' + id)).data.customerDetail;
ok(custDet !== null, '评估关联到客户档案: ' + (custDet?.name ?? 'null'));
console.log('\n=== 18. 校准(准确度) ===');
// 回填实际值以产生偏差数据
await j('POST', '/api/assessments/' + rlRun.data.assessmentId + '/actuals', { month: 1, metrics: [{ name: '月净利率', value: 0.02 }] });
const calib = await j('GET', '/api/calibration');
ok(calib.status === 200 && typeof calib.data.currentBase === 'number', '校准查询返回当前/建议基准');
console.log(' 当前基准:', calib.data.currentBase, '建议:', calib.data.suggestedBase, '偏差:', calib.data.deviationPct, calib.data.bias);
console.log('\n=== 19. 看板/告警/统计 ===');
ok((await j('GET', '/api/dashboard/stats')).status === 200, '组合看板 200');
ok(Array.isArray((await j('GET', '/api/assessments/expiring')).data), '到期提醒返回数组');
ok(Array.isArray((await j('GET', '/api/assessments/overdue')).data), '超时提醒返回数组');
ok((await j('GET', '/api/assessments/summary')).status === 200, '状态汇总 200');
console.log('\n=== 20. 费率/红线/相似项目 ===');
ok((await j('GET', '/api/region-rates/engine-defaults')).status === 200, '引擎默认费率 200');
ok(Array.isArray((await j('GET', '/api/redline-rules')).data), '红线列表返回数组');
const sim = await j('GET', '/api/similar?description=' + encodeURIComponent('劳务派遣呼叫中心客服外包'));
ok(Array.isArray(sim.data), '相似项目检索返回数组(' + sim.data.length + ')');
console.log('\n=== 21. 回款数据→逾期自动化 ===');
const tmpCustId = 'e2e-pay-cust';
await j('POST', '/api/customers', { id: tmpCustId, name: 'E2E回款客户', creditRating: 'A' }, '管理层');
const payRes = await j('POST', `/api/customers/${tmpCustId}/payments`, { invoiceAmount: 100000, dueDate: '2024-01-01' }, '管理层');
ok(payRes.data.avgOverdueDays > 0, '录入逾期回款后自动重算平均逾期天数: ' + payRes.data.avgOverdueDays + ' 天');
const pays = (await j('GET', `/api/customers/${tmpCustId}/payments`, undefined, '管理层')).data;
ok(Array.isArray(pays) && pays.length === 1, '回款记录可读取');
await j('DELETE', `/api/customers/${tmpCustId}`, undefined, '管理层'); // 级联删除回款
console.log('\n=== 22. 最低工资表后台化 ===');
const mws = (await j('GET', '/api/min-wages')).data;
ok(Array.isArray(mws) && mws.some((m) => m.region === '北京' && m.monthlyWage > 0), '最低工资表含北京标准(' + (mws.find((m) => m.region === '北京')?.monthlyWage) + ')');
console.log('\n=== 23. RBAC 强制鉴权(仅鉴权模式断言) ===');
if (authMode) {
const noTok = await fetch(BASE + '/api/redline-rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'e2e-noauth', title: 'x', triggerCondition: 'x', consequence: 'x', enabled: true, version: 1 }) });
ok(noTok.status === 403, '无令牌写红线 → 403');
const wrongRole = await j('POST', '/api/redline-rules', { id: 'e2e-wrongrole', title: 'x', triggerCondition: 'x', consequence: 'x', enabled: true, version: 1 }, '商务/销售');
ok(wrongRole.status === 403, '销售令牌写红线 → 403(角色不足)');
const rightRole = await addRedline({ id: 'e2e-rbac-ok', title: 'RBAC通过', triggerCondition: 'x', consequence: 'x', enabled: true, version: 1 });
ok(rightRole.status === 200, '管理层令牌写红线 → 200');
} else {
ok(true, '演示模式:跳过 RBAC 强制断言(设置 AUTH_SECRET 后启用)');
}
console.log('\n=== 24. 向导草稿服务端持久化(跨设备) ===');
const draftId = `e2e-draft-${Date.now()}`;
const upserted = (await j('POST', '/api/drafts', { id: draftId, assessorId: 'e2e-sales', projectName: 'E2E草稿项目', form: { step: 3, description: '草稿描述', answers: { a: 2 } } })).data;
ok(upserted.id === draftId && upserted.projectName === 'E2E草稿项目', '草稿 upsert 成功');
const draftList = (await j('GET', '/api/drafts?assessorId=e2e-sales')).data;
ok(Array.isArray(draftList) && draftList.some((d) => d.id === draftId), '草稿出现在列表(按评估人过滤)');
const got = (await j('GET', `/api/drafts/${draftId}`)).data;
ok(got.form && got.form.step === 3 && got.form.answers.a === 2, '草稿详情含完整向导快照 form');
// 编辑草稿:source_assessment_id 非空
const editDraftId = `e2e-draft-edit-${Date.now()}`;
const editDraft = (await j('POST', '/api/drafts', { id: editDraftId, assessorId: 'e2e-sales', sourceAssessmentId: 'some-assessment', projectName: '编辑草稿', form: { step: 4 } })).data;
ok(editDraft.sourceAssessmentId === 'some-assessment', '编辑草稿记录源评估 id');
const del = (await j('DELETE', `/api/drafts/${draftId}`)).data;
ok(del.deleted === true, '草稿删除成功');
await j('DELETE', `/api/drafts/${editDraftId}`);
const afterList = (await j('GET', '/api/drafts?assessorId=e2e-sales')).data;
ok(Array.isArray(afterList) && !afterList.some((d) => d.id === draftId), '删除后草稿不再出现在列表');
console.log(`\n=== 结果:${pass} 通过 / ${fail} 失败 ===`);
}
async function cleanup() {
console.log('\n=== 清理测试数据 ===');
const c = new Client({ connectionString: 'postgresql://localhost:5432/riskagent' });
await c.connect();
for (const id of created) {
for (const t of ['workflow_status', 'audit_logs', 'profitability', 'recommendations', 'actuals']) {
await c.query(`DELETE FROM ${t} WHERE assessment_id=$1`, [id]).catch(() => {});
}
await c.query('DELETE FROM assessments WHERE id=$1', [id]).catch(() => {});
}
for (const rid of redlines) {
await c.query('DELETE FROM redline_rules WHERE id=$1', [rid]).catch(() => {});
}
await c.end();
console.log(' 清理评估', created.length, '条, 红线', redlines.length, '条');
}
main()
.catch((e) => { console.error('E2E 异常:', e); fail += 1; })
.finally(async () => {
await cleanup().catch((e) => console.error('清理异常:', e));
console.log(fail === 0 ? '\n✅ 全部 E2E 通过' : `\n❌ 有 ${fail} 项失败`);
process.exit(fail === 0 ? 0 : 1);
});
+102
View File
@@ -0,0 +1,102 @@
// 一次性脚本:通过 HTTP API 向运行中的后端批量灌入 3 个评估示例。
const BASE = process.env.API_BASE ?? 'http://localhost:3005';
const SEP = '\u0000'; // indicatorKey 复合键分隔符(NUL
const kd = (rows) => rows.map(([d, i, l]) => [`${d}${SEP}${i}`, l]);
const cases = [
{
name: '案例1 银行IT运维岗位外包(预期可接受)',
body: {
projectDescription:
'为某大型国有银行提供IT运维岗位外包服务。客户资信优良、注册资本充足、无失信记录;账期30天且历史回款准时;岗位为常规运维支持非高危;SLA交付标准清晰可量化;毛利率充足、人力成本可控。',
confirmation: { businessType: '岗位外包', industry: '通用' },
region: 'CN-SH',
assessorId: 'sales-001',
knownData: kd([
['dim-customer', 'customer-credit', 1],
['dim-customer', 'payment-ability', 1],
['dim-position', 'position-nature', 2],
['dim-business', 'delivery-standard', 1],
['dim-labor', 'layoff-risk', 2],
['dim-labor', 'injury-risk', 1],
['dim-financial', 'gross-margin', 2],
['dim-legal', 'qualification', 1],
['dim-lifecycle', 'lifecycle-stage', 2],
['dim-macro', 'macro-policy', 2],
]),
costInputs: { baselineQuote: 2000000 },
topN: 5,
},
},
{
name: '案例2 互联网客服BPO(预期有条件接受)',
body: {
projectDescription:
'为某成长期互联网公司提供客服BPO外包。客户为创业公司资信一般、账期90天且偶有逾期;岗位含部分替代性用工;SLA标准较模糊;毛利率偏薄;存在一定裁员与合规风险。',
confirmation: { businessType: 'BPO', industry: '通用' },
region: 'CN-GD',
assessorId: 'sales-002',
knownData: kd([
['dim-customer', 'customer-credit', 4],
['dim-customer', 'payment-ability', 4],
['dim-position', 'position-nature', 4],
['dim-business', 'delivery-standard', 4],
['dim-labor', 'layoff-risk', 4],
['dim-labor', 'injury-risk', 3],
['dim-financial', 'gross-margin', 4],
['dim-legal', 'qualification', 3],
['dim-lifecycle', 'lifecycle-stage', 3],
['dim-macro', 'macro-policy', 3],
]),
costInputs: { baselineQuote: 1200000 },
topN: 5,
},
},
{
name: '案例3 衰退期制造高危劳务派遣(预期不可接受)',
body: {
projectDescription:
'为某衰退期制造企业提供高危产线劳务派遣。客户资信差、已有涉诉与逾期记录;账期超120天回款困难;岗位为高危作业工伤风险高;测算毛利率为负;资质不全存在假外包真派遣风险。',
confirmation: { businessType: '劳务派遣', industry: '通用' },
region: 'CN-HB',
assessorId: 'sales-003',
knownData: kd([
['dim-customer', 'customer-credit', 5],
['dim-customer', 'payment-ability', 5],
['dim-position', 'position-nature', 5],
['dim-business', 'delivery-standard', 5],
['dim-labor', 'layoff-risk', 5],
['dim-labor', 'injury-risk', 5],
['dim-financial', 'gross-margin', 5],
['dim-legal', 'qualification', 5],
['dim-lifecycle', 'lifecycle-stage', 5],
['dim-macro', 'macro-policy', 4],
]),
costInputs: { baselineQuote: 800000 },
topN: 5,
},
},
];
for (const c of cases) {
const res = await fetch(`${BASE}/api/assessments/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(c.body),
});
const j = await res.json();
if (!res.ok) {
console.error(`${c.name} 失败 [${res.status}]:`, j.error ?? j);
continue;
}
console.log(
`${c.name}\n id=${j.assessmentId} | score=${j.riskScore} grade=${j.riskGrade} | 结论=${j.acceptability} | 报价 ${j.assessment.costEstimate.baselineQuote}->${j.assessment.costEstimate.riskAdjustedQuote}`,
);
}
// 校验列表接口
const list = await (await fetch(`${BASE}/api/assessments`)).json();
console.log(`\n后端现有评估记录数:${list.length}`);
for (const r of list) {
console.log(` - ${r.id} | ${r.businessType} | ${r.riskGrade} | ${r.acceptability} | 状态=${r.status}`);
}
+26
View File
@@ -0,0 +1,26 @@
/**
* E2E 基础数据播种:插入少量客户档案(含各信用等级),供 e2e-full.mjs 使用。
* 幂等(ON CONFLICT DO NOTHING)。CI 与本地均可运行。
*/
import { Client } from 'pg';
const URL = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/riskagent';
const customers = [
{ id: 'seed-cust-aaa', name: '某国有大行', creditRating: 'AAA', avgOverdueDays: 3, totalContractAmount: 8000000, assessmentCount: 0, notes: null },
{ id: 'seed-cust-a', name: '某制造企业', creditRating: 'A', avgOverdueDays: 15, totalContractAmount: 2000000, assessmentCount: 0, notes: null },
{ id: 'seed-cust-bbb', name: '某省电信公司', creditRating: 'BBB', avgOverdueDays: 45, totalContractAmount: 3000000, assessmentCount: 0, notes: null },
{ id: 'seed-cust-b', name: '某互联网初创企业', creditRating: 'B', avgOverdueDays: 70, totalContractAmount: 500000, assessmentCount: 0, notes: null },
];
const c = new Client({ connectionString: URL });
await c.connect();
for (const x of customers) {
await c.query(
`INSERT INTO customers(id, name, credit_rating, avg_overdue_days, total_contract_amount, assessment_count, notes, updated_at)
VALUES($1,$2,$3,$4,$5,$6,$7,now()) ON CONFLICT(id) DO NOTHING`,
[x.id, x.name, x.creditRating, x.avgOverdueDays, x.totalContractAmount, x.assessmentCount, x.notes],
);
}
await c.end();
console.log('已播种', customers.length, '个客户档案');
+14
View File
@@ -0,0 +1,14 @@
const desc = '为省级政府数据中心提供全栈IT运维外包服务,涵盖服务器运维20人、网络安全5人、桌面运维10人,共35人团队。5x8+7x24值班,按ITIL流程管理,月度SLA 99.9%,合同3年,账期3个月,需安全审查资质。';
const pattern1 = /([^\d,,。、;\n]{2,8}?)\s*(\d{1,4})\s*人/g;
let m;
const results = [];
while ((m = pattern1.exec(desc)) !== null) {
const name = m[1].trim();
const count = m[2];
if (!/共|总|约|含/.test(name)) {
results.push({ name, count });
}
}
console.log('正则解析:', results.length, '个岗位');
results.forEach(r => console.log(' ', r.name, r.count + '人'));
+135
View File
@@ -0,0 +1,135 @@
/**
* 优化项单元测试:覆盖本轮新增的引擎逻辑分支
* - 坏账准备金(客户信用驱动)降低净利
* - 可计算红线(数值度量 / 指标等级 / AND 复合条件)
* - 目标净利率分层
* - 差异化业务类型模板(指标差异 + 维度/指标权重合规)
*/
import { describe, expect, it } from 'vitest';
import { analyzeProfitability, type ProfitabilityInputs } from '../cost/profitability.js';
import { buildMetricRedline, compareMetric, metricLabel, type ComputableRedlineConfig } from '../scoring/redlineMetrics.js';
import { checkRedlines } from '../scoring/checkRedlines.js';
import { targetMarginForGrade, DEFAULT_TARGET_NET_MARGIN } from '../strategy/recommendation.js';
import { DEFAULT_TEMPLATES } from '../server/templates/defaultTemplates.js';
import type { Redline } from '../domain/model.js';
const baseInputs: ProfitabilityInputs = {
businessType: '岗位外包',
region: '北京',
pricingModel: 'per_head',
contractMonths: 12,
positions: [{ name: '运维', headcount: 10, monthlyGrossSalary: 10000, unitPrice: 16000 }],
};
describe('坏账准备金(客户信用驱动)', () => {
it('badDebtRate 越高净利越低,且 0 时无坏账行', () => {
const r0 = analyzeProfitability({ ...baseInputs, badDebtRate: 0 });
const r6 = analyzeProfitability({ ...baseInputs, badDebtRate: 0.06 });
expect(r0.monthly.badDebtReserve).toBe(0);
expect(r6.monthly.badDebtReserve).toBeGreaterThan(0);
expect(r6.monthly.netProfit).toBeLessThan(r0.monthly.netProfit);
// 坏账准备金 = 不含税收入 × 比例
expect(r6.monthly.badDebtReserve).toBeCloseTo(r6.monthly.revenueNet * 0.06, 0);
});
});
describe('可计算红线', () => {
const redlines: Redline[] = [
{ id: 'rl-margin', triggerCondition: '净利率<0', consequence: '一票否决', enabled: true },
{ id: 'rl-qual', triggerCondition: '资质=5', consequence: '一票否决', enabled: true },
{ id: 'rl-compound', triggerCondition: '毛利为负且净利为负', consequence: '一票否决', enabled: true },
];
it('数值度量:净利率 < 0 命中', () => {
const cfg = new Map<string, ComputableRedlineConfig>([
['rl-margin', { linkedMetric: 'netMargin', compareOp: '<', threshold: 0 }],
]);
const { resolveCondition, dataContext } = buildMetricRedline(cfg, { netMargin: -5 });
const res = checkRedlines([redlines[0]!], resolveCondition, dataContext);
expect(res[0]!.status).toBe('命中');
});
it('指标等级:资质 >= 5 命中、=3 未命中', () => {
const cfg = new Map<string, ComputableRedlineConfig>([
['rl-qual', { linkedMetric: 'ind:qualification', compareOp: '>=', threshold: 5 }],
]);
const hit = buildMetricRedline(cfg, { 'ind:qualification': 5 });
expect(checkRedlines([redlines[1]!], hit.resolveCondition, hit.dataContext)[0]!.status).toBe('命中');
const miss = buildMetricRedline(cfg, { 'ind:qualification': 3 });
expect(checkRedlines([redlines[1]!], miss.resolveCondition, miss.dataContext)[0]!.status).toBe('未命中');
});
it('AND 复合:两条件都满足才命中', () => {
const cfg = new Map<string, ComputableRedlineConfig>([
['rl-compound', { linkedMetric: 'grossMargin', compareOp: '<', threshold: 0, and: { linkedMetric: 'netMargin', compareOp: '<', threshold: 0 } }],
]);
const both = buildMetricRedline(cfg, { grossMargin: -2, netMargin: -5 });
expect(checkRedlines([redlines[2]!], both.resolveCondition, both.dataContext)[0]!.status).toBe('命中');
const onlyOne = buildMetricRedline(cfg, { grossMargin: 5, netMargin: -5 });
expect(checkRedlines([redlines[2]!], onlyOne.resolveCondition, onlyOne.dataContext)[0]!.status).toBe('未命中');
});
it('度量数据缺失 → 待核实', () => {
const cfg = new Map<string, ComputableRedlineConfig>([
['rl-margin', { linkedMetric: 'netMargin', compareOp: '<', threshold: 0 }],
]);
const { resolveCondition, dataContext } = buildMetricRedline(cfg, {});
expect(checkRedlines([redlines[0]!], resolveCondition, dataContext)[0]!.status).toBe('待核实');
});
it('compareMetric / metricLabel 基本正确', () => {
expect(compareMetric(5, '>=', 5)).toBe(true);
expect(compareMetric(4, '>=', 5)).toBe(false);
expect(metricLabel('netMargin')).toContain('净利率');
expect(metricLabel('ind:qualification')).toContain('qualification');
});
});
describe('目标净利率分层', () => {
it('风险越高目标净利率越高', () => {
const low = targetMarginForGrade('低');
const mid = targetMarginForGrade('中');
const high = targetMarginForGrade('高');
const extreme = targetMarginForGrade('极高');
expect(low).toBeLessThan(mid);
expect(mid).toBeLessThan(high);
expect(high).toBeLessThan(extreme);
expect(mid).toBeCloseTo(DEFAULT_TARGET_NET_MARGIN, 6);
expect(targetMarginForGrade(undefined)).toBeCloseTo(DEFAULT_TARGET_NET_MARGIN, 6);
});
});
describe('差异化业务类型模板', () => {
it('各业务类型指标集差异化', () => {
const byType = new Map(DEFAULT_TEMPLATES.map((t) => [t.businessType, t.riskModelConfig.dimensions.flatMap((d) => d.indicators.map((i) => i.id))]));
expect(byType.get('劳务派遣')).toContain('dispatch-ratio');
expect(byType.get('BPO')).toContain('data-security');
expect(byType.get('BPO')).toContain('capacity-stability');
expect(byType.get('项目制外包')).toContain('scope-clarity');
// 劳务派遣不含 BPO 专属的产能指标
expect(byType.get('劳务派遣')).not.toContain('capacity-stability');
});
it('每个维度的启用指标权重之和为 100、维度权重之和为 100', () => {
for (const t of DEFAULT_TEMPLATES) {
const dims = t.riskModelConfig.dimensions;
const dimSum = dims.reduce((s, d) => s + d.weight, 0);
expect(dimSum).toBe(100);
for (const d of dims) {
const indSum = d.indicators.filter((i) => i.enabled).reduce((s, i) => s + i.weight, 0);
expect(indSum).toBe(100);
}
}
});
it('每个指标的评分规则覆盖 1-5 级', () => {
for (const t of DEFAULT_TEMPLATES) {
for (const d of t.riskModelConfig.dimensions) {
for (const i of d.indicators) {
expect(i.scoringRules.map((r) => r.level).sort()).toEqual([1, 2, 3, 4, 5]);
}
}
}
});
});
+13
View File
@@ -0,0 +1,13 @@
import fc from 'fast-check';
/**
* Global fast-check configuration.
*
* Property-based tests for this feature MUST run at least 100 iterations per
* property (see tasks.md "属性测试约束"). fast-check's default `numRuns` is 100;
* we set it explicitly here so the guarantee is enforced for every property test
* and survives any future change to library defaults.
*/
fc.configureGlobal({
numRuns: 100,
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
import { VERSION } from '../index.js';
describe('project skeleton smoke test', () => {
it('exposes the package version', () => {
expect(VERSION).toBe('0.1.0');
});
it('runs fast-check property tests with the configured iteration count', () => {
// Verify the global fast-check config applies at least 100 iterations.
expect(fc.readConfigureGlobal()?.numRuns).toBe(100);
// A trivial always-true property to confirm fast-check is wired up.
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
return a + b === b + a;
}),
);
});
});
@@ -0,0 +1,311 @@
/**
* External_Data_Adapter 外部数据适配集成测试(Req 15.1, 15.5)。
*
* 覆盖两条端到端路径:
*
* 1. 成功取数路径(Req 15.1, 15.2):以 mock {@link DataSourceAdapter} 在超时上限内
* 成功返回数据点,经 {@link fetchExternalData} / {@link fetchWithFallback} 验证每个
* 数据点 Data_Provenance 恒为"外部数据"、Confidence 落在 [0,1],且能回填客户风险
* 相关 Indicator 取值。
*
* 2. 可插拔扩展路径(Req 15.5):以一个最小适配器注册表(Map<sourceId, adapter>)注册
* 一个全新的外部数据源适配器,再将其取数结果驱动 Scoring_Engine 的 `scoreDimension`
* / `scoreIndicator` 评分。整个过程仅 import 现有 scoringEngine.ts 而**不修改其源代码**
* 以此证明新增数据源无需改动 Scoring_EngineReq 15.5)。
*
* Feature: outsourcing-risk-assessment
* Validates: Requirements 15.1, 15.5
*/
import { describe, expect, it } from 'vitest';
import {
EXTERNAL_DATA_PROVENANCE,
annotateExternalDataPoints,
fetchExternalData,
fetchWithFallback,
type DataPoint,
type DataSourceAdapter,
type DataSourceQuery,
type RawExternalDataPoint,
} from '../index.js';
import {
scoreDimension,
scoreIndicator,
type RiskLevelResolver,
} from '../../scoring/index.js';
import type { Dimension, Indicator } from '../../domain/model.js';
import type { RiskLevel } from '../../domain/common.js';
// ----------------------------------------------------------------------------
// 测试替身:可配置的 mock 数据源适配器。
// ----------------------------------------------------------------------------
/**
* 构造一个 mock 适配器:在调用时按给定原始数据点产出已标注的数据点(成功路径)。
* 标注统一经 {@link annotateExternalDataPoints},保证 Req 15.2 的来源/置信不变式。
*/
function makeMockAdapter(
sourceId: string,
raws: readonly RawExternalDataPoint[],
sourceName?: string,
): DataSourceAdapter {
const base = {
sourceId,
fetch: (_query: DataSourceQuery): Promise<DataPoint[]> =>
Promise.resolve(annotateExternalDataPoints(raws)),
};
return sourceName === undefined ? base : { ...base, sourceName };
}
/** 最小适配器注册表:以 sourceId 为键插拔注册外部数据源(Req 15.5)。 */
class AdapterRegistry {
private readonly adapters = new Map<string, DataSourceAdapter>();
register(adapter: DataSourceAdapter): void {
this.adapters.set(adapter.sourceId, adapter);
}
get(sourceId: string): DataSourceAdapter {
const adapter = this.adapters.get(sourceId);
if (adapter === undefined) {
throw new Error(`未注册的数据源: ${sourceId}`);
}
return adapter;
}
get size(): number {
return this.adapters.size;
}
}
// ----------------------------------------------------------------------------
// 测试夹具:客户风险维度与指标,以及由外部数据点构造的风险等级解析器。
// ----------------------------------------------------------------------------
/** 构造一个启用的客户风险 Indicator(评分规则细节与本集成测试无关,留空)。 */
function makeIndicator(id: string, name: string, weight: number): Indicator {
return {
id,
name,
weight,
enabled: true,
scoringRules: [],
evidenceRequired: '',
askPrompt: '',
};
}
/** 将外部数值取值夹取为 1-5 的 Risk_Level。 */
function toRiskLevel(value: number): RiskLevel {
const clamped = Math.min(5, Math.max(1, Math.round(value)));
return clamped as RiskLevel;
}
/**
* 由外部数据点构造 Scoring_Engine 所需的 RiskLevelResolver。
*
* 这是适配层与评分引擎之间的"接缝":外部数据经此函数转换为引擎可消费的
* 风险等级解析器,引擎本身无需感知数据来源(Req 15.5)。
*/
function buildResolver(points: readonly DataPoint[]): RiskLevelResolver {
const levelByIndicator = new Map<string, RiskLevel>();
for (const point of points) {
if (point.indicatorId !== undefined && typeof point.value === 'number') {
levelByIndicator.set(point.indicatorId, toRiskLevel(point.value));
}
}
return (indicator: Indicator): RiskLevel =>
levelByIndicator.get(indicator.id) ?? 1;
}
// ----------------------------------------------------------------------------
// 路径一:mock 数据源验证成功取数路径(Req 15.1, 15.2)。
// ----------------------------------------------------------------------------
describe('外部数据适配集成 - 成功取数路径 (Req 15.1)', () => {
const creditRaws: readonly RawExternalDataPoint[] = [
{
field: '信用评级',
value: 3,
category: '企业资信',
confidence: 0.9,
indicatorId: 'cust.credit',
},
{
field: '征信查询次数',
value: 2,
category: '征信',
confidence: 1.4, // 越界值,应被规整至 1
indicatorId: 'cust.creditInquiry',
},
];
it('mock 适配器在超时内成功取数, 每个数据点标注为"外部数据"且 Confidence ∈ [0,1]', async () => {
const adapter = makeMockAdapter('mock-credit', creditRaws, '资信 Mock');
const query: DataSourceQuery = {
subjectName: '示例外包供应商',
categories: ['企业资信', '征信'],
indicatorIds: ['cust.credit', 'cust.creditInquiry'],
};
const outcome = await fetchExternalData(adapter, query);
expect(outcome.status).toBe('success');
if (outcome.status !== 'success') {
throw new Error('期望成功取数');
}
expect(outcome.dataPoints).toHaveLength(2);
for (const point of outcome.dataPoints) {
expect(point.provenance).toBe(EXTERNAL_DATA_PROVENANCE);
expect(point.confidence).toBeGreaterThanOrEqual(0);
expect(point.confidence).toBeLessThanOrEqual(1);
}
});
it('成功取数经 fetchWithFallback 回填指标取值且不触发降级', async () => {
const adapter = makeMockAdapter('mock-credit', creditRaws);
const query: DataSourceQuery = {
subjectName: '示例外包供应商',
indicatorIds: ['cust.credit', 'cust.creditInquiry'],
};
const result = await fetchWithFallback({ adapter, query });
expect(result.outcome.status).toBe('success');
expect(result.usedFallback).toBe(false);
expect(result.dataPoints).toHaveLength(2);
for (const point of result.dataPoints) {
expect(point.provenance).toBe(EXTERNAL_DATA_PROVENANCE);
}
// 数据点确实按请求的 Indicator 标识回填。
const ids = result.dataPoints.map((point) => point.indicatorId);
expect(ids).toContain('cust.credit');
expect(ids).toContain('cust.creditInquiry');
});
});
// ----------------------------------------------------------------------------
// 路径二:注册新适配器, 无需改 Scoring_Engine 源码即可使用(Req 15.5)。
// ----------------------------------------------------------------------------
describe('外部数据适配集成 - 可插拔扩展无需改 Scoring_Engine (Req 15.5)', () => {
/** 客户风险维度:两个启用指标。 */
function makeCustomerDimension(): Dimension {
return {
id: 'dim.customer',
name: '客户风险',
weight: 100,
enabled: true,
indicators: [
makeIndicator('cust.credit', '信用评级', 6),
makeIndicator('cust.litigation', '涉诉风险', 4),
],
};
}
it('用已注册适配器取数, 其结果可直接驱动 scoreDimension 评分', async () => {
const registry = new AdapterRegistry();
registry.register(
makeMockAdapter('mock-credit', [
{
field: '信用评级',
value: 3,
category: '企业资信',
confidence: 0.8,
indicatorId: 'cust.credit',
},
{
field: '涉诉风险',
value: 2,
category: '涉诉',
confidence: 0.7,
indicatorId: 'cust.litigation',
},
]),
);
const dimension = makeCustomerDimension();
const query: DataSourceQuery = {
subjectName: '供应商A',
indicatorIds: ['cust.credit', 'cust.litigation'],
};
const outcome = await fetchExternalData(registry.get('mock-credit'), query);
expect(outcome.status).toBe('success');
if (outcome.status !== 'success') {
throw new Error('期望成功取数');
}
// 评分引擎源码未改动: 仅用其导出的纯函数 + 适配层产出的 resolver。
const resolver = buildResolver(outcome.dataPoints);
const score = scoreDimension(dimension, resolver);
// cust.credit: level 3 × weight 6 = 18; cust.litigation: level 2 × weight 4 = 8 → 26。
expect(score).toBe(26);
});
it('注册一个全新数据源适配器后, 同一评分流程无需改动即可使用 (Req 15.5)', async () => {
const registry = new AdapterRegistry();
// 先注册一个既有适配器。
registry.register(makeMockAdapter('mock-credit', []));
const sizeBefore = registry.size;
// 注册一个此前不存在的"工商数据"适配器 —— 仅实现接口并注册, 无任何引擎改动。
const businessRegistryAdapter = makeMockAdapter(
'mock-business-registry',
[
{
field: '注册资本异常',
value: 4,
category: '工商',
confidence: 0.95,
indicatorId: 'cust.credit',
},
{
field: '失信记录',
value: 5,
category: '失信',
confidence: 0.99,
indicatorId: 'cust.litigation',
},
],
'工商数据 Mock',
);
registry.register(businessRegistryAdapter);
expect(registry.size).toBe(sizeBefore + 1);
const dimension = makeCustomerDimension();
const query: DataSourceQuery = {
subjectName: '供应商B',
indicatorIds: ['cust.credit', 'cust.litigation'],
};
// 取出新注册的适配器, 复用与既有适配器完全相同的取数 + 评分链路。
const outcome = await fetchExternalData(
registry.get('mock-business-registry'),
query,
);
expect(outcome.status).toBe('success');
if (outcome.status !== 'success') {
throw new Error('期望成功取数');
}
for (const point of outcome.dataPoints) {
expect(point.provenance).toBe(EXTERNAL_DATA_PROVENANCE);
}
const resolver = buildResolver(outcome.dataPoints);
// 单指标得分: cust.credit level 4 × weight 6 = 24。
const creditIndicator = dimension.indicators[0];
expect(creditIndicator).toBeDefined();
if (creditIndicator === undefined) {
throw new Error('缺少 cust.credit 指标');
}
expect(scoreIndicator(creditIndicator, resolver(creditIndicator))).toBe(24);
// 维度得分: cust.credit 4×6=24; cust.litigation 5×4=20 → 44。
expect(scoreDimension(dimension, resolver)).toBe(44);
});
});

Some files were not shown because too many files have changed in this diff Show More