外包风险评估系统:领域引擎+前端+服务端持久化与生产部署
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解 - 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护) - 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理 - 专业图标体系替换全部emoji、看板美化 - 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC - 444单测+204前端测试+51 e2e
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Property 62: 假设项进入缺口说明并附尽调建议 的属性化测试(Report_Generator,Req 18.5, 18.6)。
|
||||
*
|
||||
* 属性陈述:*对任意*评分/红线/费用/可接受性结论均已完成的评估,
|
||||
* `generate` 产出报告的"假设与信息缺口说明"章节(`assumptionsAndGaps.items`)*必*:
|
||||
* - 恰好列出全部 Data_Provenance 为"智能体假设"的评分项,且*仅*列出此类项
|
||||
* (取值来源为"用户输入"/"外部数据"的评分项一律不出现,Req 18.5);
|
||||
* - 为每个假设项输出一条**非空**且关联其对应 Indicator(显式引用维度/指标)
|
||||
* 的补充尽调建议(Req 18.6)。
|
||||
*
|
||||
* 测试在生成器中构造"完整 Assessment",覆盖启用/停用维度与指标、空指标维度、
|
||||
* 三种数据来源混合(含/不含"智能体假设")等形态,并对每个 Indicator 赋予可识别的
|
||||
* 名称以校验尽调建议确实关联到对应 Indicator。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 62: 假设项进入缺口说明并附尽调建议
|
||||
* Validates: Requirements 18.5, 18.6
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
ACCEPTABILITY_VALUES,
|
||||
REDLINE_STATUS_VALUES,
|
||||
type Acceptability,
|
||||
type Assessment,
|
||||
type RedlineResult,
|
||||
type RedlineStatus,
|
||||
type ScoringItem,
|
||||
} from '../../domain/assessment.js';
|
||||
import {
|
||||
DATA_PROVENANCE_VALUES,
|
||||
RISK_GRADE_VALUES,
|
||||
RISK_LEVEL_VALUES,
|
||||
type DataProvenance,
|
||||
type RiskGrade,
|
||||
type RiskLevel,
|
||||
} from '../../domain/common.js';
|
||||
import type { CostEstimate } from '../../domain/cost.js';
|
||||
import type { Dimension, Indicator, Redline, RiskModel, ScoringRule } from '../../domain/model.js';
|
||||
import { REGION_CN } from '../../domain/region.js';
|
||||
import { generate } from '../report.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:构造"评分/红线/费用/可接受性结论均已完成"的 Assessment(混合数据来源)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 合法 Indicator 须覆盖 Risk_Level 1-5(Req 11.3);报告不读取规则文本。 */
|
||||
const scoringRules: ScoringRule[] = (RISK_LEVEL_VALUES as readonly RiskLevel[]).map(
|
||||
(level) => ({ level, label: `L${level}`, description: `规则${level}` }),
|
||||
);
|
||||
|
||||
const riskLevelArb = fc.constantFrom<RiskLevel>(...(RISK_LEVEL_VALUES as readonly RiskLevel[]));
|
||||
const provenanceArb = fc.constantFrom<DataProvenance>(
|
||||
...(DATA_PROVENANCE_VALUES as readonly DataProvenance[]),
|
||||
);
|
||||
const riskGradeArb = fc.constantFrom<RiskGrade>(...(RISK_GRADE_VALUES as readonly RiskGrade[]));
|
||||
const acceptabilityArb = fc.constantFrom<Acceptability>(
|
||||
...(ACCEPTABILITY_VALUES as readonly Acceptability[]),
|
||||
);
|
||||
const redlineStatusArb = fc.constantFrom<RedlineStatus>(
|
||||
...(REDLINE_STATUS_VALUES as readonly RedlineStatus[]),
|
||||
);
|
||||
|
||||
/** 置信度:[0,1] 两位小数(Req 4.6, 18.4)。 */
|
||||
const confidenceArb = fc.integer({ min: 0, max: 100 }).map((n) => n / 100);
|
||||
const weightArb = fc.double({ min: 0, max: 100, noNaN: true });
|
||||
const moneyArb = fc.double({ min: 0, max: 1_000_000, noNaN: true });
|
||||
|
||||
interface RawIndicator {
|
||||
enabled: boolean;
|
||||
weight: number;
|
||||
riskLevel: RiskLevel;
|
||||
provenance: DataProvenance;
|
||||
confidence: number;
|
||||
/** 该指标是否提供非空证据要求(用于覆盖建议关联到证据要求 vs 回退到评分项建议两条分支)。 */
|
||||
hasEvidence: boolean;
|
||||
}
|
||||
|
||||
interface RawDimension {
|
||||
enabled: boolean;
|
||||
weight: number;
|
||||
indicators: RawIndicator[];
|
||||
}
|
||||
|
||||
interface RawRedline {
|
||||
enabled: boolean;
|
||||
status: RedlineStatus;
|
||||
}
|
||||
|
||||
const rawIndicatorArb: fc.Arbitrary<RawIndicator> = fc.record({
|
||||
enabled: fc.boolean(),
|
||||
weight: weightArb,
|
||||
riskLevel: riskLevelArb,
|
||||
provenance: provenanceArb,
|
||||
confidence: confidenceArb,
|
||||
hasEvidence: fc.boolean(),
|
||||
});
|
||||
|
||||
const rawDimensionArb: fc.Arbitrary<RawDimension> = fc.record({
|
||||
enabled: fc.boolean(),
|
||||
weight: weightArb,
|
||||
indicators: fc.array(rawIndicatorArb, { minLength: 0, maxLength: 4 }),
|
||||
});
|
||||
|
||||
const rawRedlineArb: fc.Arbitrary<RawRedline> = fc.record({
|
||||
enabled: fc.boolean(),
|
||||
status: redlineStatusArb,
|
||||
});
|
||||
|
||||
const costEstimateArb: fc.Arbitrary<CostEstimate> = fc
|
||||
.record({
|
||||
baseline: moneyArb,
|
||||
premiumLower: fc.double({ min: 0, max: 50, noNaN: true }),
|
||||
premiumDelta: fc.double({ min: 0, max: 50, noNaN: true }),
|
||||
})
|
||||
.map(({ baseline, premiumLower, premiumDelta }) => ({
|
||||
riskPremiumRange: { lower: premiumLower, upper: premiumLower + premiumDelta, unit: '百分比' },
|
||||
advanceInterest: 0,
|
||||
insuranceCost: 0,
|
||||
compensationReserve: 0,
|
||||
badDebtReserve: 0,
|
||||
baselineQuote: baseline,
|
||||
riskAdjustedQuote: baseline,
|
||||
breakdown: [],
|
||||
}));
|
||||
|
||||
interface AssessmentSpec {
|
||||
dimensions: RawDimension[];
|
||||
redlines: RawRedline[];
|
||||
riskScore: number;
|
||||
riskGrade: RiskGrade;
|
||||
acceptability: Acceptability;
|
||||
costEstimate: CostEstimate;
|
||||
}
|
||||
|
||||
const assessmentSpecArb: fc.Arbitrary<AssessmentSpec> = fc.record({
|
||||
dimensions: fc.array(rawDimensionArb, { minLength: 0, maxLength: 4 }),
|
||||
redlines: fc.array(rawRedlineArb, { minLength: 0, maxLength: 5 }),
|
||||
riskScore: fc.integer({ min: 0, max: 100 }),
|
||||
riskGrade: riskGradeArb,
|
||||
acceptability: acceptabilityArb,
|
||||
costEstimate: costEstimateArb,
|
||||
});
|
||||
|
||||
/** 稳定的指标名称:用于校验尽调建议确实关联到对应 Indicator。 */
|
||||
function indicatorName(i: number, j: number): string {
|
||||
return `指标_${String(i)}_${String(j)}`;
|
||||
}
|
||||
|
||||
/** 稳定的维度名称:用于校验尽调建议确实关联到对应 Dimension。 */
|
||||
function dimensionName(i: number): string {
|
||||
return `维度_${String(i)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由随机规格构造一个"已完成"的 Assessment:
|
||||
* - 风险模型保留全部随机形态(启用/停用维度与指标、空指标维度、权重为 0 等);
|
||||
* - 每个 Indicator 均有对应 ScoringItem,三种数据来源混合出现;
|
||||
* - 每个**启用**红线均有对应的红线校验结果(满足红线校验完成条件);
|
||||
* - riskScore/riskGrade/costEstimate/acceptability 均已产生。
|
||||
*/
|
||||
function buildAssessment(spec: AssessmentSpec): Assessment {
|
||||
const dimensions: Dimension[] = spec.dimensions.map((d, i) => ({
|
||||
id: `d${String(i)}`,
|
||||
name: dimensionName(i),
|
||||
weight: d.weight,
|
||||
enabled: d.enabled,
|
||||
indicators: d.indicators.map(
|
||||
(ind, j): Indicator => ({
|
||||
id: `i${String(j)}`,
|
||||
name: indicatorName(i, j),
|
||||
weight: ind.weight,
|
||||
enabled: ind.enabled,
|
||||
scoringRules,
|
||||
evidenceRequired: ind.hasEvidence ? `证据要求_${String(i)}_${String(j)}` : '',
|
||||
askPrompt: '',
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
const redlines: Redline[] = spec.redlines.map((r, k) => ({
|
||||
id: `r${String(k)}`,
|
||||
triggerCondition: `触发条件${String(k)}`,
|
||||
consequence: `后果${String(k)}`,
|
||||
enabled: r.enabled,
|
||||
}));
|
||||
|
||||
const riskModel: RiskModel = {
|
||||
id: 'm1',
|
||||
name: '风险模型',
|
||||
businessType: '岗位外包',
|
||||
dimensions,
|
||||
redlines,
|
||||
};
|
||||
|
||||
// 为每个指标构造评分项(含停用项);保留随机数据来源以覆盖混合来源情形。
|
||||
const scoringItems: ScoringItem[] = spec.dimensions.flatMap((d, i) =>
|
||||
d.indicators.map((ind, j): ScoringItem => ({
|
||||
dimensionId: `d${String(i)}`,
|
||||
indicatorId: `i${String(j)}`,
|
||||
riskLevel: ind.riskLevel,
|
||||
score: ind.riskLevel * ind.weight,
|
||||
provenance: ind.provenance,
|
||||
confidence: ind.confidence,
|
||||
rationale: `判定依据${String(i)}-${String(j)}`,
|
||||
riskImpact: `风险影响${String(i)}-${String(j)}`,
|
||||
recommendation: `建议${String(i)}-${String(j)}`,
|
||||
})),
|
||||
);
|
||||
|
||||
// 每个启用红线产生一个红线校验结果(满足红线校验完成条件)。
|
||||
const redlineResults: RedlineResult[] = spec.redlines
|
||||
.map((r, k): { enabled: boolean; result: RedlineResult } => {
|
||||
const base: RedlineResult = { redlineId: `r${String(k)}`, status: r.status };
|
||||
const result: RedlineResult =
|
||||
r.status === '命中'
|
||||
? { ...base, triggeredCondition: `触发条件${String(k)}`, evidenceData: `判定依据数据${String(k)}` }
|
||||
: base;
|
||||
return { enabled: r.enabled, result };
|
||||
})
|
||||
.filter((entry) => entry.enabled)
|
||||
.map((entry) => entry.result);
|
||||
|
||||
return {
|
||||
id: 'a1',
|
||||
projectDescription: '某外包项目风险评估',
|
||||
businessType: '岗位外包',
|
||||
industry: '制造业',
|
||||
region: REGION_CN,
|
||||
riskModel,
|
||||
scoringItems,
|
||||
riskScore: spec.riskScore,
|
||||
riskGrade: spec.riskGrade,
|
||||
redlineResults,
|
||||
costEstimate: spec.costEstimate,
|
||||
acceptability: spec.acceptability,
|
||||
metadata: {
|
||||
businessType: '岗位外包',
|
||||
industry: '制造业',
|
||||
region: REGION_CN,
|
||||
riskScore: spec.riskScore,
|
||||
riskGrade: spec.riskGrade,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
assessorId: 'assessor-1',
|
||||
},
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
assessorId: 'assessor-1',
|
||||
};
|
||||
}
|
||||
|
||||
/** 评分项的稳定标识键(用于集合比较)。 */
|
||||
function itemKey(dimensionId: string, indicatorId: string): string {
|
||||
return `${dimensionId}.${indicatorId}`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 62: 假设项进入缺口说明并附尽调建议 (Req 18.5, 18.6)', () => {
|
||||
it('信息缺口说明恰好列出全部"智能体假设"评分项(且仅此类),每项附非空且关联对应 Indicator 的尽调建议', () => {
|
||||
fc.assert(
|
||||
fc.property(assessmentSpecArb, (spec) => {
|
||||
const assessment = buildAssessment(spec);
|
||||
const report = generate(assessment);
|
||||
const items = report.assumptionsAndGaps.items;
|
||||
|
||||
// 实际为"智能体假设"的评分项键集合(期望集合)。
|
||||
const expectedAssumptionKeys = new Set(
|
||||
assessment.scoringItems
|
||||
.filter((item) => item.provenance === '智能体假设')
|
||||
.map((item) => itemKey(item.dimensionId, item.indicatorId)),
|
||||
);
|
||||
|
||||
const reportedKeys = items.map((it) => itemKey(it.dimensionId, it.indicatorId));
|
||||
|
||||
// 1) 缺口说明中的每一项来源均为"智能体假设",且无重复(Req 18.5:仅列假设项)。
|
||||
for (const it of items) {
|
||||
expect(it.provenance).toBe('智能体假设');
|
||||
}
|
||||
expect(new Set(reportedKeys).size).toBe(reportedKeys.length);
|
||||
|
||||
// 2) 缺口说明列出的项集合 === 全部"智能体假设"评分项集合(恰好且仅此类,Req 18.5)。
|
||||
expect(new Set(reportedKeys)).toEqual(expectedAssumptionKeys);
|
||||
expect(items.length).toBe(expectedAssumptionKeys.size);
|
||||
|
||||
// 3) 每个假设项附一条非空、关联对应 Indicator 的补充尽调建议(Req 18.6)。
|
||||
for (const it of items) {
|
||||
// 建议非空。
|
||||
expect(it.recommendation.trim().length).toBeGreaterThan(0);
|
||||
|
||||
// 解析该项对应的维度/指标,确认建议显式引用其名称与标识。
|
||||
const dimension = assessment.riskModel.dimensions.find((d) => d.id === it.dimensionId);
|
||||
expect(dimension).toBeDefined();
|
||||
const indicator = dimension?.indicators.find((ind) => ind.id === it.indicatorId);
|
||||
expect(indicator).toBeDefined();
|
||||
|
||||
// 建议关联对应 Indicator:包含指标名称、维度名称与 维度.指标 标识。
|
||||
expect(it.recommendation).toContain(indicator?.name ?? '__missing__');
|
||||
expect(it.recommendation).toContain(dimension?.name ?? '__missing__');
|
||||
expect(it.recommendation).toContain(`${it.dimensionId}.${it.indicatorId}`);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Property 37: 维度明细字段齐备 的属性化测试(Report_Generator,Req 10.3)。
|
||||
*
|
||||
* 属性陈述:*对任意*报告(由已完成评分/红线/费用/可接受性结论的评估生成),
|
||||
* 各维度风险明细(`dimensionDetails.dimensions[*].scoringItems[*]`)中每个评分项
|
||||
* *必*展示评分(score)、判定依据(rationale)、风险影响(riskImpact)、
|
||||
* 数据来源(Data_Provenance)与置信度(Confidence),且各字段均有效:
|
||||
* - score 为有限数值;
|
||||
* - rationale 与 riskImpact 为非空字符串;
|
||||
* - provenance 取值属于 {用户输入, 外部数据, 智能体假设};
|
||||
* - confidence 落在 [0,1] 闭区间内。
|
||||
*
|
||||
* 本测试在生成器中构造"完整 Assessment"(评分/红线/费用/可接受性结论均已产生),
|
||||
* 覆盖启用/停用维度与指标、空指标维度、混合数据来源(含"智能体假设")、权重为 0、
|
||||
* 边界置信度(0 与 1)等形态,对 `generate` 产出的每个维度明细评分项施加上述齐备性约束。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 37: 维度明细字段齐备
|
||||
* Validates: Requirements 10.3
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
ACCEPTABILITY_VALUES,
|
||||
REDLINE_STATUS_VALUES,
|
||||
type Acceptability,
|
||||
type Assessment,
|
||||
type RedlineResult,
|
||||
type RedlineStatus,
|
||||
type ScoringItem,
|
||||
} from '../../domain/assessment.js';
|
||||
import {
|
||||
DATA_PROVENANCE_VALUES,
|
||||
RISK_GRADE_VALUES,
|
||||
RISK_LEVEL_VALUES,
|
||||
type DataProvenance,
|
||||
type RiskGrade,
|
||||
type RiskLevel,
|
||||
} from '../../domain/common.js';
|
||||
import type { CostEstimate } from '../../domain/cost.js';
|
||||
import type { Dimension, Indicator, Redline, RiskModel, ScoringRule } from '../../domain/model.js';
|
||||
import { REGION_CN } from '../../domain/region.js';
|
||||
import { generate } from '../report.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:构造"评分/红线/费用/可接受性结论均已完成"的 Assessment
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 合法 Indicator 须覆盖 Risk_Level 1-5(Req 11.3);报告不读取规则文本。 */
|
||||
const scoringRules: ScoringRule[] = (RISK_LEVEL_VALUES as readonly RiskLevel[]).map(
|
||||
(level) => ({ level, label: `L${level}`, description: `规则${level}` }),
|
||||
);
|
||||
|
||||
const riskLevelArb = fc.constantFrom<RiskLevel>(...(RISK_LEVEL_VALUES as readonly RiskLevel[]));
|
||||
const provenanceArb = fc.constantFrom<DataProvenance>(
|
||||
...(DATA_PROVENANCE_VALUES as readonly DataProvenance[]),
|
||||
);
|
||||
const riskGradeArb = fc.constantFrom<RiskGrade>(...(RISK_GRADE_VALUES as readonly RiskGrade[]));
|
||||
const acceptabilityArb = fc.constantFrom<Acceptability>(
|
||||
...(ACCEPTABILITY_VALUES as readonly Acceptability[]),
|
||||
);
|
||||
const redlineStatusArb = fc.constantFrom<RedlineStatus>(
|
||||
...(REDLINE_STATUS_VALUES as readonly RedlineStatus[]),
|
||||
);
|
||||
|
||||
/** 置信度:[0,1] 两位小数,含边界 0 与 1(Req 4.6, 18.4)。 */
|
||||
const confidenceArb = fc.integer({ min: 0, max: 100 }).map((n) => n / 100);
|
||||
const weightArb = fc.double({ min: 0, max: 100, noNaN: true });
|
||||
const moneyArb = fc.double({ min: 0, max: 1_000_000, noNaN: true });
|
||||
|
||||
interface RawIndicator {
|
||||
enabled: boolean;
|
||||
weight: number;
|
||||
riskLevel: RiskLevel;
|
||||
provenance: DataProvenance;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface RawDimension {
|
||||
enabled: boolean;
|
||||
weight: number;
|
||||
indicators: RawIndicator[];
|
||||
}
|
||||
|
||||
interface RawRedline {
|
||||
enabled: boolean;
|
||||
status: RedlineStatus;
|
||||
}
|
||||
|
||||
const rawIndicatorArb: fc.Arbitrary<RawIndicator> = fc.record({
|
||||
enabled: fc.boolean(),
|
||||
weight: weightArb,
|
||||
riskLevel: riskLevelArb,
|
||||
provenance: provenanceArb,
|
||||
confidence: confidenceArb,
|
||||
});
|
||||
|
||||
const rawDimensionArb: fc.Arbitrary<RawDimension> = fc.record({
|
||||
enabled: fc.boolean(),
|
||||
weight: weightArb,
|
||||
indicators: fc.array(rawIndicatorArb, { minLength: 0, maxLength: 4 }),
|
||||
});
|
||||
|
||||
const rawRedlineArb: fc.Arbitrary<RawRedline> = fc.record({
|
||||
enabled: fc.boolean(),
|
||||
status: redlineStatusArb,
|
||||
});
|
||||
|
||||
const costEstimateArb: fc.Arbitrary<CostEstimate> = fc
|
||||
.record({
|
||||
baseline: moneyArb,
|
||||
premiumLower: fc.double({ min: 0, max: 50, noNaN: true }),
|
||||
premiumDelta: fc.double({ min: 0, max: 50, noNaN: true }),
|
||||
})
|
||||
.map(({ baseline, premiumLower, premiumDelta }) => ({
|
||||
riskPremiumRange: { lower: premiumLower, upper: premiumLower + premiumDelta, unit: '百分比' },
|
||||
advanceInterest: 0,
|
||||
insuranceCost: 0,
|
||||
compensationReserve: 0,
|
||||
badDebtReserve: 0,
|
||||
baselineQuote: baseline,
|
||||
riskAdjustedQuote: baseline,
|
||||
breakdown: [],
|
||||
}));
|
||||
|
||||
interface AssessmentSpec {
|
||||
dimensions: RawDimension[];
|
||||
redlines: RawRedline[];
|
||||
riskScore: number;
|
||||
riskGrade: RiskGrade;
|
||||
acceptability: Acceptability;
|
||||
costEstimate: CostEstimate;
|
||||
}
|
||||
|
||||
const assessmentSpecArb: fc.Arbitrary<AssessmentSpec> = fc.record({
|
||||
dimensions: fc.array(rawDimensionArb, { minLength: 0, maxLength: 4 }),
|
||||
redlines: fc.array(rawRedlineArb, { minLength: 0, maxLength: 5 }),
|
||||
riskScore: fc.integer({ min: 0, max: 100 }),
|
||||
riskGrade: riskGradeArb,
|
||||
acceptability: acceptabilityArb,
|
||||
costEstimate: costEstimateArb,
|
||||
});
|
||||
|
||||
/**
|
||||
* 由随机规格构造一个"已完成"的 Assessment:
|
||||
* - 风险模型保留全部随机形态(启用/停用维度与指标、空指标维度、权重为 0 等);
|
||||
* - 每个 Indicator 均有对应 ScoringItem(含来源/置信/三要素);
|
||||
* - 每个**启用**红线均有对应的红线校验结果(满足红线校验完成条件);
|
||||
* - riskScore/riskGrade/costEstimate/acceptability 均已产生。
|
||||
*/
|
||||
function buildAssessment(spec: AssessmentSpec): Assessment {
|
||||
const dimensions: Dimension[] = spec.dimensions.map((d, i) => ({
|
||||
id: `d${i}`,
|
||||
name: `维度${i}`,
|
||||
weight: d.weight,
|
||||
enabled: d.enabled,
|
||||
indicators: d.indicators.map(
|
||||
(ind, j): Indicator => ({
|
||||
id: `i${j}`,
|
||||
name: `指标${i}-${j}`,
|
||||
weight: ind.weight,
|
||||
enabled: ind.enabled,
|
||||
scoringRules,
|
||||
evidenceRequired: '',
|
||||
askPrompt: '',
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
const redlines: Redline[] = spec.redlines.map((r, k) => ({
|
||||
id: `r${k}`,
|
||||
triggerCondition: `触发条件${k}`,
|
||||
consequence: `后果${k}`,
|
||||
enabled: r.enabled,
|
||||
}));
|
||||
|
||||
const riskModel: RiskModel = {
|
||||
id: 'm1',
|
||||
name: '风险模型',
|
||||
businessType: '岗位外包',
|
||||
dimensions,
|
||||
redlines,
|
||||
};
|
||||
|
||||
// 为每个指标构造评分项(含停用项;报告维度明细按维度分组全部评分项)。
|
||||
const scoringItems: ScoringItem[] = spec.dimensions.flatMap((d, i) =>
|
||||
d.indicators.map((ind, j): ScoringItem => ({
|
||||
dimensionId: `d${i}`,
|
||||
indicatorId: `i${j}`,
|
||||
riskLevel: ind.riskLevel,
|
||||
score: ind.riskLevel * ind.weight,
|
||||
provenance: ind.provenance,
|
||||
confidence: ind.confidence,
|
||||
rationale: `判定依据${i}-${j}`,
|
||||
riskImpact: `风险影响${i}-${j}`,
|
||||
recommendation: `建议${i}-${j}`,
|
||||
})),
|
||||
);
|
||||
|
||||
// 每个启用红线产生一个红线校验结果(满足红线校验完成条件)。
|
||||
const redlineResults: RedlineResult[] = spec.redlines
|
||||
.map((r, k): { enabled: boolean; result: RedlineResult } => {
|
||||
const base: RedlineResult = { redlineId: `r${k}`, status: r.status };
|
||||
const result: RedlineResult =
|
||||
r.status === '命中'
|
||||
? { ...base, triggeredCondition: `触发条件${k}`, evidenceData: `判定依据数据${k}` }
|
||||
: base;
|
||||
return { enabled: r.enabled, result };
|
||||
})
|
||||
.filter((entry) => entry.enabled)
|
||||
.map((entry) => entry.result);
|
||||
|
||||
return {
|
||||
id: 'a1',
|
||||
projectDescription: '某外包项目风险评估',
|
||||
businessType: '岗位外包',
|
||||
industry: '制造业',
|
||||
region: REGION_CN,
|
||||
riskModel,
|
||||
scoringItems,
|
||||
riskScore: spec.riskScore,
|
||||
riskGrade: spec.riskGrade,
|
||||
redlineResults,
|
||||
costEstimate: spec.costEstimate,
|
||||
acceptability: spec.acceptability,
|
||||
metadata: {
|
||||
businessType: '岗位外包',
|
||||
industry: '制造业',
|
||||
region: REGION_CN,
|
||||
riskScore: spec.riskScore,
|
||||
riskGrade: spec.riskGrade,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
assessorId: 'assessor-1',
|
||||
},
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
assessorId: 'assessor-1',
|
||||
};
|
||||
}
|
||||
|
||||
/** 数据来源合法取值集合(运行时校验)。 */
|
||||
const PROVENANCE_SET = new Set<string>(DATA_PROVENANCE_VALUES);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 37: 维度明细字段齐备 (Req 10.3)', () => {
|
||||
it('对任意已完成评估,维度明细中每个评分项均展示且有效暴露 评分/判定依据/风险影响/来源/置信', () => {
|
||||
fc.assert(
|
||||
fc.property(assessmentSpecArb, (spec) => {
|
||||
const assessment = buildAssessment(spec);
|
||||
const report = generate(assessment);
|
||||
|
||||
const { dimensions } = report.dimensionDetails;
|
||||
|
||||
// 维度明细按维度分组,维度数与模型一致。
|
||||
expect(dimensions.length).toBe(assessment.riskModel.dimensions.length);
|
||||
|
||||
for (const dimension of dimensions) {
|
||||
for (const item of dimension.scoringItems) {
|
||||
// 评分(score):必须存在且为有限数值。
|
||||
expect(item.score).toBeDefined();
|
||||
expect(typeof item.score).toBe('number');
|
||||
expect(Number.isFinite(item.score)).toBe(true);
|
||||
|
||||
// 判定依据(rationale):必须存在且非空。
|
||||
expect(typeof item.rationale).toBe('string');
|
||||
expect(item.rationale.trim().length).toBeGreaterThan(0);
|
||||
|
||||
// 风险影响(riskImpact):必须存在且非空。
|
||||
expect(typeof item.riskImpact).toBe('string');
|
||||
expect(item.riskImpact.trim().length).toBeGreaterThan(0);
|
||||
|
||||
// 数据来源(Data_Provenance):必须为三类合法取值之一。
|
||||
expect(PROVENANCE_SET.has(item.provenance)).toBe(true);
|
||||
|
||||
// 置信度(Confidence):必须存在且落在 [0,1] 闭区间。
|
||||
expect(typeof item.confidence).toBe('number');
|
||||
expect(Number.isFinite(item.confidence)).toBe(true);
|
||||
expect(item.confidence).toBeGreaterThanOrEqual(0);
|
||||
expect(item.confidence).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Report_Generator 报告导出集成测试(任务 13.10,Req 10.2, 10.5)。
|
||||
*
|
||||
* 本集成测试以端到端方式串联报告生成({@link generate})与报告导出
|
||||
* ({@link exportReport}),覆盖以下三条验收路径:
|
||||
*
|
||||
* 1. 导出文件自包含且含全部章节(Req 10.2):对一份已完成评估生成报告并分别导出为
|
||||
* JSON 与 HTML 两种格式,验证产物为完整、自包含的可下载文件——
|
||||
* - JSON:可被解析回结构化报告,且报告全部八个规定章节(Req 10.1 所列)均完整呈现;
|
||||
* - HTML:单文件内联样式、不引用任何外部资源(无外链 src/href、script/link 外链),
|
||||
* 且报告全部八个规定章节标题均出现在文档中。
|
||||
*
|
||||
* 2. 导出耗时 < 30 秒(Req 10.2):实测从请求导出到产出文件的耗时,断言其远低于
|
||||
* 30 秒上限(导出为纯内存操作,应在毫秒级完成)。
|
||||
*
|
||||
* 3. 注入导出失败、报告内容不变(Req 10.5):注入一个抛错的渲染器以模拟导出失败,
|
||||
* 验证 {@link exportReport} 中止本次导出并抛出 {@link ExportFailedError}(携带"报告导出失败"
|
||||
* 提示),且未产生任何文件;同时断言失败前后已生成的报告对象内容逐字节不变。
|
||||
*
|
||||
* 本测试仅 import 现有 report.ts / export.ts 的导出,不修改其源代码。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment
|
||||
* Validates: Requirements 10.2, 10.5
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Acceptability, Assessment, RedlineResult, ScoringItem } from '../../domain/assessment.js';
|
||||
import type { RiskGrade, RiskLevel } from '../../domain/common.js';
|
||||
import type { CostEstimate } from '../../domain/cost.js';
|
||||
import type { Dimension, Indicator, Redline, RiskModel, ScoringRule } from '../../domain/model.js';
|
||||
import { REGION_CN } from '../../domain/region.js';
|
||||
import { generate, type Report } from '../report.js';
|
||||
import { ExportFailedError, exportReport, type ReportRenderer } from '../export.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 固定夹具:构造一份"评分/红线/费用/可接受性结论均已完成"的 Assessment。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 合法 Indicator 须覆盖 Risk_Level 1-5;报告/导出不读取规则文本。 */
|
||||
const SCORING_RULES: ScoringRule[] = ([1, 2, 3, 4, 5] as readonly RiskLevel[]).map((level) => ({
|
||||
level,
|
||||
label: `L${level}`,
|
||||
description: `规则${level}`,
|
||||
}));
|
||||
|
||||
function makeIndicator(id: string, name: string, weight: number): Indicator {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
weight,
|
||||
enabled: true,
|
||||
scoringRules: SCORING_RULES,
|
||||
evidenceRequired: `${name}的证据要求`,
|
||||
askPrompt: `请提供${name}相关信息`,
|
||||
};
|
||||
}
|
||||
|
||||
function makeScoringItem(
|
||||
dimensionId: string,
|
||||
indicator: Indicator,
|
||||
riskLevel: RiskLevel,
|
||||
provenance: ScoringItem['provenance'],
|
||||
): ScoringItem {
|
||||
return {
|
||||
dimensionId,
|
||||
indicatorId: indicator.id,
|
||||
riskLevel,
|
||||
score: riskLevel * indicator.weight,
|
||||
provenance,
|
||||
confidence: 0.8,
|
||||
rationale: `${indicator.name} 判定依据`,
|
||||
riskImpact: `${indicator.name} 风险影响`,
|
||||
recommendation: `${indicator.name} 建议`,
|
||||
};
|
||||
}
|
||||
|
||||
const COST_ESTIMATE: CostEstimate = {
|
||||
riskPremiumRange: { lower: 5, upper: 12, unit: '百分比' },
|
||||
advanceInterest: 1000,
|
||||
insuranceCost: 2000,
|
||||
compensationReserve: 3000,
|
||||
badDebtReserve: 1500,
|
||||
baselineQuote: 100_000,
|
||||
riskAdjustedQuote: 112_000,
|
||||
breakdown: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* 构造一份完整评估:含两个启用指标(其一来源为"智能体假设",以填充假设与信息缺口章节),
|
||||
* 一条命中红线与一条未命中红线(覆盖红线章节命中/未命中分支)。
|
||||
*/
|
||||
function buildCompleteAssessment(): Assessment {
|
||||
const indicatorA = makeIndicator('i-credit', '信用评级', 6);
|
||||
const indicatorB = makeIndicator('i-litigation', '涉诉风险', 4);
|
||||
|
||||
const dimension: Dimension = {
|
||||
id: 'd-customer',
|
||||
name: '客户风险',
|
||||
weight: 100,
|
||||
enabled: true,
|
||||
indicators: [indicatorA, indicatorB],
|
||||
};
|
||||
|
||||
const redlines: Redline[] = [
|
||||
{ id: 'r-hit', triggerCondition: '严重失信', consequence: '一票否决', enabled: true },
|
||||
{ id: 'r-miss', triggerCondition: '资质缺失', consequence: '一票否决', enabled: true },
|
||||
];
|
||||
|
||||
const riskModel: RiskModel = {
|
||||
id: 'm-1',
|
||||
name: '岗位外包风险模型',
|
||||
businessType: '岗位外包',
|
||||
dimensions: [dimension],
|
||||
redlines,
|
||||
};
|
||||
|
||||
const scoringItems: ScoringItem[] = [
|
||||
makeScoringItem('d-customer', indicatorA, 4, '用户输入'),
|
||||
makeScoringItem('d-customer', indicatorB, 3, '智能体假设'),
|
||||
];
|
||||
|
||||
const redlineResults: RedlineResult[] = [
|
||||
{
|
||||
redlineId: 'r-hit',
|
||||
status: '命中',
|
||||
triggeredCondition: '严重失信',
|
||||
evidenceData: '近一年被列入失信被执行人名单',
|
||||
},
|
||||
{ redlineId: 'r-miss', status: '未命中' },
|
||||
];
|
||||
|
||||
return {
|
||||
id: 'a-1',
|
||||
projectDescription: '某制造企业岗位外包项目风险评估',
|
||||
businessType: '岗位外包',
|
||||
industry: '制造业',
|
||||
region: REGION_CN,
|
||||
riskModel,
|
||||
scoringItems,
|
||||
riskScore: 72,
|
||||
riskGrade: '高' as RiskGrade,
|
||||
redlineResults,
|
||||
costEstimate: COST_ESTIMATE,
|
||||
acceptability: '有条件接受' as Acceptability,
|
||||
metadata: {
|
||||
businessType: '岗位外包',
|
||||
industry: '制造业',
|
||||
region: REGION_CN,
|
||||
riskScore: 72,
|
||||
riskGrade: '高' as RiskGrade,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
assessorId: 'assessor-1',
|
||||
},
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
assessorId: 'assessor-1',
|
||||
};
|
||||
}
|
||||
|
||||
/** 报告全部八个规定章节标题(Req 10.1),用于自包含完备性校验。 */
|
||||
const SECTION_TITLES: readonly string[] = [
|
||||
'项目概要与业务类型判定',
|
||||
'风险总分与分级',
|
||||
'风险热力图',
|
||||
'各维度风险明细',
|
||||
'Top 关键风险与红线校验结果',
|
||||
'可接受性结论',
|
||||
'应对方案',
|
||||
'假设与信息缺口说明',
|
||||
];
|
||||
|
||||
/** 断言报告对象含全部八个章节且各章节标题非空。 */
|
||||
function expectAllSectionsPresent(report: Report): void {
|
||||
expect(report.projectOverview.title).toBe('项目概要与业务类型判定');
|
||||
expect(report.riskScoreGrade.title).toBe('风险总分与分级');
|
||||
expect(report.heatmap.title).toBe('风险热力图');
|
||||
expect(report.dimensionDetails.title).toBe('各维度风险明细');
|
||||
expect(report.keyRisksAndRedlines.title).toBe('Top 关键风险与红线校验结果');
|
||||
expect(report.acceptabilityConclusion.title).toBe('可接受性结论');
|
||||
expect(report.responsePlan.title).toBe('应对方案');
|
||||
expect(report.assumptionsAndGaps.title).toBe('假设与信息缺口说明');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 路径一:导出文件自包含且含全部章节(Req 10.2)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('报告导出集成 - 自包含且含全部章节 (Req 10.2)', () => {
|
||||
it('JSON 导出产物可解析回报告且含全部八个规定章节', () => {
|
||||
const report = generate(buildCompleteAssessment());
|
||||
expectAllSectionsPresent(report);
|
||||
|
||||
const file = exportReport(report, 'json');
|
||||
|
||||
expect(file.filename.endsWith('.json')).toBe(true);
|
||||
expect(file.mimeType).toContain('application/json');
|
||||
expect(file.content.length).toBeGreaterThan(0);
|
||||
|
||||
// 自包含:单一文件正文即可还原结构化报告,无需任何外部资源。
|
||||
const parsed = JSON.parse(file.content) as Report;
|
||||
expectAllSectionsPresent(parsed);
|
||||
expect(parsed.riskScoreGrade.riskScore).toBe(report.riskScoreGrade.riskScore);
|
||||
expect(parsed.riskScoreGrade.riskGrade).toBe(report.riskScoreGrade.riskGrade);
|
||||
expect(parsed.acceptabilityConclusion.acceptability).toBe(
|
||||
report.acceptabilityConclusion.acceptability,
|
||||
);
|
||||
// 命中红线章节完整保留(含触发条件与判定依据数据)。
|
||||
expect(parsed.keyRisksAndRedlines.hasRedlineHit).toBe(true);
|
||||
expect(parsed.keyRisksAndRedlines.hitRedlines.length).toBe(1);
|
||||
// 假设与信息缺口章节完整保留。
|
||||
expect(parsed.assumptionsAndGaps.items.length).toBe(1);
|
||||
});
|
||||
|
||||
it('HTML 导出产物为自包含单文件(无外部资源引用)且含全部章节标题', () => {
|
||||
const report = generate(buildCompleteAssessment());
|
||||
const file = exportReport(report, 'html');
|
||||
|
||||
expect(file.filename.endsWith('.html')).toBe(true);
|
||||
expect(file.mimeType).toContain('text/html');
|
||||
expect(file.content).toContain('<!DOCTYPE html>');
|
||||
|
||||
// 自包含:不得引用任何外部资源(外链脚本/样式/图片等)。
|
||||
expect(file.content).not.toMatch(/(?:src|href)\s*=\s*["']https?:\/\//i);
|
||||
expect(file.content).not.toMatch(/<script\b[^>]*\bsrc\s*=/i);
|
||||
expect(file.content).not.toMatch(/<link\b[^>]*\brel\s*=\s*["']stylesheet["']/i);
|
||||
// 样式内联呈现,确认无外部样式表依赖。
|
||||
expect(file.content).toContain('<style>');
|
||||
|
||||
// 全部八个规定章节标题均出现在文档中。
|
||||
for (const title of SECTION_TITLES) {
|
||||
expect(file.content).toContain(title);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 路径二:导出耗时 < 30 秒(Req 10.2)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('报告导出集成 - 耗时 < 30 秒 (Req 10.2)', () => {
|
||||
it('生成并导出报告的实测耗时远低于 30 秒上限', () => {
|
||||
const report = generate(buildCompleteAssessment());
|
||||
|
||||
const start = Date.now();
|
||||
const jsonFile = exportReport(report, 'json');
|
||||
const htmlFile = exportReport(report, 'html');
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
expect(jsonFile.content.length).toBeGreaterThan(0);
|
||||
expect(htmlFile.content.length).toBeGreaterThan(0);
|
||||
// 契约上限 30 秒;纯内存导出应在毫秒级完成。
|
||||
expect(elapsedMs).toBeLessThan(30_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 路径三:注入导出失败,已生成报告内容不变(Req 10.5)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('报告导出集成 - 导出失败保留报告内容不变 (Req 10.5)', () => {
|
||||
it('渲染器抛错时中止导出、抛 ExportFailedError,且报告对象内容逐字段不变', () => {
|
||||
const report = generate(buildCompleteAssessment());
|
||||
|
||||
// 失败前对报告内容做不可变快照(以 JSON 序列化捕获完整内容)。
|
||||
const snapshotBefore = JSON.stringify(report);
|
||||
|
||||
const failingRenderer: ReportRenderer = () => {
|
||||
throw new Error('模拟序列化失败');
|
||||
};
|
||||
|
||||
let caught: unknown;
|
||||
let produced: unknown;
|
||||
try {
|
||||
produced = exportReport(report, 'json', { renderer: failingRenderer });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
// 1) 导出被中止:未产生任何下载文件。
|
||||
expect(produced).toBeUndefined();
|
||||
|
||||
// 2) 抛出语义化的导出失败错误,携带"报告导出失败"提示。
|
||||
expect(caught).toBeInstanceOf(ExportFailedError);
|
||||
const error = caught as ExportFailedError;
|
||||
expect(error.userMessage).toContain('报告导出失败');
|
||||
expect(error.message).toContain('报告导出失败');
|
||||
// 底层原因经标准 cause 保留以便排查。
|
||||
expect(error.cause).toBeInstanceOf(Error);
|
||||
|
||||
// 3) 已生成报告内容保持不变(失败不污染入参 report)。
|
||||
expect(JSON.stringify(report)).toBe(snapshotBefore);
|
||||
|
||||
// 4) 修复条件后(使用默认渲染器)可正常导出完整文件,证明报告本身仍可用。
|
||||
const recovered = exportReport(report, 'json');
|
||||
expect(recovered.content.length).toBeGreaterThan(0);
|
||||
const reparsed = JSON.parse(recovered.content) as Report;
|
||||
expectAllSectionsPresent(reparsed);
|
||||
});
|
||||
|
||||
it('渲染结果为空字符串时同样中止导出并抛 ExportFailedError,报告内容不变', () => {
|
||||
const report = generate(buildCompleteAssessment());
|
||||
const snapshotBefore = JSON.stringify(report);
|
||||
|
||||
const emptyRenderer: ReportRenderer = () => '';
|
||||
|
||||
expect(() => exportReport(report, 'json', { renderer: emptyRenderer })).toThrow(
|
||||
ExportFailedError,
|
||||
);
|
||||
expect(JSON.stringify(report)).toBe(snapshotBefore);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Property 38: 流程未完成拒绝报告 的属性化测试(Report_Generator,Req 10.4)。
|
||||
*
|
||||
* 属性陈述:*对任意*评估流程尚未完成的状态(风险评分、红线校验、费用测算、
|
||||
* 可接受性结论中存在任一未完成项),请求生成报告 `generate` *必*被拒绝并抛出
|
||||
* {@link FlowNotCompleteError},其面向评估者的提示固定包含"评估流程尚未完成",
|
||||
* 且未完成步骤清单(`missingSteps`)恰好等于实际缺失的前置步骤集合。
|
||||
*
|
||||
* 完成判定(与实现 `collectMissingSteps` 对齐):
|
||||
* - 风险评分:`riskScore` 与 `riskGrade` 均已产生。
|
||||
* - 红线校验:每个**启用**红线均有对应校验结果(停用红线不计入)。
|
||||
* - 费用测算:`costEstimate` 已产生。
|
||||
* - 可接受性结论:`acceptability` 已产生。
|
||||
*
|
||||
* 本测试构造"至少一步未完成"的随机评估状态(覆盖风险评分整体缺失/部分缺失、
|
||||
* 红线结果缺失、费用缺失、可接受性缺失的任意非空组合),对 `generate` 施加上述约束。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 38: 流程未完成拒绝报告
|
||||
* Validates: Requirements 10.4
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import type { RiskGrade } from '../../domain/common.js';
|
||||
import type { Redline, RiskModel } from '../../domain/model.js';
|
||||
import type {
|
||||
Assessment,
|
||||
AssessmentMetadata,
|
||||
RedlineResult,
|
||||
} from '../../domain/assessment.js';
|
||||
import type { CostEstimate } from '../../domain/cost.js';
|
||||
import { REGION_CN } from '../../domain/region.js';
|
||||
import { generate } from '../report.js';
|
||||
import { FlowNotCompleteError, type IncompleteFlowStep } from '../errors.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 固定夹具:与流程门控无关的完整字段(仅当对应步骤"已完成"时才注入评估)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 一个合法的费用测算结果,仅在"费用测算已完成"时注入。 */
|
||||
const COST_ESTIMATE: CostEstimate = {
|
||||
riskPremiumRange: { lower: 0, upper: 10, unit: '百分比' },
|
||||
advanceInterest: 0,
|
||||
insuranceCost: 0,
|
||||
compensationReserve: 0,
|
||||
badDebtReserve: 0,
|
||||
baselineQuote: 1000,
|
||||
riskAdjustedQuote: 1000,
|
||||
breakdown: [],
|
||||
};
|
||||
|
||||
/** 评估元数据(检索索引字段,与流程门控无关)。 */
|
||||
const METADATA: AssessmentMetadata = {
|
||||
businessType: '岗位外包',
|
||||
industry: '未识别',
|
||||
region: REGION_CN,
|
||||
riskScore: 50,
|
||||
riskGrade: '高',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
assessorId: 'assessor-1',
|
||||
};
|
||||
|
||||
/**
|
||||
* 红线集合:一个**启用**红线 r1(其结果缺失即令红线校验未完成)与一个**停用**红线 r2
|
||||
* (永不影响完成判定)。据此可独立控制"红线校验"步骤的完成与否。
|
||||
*/
|
||||
const REDLINES: Redline[] = [
|
||||
{ id: 'r1', triggerCondition: '条件1', consequence: '一票否决', enabled: true },
|
||||
{ id: 'r2', triggerCondition: '条件2', consequence: '一票否决', enabled: false },
|
||||
];
|
||||
|
||||
const RISK_MODEL: RiskModel = {
|
||||
id: 'm1',
|
||||
name: '风险模型',
|
||||
businessType: '岗位外包',
|
||||
dimensions: [],
|
||||
redlines: REDLINES,
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 风险评分步骤的完成形态:both=已完成;其余三态均为未完成。 */
|
||||
type ScoringShape = 'both' | 'scoreOnly' | 'gradeOnly' | 'none';
|
||||
|
||||
interface FlowFlags {
|
||||
/** 风险评分形态。 */
|
||||
scoring: ScoringShape;
|
||||
/** 红线校验是否完成(启用红线 r1 是否有结果)。 */
|
||||
redline: boolean;
|
||||
/** 费用测算是否完成。 */
|
||||
cost: boolean;
|
||||
/** 可接受性结论是否完成。 */
|
||||
acceptability: boolean;
|
||||
}
|
||||
|
||||
/** 判定该组合是否"四步全部完成"(即流程已完成、不应抛错)。 */
|
||||
function isAllComplete(flags: FlowFlags): boolean {
|
||||
return flags.scoring === 'both' && flags.redline && flags.cost && flags.acceptability;
|
||||
}
|
||||
|
||||
/** 任意"至少一步未完成"的流程状态组合。 */
|
||||
const incompleteFlagsArb: fc.Arbitrary<FlowFlags> = fc
|
||||
.record<FlowFlags>({
|
||||
scoring: fc.constantFrom<ScoringShape>('both', 'scoreOnly', 'gradeOnly', 'none'),
|
||||
redline: fc.boolean(),
|
||||
cost: fc.boolean(),
|
||||
acceptability: fc.boolean(),
|
||||
})
|
||||
.filter((flags) => !isAllComplete(flags));
|
||||
|
||||
/** 由流程标志构造一份评估记录;仅对"已完成"的步骤注入对应结果字段。 */
|
||||
function buildAssessment(flags: FlowFlags): Assessment {
|
||||
const scoringFields =
|
||||
flags.scoring === 'both'
|
||||
? { riskScore: 50, riskGrade: '高' as RiskGrade }
|
||||
: flags.scoring === 'scoreOnly'
|
||||
? { riskScore: 50 }
|
||||
: flags.scoring === 'gradeOnly'
|
||||
? { riskGrade: '高' as RiskGrade }
|
||||
: {};
|
||||
|
||||
const redlineResults: RedlineResult[] = flags.redline
|
||||
? [{ redlineId: 'r1', status: '未命中' }]
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: 'a1',
|
||||
projectDescription: '某外包项目',
|
||||
businessType: '岗位外包',
|
||||
industry: '未识别',
|
||||
region: REGION_CN,
|
||||
riskModel: RISK_MODEL,
|
||||
scoringItems: [],
|
||||
redlineResults,
|
||||
metadata: METADATA,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
assessorId: 'assessor-1',
|
||||
...scoringFields,
|
||||
...(flags.cost ? { costEstimate: COST_ESTIMATE } : {}),
|
||||
...(flags.acceptability ? { acceptability: '可接受' as const } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 期望缺失步骤集合(顺序与实现 `collectMissingSteps` 一致)。 */
|
||||
function expectedMissingSteps(flags: FlowFlags): IncompleteFlowStep[] {
|
||||
const missing: IncompleteFlowStep[] = [];
|
||||
if (flags.scoring !== 'both') missing.push('风险评分');
|
||||
if (!flags.redline) missing.push('红线校验');
|
||||
if (!flags.cost) missing.push('费用测算');
|
||||
if (!flags.acceptability) missing.push('可接受性结论');
|
||||
return missing;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 38: 流程未完成拒绝报告 (Req 10.4)', () => {
|
||||
it('对任意流程未完成的评估,generate 必抛 FlowNotCompleteError 并提示流程尚未完成,缺失步骤准确', () => {
|
||||
fc.assert(
|
||||
fc.property(incompleteFlagsArb, (flags) => {
|
||||
const assessment = buildAssessment(flags);
|
||||
const expected = expectedMissingSteps(flags);
|
||||
|
||||
// 前置:构造的状态确实"至少一步未完成"。
|
||||
expect(expected.length).toBeGreaterThan(0);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
generate(assessment);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
// 1) 必被拒绝并抛出语义化的流程未完成错误。
|
||||
expect(caught).toBeInstanceOf(FlowNotCompleteError);
|
||||
const error = caught as FlowNotCompleteError;
|
||||
|
||||
// 2) 面向评估者的提示固定包含"评估流程尚未完成"。
|
||||
expect(error.userMessage).toContain('评估流程尚未完成');
|
||||
expect(error.message).toContain('评估流程尚未完成');
|
||||
|
||||
// 3) 缺失步骤清单恰好等于实际缺失的前置步骤集合(顺序无关)。
|
||||
expect([...error.missingSteps].sort()).toEqual([...expected].sort());
|
||||
expect(error.missingSteps.length).toBeGreaterThan(0);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Property 25: 命中红线列入报告 的属性化测试(Report_Generator,Req 6.3)。
|
||||
*
|
||||
* 属性陈述:*对任意*命中红线的评估,`generate` 产出的报告*必*列出每个被命中红线,
|
||||
* 且每个命中红线条目均携带其**被触发的条件**(triggeredCondition)与**对应判定依据数据**
|
||||
* (evidenceData),二者恒为非空。
|
||||
*
|
||||
* 完成判定(与实现 `collectMissingSteps` 对齐):风险评分、红线校验(每个启用红线均有结果)、
|
||||
* 费用测算、可接受性结论均已产生,以便报告可被生成。
|
||||
*
|
||||
* 本测试构造"至少命中一条红线"的已完成评估,并刻意覆盖命中结果中
|
||||
* triggeredCondition / evidenceData 的多种形态(自带非空、自带空白、缺省),
|
||||
* 以检验报告侧恒回退到模型中红线的触发条件 / 一票否决后果,从而保证两字段非空。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 25: 命中红线列入报告
|
||||
* Validates: Requirements 6.3
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
ACCEPTABILITY_VALUES,
|
||||
type Acceptability,
|
||||
type Assessment,
|
||||
type RedlineResult,
|
||||
} from '../../domain/assessment.js';
|
||||
import { RISK_GRADE_VALUES, RISK_LEVEL_VALUES, type RiskGrade, type RiskLevel } from '../../domain/common.js';
|
||||
import type { CostEstimate } from '../../domain/cost.js';
|
||||
import type { Redline, RiskModel, ScoringRule } from '../../domain/model.js';
|
||||
import { REGION_CN } from '../../domain/region.js';
|
||||
import { generate } from '../report.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 固定夹具
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 合法 Indicator 须覆盖 Risk_Level 1-5(Req 11.3);本测试不含指标,故仅备用。 */
|
||||
const scoringRules: ScoringRule[] = (RISK_LEVEL_VALUES as readonly RiskLevel[]).map((level) => ({
|
||||
level,
|
||||
label: `L${level}`,
|
||||
description: `规则${level}`,
|
||||
}));
|
||||
void scoringRules;
|
||||
|
||||
const COST_ESTIMATE: CostEstimate = {
|
||||
riskPremiumRange: { lower: 0, upper: 10, unit: '百分比' },
|
||||
advanceInterest: 0,
|
||||
insuranceCost: 0,
|
||||
compensationReserve: 0,
|
||||
badDebtReserve: 0,
|
||||
baselineQuote: 1000,
|
||||
riskAdjustedQuote: 1000,
|
||||
breakdown: [],
|
||||
};
|
||||
|
||||
const riskGradeArb = fc.constantFrom<RiskGrade>(...(RISK_GRADE_VALUES as readonly RiskGrade[]));
|
||||
const acceptabilityArb = fc.constantFrom<Acceptability>(
|
||||
...(ACCEPTABILITY_VALUES as readonly Acceptability[]),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:构造"至少命中一条红线"的已完成评估
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 命中红线结果中 triggeredCondition / evidenceData 的形态。 */
|
||||
type EvidenceShape = 'own' | 'blank' | 'absent';
|
||||
const evidenceShapeArb = fc.constantFrom<EvidenceShape>('own', 'blank', 'absent');
|
||||
|
||||
/** 单条红线规格:是否命中,以及命中时其结果字段的形态。 */
|
||||
interface RawRedline {
|
||||
/** 命中(true)或未命中(false)。所有红线均启用。 */
|
||||
hit: boolean;
|
||||
/** 命中结果中触发条件/判定依据数据的形态。 */
|
||||
shape: EvidenceShape;
|
||||
}
|
||||
|
||||
const rawRedlineArb: fc.Arbitrary<RawRedline> = fc.record({
|
||||
hit: fc.boolean(),
|
||||
shape: evidenceShapeArb,
|
||||
});
|
||||
|
||||
interface AssessmentSpec {
|
||||
/** 其余红线(命中/未命中、各形态任意)。 */
|
||||
redlines: RawRedline[];
|
||||
/** 强制命中的那一条的形态,保证"至少命中一条"。 */
|
||||
forcedHitShape: EvidenceShape;
|
||||
riskScore: number;
|
||||
riskGrade: RiskGrade;
|
||||
acceptability: Acceptability;
|
||||
}
|
||||
|
||||
const assessmentSpecArb: fc.Arbitrary<AssessmentSpec> = fc.record({
|
||||
redlines: fc.array(rawRedlineArb, { minLength: 0, maxLength: 5 }),
|
||||
forcedHitShape: evidenceShapeArb,
|
||||
riskScore: fc.integer({ min: 0, max: 100 }),
|
||||
riskGrade: riskGradeArb,
|
||||
acceptability: acceptabilityArb,
|
||||
});
|
||||
|
||||
/** 由形态构造一条命中红线的校验结果(命中时按形态填充/留空触发条件与判定依据数据)。 */
|
||||
function buildHitResult(id: string, k: number, shape: EvidenceShape): RedlineResult {
|
||||
const base: RedlineResult = { redlineId: id, status: '命中' };
|
||||
switch (shape) {
|
||||
case 'own':
|
||||
return { ...base, triggeredCondition: `命中触发条件${k}`, evidenceData: `命中判定依据数据${k}` };
|
||||
case 'blank':
|
||||
// 空白字段应触发报告侧回退到模型红线的触发条件 / 一票否决后果。
|
||||
return { ...base, triggeredCondition: ' ', evidenceData: '' };
|
||||
case 'absent':
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一份已完成的 Assessment:强制存在一条命中红线(索引 0),其余红线随机命中/未命中;
|
||||
* 每个启用红线均有对应校验结果,故红线校验视为完成;评分/费用/可接受性均已产生。
|
||||
*/
|
||||
function buildAssessment(spec: AssessmentSpec): Assessment {
|
||||
const rawAll: RawRedline[] = [{ hit: true, shape: spec.forcedHitShape }, ...spec.redlines];
|
||||
|
||||
const redlines: Redline[] = rawAll.map((_, k) => ({
|
||||
id: `r${k}`,
|
||||
triggerCondition: `模型触发条件${k}`,
|
||||
consequence: `一票否决后果${k}`,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
const redlineResults: RedlineResult[] = rawAll.map((r, k) => {
|
||||
if (r.hit) return buildHitResult(`r${k}`, k, r.shape);
|
||||
return { redlineId: `r${k}`, status: '未命中' };
|
||||
});
|
||||
|
||||
const riskModel: RiskModel = {
|
||||
id: 'm1',
|
||||
name: '风险模型',
|
||||
businessType: '岗位外包',
|
||||
dimensions: [],
|
||||
redlines,
|
||||
};
|
||||
|
||||
return {
|
||||
id: 'a1',
|
||||
projectDescription: '某外包项目风险评估',
|
||||
businessType: '岗位外包',
|
||||
industry: '制造业',
|
||||
region: REGION_CN,
|
||||
riskModel,
|
||||
scoringItems: [],
|
||||
riskScore: spec.riskScore,
|
||||
riskGrade: spec.riskGrade,
|
||||
redlineResults,
|
||||
costEstimate: COST_ESTIMATE,
|
||||
acceptability: spec.acceptability,
|
||||
metadata: {
|
||||
businessType: '岗位外包',
|
||||
industry: '制造业',
|
||||
region: REGION_CN,
|
||||
riskScore: spec.riskScore,
|
||||
riskGrade: spec.riskGrade,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
assessorId: 'assessor-1',
|
||||
},
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
assessorId: 'assessor-1',
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 25: 命中红线列入报告 (Req 6.3)', () => {
|
||||
it('对任意命中红线的评估,报告列出每个被命中红线及其非空触发条件与判定依据数据', () => {
|
||||
fc.assert(
|
||||
fc.property(assessmentSpecArb, (spec) => {
|
||||
const assessment = buildAssessment(spec);
|
||||
const report = generate(assessment);
|
||||
const { hitRedlines } = report.keyRisksAndRedlines;
|
||||
|
||||
// 实际命中的红线标识集合(来源于已完成评估的红线校验结果)。
|
||||
const expectedHitIds = assessment.redlineResults
|
||||
.filter((r) => r.status === '命中')
|
||||
.map((r) => r.redlineId)
|
||||
.sort();
|
||||
|
||||
// 前置:构造保证至少命中一条红线。
|
||||
expect(expectedHitIds.length).toBeGreaterThan(0);
|
||||
|
||||
// 1) 报告恰好列出每个被命中红线(不漏不增,一一对应)。
|
||||
const reportedHitIds = hitRedlines.map((h) => h.redlineId).sort();
|
||||
expect(reportedHitIds).toEqual(expectedHitIds);
|
||||
expect(report.keyRisksAndRedlines.hasRedlineHit).toBe(true);
|
||||
|
||||
// 2) 每个命中红线条目均携带非空触发条件与非空判定依据数据,状态恒为"命中"。
|
||||
for (const hit of hitRedlines) {
|
||||
expect(hit.status).toBe('命中');
|
||||
expect(hit.triggeredCondition.trim().length).toBeGreaterThan(0);
|
||||
expect(hit.evidenceData.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Property 36: 报告章节完备 的属性化测试(Report_Generator,Req 10.1)。
|
||||
*
|
||||
* 属性陈述:对任意风险评分、红线校验、费用测算与可接受性结论均已完成的评估,
|
||||
* `generate` 产出的报告*必*包含全部规定章节(项目概要与业务类型判定、风险总分与分级、
|
||||
* 风险热力图、各维度风险明细、Top 关键风险与红线校验结果、可接受性结论、应对方案、
|
||||
* 假设与信息缺口说明);且未命中任一红线时,红线校验结果*必*明确标注为"无红线命中"。
|
||||
*
|
||||
* 本测试在生成器中构造"完整 Assessment"(评分/红线/费用/可接受性结论均已产生),
|
||||
* 覆盖启用/停用维度与指标、混合数据来源(含"智能体假设")、有/无红线命中等形态,
|
||||
* 对 `generate` 施加上述章节完备性与"无红线命中"标注约束。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 36: 报告章节完备
|
||||
* Validates: Requirements 10.1
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
ACCEPTABILITY_VALUES,
|
||||
REDLINE_STATUS_VALUES,
|
||||
type Acceptability,
|
||||
type Assessment,
|
||||
type RedlineResult,
|
||||
type RedlineStatus,
|
||||
type ScoringItem,
|
||||
} from '../../domain/assessment.js';
|
||||
import {
|
||||
DATA_PROVENANCE_VALUES,
|
||||
RISK_GRADE_VALUES,
|
||||
RISK_LEVEL_VALUES,
|
||||
type DataProvenance,
|
||||
type RiskGrade,
|
||||
type RiskLevel,
|
||||
} from '../../domain/common.js';
|
||||
import type { CostEstimate } from '../../domain/cost.js';
|
||||
import type { Dimension, Indicator, Redline, RiskModel, ScoringRule } from '../../domain/model.js';
|
||||
import { REGION_CN } from '../../domain/region.js';
|
||||
import { generate, NO_REDLINE_HIT_LABEL } from '../report.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:构造"评分/红线/费用/可接受性结论均已完成"的 Assessment
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 合法 Indicator 须覆盖 Risk_Level 1-5(Req 11.3);报告不读取规则文本。 */
|
||||
const scoringRules: ScoringRule[] = (RISK_LEVEL_VALUES as readonly RiskLevel[]).map(
|
||||
(level) => ({ level, label: `L${level}`, description: `规则${level}` }),
|
||||
);
|
||||
|
||||
const riskLevelArb = fc.constantFrom<RiskLevel>(...(RISK_LEVEL_VALUES as readonly RiskLevel[]));
|
||||
const provenanceArb = fc.constantFrom<DataProvenance>(
|
||||
...(DATA_PROVENANCE_VALUES as readonly DataProvenance[]),
|
||||
);
|
||||
const riskGradeArb = fc.constantFrom<RiskGrade>(...(RISK_GRADE_VALUES as readonly RiskGrade[]));
|
||||
const acceptabilityArb = fc.constantFrom<Acceptability>(
|
||||
...(ACCEPTABILITY_VALUES as readonly Acceptability[]),
|
||||
);
|
||||
const redlineStatusArb = fc.constantFrom<RedlineStatus>(
|
||||
...(REDLINE_STATUS_VALUES as readonly RedlineStatus[]),
|
||||
);
|
||||
|
||||
/** 置信度:[0,1] 两位小数(Req 4.6, 18.4)。 */
|
||||
const confidenceArb = fc.integer({ min: 0, max: 100 }).map((n) => n / 100);
|
||||
const weightArb = fc.double({ min: 0, max: 100, noNaN: true });
|
||||
const moneyArb = fc.double({ min: 0, max: 1_000_000, noNaN: true });
|
||||
|
||||
interface RawIndicator {
|
||||
enabled: boolean;
|
||||
weight: number;
|
||||
riskLevel: RiskLevel;
|
||||
provenance: DataProvenance;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface RawDimension {
|
||||
enabled: boolean;
|
||||
weight: number;
|
||||
indicators: RawIndicator[];
|
||||
}
|
||||
|
||||
interface RawRedline {
|
||||
enabled: boolean;
|
||||
status: RedlineStatus;
|
||||
}
|
||||
|
||||
const rawIndicatorArb: fc.Arbitrary<RawIndicator> = fc.record({
|
||||
enabled: fc.boolean(),
|
||||
weight: weightArb,
|
||||
riskLevel: riskLevelArb,
|
||||
provenance: provenanceArb,
|
||||
confidence: confidenceArb,
|
||||
});
|
||||
|
||||
const rawDimensionArb: fc.Arbitrary<RawDimension> = fc.record({
|
||||
enabled: fc.boolean(),
|
||||
weight: weightArb,
|
||||
indicators: fc.array(rawIndicatorArb, { minLength: 0, maxLength: 4 }),
|
||||
});
|
||||
|
||||
const rawRedlineArb: fc.Arbitrary<RawRedline> = fc.record({
|
||||
enabled: fc.boolean(),
|
||||
status: redlineStatusArb,
|
||||
});
|
||||
|
||||
const costEstimateArb: fc.Arbitrary<CostEstimate> = fc
|
||||
.record({
|
||||
baseline: moneyArb,
|
||||
premiumLower: fc.double({ min: 0, max: 50, noNaN: true }),
|
||||
premiumDelta: fc.double({ min: 0, max: 50, noNaN: true }),
|
||||
})
|
||||
.map(({ baseline, premiumLower, premiumDelta }) => ({
|
||||
riskPremiumRange: { lower: premiumLower, upper: premiumLower + premiumDelta, unit: '百分比' },
|
||||
advanceInterest: 0,
|
||||
insuranceCost: 0,
|
||||
compensationReserve: 0,
|
||||
badDebtReserve: 0,
|
||||
baselineQuote: baseline,
|
||||
riskAdjustedQuote: baseline,
|
||||
breakdown: [],
|
||||
}));
|
||||
|
||||
interface AssessmentSpec {
|
||||
dimensions: RawDimension[];
|
||||
redlines: RawRedline[];
|
||||
riskScore: number;
|
||||
riskGrade: RiskGrade;
|
||||
acceptability: Acceptability;
|
||||
costEstimate: CostEstimate;
|
||||
/** 强制构造"无红线命中"情形,确保该分支被充分覆盖。 */
|
||||
forceNoHit: boolean;
|
||||
}
|
||||
|
||||
const assessmentSpecArb: fc.Arbitrary<AssessmentSpec> = fc.record({
|
||||
dimensions: fc.array(rawDimensionArb, { minLength: 0, maxLength: 4 }),
|
||||
redlines: fc.array(rawRedlineArb, { minLength: 0, maxLength: 5 }),
|
||||
riskScore: fc.integer({ min: 0, max: 100 }),
|
||||
riskGrade: riskGradeArb,
|
||||
acceptability: acceptabilityArb,
|
||||
costEstimate: costEstimateArb,
|
||||
forceNoHit: fc.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 由随机规格构造一个"已完成"的 Assessment:
|
||||
* - 风险模型保留全部随机形态(启用/停用维度与指标、空指标维度、权重为 0 等);
|
||||
* - 每个 Indicator 均有对应 ScoringItem(含来源/置信/三要素);
|
||||
* - 每个**启用**红线均有对应的红线校验结果(满足红线校验完成条件);
|
||||
* - riskScore/riskGrade/costEstimate/acceptability 均已产生。
|
||||
*/
|
||||
function buildAssessment(spec: AssessmentSpec): Assessment {
|
||||
const dimensions: Dimension[] = spec.dimensions.map((d, i) => ({
|
||||
id: `d${i}`,
|
||||
name: `维度${i}`,
|
||||
weight: d.weight,
|
||||
enabled: d.enabled,
|
||||
indicators: d.indicators.map(
|
||||
(ind, j): Indicator => ({
|
||||
id: `i${j}`,
|
||||
name: `指标${i}-${j}`,
|
||||
weight: ind.weight,
|
||||
enabled: ind.enabled,
|
||||
scoringRules,
|
||||
evidenceRequired: '',
|
||||
askPrompt: '',
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
const redlines: Redline[] = spec.redlines.map((r, k) => ({
|
||||
id: `r${k}`,
|
||||
triggerCondition: `触发条件${k}`,
|
||||
consequence: `后果${k}`,
|
||||
enabled: r.enabled,
|
||||
}));
|
||||
|
||||
const riskModel: RiskModel = {
|
||||
id: 'm1',
|
||||
name: '风险模型',
|
||||
businessType: '岗位外包',
|
||||
dimensions,
|
||||
redlines,
|
||||
};
|
||||
|
||||
// 为每个指标构造评分项(含停用项;报告以对象引用映射,停用项不进入热力图/TopN)。
|
||||
const scoringItems: ScoringItem[] = spec.dimensions.flatMap((d, i) =>
|
||||
d.indicators.map((ind, j): ScoringItem => ({
|
||||
dimensionId: `d${i}`,
|
||||
indicatorId: `i${j}`,
|
||||
riskLevel: ind.riskLevel,
|
||||
score: ind.riskLevel * ind.weight,
|
||||
provenance: ind.provenance,
|
||||
confidence: ind.confidence,
|
||||
rationale: `判定依据${i}-${j}`,
|
||||
riskImpact: `风险影响${i}-${j}`,
|
||||
recommendation: `建议${i}-${j}`,
|
||||
})),
|
||||
);
|
||||
|
||||
// 每个启用红线产生一个红线校验结果(满足红线校验完成条件)。
|
||||
const redlineResults: RedlineResult[] = spec.redlines
|
||||
.map((r, k): { enabled: boolean; result: RedlineResult } => {
|
||||
const status: RedlineStatus =
|
||||
spec.forceNoHit && r.status === '命中' ? '未命中' : r.status;
|
||||
const base: RedlineResult = { redlineId: `r${k}`, status };
|
||||
const result: RedlineResult =
|
||||
status === '命中'
|
||||
? { ...base, triggeredCondition: `触发条件${k}`, evidenceData: `判定依据数据${k}` }
|
||||
: base;
|
||||
return { enabled: r.enabled, result };
|
||||
})
|
||||
.filter((entry) => entry.enabled)
|
||||
.map((entry) => entry.result);
|
||||
|
||||
return {
|
||||
id: 'a1',
|
||||
projectDescription: '某外包项目风险评估',
|
||||
businessType: '岗位外包',
|
||||
industry: '制造业',
|
||||
region: REGION_CN,
|
||||
riskModel,
|
||||
scoringItems,
|
||||
riskScore: spec.riskScore,
|
||||
riskGrade: spec.riskGrade,
|
||||
redlineResults,
|
||||
costEstimate: spec.costEstimate,
|
||||
acceptability: spec.acceptability,
|
||||
metadata: {
|
||||
businessType: '岗位外包',
|
||||
industry: '制造业',
|
||||
region: REGION_CN,
|
||||
riskScore: spec.riskScore,
|
||||
riskGrade: spec.riskGrade,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
assessorId: 'assessor-1',
|
||||
},
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
assessorId: 'assessor-1',
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 36: 报告章节完备 (Req 10.1)', () => {
|
||||
it('对任意已完成评估,报告含全部规定章节;未命中任一红线时明确标注"无红线命中"', () => {
|
||||
fc.assert(
|
||||
fc.property(assessmentSpecArb, (spec) => {
|
||||
const assessment = buildAssessment(spec);
|
||||
const report = generate(assessment);
|
||||
|
||||
// 1) 全部八个规定章节均存在,且各章节标题非空。
|
||||
expect(report.projectOverview).toBeDefined();
|
||||
expect(report.projectOverview.title.length).toBeGreaterThan(0);
|
||||
expect(report.riskScoreGrade).toBeDefined();
|
||||
expect(report.riskScoreGrade.title.length).toBeGreaterThan(0);
|
||||
expect(report.heatmap).toBeDefined();
|
||||
expect(report.heatmap.title.length).toBeGreaterThan(0);
|
||||
expect(report.dimensionDetails).toBeDefined();
|
||||
expect(report.dimensionDetails.title.length).toBeGreaterThan(0);
|
||||
expect(report.keyRisksAndRedlines).toBeDefined();
|
||||
expect(report.keyRisksAndRedlines.title.length).toBeGreaterThan(0);
|
||||
expect(report.acceptabilityConclusion).toBeDefined();
|
||||
expect(report.acceptabilityConclusion.title.length).toBeGreaterThan(0);
|
||||
expect(report.responsePlan).toBeDefined();
|
||||
expect(report.responsePlan.title.length).toBeGreaterThan(0);
|
||||
expect(report.assumptionsAndGaps).toBeDefined();
|
||||
expect(report.assumptionsAndGaps.title.length).toBeGreaterThan(0);
|
||||
|
||||
// 2) 章节内容与已完成评估一致的基本结构。
|
||||
expect(report.riskScoreGrade.riskScore).toBe(assessment.riskScore);
|
||||
expect(report.riskScoreGrade.riskGrade).toBe(assessment.riskGrade);
|
||||
expect(report.acceptabilityConclusion.acceptability).toBe(assessment.acceptability);
|
||||
expect(Array.isArray(report.heatmap.cells)).toBe(true);
|
||||
expect(report.dimensionDetails.dimensions.length).toBe(
|
||||
assessment.riskModel.dimensions.length,
|
||||
);
|
||||
expect(report.keyRisksAndRedlines.redlineResults.length).toBe(
|
||||
assessment.redlineResults.length,
|
||||
);
|
||||
expect(Array.isArray(report.assumptionsAndGaps.items)).toBe(true);
|
||||
|
||||
// 3) 红线命中与"无红线命中"标注。
|
||||
const anyHit = assessment.redlineResults.some((r) => r.status === '命中');
|
||||
if (anyHit) {
|
||||
expect(report.keyRisksAndRedlines.hasRedlineHit).toBe(true);
|
||||
expect(report.keyRisksAndRedlines.hitRedlines.length).toBe(
|
||||
assessment.redlineResults.filter((r) => r.status === '命中').length,
|
||||
);
|
||||
expect(report.keyRisksAndRedlines.redlineSummary).not.toBe(NO_REDLINE_HIT_LABEL);
|
||||
} else {
|
||||
expect(report.keyRisksAndRedlines.hasRedlineHit).toBe(false);
|
||||
expect(report.keyRisksAndRedlines.hitRedlines.length).toBe(0);
|
||||
expect(report.keyRisksAndRedlines.redlineSummary).toBe(NO_REDLINE_HIT_LABEL);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Report_Generator 报告生成错误类型(Req 10.4)。
|
||||
*
|
||||
* 当评估流程尚未完成(风险评分、红线校验、费用测算、可接受性结论中任一未生成)时,
|
||||
* Report_Generator 拒绝生成或导出报告并抛出语义化错误,供上层捕获并向评估者返回
|
||||
* "评估流程尚未完成"的提示。错误处理遵循统一原则:前置校验、错误可解释、失败不产生
|
||||
* 不完整的报告。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 评估流程未完成的步骤标识。
|
||||
* 用于在错误信息中明确指出哪些前置步骤尚未完成。
|
||||
*/
|
||||
export type IncompleteFlowStep = '风险评分' | '红线校验' | '费用测算' | '可接受性结论';
|
||||
|
||||
/**
|
||||
* 流程未完成错误(Req 10.4)。
|
||||
*
|
||||
* 当请求生成或导出报告时,本次 Assessment 的风险评分、红线校验、费用测算或可接受性
|
||||
* 结论中存在任一未完成项时抛出。Report_Generator 据此拒绝该请求并返回指示评估流程
|
||||
* 尚未完成的提示。
|
||||
*/
|
||||
export class FlowNotCompleteError extends Error {
|
||||
/** 面向评估者的可读提示,固定包含"评估流程尚未完成"。 */
|
||||
readonly userMessage: string;
|
||||
|
||||
/** 尚未完成的前置步骤列表,便于定位缺失环节。 */
|
||||
readonly missingSteps: readonly IncompleteFlowStep[];
|
||||
|
||||
public constructor(missingSteps: readonly IncompleteFlowStep[]) {
|
||||
const detail =
|
||||
missingSteps.length > 0 ? `(缺失:${missingSteps.join('、')})` : '';
|
||||
const message = `评估流程尚未完成,无法生成报告${detail}:请先完成风险评分、红线校验、费用测算与可接受性结论`;
|
||||
super(message);
|
||||
this.name = 'FlowNotCompleteError';
|
||||
this.userMessage = message;
|
||||
this.missingSteps = [...missingSteps];
|
||||
// 维持 instanceof 在编译目标下正确工作。
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Report_Generator 报告导出(Req 10.2, 10.5)。
|
||||
*
|
||||
* 本模块实现 `export(report, format?, options?): DownloadFile`:将一份已生成的
|
||||
* {@link Report} 序列化为**完整且自包含**的可下载文件,包含报告全部八个规定章节
|
||||
* (Req 10.1 所列内容)。导出为同步、确定性、无副作用的纯函数,以满足"请求后 30 秒内
|
||||
* 导出完整自包含文件"的契约(Req 10.2)——序列化在内存中即时完成,不依赖外部 I/O。
|
||||
*
|
||||
* 失败语义(Req 10.5):当序列化/渲染过程失败时,导出**中止**,**不对入参 report 作任何修改**
|
||||
* (导出为纯函数,天然不改动入参),并抛出语义化的 {@link ExportFailedError}(携带
|
||||
* 面向评估者的 `userMessage`),供上层捕获后向评估者返回"导出失败"提示。失败时不产生
|
||||
* 任何部分写出的 {@link DownloadFile}。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 支持两种自包含格式:`json`(默认,机器可读)与 `html`(人类可读、内联样式、无外部依赖)。
|
||||
* 两种格式均包含全部八个章节的完整数据。
|
||||
* - 渲染器可注入({@link ExportOptions.renderer}),便于在测试中模拟导出失败而无需改动数据。
|
||||
*/
|
||||
|
||||
import type { Report } from './report.js';
|
||||
|
||||
/** 支持的导出格式(均为完整自包含文件)。 */
|
||||
export type ReportFormat = 'json' | 'html';
|
||||
|
||||
/** 默认导出格式。 */
|
||||
export const DEFAULT_REPORT_FORMAT: ReportFormat = 'json';
|
||||
|
||||
/**
|
||||
* 可下载文件(DownloadFile,Req 10.2)。
|
||||
*
|
||||
* 自包含的导出产物:`content` 为完整文件正文(不引用任何外部资源),可由上层直接
|
||||
* 触发浏览器下载或写入磁盘。
|
||||
*/
|
||||
export interface DownloadFile {
|
||||
/** 建议的下载文件名(含扩展名)。 */
|
||||
filename: string;
|
||||
/** 文件 MIME 类型。 */
|
||||
mimeType: string;
|
||||
/** 完整、自包含的文件正文。 */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告渲染器:将报告与目标格式序列化为文件正文字符串。
|
||||
*
|
||||
* 可注入以替换默认序列化逻辑(如测试中模拟渲染失败)。渲染器若抛出异常,
|
||||
* `export` 将其归一化为 {@link ExportFailedError}。
|
||||
*/
|
||||
export type ReportRenderer = (report: Report, format: ReportFormat) => string;
|
||||
|
||||
/** `export` 的可选项。 */
|
||||
export interface ExportOptions {
|
||||
/** 自定义文件名(不含扩展名时按格式补全扩展名)。默认 `risk-assessment-report`。 */
|
||||
filename?: string;
|
||||
/** 自定义渲染器;缺省时使用内置 JSON/HTML 渲染器。 */
|
||||
renderer?: ReportRenderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告导出失败错误(Req 10.5)。
|
||||
*
|
||||
* 当报告导出过程失败时抛出。携带面向评估者的可读提示 {@link userMessage}(固定包含
|
||||
* "报告导出失败"),并通过标准 `cause` 保留底层原因以便排查。抛出本错误即表示本次导出
|
||||
* 已中止且未产生任何输出文件;入参 report 未被修改。
|
||||
*/
|
||||
export class ExportFailedError extends Error {
|
||||
/** 面向评估者的可读提示,固定包含"报告导出失败"。 */
|
||||
readonly userMessage: string;
|
||||
|
||||
public constructor(detail?: string, options?: { cause?: unknown }) {
|
||||
const message =
|
||||
detail !== undefined && detail.length > 0
|
||||
? `报告导出失败:${detail}`
|
||||
: '报告导出失败';
|
||||
super(message, options);
|
||||
this.name = 'ExportFailedError';
|
||||
this.userMessage = message;
|
||||
// 维持 instanceof 在编译目标下正确工作。
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/** 各格式对应的 MIME 类型与文件扩展名。 */
|
||||
const FORMAT_META: Record<ReportFormat, { mimeType: string; extension: string }> = {
|
||||
json: { mimeType: 'application/json;charset=utf-8', extension: 'json' },
|
||||
html: { mimeType: 'text/html;charset=utf-8', extension: 'html' },
|
||||
};
|
||||
|
||||
const DEFAULT_FILENAME_BASE = 'risk-assessment-report';
|
||||
|
||||
/** HTML 转义,避免报告文本内容破坏文档结构。 */
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** 将任意标量渲染为可读字符串。 */
|
||||
function asText(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/** 渲染一个章节:标题 + 其结构化内容(JSON 预格式化,保证完整且自包含)。 */
|
||||
function renderSection(title: string, body: unknown): string {
|
||||
const json = JSON.stringify(body, null, 2);
|
||||
return [
|
||||
' <section>',
|
||||
` <h2>${escapeHtml(title)}</h2>`,
|
||||
` <pre>${escapeHtml(json)}</pre>`,
|
||||
' </section>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置 HTML 渲染器:生成自包含的 HTML 文档(内联样式、无外部资源引用),
|
||||
* 依次呈现报告全部八个规定章节(Req 10.1)。
|
||||
*/
|
||||
function renderHtml(report: Report): string {
|
||||
const sections: ReadonlyArray<{ title: string; body: unknown }> = [
|
||||
{ title: report.projectOverview.title, body: report.projectOverview },
|
||||
{ title: report.riskScoreGrade.title, body: report.riskScoreGrade },
|
||||
{ title: report.heatmap.title, body: report.heatmap },
|
||||
{ title: report.dimensionDetails.title, body: report.dimensionDetails },
|
||||
{ title: report.keyRisksAndRedlines.title, body: report.keyRisksAndRedlines },
|
||||
{ title: report.acceptabilityConclusion.title, body: report.acceptabilityConclusion },
|
||||
{ title: report.responsePlan.title, body: report.responsePlan },
|
||||
{ title: report.assumptionsAndGaps.title, body: report.assumptionsAndGaps },
|
||||
];
|
||||
|
||||
const grade = asText(report.riskScoreGrade.riskGrade);
|
||||
const score = asText(report.riskScoreGrade.riskScore);
|
||||
|
||||
return [
|
||||
'<!DOCTYPE html>',
|
||||
'<html lang="zh-CN">',
|
||||
' <head>',
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
' <title>外包风险评估报告</title>',
|
||||
' <style>',
|
||||
' body { font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; margin: 2rem; color: #1f2937; line-height: 1.6; }',
|
||||
' h1 { font-size: 1.6rem; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.5rem; }',
|
||||
' h2 { font-size: 1.2rem; margin-top: 2rem; color: #111827; }',
|
||||
' .summary { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; margin: 1rem 0; }',
|
||||
' pre { background: #f3f4f6; border-radius: 6px; padding: 1rem; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }',
|
||||
' </style>',
|
||||
' </head>',
|
||||
' <body>',
|
||||
' <h1>外包风险评估报告</h1>',
|
||||
' <div class="summary">',
|
||||
` <strong>风险总分:</strong>${escapeHtml(score)} <strong>风险分级:</strong>${escapeHtml(grade)}`,
|
||||
' </div>',
|
||||
...sections.map((section) => renderSection(section.title, section.body)),
|
||||
' </body>',
|
||||
'</html>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** 内置默认渲染器:按格式输出 JSON 或 HTML。 */
|
||||
function defaultRenderer(report: Report, format: ReportFormat): string {
|
||||
if (format === 'html') {
|
||||
return renderHtml(report);
|
||||
}
|
||||
return JSON.stringify(report, null, 2);
|
||||
}
|
||||
|
||||
/** 依据 base 与格式构造带扩展名的文件名。 */
|
||||
function buildFilename(base: string, format: ReportFormat): string {
|
||||
const { extension } = FORMAT_META[format];
|
||||
const trimmed = base.trim().length > 0 ? base.trim() : DEFAULT_FILENAME_BASE;
|
||||
const suffix = `.${extension}`;
|
||||
return trimmed.toLowerCase().endsWith(suffix) ? trimmed : `${trimmed}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出报告为完整且自包含的可下载文件(Req 10.2, 10.5)。
|
||||
*
|
||||
* 将已生成的报告同步序列化为目标格式的完整文件,产物为自包含的 {@link DownloadFile}
|
||||
* (正文不引用任何外部资源),包含报告全部八个规定章节(Req 10.1)。导出为纯内存操作,
|
||||
* 即时完成,以满足 30 秒导出契约(Req 10.2)。
|
||||
*
|
||||
* 失败时(渲染器抛错或产生空内容)中止导出、不修改入参 report,并抛出
|
||||
* {@link ExportFailedError}(Req 10.5);不产生任何部分写出的文件。
|
||||
*
|
||||
* @param report 已生成的结构化报告。
|
||||
* @param format 目标格式,默认 {@link DEFAULT_REPORT_FORMAT}(`json`)。
|
||||
* @param options 可选项:自定义文件名与渲染器。
|
||||
* @returns 完整且自包含的可下载文件。
|
||||
* @throws {ExportFailedError} 当序列化/渲染过程失败时。
|
||||
*/
|
||||
export function exportReport(
|
||||
report: Report,
|
||||
format: ReportFormat = DEFAULT_REPORT_FORMAT,
|
||||
options: ExportOptions = {},
|
||||
): DownloadFile {
|
||||
const meta = FORMAT_META[format] as { mimeType: string; extension: string } | undefined;
|
||||
if (meta === undefined) {
|
||||
throw new ExportFailedError(`不支持的导出格式「${String(format)}」`);
|
||||
}
|
||||
|
||||
const render = options.renderer ?? defaultRenderer;
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = render(report, format);
|
||||
} catch (cause) {
|
||||
const detail = cause instanceof Error ? cause.message : asText(cause);
|
||||
throw new ExportFailedError(detail, { cause });
|
||||
}
|
||||
|
||||
if (typeof content !== 'string' || content.length === 0) {
|
||||
throw new ExportFailedError('渲染结果为空');
|
||||
}
|
||||
|
||||
return {
|
||||
filename: buildFilename(options.filename ?? DEFAULT_FILENAME_BASE, format),
|
||||
mimeType: meta.mimeType,
|
||||
content,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Report_Generator 报告生成与导出模块聚合导出(Req 10)。
|
||||
*
|
||||
* 聚合导出报告生成与流程门控(generate、Report 章节类型、"无红线命中"标注)、
|
||||
* 报告导出(export,任务 13.9:DownloadFile、ReportFormat、exportReport)
|
||||
* 及流程未完成错误(FlowNotCompleteError)与导出失败错误(ExportFailedError)。
|
||||
*/
|
||||
|
||||
export * from './report.js';
|
||||
export * from './export.js';
|
||||
export * from './errors.js';
|
||||
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* Report_Generator 报告生成与流程门控(Req 10.1, 10.4)。
|
||||
*
|
||||
* 本模块实现 `generate(assessment): Report`:在本次 Assessment 的**风险评分、红线校验、
|
||||
* 费用测算与可接受性结论均已完成**的前提下,组装一份包含全部规定章节的结构化报告;
|
||||
* 任一前置步骤未完成时拒绝生成并抛出 {@link FlowNotCompleteError}(Req 10.4)。
|
||||
*
|
||||
* 规定章节(Req 10.1):
|
||||
* 1. 项目概要与业务类型判定({@link ProjectOverviewSection})
|
||||
* 2. 风险总分与分级({@link RiskScoreGradeSection})
|
||||
* 3. 风险热力图({@link HeatmapSection})
|
||||
* 4. 各维度风险明细({@link DimensionDetailsSection})
|
||||
* 5. Top 关键风险与红线校验结果({@link KeyRisksAndRedlinesSection})
|
||||
* 6. 可接受性结论({@link AcceptabilitySection})
|
||||
* 7. 应对方案({@link ResponsePlanSection})
|
||||
* 8. 假设与信息缺口说明({@link AssumptionsAndGapsSection})
|
||||
*
|
||||
* 红线校验结果在**未命中任一 Redline** 时明确标注为"无红线命中"
|
||||
* ({@link NO_REDLINE_HIT_LABEL},Req 10.1)。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 报告完全由已完成的 Assessment 数据组装而成,不重新执行评分/红线/费用/策略链路,
|
||||
* 保证报告与各引擎产出一致;热力图与 Top N 复用 Scoring_Engine 的 `buildHeatmap`
|
||||
* 与 `topKeyRisks`,并以**对象引用**为键将各指标的 Risk_Level / 来源 / 置信映射回评分项,
|
||||
* 避免跨维度同名指标标识冲突。
|
||||
* - 纯函数、确定性、无副作用,便于属性化测试约束。
|
||||
*/
|
||||
|
||||
import type { BusinessType, Confidence, DataProvenance, Industry, RiskGrade, RiskLevel, RiskScore } from '../domain/common.js';
|
||||
import type { Indicator, RiskModel } from '../domain/model.js';
|
||||
import type { Region } from '../domain/region.js';
|
||||
import type {
|
||||
Acceptability,
|
||||
Assessment,
|
||||
HeatmapCell,
|
||||
RedlineResult,
|
||||
RiskItem,
|
||||
ScoringItem,
|
||||
} from '../domain/assessment.js';
|
||||
import { buildHeatmap } from '../scoring/buildHeatmap.js';
|
||||
import { topKeyRisks, DEFAULT_TOP_N, type ExplainContextResolver } from '../scoring/topKeyRisks.js';
|
||||
import type { RiskLevelResolver } from '../scoring/scoringEngine.js';
|
||||
import {
|
||||
acceptanceConditions,
|
||||
costMeasures,
|
||||
managementMeasures,
|
||||
type AcceptanceCondition,
|
||||
type Measure,
|
||||
} from '../strategy/index.js';
|
||||
import { FlowNotCompleteError, type IncompleteFlowStep } from './errors.js';
|
||||
|
||||
/**
|
||||
* 红线未命中时的明确标注(Req 10.1)。
|
||||
* 当本次评估未命中任一启用红线时,红线校验结果以此标签呈现。
|
||||
*/
|
||||
export const NO_REDLINE_HIT_LABEL = '无红线命中' as const;
|
||||
|
||||
/** 章节一:项目概要与业务类型判定(Req 10.1)。 */
|
||||
export interface ProjectOverviewSection {
|
||||
/** 章节标题。 */
|
||||
title: string;
|
||||
/** 项目描述原文。 */
|
||||
projectDescription: string;
|
||||
/** 业务类型判定结果。 */
|
||||
businessType: BusinessType;
|
||||
/** 行业判定结果。 */
|
||||
industry: Industry;
|
||||
/** 本次评估采用的地域。 */
|
||||
region: Region;
|
||||
}
|
||||
|
||||
/** 章节二:风险总分与分级(Req 10.1)。 */
|
||||
export interface RiskScoreGradeSection {
|
||||
/** 章节标题。 */
|
||||
title: string;
|
||||
/** 归一化风险总分(0-100)。 */
|
||||
riskScore: RiskScore;
|
||||
/** 风险分级(低/中/高/极高)。 */
|
||||
riskGrade: RiskGrade;
|
||||
}
|
||||
|
||||
/** 章节三:风险热力图(Req 10.1, 7.1)。 */
|
||||
export interface HeatmapSection {
|
||||
/** 章节标题。 */
|
||||
title: string;
|
||||
/** 热力图单元格(Dimension 行 × Indicator 列 × Risk_Level 严重度)。 */
|
||||
cells: HeatmapCell[];
|
||||
}
|
||||
|
||||
/** 某维度下的风险明细(Req 10.1, 10.3)。 */
|
||||
export interface DimensionDetail {
|
||||
/** 维度标识。 */
|
||||
dimensionId: string;
|
||||
/** 维度名称。 */
|
||||
dimensionName: string;
|
||||
/** 该维度下各评分项明细(含评分/判定依据/风险影响/来源/置信)。 */
|
||||
scoringItems: ScoringItem[];
|
||||
}
|
||||
|
||||
/** 章节四:各维度风险明细(Req 10.1, 10.3)。 */
|
||||
export interface DimensionDetailsSection {
|
||||
/** 章节标题。 */
|
||||
title: string;
|
||||
/** 按维度分组的风险明细。 */
|
||||
dimensions: DimensionDetail[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 命中红线条目(HitRedline,Req 6.3)。
|
||||
*
|
||||
* 在 {@link RedlineResult} 基础上,将**被触发的条件**(`triggeredCondition`)与
|
||||
* **对应判定依据数据**(`evidenceData`)收窄为必填,从而在类型层面保证报告中
|
||||
* 每个命中红线条目恒携带其触发条件与判定依据数据(Req 6.3)。
|
||||
*/
|
||||
export interface HitRedline extends RedlineResult {
|
||||
/** 校验状态恒为"命中"。 */
|
||||
status: '命中';
|
||||
/** 被触发的条件描述(必填,Req 6.3)。 */
|
||||
triggeredCondition: string;
|
||||
/** 对应判定依据数据(必填,Req 6.3)。 */
|
||||
evidenceData: string;
|
||||
}
|
||||
|
||||
/** 章节五:Top 关键风险与红线校验结果(Req 10.1, 6.3, 7.2)。 */
|
||||
export interface KeyRisksAndRedlinesSection {
|
||||
/** 章节标题。 */
|
||||
title: string;
|
||||
/** Top N 关键风险清单。 */
|
||||
topKeyRisks: RiskItem[];
|
||||
/** 红线校验结果集合(命中/未命中/待核实)。 */
|
||||
redlineResults: RedlineResult[];
|
||||
/** 命中红线列表(每项含被触发条件与判定依据数据,Req 6.3)。 */
|
||||
hitRedlines: HitRedline[];
|
||||
/** 是否命中任一红线。 */
|
||||
hasRedlineHit: boolean;
|
||||
/** 红线校验结果摘要;未命中任一红线时为"无红线命中"(Req 10.1)。 */
|
||||
redlineSummary: string;
|
||||
}
|
||||
|
||||
/** 章节六:可接受性结论(Req 10.1, 9.1)。 */
|
||||
export interface AcceptabilitySection {
|
||||
/** 章节标题。 */
|
||||
title: string;
|
||||
/** 可接受性结论(可接受/有条件接受/不可接受)。 */
|
||||
acceptability: Acceptability;
|
||||
}
|
||||
|
||||
/** 章节七:应对方案(Req 10.1, 9.2-9.5)。 */
|
||||
export interface ResponsePlanSection {
|
||||
/** 章节标题。 */
|
||||
title: string;
|
||||
/** 管理层面应对措施(四类各≥1,结论为可接受/有条件接受时输出)。 */
|
||||
managementMeasures: Measure[];
|
||||
/** 费用层面应对措施(五类各≥1,结论为可接受/有条件接受时输出)。 */
|
||||
costMeasures: Measure[];
|
||||
/** 接受条件清单(结论为有条件接受时输出,每条关联≥1 关键风险并附成本影响)。 */
|
||||
acceptanceConditions: AcceptanceCondition[];
|
||||
}
|
||||
|
||||
/** 假设/信息缺口项(Req 10.1, 18.5, 18.6)。 */
|
||||
export interface AssumptionGapItem {
|
||||
/** 所属维度标识。 */
|
||||
dimensionId: string;
|
||||
/** 指标标识。 */
|
||||
indicatorId: string;
|
||||
/** 数据来源标注(恒为"智能体假设")。 */
|
||||
provenance: DataProvenance;
|
||||
/** 该项置信度。 */
|
||||
confidence: Confidence;
|
||||
/** 关联该 Indicator 的补充尽调建议(Req 18.6)。 */
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
/** 章节八:假设与信息缺口说明(Req 10.1, 18.5, 18.6)。 */
|
||||
export interface AssumptionsAndGapsSection {
|
||||
/** 章节标题。 */
|
||||
title: string;
|
||||
/** 取值来源为"智能体假设"的评分项清单(Req 18.5)。 */
|
||||
items: AssumptionGapItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 风险评估报告(Report,Req 10.1)。
|
||||
*
|
||||
* 包含全部八个规定章节,由已完成的 Assessment 数据组装而成。报告为结构化数据,
|
||||
* 供导出({@link ../report/export},任务 13.9)与角色化视图复用。
|
||||
*/
|
||||
export interface Report {
|
||||
/** 章节一:项目概要与业务类型判定。 */
|
||||
projectOverview: ProjectOverviewSection;
|
||||
/** 章节二:风险总分与分级。 */
|
||||
riskScoreGrade: RiskScoreGradeSection;
|
||||
/** 章节三:风险热力图。 */
|
||||
heatmap: HeatmapSection;
|
||||
/** 章节四:各维度风险明细。 */
|
||||
dimensionDetails: DimensionDetailsSection;
|
||||
/** 章节五:Top 关键风险与红线校验结果。 */
|
||||
keyRisksAndRedlines: KeyRisksAndRedlinesSection;
|
||||
/** 章节六:可接受性结论。 */
|
||||
acceptabilityConclusion: AcceptabilitySection;
|
||||
/** 章节七:应对方案。 */
|
||||
responsePlan: ResponsePlanSection;
|
||||
/** 章节八:假设与信息缺口说明。 */
|
||||
assumptionsAndGaps: AssumptionsAndGapsSection;
|
||||
}
|
||||
|
||||
/** `generate` 的可选项。 */
|
||||
export interface GenerateOptions {
|
||||
/**
|
||||
* Top N 关键风险的 N,可配置正整数,取值范围 1 至 50,默认 10(Req 7.2)。
|
||||
* 透传给 {@link topKeyRisks},非法 N 由其校验并抛错。
|
||||
*/
|
||||
topN?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验评估流程是否完成,返回尚未完成的步骤列表(Req 10.4)。
|
||||
*
|
||||
* 完成判定:
|
||||
* - 风险评分:`riskScore` 与 `riskGrade` 均已产生。
|
||||
* - 红线校验:每个**启用**红线均有对应的红线校验结果(无启用红线则视为已完成)。
|
||||
* - 费用测算:`costEstimate` 已产生。
|
||||
* - 可接受性结论:`acceptability` 已产生。
|
||||
*/
|
||||
function collectMissingSteps(assessment: Assessment): IncompleteFlowStep[] {
|
||||
const missing: IncompleteFlowStep[] = [];
|
||||
|
||||
if (assessment.riskScore === undefined || assessment.riskGrade === undefined) {
|
||||
missing.push('风险评分');
|
||||
}
|
||||
|
||||
if (!isRedlineCheckComplete(assessment)) {
|
||||
missing.push('红线校验');
|
||||
}
|
||||
|
||||
if (assessment.costEstimate === undefined) {
|
||||
missing.push('费用测算');
|
||||
}
|
||||
|
||||
if (assessment.acceptability === undefined) {
|
||||
missing.push('可接受性结论');
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定红线校验是否完成:每个启用红线均已产生对应的红线校验结果。
|
||||
*
|
||||
* 模型不含任何启用红线时,红线校验平凡完成(空结果集合法)。
|
||||
*/
|
||||
function isRedlineCheckComplete(assessment: Assessment): boolean {
|
||||
const enabledRedlineIds = assessment.riskModel.redlines
|
||||
.filter((redline) => redline.enabled)
|
||||
.map((redline) => redline.id);
|
||||
const resultIds = new Set(assessment.redlineResults.map((result) => result.redlineId));
|
||||
return enabledRedlineIds.every((id) => resultIds.has(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 以**对象引用**为键,建立 Indicator → ScoringItem 的映射。
|
||||
*
|
||||
* ScoringItem 以 (dimensionId, indicatorId) 引用指标,而 `buildHeatmap` / `topKeyRisks`
|
||||
* 的解析器仅接收 Indicator 对象;以对象引用为键可避免跨维度同名指标标识冲突。
|
||||
*/
|
||||
function buildScoringItemIndex(
|
||||
riskModel: RiskModel,
|
||||
scoringItems: readonly ScoringItem[],
|
||||
): Map<Indicator, ScoringItem> {
|
||||
const index = new Map<Indicator, ScoringItem>();
|
||||
for (const dimension of riskModel.dimensions) {
|
||||
for (const indicator of dimension.indicators) {
|
||||
const item = scoringItems.find(
|
||||
(scoringItem) =>
|
||||
scoringItem.dimensionId === dimension.id &&
|
||||
scoringItem.indicatorId === indicator.id,
|
||||
);
|
||||
if (item !== undefined) {
|
||||
index.set(indicator, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命中红线列入报告(Req 6.3)。
|
||||
*
|
||||
* 从红线校验结果中筛出**命中**的红线,并保证每个条目均携带其**被触发的条件**
|
||||
* (`triggeredCondition`)与**对应判定依据数据**(`evidenceData`):
|
||||
* - 优先采用 {@link checkRedlines} 在命中时给出的 triggeredCondition / evidenceData;
|
||||
* - 当其缺省(undefined 或空白)时,回退到风险模型中对应 Redline 的触发条件
|
||||
* ({@link Redline.triggerCondition})与一票否决后果({@link Redline.consequence}),
|
||||
* 从而保证报告中每个命中红线条目恒携带非空的触发条件与判定依据数据。
|
||||
*
|
||||
* 返回的 {@link HitRedline} 在类型层面要求两字段必填,强约束 Req 6.3 的报告契约。
|
||||
*/
|
||||
function buildHitRedlines(assessment: Assessment): HitRedline[] {
|
||||
const redlineById = new Map(
|
||||
assessment.riskModel.redlines.map((redline) => [redline.id, redline]),
|
||||
);
|
||||
return assessment.redlineResults
|
||||
.filter((result) => result.status === '命中')
|
||||
.map((result): HitRedline => {
|
||||
const redline = redlineById.get(result.redlineId);
|
||||
const condition = result.triggeredCondition?.trim();
|
||||
const triggeredCondition =
|
||||
condition !== undefined && condition.length > 0
|
||||
? result.triggeredCondition ?? condition
|
||||
: redline?.triggerCondition ?? `红线「${result.redlineId}」触发条件`;
|
||||
const evidence = result.evidenceData?.trim();
|
||||
const evidenceData =
|
||||
evidence !== undefined && evidence.length > 0
|
||||
? result.evidenceData ?? evidence
|
||||
: `判定依据数据缺省;一票否决后果:${redline?.consequence ?? '(未提供)'}`;
|
||||
return { ...result, status: '命中', triggeredCondition, evidenceData };
|
||||
});
|
||||
}
|
||||
|
||||
/** 各维度风险明细:按维度分组评分项,并附维度名称(Req 10.3)。 */
|
||||
function buildDimensionDetails(assessment: Assessment): DimensionDetail[] {
|
||||
return assessment.riskModel.dimensions.map((dimension) => ({
|
||||
dimensionId: dimension.id,
|
||||
dimensionName: dimension.name,
|
||||
scoringItems: assessment.scoringItems.filter(
|
||||
(item) => item.dimensionId === dimension.id,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装关联对应 Indicator 的补充尽调建议(Req 18.6)。
|
||||
*
|
||||
* 该建议显式引用所属 Dimension/Indicator(名称与标识),并以指标的证据要求
|
||||
* ({@link Indicator.evidenceRequired})作为补充尽调方向;当证据要求缺省时,
|
||||
* 回退到评分项自身的建议({@link ScoringItem.recommendation},Req 18.3 保证非空),
|
||||
* 从而恒输出非空且关联到具体 Indicator 的尽调建议。
|
||||
*/
|
||||
function buildDueDiligenceRecommendation(
|
||||
dimension: { id: string; name: string } | undefined,
|
||||
indicator: Indicator | undefined,
|
||||
item: ScoringItem,
|
||||
): string {
|
||||
const dimensionName = dimension?.name ?? item.dimensionId;
|
||||
const indicatorName = indicator?.name ?? item.indicatorId;
|
||||
const evidence = indicator?.evidenceRequired.trim();
|
||||
const direction = evidence !== undefined && evidence.length > 0 ? evidence : item.recommendation;
|
||||
return (
|
||||
`指标「${indicatorName}」(${dimensionName}/${item.dimensionId}.${item.indicatorId})当前取值来源为"智能体假设",` +
|
||||
`建议补充尽调以核实真实取值:${direction}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 假设与信息缺口:取来源为"智能体假设"的评分项(Req 18.5, 18.6)。
|
||||
*
|
||||
* 每个假设项均列入信息缺口说明(Req 18.5),并附一条**关联对应 Indicator** 的
|
||||
* 补充尽调建议(Req 18.6)。建议通过 {@link buildDueDiligenceRecommendation} 组装,
|
||||
* 恒非空且显式引用该评分项所属的 Dimension/Indicator。
|
||||
*/
|
||||
function buildAssumptionGapItems(
|
||||
riskModel: RiskModel,
|
||||
scoringItems: readonly ScoringItem[],
|
||||
): AssumptionGapItem[] {
|
||||
return scoringItems
|
||||
.filter((item) => item.provenance === '智能体假设')
|
||||
.map((item) => {
|
||||
const dimension = riskModel.dimensions.find((d) => d.id === item.dimensionId);
|
||||
const indicator = dimension?.indicators.find((ind) => ind.id === item.indicatorId);
|
||||
return {
|
||||
dimensionId: item.dimensionId,
|
||||
indicatorId: item.indicatorId,
|
||||
provenance: item.provenance,
|
||||
confidence: item.confidence,
|
||||
recommendation: buildDueDiligenceRecommendation(dimension, indicator, item),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成风险评估报告(Req 10.1, 10.4)。
|
||||
*
|
||||
* 前置:本次 Assessment 的风险评分、红线校验、费用测算与可接受性结论均已完成;
|
||||
* 任一未完成时抛出 {@link FlowNotCompleteError}(Req 10.4)。
|
||||
*
|
||||
* 生成的报告包含全部八个规定章节(Req 10.1);未命中任一启用红线时,红线校验结果
|
||||
* 摘要明确标注为"无红线命中"({@link NO_REDLINE_HIT_LABEL})。
|
||||
*
|
||||
* @param assessment 本次评估记录(须已完成评分/红线/费用/可接受性结论)。
|
||||
* @param options 可选项:Top N 关键风险的 N。
|
||||
* @returns 包含全部规定章节的结构化报告。
|
||||
* @throws {FlowNotCompleteError} 当评估流程任一前置步骤尚未完成时。
|
||||
*/
|
||||
export function generate(assessment: Assessment, options: GenerateOptions = {}): Report {
|
||||
const missingSteps = collectMissingSteps(assessment);
|
||||
if (missingSteps.length > 0) {
|
||||
throw new FlowNotCompleteError(missingSteps);
|
||||
}
|
||||
|
||||
// 流程已完成:以下字段必然已产生(由 collectMissingSteps 保证)。
|
||||
const riskScore = assessment.riskScore as RiskScore;
|
||||
const riskGrade = assessment.riskGrade as RiskGrade;
|
||||
const acceptability = assessment.acceptability as Acceptability;
|
||||
|
||||
// 以对象引用为键将评分项映射回各指标,构造与评分一致的解析器。
|
||||
const itemByIndicator = buildScoringItemIndex(assessment.riskModel, assessment.scoringItems);
|
||||
const resolveRiskLevel: RiskLevelResolver = (indicator) =>
|
||||
(itemByIndicator.get(indicator)?.riskLevel ?? 1) as RiskLevel;
|
||||
const resolveExplainContext: ExplainContextResolver = (indicator) => {
|
||||
const item = itemByIndicator.get(indicator);
|
||||
return {
|
||||
provenance: item?.provenance ?? '用户输入',
|
||||
confidence: item?.confidence ?? 1,
|
||||
};
|
||||
};
|
||||
|
||||
const heatmapCells = buildHeatmap(assessment.riskModel, resolveRiskLevel);
|
||||
const keyRisks = topKeyRisks(
|
||||
assessment.riskModel,
|
||||
resolveRiskLevel,
|
||||
options.topN ?? DEFAULT_TOP_N,
|
||||
resolveExplainContext,
|
||||
);
|
||||
|
||||
const hitRedlines = buildHitRedlines(assessment);
|
||||
const redlineHit = hitRedlines.length > 0;
|
||||
const redlineSummary = redlineHit
|
||||
? `命中 ${String(hitRedlines.length)} 条红线`
|
||||
: NO_REDLINE_HIT_LABEL;
|
||||
|
||||
return {
|
||||
projectOverview: {
|
||||
title: '项目概要与业务类型判定',
|
||||
projectDescription: assessment.projectDescription,
|
||||
businessType: assessment.businessType,
|
||||
industry: assessment.industry,
|
||||
region: assessment.region,
|
||||
},
|
||||
riskScoreGrade: {
|
||||
title: '风险总分与分级',
|
||||
riskScore,
|
||||
riskGrade,
|
||||
},
|
||||
heatmap: {
|
||||
title: '风险热力图',
|
||||
cells: heatmapCells,
|
||||
},
|
||||
dimensionDetails: {
|
||||
title: '各维度风险明细',
|
||||
dimensions: buildDimensionDetails(assessment),
|
||||
},
|
||||
keyRisksAndRedlines: {
|
||||
title: 'Top 关键风险与红线校验结果',
|
||||
topKeyRisks: keyRisks,
|
||||
redlineResults: [...assessment.redlineResults],
|
||||
hitRedlines,
|
||||
hasRedlineHit: redlineHit,
|
||||
redlineSummary,
|
||||
},
|
||||
acceptabilityConclusion: {
|
||||
title: '可接受性结论',
|
||||
acceptability,
|
||||
},
|
||||
responsePlan: {
|
||||
title: '应对方案',
|
||||
managementMeasures: managementMeasures(acceptability),
|
||||
costMeasures: costMeasures(acceptability),
|
||||
acceptanceConditions: acceptanceConditions(acceptability, keyRisks, {
|
||||
...(assessment.costEstimate !== undefined
|
||||
? { baselineQuote: assessment.costEstimate.baselineQuote }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
assumptionsAndGaps: {
|
||||
title: '假设与信息缺口说明',
|
||||
items: buildAssumptionGapItems(assessment.riskModel, assessment.scoringItems),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user