/** * 全功能 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('=', 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); });