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

- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+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
+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 + '人'));