外包风险评估系统:领域引擎+前端+服务端持久化与生产部署
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解 - 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护) - 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理 - 专业图标体系替换全部emoji、看板美化 - 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC - 444单测+204前端测试+51 e2e
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 优化项单元测试:覆盖本轮新增的引擎逻辑分支
|
||||
* - 坏账准备金(客户信用驱动)降低净利
|
||||
* - 可计算红线(数值度量 / 指标等级 / AND 复合条件)
|
||||
* - 目标净利率分层
|
||||
* - 差异化业务类型模板(指标差异 + 维度/指标权重合规)
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { analyzeProfitability, type ProfitabilityInputs } from '../cost/profitability.js';
|
||||
import { buildMetricRedline, compareMetric, metricLabel, type ComputableRedlineConfig } from '../scoring/redlineMetrics.js';
|
||||
import { checkRedlines } from '../scoring/checkRedlines.js';
|
||||
import { targetMarginForGrade, DEFAULT_TARGET_NET_MARGIN } from '../strategy/recommendation.js';
|
||||
import { DEFAULT_TEMPLATES } from '../server/templates/defaultTemplates.js';
|
||||
import type { Redline } from '../domain/model.js';
|
||||
|
||||
const baseInputs: ProfitabilityInputs = {
|
||||
businessType: '岗位外包',
|
||||
region: '北京',
|
||||
pricingModel: 'per_head',
|
||||
contractMonths: 12,
|
||||
positions: [{ name: '运维', headcount: 10, monthlyGrossSalary: 10000, unitPrice: 16000 }],
|
||||
};
|
||||
|
||||
describe('坏账准备金(客户信用驱动)', () => {
|
||||
it('badDebtRate 越高净利越低,且 0 时无坏账行', () => {
|
||||
const r0 = analyzeProfitability({ ...baseInputs, badDebtRate: 0 });
|
||||
const r6 = analyzeProfitability({ ...baseInputs, badDebtRate: 0.06 });
|
||||
expect(r0.monthly.badDebtReserve).toBe(0);
|
||||
expect(r6.monthly.badDebtReserve).toBeGreaterThan(0);
|
||||
expect(r6.monthly.netProfit).toBeLessThan(r0.monthly.netProfit);
|
||||
// 坏账准备金 = 不含税收入 × 比例
|
||||
expect(r6.monthly.badDebtReserve).toBeCloseTo(r6.monthly.revenueNet * 0.06, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('可计算红线', () => {
|
||||
const redlines: Redline[] = [
|
||||
{ id: 'rl-margin', triggerCondition: '净利率<0', consequence: '一票否决', enabled: true },
|
||||
{ id: 'rl-qual', triggerCondition: '资质=5', consequence: '一票否决', enabled: true },
|
||||
{ id: 'rl-compound', triggerCondition: '毛利为负且净利为负', consequence: '一票否决', enabled: true },
|
||||
];
|
||||
|
||||
it('数值度量:净利率 < 0 命中', () => {
|
||||
const cfg = new Map<string, ComputableRedlineConfig>([
|
||||
['rl-margin', { linkedMetric: 'netMargin', compareOp: '<', threshold: 0 }],
|
||||
]);
|
||||
const { resolveCondition, dataContext } = buildMetricRedline(cfg, { netMargin: -5 });
|
||||
const res = checkRedlines([redlines[0]!], resolveCondition, dataContext);
|
||||
expect(res[0]!.status).toBe('命中');
|
||||
});
|
||||
|
||||
it('指标等级:资质 >= 5 命中、=3 未命中', () => {
|
||||
const cfg = new Map<string, ComputableRedlineConfig>([
|
||||
['rl-qual', { linkedMetric: 'ind:qualification', compareOp: '>=', threshold: 5 }],
|
||||
]);
|
||||
const hit = buildMetricRedline(cfg, { 'ind:qualification': 5 });
|
||||
expect(checkRedlines([redlines[1]!], hit.resolveCondition, hit.dataContext)[0]!.status).toBe('命中');
|
||||
const miss = buildMetricRedline(cfg, { 'ind:qualification': 3 });
|
||||
expect(checkRedlines([redlines[1]!], miss.resolveCondition, miss.dataContext)[0]!.status).toBe('未命中');
|
||||
});
|
||||
|
||||
it('AND 复合:两条件都满足才命中', () => {
|
||||
const cfg = new Map<string, ComputableRedlineConfig>([
|
||||
['rl-compound', { linkedMetric: 'grossMargin', compareOp: '<', threshold: 0, and: { linkedMetric: 'netMargin', compareOp: '<', threshold: 0 } }],
|
||||
]);
|
||||
const both = buildMetricRedline(cfg, { grossMargin: -2, netMargin: -5 });
|
||||
expect(checkRedlines([redlines[2]!], both.resolveCondition, both.dataContext)[0]!.status).toBe('命中');
|
||||
const onlyOne = buildMetricRedline(cfg, { grossMargin: 5, netMargin: -5 });
|
||||
expect(checkRedlines([redlines[2]!], onlyOne.resolveCondition, onlyOne.dataContext)[0]!.status).toBe('未命中');
|
||||
});
|
||||
|
||||
it('度量数据缺失 → 待核实', () => {
|
||||
const cfg = new Map<string, ComputableRedlineConfig>([
|
||||
['rl-margin', { linkedMetric: 'netMargin', compareOp: '<', threshold: 0 }],
|
||||
]);
|
||||
const { resolveCondition, dataContext } = buildMetricRedline(cfg, {});
|
||||
expect(checkRedlines([redlines[0]!], resolveCondition, dataContext)[0]!.status).toBe('待核实');
|
||||
});
|
||||
|
||||
it('compareMetric / metricLabel 基本正确', () => {
|
||||
expect(compareMetric(5, '>=', 5)).toBe(true);
|
||||
expect(compareMetric(4, '>=', 5)).toBe(false);
|
||||
expect(metricLabel('netMargin')).toContain('净利率');
|
||||
expect(metricLabel('ind:qualification')).toContain('qualification');
|
||||
});
|
||||
});
|
||||
|
||||
describe('目标净利率分层', () => {
|
||||
it('风险越高目标净利率越高', () => {
|
||||
const low = targetMarginForGrade('低');
|
||||
const mid = targetMarginForGrade('中');
|
||||
const high = targetMarginForGrade('高');
|
||||
const extreme = targetMarginForGrade('极高');
|
||||
expect(low).toBeLessThan(mid);
|
||||
expect(mid).toBeLessThan(high);
|
||||
expect(high).toBeLessThan(extreme);
|
||||
expect(mid).toBeCloseTo(DEFAULT_TARGET_NET_MARGIN, 6);
|
||||
expect(targetMarginForGrade(undefined)).toBeCloseTo(DEFAULT_TARGET_NET_MARGIN, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('差异化业务类型模板', () => {
|
||||
it('各业务类型指标集差异化', () => {
|
||||
const byType = new Map(DEFAULT_TEMPLATES.map((t) => [t.businessType, t.riskModelConfig.dimensions.flatMap((d) => d.indicators.map((i) => i.id))]));
|
||||
expect(byType.get('劳务派遣')).toContain('dispatch-ratio');
|
||||
expect(byType.get('BPO')).toContain('data-security');
|
||||
expect(byType.get('BPO')).toContain('capacity-stability');
|
||||
expect(byType.get('项目制外包')).toContain('scope-clarity');
|
||||
// 劳务派遣不含 BPO 专属的产能指标
|
||||
expect(byType.get('劳务派遣')).not.toContain('capacity-stability');
|
||||
});
|
||||
|
||||
it('每个维度的启用指标权重之和为 100、维度权重之和为 100', () => {
|
||||
for (const t of DEFAULT_TEMPLATES) {
|
||||
const dims = t.riskModelConfig.dimensions;
|
||||
const dimSum = dims.reduce((s, d) => s + d.weight, 0);
|
||||
expect(dimSum).toBe(100);
|
||||
for (const d of dims) {
|
||||
const indSum = d.indicators.filter((i) => i.enabled).reduce((s, i) => s + i.weight, 0);
|
||||
expect(indSum).toBe(100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('每个指标的评分规则覆盖 1-5 级', () => {
|
||||
for (const t of DEFAULT_TEMPLATES) {
|
||||
for (const d of t.riskModelConfig.dimensions) {
|
||||
for (const i of d.indicators) {
|
||||
expect(i.scoringRules.map((r) => r.level).sort()).toEqual([1, 2, 3, 4, 5]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import fc from 'fast-check';
|
||||
|
||||
/**
|
||||
* Global fast-check configuration.
|
||||
*
|
||||
* Property-based tests for this feature MUST run at least 100 iterations per
|
||||
* property (see tasks.md "属性测试约束"). fast-check's default `numRuns` is 100;
|
||||
* we set it explicitly here so the guarantee is enforced for every property test
|
||||
* and survives any future change to library defaults.
|
||||
*/
|
||||
fc.configureGlobal({
|
||||
numRuns: 100,
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { VERSION } from '../index.js';
|
||||
|
||||
describe('project skeleton smoke test', () => {
|
||||
it('exposes the package version', () => {
|
||||
expect(VERSION).toBe('0.1.0');
|
||||
});
|
||||
|
||||
it('runs fast-check property tests with the configured iteration count', () => {
|
||||
// Verify the global fast-check config applies at least 100 iterations.
|
||||
expect(fc.readConfigureGlobal()?.numRuns).toBe(100);
|
||||
|
||||
// A trivial always-true property to confirm fast-check is wired up.
|
||||
fc.assert(
|
||||
fc.property(fc.integer(), fc.integer(), (a, b) => {
|
||||
return a + b === b + a;
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* External_Data_Adapter 外部数据适配集成测试(Req 15.1, 15.5)。
|
||||
*
|
||||
* 覆盖两条端到端路径:
|
||||
*
|
||||
* 1. 成功取数路径(Req 15.1, 15.2):以 mock {@link DataSourceAdapter} 在超时上限内
|
||||
* 成功返回数据点,经 {@link fetchExternalData} / {@link fetchWithFallback} 验证每个
|
||||
* 数据点 Data_Provenance 恒为"外部数据"、Confidence 落在 [0,1],且能回填客户风险
|
||||
* 相关 Indicator 取值。
|
||||
*
|
||||
* 2. 可插拔扩展路径(Req 15.5):以一个最小适配器注册表(Map<sourceId, adapter>)注册
|
||||
* 一个全新的外部数据源适配器,再将其取数结果驱动 Scoring_Engine 的 `scoreDimension`
|
||||
* / `scoreIndicator` 评分。整个过程仅 import 现有 scoringEngine.ts 而**不修改其源代码**,
|
||||
* 以此证明新增数据源无需改动 Scoring_Engine(Req 15.5)。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment
|
||||
* Validates: Requirements 15.1, 15.5
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
EXTERNAL_DATA_PROVENANCE,
|
||||
annotateExternalDataPoints,
|
||||
fetchExternalData,
|
||||
fetchWithFallback,
|
||||
type DataPoint,
|
||||
type DataSourceAdapter,
|
||||
type DataSourceQuery,
|
||||
type RawExternalDataPoint,
|
||||
} from '../index.js';
|
||||
import {
|
||||
scoreDimension,
|
||||
scoreIndicator,
|
||||
type RiskLevelResolver,
|
||||
} from '../../scoring/index.js';
|
||||
import type { Dimension, Indicator } from '../../domain/model.js';
|
||||
import type { RiskLevel } from '../../domain/common.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 测试替身:可配置的 mock 数据源适配器。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 构造一个 mock 适配器:在调用时按给定原始数据点产出已标注的数据点(成功路径)。
|
||||
* 标注统一经 {@link annotateExternalDataPoints},保证 Req 15.2 的来源/置信不变式。
|
||||
*/
|
||||
function makeMockAdapter(
|
||||
sourceId: string,
|
||||
raws: readonly RawExternalDataPoint[],
|
||||
sourceName?: string,
|
||||
): DataSourceAdapter {
|
||||
const base = {
|
||||
sourceId,
|
||||
fetch: (_query: DataSourceQuery): Promise<DataPoint[]> =>
|
||||
Promise.resolve(annotateExternalDataPoints(raws)),
|
||||
};
|
||||
return sourceName === undefined ? base : { ...base, sourceName };
|
||||
}
|
||||
|
||||
/** 最小适配器注册表:以 sourceId 为键插拔注册外部数据源(Req 15.5)。 */
|
||||
class AdapterRegistry {
|
||||
private readonly adapters = new Map<string, DataSourceAdapter>();
|
||||
|
||||
register(adapter: DataSourceAdapter): void {
|
||||
this.adapters.set(adapter.sourceId, adapter);
|
||||
}
|
||||
|
||||
get(sourceId: string): DataSourceAdapter {
|
||||
const adapter = this.adapters.get(sourceId);
|
||||
if (adapter === undefined) {
|
||||
throw new Error(`未注册的数据源: ${sourceId}`);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.adapters.size;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 测试夹具:客户风险维度与指标,以及由外部数据点构造的风险等级解析器。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 构造一个启用的客户风险 Indicator(评分规则细节与本集成测试无关,留空)。 */
|
||||
function makeIndicator(id: string, name: string, weight: number): Indicator {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
weight,
|
||||
enabled: true,
|
||||
scoringRules: [],
|
||||
evidenceRequired: '',
|
||||
askPrompt: '',
|
||||
};
|
||||
}
|
||||
|
||||
/** 将外部数值取值夹取为 1-5 的 Risk_Level。 */
|
||||
function toRiskLevel(value: number): RiskLevel {
|
||||
const clamped = Math.min(5, Math.max(1, Math.round(value)));
|
||||
return clamped as RiskLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由外部数据点构造 Scoring_Engine 所需的 RiskLevelResolver。
|
||||
*
|
||||
* 这是适配层与评分引擎之间的"接缝":外部数据经此函数转换为引擎可消费的
|
||||
* 风险等级解析器,引擎本身无需感知数据来源(Req 15.5)。
|
||||
*/
|
||||
function buildResolver(points: readonly DataPoint[]): RiskLevelResolver {
|
||||
const levelByIndicator = new Map<string, RiskLevel>();
|
||||
for (const point of points) {
|
||||
if (point.indicatorId !== undefined && typeof point.value === 'number') {
|
||||
levelByIndicator.set(point.indicatorId, toRiskLevel(point.value));
|
||||
}
|
||||
}
|
||||
return (indicator: Indicator): RiskLevel =>
|
||||
levelByIndicator.get(indicator.id) ?? 1;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 路径一:mock 数据源验证成功取数路径(Req 15.1, 15.2)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('外部数据适配集成 - 成功取数路径 (Req 15.1)', () => {
|
||||
const creditRaws: readonly RawExternalDataPoint[] = [
|
||||
{
|
||||
field: '信用评级',
|
||||
value: 3,
|
||||
category: '企业资信',
|
||||
confidence: 0.9,
|
||||
indicatorId: 'cust.credit',
|
||||
},
|
||||
{
|
||||
field: '征信查询次数',
|
||||
value: 2,
|
||||
category: '征信',
|
||||
confidence: 1.4, // 越界值,应被规整至 1
|
||||
indicatorId: 'cust.creditInquiry',
|
||||
},
|
||||
];
|
||||
|
||||
it('mock 适配器在超时内成功取数, 每个数据点标注为"外部数据"且 Confidence ∈ [0,1]', async () => {
|
||||
const adapter = makeMockAdapter('mock-credit', creditRaws, '资信 Mock');
|
||||
const query: DataSourceQuery = {
|
||||
subjectName: '示例外包供应商',
|
||||
categories: ['企业资信', '征信'],
|
||||
indicatorIds: ['cust.credit', 'cust.creditInquiry'],
|
||||
};
|
||||
|
||||
const outcome = await fetchExternalData(adapter, query);
|
||||
|
||||
expect(outcome.status).toBe('success');
|
||||
if (outcome.status !== 'success') {
|
||||
throw new Error('期望成功取数');
|
||||
}
|
||||
expect(outcome.dataPoints).toHaveLength(2);
|
||||
for (const point of outcome.dataPoints) {
|
||||
expect(point.provenance).toBe(EXTERNAL_DATA_PROVENANCE);
|
||||
expect(point.confidence).toBeGreaterThanOrEqual(0);
|
||||
expect(point.confidence).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('成功取数经 fetchWithFallback 回填指标取值且不触发降级', async () => {
|
||||
const adapter = makeMockAdapter('mock-credit', creditRaws);
|
||||
const query: DataSourceQuery = {
|
||||
subjectName: '示例外包供应商',
|
||||
indicatorIds: ['cust.credit', 'cust.creditInquiry'],
|
||||
};
|
||||
|
||||
const result = await fetchWithFallback({ adapter, query });
|
||||
|
||||
expect(result.outcome.status).toBe('success');
|
||||
expect(result.usedFallback).toBe(false);
|
||||
expect(result.dataPoints).toHaveLength(2);
|
||||
for (const point of result.dataPoints) {
|
||||
expect(point.provenance).toBe(EXTERNAL_DATA_PROVENANCE);
|
||||
}
|
||||
// 数据点确实按请求的 Indicator 标识回填。
|
||||
const ids = result.dataPoints.map((point) => point.indicatorId);
|
||||
expect(ids).toContain('cust.credit');
|
||||
expect(ids).toContain('cust.creditInquiry');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 路径二:注册新适配器, 无需改 Scoring_Engine 源码即可使用(Req 15.5)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('外部数据适配集成 - 可插拔扩展无需改 Scoring_Engine (Req 15.5)', () => {
|
||||
/** 客户风险维度:两个启用指标。 */
|
||||
function makeCustomerDimension(): Dimension {
|
||||
return {
|
||||
id: 'dim.customer',
|
||||
name: '客户风险',
|
||||
weight: 100,
|
||||
enabled: true,
|
||||
indicators: [
|
||||
makeIndicator('cust.credit', '信用评级', 6),
|
||||
makeIndicator('cust.litigation', '涉诉风险', 4),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it('用已注册适配器取数, 其结果可直接驱动 scoreDimension 评分', async () => {
|
||||
const registry = new AdapterRegistry();
|
||||
registry.register(
|
||||
makeMockAdapter('mock-credit', [
|
||||
{
|
||||
field: '信用评级',
|
||||
value: 3,
|
||||
category: '企业资信',
|
||||
confidence: 0.8,
|
||||
indicatorId: 'cust.credit',
|
||||
},
|
||||
{
|
||||
field: '涉诉风险',
|
||||
value: 2,
|
||||
category: '涉诉',
|
||||
confidence: 0.7,
|
||||
indicatorId: 'cust.litigation',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const dimension = makeCustomerDimension();
|
||||
const query: DataSourceQuery = {
|
||||
subjectName: '供应商A',
|
||||
indicatorIds: ['cust.credit', 'cust.litigation'],
|
||||
};
|
||||
|
||||
const outcome = await fetchExternalData(registry.get('mock-credit'), query);
|
||||
expect(outcome.status).toBe('success');
|
||||
if (outcome.status !== 'success') {
|
||||
throw new Error('期望成功取数');
|
||||
}
|
||||
|
||||
// 评分引擎源码未改动: 仅用其导出的纯函数 + 适配层产出的 resolver。
|
||||
const resolver = buildResolver(outcome.dataPoints);
|
||||
const score = scoreDimension(dimension, resolver);
|
||||
|
||||
// cust.credit: level 3 × weight 6 = 18; cust.litigation: level 2 × weight 4 = 8 → 26。
|
||||
expect(score).toBe(26);
|
||||
});
|
||||
|
||||
it('注册一个全新数据源适配器后, 同一评分流程无需改动即可使用 (Req 15.5)', async () => {
|
||||
const registry = new AdapterRegistry();
|
||||
|
||||
// 先注册一个既有适配器。
|
||||
registry.register(makeMockAdapter('mock-credit', []));
|
||||
const sizeBefore = registry.size;
|
||||
|
||||
// 注册一个此前不存在的"工商数据"适配器 —— 仅实现接口并注册, 无任何引擎改动。
|
||||
const businessRegistryAdapter = makeMockAdapter(
|
||||
'mock-business-registry',
|
||||
[
|
||||
{
|
||||
field: '注册资本异常',
|
||||
value: 4,
|
||||
category: '工商',
|
||||
confidence: 0.95,
|
||||
indicatorId: 'cust.credit',
|
||||
},
|
||||
{
|
||||
field: '失信记录',
|
||||
value: 5,
|
||||
category: '失信',
|
||||
confidence: 0.99,
|
||||
indicatorId: 'cust.litigation',
|
||||
},
|
||||
],
|
||||
'工商数据 Mock',
|
||||
);
|
||||
registry.register(businessRegistryAdapter);
|
||||
|
||||
expect(registry.size).toBe(sizeBefore + 1);
|
||||
|
||||
const dimension = makeCustomerDimension();
|
||||
const query: DataSourceQuery = {
|
||||
subjectName: '供应商B',
|
||||
indicatorIds: ['cust.credit', 'cust.litigation'],
|
||||
};
|
||||
|
||||
// 取出新注册的适配器, 复用与既有适配器完全相同的取数 + 评分链路。
|
||||
const outcome = await fetchExternalData(
|
||||
registry.get('mock-business-registry'),
|
||||
query,
|
||||
);
|
||||
expect(outcome.status).toBe('success');
|
||||
if (outcome.status !== 'success') {
|
||||
throw new Error('期望成功取数');
|
||||
}
|
||||
for (const point of outcome.dataPoints) {
|
||||
expect(point.provenance).toBe(EXTERNAL_DATA_PROVENANCE);
|
||||
}
|
||||
|
||||
const resolver = buildResolver(outcome.dataPoints);
|
||||
|
||||
// 单指标得分: cust.credit level 4 × weight 6 = 24。
|
||||
const creditIndicator = dimension.indicators[0];
|
||||
expect(creditIndicator).toBeDefined();
|
||||
if (creditIndicator === undefined) {
|
||||
throw new Error('缺少 cust.credit 指标');
|
||||
}
|
||||
expect(scoreIndicator(creditIndicator, resolver(creditIndicator))).toBe(24);
|
||||
|
||||
// 维度得分: cust.credit 4×6=24; cust.litigation 5×4=20 → 44。
|
||||
expect(scoreDimension(dimension, resolver)).toBe(44);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Property 51: 外部数据点来源与置信标注 的属性化测试(External_Data_Adapter,Req 15.2)。
|
||||
*
|
||||
* 属性陈述:对任意成功获取的外部数据点,经来源标注后其 Data_Provenance 必为"外部数据",
|
||||
* 且 Confidence 落在 [0, 1] 内并保留两位小数。
|
||||
*
|
||||
* 本测试以智能生成器构造任意原始外部数据点 RawExternalDataPoint:
|
||||
* - 原始 confidence 跨越 [0, 1] 内、负值、>1、极端值与边界值(0 / 1 / 0.005 等),
|
||||
* 充分覆盖 normalizeConfidence 的夹取与两位小数规整分支;
|
||||
* - field / value / category / 可选 indicatorId 取遍各形态,使输入空间贴近真实适配器产出。
|
||||
*
|
||||
* 对单点 annotateExternalDataPoint 与批量 annotateExternalDataPoints 均断言:
|
||||
* - provenance === EXTERNAL_DATA_PROVENANCE("外部数据")
|
||||
* - confidence ∈ [0, 1]
|
||||
* - confidence 为两位小数(confidence × 100 在浮点容差内为整数)
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 51: 外部数据点来源与置信标注
|
||||
* Validates: Requirements 15.2
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
annotateExternalDataPoint,
|
||||
annotateExternalDataPoints,
|
||||
EXTERNAL_DATA_CATEGORY_VALUES,
|
||||
EXTERNAL_DATA_PROVENANCE,
|
||||
type ExternalDataCategory,
|
||||
type ExternalDataValue,
|
||||
type RawExternalDataPoint,
|
||||
} from '../index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:构造任意原始外部数据点。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 数据点取值:标量(string | number | boolean | null)。 */
|
||||
const valueArb: fc.Arbitrary<ExternalDataValue> = fc.oneof(
|
||||
fc.string(),
|
||||
fc.double({ noNaN: true, noDefaultInfinity: true }),
|
||||
fc.boolean(),
|
||||
fc.constant(null),
|
||||
);
|
||||
|
||||
/** 外部数据类别:取自规定的五类。 */
|
||||
const categoryArb: fc.Arbitrary<ExternalDataCategory> = fc.constantFrom(
|
||||
...EXTERNAL_DATA_CATEGORY_VALUES,
|
||||
);
|
||||
|
||||
/**
|
||||
* 原始置信度:覆盖 [0,1] 内、越界(负值 / >1)、边界值与极端值,
|
||||
* 充分检验 normalizeConfidence 的夹取与两位小数规整。
|
||||
*/
|
||||
const rawConfidenceArb: fc.Arbitrary<number> = fc.oneof(
|
||||
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
fc.double({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
|
||||
fc.constantFrom(0, 1, 0.005, 0.125, 0.999, -0.5, 1.5, 42),
|
||||
);
|
||||
|
||||
/** 原始外部数据点生成器(indicatorId 可选)。 */
|
||||
const rawDataPointArb: fc.Arbitrary<RawExternalDataPoint> = fc
|
||||
.record(
|
||||
{
|
||||
field: fc.string({ minLength: 1, maxLength: 16 }),
|
||||
value: valueArb,
|
||||
category: categoryArb,
|
||||
confidence: rawConfidenceArb,
|
||||
indicatorId: fc.option(fc.string({ minLength: 1, maxLength: 12 }), {
|
||||
nil: undefined,
|
||||
}),
|
||||
},
|
||||
{ requiredKeys: ['field', 'value', 'category', 'confidence'] },
|
||||
)
|
||||
.map((r) => {
|
||||
// exactOptionalPropertyTypes:仅在存在时附加 indicatorId。
|
||||
if (r.indicatorId === undefined) {
|
||||
const { field, value, category, confidence } = r;
|
||||
return { field, value, category, confidence };
|
||||
}
|
||||
return r as RawExternalDataPoint;
|
||||
});
|
||||
|
||||
/** 两位小数判定:value × 100 在浮点容差内为整数。 */
|
||||
function isTwoDecimals(value: number): boolean {
|
||||
return Math.abs(value * 100 - Math.round(value * 100)) < 1e-9;
|
||||
}
|
||||
|
||||
/** 单点不变式断言:provenance 为"外部数据",confidence ∈ [0,1] 且两位小数。 */
|
||||
function assertAnnotated(point: { provenance: string; confidence: number }): void {
|
||||
expect(point.provenance).toBe(EXTERNAL_DATA_PROVENANCE);
|
||||
expect(point.confidence).toBeGreaterThanOrEqual(0);
|
||||
expect(point.confidence).toBeLessThanOrEqual(1);
|
||||
expect(isTwoDecimals(point.confidence)).toBe(true);
|
||||
}
|
||||
|
||||
describe('Property 51: 外部数据点来源与置信标注 (Req 15.2)', () => {
|
||||
it('单点标注后 provenance 恒为"外部数据"且 confidence ∈ [0,1] 两位小数', () => {
|
||||
fc.assert(
|
||||
fc.property(rawDataPointArb, (raw) => {
|
||||
assertAnnotated(annotateExternalDataPoint(raw));
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('批量标注的每个数据点均满足来源与置信不变式', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(rawDataPointArb, { minLength: 0, maxLength: 12 }),
|
||||
(raws) => {
|
||||
const points = annotateExternalDataPoints(raws);
|
||||
expect(points).toHaveLength(raws.length);
|
||||
for (const point of points) {
|
||||
assertAnnotated(point);
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Property 52: 外部数据失败降级回退 的属性化测试(External_Data_Adapter,Req 15.3, 15.4)。
|
||||
*
|
||||
* 属性陈述:对任意外部数据源连接失败、超过约定时长未返回(超时)或返回错误响应的情形,
|
||||
* 受影响数据点必回退到用户输入并标注 Data_Provenance="用户输入"(Req 15.3);若回退后
|
||||
* 用户输入仍不完整(缺失),则必标注为"智能体假设"并继续执行评估、不中断流程(Req 15.4)。
|
||||
*
|
||||
* 本测试以智能生成器构造三类失败场景的适配器(连接失败 / 超时 / 错误响应),并为每个被
|
||||
* 请求键随机决定其用户输入是否存在且非缺失、以及智能体假设兜底是否存在。断言:
|
||||
* - 取数结果恒为 status==="failed"(外部数据不可用),usedFallback===true;
|
||||
* - 用户输入存在且非缺失的键 → provenance==="用户输入"(Req 15.3);
|
||||
* - 用户输入缺失的键 → provenance==="智能体假设"(Req 15.4);
|
||||
* - 无论何种失败场景,fetchWithFallback 恒返回完整解析结果、不抛出、不中断流程(Req 15.4);
|
||||
* - 失败场景下没有任何数据点被标注为"外部数据"。
|
||||
*
|
||||
* 另以纯函数 resolveWithFallback 在"外部取数失败=空外部数据点"前提下覆盖更广输入空间,
|
||||
* 验证优先级链路"外部数据 → 用户输入 → 智能体假设"的解析正确性。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 52: 外部数据失败降级回退
|
||||
* Validates: Requirements 15.3, 15.4
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
fetchWithFallback,
|
||||
resolveWithFallback,
|
||||
EXTERNAL_DATA_TIMEOUT_MS,
|
||||
type DataPoint,
|
||||
type DataSourceAdapter,
|
||||
type ExternalDataValue,
|
||||
type FallbackInput,
|
||||
} from '../index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 外部取数失败的三类成因(Req 15.3)。 */
|
||||
type FailureKind = '连接失败' | '超时' | '错误响应';
|
||||
|
||||
const failureKindArb: fc.Arbitrary<FailureKind> = fc.constantFrom(
|
||||
'连接失败',
|
||||
'超时',
|
||||
'错误响应',
|
||||
);
|
||||
|
||||
/** 非缺失取值:非空白字符串 / 数值 / 布尔(含 false、0 等有效值)。 */
|
||||
const nonMissingValueArb: fc.Arbitrary<ExternalDataValue> = fc.oneof(
|
||||
fc.string({ minLength: 1, maxLength: 16 }).filter((s) => s.trim() !== ''),
|
||||
fc.double({ noNaN: true, noDefaultInfinity: true }),
|
||||
fc.boolean(),
|
||||
);
|
||||
|
||||
/** 任意取值:在非缺失基础上额外包含 null 与空白串(用于假设候选)。 */
|
||||
const anyValueArb: fc.Arbitrary<ExternalDataValue> = fc.oneof(
|
||||
nonMissingValueArb,
|
||||
fc.constant(null),
|
||||
fc.constant(' '),
|
||||
);
|
||||
|
||||
/** 对齐键:非空白字符串。 */
|
||||
const keyArb: fc.Arbitrary<string> = fc
|
||||
.string({ minLength: 1, maxLength: 10 })
|
||||
.filter((s) => s.trim() !== '');
|
||||
|
||||
/** 每个键的回退方案:用户输入是否存在(非缺失)、假设兜底是否存在及其取值。 */
|
||||
interface KeyPlan {
|
||||
userPresent: boolean;
|
||||
userValue: ExternalDataValue;
|
||||
assumptionPresent: boolean;
|
||||
assumptionValue: ExternalDataValue;
|
||||
}
|
||||
|
||||
const keyPlanArb: fc.Arbitrary<KeyPlan> = fc.record({
|
||||
userPresent: fc.boolean(),
|
||||
userValue: nonMissingValueArb,
|
||||
assumptionPresent: fc.boolean(),
|
||||
assumptionValue: anyValueArb,
|
||||
});
|
||||
|
||||
/** 完整场景:唯一键集合、各键回退方案、失败成因。 */
|
||||
interface Scenario {
|
||||
keys: string[];
|
||||
plans: KeyPlan[];
|
||||
failureKind: FailureKind;
|
||||
}
|
||||
|
||||
const scenarioArb: fc.Arbitrary<Scenario> = fc
|
||||
.uniqueArray(keyArb, { minLength: 1, maxLength: 8 })
|
||||
.chain((keys) =>
|
||||
fc.record({
|
||||
keys: fc.constant(keys),
|
||||
plans: fc.array(keyPlanArb, {
|
||||
minLength: keys.length,
|
||||
maxLength: keys.length,
|
||||
}),
|
||||
failureKind: failureKindArb,
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 失败适配器构造。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 构造对应失败成因的适配器;超时场景返回永不结算的 Promise,由超时包装器拒绝。 */
|
||||
function makeFailingAdapter(kind: FailureKind): DataSourceAdapter {
|
||||
return {
|
||||
sourceId: 'test-failing-source',
|
||||
fetch: (): Promise<DataPoint[]> => {
|
||||
if (kind === '超时') {
|
||||
// 永不结算 → 由 withTimeout 在 timeoutMs 后以超时拒绝。
|
||||
return new Promise<DataPoint[]>(() => {
|
||||
/* never settles */
|
||||
});
|
||||
}
|
||||
if (kind === '连接失败') {
|
||||
return Promise.reject(new Error('ECONNREFUSED 模拟连接失败'));
|
||||
}
|
||||
return Promise.reject(new Error('HTTP 500 模拟错误响应'));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 由场景构造用户输入与假设候选数组。 */
|
||||
function buildFallbacks(scenario: Scenario): {
|
||||
userInputs: FallbackInput[];
|
||||
assumptions: FallbackInput[];
|
||||
} {
|
||||
const userInputs: FallbackInput[] = [];
|
||||
const assumptions: FallbackInput[] = [];
|
||||
scenario.keys.forEach((key, i) => {
|
||||
const plan = scenario.plans[i]!;
|
||||
if (plan.userPresent) {
|
||||
userInputs.push({ field: key, value: plan.userValue });
|
||||
}
|
||||
if (plan.assumptionPresent) {
|
||||
assumptions.push({ field: key, value: plan.assumptionValue });
|
||||
}
|
||||
});
|
||||
return { userInputs, assumptions };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 52: 外部数据失败降级回退 (Req 15.3, 15.4)', () => {
|
||||
it('任意失败/超时/错误响应场景:受影响点回退用户输入,仍缺失则标智能体假设,且不中断', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(scenarioArb, async (scenario) => {
|
||||
const adapter = makeFailingAdapter(scenario.failureKind);
|
||||
const { userInputs, assumptions } = buildFallbacks(scenario);
|
||||
// 超时场景使用极短超时上限以保证测试快速;其余场景立即拒绝,上限无影响。
|
||||
const timeoutMs =
|
||||
scenario.failureKind === '超时' ? 5 : EXTERNAL_DATA_TIMEOUT_MS;
|
||||
|
||||
// 恒不抛出 / 不中断(Req 15.4):asyncProperty 等待其正常结算。
|
||||
const result = await fetchWithFallback({
|
||||
adapter,
|
||||
query: { subjectName: '测试主体' },
|
||||
userInputs,
|
||||
assumptions,
|
||||
requestedKeys: scenario.keys,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
// 外部数据不可用:取数失败且发生回退。
|
||||
expect(result.outcome.status).toBe('failed');
|
||||
expect(result.usedFallback).toBe(true);
|
||||
expect(result.dataPoints).toHaveLength(scenario.keys.length);
|
||||
|
||||
const byField = new Map(result.dataPoints.map((p) => [p.field, p]));
|
||||
scenario.keys.forEach((key, i) => {
|
||||
const plan = scenario.plans[i]!;
|
||||
const point = byField.get(key);
|
||||
expect(point).toBeDefined();
|
||||
// 失败场景下不应出现"外部数据"来源。
|
||||
expect(point!.provenance).not.toBe('外部数据');
|
||||
if (plan.userPresent) {
|
||||
// 受影响数据点回退到用户输入(Req 15.3)。
|
||||
expect(point!.provenance).toBe('用户输入');
|
||||
expect(point!.value).toBe(plan.userValue);
|
||||
} else {
|
||||
// 回退后仍缺失 → 标注智能体假设并继续(Req 15.4)。
|
||||
expect(point!.provenance).toBe('智能体假设');
|
||||
}
|
||||
});
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('纯函数解析:空外部数据点时按 用户输入→智能体假设 优先级解析(Req 15.3, 15.4)', () => {
|
||||
fc.assert(
|
||||
fc.property(scenarioArb, (scenario) => {
|
||||
const { userInputs, assumptions } = buildFallbacks(scenario);
|
||||
// 外部取数失败 → 外部数据点为空。
|
||||
const resolved = resolveWithFallback(
|
||||
[],
|
||||
scenario.keys,
|
||||
userInputs,
|
||||
assumptions,
|
||||
);
|
||||
|
||||
expect(resolved).toHaveLength(scenario.keys.length);
|
||||
const byField = new Map(resolved.map((p) => [p.field, p]));
|
||||
scenario.keys.forEach((key, i) => {
|
||||
const plan = scenario.plans[i]!;
|
||||
const point = byField.get(key);
|
||||
expect(point).toBeDefined();
|
||||
expect(point!.provenance).not.toBe('外部数据');
|
||||
if (plan.userPresent) {
|
||||
expect(point!.provenance).toBe('用户输入');
|
||||
} else {
|
||||
expect(point!.provenance).toBe('智能体假设');
|
||||
}
|
||||
// 来源恒为三态之一。
|
||||
expect(['用户输入', '外部数据', '智能体假设']).toContain(
|
||||
point!.provenance,
|
||||
);
|
||||
});
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 外部数据成功取数的来源标注(Req 15.2)。
|
||||
*
|
||||
* 将适配器产出的原始数据点统一标注为 Data_Provenance="外部数据",并经
|
||||
* {@link normalizeConfidence} 将 Confidence 规整至 [0, 1] 区间、保留两位小数。
|
||||
* 所有数据源经此单一出口产出数据点,保证 Req 15.2 在全部适配器上的一致性。
|
||||
*/
|
||||
|
||||
import { normalizeConfidence } from '../domain/provenance.js';
|
||||
import {
|
||||
EXTERNAL_DATA_PROVENANCE,
|
||||
type DataPoint,
|
||||
type RawExternalDataPoint,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* 为单个原始外部数据点施加成功取数的来源标注(Req 15.2)。
|
||||
*
|
||||
* 不变式:返回数据点的 `provenance` 恒为"外部数据",`confidence` 落在 [0, 1] 内
|
||||
* 并保留两位小数。该函数为纯函数、无副作用。
|
||||
*
|
||||
* @param raw 适配器产出的原始数据点。
|
||||
* @returns 完成来源标注与置信度规整的数据点。
|
||||
* @throws {RangeError} 当 `raw.confidence` 非有限数(NaN / ±Infinity)时。
|
||||
*/
|
||||
export function annotateExternalDataPoint(raw: RawExternalDataPoint): DataPoint {
|
||||
const point: DataPoint = {
|
||||
field: raw.field,
|
||||
value: raw.value,
|
||||
category: raw.category,
|
||||
provenance: EXTERNAL_DATA_PROVENANCE,
|
||||
confidence: normalizeConfidence(raw.confidence),
|
||||
};
|
||||
|
||||
// exactOptionalPropertyTypes:仅在存在时附加可选字段,避免赋值 undefined。
|
||||
if (raw.indicatorId !== undefined) {
|
||||
return { ...point, indicatorId: raw.indicatorId };
|
||||
}
|
||||
return point;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量为原始外部数据点施加成功取数的来源标注(Req 15.2)。
|
||||
*
|
||||
* @param raws 原始数据点集合。
|
||||
* @returns 完成来源标注的数据点集合(顺序与输入一致)。
|
||||
*/
|
||||
export function annotateExternalDataPoints(
|
||||
raws: readonly RawExternalDataPoint[],
|
||||
): DataPoint[] {
|
||||
return raws.map(annotateExternalDataPoint);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* External_Data_Adapter 超时 / 失败降级回退(Req 15.3, 15.4)。
|
||||
*
|
||||
* 本模块在适配器成功取数(Req 15.2)之上补齐降级回退链路:
|
||||
*
|
||||
* - 连接失败 / 超过 10 秒未返回 / 返回错误响应(Req 15.3):受影响数据点回退到
|
||||
* 用户输入并标注 Data_Provenance="用户输入"。
|
||||
* - 回退后用户输入仍不完整(Req 15.4):将缺失数据点标注为"智能体假设",
|
||||
* 并继续执行评估、不中断流程。
|
||||
*
|
||||
* 设计要点:
|
||||
* - {@link withTimeout} 为通用超时包装器,超过 {@link EXTERNAL_DATA_TIMEOUT_MS}
|
||||
* 即视为失败(Req 15.1 的 10 秒约定)。
|
||||
* - {@link fetchExternalData} 捕获连接失败 / 超时 / 错误响应并归一为 {@link FetchOutcome},
|
||||
* 恒不抛出,保证主流程不被中断(Req 15.4)。
|
||||
* - {@link resolveWithFallback} 为纯函数,按"外部数据 → 用户输入 → 智能体假设"
|
||||
* 的优先级为每个被请求项解析最终数据点。
|
||||
* - {@link fetchWithFallback} 将取数与解析串联,对外提供一站式入口。
|
||||
*/
|
||||
|
||||
import {
|
||||
markAsAssumption,
|
||||
normalizeConfidence,
|
||||
} from '../domain/provenance.js';
|
||||
import type { Confidence, DataProvenance } from '../domain/common.js';
|
||||
import {
|
||||
EXTERNAL_DATA_TIMEOUT_MS,
|
||||
type DataPoint,
|
||||
type DataSourceAdapter,
|
||||
type DataSourceQuery,
|
||||
type ExternalDataCategory,
|
||||
type ExternalDataValue,
|
||||
} from './types.js';
|
||||
|
||||
/** 用户输入回退的来源标注常量(Req 15.3)。与 DataProvenance 三态之一对齐。 */
|
||||
export const USER_INPUT_PROVENANCE: DataProvenance = '用户输入';
|
||||
|
||||
/** 用户输入回退缺省置信度(评估者直接录入,默认视为确定)。 */
|
||||
export const DEFAULT_USER_INPUT_CONFIDENCE = 1 as const;
|
||||
|
||||
/** "智能体假设"兜底缺省置信度(缺失项的默认取值,默认视为不确定)。 */
|
||||
export const DEFAULT_ASSUMPTION_CONFIDENCE = 0 as const;
|
||||
|
||||
/**
|
||||
* 计时器全局函数的最小类型门面。
|
||||
*
|
||||
* 本工程未引入 DOM / Node 的全局类型库,故以受限的 `globalThis` 视图获取
|
||||
* `setTimeout` / `clearTimeout`,避免对环境类型库的依赖。
|
||||
*/
|
||||
const timers = globalThis as unknown as {
|
||||
setTimeout: (handler: () => void, ms: number) => TimerHandle;
|
||||
clearTimeout: (handle: TimerHandle) => void;
|
||||
};
|
||||
|
||||
/** 计时器句柄的不透明类型(可选携带 unref)。 */
|
||||
interface TimerHandle {
|
||||
unref?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部数据取数失败原因(Req 15.3)。
|
||||
* - 超时:超过约定时长未返回。
|
||||
* - 失败:连接失败或返回错误响应(二者降级处理一致,统一归类)。
|
||||
*/
|
||||
export type FetchFailureReason = '超时' | '失败';
|
||||
|
||||
/**
|
||||
* 外部数据取数结果(恒不抛出,保证主流程不中断,Req 15.4)。
|
||||
* - success:成功取得已完成来源标注的数据点(Req 15.2)。
|
||||
* - failed:连接失败 / 超时 / 错误响应,附失败原因与原始错误(Req 15.3)。
|
||||
*/
|
||||
export type FetchOutcome =
|
||||
| { readonly status: 'success'; readonly dataPoints: DataPoint[] }
|
||||
| {
|
||||
readonly status: 'failed';
|
||||
readonly reason: FetchFailureReason;
|
||||
readonly error: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* 回退输入项:用户输入或智能体假设的候选取值。
|
||||
*
|
||||
* 以 `indicatorId`(若存在)否则 `field` 作为与外部数据点对齐的键。
|
||||
*/
|
||||
export interface FallbackInput {
|
||||
/** 数据点字段名 / 指标键。 */
|
||||
field: string;
|
||||
/** 关联的 Indicator 标识;存在时优先作为对齐键。 */
|
||||
indicatorId?: string;
|
||||
/** 数据点所属类别,可选。 */
|
||||
category?: ExternalDataCategory;
|
||||
/** 取值;null 或空白字符串视为缺失。 */
|
||||
value: ExternalDataValue;
|
||||
/** 置信度(将被规整至 [0,1] 两位小数);省略时取对应缺省值。 */
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 经降级回退解析后的最终数据点。
|
||||
*
|
||||
* 不变式:`provenance` 为三态之一,`confidence` 落在 [0,1] 内并保留两位小数。
|
||||
*/
|
||||
export interface ResolvedDataPoint {
|
||||
/** 数据点字段名 / 指标键。 */
|
||||
field: string;
|
||||
/** 取值。 */
|
||||
value: ExternalDataValue;
|
||||
/** 数据点所属类别,可选。 */
|
||||
category?: ExternalDataCategory;
|
||||
/** 来源标注:外部数据 / 用户输入 / 智能体假设。 */
|
||||
provenance: DataProvenance;
|
||||
/** 置信度,取值 [0,1]、保留两位小数。 */
|
||||
confidence: Confidence;
|
||||
/** 关联的 Indicator 标识,可选。 */
|
||||
indicatorId?: string;
|
||||
}
|
||||
|
||||
/** {@link fetchWithFallback} 的参数。 */
|
||||
export interface FetchWithFallbackOptions {
|
||||
/** 外部数据源适配器。 */
|
||||
adapter: DataSourceAdapter;
|
||||
/** 查询请求。 */
|
||||
query: DataSourceQuery;
|
||||
/** 用户输入回退候选(Req 15.3)。 */
|
||||
userInputs?: readonly FallbackInput[];
|
||||
/** 智能体假设兜底候选(Req 15.4);缺省时缺失项取值为 null。 */
|
||||
assumptions?: readonly FallbackInput[];
|
||||
/**
|
||||
* 待解析的键集合(indicatorId 或 field)。
|
||||
* 省略时回退至 `query.indicatorIds`,再回退至外部/用户/假设候选键的并集。
|
||||
*/
|
||||
requestedKeys?: readonly string[];
|
||||
/** "智能体假设"兜底缺省置信度,默认 {@link DEFAULT_ASSUMPTION_CONFIDENCE}。 */
|
||||
assumptionConfidence?: number;
|
||||
/** 单次请求超时上限(毫秒),默认 {@link EXTERNAL_DATA_TIMEOUT_MS}(10 秒)。 */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** {@link fetchWithFallback} 的返回结果。 */
|
||||
export interface FetchWithFallbackResult {
|
||||
/** 解析后的最终数据点集合(按解析键顺序)。 */
|
||||
dataPoints: ResolvedDataPoint[];
|
||||
/** 外部取数结果(成功 / 失败原因)。 */
|
||||
outcome: FetchOutcome;
|
||||
/** 是否发生降级回退(外部取数失败或存在非"外部数据"来源的数据点)。 */
|
||||
usedFallback: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部数据取数超时错误(Req 15.1 的 10 秒约定)。
|
||||
*/
|
||||
export class ExternalDataTimeoutError extends Error {
|
||||
/** 触发超时的时长(毫秒)。 */
|
||||
readonly timeoutMs: number;
|
||||
|
||||
constructor(timeoutMs: number) {
|
||||
super(`外部数据取数超过 ${timeoutMs} 毫秒未返回,触发降级回退`);
|
||||
this.name = 'ExternalDataTimeoutError';
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断回退取值是否缺失:null 或空白字符串视为缺失(Req 15.4)。
|
||||
*
|
||||
* 注意:布尔 false 与数值 0 为有效取值,不视为缺失。
|
||||
*
|
||||
* @param value 待判断取值。
|
||||
* @returns 当且仅当取值缺失时返回 true。
|
||||
*/
|
||||
export function isMissingValue(value: ExternalDataValue): boolean {
|
||||
return value === null || (typeof value === 'string' && value.trim() === '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用超时包装器:在 `timeoutMs` 内未结算则以 {@link ExternalDataTimeoutError} 拒绝(Req 15.1)。
|
||||
*
|
||||
* 一旦底层操作先行结算(成功或失败),计时器立即清除;超时优先结算后底层操作的
|
||||
* 后续结算被忽略,避免重复结算。
|
||||
*
|
||||
* @param operation 产出 Promise 的取数操作(惰性,便于在调用内统一捕获同步抛出)。
|
||||
* @param timeoutMs 超时上限(毫秒),默认 {@link EXTERNAL_DATA_TIMEOUT_MS}。
|
||||
* @returns 底层操作的结果。
|
||||
* @throws {ExternalDataTimeoutError} 超过 `timeoutMs` 未返回时。
|
||||
*/
|
||||
export function withTimeout<T>(
|
||||
operation: () => Promise<T>,
|
||||
timeoutMs: number = EXTERNAL_DATA_TIMEOUT_MS,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = timers.setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new ExternalDataTimeoutError(timeoutMs));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
// 避免计时器在 Node 环境阻止进程退出。
|
||||
timer.unref?.();
|
||||
|
||||
// 以 Promise.resolve 包裹,统一捕获 operation() 同步抛出的情形。
|
||||
Promise.resolve()
|
||||
.then(operation)
|
||||
.then(
|
||||
(value) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
timers.clearTimeout(timer);
|
||||
resolve(value);
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
timers.clearTimeout(timer);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行外部取数并归一化失败(Req 15.1, 15.3)。
|
||||
*
|
||||
* 在超时上限内尝试 `adapter.fetch`;连接失败 / 超时 / 错误响应均被捕获并归一为
|
||||
* `status: 'failed'` 的 {@link FetchOutcome}。该函数恒不抛出,保证主流程不中断(Req 15.4)。
|
||||
*
|
||||
* @param adapter 外部数据源适配器。
|
||||
* @param query 查询请求。
|
||||
* @param timeoutMs 超时上限(毫秒),默认 {@link EXTERNAL_DATA_TIMEOUT_MS}。
|
||||
* @returns 取数结果(成功数据点或失败原因)。
|
||||
*/
|
||||
export async function fetchExternalData(
|
||||
adapter: DataSourceAdapter,
|
||||
query: DataSourceQuery,
|
||||
timeoutMs: number = EXTERNAL_DATA_TIMEOUT_MS,
|
||||
): Promise<FetchOutcome> {
|
||||
try {
|
||||
const dataPoints = await withTimeout(() => adapter.fetch(query), timeoutMs);
|
||||
return { status: 'success', dataPoints };
|
||||
} catch (error) {
|
||||
const reason: FetchFailureReason =
|
||||
error instanceof ExternalDataTimeoutError ? '超时' : '失败';
|
||||
return { status: 'failed', reason, error };
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算数据点 / 回退输入的对齐键:indicatorId 优先,否则 field。 */
|
||||
function keyOf(point: {
|
||||
field: string;
|
||||
indicatorId?: string;
|
||||
}): string {
|
||||
return point.indicatorId ?? point.field;
|
||||
}
|
||||
|
||||
/** 由外部数据点构造解析结果(来源恒为"外部数据")。 */
|
||||
function fromExternal(point: DataPoint): ResolvedDataPoint {
|
||||
const resolved: ResolvedDataPoint = {
|
||||
field: point.field,
|
||||
value: point.value,
|
||||
provenance: point.provenance,
|
||||
confidence: point.confidence,
|
||||
};
|
||||
return withOptional(resolved, point.category, point.indicatorId);
|
||||
}
|
||||
|
||||
/** 由回退输入构造解析结果,施加指定来源与置信度。 */
|
||||
function fromFallback(
|
||||
input: FallbackInput,
|
||||
provenance: DataProvenance,
|
||||
defaultConfidence: number,
|
||||
): ResolvedDataPoint {
|
||||
const resolved: ResolvedDataPoint = {
|
||||
field: input.field,
|
||||
value: input.value,
|
||||
provenance,
|
||||
confidence: normalizeConfidence(input.confidence ?? defaultConfidence),
|
||||
};
|
||||
return withOptional(resolved, input.category, input.indicatorId);
|
||||
}
|
||||
|
||||
/** 构造缺失项的"智能体假设"兜底数据点(Req 15.4)。 */
|
||||
function assumedFor(
|
||||
key: string,
|
||||
assumption: FallbackInput | undefined,
|
||||
assumptionConfidence: number,
|
||||
): ResolvedDataPoint {
|
||||
// 即便存在前序来源,markAsAssumption 也保证结果恒为"智能体假设"(Req 3.7 单调永久)。
|
||||
const provenance = markAsAssumption(USER_INPUT_PROVENANCE);
|
||||
const base: ResolvedDataPoint = {
|
||||
field: assumption?.field ?? key,
|
||||
value: assumption?.value ?? null,
|
||||
provenance,
|
||||
confidence: normalizeConfidence(
|
||||
assumption?.confidence ?? assumptionConfidence,
|
||||
),
|
||||
};
|
||||
return withOptional(base, assumption?.category, assumption?.indicatorId ?? key);
|
||||
}
|
||||
|
||||
/** exactOptionalPropertyTypes:仅在存在时附加可选字段,避免赋值 undefined。 */
|
||||
function withOptional(
|
||||
point: ResolvedDataPoint,
|
||||
category: ExternalDataCategory | undefined,
|
||||
indicatorId: string | undefined,
|
||||
): ResolvedDataPoint {
|
||||
let result = point;
|
||||
if (category !== undefined) {
|
||||
result = { ...result, category };
|
||||
}
|
||||
if (indicatorId !== undefined) {
|
||||
result = { ...result, indicatorId };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按"外部数据 → 用户输入 → 智能体假设"优先级为每个被请求键解析最终数据点(Req 15.3, 15.4)。
|
||||
*
|
||||
* 解析规则(对每个 key):
|
||||
* 1. 若外部数据点存在且取值非缺失 → 采用"外部数据"。
|
||||
* 2. 否则若用户输入存在且取值非缺失 → 回退"用户输入"(Req 15.3)。
|
||||
* 3. 否则 → 标注"智能体假设"并采用假设候选取值(无候选则取 null),继续不中断(Req 15.4)。
|
||||
*
|
||||
* 该函数为纯函数、无副作用,输出顺序与 `keys` 一致。
|
||||
*
|
||||
* @param externalPoints 外部成功取得的数据点(取数失败时传空数组)。
|
||||
* @param keys 待解析的键集合(indicatorId 或 field)。
|
||||
* @param userInputs 用户输入回退候选。
|
||||
* @param assumptions 智能体假设兜底候选。
|
||||
* @param assumptionConfidence "智能体假设"缺省置信度。
|
||||
* @returns 解析后的数据点集合。
|
||||
*/
|
||||
export function resolveWithFallback(
|
||||
externalPoints: readonly DataPoint[],
|
||||
keys: readonly string[],
|
||||
userInputs: readonly FallbackInput[] = [],
|
||||
assumptions: readonly FallbackInput[] = [],
|
||||
assumptionConfidence: number = DEFAULT_ASSUMPTION_CONFIDENCE,
|
||||
): ResolvedDataPoint[] {
|
||||
const externalByKey = indexBy(externalPoints, keyOf);
|
||||
const userByKey = indexBy(userInputs, keyOf);
|
||||
const assumptionByKey = indexBy(assumptions, keyOf);
|
||||
|
||||
return keys.map((key) => {
|
||||
const external = externalByKey.get(key);
|
||||
if (external !== undefined && !isMissingValue(external.value)) {
|
||||
return fromExternal(external);
|
||||
}
|
||||
|
||||
const userInput = userByKey.get(key);
|
||||
if (userInput !== undefined && !isMissingValue(userInput.value)) {
|
||||
return fromFallback(
|
||||
userInput,
|
||||
USER_INPUT_PROVENANCE,
|
||||
DEFAULT_USER_INPUT_CONFIDENCE,
|
||||
);
|
||||
}
|
||||
|
||||
return assumedFor(key, assumptionByKey.get(key), assumptionConfidence);
|
||||
});
|
||||
}
|
||||
|
||||
/** 以键函数构造 Map;同键后者覆盖前者。 */
|
||||
function indexBy<T extends { field: string; indicatorId?: string }>(
|
||||
items: readonly T[],
|
||||
key: (item: T) => string,
|
||||
): Map<string, T> {
|
||||
const map = new Map<string, T>();
|
||||
for (const item of items) {
|
||||
map.set(key(item), item);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取数 + 降级回退一站式入口(Req 15.3, 15.4)。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 经 {@link fetchExternalData} 在超时上限内取数;失败不抛出(Req 15.3)。
|
||||
* 2. 解析待处理键集合:`requestedKeys` → `query.indicatorIds` → 候选键并集。
|
||||
* 3. 经 {@link resolveWithFallback} 按优先级解析最终数据点(Req 15.3, 15.4)。
|
||||
*
|
||||
* 无论外部取数成功与否,本函数恒返回完整解析结果、不中断流程(Req 15.4)。
|
||||
*
|
||||
* @param options 取数与回退参数。
|
||||
* @returns 解析结果、外部取数结果与是否回退标志。
|
||||
*/
|
||||
export async function fetchWithFallback(
|
||||
options: FetchWithFallbackOptions,
|
||||
): Promise<FetchWithFallbackResult> {
|
||||
const {
|
||||
adapter,
|
||||
query,
|
||||
userInputs = [],
|
||||
assumptions = [],
|
||||
requestedKeys,
|
||||
assumptionConfidence = DEFAULT_ASSUMPTION_CONFIDENCE,
|
||||
timeoutMs = EXTERNAL_DATA_TIMEOUT_MS,
|
||||
} = options;
|
||||
|
||||
const outcome = await fetchExternalData(adapter, query, timeoutMs);
|
||||
const externalPoints = outcome.status === 'success' ? outcome.dataPoints : [];
|
||||
|
||||
const keys = resolveKeys(
|
||||
requestedKeys,
|
||||
query,
|
||||
externalPoints,
|
||||
userInputs,
|
||||
assumptions,
|
||||
);
|
||||
|
||||
const dataPoints = resolveWithFallback(
|
||||
externalPoints,
|
||||
keys,
|
||||
userInputs,
|
||||
assumptions,
|
||||
assumptionConfidence,
|
||||
);
|
||||
|
||||
const usedFallback =
|
||||
outcome.status === 'failed' ||
|
||||
dataPoints.some((point) => point.provenance !== '外部数据');
|
||||
|
||||
return { dataPoints, outcome, usedFallback };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析待处理键集合:`requestedKeys` 优先,其次 `query.indicatorIds`,
|
||||
* 最后取外部 / 用户 / 假设候选键的并集(保持首次出现顺序、去重)。
|
||||
*/
|
||||
function resolveKeys(
|
||||
requestedKeys: readonly string[] | undefined,
|
||||
query: DataSourceQuery,
|
||||
externalPoints: readonly DataPoint[],
|
||||
userInputs: readonly FallbackInput[],
|
||||
assumptions: readonly FallbackInput[],
|
||||
): string[] {
|
||||
if (requestedKeys !== undefined) {
|
||||
return dedupe(requestedKeys);
|
||||
}
|
||||
if (query.indicatorIds !== undefined && query.indicatorIds.length > 0) {
|
||||
return dedupe(query.indicatorIds);
|
||||
}
|
||||
return dedupe([
|
||||
...externalPoints.map(keyOf),
|
||||
...userInputs.map(keyOf),
|
||||
...assumptions.map(keyOf),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 去重并保持首次出现顺序。 */
|
||||
function dedupe(keys: readonly string[]): string[] {
|
||||
return [...new Set(keys)];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* External_Data_Adapter 外部数据适配层模块(Req 15)。
|
||||
*
|
||||
* 职责:
|
||||
* - 定义可插拔的 {@link DataSourceAdapter} 接口,新增数据源无需改动
|
||||
* Scoring_Engine 源代码(Req 15.1, 15.5)。
|
||||
* - 为成功取得的数据点统一标注 Data_Provenance="外部数据" 与 [0,1] Confidence
|
||||
* ({@link annotateExternalDataPoint} / {@link annotateExternalDataPoints},Req 15.2)。
|
||||
* - 超时 / 失败 / 错误响应的降级回退:回退用户输入("用户输入"),仍缺失则标注
|
||||
* "智能体假设"并继续不中断({@link fetchWithFallback} / {@link resolveWithFallback},Req 15.3, 15.4)。
|
||||
*/
|
||||
|
||||
export * from './types.js';
|
||||
export * from './annotate.js';
|
||||
export * from './fallback.js';
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* External_Data_Adapter 适配层核心类型(Req 15)。
|
||||
*
|
||||
* 定义可插拔的 {@link DataSourceAdapter} 接口及其查询/数据点契约。设计目标是
|
||||
* 让新增外部数据源(企业资信 / 征信 / 涉诉 / 失信 / 工商)只需实现该接口并注册,
|
||||
* 而无需改动 Scoring_Engine 源代码(Req 15.5)。
|
||||
*
|
||||
* 契约要点:
|
||||
* - 单次请求遵循 10 秒超时约定({@link EXTERNAL_DATA_TIMEOUT_MS},Req 15.1)。
|
||||
* - 成功取数的每个数据点其 Data_Provenance 恒为"外部数据"且 Confidence 落在
|
||||
* [0, 1] 内(Req 15.2);该标注由 {@link annotateExternalDataPoint} 统一施加。
|
||||
*
|
||||
* 注意:超时 / 失败 / 错误响应的降级回退(Req 15.3, 15.4)见 fallback.ts,
|
||||
* 本模块仅定义接口与成功路径的来源标注。
|
||||
*/
|
||||
|
||||
import type { Confidence, DataProvenance } from '../domain/common.js';
|
||||
|
||||
/**
|
||||
* 外部数据单次请求的超时上限(毫秒,Req 15.1)。
|
||||
* 超过该时长未返回视为失败,触发降级回退(回退逻辑见 fallback.ts)。
|
||||
*/
|
||||
export const EXTERNAL_DATA_TIMEOUT_MS = 10_000 as const;
|
||||
|
||||
/**
|
||||
* 成功取数的来源标注常量(Req 15.2)。
|
||||
* 与 DataProvenance 三态之一"外部数据"对齐。
|
||||
*/
|
||||
export const EXTERNAL_DATA_PROVENANCE: DataProvenance = '外部数据';
|
||||
|
||||
/**
|
||||
* 外部数据类别(Req 15.1):企业资信、征信、涉诉、失信、工商。
|
||||
*/
|
||||
export type ExternalDataCategory =
|
||||
| '企业资信'
|
||||
| '征信'
|
||||
| '涉诉'
|
||||
| '失信'
|
||||
| '工商';
|
||||
|
||||
/** ExternalDataCategory 的全部取值,便于运行时校验与遍历。 */
|
||||
export const EXTERNAL_DATA_CATEGORY_VALUES = [
|
||||
'企业资信',
|
||||
'征信',
|
||||
'涉诉',
|
||||
'失信',
|
||||
'工商',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 外部数据点的取值类型。结构化原值在被引擎消费前一般规整为标量。
|
||||
*/
|
||||
export type ExternalDataValue = string | number | boolean | null;
|
||||
|
||||
/**
|
||||
* 外部数据查询请求。描述待查询主体与期望取得的数据类别。
|
||||
*/
|
||||
export interface DataSourceQuery {
|
||||
/** 被查询主体(企业)名称。 */
|
||||
subjectName: string;
|
||||
/** 被查询主体唯一标识(如统一社会信用代码),可选。 */
|
||||
subjectId?: string;
|
||||
/** 期望取得的数据类别;省略表示由适配器决定其支持的全部类别。 */
|
||||
categories?: readonly ExternalDataCategory[];
|
||||
/** 关联的客户风险相关 Indicator 标识集合,用于回填指标取值(Req 15.1)。 */
|
||||
indicatorIds?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 适配器返回的原始数据点(尚未施加来源标注)。
|
||||
*
|
||||
* 适配器实现可仅产出原始数据点,再经 {@link annotateExternalDataPoint} 统一
|
||||
* 标注 Data_Provenance="外部数据" 与规整后的 Confidence,从而保证 Req 15.2
|
||||
* 在所有数据源上的一致性。
|
||||
*/
|
||||
export interface RawExternalDataPoint {
|
||||
/** 数据点字段名 / 指标键。 */
|
||||
field: string;
|
||||
/** 数据点取值。 */
|
||||
value: ExternalDataValue;
|
||||
/** 数据点所属类别。 */
|
||||
category: ExternalDataCategory;
|
||||
/** 原始置信度(将由 normalizeConfidence 夹取至 [0,1] 并保留两位小数)。 */
|
||||
confidence: number;
|
||||
/** 关联的客户风险相关 Indicator 标识,可选。 */
|
||||
indicatorId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功取得并完成来源标注的外部数据点(Req 15.2)。
|
||||
*
|
||||
* 不变式:`provenance` 恒为"外部数据",`confidence` 落在 [0, 1] 内并保留两位小数。
|
||||
*/
|
||||
export interface DataPoint {
|
||||
/** 数据点字段名 / 指标键。 */
|
||||
field: string;
|
||||
/** 数据点取值。 */
|
||||
value: ExternalDataValue;
|
||||
/** 数据点所属类别。 */
|
||||
category: ExternalDataCategory;
|
||||
/** 来源标注,成功取数恒为"外部数据"(Req 15.2)。 */
|
||||
provenance: DataProvenance;
|
||||
/** 置信度,取值 [0,1]、保留两位小数(Req 15.2)。 */
|
||||
confidence: Confidence;
|
||||
/** 关联的客户风险相关 Indicator 标识,可选。 */
|
||||
indicatorId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可插拔的外部数据源适配器接口(Req 15.1, 15.5)。
|
||||
*
|
||||
* 每个外部数据源实现该接口并以唯一 `sourceId` 注册即可被 System 使用,
|
||||
* 新增数据源无需改动 Scoring_Engine 源代码(Req 15.5)。
|
||||
*
|
||||
* 契约:
|
||||
* - `fetch` 单次请求应遵循 {@link EXTERNAL_DATA_TIMEOUT_MS}(10 秒)超时约定(Req 15.1)。
|
||||
* - 成功返回的每个数据点须满足来源标注不变式(Req 15.2),推荐经
|
||||
* {@link annotateExternalDataPoint} 产出以保证一致性。
|
||||
*/
|
||||
export interface DataSourceAdapter {
|
||||
/** 适配器唯一标识,用于注册与插拔。 */
|
||||
readonly sourceId: string;
|
||||
/** 适配器可读名称,可选。 */
|
||||
readonly sourceName?: string;
|
||||
/** 该适配器支持的数据类别,可选;用于路由查询。 */
|
||||
readonly supportedCategories?: readonly ExternalDataCategory[];
|
||||
/**
|
||||
* 取数:依据查询返回已完成来源标注的数据点集合。
|
||||
*
|
||||
* @param query 查询请求。
|
||||
* @returns 成功取得并标注的数据点集合(Req 15.1, 15.2)。
|
||||
*/
|
||||
fetch(query: DataSourceQuery): Promise<DataPoint[]>;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 认证与权限模块(生产级基础)。
|
||||
*
|
||||
* 当前实现:基于 JWT 的无状态认证 + 角色权限校验中间件。
|
||||
* 密钥取自环境变量 AUTH_SECRET,未配置时降级为无校验(演示模式)。
|
||||
*/
|
||||
|
||||
import type { Context, Next } from 'hono';
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
export type AuthRole = '商务/销售' | '风控' | '管理层';
|
||||
|
||||
export interface AuthPayload {
|
||||
username: string;
|
||||
role: AuthRole;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
const SECRET = (): string => process.env.AUTH_SECRET ?? '';
|
||||
|
||||
/** 简易 HMAC-SHA256 签名(不依赖外部库,生产建议替换为 jose)。 */
|
||||
function base64url(buf: Buffer): string {
|
||||
return buf.toString('base64url');
|
||||
}
|
||||
|
||||
function sign(payload: object): string {
|
||||
if (SECRET() === '') return ''; // 演示模式不签发
|
||||
const header = base64url(Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
|
||||
const body = base64url(Buffer.from(JSON.stringify(payload)));
|
||||
const sig = base64url(createHmac('sha256', SECRET()).update(`${header}.${body}`).digest());
|
||||
return `${header}.${body}.${sig}`;
|
||||
}
|
||||
|
||||
function verify(token: string): AuthPayload | null {
|
||||
if (SECRET() === '') return null;
|
||||
try {
|
||||
const [header, body, sig] = token.split('.');
|
||||
if (!header || !body || !sig) return null;
|
||||
const expected = base64url(createHmac('sha256', SECRET()).update(`${header}.${body}`).digest());
|
||||
if (sig !== expected) return null;
|
||||
const payload = JSON.parse(Buffer.from(body, 'base64url').toString()) as AuthPayload;
|
||||
if (payload.exp < Date.now() / 1000) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 签发 JWT(登录成功后调用)。 */
|
||||
export function issueToken(username: string, role: AuthRole): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return sign({ username, role, iat: now, exp: now + 86400 });
|
||||
}
|
||||
|
||||
/** 无需鉴权的公共路径(登录与健康检查)。 */
|
||||
const PUBLIC_PATHS = new Set(['/api/health', '/api/auth/login', '/api/llm/status']);
|
||||
|
||||
/**
|
||||
* Hono 中间件:从 Authorization Bearer token 解析并注入当前用户(供 requireRole 使用)。
|
||||
*
|
||||
* 设计:本中间件**只负责识别身份、不负责拦截**——读操作保持开放,敏感写操作由
|
||||
* {@link requireRole} 按角色拦截。AUTH_SECRET 未配置时为演示模式(不识别身份)。
|
||||
* 这样开启鉴权后既能强制敏感操作的角色校验,又不破坏只读接口与看板。
|
||||
*/
|
||||
export function authMiddleware() {
|
||||
return async (c: Context, next: Next): Promise<void | Response> => {
|
||||
if (SECRET() === '' || PUBLIC_PATHS.has(c.req.path)) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
const auth = c.req.header('Authorization');
|
||||
if (auth !== undefined && auth.startsWith('Bearer ')) {
|
||||
const payload = verify(auth.slice(7));
|
||||
if (payload !== null) {
|
||||
c.set('user', payload);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
/** 角色权限校验中间件。 */
|
||||
export function requireRole(...roles: AuthRole[]) {
|
||||
return async (c: Context, next: Next): Promise<void | Response> => {
|
||||
if (SECRET() === '') {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
const user = c.get('user') as AuthPayload | undefined;
|
||||
if (user === undefined || !roles.includes(user.role)) {
|
||||
return c.json({ error: '权限不足' }, 403);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Classifier 行业语义识别单元测试(Req 1.2)。
|
||||
*
|
||||
* 以代表性项目描述验证两条分支:
|
||||
* 1. 可识别行业:描述命中某行业关键词时,Classifier 输出该行业且置信度 > 0;
|
||||
* 2. "未识别"分支:描述不含任何行业关键词时,Classifier 输出"未识别"且置信度为 0。
|
||||
*
|
||||
* 本文件仅覆盖行业判定语义,业务类型与输入校验由 classifier.test.ts 覆盖。
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { INDUSTRY_UNRECOGNIZED } from '../../domain/common.js';
|
||||
import { INDUSTRY_KEYWORDS } from '../keywords.js';
|
||||
import { classify } from '../classifier.js';
|
||||
|
||||
describe('classify - 可识别行业语义识别 (Req 1.2)', () => {
|
||||
// 每个行业各一条代表性描述,关键词强命中目标行业、不命中其他行业。
|
||||
const cases: ReadonlyArray<readonly [string, string]> = [
|
||||
['制造业', '为某大型制造工厂提供生产线车间装配的人员外包服务。'],
|
||||
['信息技术', '承接软件系统开发与互联网研发的IT项目外包服务。'],
|
||||
['金融业', '为银行金融机构及保险证券公司提供用工外包服务。'],
|
||||
['零售业', '为零售门店商超与电商企业提供促销人员外包服务。'],
|
||||
['物流业', '为物流仓储配送快递企业提供分拣人员外包服务。'],
|
||||
['建筑业', '为建筑施工工地的工程项目提供劳务用工外包服务。'],
|
||||
['餐饮业', '为连锁餐饮餐厅后厨提供厨工与备餐人员外包服务。'],
|
||||
['医疗健康', '为医疗医院提供护理及辅助岗位的人员用工外包服务。'],
|
||||
['客服服务', '为呼叫中心客服坐席团队提供运营人员外包服务。'],
|
||||
];
|
||||
|
||||
it('词典中的全部行业均被本测试覆盖', () => {
|
||||
const covered = new Set(cases.map(([industry]) => industry));
|
||||
for (const industry of Object.keys(INDUSTRY_KEYWORDS)) {
|
||||
expect(covered.has(industry)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
for (const [expectedIndustry, description] of cases) {
|
||||
it(`识别"${expectedIndustry}"行业`, () => {
|
||||
const result = classify(description);
|
||||
expect(result.industry).toBe(expectedIndustry);
|
||||
expect(result.industry).not.toBe(INDUSTRY_UNRECOGNIZED);
|
||||
expect(result.industryConfidence).toBeGreaterThan(0);
|
||||
expect(result.industryConfidence).toBeLessThanOrEqual(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('classify - 行业"未识别"分支 (Req 1.2)', () => {
|
||||
it('描述不含任何行业关键词时输出"未识别"且置信度为 0', () => {
|
||||
const result = classify('提供一般性人力用工外包,未提及任何具体所属领域信息。');
|
||||
expect(result.industry).toBe(INDUSTRY_UNRECOGNIZED);
|
||||
expect(result.industryConfidence).toBe(0);
|
||||
});
|
||||
|
||||
it('纯业务类型描述无行业线索时不误判行业', () => {
|
||||
const result = classify('本项目为劳务派遣用工,需派遣被派遣员工至客户现场。');
|
||||
expect(result.industry).toBe(INDUSTRY_UNRECOGNIZED);
|
||||
expect(result.industryConfidence).toBe(0);
|
||||
});
|
||||
|
||||
it('"未识别"分支不返回行业候选且无需行业确认', () => {
|
||||
const result = classify('提供综合性人员外包,描述中未涉及任何行业领域关键词。');
|
||||
expect(result.industry).toBe(INDUSTRY_UNRECOGNIZED);
|
||||
expect(result.needsIndustryConfirm).toBe(false);
|
||||
expect(result.industryCandidates).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Classifier 属性化测试 —— Property 1: 业务类型判定唯一且取最高置信。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 1: 业务类型判定唯一且取最高置信
|
||||
*
|
||||
* 对任意有效项目描述产生的业务类型置信分布,Classifier 输出的 businessType
|
||||
* 必为五类业务类型中 Confidence 最高的唯一一项。
|
||||
*
|
||||
* Validates: Requirements 1.1
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { BUSINESS_TYPE_VALUES } from '../../domain/common.js';
|
||||
import { BUSINESS_TYPE_KEYWORDS, type KeywordWeight } from '../keywords.js';
|
||||
import { classify, scoreLabels } from '../classifier.js';
|
||||
|
||||
/**
|
||||
* 五类业务类型的全部关键词(去重),用作智能生成器的取词池,
|
||||
* 以构造能驱动出多样置信分布的有效项目描述。
|
||||
*/
|
||||
const ALL_BUSINESS_TYPE_TERMS: string[] = Array.from(
|
||||
new Set(
|
||||
Object.values(BUSINESS_TYPE_KEYWORDS)
|
||||
.flat()
|
||||
.map((kw: KeywordWeight) => kw.term),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* 中文填充片段:保证生成的描述非纯空白且有效字符数 ≥ 10(Req 1.6 边界之外),
|
||||
* 使生成样本恒为"有效项目描述",聚焦验证 Property 1 本身。
|
||||
*/
|
||||
const FILLERS = [
|
||||
'本项目面向客户现场交付相关服务内容说明。',
|
||||
'甲方要求乙方按约定标准提供对应人力安排。',
|
||||
'合作期限内按月结算并接受过程质量监控。',
|
||||
'项目背景与范围如下所述供评估参考使用。',
|
||||
];
|
||||
|
||||
/**
|
||||
* 智能生成器:从业务类型关键词池中随机取若干词,混入随机填充文本,
|
||||
* 组合为有效项目描述。既覆盖"多类型关键词共现"导致的平局/接近平局分布,
|
||||
* 也覆盖"无任何关键词命中"的全零分布,输入空间贴合 classify 的判定逻辑。
|
||||
*/
|
||||
const validDescriptionArb: fc.Arbitrary<string> = fc
|
||||
.record({
|
||||
terms: fc.subarray(ALL_BUSINESS_TYPE_TERMS, { minLength: 0, maxLength: 6 }),
|
||||
filler: fc.constantFrom(...FILLERS),
|
||||
// 额外自由文本,增加多样性(可能为空)。
|
||||
extra: fc.string({ maxLength: 20 }),
|
||||
// 关键词与填充的拼接顺序随机化。
|
||||
shuffle: fc.boolean(),
|
||||
})
|
||||
.map(({ terms, filler, extra, shuffle }) => {
|
||||
const head = terms.join(',');
|
||||
const parts = shuffle ? [filler, head, extra] : [head, filler, extra];
|
||||
return parts.join('');
|
||||
});
|
||||
|
||||
describe('Property 1: 业务类型判定唯一且取最高置信 (Req 1.1)', () => {
|
||||
it('classify 的 businessType 恒为五类中置信度最高的唯一一项', () => {
|
||||
fc.assert(
|
||||
fc.property(validDescriptionArb, (description) => {
|
||||
const result = classify(description);
|
||||
|
||||
// 独立复算五类业务类型的置信分布(与实现共用同一打分函数与词典)。
|
||||
const distribution = scoreLabels(
|
||||
description,
|
||||
BUSINESS_TYPE_VALUES.map(
|
||||
(bt) => [bt, BUSINESS_TYPE_KEYWORDS[bt]] as const,
|
||||
),
|
||||
);
|
||||
|
||||
// (a) 判定结果必为五类之一。
|
||||
expect(BUSINESS_TYPE_VALUES).toContain(result.businessType);
|
||||
|
||||
// (b) 其置信度必等于全部五类中的最大置信度(取最高置信)。
|
||||
const maxConfidence = Math.max(
|
||||
...distribution.map((c) => c.confidence),
|
||||
);
|
||||
expect(result.businessTypeConfidence).toBe(maxConfidence);
|
||||
|
||||
const chosen = distribution.find(
|
||||
(c) => c.label === result.businessType,
|
||||
);
|
||||
expect(chosen).toBeDefined();
|
||||
expect(chosen!.confidence).toBe(maxConfidence);
|
||||
|
||||
// (c) 唯一性:在所有达到最大置信度的类型中,判定结果必为按业务类型
|
||||
// 声明顺序(BUSINESS_TYPE_VALUES)最靠前的那一项,确保输出唯一确定,
|
||||
// 不存在另一项同时被判定。
|
||||
const firstAtMax = distribution.find(
|
||||
(c) => c.confidence === maxConfidence,
|
||||
);
|
||||
expect(result.businessType).toBe(firstAtMax!.label);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Property 2: 置信度恒在有效值域内 的属性化测试(Classifier,Req 1.3)。
|
||||
*
|
||||
* 属性陈述:对任意分类结果,业务类型 Confidence 与行业 Confidence 均落在区间 [0, 1]
|
||||
* 内且保留两位小数。
|
||||
*
|
||||
* 本测试以智能生成器构造任意有效项目描述(有效字符数 ≥ 10 且非纯空白,满足 Req 1.6
|
||||
* 前置以避免 InsufficientInputError),其中混入业务类型与行业关键词片段,使识别打分
|
||||
* 真实跨越"无命中 / 部分命中 / 多类命中"的输入空间,从而充分覆盖置信度取值分布。
|
||||
*
|
||||
* 对每个分类结果断言:
|
||||
* - businessTypeConfidence ∈ [0, 1]
|
||||
* - industryConfidence ∈ [0, 1]
|
||||
* - 二者均为两位小数(confidence × 100 为整数,浮点容差内)
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 2: 置信度恒在有效值域内
|
||||
* Validates: Requirements 1.3
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { classify, countValidChars } from '../classifier.js';
|
||||
import { BUSINESS_TYPE_KEYWORDS, INDUSTRY_KEYWORDS } from '../keywords.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:构造任意有效项目描述。
|
||||
//
|
||||
// 输入空间设计:
|
||||
// - 关键词片段池汇集全部业务类型与行业关键词,使描述高概率命中识别词典,
|
||||
// 覆盖单类命中、多类命中、跨业务/行业混合命中等分支。
|
||||
// - 随机自由文本片段引入噪声与无命中情形。
|
||||
// - 末尾补足填充字符,保证有效字符数 ≥ 10(满足 Req 1.6 前置,避免抛错)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 全部识别关键词词条,作为可拼接的描述片段池。 */
|
||||
const KEYWORD_TERMS: string[] = [
|
||||
...Object.values(BUSINESS_TYPE_KEYWORDS).flatMap((kws) => kws.map((k) => k.term)),
|
||||
...Object.values(INDUSTRY_KEYWORDS).flatMap((kws) => kws.map((k) => k.term)),
|
||||
];
|
||||
|
||||
/** 片段:关键词词条或任意自由文本(含中英文与空白)。 */
|
||||
const fragmentArb: fc.Arbitrary<string> = fc.oneof(
|
||||
fc.constantFrom(...KEYWORD_TERMS),
|
||||
fc.string({ minLength: 0, maxLength: 12 }),
|
||||
fc.constantFrom('外包', '用工', '服务', '项目', '客户', '的', ',', '。', ' '),
|
||||
);
|
||||
|
||||
/**
|
||||
* 有效项目描述:拼接若干片段后补足填充字符使有效字符数 ≥ 10。
|
||||
* 保证生成的描述恒为合法输入(不触发 InsufficientInputError)。
|
||||
*/
|
||||
const validDescriptionArb: fc.Arbitrary<string> = fc
|
||||
.array(fragmentArb, { minLength: 1, maxLength: 8 })
|
||||
.map((fragments) => {
|
||||
let desc = fragments.join('');
|
||||
// 补足有效字符至 ≥ 10(使用非空白填充字符)。
|
||||
while (countValidChars(desc) < 10) {
|
||||
desc += '项';
|
||||
}
|
||||
return desc;
|
||||
});
|
||||
|
||||
/** 两位小数判定:value × 100 在浮点容差内为整数。 */
|
||||
function isTwoDecimals(value: number): boolean {
|
||||
return Math.abs(value * 100 - Math.round(value * 100)) < 1e-9;
|
||||
}
|
||||
|
||||
describe('Property 2: 置信度恒在有效值域内 (Req 1.3)', () => {
|
||||
it('业务类型与行业置信度均落在 [0,1] 且保留两位小数', () => {
|
||||
fc.assert(
|
||||
fc.property(validDescriptionArb, (description) => {
|
||||
const result = classify(description);
|
||||
|
||||
for (const confidence of [
|
||||
result.businessTypeConfidence,
|
||||
result.industryConfidence,
|
||||
]) {
|
||||
// 值域 [0, 1]。
|
||||
expect(confidence).toBeGreaterThanOrEqual(0);
|
||||
expect(confidence).toBeLessThanOrEqual(1);
|
||||
// 两位小数精度。
|
||||
expect(isTwoDecimals(confidence)).toBe(true);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Property 3: 低置信触发候选确认 的属性化测试(Classifier,Req 1.4, 1.5)。
|
||||
*
|
||||
* 属性陈述:对任意分类结果,当某判定的 Confidence 低于 0.6(行业判定附加条件:行业标记
|
||||
* 不为"未识别")时,System 必返回按 Confidence 降序排列、数量至多 3 项的候选列表并置
|
||||
* 确认标志为真;否则不触发确认。
|
||||
*
|
||||
* 本测试以智能生成器构造任意有效项目描述(有效字符数 ≥ 10 且非纯空白,满足 Req 1.6
|
||||
* 前置以避免 InsufficientInputError),其中混入业务类型与行业关键词片段,使识别打分
|
||||
* 真实跨越"无命中 / 部分命中 / 多类命中"的输入空间,从而覆盖高/低置信两类分支。
|
||||
*
|
||||
* 对每个分类结果断言:
|
||||
* 业务类型(Req 1.4):
|
||||
* - 置信度 < 0.6 → needsBusinessTypeConfirm 为真,候选非空、≤3 项且按置信度降序;
|
||||
* - 否则 needsBusinessTypeConfirm 为假,候选列表为空。
|
||||
* 行业(Req 1.5):
|
||||
* - 置信度 < 0.6 且行业 ≠ "未识别" → needsIndustryConfirm 为真,候选非空、≤3 项且降序;
|
||||
* - 否则 needsIndustryConfirm 为假,候选列表为空。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 3: 低置信触发候选确认
|
||||
* Validates: Requirements 1.4, 1.5
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
classify,
|
||||
countValidChars,
|
||||
CONFIRMATION_CONFIDENCE_THRESHOLD,
|
||||
MAX_CANDIDATES,
|
||||
type ScoredCandidate,
|
||||
} from '../classifier.js';
|
||||
import { BUSINESS_TYPE_KEYWORDS, INDUSTRY_KEYWORDS } from '../keywords.js';
|
||||
import { INDUSTRY_UNRECOGNIZED } from '../../domain/common.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:构造任意有效项目描述。
|
||||
//
|
||||
// 输入空间设计:
|
||||
// - 关键词片段池汇集全部业务类型与行业关键词,使描述高概率命中识别词典,
|
||||
// 覆盖单类命中、多类命中、跨业务/行业混合命中等分支(驱动高/低置信两类结果)。
|
||||
// - 随机自由文本片段引入噪声与无命中情形(行业可能落到"未识别",业务类型可能低置信)。
|
||||
// - 末尾补足填充字符,保证有效字符数 ≥ 10(满足 Req 1.6 前置,避免抛错)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 全部识别关键词词条,作为可拼接的描述片段池。 */
|
||||
const KEYWORD_TERMS: string[] = [
|
||||
...Object.values(BUSINESS_TYPE_KEYWORDS).flatMap((kws) => kws.map((k) => k.term)),
|
||||
...Object.values(INDUSTRY_KEYWORDS).flatMap((kws) => kws.map((k) => k.term)),
|
||||
];
|
||||
|
||||
/** 片段:关键词词条或任意自由文本(含中英文与空白)。 */
|
||||
const fragmentArb: fc.Arbitrary<string> = fc.oneof(
|
||||
fc.constantFrom(...KEYWORD_TERMS),
|
||||
fc.string({ minLength: 0, maxLength: 12 }),
|
||||
fc.constantFrom('外包', '用工', '服务', '项目', '客户', '的', ',', '。', ' '),
|
||||
);
|
||||
|
||||
/**
|
||||
* 有效项目描述:拼接若干片段后补足填充字符使有效字符数 ≥ 10。
|
||||
* 保证生成的描述恒为合法输入(不触发 InsufficientInputError)。
|
||||
*/
|
||||
const validDescriptionArb: fc.Arbitrary<string> = fc
|
||||
.array(fragmentArb, { minLength: 1, maxLength: 8 })
|
||||
.map((fragments) => {
|
||||
let desc = fragments.join('');
|
||||
while (countValidChars(desc) < 10) {
|
||||
desc += '项';
|
||||
}
|
||||
return desc;
|
||||
});
|
||||
|
||||
/** 断言候选列表按置信度由高到低排序(允许相等)。 */
|
||||
function expectSortedDesc<T extends string>(
|
||||
candidates: ScoredCandidate<T>[],
|
||||
): void {
|
||||
for (let i = 1; i < candidates.length; i++) {
|
||||
expect(candidates[i - 1]!.confidence).toBeGreaterThanOrEqual(
|
||||
candidates[i]!.confidence,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Property 3: 低置信触发候选确认 (Req 1.4, 1.5)', () => {
|
||||
it('低置信判定返回降序、≤3 项候选并置确认标志;否则不触发确认', () => {
|
||||
fc.assert(
|
||||
fc.property(validDescriptionArb, (description) => {
|
||||
const result = classify(description);
|
||||
|
||||
// --- 业务类型(Req 1.4)---
|
||||
const businessLow =
|
||||
result.businessTypeConfidence < CONFIRMATION_CONFIDENCE_THRESHOLD;
|
||||
expect(result.needsBusinessTypeConfirm).toBe(businessLow);
|
||||
if (businessLow) {
|
||||
// 触发确认:候选非空、至多 3 项、按置信度降序。
|
||||
expect(result.businessTypeCandidates.length).toBeGreaterThan(0);
|
||||
expect(result.businessTypeCandidates.length).toBeLessThanOrEqual(
|
||||
MAX_CANDIDATES,
|
||||
);
|
||||
expectSortedDesc(result.businessTypeCandidates);
|
||||
} else {
|
||||
// 不触发确认:候选列表为空。
|
||||
expect(result.businessTypeCandidates).toHaveLength(0);
|
||||
}
|
||||
|
||||
// --- 行业(Req 1.5)---
|
||||
const industryLow =
|
||||
result.industry !== INDUSTRY_UNRECOGNIZED &&
|
||||
result.industryConfidence < CONFIRMATION_CONFIDENCE_THRESHOLD;
|
||||
expect(result.needsIndustryConfirm).toBe(industryLow);
|
||||
if (industryLow) {
|
||||
// 触发确认:候选非空、至多 3 项、按置信度降序。
|
||||
expect(result.industryCandidates.length).toBeGreaterThan(0);
|
||||
expect(result.industryCandidates.length).toBeLessThanOrEqual(
|
||||
MAX_CANDIDATES,
|
||||
);
|
||||
expectSortedDesc(result.industryCandidates);
|
||||
} else {
|
||||
// 不触发确认(含行业为"未识别"或高置信):候选列表为空。
|
||||
expect(result.industryCandidates).toHaveLength(0);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Property 4: 描述信息不足必被拒绝
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 4: 描述信息不足必被拒绝
|
||||
*
|
||||
* 对任意为空、仅含空白字符或有效字符数少于 10 的项目描述,System 必拒绝执行业务类型与
|
||||
* 行业判定并返回信息不足错误;而任意有效字符数不少于 10 的非空白描述不会因长度被拒。
|
||||
*
|
||||
* Validates: Requirements 1.6
|
||||
*/
|
||||
|
||||
import fc from 'fast-check';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
classify,
|
||||
countValidChars,
|
||||
InsufficientInputError,
|
||||
MIN_VALID_CHARS,
|
||||
} from '../classifier.js';
|
||||
|
||||
/**
|
||||
* 一组明确的非空白(有效)字符:经 countValidChars 计数后每个均计为 1 个有效字符。
|
||||
* 覆盖中文、拉丁字母、数字与标点,以体现真实描述的多样性。
|
||||
*/
|
||||
const NON_WHITESPACE_CHARS =
|
||||
'一二三四五六七八九十甲乙丙丁项目外包派遣业务abcXYZ0123456789!@#。,'.split('');
|
||||
|
||||
/** 一组空白字符(含全角空格 U+3000),countValidChars 会将其全部剔除。 */
|
||||
const WHITESPACE_CHARS = [' ', '\t', '\n', '\r', '\f', '\v', '\u3000'];
|
||||
|
||||
/** 任意单个非空白有效字符。 */
|
||||
const nonWhitespaceCharArb = fc.constantFrom(...NON_WHITESPACE_CHARS);
|
||||
/** 任意单个空白字符。 */
|
||||
const whitespaceCharArb = fc.constantFrom(...WHITESPACE_CHARS);
|
||||
|
||||
/**
|
||||
* 将有效字符与空白字符交错拼接为一个字符串。
|
||||
* 拼接顺序不影响 countValidChars 的计数,但交错可更贴近真实输入分布。
|
||||
*/
|
||||
function interleave(valids: string[], spaces: string[]): string {
|
||||
const out: string[] = [];
|
||||
const max = Math.max(valids.length, spaces.length);
|
||||
for (let i = 0; i < max; i++) {
|
||||
if (i < valids.length) out.push(valids[i]!);
|
||||
if (i < spaces.length) out.push(spaces[i]!);
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成有效字符数严格小于 MIN_VALID_CHARS 的描述(含为空与仅含空白两种边界)。
|
||||
* 有效字符数 k ∈ [0, MIN_VALID_CHARS - 1];空白字符任意数量(不计入有效字符数)。
|
||||
*/
|
||||
const insufficientDescriptionArb: fc.Arbitrary<string> = fc
|
||||
.tuple(
|
||||
fc.array(nonWhitespaceCharArb, {
|
||||
minLength: 0,
|
||||
maxLength: MIN_VALID_CHARS - 1,
|
||||
}),
|
||||
fc.array(whitespaceCharArb, { minLength: 0, maxLength: 20 }),
|
||||
)
|
||||
.map(([valids, spaces]) => interleave(valids, spaces));
|
||||
|
||||
/**
|
||||
* 生成有效字符数不少于 MIN_VALID_CHARS 的非空白描述。
|
||||
* 有效字符数 k ∈ [MIN_VALID_CHARS, MIN_VALID_CHARS + 30];可附带任意空白字符。
|
||||
*/
|
||||
const sufficientDescriptionArb: fc.Arbitrary<string> = fc
|
||||
.tuple(
|
||||
fc.array(nonWhitespaceCharArb, {
|
||||
minLength: MIN_VALID_CHARS,
|
||||
maxLength: MIN_VALID_CHARS + 30,
|
||||
}),
|
||||
fc.array(whitespaceCharArb, { minLength: 0, maxLength: 20 }),
|
||||
)
|
||||
.map(([valids, spaces]) => interleave(valids, spaces));
|
||||
|
||||
describe('Property 4: 描述信息不足必被拒绝 (Req 1.6)', () => {
|
||||
it('任意有效字符数 < 10(含空 / 仅空白)的描述必被拒绝并抛 InsufficientInputError', () => {
|
||||
fc.assert(
|
||||
fc.property(insufficientDescriptionArb, (description) => {
|
||||
// 前置:生成器确保有效字符数严格小于阈值。
|
||||
expect(countValidChars(description)).toBeLessThan(MIN_VALID_CHARS);
|
||||
// 必拒绝执行判定并返回信息不足错误。
|
||||
expect(() => classify(description)).toThrow(InsufficientInputError);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('任意有效字符数 ≥ 10 的非空白描述不会因长度被拒', () => {
|
||||
fc.assert(
|
||||
fc.property(sufficientDescriptionArb, (description) => {
|
||||
// 前置:生成器确保有效字符数不小于阈值。
|
||||
expect(countValidChars(description)).toBeGreaterThanOrEqual(
|
||||
MIN_VALID_CHARS,
|
||||
);
|
||||
// classify 仅在信息不足时抛 InsufficientInputError;此处不应因长度被拒。
|
||||
expect(() => classify(description)).not.toThrow();
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Property 5: 确认值驱动后续加载
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 5: 确认值驱动后续加载
|
||||
*
|
||||
* 对任意评估者确认或修改后的(业务类型, 行业),System 后续加载模板必以该确认值为依据:
|
||||
* 将 confirmClassification 的输出(确认值)喂入 loadTemplate 时,所选中的模板必与确认后的
|
||||
* businessType / industry 一致;当评估者修改了 System 原判定时,加载以修改后的确认值(而非
|
||||
* 原判定值)为基准。
|
||||
*
|
||||
* Validates: Requirements 1.7
|
||||
*/
|
||||
|
||||
import fc from 'fast-check';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BUSINESS_TYPE_VALUES, type BusinessType } from '../../domain/common.js';
|
||||
import type { Template } from '../../domain/model.js';
|
||||
import { loadTemplate } from '../../config/loadTemplate.js';
|
||||
import { confirmClassification } from '../confirm.js';
|
||||
|
||||
/** 任意五类业务类型之一。 */
|
||||
const businessTypeArb: fc.Arbitrary<BusinessType> = fc.constantFrom(
|
||||
...BUSINESS_TYPE_VALUES,
|
||||
);
|
||||
|
||||
/** 一组确定的非空白行业名(含"未识别"分支),均经规范化后稳定。 */
|
||||
const INDUSTRY_POOL = [
|
||||
'制造业',
|
||||
'金融',
|
||||
'物流',
|
||||
'零售',
|
||||
'医疗',
|
||||
'信息技术',
|
||||
'建筑',
|
||||
'未识别',
|
||||
] as const;
|
||||
|
||||
/** 任意单个行业名(非空、非空白)。 */
|
||||
const baseIndustryArb = fc.constantFrom(...INDUSTRY_POOL);
|
||||
|
||||
/** 任意空白填充(可为空),用于检验确认环节的首尾裁剪贯穿到模板加载。 */
|
||||
const whitespaceArb = fc.stringOf(fc.constantFrom(' ', '\t', '\u3000'), {
|
||||
maxLength: 3,
|
||||
});
|
||||
|
||||
/**
|
||||
* 一份评估者确认/修改后的分类:
|
||||
* - `businessType`:确认的业务类型;
|
||||
* - `industryRaw`:评估者实际提交的行业文本(可带首尾空白);
|
||||
* - `industry`:规范化(裁剪)后的期望行业值。
|
||||
*/
|
||||
interface Classification {
|
||||
businessType: BusinessType;
|
||||
industryRaw: string;
|
||||
industry: string;
|
||||
}
|
||||
|
||||
const classificationArb: fc.Arbitrary<Classification> = fc
|
||||
.record({
|
||||
businessType: businessTypeArb,
|
||||
base: baseIndustryArb,
|
||||
pad: fc.tuple(whitespaceArb, whitespaceArb),
|
||||
})
|
||||
.map(({ businessType, base, pad }) => ({
|
||||
businessType,
|
||||
industryRaw: `${pad[0]}${base}${pad[1]}`,
|
||||
industry: base,
|
||||
}));
|
||||
|
||||
/** 构造一个最小但合法的 Template(loadTemplate 仅消费 businessType/industry/isDefault/id)。 */
|
||||
function makeTemplate(
|
||||
id: string,
|
||||
businessType: BusinessType,
|
||||
industry: string,
|
||||
isDefault: boolean,
|
||||
): Template {
|
||||
return {
|
||||
id,
|
||||
name: `${id}-${businessType}-${industry}`,
|
||||
businessType,
|
||||
industry,
|
||||
isDefault,
|
||||
riskModelConfig: {
|
||||
name: id,
|
||||
businessType,
|
||||
dimensions: [],
|
||||
redlines: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Property 5: 确认值驱动后续加载 (Req 1.7)', () => {
|
||||
it('确认值即加载依据:loadTemplate 选中与确认 businessType/industry 一致的模板', () => {
|
||||
fc.assert(
|
||||
fc.property(classificationArb, (c) => {
|
||||
const confirmed = confirmClassification(c.businessType, c.industryRaw);
|
||||
// 确认值以评估者提交(裁剪后)取值为准。
|
||||
expect(confirmed.businessType).toBe(c.businessType);
|
||||
expect(confirmed.industry).toBe(c.industry);
|
||||
|
||||
// 知识库:含确认组合的精确模板、该业务类型的默认模板。
|
||||
const exact = makeTemplate(
|
||||
't-exact',
|
||||
confirmed.businessType,
|
||||
confirmed.industry,
|
||||
false,
|
||||
);
|
||||
const fallback = makeTemplate(
|
||||
't-default',
|
||||
confirmed.businessType,
|
||||
'__业务类型默认__',
|
||||
true,
|
||||
);
|
||||
const templates: Template[] = [fallback, exact];
|
||||
|
||||
// 将确认值喂入后续加载:必以确认值为依据命中行业专用模板。
|
||||
const result = loadTemplate(
|
||||
confirmed.businessType,
|
||||
confirmed.industry,
|
||||
templates,
|
||||
);
|
||||
expect(result.template.id).toBe('t-exact');
|
||||
expect(result.matchedIndustrySpecific).toBe(true);
|
||||
expect(result.template.businessType).toBe(confirmed.businessType);
|
||||
expect(result.template.industry.trim()).toBe(confirmed.industry);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('评估者修改后以确认(修改)值而非原判定值为依据加载模板', () => {
|
||||
fc.assert(
|
||||
fc.property(classificationArb, classificationArb, (original, modified) => {
|
||||
// 仅考察确认值与原判定不同的情形(评估者确有修改)。
|
||||
fc.pre(
|
||||
original.businessType !== modified.businessType ||
|
||||
original.industry !== modified.industry,
|
||||
);
|
||||
|
||||
const confirmed = confirmClassification(
|
||||
modified.businessType,
|
||||
modified.industryRaw,
|
||||
);
|
||||
|
||||
// 知识库同时含「原判定」与「修改后确认值」两套精确模板。
|
||||
const tOriginal = makeTemplate(
|
||||
't-original',
|
||||
original.businessType,
|
||||
original.industry,
|
||||
false,
|
||||
);
|
||||
const tConfirmed = makeTemplate(
|
||||
't-confirmed',
|
||||
confirmed.businessType,
|
||||
confirmed.industry,
|
||||
false,
|
||||
);
|
||||
const templates: Template[] = [tOriginal, tConfirmed];
|
||||
|
||||
// 加载必跟随确认(修改)值,而非原判定值。
|
||||
const result = loadTemplate(
|
||||
confirmed.businessType,
|
||||
confirmed.industry,
|
||||
templates,
|
||||
);
|
||||
expect(result.template.id).toBe('t-confirmed');
|
||||
expect(result.template.businessType).toBe(confirmed.businessType);
|
||||
expect(result.template.industry.trim()).toBe(confirmed.industry);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Classifier 基础识别与输入校验单元测试(Req 1.1, 1.2, 1.3, 1.6)。
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BUSINESS_TYPE_VALUES, INDUSTRY_UNRECOGNIZED } from '../../domain/common.js';
|
||||
import {
|
||||
classify,
|
||||
countValidChars,
|
||||
InsufficientInputError,
|
||||
MIN_VALID_CHARS,
|
||||
toConfidence,
|
||||
} from '../classifier.js';
|
||||
|
||||
describe('countValidChars', () => {
|
||||
it('剔除空白字符后统计有效字符数', () => {
|
||||
expect(countValidChars(' ab\tc\n ')).toBe(3);
|
||||
expect(countValidChars('全角 空格')).toBe(4);
|
||||
expect(countValidChars('')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toConfidence', () => {
|
||||
it('夹取到 [0,1] 并保留两位小数', () => {
|
||||
expect(toConfidence(-0.5)).toBe(0);
|
||||
expect(toConfidence(1.5)).toBe(1);
|
||||
expect(toConfidence(0.3333)).toBe(0.33);
|
||||
expect(toConfidence(0.666)).toBe(0.67);
|
||||
expect(toConfidence(Number.NaN)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classify - 输入校验 (Req 1.6)', () => {
|
||||
it('空字符串被拒绝', () => {
|
||||
expect(() => classify('')).toThrow(InsufficientInputError);
|
||||
});
|
||||
|
||||
it('仅含空白字符被拒绝', () => {
|
||||
expect(() => classify(' \t \n ')).toThrow(InsufficientInputError);
|
||||
});
|
||||
|
||||
it('有效字符数少于 10 被拒绝', () => {
|
||||
expect(() => classify('短描述项目')).toThrow(InsufficientInputError);
|
||||
});
|
||||
|
||||
it('有效字符数恰为 10 不因长度被拒', () => {
|
||||
const desc = '一二三四五六七八九十';
|
||||
expect(countValidChars(desc)).toBe(MIN_VALID_CHARS);
|
||||
expect(() => classify(desc)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('classify - 业务类型判定 (Req 1.1)', () => {
|
||||
it('识别劳务派遣为最高置信类型', () => {
|
||||
const result = classify('本项目为劳务派遣用工,需派遣被派遣员工至客户现场。');
|
||||
expect(result.businessType).toBe('劳务派遣');
|
||||
});
|
||||
|
||||
it('识别 BPO 业务流程外包', () => {
|
||||
const result = classify('承接客户呼叫中心业务流程外包(BPO),提供客服坐席服务。');
|
||||
expect(result.businessType).toBe('BPO');
|
||||
});
|
||||
|
||||
it('返回的业务类型置信度为五类中的最高值', () => {
|
||||
const desc = '本项目为岗位外包,提供驻场人员外包用工服务。';
|
||||
const result = classify(desc);
|
||||
expect(BUSINESS_TYPE_VALUES).toContain(result.businessType);
|
||||
// 置信度应为有效值域内
|
||||
expect(result.businessTypeConfidence).toBeGreaterThanOrEqual(0);
|
||||
expect(result.businessTypeConfidence).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('无任何关键词命中时仍确定性返回唯一业务类型', () => {
|
||||
const result = classify('这是一段没有任何业务关键词的普通中文描述文本内容。');
|
||||
expect(BUSINESS_TYPE_VALUES).toContain(result.businessType);
|
||||
// 平局时按声明顺序取首项
|
||||
expect(result.businessType).toBe(BUSINESS_TYPE_VALUES[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classify - 行业判定 (Req 1.2)', () => {
|
||||
it('识别可判定行业', () => {
|
||||
const result = classify('为某大型制造工厂提供生产线车间用工外包服务。');
|
||||
expect(result.industry).toBe('制造业');
|
||||
expect(result.industryConfidence).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('行业不可判定时输出"未识别"且置信度为 0', () => {
|
||||
const result = classify('提供一般性人力用工外包,未提及具体所属领域信息。');
|
||||
expect(result.industry).toBe(INDUSTRY_UNRECOGNIZED);
|
||||
expect(result.industryConfidence).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classify - 置信度值域 (Req 1.3)', () => {
|
||||
it('两类置信度均落在 [0,1] 且至多两位小数', () => {
|
||||
const result = classify('为银行金融机构提供业务流程外包BPO客服坐席服务。');
|
||||
for (const c of [result.businessTypeConfidence, result.industryConfidence]) {
|
||||
expect(c).toBeGreaterThanOrEqual(0);
|
||||
expect(c).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(c * 100 - Math.round(c * 100))).toBeLessThan(1e-9);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Classifier 业务类型与行业识别(Req 1.1, 1.2, 1.3, 1.6)。
|
||||
*
|
||||
* 本模块实现基础识别与输入校验:
|
||||
* - 输入为空 / 仅含空白字符 / 有效字符数 < 10 时拒绝并抛 InsufficientInputError(Req 1.6)。
|
||||
* - 业务类型判定为五类中 Confidence 最高的唯一一项(Req 1.1)。
|
||||
* - 行业无法判定时输出标记"未识别"(Req 1.2)。
|
||||
* - 业务类型与行业各输出取值 [0,1]、保留两位小数的 Confidence(Req 1.3)。
|
||||
* - 当业务类型 Confidence < 0.6 时,返回按 Confidence 降序、至多 3 项的候选业务类型列表
|
||||
* 并置 needsBusinessTypeConfirm 为真(Req 1.4)。
|
||||
* - 当行业 Confidence < 0.6 且行业标记不为"未识别"时,返回按 Confidence 降序、至多 3 项的
|
||||
* 候选行业列表并置 needsIndustryConfirm 为真(Req 1.5)。
|
||||
*
|
||||
* 确认值驱动(Req 1.7)由后续任务实现。
|
||||
*/
|
||||
|
||||
import {
|
||||
BUSINESS_TYPE_VALUES,
|
||||
INDUSTRY_UNRECOGNIZED,
|
||||
type BusinessType,
|
||||
type Confidence,
|
||||
type Industry,
|
||||
} from '../domain/common.js';
|
||||
import {
|
||||
BUSINESS_TYPE_KEYWORDS,
|
||||
INDUSTRY_KEYWORDS,
|
||||
type KeywordWeight,
|
||||
} from './keywords.js';
|
||||
|
||||
/** 有效项目描述所需的最小有效字符数(Req 1.6)。 */
|
||||
export const MIN_VALID_CHARS = 10;
|
||||
|
||||
/**
|
||||
* 触发候选确认的置信度阈值(Req 1.4, 1.5)。
|
||||
* 判定置信度严格低于该阈值时,返回候选列表并请求评估者确认。
|
||||
*/
|
||||
export const CONFIRMATION_CONFIDENCE_THRESHOLD = 0.6;
|
||||
|
||||
/** 候选列表的最大长度(Req 1.4, 1.5:至多 3 项)。 */
|
||||
export const MAX_CANDIDATES = 3;
|
||||
|
||||
/**
|
||||
* 项目描述信息不足错误(Req 1.6)。
|
||||
* 当描述为空、仅含空白字符或有效字符数少于 MIN_VALID_CHARS 时抛出。
|
||||
*/
|
||||
export class InsufficientInputError extends Error {
|
||||
constructor(
|
||||
message = `项目描述信息不足:有效字符数须不少于 ${MIN_VALID_CHARS} 个`,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'InsufficientInputError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别结果(Req 1.1-1.5)。
|
||||
*/
|
||||
export interface ClassificationResult {
|
||||
/** 业务类型判定:五类中 Confidence 最高的唯一一项(Req 1.1)。 */
|
||||
businessType: BusinessType;
|
||||
/** 业务类型判定的置信度,取值 [0,1]、两位小数(Req 1.3)。 */
|
||||
businessTypeConfidence: Confidence;
|
||||
/**
|
||||
* 候选业务类型列表:按 Confidence 由高到低排序、至多 3 项(Req 1.4)。
|
||||
* 仅在 needsBusinessTypeConfirm 为真时具语义,否则为空数组。
|
||||
*/
|
||||
businessTypeCandidates: ScoredCandidate<BusinessType>[];
|
||||
/**
|
||||
* 业务类型确认标志:businessTypeConfidence < 0.6 时为真(Req 1.4)。
|
||||
* 为真表示需向评估者展示候选列表并请求确认。
|
||||
*/
|
||||
needsBusinessTypeConfirm: boolean;
|
||||
/** 行业判定;无法判定时为"未识别"(Req 1.2)。 */
|
||||
industry: Industry;
|
||||
/** 行业判定的置信度,取值 [0,1]、两位小数(Req 1.3)。 */
|
||||
industryConfidence: Confidence;
|
||||
/**
|
||||
* 候选行业列表:按 Confidence 由高到低排序、至多 3 项(Req 1.5)。
|
||||
* 仅在 needsIndustryConfirm 为真时具语义,否则为空数组。
|
||||
*/
|
||||
industryCandidates: ScoredCandidate<Industry>[];
|
||||
/**
|
||||
* 行业确认标志:industryConfidence < 0.6 且行业标记不为"未识别"时为真(Req 1.5)。
|
||||
* 为真表示需向评估者展示候选列表并请求确认。
|
||||
*/
|
||||
needsIndustryConfirm: boolean;
|
||||
}
|
||||
|
||||
/** 候选项:标识与其置信度。供业务类型与行业打分复用。 */
|
||||
export interface ScoredCandidate<T extends string> {
|
||||
/** 候选标识(业务类型或行业)。 */
|
||||
label: T;
|
||||
/** 置信度,取值 [0,1]、两位小数。 */
|
||||
confidence: Confidence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计有效字符数:剔除全部空白字符(含半角/全角空格、制表、换行)后的字符数(Req 1.6)。
|
||||
*/
|
||||
export function countValidChars(text: string): number {
|
||||
// \s 覆盖常见空白;额外剔除全角空格 U+3000。
|
||||
return text.replace(/[\s\u3000]/g, '').length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将任意实数夹取到 [0,1] 并保留两位小数(Req 1.3)。
|
||||
*/
|
||||
export function toConfidence(value: number): Confidence {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
const clamped = Math.min(1, Math.max(0, value));
|
||||
return Math.round(clamped * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算单个标签对文本的原始匹配分:累加全部命中关键词的权重(大小写不敏感)。
|
||||
*/
|
||||
function rawMatchScore(text: string, keywords: KeywordWeight[]): number {
|
||||
const haystack = text.toLowerCase();
|
||||
let score = 0;
|
||||
for (const { term, weight } of keywords) {
|
||||
if (haystack.includes(term.toLowerCase())) {
|
||||
score += weight;
|
||||
}
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对一组标签按关键词词典打分并归一化为置信度分布。
|
||||
*
|
||||
* 归一化:confidence = rawScore / Σ rawScore,落在 [0,1];全部为 0 时返回全 0 分布。
|
||||
* 返回顺序与传入 entries 顺序一致,保证确定性(平局消歧依赖该顺序)。
|
||||
*/
|
||||
export function scoreLabels<T extends string>(
|
||||
text: string,
|
||||
entries: ReadonlyArray<readonly [T, KeywordWeight[]]>,
|
||||
): ScoredCandidate<T>[] {
|
||||
const raw = entries.map(
|
||||
([label, keywords]) => [label, rawMatchScore(text, keywords)] as const,
|
||||
);
|
||||
const total = raw.reduce((sum, [, s]) => sum + s, 0);
|
||||
return raw.map(([label, s]) => ({
|
||||
label,
|
||||
confidence: total > 0 ? toConfidence(s / total) : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从置信度分布中选出唯一最高项(Req 1.1)。
|
||||
* 平局时按 candidates 顺序(即词典声明顺序)确定性取靠前者,保证输出唯一确定。
|
||||
*/
|
||||
function pickHighest<T extends string>(
|
||||
candidates: ScoredCandidate<T>[],
|
||||
): ScoredCandidate<T> {
|
||||
// candidates 至少含一项(业务类型恒为五类,调用方保证非空)。
|
||||
let best = candidates[0]!;
|
||||
for (let i = 1; i < candidates.length; i++) {
|
||||
const current = candidates[i]!;
|
||||
if (current.confidence > best.confidence) {
|
||||
best = current;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选出按 Confidence 由高到低排序、至多 MAX_CANDIDATES 项的候选列表(Req 1.4, 1.5)。
|
||||
*
|
||||
* @param candidates 已打分的候选分布(顺序为词典声明顺序)。
|
||||
* @param includeZero 是否保留置信度为 0 的候选;行业候选剔除 0("未识别"无候选意义)。
|
||||
*
|
||||
* 排序采用稳定排序:Confidence 相同的候选保持其在 candidates 中的相对顺序,
|
||||
* 保证同一输入恒产生同一候选顺序(确定性,支撑属性化测试)。
|
||||
*/
|
||||
function topCandidates<T extends string>(
|
||||
candidates: ScoredCandidate<T>[],
|
||||
includeZero: boolean,
|
||||
): ScoredCandidate<T>[] {
|
||||
const pool = includeZero
|
||||
? candidates.slice()
|
||||
: candidates.filter((c) => c.confidence > 0);
|
||||
// Array.prototype.sort 在 Node ≥ 11 为稳定排序,平局保持原相对顺序(确定性消歧)。
|
||||
pool.sort((a, b) => b.confidence - a.confidence);
|
||||
return pool.slice(0, MAX_CANDIDATES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务类型与行业识别(Req 1.1, 1.2, 1.3, 1.6)。
|
||||
*
|
||||
* @param description 项目描述文本。
|
||||
* @throws {InsufficientInputError} 描述为空、仅含空白字符或有效字符数 < 10 时(Req 1.6)。
|
||||
* @returns 业务类型与行业判定及各自置信度。
|
||||
*/
|
||||
export function classify(description: string): ClassificationResult {
|
||||
// Req 1.6:输入校验前置——为空 / 仅空白 / 有效字符 < 10 一律拒绝。
|
||||
if (countValidChars(description) < MIN_VALID_CHARS) {
|
||||
throw new InsufficientInputError();
|
||||
}
|
||||
|
||||
// Req 1.1:业务类型取五类中置信度最高的唯一一项(平局按声明顺序消歧)。
|
||||
const businessTypeScores = scoreLabels(
|
||||
description,
|
||||
BUSINESS_TYPE_VALUES.map(
|
||||
(bt) => [bt, BUSINESS_TYPE_KEYWORDS[bt]] as const,
|
||||
),
|
||||
);
|
||||
const topBusinessType = pickHighest(businessTypeScores);
|
||||
|
||||
// Req 1.2:行业无任何关键词命中(置信度全 0)时输出"未识别"。
|
||||
const industryScores = scoreLabels(
|
||||
description,
|
||||
Object.entries(INDUSTRY_KEYWORDS) as ReadonlyArray<
|
||||
readonly [string, KeywordWeight[]]
|
||||
>,
|
||||
);
|
||||
const topIndustry = pickHighest(industryScores);
|
||||
const industryDeterminable = topIndustry.confidence > 0;
|
||||
const industry = industryDeterminable
|
||||
? topIndustry.label
|
||||
: INDUSTRY_UNRECOGNIZED;
|
||||
const industryConfidence = industryDeterminable ? topIndustry.confidence : 0;
|
||||
|
||||
// Req 1.4:业务类型置信度 < 0.6 时置确认标志并返回 ≤3 项按置信度降序的候选。
|
||||
const needsBusinessTypeConfirm =
|
||||
topBusinessType.confidence < CONFIRMATION_CONFIDENCE_THRESHOLD;
|
||||
// Req 1.5:行业置信度 < 0.6 且行业标记不为"未识别"时置确认标志并返回候选。
|
||||
const needsIndustryConfirm =
|
||||
industry !== INDUSTRY_UNRECOGNIZED &&
|
||||
industryConfidence < CONFIRMATION_CONFIDENCE_THRESHOLD;
|
||||
|
||||
return {
|
||||
businessType: topBusinessType.label,
|
||||
businessTypeConfidence: topBusinessType.confidence,
|
||||
businessTypeCandidates: needsBusinessTypeConfirm
|
||||
? topCandidates(businessTypeScores, true)
|
||||
: [],
|
||||
needsBusinessTypeConfirm,
|
||||
industry,
|
||||
industryConfidence,
|
||||
industryCandidates: needsIndustryConfirm
|
||||
? topCandidates(industryScores, false)
|
||||
: [],
|
||||
needsIndustryConfirm,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Classifier 确认值驱动(Req 1.7)。
|
||||
*
|
||||
* 当评估者确认或修改 System 判定的业务类型与行业后,本模块校验并产出
|
||||
* 一个"确认后的分类值"(ConfirmedClassification),作为后续 Config_Center
|
||||
* loadTemplate(businessType, industry) 加载模板的唯一依据(Req 1.7 → Req 2.1)。
|
||||
*
|
||||
* 设计取舍:design.md 以 `confirmClassification(assessmentId, businessType, industry)`
|
||||
* 的有状态伪签名描述该契约。此处按本仓库的纯函数模块风格,将其实现为返回确认值的
|
||||
* 纯函数:输入为评估者确认/修改后的业务类型与行业,输出为校验通过、可直接供下游
|
||||
* 模板加载消费的确认值。调用方据此更新对应 Assessment 的 businessType/industry。
|
||||
*/
|
||||
|
||||
import {
|
||||
BUSINESS_TYPE_VALUES,
|
||||
INDUSTRY_UNRECOGNIZED,
|
||||
type BusinessType,
|
||||
type Industry,
|
||||
} from '../domain/common.js';
|
||||
|
||||
/**
|
||||
* 确认后的分类值(Req 1.7)。
|
||||
* 以评估者确认/修改后的取值为准,作为后续加载模板的依据。
|
||||
*/
|
||||
export interface ConfirmedClassification {
|
||||
/** 评估者确认/修改后的业务类型(五类之一)。 */
|
||||
businessType: BusinessType;
|
||||
/** 评估者确认/修改后的行业标记(非空,可为"未识别")。 */
|
||||
industry: Industry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类确认无效错误(Req 1.7)。
|
||||
* 当确认的业务类型不属于五类、或行业为空 / 仅含空白字符时抛出。
|
||||
*/
|
||||
export class InvalidConfirmationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidConfirmationError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 判定给定字符串是否为合法的五类业务类型之一。 */
|
||||
export function isBusinessType(value: string): value is BusinessType {
|
||||
return (BUSINESS_TYPE_VALUES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 采用评估者确认/修改后的业务类型与行业作为后续加载模板的依据(Req 1.7)。
|
||||
*
|
||||
* 行为:以评估者提交的取值为准(无论其是否修改了 System 的判定),校验合法后
|
||||
* 产出确认值供下游 loadTemplate 消费。
|
||||
*
|
||||
* @param businessType 评估者确认/修改后的业务类型,须为五类之一。
|
||||
* @param industry 评估者确认/修改后的行业;前后空白将被裁剪,须非空。
|
||||
* 允许取"未识别"以表达评估者确认行业不可判定(下游据此回退默认模板,Req 2.2)。
|
||||
* @throws {InvalidConfirmationError} 业务类型非五类之一,或行业为空 / 仅含空白字符时。
|
||||
* @returns 校验通过、可直接供后续加载模板消费的确认值。
|
||||
*/
|
||||
export function confirmClassification(
|
||||
businessType: BusinessType,
|
||||
industry: Industry,
|
||||
): ConfirmedClassification {
|
||||
if (!isBusinessType(businessType)) {
|
||||
throw new InvalidConfirmationError(
|
||||
`确认的业务类型无效:须为 ${BUSINESS_TYPE_VALUES.join(' / ')} 之一`,
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedIndustry = industry.trim();
|
||||
if (normalizedIndustry.length === 0) {
|
||||
throw new InvalidConfirmationError(
|
||||
`确认的行业无效:不可为空或仅含空白字符(不可判定时请取"${INDUSTRY_UNRECOGNIZED}")`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
businessType,
|
||||
industry: normalizedIndustry,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Classifier 模块聚合导出(业务类型与行业识别,Req 1)。
|
||||
*/
|
||||
|
||||
export * from './keywords.js';
|
||||
export * from './classifier.js';
|
||||
export * from './confirm.js';
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Classifier 关键词词典(Req 1.1, 1.2)。
|
||||
*
|
||||
* 业务类型与行业的识别采用确定性的关键词匹配打分:对项目描述文本逐一匹配每个
|
||||
* 业务类型 / 行业的关键词,按命中关键词的权重累加得到原始分,再归一化为 [0,1] 置信度。
|
||||
*
|
||||
* 词典内容与排序均稳定,保证同一输入恒产生同一识别结果(确定性,支撑属性化测试)。
|
||||
* 后续可由 Knowledge_Base 行业分区扩展,但识别算法本身不变(配置驱动思想)。
|
||||
*/
|
||||
|
||||
import type { BusinessType } from '../domain/common.js';
|
||||
|
||||
/** 关键词及其权重;权重越高表示该词对判定的指示性越强。 */
|
||||
export interface KeywordWeight {
|
||||
/** 关键词(大小写不敏感匹配)。 */
|
||||
term: string;
|
||||
/** 匹配权重(正数)。 */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 五类业务类型的关键词词典(Req 1.1)。
|
||||
* 键顺序与 BUSINESS_TYPE_VALUES 一致,用于平局时的确定性消歧(优先级由前到后)。
|
||||
*/
|
||||
export const BUSINESS_TYPE_KEYWORDS: Record<BusinessType, KeywordWeight[]> = {
|
||||
岗位外包: [
|
||||
{ term: '岗位外包', weight: 5 },
|
||||
{ term: '岗位', weight: 2 },
|
||||
{ term: '驻场', weight: 2 },
|
||||
{ term: '人员外包', weight: 3 },
|
||||
{ term: '用工', weight: 1 },
|
||||
],
|
||||
劳务派遣: [
|
||||
{ term: '劳务派遣', weight: 5 },
|
||||
{ term: '派遣', weight: 3 },
|
||||
{ term: '用工比例', weight: 2 },
|
||||
{ term: '被派遣', weight: 3 },
|
||||
],
|
||||
'业务/服务外包': [
|
||||
{ term: '业务外包', weight: 5 },
|
||||
{ term: '服务外包', weight: 5 },
|
||||
{ term: '业务/服务', weight: 4 },
|
||||
{ term: '外包服务', weight: 3 },
|
||||
{ term: '服务', weight: 1 },
|
||||
],
|
||||
BPO: [
|
||||
{ term: 'BPO', weight: 5 },
|
||||
{ term: '业务流程外包', weight: 5 },
|
||||
{ term: '流程外包', weight: 4 },
|
||||
{ term: '呼叫中心', weight: 3 },
|
||||
{ term: '客服', weight: 2 },
|
||||
{ term: '坐席', weight: 2 },
|
||||
],
|
||||
项目制外包: [
|
||||
{ term: '项目制外包', weight: 5 },
|
||||
{ term: '项目制', weight: 4 },
|
||||
{ term: '项目外包', weight: 4 },
|
||||
{ term: '项目交付', weight: 3 },
|
||||
{ term: '交钥匙', weight: 3 },
|
||||
{ term: '总包', weight: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 行业关键词词典(Req 1.2)。
|
||||
* 键为行业标识;无任何行业关键词命中时,Classifier 输出"未识别"。
|
||||
* 键的插入顺序用于平局时的确定性消歧。
|
||||
*/
|
||||
export const INDUSTRY_KEYWORDS: Record<string, KeywordWeight[]> = {
|
||||
制造业: [
|
||||
{ term: '制造', weight: 4 },
|
||||
{ term: '工厂', weight: 3 },
|
||||
{ term: '生产线', weight: 3 },
|
||||
{ term: '车间', weight: 3 },
|
||||
{ term: '装配', weight: 2 },
|
||||
],
|
||||
信息技术: [
|
||||
{ term: '软件', weight: 4 },
|
||||
{ term: '互联网', weight: 3 },
|
||||
{ term: '系统开发', weight: 4 },
|
||||
{ term: '研发', weight: 2 },
|
||||
{ term: 'IT', weight: 3 },
|
||||
],
|
||||
金融业: [
|
||||
{ term: '银行', weight: 4 },
|
||||
{ term: '金融', weight: 4 },
|
||||
{ term: '保险', weight: 3 },
|
||||
{ term: '证券', weight: 3 },
|
||||
],
|
||||
零售业: [
|
||||
{ term: '零售', weight: 4 },
|
||||
{ term: '门店', weight: 3 },
|
||||
{ term: '商超', weight: 3 },
|
||||
{ term: '电商', weight: 3 },
|
||||
],
|
||||
物流业: [
|
||||
{ term: '物流', weight: 4 },
|
||||
{ term: '仓储', weight: 3 },
|
||||
{ term: '配送', weight: 3 },
|
||||
{ term: '快递', weight: 3 },
|
||||
],
|
||||
建筑业: [
|
||||
{ term: '建筑', weight: 4 },
|
||||
{ term: '施工', weight: 4 },
|
||||
{ term: '工地', weight: 3 },
|
||||
{ term: '工程', weight: 2 },
|
||||
],
|
||||
餐饮业: [
|
||||
{ term: '餐饮', weight: 4 },
|
||||
{ term: '餐厅', weight: 3 },
|
||||
{ term: '后厨', weight: 3 },
|
||||
],
|
||||
医疗健康: [
|
||||
{ term: '医疗', weight: 4 },
|
||||
{ term: '医院', weight: 4 },
|
||||
{ term: '护理', weight: 3 },
|
||||
],
|
||||
客服服务: [
|
||||
{ term: '呼叫中心', weight: 4 },
|
||||
{ term: '客服', weight: 3 },
|
||||
{ term: '坐席', weight: 3 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 合规判定/费用测算的代表性算例单元测试(Req 16.2)。
|
||||
*
|
||||
* 以社保缴费基数、社保费率、约定月薪、工作年限与劳务派遣比例的代表性数值,
|
||||
* 验证:
|
||||
* - 经济补偿月数折算(N 规则):满整年、不足六个月(半月)、六个月以上不满一年(按一年计)。
|
||||
* - 经济补偿金额 N = 月数 × 月薪,N+1 = N + 月薪。
|
||||
* - 社保缴费金额 = 缴费基数 × 费率。
|
||||
* - 测算金额标注所依据的规则项与输入项。
|
||||
*
|
||||
* 与既有属性化测试(Property 53/54)互补:此处固定具体数值核对计算正确性。
|
||||
* Validates: Requirements 16.2
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ComplianceRuleSet } from '../../domain/region.js';
|
||||
import { REGION_CN } from '../../domain/region.js';
|
||||
import type { CnComplianceInput } from '../judgment.js';
|
||||
import {
|
||||
assessCnCompliance,
|
||||
computeCompensationMonths,
|
||||
} from '../judgment.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 代表性规则集:填充具体的社保下限、最低工资标准(CN 默认规则集以占位值发布)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const REPRESENTATIVE_RULE_SET: ComplianceRuleSet = {
|
||||
region: REGION_CN,
|
||||
socialInsuranceBase: { lowerBound: 4000 },
|
||||
economicCompensation: {
|
||||
nRule:
|
||||
'按在本单位工作年限,每满一年支付一个月工资;六个月以上不满一年按一年计;不满六个月支付半个月工资(N)',
|
||||
nPlusOneRule: '在 N 的基础上额外支付一个月工资作为代通知金(N+1)',
|
||||
},
|
||||
dispatchRatioCap: 0.1,
|
||||
minimumWage: { byLocality: { 上海: 2690, 北京: 2420 } },
|
||||
};
|
||||
|
||||
/** 构造一份合法的基准输入,便于各算例按需覆盖字段。 */
|
||||
function baseInput(overrides: Partial<CnComplianceInput> = {}): CnComplianceInput {
|
||||
return {
|
||||
socialInsuranceBase: 8000,
|
||||
socialInsuranceContributionRate: 0.16,
|
||||
monthlyWage: 10000,
|
||||
serviceYears: 3,
|
||||
compensationScheme: 'N',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** 从测算结果中按标签取金额。 */
|
||||
function amountByLabel(
|
||||
result: ReturnType<typeof assessCnCompliance>,
|
||||
label: string,
|
||||
): number {
|
||||
const found = result.amounts.find((a) => a.label === label);
|
||||
expect(found, `应存在标签为「${label}」的测算金额`).toBeDefined();
|
||||
// 上一行断言已保证 found 存在,这里非空断言用于满足类型收窄。
|
||||
return found!.amount;
|
||||
}
|
||||
|
||||
describe('经济补偿月数折算(N 规则)— 代表性算例', () => {
|
||||
it('满整年:3 年 → 3 个月', () => {
|
||||
const months = computeCompensationMonths(3);
|
||||
expect(months.fullYears).toBe(3);
|
||||
expect(months.remainderMonths).toBe(0);
|
||||
expect(months.total).toBe(3);
|
||||
});
|
||||
|
||||
it('不满六个月:3 年又 3 个月 → 3.5 个月(半月)', () => {
|
||||
// 3 + 3/12 = 3.25 年,余 3 个月(<6 个月)→ 折半月。
|
||||
const months = computeCompensationMonths(3.25);
|
||||
expect(months.fullYears).toBe(3);
|
||||
expect(months.remainderMonths).toBe(0.5);
|
||||
expect(months.total).toBe(3.5);
|
||||
});
|
||||
|
||||
it('恰满六个月:3 年又 6 个月 → 4 个月(按一年计)', () => {
|
||||
// 六个月以上不满一年按一年计。
|
||||
const months = computeCompensationMonths(3.5);
|
||||
expect(months.fullYears).toBe(3);
|
||||
expect(months.remainderMonths).toBe(1);
|
||||
expect(months.total).toBe(4);
|
||||
});
|
||||
|
||||
it('六个月以上不满一年:5 年又 9 个月 → 6 个月(按一年计)', () => {
|
||||
const months = computeCompensationMonths(5.75);
|
||||
expect(months.fullYears).toBe(5);
|
||||
expect(months.remainderMonths).toBe(1);
|
||||
expect(months.total).toBe(6);
|
||||
});
|
||||
|
||||
it('不足一年:0.4 年(约 4.8 个月)→ 0.5 个月(半月)', () => {
|
||||
const months = computeCompensationMonths(0.4);
|
||||
expect(months.fullYears).toBe(0);
|
||||
expect(months.remainderMonths).toBe(0.5);
|
||||
expect(months.total).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('经济补偿金额 N 与 N+1 — 代表性算例', () => {
|
||||
it('5 年整、月薪 10000:N = 50000,N+1 = 60000', () => {
|
||||
const result = assessCnCompliance(
|
||||
baseInput({ monthlyWage: 10000, serviceYears: 5, compensationScheme: 'N' }),
|
||||
REPRESENTATIVE_RULE_SET,
|
||||
);
|
||||
|
||||
// N = 月数 × 月薪 = 5 × 10000。
|
||||
expect(amountByLabel(result, '经济补偿(N)')).toBe(50000);
|
||||
// N+1 = N + 月薪 = 50000 + 10000。
|
||||
expect(amountByLabel(result, '经济补偿(N+1)')).toBe(60000);
|
||||
});
|
||||
|
||||
it('3 年又 6 个月、月薪 8000:月数按一年计 → 4 个月,N = 32000,N+1 = 40000', () => {
|
||||
const result = assessCnCompliance(
|
||||
baseInput({ monthlyWage: 8000, serviceYears: 3.5 }),
|
||||
REPRESENTATIVE_RULE_SET,
|
||||
);
|
||||
|
||||
// 月数 4(3 满年 + 六个月以上按一年计)× 8000 = 32000。
|
||||
expect(amountByLabel(result, '经济补偿(N)')).toBe(32000);
|
||||
expect(amountByLabel(result, '经济补偿(N+1)')).toBe(40000);
|
||||
});
|
||||
|
||||
it('3 年又 3 个月、月薪 12000:月数折半月 → 3.5 个月,N = 42000,N+1 = 54000', () => {
|
||||
const result = assessCnCompliance(
|
||||
baseInput({ monthlyWage: 12000, serviceYears: 3.25 }),
|
||||
REPRESENTATIVE_RULE_SET,
|
||||
);
|
||||
|
||||
// 月数 3.5 × 12000 = 42000;N+1 = 42000 + 12000 = 54000。
|
||||
expect(amountByLabel(result, '经济补偿(N)')).toBe(42000);
|
||||
expect(amountByLabel(result, '经济补偿(N+1)')).toBe(54000);
|
||||
});
|
||||
|
||||
it('N+1 恒等于 N 加一个月薪(代表性数值核验)', () => {
|
||||
const result = assessCnCompliance(
|
||||
baseInput({ monthlyWage: 9500, serviceYears: 2 }),
|
||||
REPRESENTATIVE_RULE_SET,
|
||||
);
|
||||
|
||||
const n = amountByLabel(result, '经济补偿(N)');
|
||||
const nPlusOne = amountByLabel(result, '经济补偿(N+1)');
|
||||
expect(n).toBe(19000); // 2 × 9500
|
||||
expect(nPlusOne - n).toBe(9500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('社保缴费金额 = 基数 × 费率 — 代表性算例', () => {
|
||||
it('基数 8000、费率 0.16 → 1280', () => {
|
||||
const result = assessCnCompliance(
|
||||
baseInput({ socialInsuranceBase: 8000, socialInsuranceContributionRate: 0.16 }),
|
||||
REPRESENTATIVE_RULE_SET,
|
||||
);
|
||||
expect(amountByLabel(result, '社保缴费金额')).toBe(1280);
|
||||
});
|
||||
|
||||
it('基数 6000、费率 0.105 → 630', () => {
|
||||
const result = assessCnCompliance(
|
||||
baseInput({ socialInsuranceBase: 6000, socialInsuranceContributionRate: 0.105 }),
|
||||
REPRESENTATIVE_RULE_SET,
|
||||
);
|
||||
expect(amountByLabel(result, '社保缴费金额')).toBeCloseTo(630, 9);
|
||||
});
|
||||
|
||||
it('费率为 0 → 社保缴费金额为 0', () => {
|
||||
const result = assessCnCompliance(
|
||||
baseInput({ socialInsuranceBase: 12000, socialInsuranceContributionRate: 0 }),
|
||||
REPRESENTATIVE_RULE_SET,
|
||||
);
|
||||
expect(amountByLabel(result, '社保缴费金额')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('相关合规费用合计与金额标注 — 代表性算例', () => {
|
||||
it('N 计法:合计 = 经济补偿(N) + 社保缴费金额', () => {
|
||||
const result = assessCnCompliance(
|
||||
baseInput({
|
||||
monthlyWage: 10000,
|
||||
serviceYears: 5,
|
||||
socialInsuranceBase: 8000,
|
||||
socialInsuranceContributionRate: 0.16,
|
||||
compensationScheme: 'N',
|
||||
}),
|
||||
REPRESENTATIVE_RULE_SET,
|
||||
);
|
||||
|
||||
// 经济补偿(N)= 50000,社保缴费金额 = 1280 → 合计 51280。
|
||||
expect(amountByLabel(result, '相关合规费用合计')).toBe(51280);
|
||||
});
|
||||
|
||||
it('N+1 计法:合计 = 经济补偿(N+1) + 社保缴费金额', () => {
|
||||
const result = assessCnCompliance(
|
||||
baseInput({
|
||||
monthlyWage: 10000,
|
||||
serviceYears: 5,
|
||||
socialInsuranceBase: 8000,
|
||||
socialInsuranceContributionRate: 0.16,
|
||||
compensationScheme: 'N+1',
|
||||
}),
|
||||
REPRESENTATIVE_RULE_SET,
|
||||
);
|
||||
|
||||
// 经济补偿(N+1)= 60000,社保缴费金额 = 1280 → 合计 61280。
|
||||
expect(amountByLabel(result, '相关合规费用合计')).toBe(61280);
|
||||
});
|
||||
|
||||
it('每项测算金额均标注规则项与输入项(Req 16.2)', () => {
|
||||
const result = assessCnCompliance(
|
||||
baseInput({ socialInsuranceBase: 8000, socialInsuranceContributionRate: 0.16 }),
|
||||
REPRESENTATIVE_RULE_SET,
|
||||
);
|
||||
|
||||
for (const amount of result.amounts) {
|
||||
expect(amount.ruleReference.length).toBeGreaterThan(0);
|
||||
expect(amount.inputs.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// 社保缴费金额一项的输入应包含基数与费率两项。
|
||||
const social = result.amounts.find((a) => a.label === '社保缴费金额');
|
||||
expect(social).toBeDefined();
|
||||
const fields = social!.inputs.map((i) => i.field);
|
||||
expect(fields).toContain('实际社保缴费基数');
|
||||
expect(fields).toContain('社保缴费费率');
|
||||
});
|
||||
});
|
||||
|
||||
describe('劳务派遣用工比例代表性算例(与最低工资判定联动)', () => {
|
||||
it('派遣比例 10%(10/100)恰为上限 → 合规通过', () => {
|
||||
const result = assessCnCompliance(
|
||||
baseInput({ dispatch: { dispatchedHeadcount: 10, totalHeadcount: 100 } }),
|
||||
REPRESENTATIVE_RULE_SET,
|
||||
);
|
||||
const dispatch = result.judgments.find((j) => j.item === '劳务派遣用工比例');
|
||||
expect(dispatch?.status).toBe('合规通过');
|
||||
});
|
||||
|
||||
it('派遣比例 12%(12/100)超过上限 → 合规不通过', () => {
|
||||
const result = assessCnCompliance(
|
||||
baseInput({ dispatch: { dispatchedHeadcount: 12, totalHeadcount: 100 } }),
|
||||
REPRESENTATIVE_RULE_SET,
|
||||
);
|
||||
const dispatch = result.judgments.find((j) => j.item === '劳务派遣用工比例');
|
||||
expect(dispatch?.status).toBe('合规不通过');
|
||||
expect(result.passed).toBe(false);
|
||||
});
|
||||
|
||||
it('约定月薪低于当地(上海 2690)最低工资 → 合规不通过', () => {
|
||||
const result = assessCnCompliance(
|
||||
baseInput({ monthlyWage: 2500, locality: '上海' }),
|
||||
REPRESENTATIVE_RULE_SET,
|
||||
);
|
||||
const minWage = result.judgments.find((j) => j.item === '当地最低工资');
|
||||
expect(minWage?.status).toBe('合规不通过');
|
||||
expect(result.passed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Property 53: 合规判定覆盖与计量标注(Req 16.1, 16.2)。
|
||||
*
|
||||
* 属性陈述:
|
||||
* 对任意 Region 为中国大陆(CN)的合法评估输入,`assessCnCompliance` 的判定结果:
|
||||
* - 合规判定必覆盖四项判定项(社保缴费基数、经济补偿、劳务派遣用工比例、当地最低工资);
|
||||
* - 经济补偿(N 与 N+1)、社保缴费金额与相关合规费用必被测算,且每项金额必标注其所
|
||||
* 依据的规则项(ruleReference)与输入项(inputs)。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 53: 合规判定覆盖与计量标注
|
||||
* Validates: Requirements 16.1, 16.2
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import type { ComplianceRuleSet } from '../../domain/region.js';
|
||||
import { REGION_CN } from '../../domain/region.js';
|
||||
import {
|
||||
assessCnCompliance,
|
||||
COMPLIANCE_ITEM_KEYS,
|
||||
type CnComplianceInput,
|
||||
} from '../judgment.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:合法的 CnComplianceInput(满足输入校验前置约束)以及可变的 CN 规则集。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 货币金额(非负有限值)。 */
|
||||
const moneyArb: fc.Arbitrary<number> = fc.double({
|
||||
min: 0,
|
||||
max: 1_000_000,
|
||||
noNaN: true,
|
||||
noDefaultInfinity: true,
|
||||
});
|
||||
|
||||
/** 劳务派遣用工人数信息:被派遣人数不超过用工总量、总量为正。 */
|
||||
const dispatchArb: fc.Arbitrary<{
|
||||
dispatchedHeadcount: number;
|
||||
totalHeadcount: number;
|
||||
}> = fc
|
||||
.tuple(
|
||||
fc.integer({ min: 1, max: 1000 }),
|
||||
fc.integer({ min: 0, max: 1000 }),
|
||||
)
|
||||
.map(([totalHeadcount, dispatchedRaw]) => ({
|
||||
totalHeadcount,
|
||||
dispatchedHeadcount: Math.min(dispatchedRaw, totalHeadcount),
|
||||
}));
|
||||
|
||||
/** 当地标识(用于查询当地最低工资;可能命中或未命中规则集)。 */
|
||||
const localityArb: fc.Arbitrary<string> = fc.constantFrom(
|
||||
'北京',
|
||||
'上海',
|
||||
'深圳',
|
||||
'杭州',
|
||||
'未知地区',
|
||||
);
|
||||
|
||||
const inputArb: fc.Arbitrary<CnComplianceInput> = fc
|
||||
.record({
|
||||
socialInsuranceBase: moneyArb,
|
||||
socialInsuranceContributionRate: fc.double({
|
||||
min: 0,
|
||||
max: 1,
|
||||
noNaN: true,
|
||||
noDefaultInfinity: true,
|
||||
}),
|
||||
monthlyWage: moneyArb,
|
||||
serviceYears: fc.double({
|
||||
min: 0,
|
||||
max: 40,
|
||||
noNaN: true,
|
||||
noDefaultInfinity: true,
|
||||
}),
|
||||
compensationScheme: fc.constantFrom<'N' | 'N+1'>('N', 'N+1'),
|
||||
locality: fc.option(localityArb, { nil: undefined }),
|
||||
dispatch: fc.option(dispatchArb, { nil: undefined }),
|
||||
})
|
||||
.map((fields) => {
|
||||
// exactOptionalPropertyTypes:可选项仅在有值时写入键,避免显式 undefined。
|
||||
const input: CnComplianceInput = {
|
||||
socialInsuranceBase: fields.socialInsuranceBase,
|
||||
socialInsuranceContributionRate: fields.socialInsuranceContributionRate,
|
||||
monthlyWage: fields.monthlyWage,
|
||||
serviceYears: fields.serviceYears,
|
||||
compensationScheme: fields.compensationScheme,
|
||||
};
|
||||
if (fields.locality !== undefined) {
|
||||
input.locality = fields.locality;
|
||||
}
|
||||
if (fields.dispatch !== undefined) {
|
||||
input.dispatch = fields.dispatch;
|
||||
}
|
||||
return input;
|
||||
});
|
||||
|
||||
/** 可变的 CN 规则集:社保基数下限与部分地区最低工资取不同取值,覆盖更广输入空间。 */
|
||||
const ruleSetArb: fc.Arbitrary<ComplianceRuleSet> = fc
|
||||
.record({
|
||||
lowerBound: moneyArb,
|
||||
minimumWageByLocality: fc.dictionary(
|
||||
fc.constantFrom('北京', '上海', '深圳', '杭州'),
|
||||
fc.double({ min: 0, max: 50_000, noNaN: true, noDefaultInfinity: true }),
|
||||
),
|
||||
})
|
||||
.map(({ lowerBound, minimumWageByLocality }) => ({
|
||||
region: REGION_CN,
|
||||
socialInsuranceBase: { lowerBound },
|
||||
economicCompensation: {
|
||||
nRule:
|
||||
'按在本单位工作年限,每满一年支付一个月工资;六个月以上不满一年按一年计;不满六个月支付半个月工资(N)',
|
||||
nPlusOneRule: '在 N 的基础上额外支付一个月工资作为代通知金(N+1)',
|
||||
},
|
||||
dispatchRatioCap: 0.1,
|
||||
minimumWage: { byLocality: minimumWageByLocality },
|
||||
}));
|
||||
|
||||
describe('Property 53: 合规判定覆盖与计量标注', () => {
|
||||
it('合规判定必覆盖四项,且每项测算金额必标注规则项与输入项(Req 16.1, 16.2)', () => {
|
||||
fc.assert(
|
||||
fc.property(inputArb, ruleSetArb, (input, ruleSet) => {
|
||||
const result = assessCnCompliance(input, ruleSet);
|
||||
|
||||
// --- Req 16.1:合规判定必覆盖四项判定项 ---
|
||||
const judgedItems = result.judgments.map((judgment) => judgment.item);
|
||||
for (const requiredItem of COMPLIANCE_ITEM_KEYS) {
|
||||
expect(judgedItems).toContain(requiredItem);
|
||||
}
|
||||
// 四项判定项均无重复(恰覆盖四项)。
|
||||
expect(new Set(judgedItems).size).toBe(COMPLIANCE_ITEM_KEYS.length);
|
||||
|
||||
// 每项判定均标注规则项(ruleReference 非空)。
|
||||
for (const judgment of result.judgments) {
|
||||
expect(judgment.ruleReference.length).toBeGreaterThan(0);
|
||||
expect(judgment.basis.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// --- Req 16.2:经济补偿(N、N+1)、社保缴费金额与相关合规费用均被测算 ---
|
||||
const amountLabels = result.amounts.map((amount) => amount.label);
|
||||
for (const requiredLabel of [
|
||||
'经济补偿(N)',
|
||||
'经济补偿(N+1)',
|
||||
'社保缴费金额',
|
||||
'相关合规费用合计',
|
||||
]) {
|
||||
expect(amountLabels).toContain(requiredLabel);
|
||||
}
|
||||
|
||||
// 每项测算金额必为非负有限值,且标注其规则项与至少一个输入项。
|
||||
for (const amount of result.amounts) {
|
||||
expect(Number.isFinite(amount.amount)).toBe(true);
|
||||
expect(amount.amount).toBeGreaterThanOrEqual(0);
|
||||
expect(amount.ruleReference.length).toBeGreaterThan(0);
|
||||
expect(amount.inputs.length).toBeGreaterThan(0);
|
||||
for (const ref of amount.inputs) {
|
||||
expect(ref.field.length).toBeGreaterThan(0);
|
||||
expect(ref.value).toBeDefined();
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Property 54: 合规不满足项标注的属性化测试(Compliance_Rule_Set,Req 16.3)。
|
||||
*
|
||||
* 属性陈述:
|
||||
* *对任意*合规判定中存在不满足项(社保缴费基数低于法定下限、劳务派遣用工比例
|
||||
* 超过 10% 或约定薪酬低于当地最低工资标准)的评估,System *必*将该项标注为
|
||||
* 「合规不通过」,并在结果中列出对应规则项(ruleReference)与判定依据(basis)。
|
||||
*
|
||||
* 生成器针对三类不满足项分别构造「确定违规」与「确定合规」两种输入,并保证每次
|
||||
* 至少注入一类违规;据此既验证违规项必被标注为合规不通过并进入 failures,
|
||||
* 也验证合规项不会被误标注(failures 集合恰等于注入的违规项集合)。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 54: 合规不满足项标注
|
||||
* Validates: Requirements 16.3
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import type { ComplianceRuleSet } from '../../domain/region.js';
|
||||
import { REGION_CN } from '../../domain/region.js';
|
||||
import type { CnComplianceInput, ComplianceItemKey } from '../judgment.js';
|
||||
import { assessCnCompliance } from '../judgment.js';
|
||||
|
||||
const LOCALITY = 'CN-TEST';
|
||||
const DISPATCH_CAP = 0.1;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:违规标志(保证至少一类违规)+ 规则集参数 + 取值比例参数。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 三类可判定不满足项的违规标志,至少一类为 true。 */
|
||||
const violationFlagsArb: fc.Arbitrary<readonly [boolean, boolean, boolean]> = fc
|
||||
.tuple(fc.boolean(), fc.boolean(), fc.boolean())
|
||||
.map(([base, dispatch, wage]) =>
|
||||
base || dispatch || wage ? [base, dispatch, wage] : [true, dispatch, wage],
|
||||
);
|
||||
|
||||
interface ScenarioParams {
|
||||
readonly flags: readonly [boolean, boolean, boolean];
|
||||
/** 社保缴费基数下限(法定下限)。 */
|
||||
readonly lowerBound: number;
|
||||
/** 当地最低工资标准。 */
|
||||
readonly minimumWage: number;
|
||||
/** 用工总量。 */
|
||||
readonly totalHeadcount: number;
|
||||
/** [0,1) 取值比例参数,用于在静态边界内派生具体取值。 */
|
||||
readonly baseFrac: number;
|
||||
readonly wageFrac: number;
|
||||
readonly dispatchFrac: number;
|
||||
/** 合规取值的附加量。 */
|
||||
readonly baseExtra: number;
|
||||
readonly wageExtra: number;
|
||||
/** 经济补偿测算所需输入。 */
|
||||
readonly contributionRate: number;
|
||||
readonly serviceYears: number;
|
||||
readonly scheme: 'N' | 'N+1';
|
||||
}
|
||||
|
||||
const scenarioArb: fc.Arbitrary<ScenarioParams> = fc.record({
|
||||
flags: violationFlagsArb,
|
||||
lowerBound: fc.integer({ min: 1000, max: 30000 }),
|
||||
minimumWage: fc.integer({ min: 1000, max: 30000 }),
|
||||
totalHeadcount: fc.integer({ min: 10, max: 1000 }),
|
||||
baseFrac: fc.double({ min: 0, max: 0.9999999, noNaN: true }),
|
||||
wageFrac: fc.double({ min: 0, max: 0.9999999, noNaN: true }),
|
||||
dispatchFrac: fc.double({ min: 0, max: 0.9999999, noNaN: true }),
|
||||
baseExtra: fc.integer({ min: 0, max: 100000 }),
|
||||
wageExtra: fc.integer({ min: 0, max: 100000 }),
|
||||
contributionRate: fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
serviceYears: fc.double({ min: 0, max: 40, noNaN: true }),
|
||||
scheme: fc.constantFrom<'N' | 'N+1'>('N', 'N+1'),
|
||||
});
|
||||
|
||||
/** 由参数派生规则集、判定输入与期望违规项集合。 */
|
||||
function buildScenario(p: ScenarioParams): {
|
||||
ruleSet: ComplianceRuleSet;
|
||||
input: CnComplianceInput;
|
||||
expectedFailures: Set<ComplianceItemKey>;
|
||||
} {
|
||||
const [violateBase, violateDispatch, violateWage] = p.flags;
|
||||
const expectedFailures = new Set<ComplianceItemKey>();
|
||||
|
||||
// 社保缴费基数:违规 → 严格低于下限;合规 → 不低于下限。
|
||||
const socialInsuranceBase = violateBase
|
||||
? Math.min(p.lowerBound - 1, Math.floor(p.baseFrac * p.lowerBound))
|
||||
: p.lowerBound + p.baseExtra;
|
||||
if (violateBase) expectedFailures.add('社保缴费基数');
|
||||
|
||||
// 约定月薪:违规 → 严格低于当地最低工资;合规 → 不低于。
|
||||
const monthlyWage = violateWage
|
||||
? Math.min(p.minimumWage - 1, Math.floor(p.wageFrac * p.minimumWage))
|
||||
: p.minimumWage + p.wageExtra;
|
||||
if (violateWage) expectedFailures.add('当地最低工资');
|
||||
|
||||
// 劳务派遣用工比例:违规 → 比例 > 10%;合规 → 比例 ≤ 10%。
|
||||
const total = p.totalHeadcount;
|
||||
const capCount = Math.floor(total * DISPATCH_CAP);
|
||||
let dispatchedHeadcount: number;
|
||||
if (violateDispatch) {
|
||||
const low = capCount + 1; // 严格超过 10% 的最小人数。
|
||||
const span = total - low + 1;
|
||||
dispatchedHeadcount = low + Math.floor(p.dispatchFrac * span);
|
||||
if (dispatchedHeadcount > total) dispatchedHeadcount = total;
|
||||
expectedFailures.add('劳务派遣用工比例');
|
||||
} else {
|
||||
dispatchedHeadcount = Math.min(capCount, Math.floor(p.dispatchFrac * (capCount + 1)));
|
||||
}
|
||||
|
||||
const ruleSet: ComplianceRuleSet = {
|
||||
region: REGION_CN,
|
||||
socialInsuranceBase: { lowerBound: p.lowerBound },
|
||||
economicCompensation: {
|
||||
nRule: '按工作年限折算月数(N)',
|
||||
nPlusOneRule: '在 N 基础上加一个月(N+1)',
|
||||
},
|
||||
dispatchRatioCap: DISPATCH_CAP,
|
||||
minimumWage: { byLocality: { [LOCALITY]: p.minimumWage } },
|
||||
};
|
||||
|
||||
const input: CnComplianceInput = {
|
||||
socialInsuranceBase,
|
||||
socialInsuranceContributionRate: p.contributionRate,
|
||||
monthlyWage,
|
||||
serviceYears: p.serviceYears,
|
||||
compensationScheme: p.scheme,
|
||||
locality: LOCALITY,
|
||||
dispatch: { dispatchedHeadcount, totalHeadcount: total },
|
||||
};
|
||||
|
||||
return { ruleSet, input, expectedFailures };
|
||||
}
|
||||
|
||||
describe('Property 54: 合规不满足项标注', () => {
|
||||
it('不满足项必被标注为「合规不通过」并在 failures 中列出规则项与判定依据(Req 16.3)', () => {
|
||||
fc.assert(
|
||||
fc.property(scenarioArb, (params) => {
|
||||
const { ruleSet, input, expectedFailures } = buildScenario(params);
|
||||
const result = assessCnCompliance(input, ruleSet);
|
||||
|
||||
// 1) 注入的每个违规项都被标注为「合规不通过」,且进入 failures,
|
||||
// 并附带非空的规则项(ruleReference)与判定依据(basis)。
|
||||
for (const item of expectedFailures) {
|
||||
const judgment = result.judgments.find((j) => j.item === item);
|
||||
expect(judgment).toBeDefined();
|
||||
expect(judgment?.status).toBe('合规不通过');
|
||||
|
||||
const failure = result.failures.find((f) => f.item === item);
|
||||
expect(failure).toBeDefined();
|
||||
expect(failure?.status).toBe('合规不通过');
|
||||
expect(failure?.ruleReference.length).toBeGreaterThan(0);
|
||||
expect(failure?.basis.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// 2) failures 恰等于注入的违规项集合(合规项不被误标注)。
|
||||
const actualFailures = new Set(result.failures.map((f) => f.item));
|
||||
expect(actualFailures).toEqual(expectedFailures);
|
||||
|
||||
// 3) failures 与整体通过状态一致。
|
||||
expect(result.passed).toBe(expectedFailures.size === 0);
|
||||
expect(result.passed).toBe(false); // 生成器保证至少一类违规。
|
||||
|
||||
// 4) failures 中所有项的状态均为「合规不通过」。
|
||||
for (const failure of result.failures) {
|
||||
expect(failure.status).toBe('合规不通过');
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Property 55: Region 记录与默认 的属性化测试(Compliance_Rule_Set,Req 16.4-16.5)。
|
||||
*
|
||||
* 属性陈述:
|
||||
* - 对任意显式指定的 Region,`resolveRegion` 记录该 Region 且 `isSystemDefault` 为 false。
|
||||
* - 当请求未指定 Region(undefined / null)时,默认采用中国大陆(CN)且 `isSystemDefault` 为 true。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 55: Region 记录与默认
|
||||
* Validates: Requirements 16.4, 16.5
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import type { Region } from '../../domain/region.js';
|
||||
import { REGION_CN } from '../../domain/region.js';
|
||||
import { resolveRegion } from '../region.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:任意显式指定的 Region(含可能等于 CN 的代码,记录行为对取值不敏感)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const explicitRegionArb: fc.Arbitrary<Region> = fc.record({
|
||||
code: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
name: fc.string({ minLength: 0, maxLength: 12 }),
|
||||
});
|
||||
|
||||
describe('Property 55: Region 记录与默认', () => {
|
||||
it('显式指定的 Region 被原样记录且 isSystemDefault 为 false(Req 16.4)', () => {
|
||||
fc.assert(
|
||||
fc.property(explicitRegionArb, (region) => {
|
||||
const result = resolveRegion(region);
|
||||
|
||||
// 记录评估者显式指定的 Region(同一引用,取值不被篡改)。
|
||||
expect(result.region).toBe(region);
|
||||
// 显式指定时不标注为系统默认值。
|
||||
expect(result.isSystemDefault).toBe(false);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('未指定 Region(undefined / null)时默认 CN 且 isSystemDefault 为 true(Req 16.5)', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.constantFrom<undefined | null>(undefined, null), (absent) => {
|
||||
const result = resolveRegion(absent);
|
||||
|
||||
// 默认采用中国大陆(CN)。
|
||||
expect(result.region).toEqual(REGION_CN);
|
||||
expect(result.region.code).toBe('CN');
|
||||
// 标注为系统默认值。
|
||||
expect(result.isSystemDefault).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Property 56: 无规则集地域拒绝合规处理 的属性化测试(Compliance_Rule_Set,Req 16.6)。
|
||||
*
|
||||
* 属性陈述:对任意当前无对应规则集的 Region,`loadComplianceRuleSet` 必抛出
|
||||
* `UnsupportedRegionError`,且错误消息指示该地域「暂不支持」——据此拒绝合规判定与费用测算。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 56: 无规则集地域拒绝合规处理
|
||||
* Validates: Requirements 16.6
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import type { Region } from '../../domain/region.js';
|
||||
import { UnsupportedRegionError } from '../errors.js';
|
||||
import {
|
||||
createDefaultComplianceRegistry,
|
||||
isRegionSupported,
|
||||
loadComplianceRuleSet,
|
||||
} from '../rule-set.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:当前无对应规则集的 Region
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// 默认注册表(首版仅含中国大陆 CN);匹配对地域代码大小写与首尾空白不敏感。
|
||||
// 因此排除规范化后等于 'CN' 的任何代码,即可保证生成「无规则集」的 Region。
|
||||
|
||||
const normalize = (code: string): string => code.trim().toUpperCase();
|
||||
|
||||
const unsupportedRegionArb: fc.Arbitrary<Region> = fc
|
||||
.record({
|
||||
code: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
name: fc.string({ minLength: 0, maxLength: 12 }),
|
||||
})
|
||||
.filter((region) => normalize(region.code) !== 'CN');
|
||||
|
||||
describe('Property 56: 无规则集地域拒绝合规处理', () => {
|
||||
it('对任意无规则集的 Region,loadComplianceRuleSet 抛出 UnsupportedRegionError 且提示暂不支持', () => {
|
||||
fc.assert(
|
||||
fc.property(unsupportedRegionArb, (region) => {
|
||||
const registry = createDefaultComplianceRegistry();
|
||||
|
||||
// 前置:该 Region 确实未被支持。
|
||||
expect(isRegionSupported(region, registry)).toBe(false);
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
loadComplianceRuleSet(region, registry);
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
// 必抛出 UnsupportedRegionError。
|
||||
expect(thrown).toBeInstanceOf(UnsupportedRegionError);
|
||||
|
||||
const err = thrown as UnsupportedRegionError;
|
||||
// 错误关联触发的地域。
|
||||
expect(err.region).toBe(region);
|
||||
// 消息指示「暂不支持」(message 与面向评估者的 userMessage 一致)。
|
||||
expect(err.message).toContain('暂不支持');
|
||||
expect(err.userMessage).toContain('暂不支持');
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 合规模块错误类型。
|
||||
*
|
||||
* 错误处理遵循统一原则:输入校验前置、错误可解释、失败不破坏既有状态。
|
||||
*/
|
||||
|
||||
import type { Region } from '../domain/region.js';
|
||||
|
||||
/**
|
||||
* 请求的 Region 当前无对应 Compliance_Rule_Set 时抛出(Req 16.6)。
|
||||
*
|
||||
* System 据此拒绝该 Region 的合规判定与费用测算、不生成合规结论,
|
||||
* 并向评估者返回提示该地域暂不支持的消息(`message` / `userMessage`)。
|
||||
*/
|
||||
export class UnsupportedRegionError extends Error {
|
||||
/** 触发错误的地域。 */
|
||||
readonly region: Region;
|
||||
/** 面向评估者的可读提示,固定包含"暂不支持"。 */
|
||||
readonly userMessage: string;
|
||||
|
||||
constructor(region: Region) {
|
||||
const userMessage = `地域「${region.name}(${region.code})」暂不支持合规判定与费用测算`;
|
||||
super(userMessage);
|
||||
this.name = 'UnsupportedRegionError';
|
||||
this.region = region;
|
||||
this.userMessage = userMessage;
|
||||
// 维持原型链(编译目标低于 ES2015 时的兼容保障)。
|
||||
Object.setPrototypeOf(this, UnsupportedRegionError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合规判定/费用测算输入非法时抛出(输入校验前置原则)。
|
||||
*
|
||||
* 例如金额为负、社保费率不在 [0, 1]、工作年限为负、劳务派遣总人数非正等。
|
||||
* 在执行任何计算前校验,确保失败不破坏既有状态、错误可解释。
|
||||
*/
|
||||
export class InvalidComplianceInputError extends Error {
|
||||
/** 出错的输入项字段名。 */
|
||||
readonly field: string;
|
||||
|
||||
constructor(field: string, message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidComplianceInputError';
|
||||
this.field = field;
|
||||
Object.setPrototypeOf(this, InvalidComplianceInputError.prototype);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Compliance_Rule_Set 地域合规规则集模块(Req 16)。
|
||||
*
|
||||
* 职责:
|
||||
* - 按 Region 加载 ComplianceRuleSet({@link loadComplianceRuleSet} / {@link ComplianceRuleSetRegistry})。
|
||||
* - 记录 Assessment 所采用的 Region,未指定时默认 CN 并标注系统默认值({@link resolveRegion})。
|
||||
* - Region 无对应规则集时拒绝合规处理并提示暂不支持({@link UnsupportedRegionError})。
|
||||
* - 执行 CN 合规判定与费用测算({@link assessCnCompliance},Req 16.1-16.3)。
|
||||
*/
|
||||
|
||||
export * from './errors.js';
|
||||
export * from './region.js';
|
||||
export * from './rule-set.js';
|
||||
export * from './judgment.js';
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* 中国大陆(CN)合规判定与费用测算引擎(Req 16.1, 16.2, 16.3)。
|
||||
*
|
||||
* 依据按 Region 加载的 {@link ComplianceRuleSet} 对一次 Assessment:
|
||||
* - 执行合规判定,判定项覆盖四项:社保缴费基数、经济补偿 N 与 N+1、
|
||||
* 劳务派遣用工比例上限 10%、当地最低工资标准(Req 16.1)。
|
||||
* - 测算经济补偿(N 与 N+1)金额、社保缴费金额与相关合规费用,并为每项金额
|
||||
* 标注其所依据的规则项与输入项(Req 16.2)。
|
||||
* - 对任一不满足项(社保缴费基数低于法定下限、劳务派遣用工比例超过 10%、
|
||||
* 约定薪酬低于当地最低工资标准)标注为「合规不通过」并给出规则项与判定依据(Req 16.3)。
|
||||
*
|
||||
* 引擎与具体 Region 解耦:仅消费传入的 ComplianceRuleSet,跨境通过新增规则集扩展
|
||||
* 而不改本引擎(Req 16,地域参数化)。计算为确定性纯函数,便于属性化测试约束
|
||||
* (Property 53 覆盖与计量标注、Property 54 不满足项标注)。
|
||||
*
|
||||
* 规则内容来源为《劳务派遣暂行规定》《劳动合同法》既有规则,仅用于建模规则结构,
|
||||
* 不构成法律意见。
|
||||
*/
|
||||
|
||||
import type { Money } from '../domain/common.js';
|
||||
import type { ComplianceRuleSet, Region } from '../domain/region.js';
|
||||
import { InvalidComplianceInputError } from './errors.js';
|
||||
|
||||
/** 浮点比较容差(用于工作年限折算月数等比较)。 */
|
||||
const EPSILON = 1e-9;
|
||||
|
||||
/** 合规判定项标识(恰为 Req 16.1 要求覆盖的四项)。 */
|
||||
export type ComplianceItemKey =
|
||||
| '社保缴费基数'
|
||||
| '经济补偿'
|
||||
| '劳务派遣用工比例'
|
||||
| '当地最低工资';
|
||||
|
||||
/** Req 16.1 要求合规判定必须覆盖的四项判定项标识。 */
|
||||
export const COMPLIANCE_ITEM_KEYS: readonly ComplianceItemKey[] = [
|
||||
'社保缴费基数',
|
||||
'经济补偿',
|
||||
'劳务派遣用工比例',
|
||||
'当地最低工资',
|
||||
];
|
||||
|
||||
/**
|
||||
* 单项合规判定状态。
|
||||
* - 合规通过 / 合规不通过:存在明确合规判据的项(Req 16.3)。
|
||||
* - 已测算:仅作金额测算、无通过/不通过判据的项(经济补偿)。
|
||||
* - 不适用:缺少判定所需输入(如未提供劳务派遣人数、当地无最低工资标准)。
|
||||
*/
|
||||
export type ComplianceJudgmentStatus = '合规通过' | '合规不通过' | '已测算' | '不适用';
|
||||
|
||||
/**
|
||||
* 判定/计量所引用的单个输入项(用于可解释标注,Req 16.2/16.3)。
|
||||
*/
|
||||
export interface ComplianceInputRef {
|
||||
/** 输入项中文标签。 */
|
||||
field: string;
|
||||
/** 输入项取值。 */
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一项合规判定结果,标注其所依据的规则项与输入项(Req 16.1, 16.3)。
|
||||
*/
|
||||
export interface ComplianceJudgment {
|
||||
/** 判定项标识。 */
|
||||
item: ComplianceItemKey;
|
||||
/** 判定状态。 */
|
||||
status: ComplianceJudgmentStatus;
|
||||
/** 所依据的规则项描述(报告据此列出对应规则项)。 */
|
||||
ruleReference: string;
|
||||
/** 判定所依据的输入项。 */
|
||||
inputs: readonly ComplianceInputRef[];
|
||||
/** 判定依据/说明(报告据此列出判定依据)。 */
|
||||
basis: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一项测算金额,标注其所依据的规则项与输入项(Req 16.2)。
|
||||
*/
|
||||
export interface ComplianceAmount {
|
||||
/** 金额标签,如「经济补偿(N)」。 */
|
||||
label: string;
|
||||
/** 金额(人民币元,非负)。 */
|
||||
amount: Money;
|
||||
/** 所依据的规则项描述。 */
|
||||
ruleReference: string;
|
||||
/** 计算所依据的输入项。 */
|
||||
inputs: readonly ComplianceInputRef[];
|
||||
}
|
||||
|
||||
/** 劳务派遣用工人数信息(仅劳务派遣相关时提供)。 */
|
||||
export interface DispatchHeadcount {
|
||||
/** 被派遣劳动者用工人数。 */
|
||||
dispatchedHeadcount: number;
|
||||
/** 用工单位用工总量(含被派遣劳动者)。 */
|
||||
totalHeadcount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合规判定与费用测算的输入项(Req 16.2/16.3 的可解释输入来源)。
|
||||
*/
|
||||
export interface CnComplianceInput {
|
||||
/** 实际社保缴费基数(人民币元/月)。 */
|
||||
socialInsuranceBase: Money;
|
||||
/** 用人单位社保缴费费率(取值 [0, 1]),用于测算社保缴费金额。 */
|
||||
socialInsuranceContributionRate: number;
|
||||
/** 约定月薪(人民币元/月),用于经济补偿测算及与当地最低工资比较。 */
|
||||
monthlyWage: Money;
|
||||
/** 在本单位工作年限(年,可含小数表示不足一年的部分)。 */
|
||||
serviceYears: number;
|
||||
/** 经济补偿计法:'N'(仅经济补偿)或 'N+1'(含代通知金)。 */
|
||||
compensationScheme: 'N' | 'N+1';
|
||||
/** 当地标识,用于在规则集中查询当地最低工资标准;缺省则该项不适用。 */
|
||||
locality?: string;
|
||||
/** 劳务派遣用工占比信息;缺省则劳务派遣用工比例判定不适用。 */
|
||||
dispatch?: DispatchHeadcount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合规判定与费用测算的整体结果(Req 16.1-16.3)。
|
||||
*/
|
||||
export interface CnComplianceResult {
|
||||
/** 适用地域。 */
|
||||
region: Region;
|
||||
/** 覆盖四项的合规判定列表(Req 16.1)。 */
|
||||
judgments: readonly ComplianceJudgment[];
|
||||
/** 测算金额列表,每项标注规则项与输入项(Req 16.2)。 */
|
||||
amounts: readonly ComplianceAmount[];
|
||||
/** 整体是否合规通过:无任一「合规不通过」项时为 true(Req 16.3)。 */
|
||||
passed: boolean;
|
||||
/** 合规不通过项列表(报告据此列出,Req 16.3)。 */
|
||||
failures: readonly ComplianceJudgment[];
|
||||
}
|
||||
|
||||
/** 经济补偿月数测算的明细结果。 */
|
||||
export interface CompensationMonths {
|
||||
/** 满整年数。 */
|
||||
fullYears: number;
|
||||
/** 不足一年部分折算月数(0、0.5 或 1)。 */
|
||||
remainderMonths: number;
|
||||
/** 折算总月数(N 规则月数)。 */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按工作年限将经济补偿折算为月数(N 规则,Req 16.1/16.2)。
|
||||
*
|
||||
* 规则:每满一年支付一个月工资;六个月以上不满一年按一年计(一个月);
|
||||
* 不满六个月支付半个月工资(半个月)。
|
||||
*
|
||||
* @param serviceYears 在本单位工作年限(年,非负,可含小数)。
|
||||
* @returns 折算明细,其中 `total` 为 N 规则对应的月数。
|
||||
*/
|
||||
export function computeCompensationMonths(serviceYears: number): CompensationMonths {
|
||||
const fullYears = Math.floor(serviceYears + EPSILON);
|
||||
const remainderMonths = (serviceYears - fullYears) * 12;
|
||||
let remainder: number;
|
||||
if (remainderMonths >= 6 - EPSILON) {
|
||||
// 六个月以上不满一年按一年计。
|
||||
remainder = 1;
|
||||
} else if (remainderMonths > EPSILON) {
|
||||
// 不满六个月支付半个月工资。
|
||||
remainder = 0.5;
|
||||
} else {
|
||||
remainder = 0;
|
||||
}
|
||||
return { fullYears, remainderMonths: remainder, total: fullYears + remainder };
|
||||
}
|
||||
|
||||
/** 校验输入项合法性(输入校验前置,失败抛 {@link InvalidComplianceInputError})。 */
|
||||
function validateInput(input: CnComplianceInput): void {
|
||||
if (!Number.isFinite(input.socialInsuranceBase) || input.socialInsuranceBase < 0) {
|
||||
throw new InvalidComplianceInputError(
|
||||
'socialInsuranceBase',
|
||||
'社保缴费基数必须为非负数',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(input.socialInsuranceContributionRate) ||
|
||||
input.socialInsuranceContributionRate < 0 ||
|
||||
input.socialInsuranceContributionRate > 1
|
||||
) {
|
||||
throw new InvalidComplianceInputError(
|
||||
'socialInsuranceContributionRate',
|
||||
'社保缴费费率必须在 [0, 1] 区间内',
|
||||
);
|
||||
}
|
||||
if (!Number.isFinite(input.monthlyWage) || input.monthlyWage < 0) {
|
||||
throw new InvalidComplianceInputError('monthlyWage', '约定月薪必须为非负数');
|
||||
}
|
||||
if (!Number.isFinite(input.serviceYears) || input.serviceYears < 0) {
|
||||
throw new InvalidComplianceInputError('serviceYears', '工作年限必须为非负数');
|
||||
}
|
||||
if (input.dispatch !== undefined) {
|
||||
const { dispatchedHeadcount, totalHeadcount } = input.dispatch;
|
||||
if (!Number.isFinite(dispatchedHeadcount) || dispatchedHeadcount < 0) {
|
||||
throw new InvalidComplianceInputError(
|
||||
'dispatch.dispatchedHeadcount',
|
||||
'被派遣用工人数必须为非负数',
|
||||
);
|
||||
}
|
||||
if (!Number.isFinite(totalHeadcount) || totalHeadcount <= 0) {
|
||||
throw new InvalidComplianceInputError(
|
||||
'dispatch.totalHeadcount',
|
||||
'用工总量必须为正数',
|
||||
);
|
||||
}
|
||||
if (dispatchedHeadcount > totalHeadcount) {
|
||||
throw new InvalidComplianceInputError(
|
||||
'dispatch.dispatchedHeadcount',
|
||||
'被派遣用工人数不得超过用工总量',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 将比率格式化为百分比文本(用于判定依据说明)。 */
|
||||
function formatPercent(ratio: number): string {
|
||||
return `${(ratio * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
/** 构造社保缴费基数判定项(Req 16.1, 16.3)。 */
|
||||
function judgeSocialInsuranceBase(
|
||||
input: CnComplianceInput,
|
||||
ruleSet: ComplianceRuleSet,
|
||||
): ComplianceJudgment {
|
||||
const lowerBound = ruleSet.socialInsuranceBase.lowerBound;
|
||||
const passed = input.socialInsuranceBase >= lowerBound;
|
||||
return {
|
||||
item: '社保缴费基数',
|
||||
status: passed ? '合规通过' : '合规不通过',
|
||||
ruleReference: `社保缴费基数下限 ${lowerBound} 元/月`,
|
||||
inputs: [{ field: '实际社保缴费基数', value: input.socialInsuranceBase }],
|
||||
basis: passed
|
||||
? `实际社保缴费基数 ${input.socialInsuranceBase} 元不低于法定下限 ${lowerBound} 元`
|
||||
: `实际社保缴费基数 ${input.socialInsuranceBase} 元低于法定下限 ${lowerBound} 元`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造经济补偿测算项(仅测算、无通过/不通过判据,Req 16.1, 16.2)。 */
|
||||
function judgeEconomicCompensation(
|
||||
input: CnComplianceInput,
|
||||
ruleSet: ComplianceRuleSet,
|
||||
months: CompensationMonths,
|
||||
): ComplianceJudgment {
|
||||
const { economicCompensation } = ruleSet;
|
||||
return {
|
||||
item: '经济补偿',
|
||||
status: '已测算',
|
||||
ruleReference: `${economicCompensation.nRule};${economicCompensation.nPlusOneRule}`,
|
||||
inputs: [
|
||||
{ field: '约定月薪', value: input.monthlyWage },
|
||||
{ field: '工作年限(年)', value: input.serviceYears },
|
||||
{ field: '经济补偿计法', value: input.compensationScheme },
|
||||
],
|
||||
basis: `工作年限 ${input.serviceYears} 年折算为 ${months.total} 个月工资;采用 ${input.compensationScheme} 计法`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造劳务派遣用工比例判定项(Req 16.1, 16.3)。 */
|
||||
function judgeDispatchRatio(
|
||||
input: CnComplianceInput,
|
||||
ruleSet: ComplianceRuleSet,
|
||||
): ComplianceJudgment {
|
||||
const cap = ruleSet.dispatchRatioCap;
|
||||
if (input.dispatch === undefined) {
|
||||
return {
|
||||
item: '劳务派遣用工比例',
|
||||
status: '不适用',
|
||||
ruleReference: `劳务派遣用工比例上限 ${formatPercent(cap)}`,
|
||||
inputs: [],
|
||||
basis: '未提供劳务派遣用工人数,劳务派遣用工比例判定不适用',
|
||||
};
|
||||
}
|
||||
const { dispatchedHeadcount, totalHeadcount } = input.dispatch;
|
||||
const ratio = dispatchedHeadcount / totalHeadcount;
|
||||
const passed = ratio <= cap + EPSILON;
|
||||
return {
|
||||
item: '劳务派遣用工比例',
|
||||
status: passed ? '合规通过' : '合规不通过',
|
||||
ruleReference: `劳务派遣用工比例上限 ${formatPercent(cap)}`,
|
||||
inputs: [
|
||||
{ field: '被派遣用工人数', value: dispatchedHeadcount },
|
||||
{ field: '用工总量', value: totalHeadcount },
|
||||
],
|
||||
basis: passed
|
||||
? `劳务派遣用工比例 ${formatPercent(ratio)} 未超过上限 ${formatPercent(cap)}`
|
||||
: `劳务派遣用工比例 ${formatPercent(ratio)} 超过上限 ${formatPercent(cap)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造当地最低工资判定项(Req 16.1, 16.3)。 */
|
||||
function judgeMinimumWage(
|
||||
input: CnComplianceInput,
|
||||
ruleSet: ComplianceRuleSet,
|
||||
): ComplianceJudgment {
|
||||
const { locality } = input;
|
||||
const minimumWage =
|
||||
locality !== undefined ? ruleSet.minimumWage.byLocality[locality] : undefined;
|
||||
if (locality === undefined || minimumWage === undefined) {
|
||||
return {
|
||||
item: '当地最低工资',
|
||||
status: '不适用',
|
||||
ruleReference: '当地最低工资标准',
|
||||
inputs:
|
||||
locality !== undefined ? [{ field: '当地标识', value: locality }] : [],
|
||||
basis:
|
||||
locality === undefined
|
||||
? '未提供当地标识,当地最低工资判定不适用'
|
||||
: `规则集中无当地标识「${locality}」的最低工资标准,判定不适用`,
|
||||
};
|
||||
}
|
||||
const passed = input.monthlyWage >= minimumWage;
|
||||
return {
|
||||
item: '当地最低工资',
|
||||
status: passed ? '合规通过' : '合规不通过',
|
||||
ruleReference: `当地(${locality})最低工资标准 ${minimumWage} 元/月`,
|
||||
inputs: [
|
||||
{ field: '约定月薪', value: input.monthlyWage },
|
||||
{ field: '当地标识', value: locality },
|
||||
],
|
||||
basis: passed
|
||||
? `约定月薪 ${input.monthlyWage} 元不低于当地最低工资标准 ${minimumWage} 元`
|
||||
: `约定月薪 ${input.monthlyWage} 元低于当地最低工资标准 ${minimumWage} 元`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造经济补偿/社保/合规费用的测算金额(Req 16.2)。 */
|
||||
function measureAmounts(
|
||||
input: CnComplianceInput,
|
||||
ruleSet: ComplianceRuleSet,
|
||||
months: CompensationMonths,
|
||||
): ComplianceAmount[] {
|
||||
const { economicCompensation } = ruleSet;
|
||||
const compensationN = months.total * input.monthlyWage;
|
||||
const compensationNPlusOne = compensationN + input.monthlyWage;
|
||||
const socialInsuranceAmount =
|
||||
input.socialInsuranceBase * input.socialInsuranceContributionRate;
|
||||
const applicableCompensation =
|
||||
input.compensationScheme === 'N+1' ? compensationNPlusOne : compensationN;
|
||||
const totalComplianceCost = applicableCompensation + socialInsuranceAmount;
|
||||
|
||||
const compensationInputs: readonly ComplianceInputRef[] = [
|
||||
{ field: '约定月薪', value: input.monthlyWage },
|
||||
{ field: '工作年限(年)', value: input.serviceYears },
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
label: '经济补偿(N)',
|
||||
amount: compensationN,
|
||||
ruleReference: economicCompensation.nRule,
|
||||
inputs: compensationInputs,
|
||||
},
|
||||
{
|
||||
label: '经济补偿(N+1)',
|
||||
amount: compensationNPlusOne,
|
||||
ruleReference: economicCompensation.nPlusOneRule,
|
||||
inputs: compensationInputs,
|
||||
},
|
||||
{
|
||||
label: '社保缴费金额',
|
||||
amount: socialInsuranceAmount,
|
||||
ruleReference: `社保缴费基数下限 ${ruleSet.socialInsuranceBase.lowerBound} 元/月`,
|
||||
inputs: [
|
||||
{ field: '实际社保缴费基数', value: input.socialInsuranceBase },
|
||||
{ field: '社保缴费费率', value: input.socialInsuranceContributionRate },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '相关合规费用合计',
|
||||
amount: totalComplianceCost,
|
||||
ruleReference: `经济补偿(${input.compensationScheme})与社保缴费金额合计`,
|
||||
inputs: [
|
||||
{ field: `经济补偿(${input.compensationScheme})`, value: applicableCompensation },
|
||||
{ field: '社保缴费金额', value: socialInsuranceAmount },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行中国大陆(CN)合规判定与费用测算(Req 16.1, 16.2, 16.3)。
|
||||
*
|
||||
* 判定项恒覆盖四项(社保缴费基数、经济补偿、劳务派遣用工比例、当地最低工资);
|
||||
* 测算经济补偿(N 与 N+1)、社保缴费金额与相关合规费用,每项标注规则项与输入项;
|
||||
* 任一不满足项标注为「合规不通过」并随结果一并返回(含规则项与判定依据)。
|
||||
*
|
||||
* @param input 合规判定/费用测算输入。
|
||||
* @param ruleSet 按 Region 加载的合规规则集(由调用方经
|
||||
* {@link loadComplianceRuleSet} 提供,本引擎不耦合具体 Region)。
|
||||
* @returns 合规判定与费用测算结果。
|
||||
* @throws {InvalidComplianceInputError} 当输入项非法时(输入校验前置)。
|
||||
*/
|
||||
export function assessCnCompliance(
|
||||
input: CnComplianceInput,
|
||||
ruleSet: ComplianceRuleSet,
|
||||
): CnComplianceResult {
|
||||
validateInput(input);
|
||||
|
||||
const months = computeCompensationMonths(input.serviceYears);
|
||||
|
||||
const judgments: ComplianceJudgment[] = [
|
||||
judgeSocialInsuranceBase(input, ruleSet),
|
||||
judgeEconomicCompensation(input, ruleSet, months),
|
||||
judgeDispatchRatio(input, ruleSet),
|
||||
judgeMinimumWage(input, ruleSet),
|
||||
];
|
||||
|
||||
const amounts = measureAmounts(input, ruleSet, months);
|
||||
const failures = judgments.filter(
|
||||
(judgment) => judgment.status === '合规不通过',
|
||||
);
|
||||
|
||||
return {
|
||||
region: ruleSet.region,
|
||||
judgments,
|
||||
amounts,
|
||||
passed: failures.length === 0,
|
||||
failures,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Region 解析:记录 Assessment 所采用的 Region,未指定时默认 CN(Req 16.4-16.5)。
|
||||
*/
|
||||
|
||||
import type { Region } from '../domain/region.js';
|
||||
import { REGION_CN } from '../domain/region.js';
|
||||
|
||||
/**
|
||||
* Region 解析结果(Req 16.4-16.5)。
|
||||
*
|
||||
* 记录最终采用的 Region,并标注其是否由系统默认值填充(请求未指定 Region)。
|
||||
*/
|
||||
export interface RegionResolution {
|
||||
/** 本次 Assessment 最终采用的 Region。 */
|
||||
region: Region;
|
||||
/** 是否为系统默认值:true 表示请求未指定 Region 而采用了中国大陆(CN)。 */
|
||||
isSystemDefault: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析创建 Assessment 请求所采用的 Region(Req 16.4-16.5)。
|
||||
*
|
||||
* - 请求显式指定 Region 时,采用该 Region,`isSystemDefault` 为 false。
|
||||
* - 请求未指定 Region(undefined / null)时,采用中国大陆(CN)并标注为系统默认值。
|
||||
*
|
||||
* 本函数仅负责"记录与默认",不校验该 Region 是否存在对应规则集;
|
||||
* 规则集缺失的拒绝由 {@link UnsupportedRegionError} 在加载规则集时处理(Req 16.6)。
|
||||
*
|
||||
* @param requested 请求指定的 Region;未指定时传入 undefined 或 null。
|
||||
*/
|
||||
export function resolveRegion(requested?: Region | null): RegionResolution {
|
||||
if (requested == null) {
|
||||
return { region: REGION_CN, isSystemDefault: true };
|
||||
}
|
||||
return { region: requested, isSystemDefault: false };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 按 Region 加载 Compliance_Rule_Set 的注册表与默认规则集(Req 16.1, 16.6)。
|
||||
*
|
||||
* 规则集按 Region 加载;跨境通过向注册表新增 ComplianceRuleSet 扩展,
|
||||
* 而不改判定/费用引擎(地域参数化合规规则集,Req 16)。
|
||||
* Region 无对应规则集时拒绝合规处理并提示暂不支持(Req 16.6)。
|
||||
*/
|
||||
|
||||
import type { ComplianceRuleSet, Region } from '../domain/region.js';
|
||||
import { REGION_CN } from '../domain/region.js';
|
||||
import { UnsupportedRegionError } from './errors.js';
|
||||
|
||||
/**
|
||||
* 中国大陆(CN)合规规则集(Req 16.1)。
|
||||
*
|
||||
* 来源为《劳务派遣暂行规定》《劳动合同法》既有规则,仅用于建模规则结构、不作法律意见。
|
||||
* 社保缴费基数下限与当地最低工资按地区取值的细化在后续合规判定/费用测算任务中填充。
|
||||
*/
|
||||
export const CN_COMPLIANCE_RULE_SET: ComplianceRuleSet = {
|
||||
region: REGION_CN,
|
||||
socialInsuranceBase: {
|
||||
// 按地区取值,首版以 0 占位,后续任务按地区填充下限。
|
||||
lowerBound: 0,
|
||||
},
|
||||
economicCompensation: {
|
||||
nRule:
|
||||
'按在本单位工作年限,每满一年支付一个月工资;六个月以上不满一年按一年计;不满六个月支付半个月工资(N)',
|
||||
nPlusOneRule: '在 N 的基础上额外支付一个月工资作为代通知金(N+1)',
|
||||
},
|
||||
// 劳务派遣用工比例上限 10%。
|
||||
dispatchRatioCap: 0.1,
|
||||
minimumWage: {
|
||||
// 按地区/城市标识映射月最低工资,首版为空、后续任务按地区填充。
|
||||
byLocality: {},
|
||||
},
|
||||
};
|
||||
|
||||
/** 规范化地域代码用于匹配(大小写与首尾空白不敏感)。 */
|
||||
function normalizeCode(code: string): string {
|
||||
return code.trim().toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 地域合规规则集注册表(Req 16)。
|
||||
*
|
||||
* 以 Region 代码为键登记 ComplianceRuleSet;新增地域只需注册新规则集,
|
||||
* 无需改动调用方(判定/费用引擎)。
|
||||
*/
|
||||
export class ComplianceRuleSetRegistry {
|
||||
private readonly ruleSets = new Map<string, ComplianceRuleSet>();
|
||||
|
||||
/**
|
||||
* @param initial 初始登记的规则集集合。
|
||||
*/
|
||||
constructor(initial: readonly ComplianceRuleSet[] = []) {
|
||||
for (const ruleSet of initial) {
|
||||
this.register(ruleSet);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记(或覆盖)某地域的合规规则集。
|
||||
*/
|
||||
register(ruleSet: ComplianceRuleSet): void {
|
||||
this.ruleSets.set(normalizeCode(ruleSet.region.code), ruleSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某 Region 是否存在对应规则集(Req 16.6 的前置判断)。
|
||||
*/
|
||||
has(region: Region): boolean {
|
||||
return this.ruleSets.has(normalizeCode(region.code));
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载某 Region 的合规规则集。
|
||||
*
|
||||
* @throws {UnsupportedRegionError} 当该 Region 无对应规则集时(Req 16.6)。
|
||||
*/
|
||||
load(region: Region): ComplianceRuleSet {
|
||||
const ruleSet = this.ruleSets.get(normalizeCode(region.code));
|
||||
if (ruleSet === undefined) {
|
||||
throw new UnsupportedRegionError(region);
|
||||
}
|
||||
return ruleSet;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认注册表(首版仅含中国大陆 CN 规则集,Req 16.1)。
|
||||
*/
|
||||
export function createDefaultComplianceRegistry(): ComplianceRuleSetRegistry {
|
||||
return new ComplianceRuleSetRegistry([CN_COMPLIANCE_RULE_SET]);
|
||||
}
|
||||
|
||||
/** 进程级默认注册表实例(首版仅含 CN)。 */
|
||||
export const defaultComplianceRegistry: ComplianceRuleSetRegistry =
|
||||
createDefaultComplianceRegistry();
|
||||
|
||||
/**
|
||||
* 判断某 Region 当前是否被支持(存在对应规则集,Req 16.6)。
|
||||
*/
|
||||
export function isRegionSupported(
|
||||
region: Region,
|
||||
registry: ComplianceRuleSetRegistry = defaultComplianceRegistry,
|
||||
): boolean {
|
||||
return registry.has(region);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 Region 加载合规规则集(Req 16.1, 16.6)。
|
||||
*
|
||||
* @throws {UnsupportedRegionError} 当该 Region 无对应规则集时——
|
||||
* 据此拒绝合规判定与费用测算并提示暂不支持(Req 16.6)。
|
||||
*/
|
||||
export function loadComplianceRuleSet(
|
||||
region: Region,
|
||||
registry: ComplianceRuleSetRegistry = defaultComplianceRegistry,
|
||||
): ComplianceRuleSet {
|
||||
return registry.load(region);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Property 7: 模板实例化结构保持 的属性化测试(Config_Center,Req 2.3)。
|
||||
*
|
||||
* 属性陈述:对任意合法模板,实例化为 Risk_Model 后必完整保留其全部
|
||||
* Dimension、Indicator、权重、Scoring_Rule、Redline、追问话术(askPrompt)
|
||||
* 及各 Dimension 与 Indicator 的启用/停用状态,无丢失、无篡改。
|
||||
*
|
||||
* 本测试生成"合法模板"——同级启用 Dimension 权重之和、每个 Dimension 下同级
|
||||
* 启用 Indicator 权重之和均等于 100%,每个 Indicator 的 Scoring_Rule 覆盖
|
||||
* Risk_Level 1 至 5,且必填组成项齐备——使其通过 instantiateRiskModel 的
|
||||
* 校验门槛,从而专注验证"实例化的结构保持性"。
|
||||
*
|
||||
* 断言两层含义:
|
||||
* 1. 结构保持:实例化结果逐项深相等于模板配置(id/name/businessType、
|
||||
* 全部维度与指标及其权重/启停/评分规则/话术、全部红线)。
|
||||
* 2. 无篡改且无共享可变引用:实例化结果与输入模板不共享引用,且对结果的
|
||||
* 任意深层改动均不回写影响原模板。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 7: 模板实例化结构保持
|
||||
* Validates: Requirements 2.3
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
BUSINESS_TYPE_VALUES,
|
||||
RISK_LEVEL_VALUES,
|
||||
type RiskLevel,
|
||||
} from '../../domain/common.js';
|
||||
import type {
|
||||
Dimension,
|
||||
Indicator,
|
||||
Redline,
|
||||
RiskModelConfig,
|
||||
ScoringRule,
|
||||
Template,
|
||||
} from '../../domain/model.js';
|
||||
import { instantiateRiskModel } from '../instantiateRiskModel.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:构造"合法模板",保证通过 instantiateRiskModel 的必填项与权重和校验。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const businessTypeArb = fc.constantFrom(...BUSINESS_TYPE_VALUES);
|
||||
|
||||
/**
|
||||
* 将 100 划分为 `parts` 个各 ≥1 的整数,且其和恰为 100。
|
||||
* 用于生成"同级启用项权重之和等于 100%"的合法权重向量。
|
||||
*/
|
||||
function partitionArb(parts: number): fc.Arbitrary<number[]> {
|
||||
if (parts <= 1) {
|
||||
return fc.constant([100]);
|
||||
}
|
||||
return fc
|
||||
.uniqueArray(fc.integer({ min: 1, max: 99 }), {
|
||||
minLength: parts - 1,
|
||||
maxLength: parts - 1,
|
||||
})
|
||||
.map((cuts) => {
|
||||
const sorted = [...cuts].sort((a, b) => a - b);
|
||||
const weights: number[] = [];
|
||||
let prev = 0;
|
||||
for (const cut of sorted) {
|
||||
weights.push(cut - prev);
|
||||
prev = cut;
|
||||
}
|
||||
weights.push(100 - prev);
|
||||
return weights;
|
||||
});
|
||||
}
|
||||
|
||||
/** 覆盖 Risk_Level 1 至 5 全部级别的评分规则(满足必填校验,Req 11.3)。 */
|
||||
const scoringRulesArb: fc.Arbitrary<ScoringRule[]> = fc.tuple(
|
||||
...RISK_LEVEL_VALUES.map((level: RiskLevel) =>
|
||||
fc.record({
|
||||
level: fc.constant(level),
|
||||
label: fc.string({ minLength: 0, maxLength: 6 }),
|
||||
description: fc.string({ minLength: 0, maxLength: 12 }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/** 构造单个指标,指定其稳定标识、启停状态与权重。 */
|
||||
function makeIndicator(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
weight: number,
|
||||
): fc.Arbitrary<Indicator> {
|
||||
return fc
|
||||
.record({
|
||||
name: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
scoringRules: scoringRulesArb,
|
||||
evidenceRequired: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
askPrompt: fc.string({ minLength: 0, maxLength: 12 }),
|
||||
})
|
||||
.map((r) => ({
|
||||
id,
|
||||
name: r.name,
|
||||
weight,
|
||||
enabled,
|
||||
scoringRules: r.scoringRules,
|
||||
evidenceRequired: r.evidenceRequired,
|
||||
askPrompt: r.askPrompt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成某维度下的指标集合:启用指标权重之和恒为 100%(满足校验),
|
||||
* 另附若干停用指标(权重任意,不计入权重和,用于验证停用状态保持)。
|
||||
*/
|
||||
const indicatorsArb: fc.Arbitrary<Indicator[]> = fc
|
||||
.record({
|
||||
enabledCount: fc.integer({ min: 1, max: 3 }),
|
||||
disabledCount: fc.integer({ min: 0, max: 2 }),
|
||||
})
|
||||
.chain(({ enabledCount, disabledCount }) =>
|
||||
partitionArb(enabledCount).chain((enabledWeights) => {
|
||||
const enabledArbs = enabledWeights.map((w, idx) =>
|
||||
makeIndicator(`i-en-${idx}`, true, w),
|
||||
);
|
||||
const disabledArbs = Array.from({ length: disabledCount }, (_unused, idx) =>
|
||||
fc.nat({ max: 100 }).chain((w) => makeIndicator(`i-dis-${idx}`, false, w)),
|
||||
);
|
||||
return fc.tuple(...enabledArbs, ...disabledArbs);
|
||||
}),
|
||||
);
|
||||
|
||||
/** 构造单个维度,指定其稳定标识、启停状态与权重。 */
|
||||
function makeDimension(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
weight: number,
|
||||
): fc.Arbitrary<Dimension> {
|
||||
return fc
|
||||
.record({
|
||||
name: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
indicators: indicatorsArb,
|
||||
})
|
||||
.map((r) => ({
|
||||
id,
|
||||
name: r.name,
|
||||
weight,
|
||||
enabled,
|
||||
indicators: r.indicators,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成维度集合:启用维度权重之和恒为 100%(满足校验),另附若干停用维度
|
||||
* (权重任意,不计入权重和)。每个维度自身亦为合法结构。
|
||||
*/
|
||||
const dimensionsArb: fc.Arbitrary<Dimension[]> = fc
|
||||
.record({
|
||||
enabledCount: fc.integer({ min: 1, max: 3 }),
|
||||
disabledCount: fc.integer({ min: 0, max: 2 }),
|
||||
})
|
||||
.chain(({ enabledCount, disabledCount }) =>
|
||||
partitionArb(enabledCount).chain((enabledWeights) => {
|
||||
const enabledArbs = enabledWeights.map((w, idx) =>
|
||||
makeDimension(`d-en-${idx}`, true, w),
|
||||
);
|
||||
const disabledArbs = Array.from({ length: disabledCount }, (_unused, idx) =>
|
||||
fc.nat({ max: 100 }).chain((w) => makeDimension(`d-dis-${idx}`, false, w)),
|
||||
);
|
||||
return fc.tuple(...enabledArbs, ...disabledArbs);
|
||||
}),
|
||||
);
|
||||
|
||||
const redlineArb = (id: string): fc.Arbitrary<Redline> =>
|
||||
fc.record({
|
||||
id: fc.constant(id),
|
||||
triggerCondition: fc.string({ minLength: 0, maxLength: 10 }),
|
||||
consequence: fc.string({ minLength: 0, maxLength: 10 }),
|
||||
enabled: fc.boolean(),
|
||||
});
|
||||
|
||||
const redlinesArb: fc.Arbitrary<Redline[]> = fc
|
||||
.uniqueArray(fc.constantFrom('r1', 'r2', 'r3'), { minLength: 0, maxLength: 3 })
|
||||
.chain((ids) => fc.tuple(...ids.map((id) => redlineArb(id))));
|
||||
|
||||
const riskModelConfigArb: fc.Arbitrary<RiskModelConfig> = fc.record({
|
||||
name: fc.string({ minLength: 0, maxLength: 10 }),
|
||||
businessType: businessTypeArb,
|
||||
dimensions: dimensionsArb,
|
||||
redlines: redlinesArb,
|
||||
});
|
||||
|
||||
const validTemplateArb: fc.Arbitrary<Template> = fc
|
||||
.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
name: fc.string({ minLength: 0, maxLength: 10 }),
|
||||
businessType: businessTypeArb,
|
||||
industry: fc.string({ minLength: 0, maxLength: 6 }),
|
||||
isDefault: fc.boolean(),
|
||||
riskModelConfig: riskModelConfigArb,
|
||||
})
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
businessType: g.businessType,
|
||||
industry: g.industry,
|
||||
isDefault: g.isDefault,
|
||||
riskModelConfig: g.riskModelConfig,
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 7: 模板实例化结构保持 (Req 2.3)', () => {
|
||||
it('实例化结果完整保留模板全部组成项与启停状态,无丢失', () => {
|
||||
fc.assert(
|
||||
fc.property(validTemplateArb, (template) => {
|
||||
const model = instantiateRiskModel(template);
|
||||
const config = template.riskModelConfig;
|
||||
|
||||
// 顶层标识与业务类型来源映射保持。
|
||||
expect(model.id).toBe(template.id);
|
||||
expect(model.name).toBe(config.name);
|
||||
expect(model.businessType).toBe(config.businessType);
|
||||
|
||||
// 维度集合逐项深相等:保留全部 Dimension/Indicator、权重、
|
||||
// Scoring_Rule、askPrompt 与启用/停用状态。
|
||||
expect(model.dimensions).toEqual(config.dimensions);
|
||||
// 红线集合逐项深相等:保留全部 Redline 与其启停状态。
|
||||
expect(model.redlines).toEqual(config.redlines);
|
||||
|
||||
// 数量一一对应(无丢失、无新增)。
|
||||
expect(model.dimensions).toHaveLength(config.dimensions.length);
|
||||
model.dimensions.forEach((dim, di) => {
|
||||
const srcDim = config.dimensions[di] as Dimension;
|
||||
expect(dim.indicators).toHaveLength(srcDim.indicators.length);
|
||||
dim.indicators.forEach((ind, ii) => {
|
||||
const srcInd = srcDim.indicators[ii] as Indicator;
|
||||
// 逐项校验关键字段(权重/启停/话术/评分规则)显式保持。
|
||||
expect(ind.weight).toBe(srcInd.weight);
|
||||
expect(ind.enabled).toBe(srcInd.enabled);
|
||||
expect(ind.askPrompt).toBe(srcInd.askPrompt);
|
||||
expect(ind.scoringRules).toEqual(srcInd.scoringRules);
|
||||
});
|
||||
});
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('实例化不篡改原模板,且不与原模板共享可变引用', () => {
|
||||
fc.assert(
|
||||
fc.property(validTemplateArb, (template) => {
|
||||
// 实例化前对模板做独立深快照,用于事后比对"无篡改"。
|
||||
const snapshot = JSON.parse(JSON.stringify(template)) as Template;
|
||||
|
||||
const model = instantiateRiskModel(template);
|
||||
|
||||
// 不共享顶层与嵌套可变引用(深拷贝)。
|
||||
expect(model.dimensions).not.toBe(template.riskModelConfig.dimensions);
|
||||
expect(model.redlines).not.toBe(template.riskModelConfig.redlines);
|
||||
if (model.dimensions.length > 0) {
|
||||
const d0 = model.dimensions[0] as Dimension;
|
||||
const src0 = template.riskModelConfig.dimensions[0] as Dimension;
|
||||
expect(d0).not.toBe(src0);
|
||||
expect(d0.indicators).not.toBe(src0.indicators);
|
||||
}
|
||||
|
||||
// 对实例化结果施加深层改动,原模板恒保持不变。
|
||||
model.id = `${model.id}-mutated`;
|
||||
model.name = `${model.name}-mutated`;
|
||||
model.dimensions.push({
|
||||
id: '__injected__',
|
||||
name: '注入维度',
|
||||
weight: 0,
|
||||
enabled: false,
|
||||
indicators: [],
|
||||
});
|
||||
const firstDim = model.dimensions[0];
|
||||
if (firstDim !== undefined) {
|
||||
firstDim.weight = firstDim.weight + 1;
|
||||
firstDim.enabled = !firstDim.enabled;
|
||||
const firstInd = firstDim.indicators[0];
|
||||
if (firstInd !== undefined) {
|
||||
firstInd.askPrompt = `${firstInd.askPrompt}-mutated`;
|
||||
firstInd.weight = firstInd.weight + 1;
|
||||
firstInd.scoringRules.push({
|
||||
level: 1,
|
||||
label: '注入',
|
||||
description: '注入',
|
||||
});
|
||||
}
|
||||
}
|
||||
const firstRedline = model.redlines[0];
|
||||
if (firstRedline !== undefined) {
|
||||
firstRedline.enabled = !firstRedline.enabled;
|
||||
}
|
||||
|
||||
// 原模板与事前快照逐项一致:实例化无任何回写副作用。
|
||||
expect(template).toEqual(snapshot);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Property 8: 非法模板必被拒绝实例化 的属性化测试(Config_Center,Req 2.4)。
|
||||
*
|
||||
* 属性陈述:对任意缺失必填组成项、或同级 Dimension 权重之和 / 同级 Indicator
|
||||
* 权重之和不等于 100% 的模板,`instantiateRiskModel` 必不实例化 Risk_Model、
|
||||
* 终止本次评估并抛出 {@link TemplateDataError}。
|
||||
*
|
||||
* 测试策略:以一个结构合法的"基线模板"为起点(确保未施加变异时能成功实例化),
|
||||
* 对每个生成样本施加恰好一种"破坏性变异",使其在某一校验维度上确定性非法:
|
||||
* - removeAllDimensions:删除全部 Dimension(缺必填项)
|
||||
* - emptyIndicators:使某 Dimension 不含任何 Indicator(缺必填项)
|
||||
* - badDimWeight:使某 Dimension 权重非法(NaN / 负数 / Infinity)
|
||||
* - badIndWeight:使某 Indicator 权重非法(NaN / 负数 / Infinity)
|
||||
* - missingLevel:删除某 Indicator 的一条 Scoring_Rule(未覆盖 1-5)
|
||||
* - dimSumOff:使同级启用 Dimension 权重之和偏离 100%
|
||||
* - indSumOff:使某 Dimension 下同级启用 Indicator 权重之和偏离 100%
|
||||
* 断言变异后的模板必抛出 TemplateDataError,且基线模板本身可成功实例化。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 8: 非法模板必被拒绝实例化
|
||||
* Validates: Requirements 2.4
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
BUSINESS_TYPE_VALUES,
|
||||
RISK_LEVEL_VALUES,
|
||||
type RiskLevel,
|
||||
} from '../../domain/common.js';
|
||||
import type {
|
||||
Dimension,
|
||||
Indicator,
|
||||
ScoringRule,
|
||||
Template,
|
||||
} from '../../domain/model.js';
|
||||
import { instantiateRiskModel } from '../instantiateRiskModel.js';
|
||||
import { TemplateDataError } from '../errors.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 基线构造:生成结构合法的模板(权重整数且同级和恰为 100,评分规则覆盖 1-5)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 将 100 拆分为 n 个正整数之和:首项取余、其余各取 1,保证同级和恰为 100。 */
|
||||
function weightsSummingTo100(n: number): number[] {
|
||||
const weights = new Array<number>(n).fill(1);
|
||||
weights[0] = 100 - (n - 1);
|
||||
return weights;
|
||||
}
|
||||
|
||||
/** 覆盖 Risk_Level 1 至 5 的完整评分规则集合。 */
|
||||
function fullScoringRules(): ScoringRule[] {
|
||||
return RISK_LEVEL_VALUES.map((level: RiskLevel) => ({
|
||||
level,
|
||||
label: `L${level}`,
|
||||
description: `desc-${level}`,
|
||||
}));
|
||||
}
|
||||
|
||||
interface BaseSpec {
|
||||
/** 每个维度下的指标数量;数组长度即维度数量(均 ≥ 1)。 */
|
||||
readonly indicatorCounts: readonly number[];
|
||||
/** 业务类型。 */
|
||||
readonly businessType: (typeof BUSINESS_TYPE_VALUES)[number];
|
||||
}
|
||||
|
||||
/** 由 BaseSpec 构造一个结构合法、全部启用的基线模板。 */
|
||||
function buildValidTemplate(spec: BaseSpec): Template {
|
||||
const dimCount = spec.indicatorCounts.length;
|
||||
const dimWeights = weightsSummingTo100(dimCount);
|
||||
|
||||
const dimensions: Dimension[] = spec.indicatorCounts.map((indCount, d) => {
|
||||
const indWeights = weightsSummingTo100(indCount);
|
||||
const indicators: Indicator[] = Array.from({ length: indCount }, (_unused, i) => ({
|
||||
id: `d${d}-i${i}`,
|
||||
name: `指标 ${d}-${i}`,
|
||||
weight: indWeights[i]!,
|
||||
enabled: true,
|
||||
scoringRules: fullScoringRules(),
|
||||
evidenceRequired: '证据',
|
||||
askPrompt: '请补充',
|
||||
}));
|
||||
return {
|
||||
id: `d${d}`,
|
||||
name: `维度 ${d}`,
|
||||
weight: dimWeights[d]!,
|
||||
enabled: true,
|
||||
indicators,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'tpl-under-test',
|
||||
name: '受测模板',
|
||||
businessType: spec.businessType,
|
||||
industry: '通用',
|
||||
isDefault: false,
|
||||
riskModelConfig: {
|
||||
name: '风险模型',
|
||||
businessType: spec.businessType,
|
||||
dimensions,
|
||||
redlines: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 变异:对合法基线施加恰好一种破坏,使其在某校验维度确定性非法。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type MutationKind =
|
||||
| 'removeAllDimensions'
|
||||
| 'emptyIndicators'
|
||||
| 'badDimWeight'
|
||||
| 'badIndWeight'
|
||||
| 'missingLevel'
|
||||
| 'dimSumOff'
|
||||
| 'indSumOff';
|
||||
|
||||
interface Mutation {
|
||||
readonly kind: MutationKind;
|
||||
/** 维度选择比例(0..1),映射为维度下标。 */
|
||||
readonly dimFrac: number;
|
||||
/** 指标选择比例(0..1),映射为指标下标。 */
|
||||
readonly indFrac: number;
|
||||
/** 评分等级选择比例(0..1),映射为待删除的等级下标。 */
|
||||
readonly levelFrac: number;
|
||||
/** 非法权重取值选择(0=NaN,1=负数,2=Infinity)。 */
|
||||
readonly badValueIdx: number;
|
||||
/** 偏移量(≥1 的整数),用于破坏权重和。 */
|
||||
readonly delta: number;
|
||||
}
|
||||
|
||||
/** 将 [0,1) 比例映射为 [0, length) 的合法下标。 */
|
||||
function toIndex(frac: number, length: number): number {
|
||||
if (length <= 0) return 0;
|
||||
const idx = Math.floor(frac * length);
|
||||
return Math.min(idx, length - 1);
|
||||
}
|
||||
|
||||
const BAD_WEIGHTS = [Number.NaN, -1, Number.POSITIVE_INFINITY];
|
||||
|
||||
/** 对模板施加变异(原地修改新构造的对象),返回必定非法的模板。 */
|
||||
function applyMutation(template: Template, mutation: Mutation): Template {
|
||||
const { dimensions } = template.riskModelConfig;
|
||||
|
||||
switch (mutation.kind) {
|
||||
case 'removeAllDimensions': {
|
||||
template.riskModelConfig.dimensions = [];
|
||||
return template;
|
||||
}
|
||||
case 'emptyIndicators': {
|
||||
const dim = dimensions[toIndex(mutation.dimFrac, dimensions.length)]!;
|
||||
dim.indicators = [];
|
||||
return template;
|
||||
}
|
||||
case 'badDimWeight': {
|
||||
const dim = dimensions[toIndex(mutation.dimFrac, dimensions.length)]!;
|
||||
dim.weight = BAD_WEIGHTS[mutation.badValueIdx]!;
|
||||
return template;
|
||||
}
|
||||
case 'badIndWeight': {
|
||||
const dim = dimensions[toIndex(mutation.dimFrac, dimensions.length)]!;
|
||||
const ind = dim.indicators[toIndex(mutation.indFrac, dim.indicators.length)]!;
|
||||
ind.weight = BAD_WEIGHTS[mutation.badValueIdx]!;
|
||||
return template;
|
||||
}
|
||||
case 'missingLevel': {
|
||||
const dim = dimensions[toIndex(mutation.dimFrac, dimensions.length)]!;
|
||||
const ind = dim.indicators[toIndex(mutation.indFrac, dim.indicators.length)]!;
|
||||
const removeAt = toIndex(mutation.levelFrac, ind.scoringRules.length);
|
||||
ind.scoringRules.splice(removeAt, 1);
|
||||
return template;
|
||||
}
|
||||
case 'dimSumOff': {
|
||||
const dim = dimensions[toIndex(mutation.dimFrac, dimensions.length)]!;
|
||||
dim.weight += mutation.delta;
|
||||
return template;
|
||||
}
|
||||
case 'indSumOff': {
|
||||
const dim = dimensions[toIndex(mutation.dimFrac, dimensions.length)]!;
|
||||
const ind = dim.indicators[toIndex(mutation.indFrac, dim.indicators.length)]!;
|
||||
ind.weight += mutation.delta;
|
||||
return template;
|
||||
}
|
||||
default: {
|
||||
// 穷尽性检查:新增变异种类时编译期报错。
|
||||
const exhaustive: never = mutation.kind;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const baseSpecArb: fc.Arbitrary<BaseSpec> = fc.record({
|
||||
// 1..4 个维度,每个维度 1..4 个指标。
|
||||
indicatorCounts: fc.array(fc.integer({ min: 1, max: 4 }), {
|
||||
minLength: 1,
|
||||
maxLength: 4,
|
||||
}),
|
||||
businessType: fc.constantFrom(...BUSINESS_TYPE_VALUES),
|
||||
});
|
||||
|
||||
const mutationArb: fc.Arbitrary<Mutation> = fc.record({
|
||||
kind: fc.constantFrom<MutationKind>(
|
||||
'removeAllDimensions',
|
||||
'emptyIndicators',
|
||||
'badDimWeight',
|
||||
'badIndWeight',
|
||||
'missingLevel',
|
||||
'dimSumOff',
|
||||
'indSumOff',
|
||||
),
|
||||
dimFrac: fc.double({ min: 0, max: 0.999_999, noNaN: true }),
|
||||
indFrac: fc.double({ min: 0, max: 0.999_999, noNaN: true }),
|
||||
levelFrac: fc.double({ min: 0, max: 0.999_999, noNaN: true }),
|
||||
badValueIdx: fc.integer({ min: 0, max: BAD_WEIGHTS.length - 1 }),
|
||||
delta: fc.integer({ min: 1, max: 50 }),
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Feature: outsourcing-risk-assessment, Property 8: 非法模板必被拒绝实例化 (Req 2.4)', () => {
|
||||
it('任意施加破坏性变异的模板必抛出 TemplateDataError 且不实例化', () => {
|
||||
fc.assert(
|
||||
fc.property(baseSpecArb, mutationArb, (spec, mutation) => {
|
||||
// 前置:基线模板本身合法、可成功实例化(确保变异是非法的唯一来源)。
|
||||
const valid = buildValidTemplate(spec);
|
||||
expect(() => instantiateRiskModel(valid)).not.toThrow();
|
||||
|
||||
// 对一份独立构造的基线施加变异,断言其被拒绝实例化。
|
||||
const invalid = applyMutation(buildValidTemplate(spec), mutation);
|
||||
expect(() => instantiateRiskModel(invalid)).toThrow(TemplateDataError);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('示例:缺少全部 Dimension 的模板被拒绝', () => {
|
||||
const tpl = buildValidTemplate({ indicatorCounts: [2], businessType: '岗位外包' });
|
||||
tpl.riskModelConfig.dimensions = [];
|
||||
expect(() => instantiateRiskModel(tpl)).toThrow(TemplateDataError);
|
||||
});
|
||||
|
||||
it('示例:某 Indicator 评分规则未覆盖 Risk_Level 1-5 的模板被拒绝', () => {
|
||||
const tpl = buildValidTemplate({ indicatorCounts: [1], businessType: 'BPO' });
|
||||
tpl.riskModelConfig.dimensions[0]!.indicators[0]!.scoringRules.splice(2, 1);
|
||||
expect(() => instantiateRiskModel(tpl)).toThrow(TemplateDataError);
|
||||
});
|
||||
|
||||
it('示例:同级 Dimension 权重之和不等于 100% 的模板被拒绝', () => {
|
||||
const tpl = buildValidTemplate({
|
||||
indicatorCounts: [1, 1],
|
||||
businessType: '劳务派遣',
|
||||
});
|
||||
tpl.riskModelConfig.dimensions[0]!.weight += 10;
|
||||
expect(() => instantiateRiskModel(tpl)).toThrow(TemplateDataError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Property 6: 模板匹配与回退确定性 的属性化测试(Config_Center,Req 2.1, 2.2, 2.6, 14.3, 14.5)。
|
||||
*
|
||||
* 属性陈述:对任意知识库与(业务类型, 行业)组合:
|
||||
* 1. 若存在精确匹配模板(同业务类型且同行业),则必选中某个精确匹配模板,
|
||||
* 标注命中行业专用(matchedIndustrySpecific === true),且无回退标记。
|
||||
* 2. 若无精确匹配但存在该业务类型的默认模板(isDefault),则必选中某个默认模板,
|
||||
* 标注未命中行业专用(matchedIndustrySpecific === false)并附「未匹配行业专用模板」标记。
|
||||
* 3. 若两者皆无,则必终止并抛出 NoAvailableTemplateError。
|
||||
* 此外选择对任意输入均确定:当同层级存在多个候选时按模板 id 升序取首项。
|
||||
*
|
||||
* 本测试以一个与被测实现相互独立的"参考预言"(oracle)按属性陈述独立计算期望
|
||||
* 分支与期望选中模板,并断言 `loadTemplate` 的输出与之逐项一致。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 6: 模板匹配与回退确定性
|
||||
* Validates: Requirements 2.1, 2.2, 2.6, 14.3, 14.5
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { BUSINESS_TYPE_VALUES, type BusinessType } from '../../domain/common.js';
|
||||
import type { RiskModelConfig, Template } from '../../domain/model.js';
|
||||
import {
|
||||
loadTemplate,
|
||||
NoAvailableTemplateError,
|
||||
UNMATCHED_INDUSTRY_TEMPLATE_MARK,
|
||||
} from '../index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:使用小业务类型与小行业池,使精确匹配 / 默认回退 / 无可用 三个分支
|
||||
// 在 100 次迭代内均被高概率覆盖。行业池含带首尾空白变体,以覆盖匹配的空白不敏感。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const businessTypeArb = fc.constantFrom<BusinessType>(...BUSINESS_TYPE_VALUES);
|
||||
|
||||
/** 行业池:含一个 "未识别" 与若干普通行业,并刻意混入带空白变体以验证 trim 不敏感。 */
|
||||
const INDUSTRY_POOL = ['制造业', '金融业', '物流业', '未识别', ' 制造业 '];
|
||||
const industryArb = fc.constantFrom(...INDUSTRY_POOL);
|
||||
|
||||
/** 极简风险模型配置:本属性只关心模板选择,不关心配置主体内容。 */
|
||||
const riskModelConfigArb: fc.Arbitrary<RiskModelConfig> = businessTypeArb.map(
|
||||
(businessType) => ({ name: 'm', businessType, dimensions: [], redlines: [] }),
|
||||
);
|
||||
|
||||
/** 单个模板:id 取自小池以制造 id 冲突,从而真正检验"按 id 升序取首"的确定性。 */
|
||||
const templateArb = (id: string): fc.Arbitrary<Template> =>
|
||||
fc.record({
|
||||
businessType: businessTypeArb,
|
||||
industry: industryArb,
|
||||
isDefault: fc.boolean(),
|
||||
config: riskModelConfigArb,
|
||||
}).map((g) => ({
|
||||
id,
|
||||
name: `tpl-${id}`,
|
||||
businessType: g.businessType,
|
||||
industry: g.industry,
|
||||
isDefault: g.isDefault,
|
||||
riskModelConfig: g.config,
|
||||
}));
|
||||
|
||||
/** 模板数组:id 取自小池(去重产生不同 id),长度 0-6,覆盖空知识库与多候选。 */
|
||||
const templatesArb: fc.Arbitrary<Template[]> = fc
|
||||
.uniqueArray(fc.constantFrom('t1', 't2', 't3', 't4', 't5', 't6'), {
|
||||
minLength: 0,
|
||||
maxLength: 6,
|
||||
})
|
||||
.chain((ids) => fc.tuple(...ids.map((id) => templateArb(id))));
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 参考预言(oracle):独立按属性陈述确定期望分支与期望选中模板。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const norm = (s: string): string => s.trim();
|
||||
|
||||
/** 在候选中按 id 升序取首项(与实现的确定性消歧一致,但独立实现)。 */
|
||||
function oraclePick(candidates: readonly Template[]): Template | undefined {
|
||||
if (candidates.length === 0) return undefined;
|
||||
return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0];
|
||||
}
|
||||
|
||||
interface Query {
|
||||
businessType: BusinessType;
|
||||
industry: string;
|
||||
}
|
||||
|
||||
const queryArb: fc.Arbitrary<Query> = fc.record({
|
||||
businessType: businessTypeArb,
|
||||
industry: industryArb,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 6: 模板匹配与回退确定性 (Req 2.1, 2.2, 2.6, 14.3, 14.5)', () => {
|
||||
it('精确匹配优先 → 默认回退并标注 → 皆无则终止;选择确定', () => {
|
||||
fc.assert(
|
||||
fc.property(templatesArb, queryArb, (templates, query) => {
|
||||
const exactMatches = templates.filter(
|
||||
(t) =>
|
||||
t.businessType === query.businessType &&
|
||||
norm(t.industry) === norm(query.industry),
|
||||
);
|
||||
const defaultMatches = templates.filter(
|
||||
(t) => t.businessType === query.businessType && t.isDefault,
|
||||
);
|
||||
|
||||
if (exactMatches.length > 0) {
|
||||
// 分支 1:精确匹配(Req 2.1, 14.3)。
|
||||
const result = loadTemplate(query.businessType, query.industry, templates);
|
||||
const expected = oraclePick(exactMatches);
|
||||
expect(result.matchedIndustrySpecific).toBe(true);
|
||||
expect(result.unmatchedIndustryMark).toBeUndefined();
|
||||
expect(result.template).toEqual(expected);
|
||||
// 选中模板确属精确匹配集。
|
||||
expect(exactMatches).toContainEqual(result.template);
|
||||
} else if (defaultMatches.length > 0) {
|
||||
// 分支 2:回退业务类型默认模板并标注(Req 2.2, 14.5)。
|
||||
const result = loadTemplate(query.businessType, query.industry, templates);
|
||||
const expected = oraclePick(defaultMatches);
|
||||
expect(result.matchedIndustrySpecific).toBe(false);
|
||||
expect(result.unmatchedIndustryMark).toBe(UNMATCHED_INDUSTRY_TEMPLATE_MARK);
|
||||
expect(result.template).toEqual(expected);
|
||||
expect(defaultMatches).toContainEqual(result.template);
|
||||
} else {
|
||||
// 分支 3:皆无 → 终止(Req 2.6)。
|
||||
expect(() =>
|
||||
loadTemplate(query.businessType, query.industry, templates),
|
||||
).toThrow(NoAvailableTemplateError);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('确定性:对同一输入重复调用结果一致', () => {
|
||||
fc.assert(
|
||||
fc.property(templatesArb, queryArb, (templates, query) => {
|
||||
const run = (): unknown => {
|
||||
try {
|
||||
return loadTemplate(query.businessType, query.industry, templates);
|
||||
} catch (err) {
|
||||
return err instanceof NoAvailableTemplateError ? 'NoAvailableTemplate' : err;
|
||||
}
|
||||
};
|
||||
expect(run()).toEqual(run());
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 权重归一化属性测试(Config_Center,Req 11.2)。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 40: 权重归一化保比例且同级和为 100%
|
||||
*
|
||||
* 对任意非全零的同级权重向量(取值 0 至 100),归一化后同级启用项权重之和恒等于
|
||||
* 100%(保留两位小数),且任意两项归一化前后的比例恒保持不变(在最大余数取整精度内)。
|
||||
*/
|
||||
|
||||
import fc from 'fast-check';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
normalizeWeights,
|
||||
type NormalizableSibling,
|
||||
} from '../normalizeWeights.js';
|
||||
|
||||
/**
|
||||
* 生成同级项数组(含启用/停用项),权重取值 [0, 100] 的浮点数。
|
||||
* 长度 1 至 8,覆盖单项、零权重项与混合启停场景。
|
||||
*/
|
||||
const siblingsArb = fc.array(
|
||||
fc.record<NormalizableSibling>({
|
||||
weight: fc.double({ min: 0, max: 100, noNaN: true }),
|
||||
enabled: fc.boolean(),
|
||||
}),
|
||||
{ minLength: 1, maxLength: 8 },
|
||||
);
|
||||
|
||||
describe('Feature: outsourcing-risk-assessment, Property 40: 权重归一化保比例且同级和为 100%', () => {
|
||||
it('非全零向量归一化后启用项之和=100%(两位小数)且保持两两比例', () => {
|
||||
fc.assert(
|
||||
fc.property(siblingsArb, (siblings) => {
|
||||
const enabledEntries = siblings
|
||||
.map((sibling, index) => ({ sibling, index }))
|
||||
.filter((entry) => entry.sibling.enabled);
|
||||
const enabledSum = enabledEntries.reduce(
|
||||
(acc, entry) => acc + entry.sibling.weight,
|
||||
0,
|
||||
);
|
||||
|
||||
// 前置条件:非全零同级权重向量(存在启用项且其权重之和 > 0)。
|
||||
fc.pre(enabledSum > 0);
|
||||
|
||||
const result = normalizeWeights(siblings);
|
||||
|
||||
// 结构保持:长度与顺序不变,停用项权重原样保留。
|
||||
expect(result).toHaveLength(siblings.length);
|
||||
for (let i = 0; i < siblings.length; i += 1) {
|
||||
const original = siblings[i]!;
|
||||
if (!original.enabled) {
|
||||
expect(result[i]!.weight).toBe(original.weight);
|
||||
}
|
||||
}
|
||||
|
||||
// 同级和为 100%(两位小数)。
|
||||
const normalizedEnabledSum = enabledEntries.reduce(
|
||||
(acc, entry) => acc + result[entry.index]!.weight,
|
||||
0,
|
||||
);
|
||||
expect(normalizedEnabledSum).toBeCloseTo(100, 6);
|
||||
expect(Math.round(normalizedEnabledSum * 100) / 100).toBe(100);
|
||||
|
||||
// 每个归一化权重为两位小数。
|
||||
for (const entry of enabledEntries) {
|
||||
const w = result[entry.index]!.weight;
|
||||
expect(Math.round(w * 100)).toBeCloseTo(w * 100, 9);
|
||||
}
|
||||
|
||||
// 保比例:对任意两启用项,归一化前后比例不变(交叉相乘,
|
||||
// 容差 = 0.01×(w1+w2),对应最大余数取整每项至多 0.01% 的偏差)。
|
||||
for (let a = 0; a < enabledEntries.length; a += 1) {
|
||||
for (let b = a + 1; b < enabledEntries.length; b += 1) {
|
||||
const ea = enabledEntries[a]!;
|
||||
const eb = enabledEntries[b]!;
|
||||
const w1 = ea.sibling.weight;
|
||||
const w2 = eb.sibling.weight;
|
||||
const n1 = result[ea.index]!.weight;
|
||||
const n2 = result[eb.index]!.weight;
|
||||
|
||||
// n1/n2 == w1/w2 <=> n1*w2 == n2*w1
|
||||
const crossDiff = Math.abs(n1 * w2 - n2 * w1);
|
||||
const tolerance = 0.01 * (w1 + w2) + 1e-9;
|
||||
expect(crossDiff).toBeLessThanOrEqual(tolerance);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('示例:[60,40] 启用项归一化为 [60,40] 且和为 100', () => {
|
||||
const result = normalizeWeights([
|
||||
{ weight: 60, enabled: true },
|
||||
{ weight: 40, enabled: true },
|
||||
]);
|
||||
expect(result[0]!.weight).toBe(60);
|
||||
expect(result[1]!.weight).toBe(40);
|
||||
});
|
||||
|
||||
it('示例:[1,1,1] 按比例归一化后和精确为 100', () => {
|
||||
const result = normalizeWeights([
|
||||
{ weight: 1, enabled: true },
|
||||
{ weight: 1, enabled: true },
|
||||
{ weight: 1, enabled: true },
|
||||
]);
|
||||
const sum = result.reduce((acc, s) => acc + s.weight, 0);
|
||||
expect(Math.round(sum * 100) / 100).toBe(100);
|
||||
// 等权三项无法被 100 整除,最大余数法得 [33.34, 33.33, 33.33];
|
||||
// 比例在取整精度内保持:各项两两相差不超过 0.01%。
|
||||
for (let i = 0; i < result.length; i += 1) {
|
||||
for (let j = i + 1; j < result.length; j += 1) {
|
||||
expect(Math.abs(result[i]!.weight - result[j]!.weight)).toBeLessThanOrEqual(
|
||||
0.01 + 1e-9,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 权重全零拒绝属性测试(Config_Center,Req 11.8)。
|
||||
*
|
||||
* Property 44: 同级权重全零拒绝归一化 —— 对任意同级向量,只要其启用项
|
||||
* (enabled = true)的权重之和为 0(含全部启用项权重为 0、以及完全没有启用项
|
||||
* 两种情形),normalizeWeights 必抛出 WeightValidationError,拒绝归一化。
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { normalizeWeights, type NormalizableSibling } from '../normalizeWeights.js';
|
||||
import { WeightValidationError } from '../errors.js';
|
||||
|
||||
/**
|
||||
* 生成"启用项权重之和为 0"的同级向量:
|
||||
* - 启用项(enabled = true)权重恒为 0;
|
||||
* - 停用项(enabled = false)权重为 [0,100] 两位小数的任意值,不影响启用项之和。
|
||||
*
|
||||
* 该生成器精确刻画 Req 11.8 的输入空间(启用项之和为 0),同时覆盖
|
||||
* "无任何启用项"的退化情形(数组可为空或全为停用项)。
|
||||
*/
|
||||
const zeroEnabledSumArb: fc.Arbitrary<NormalizableSibling[]> = fc.array(
|
||||
fc.record({
|
||||
enabled: fc.boolean(),
|
||||
// 停用项权重任意;启用项权重稍后统一置 0。
|
||||
weight: fc
|
||||
.integer({ min: 0, max: 10_000 })
|
||||
.map((units) => units / 100),
|
||||
}),
|
||||
{ maxLength: 12 },
|
||||
).map((siblings) =>
|
||||
siblings.map((s) => (s.enabled ? { ...s, weight: 0 } : s)),
|
||||
);
|
||||
|
||||
describe('Property 44: 同级权重全零拒绝归一化 (Req 11.8)', () => {
|
||||
// Feature: outsourcing-risk-assessment, Property 44: 同级权重全零拒绝归一化
|
||||
it('对任意启用项权重之和为 0 的同级向量,normalizeWeights 必抛出 WeightValidationError', () => {
|
||||
fc.assert(
|
||||
fc.property(zeroEnabledSumArb, (siblings) => {
|
||||
// 前置不变式:启用项权重之和确为 0。
|
||||
const enabledSum = siblings
|
||||
.filter((s) => s.enabled)
|
||||
.reduce((acc, s) => acc + s.weight, 0);
|
||||
expect(enabledSum).toBe(0);
|
||||
|
||||
// 归一化必被拒绝并抛出权重校验错误。
|
||||
expect(() => normalizeWeights(siblings)).toThrow(WeightValidationError);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Property 10: 继承链环与深度防护 的属性化测试(Config_Center,Req 2.7)。
|
||||
*
|
||||
* 属性陈述:对任意模板继承图,若存在循环引用或继承层级超过 5 层,
|
||||
* `resolveInheritance` 必终止并抛出 {@link TemplateInheritanceError};
|
||||
* 任意无环且层级不超过 5 的合法链必成功解析(不抛错且返回展开后的模板)。
|
||||
*
|
||||
* 继承层级以「父模板链接数」计量:链上模板数为 N 时,父链接数为 N-1。
|
||||
* 实现上限 MAX_INHERITANCE_DEPTH=5 指父链接数不得超过 5,即合法链最多含
|
||||
* 6 个模板(子 + 至多 5 层父)。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 10: 继承链环与深度防护
|
||||
* Validates: Requirements 2.7
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { BUSINESS_TYPE_VALUES } from '../../domain/common.js';
|
||||
import type { RiskModelConfig, Template } from '../../domain/model.js';
|
||||
import { TemplateInheritanceError } from '../errors.js';
|
||||
import {
|
||||
MAX_INHERITANCE_DEPTH,
|
||||
resolveInheritance,
|
||||
type TemplateLookup,
|
||||
} from '../resolveInheritance.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 辅助:构造最小合法的风险模型配置与线性继承链。
|
||||
//
|
||||
// 本属性聚焦「环 / 层级越界 / 合法链」三类拓扑,组成项合并语义已由 Property 9
|
||||
// 覆盖,故此处使用最小占位配置即可。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const businessTypeArb = fc.constantFrom(...BUSINESS_TYPE_VALUES);
|
||||
|
||||
const minimalConfigArb: fc.Arbitrary<RiskModelConfig> = fc.record({
|
||||
name: fc.string({ minLength: 0, maxLength: 6 }),
|
||||
businessType: businessTypeArb,
|
||||
dimensions: fc.constant([]),
|
||||
redlines: fc.constant([]),
|
||||
});
|
||||
|
||||
/**
|
||||
* 构造由 `count` 个模板组成的线性继承链:
|
||||
* `t0`(子)→ `t1` → … → `t{count-1}`(根,无父)。
|
||||
* 返回链上全部模板及一个按 id 解析的查找函数。
|
||||
*/
|
||||
function buildLinearChain(
|
||||
count: number,
|
||||
configs: RiskModelConfig[],
|
||||
): { child: Template; lookup: TemplateLookup } {
|
||||
const templates: Template[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const isRoot = i === count - 1;
|
||||
const config = configs[i] ?? configs[0]!;
|
||||
const template: Template = {
|
||||
id: `t${i}`,
|
||||
name: `t${i}`,
|
||||
businessType: config.businessType,
|
||||
industry: '通用',
|
||||
isDefault: false,
|
||||
riskModelConfig: config,
|
||||
...(isRoot ? {} : { parentTemplateId: `t${i + 1}` }),
|
||||
};
|
||||
templates.push(template);
|
||||
}
|
||||
const byId = new Map(templates.map((t) => [t.id, t]));
|
||||
const lookup: TemplateLookup = (id) => byId.get(id);
|
||||
const child = templates[0] as Template;
|
||||
return { child, lookup };
|
||||
}
|
||||
|
||||
/** 生成长度为 n 的最小配置数组,供链上每个模板使用。 */
|
||||
const configsArb = (n: number): fc.Arbitrary<RiskModelConfig[]> =>
|
||||
fc.array(minimalConfigArb, { minLength: n, maxLength: n });
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 10: 继承链环与深度防护 (Req 2.7)', () => {
|
||||
it('任意无环且层级 ≤ 5 的合法链必成功解析', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// 链上模板数 1..MAX+1(父链接数 0..MAX),均为合法层级。
|
||||
fc.integer({ min: 1, max: MAX_INHERITANCE_DEPTH + 1 }).chain((count) =>
|
||||
configsArb(count).map((configs) => ({ count, configs })),
|
||||
),
|
||||
({ count, configs }) => {
|
||||
const { child, lookup } = buildLinearChain(count, configs);
|
||||
const resolved = resolveInheritance(child, lookup);
|
||||
// 成功解析:保留子模板标识,且已完全展开(不再携带 parentTemplateId)。
|
||||
expect(resolved.id).toBe(child.id);
|
||||
expect(resolved.parentTemplateId).toBeUndefined();
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('任意层级超过 5 层(父链接数 ≥ 6)的链必抛出继承错误', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// 链上模板数 MAX+2..MAX+8(父链接数 MAX+1..),均越界。
|
||||
fc
|
||||
.integer({ min: MAX_INHERITANCE_DEPTH + 2, max: MAX_INHERITANCE_DEPTH + 8 })
|
||||
.chain((count) => configsArb(count).map((configs) => ({ count, configs }))),
|
||||
({ count, configs }) => {
|
||||
const { child, lookup } = buildLinearChain(count, configs);
|
||||
expect(() => resolveInheritance(child, lookup)).toThrow(
|
||||
TemplateInheritanceError,
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('任意含循环引用的继承图必抛出继承错误', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// 距离环回指节点数 1..MAX+1:自环(1) 及更长回路,且总节点数不触发层级越界。
|
||||
fc.integer({ min: 1, max: MAX_INHERITANCE_DEPTH + 1 }).chain((count) =>
|
||||
configsArb(count).map((configs) => ({ count, configs })),
|
||||
),
|
||||
// 环的回指目标索引:链上某个已存在节点。
|
||||
fc.nat(),
|
||||
({ count, configs }, rawTarget) => {
|
||||
const templates: Template[] = [];
|
||||
const targetIndex = rawTarget % count; // 0..count-1
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const config = configs[i] ?? configs[0]!;
|
||||
// 末节点回指 targetIndex 形成环;其余指向下一节点。
|
||||
const parentIndex = i === count - 1 ? targetIndex : i + 1;
|
||||
templates.push({
|
||||
id: `c${i}`,
|
||||
name: `c${i}`,
|
||||
businessType: config.businessType,
|
||||
industry: '通用',
|
||||
isDefault: false,
|
||||
riskModelConfig: config,
|
||||
parentTemplateId: `c${parentIndex}`,
|
||||
});
|
||||
}
|
||||
const byId = new Map(templates.map((t) => [t.id, t]));
|
||||
const lookup: TemplateLookup = (id) => byId.get(id);
|
||||
const child = templates[0] as Template;
|
||||
expect(() => resolveInheritance(child, lookup)).toThrow(
|
||||
TemplateInheritanceError,
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Property 9: 模板继承逐项覆盖 的属性化测试(Config_Center,Req 2.5)。
|
||||
*
|
||||
* 属性陈述:对任意父子模板对,继承解析结果必等于"应用父模板全部组成项后,
|
||||
* 以子模板中存在差异的组成项逐项覆盖"。组成项按稳定标识对齐——
|
||||
* Dimension/Indicator/Redline 按 id、ScoringRule 按 level——子模板中标识匹配者
|
||||
* 覆盖对应父项、标识新增者追加、父模板独有者保留。
|
||||
*
|
||||
* 本测试以一个与被测实现相互独立的"参考预言"(oracle)按属性陈述重新计算
|
||||
* 期望合并结果,并断言 `resolveInheritance` 的输出与之逐项一致。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 9: 模板继承逐项覆盖
|
||||
* Validates: Requirements 2.5
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
BUSINESS_TYPE_VALUES,
|
||||
RISK_LEVEL_VALUES,
|
||||
type RiskLevel,
|
||||
} from '../../domain/common.js';
|
||||
import type {
|
||||
Dimension,
|
||||
Indicator,
|
||||
Redline,
|
||||
RiskModelConfig,
|
||||
ScoringRule,
|
||||
Template,
|
||||
} from '../../domain/model.js';
|
||||
import { resolveInheritance, type TemplateLookup } from '../resolveInheritance.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:使用小标识池,使父子模板的组成项标识高概率重叠,
|
||||
// 从而真实覆盖"覆盖既有项 / 追加新项 / 保留父独有项"三种分支。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const businessTypeArb = fc.constantFrom(...BUSINESS_TYPE_VALUES);
|
||||
|
||||
const scoringRuleArb = (level: RiskLevel): fc.Arbitrary<ScoringRule> =>
|
||||
fc.record({
|
||||
level: fc.constant(level),
|
||||
label: fc.string({ minLength: 0, maxLength: 6 }),
|
||||
description: fc.string({ minLength: 0, maxLength: 12 }),
|
||||
});
|
||||
|
||||
/** 评分规则数组:按 level 去重,level 取自 1-5。 */
|
||||
const scoringRulesArb: fc.Arbitrary<ScoringRule[]> = fc
|
||||
.uniqueArray(fc.constantFrom<RiskLevel>(...(RISK_LEVEL_VALUES as readonly RiskLevel[])), {
|
||||
minLength: 0,
|
||||
maxLength: 5,
|
||||
})
|
||||
.chain((levels) => fc.tuple(...levels.map((l) => scoringRuleArb(l))));
|
||||
|
||||
const indicatorArb = (id: string): fc.Arbitrary<Indicator> =>
|
||||
fc.record({
|
||||
id: fc.constant(id),
|
||||
name: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
weight: fc.integer({ min: 0, max: 100 }),
|
||||
enabled: fc.boolean(),
|
||||
scoringRules: scoringRulesArb,
|
||||
evidenceRequired: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
askPrompt: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
});
|
||||
|
||||
/** 指标数组:按 id 去重,id 取自小池。 */
|
||||
const indicatorsArb: fc.Arbitrary<Indicator[]> = fc
|
||||
.uniqueArray(fc.constantFrom('i1', 'i2', 'i3', 'i4'), { minLength: 0, maxLength: 4 })
|
||||
.chain((ids) => fc.tuple(...ids.map((id) => indicatorArb(id))));
|
||||
|
||||
const dimensionArb = (id: string): fc.Arbitrary<Dimension> =>
|
||||
fc.record({
|
||||
id: fc.constant(id),
|
||||
name: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
weight: fc.integer({ min: 0, max: 100 }),
|
||||
enabled: fc.boolean(),
|
||||
indicators: indicatorsArb,
|
||||
});
|
||||
|
||||
const dimensionsArb: fc.Arbitrary<Dimension[]> = fc
|
||||
.uniqueArray(fc.constantFrom('d1', 'd2', 'd3', 'd4'), { minLength: 0, maxLength: 4 })
|
||||
.chain((ids) => fc.tuple(...ids.map((id) => dimensionArb(id))));
|
||||
|
||||
const redlineArb = (id: string): fc.Arbitrary<Redline> =>
|
||||
fc.record({
|
||||
id: fc.constant(id),
|
||||
triggerCondition: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
consequence: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
enabled: fc.boolean(),
|
||||
});
|
||||
|
||||
const redlinesArb: fc.Arbitrary<Redline[]> = fc
|
||||
.uniqueArray(fc.constantFrom('r1', 'r2', 'r3'), { minLength: 0, maxLength: 3 })
|
||||
.chain((ids) => fc.tuple(...ids.map((id) => redlineArb(id))));
|
||||
|
||||
const riskModelConfigArb: fc.Arbitrary<RiskModelConfig> = fc.record({
|
||||
name: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
businessType: businessTypeArb,
|
||||
dimensions: dimensionsArb,
|
||||
redlines: redlinesArb,
|
||||
});
|
||||
|
||||
/** 父子模板对:子模板 parentTemplateId 指向父模板,标识互异。 */
|
||||
const parentChildPairArb: fc.Arbitrary<{ parent: Template; child: Template }> = fc.record({
|
||||
parentConfig: riskModelConfigArb,
|
||||
childConfig: riskModelConfigArb,
|
||||
parentName: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
childName: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
parentBusiness: businessTypeArb,
|
||||
childBusiness: businessTypeArb,
|
||||
industry: fc.string({ minLength: 0, maxLength: 6 }),
|
||||
isDefault: fc.boolean(),
|
||||
}).map((g) => {
|
||||
const parent: Template = {
|
||||
id: 'parent',
|
||||
name: g.parentName,
|
||||
businessType: g.parentBusiness,
|
||||
industry: g.industry,
|
||||
isDefault: false,
|
||||
riskModelConfig: g.parentConfig,
|
||||
};
|
||||
const child: Template = {
|
||||
id: 'child',
|
||||
name: g.childName,
|
||||
businessType: g.childBusiness,
|
||||
industry: g.industry,
|
||||
parentTemplateId: 'parent',
|
||||
isDefault: g.isDefault,
|
||||
riskModelConfig: g.childConfig,
|
||||
};
|
||||
return { parent, child };
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 参考预言(oracle):独立按属性陈述计算"父基线 + 子逐项覆盖"。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 按稳定标识逐项合并:父项构成有序基线;子项标识匹配者经 mergeItem 覆盖、
|
||||
* 标识新增者按子顺序追加;父独有项保留。
|
||||
*/
|
||||
function oracleMergeByKey<T, K>(
|
||||
parents: readonly T[],
|
||||
children: readonly T[],
|
||||
keyOf: (item: T) => K,
|
||||
mergeItem: (parent: T, child: T) => T,
|
||||
): T[] {
|
||||
const order: K[] = [];
|
||||
const byKey = new Map<K, T>();
|
||||
for (const p of parents) {
|
||||
const k = keyOf(p);
|
||||
if (!byKey.has(k)) order.push(k);
|
||||
byKey.set(k, p);
|
||||
}
|
||||
for (const c of children) {
|
||||
const k = keyOf(c);
|
||||
if (byKey.has(k)) {
|
||||
const existing = byKey.get(k) as T;
|
||||
byKey.set(k, mergeItem(existing, c));
|
||||
} else {
|
||||
order.push(k);
|
||||
byKey.set(k, c);
|
||||
}
|
||||
}
|
||||
return order.map((k) => byKey.get(k) as T);
|
||||
}
|
||||
|
||||
function oracleMergeIndicator(parent: Indicator, child: Indicator): Indicator {
|
||||
return {
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
weight: child.weight,
|
||||
enabled: child.enabled,
|
||||
scoringRules: oracleMergeByKey(
|
||||
parent.scoringRules,
|
||||
child.scoringRules,
|
||||
(r) => r.level,
|
||||
(_p, c) => c,
|
||||
),
|
||||
evidenceRequired: child.evidenceRequired,
|
||||
askPrompt: child.askPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
function oracleMergeDimension(parent: Dimension, child: Dimension): Dimension {
|
||||
return {
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
weight: child.weight,
|
||||
enabled: child.enabled,
|
||||
indicators: oracleMergeByKey(
|
||||
parent.indicators,
|
||||
child.indicators,
|
||||
(i) => i.id,
|
||||
oracleMergeIndicator,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function oracleMergeConfig(
|
||||
parent: RiskModelConfig,
|
||||
child: RiskModelConfig,
|
||||
): RiskModelConfig {
|
||||
return {
|
||||
name: child.name,
|
||||
businessType: child.businessType,
|
||||
dimensions: oracleMergeByKey(
|
||||
parent.dimensions,
|
||||
child.dimensions,
|
||||
(d) => d.id,
|
||||
oracleMergeDimension,
|
||||
),
|
||||
redlines: oracleMergeByKey(
|
||||
parent.redlines,
|
||||
child.redlines,
|
||||
(r) => r.id,
|
||||
(_p, c) => c,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 9: 模板继承逐项覆盖 (Req 2.5)', () => {
|
||||
it('继承解析结果等于"父基线 + 子逐项覆盖"参考预言', () => {
|
||||
fc.assert(
|
||||
fc.property(parentChildPairArb, ({ parent, child }) => {
|
||||
const lookup: TemplateLookup = (id) => (id === parent.id ? parent : undefined);
|
||||
|
||||
const resolved = resolveInheritance(child, lookup);
|
||||
const expectedConfig = oracleMergeConfig(
|
||||
parent.riskModelConfig,
|
||||
child.riskModelConfig,
|
||||
);
|
||||
|
||||
// 合并后的配置逐项一致(覆盖/追加/保留三种分支)。
|
||||
expect(resolved.riskModelConfig).toEqual(expectedConfig);
|
||||
|
||||
// 解析后保留子模板自身元数据,且已完全展开(不再携带 parentTemplateId)。
|
||||
expect(resolved.id).toBe(child.id);
|
||||
expect(resolved.name).toBe(child.name);
|
||||
expect(resolved.businessType).toBe(child.businessType);
|
||||
expect(resolved.industry).toBe(child.industry);
|
||||
expect(resolved.isDefault).toBe(child.isDefault);
|
||||
expect(resolved.parentTemplateId).toBeUndefined();
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('退化情形:无父模板时解析结果等于子模板自身配置', () => {
|
||||
fc.assert(
|
||||
fc.property(riskModelConfigArb, (config) => {
|
||||
const standalone: Template = {
|
||||
id: 'solo',
|
||||
name: 'solo',
|
||||
businessType: config.businessType,
|
||||
industry: '通用',
|
||||
isDefault: false,
|
||||
riskModelConfig: config,
|
||||
};
|
||||
const resolved = resolveInheritance(standalone, () => undefined);
|
||||
expect(resolved.riskModelConfig).toEqual(config);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Property 42: 配置另存为模板可往返 的属性化测试(Config_Center,Req 11.5)。
|
||||
*
|
||||
* 属性陈述:对任意合法配置,将其另存为模板({@link saveAsTemplate})再实例化
|
||||
* ({@link instantiateRiskModel})所得配置必与原配置等价(round-trip)。
|
||||
*
|
||||
* 等价性说明:{@link saveAsTemplate} 在另存前会对配置执行与 saveConfig 一致的
|
||||
* 校验与按比例归一化(Req 11.2),归一化可能调整权重数值。因此往返等价性以
|
||||
* 归一化后的形态为基准——即「模板所存归一化配置」与「实例化所得配置」逐项深相等。
|
||||
* 这正是设计中保证「所存模板可被 instantiateRiskModel 直接实例化」的不变式。
|
||||
*
|
||||
* 本测试覆盖两条互补的属性:
|
||||
* 1. 一般情形:对任意合法(权重为正、可归一化)配置,另存为模板再实例化所得
|
||||
* 配置与模板所存归一化配置逐项等价(round-trip on normalized form)。
|
||||
* 2. 已归一化情形:当各同级启用项权重之和已为 100% 时,归一化为恒等变换,
|
||||
* 故往返必精确还原原始配置(instantiated == original)。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 42: 配置另存为模板可往返
|
||||
* Validates: Requirements 11.5
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
BUSINESS_TYPE_VALUES,
|
||||
RISK_LEVEL_VALUES,
|
||||
type RiskLevel,
|
||||
} from '../../domain/common.js';
|
||||
import type {
|
||||
Dimension,
|
||||
Indicator,
|
||||
Redline,
|
||||
RiskModelConfig,
|
||||
ScoringRule,
|
||||
} from '../../domain/model.js';
|
||||
import { saveAsTemplate } from '../saveConfig.js';
|
||||
import { instantiateRiskModel } from '../instantiateRiskModel.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 公共生成器片段
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const businessTypeArb = fc.constantFrom(...BUSINESS_TYPE_VALUES);
|
||||
|
||||
/** 保证去除首尾空白后非空的字符串(满足必填项校验 isNonEmpty)。 */
|
||||
const nonEmptyArb = (maxLength: number): fc.Arbitrary<string> =>
|
||||
fc.string({ minLength: 0, maxLength }).map((s) => `x${s}`);
|
||||
|
||||
/** 覆盖 Risk_Level 1 至 5 全部级别的评分规则(满足覆盖校验,Req 11.3)。 */
|
||||
const scoringRulesArb: fc.Arbitrary<ScoringRule[]> = fc.tuple(
|
||||
...RISK_LEVEL_VALUES.map((level: RiskLevel) =>
|
||||
fc.record({
|
||||
level: fc.constant(level),
|
||||
label: fc.string({ minLength: 0, maxLength: 6 }),
|
||||
description: fc.string({ minLength: 0, maxLength: 12 }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/** 构造单个指标,指定其稳定标识、启停状态与权重。 */
|
||||
function makeIndicator(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
weight: number,
|
||||
): fc.Arbitrary<Indicator> {
|
||||
return fc
|
||||
.record({
|
||||
name: nonEmptyArb(8),
|
||||
scoringRules: scoringRulesArb,
|
||||
evidenceRequired: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
askPrompt: fc.string({ minLength: 0, maxLength: 12 }),
|
||||
})
|
||||
.map((r) => ({
|
||||
id,
|
||||
name: r.name,
|
||||
weight,
|
||||
enabled,
|
||||
scoringRules: r.scoringRules,
|
||||
evidenceRequired: r.evidenceRequired,
|
||||
askPrompt: r.askPrompt,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 构造单个维度,指定其稳定标识、启停状态、权重与其下指标集合。 */
|
||||
function makeDimension(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
weight: number,
|
||||
indicators: fc.Arbitrary<Indicator[]>,
|
||||
): fc.Arbitrary<Dimension> {
|
||||
return fc
|
||||
.record({ name: nonEmptyArb(8), indicators })
|
||||
.map((r) => ({
|
||||
id,
|
||||
name: r.name,
|
||||
weight,
|
||||
enabled,
|
||||
indicators: r.indicators,
|
||||
}));
|
||||
}
|
||||
|
||||
const redlineArb = (id: string): fc.Arbitrary<Redline> =>
|
||||
fc.record({
|
||||
id: fc.constant(id),
|
||||
triggerCondition: nonEmptyArb(10),
|
||||
consequence: nonEmptyArb(10),
|
||||
enabled: fc.boolean(),
|
||||
});
|
||||
|
||||
const redlinesArb: fc.Arbitrary<Redline[]> = fc
|
||||
.uniqueArray(fc.constantFrom('r1', 'r2', 'r3'), { minLength: 0, maxLength: 3 })
|
||||
.chain((ids) => fc.tuple(...ids.map((id) => redlineArb(id))));
|
||||
|
||||
/** 另存模板所需的元数据生成器。 */
|
||||
const templateMetaArb = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 8 }).map((s) => `tpl${s}`),
|
||||
industry: nonEmptyArb(6),
|
||||
isDefault: fc.boolean(),
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器 A:任意合法配置(权重为正、可归一化),用于验证「归一化形态」往返等价。
|
||||
// 约束:至少一个启用维度且权重 ≥ 1;每个维度至少一个启用指标且权重 ≥ 1。
|
||||
// (保证 normalizeWeights 不因同级启用项权重之和为 0 而抛出。)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 某维度下的指标集合:≥1 个启用指标(权重 1..100),另附若干停用指标。 */
|
||||
const positiveIndicatorsArb: fc.Arbitrary<Indicator[]> = fc
|
||||
.record({
|
||||
enabledCount: fc.integer({ min: 1, max: 3 }),
|
||||
disabledCount: fc.integer({ min: 0, max: 2 }),
|
||||
})
|
||||
.chain(({ enabledCount, disabledCount }) => {
|
||||
const enabledArbs = Array.from({ length: enabledCount }, (_u, idx) =>
|
||||
fc
|
||||
.integer({ min: 1, max: 100 })
|
||||
.chain((w) => makeIndicator(`i-en-${idx}`, true, w)),
|
||||
);
|
||||
const disabledArbs = Array.from({ length: disabledCount }, (_u, idx) =>
|
||||
fc.nat({ max: 100 }).chain((w) => makeIndicator(`i-dis-${idx}`, false, w)),
|
||||
);
|
||||
return fc.tuple(...enabledArbs, ...disabledArbs);
|
||||
});
|
||||
|
||||
/** 维度集合:≥1 个启用维度(权重 1..100),另附若干停用维度。 */
|
||||
const positiveDimensionsArb: fc.Arbitrary<Dimension[]> = fc
|
||||
.record({
|
||||
enabledCount: fc.integer({ min: 1, max: 3 }),
|
||||
disabledCount: fc.integer({ min: 0, max: 2 }),
|
||||
})
|
||||
.chain(({ enabledCount, disabledCount }) => {
|
||||
const enabledArbs = Array.from({ length: enabledCount }, (_u, idx) =>
|
||||
fc
|
||||
.integer({ min: 1, max: 100 })
|
||||
.chain((w) =>
|
||||
makeDimension(`d-en-${idx}`, true, w, positiveIndicatorsArb),
|
||||
),
|
||||
);
|
||||
const disabledArbs = Array.from({ length: disabledCount }, (_u, idx) =>
|
||||
fc
|
||||
.nat({ max: 100 })
|
||||
.chain((w) =>
|
||||
makeDimension(`d-dis-${idx}`, false, w, positiveIndicatorsArb),
|
||||
),
|
||||
);
|
||||
return fc.tuple(...enabledArbs, ...disabledArbs);
|
||||
});
|
||||
|
||||
const positiveConfigArb: fc.Arbitrary<RiskModelConfig> = fc.record({
|
||||
name: nonEmptyArb(10),
|
||||
businessType: businessTypeArb,
|
||||
dimensions: positiveDimensionsArb,
|
||||
redlines: redlinesArb,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器 B:权重已归一化(同级启用项之和恰为 100%)的合法配置,
|
||||
// 用于验证往返精确还原原始配置(归一化为恒等变换)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 将 100 划分为 parts 个各 ≥1 的整数,其和恰为 100。 */
|
||||
function partitionArb(parts: number): fc.Arbitrary<number[]> {
|
||||
if (parts <= 1) {
|
||||
return fc.constant([100]);
|
||||
}
|
||||
return fc
|
||||
.uniqueArray(fc.integer({ min: 1, max: 99 }), {
|
||||
minLength: parts - 1,
|
||||
maxLength: parts - 1,
|
||||
})
|
||||
.map((cuts) => {
|
||||
const sorted = [...cuts].sort((a, b) => a - b);
|
||||
const weights: number[] = [];
|
||||
let prev = 0;
|
||||
for (const cut of sorted) {
|
||||
weights.push(cut - prev);
|
||||
prev = cut;
|
||||
}
|
||||
weights.push(100 - prev);
|
||||
return weights;
|
||||
});
|
||||
}
|
||||
|
||||
/** 某维度下指标集合:启用指标权重之和恒为 100%,另附停用指标。 */
|
||||
const normalizedIndicatorsArb: fc.Arbitrary<Indicator[]> = fc
|
||||
.record({
|
||||
enabledCount: fc.integer({ min: 1, max: 3 }),
|
||||
disabledCount: fc.integer({ min: 0, max: 2 }),
|
||||
})
|
||||
.chain(({ enabledCount, disabledCount }) =>
|
||||
partitionArb(enabledCount).chain((enabledWeights) => {
|
||||
const enabledArbs = enabledWeights.map((w, idx) =>
|
||||
makeIndicator(`i-en-${idx}`, true, w),
|
||||
);
|
||||
const disabledArbs = Array.from({ length: disabledCount }, (_u, idx) =>
|
||||
fc.nat({ max: 100 }).chain((w) => makeIndicator(`i-dis-${idx}`, false, w)),
|
||||
);
|
||||
return fc.tuple(...enabledArbs, ...disabledArbs);
|
||||
}),
|
||||
);
|
||||
|
||||
/** 维度集合:启用维度权重之和恒为 100%,另附停用维度。 */
|
||||
const normalizedDimensionsArb: fc.Arbitrary<Dimension[]> = fc
|
||||
.record({
|
||||
enabledCount: fc.integer({ min: 1, max: 3 }),
|
||||
disabledCount: fc.integer({ min: 0, max: 2 }),
|
||||
})
|
||||
.chain(({ enabledCount, disabledCount }) =>
|
||||
partitionArb(enabledCount).chain((enabledWeights) => {
|
||||
const enabledArbs = enabledWeights.map((w, idx) =>
|
||||
makeDimension(`d-en-${idx}`, true, w, normalizedIndicatorsArb),
|
||||
);
|
||||
const disabledArbs = Array.from({ length: disabledCount }, (_u, idx) =>
|
||||
fc
|
||||
.nat({ max: 100 })
|
||||
.chain((w) =>
|
||||
makeDimension(`d-dis-${idx}`, false, w, normalizedIndicatorsArb),
|
||||
),
|
||||
);
|
||||
return fc.tuple(...enabledArbs, ...disabledArbs);
|
||||
}),
|
||||
);
|
||||
|
||||
const normalizedConfigArb: fc.Arbitrary<RiskModelConfig> = fc.record({
|
||||
name: nonEmptyArb(10),
|
||||
businessType: businessTypeArb,
|
||||
dimensions: normalizedDimensionsArb,
|
||||
redlines: redlinesArb,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Feature: outsourcing-risk-assessment, Property 42: 配置另存为模板可往返 (Req 11.5)', () => {
|
||||
it('对任意合法配置,另存为模板再实例化所得配置与模板所存归一化配置逐项等价', () => {
|
||||
fc.assert(
|
||||
fc.property(positiveConfigArb, templateMetaArb, (config, meta) => {
|
||||
const template = saveAsTemplate(config, {
|
||||
id: meta.id,
|
||||
industry: meta.industry,
|
||||
isDefault: meta.isDefault,
|
||||
});
|
||||
const model = instantiateRiskModel(template);
|
||||
const stored = template.riskModelConfig;
|
||||
|
||||
// 往返等价(以归一化形态为基准):实例化结果逐项深相等于模板所存配置。
|
||||
expect(model.name).toBe(stored.name);
|
||||
expect(model.businessType).toBe(stored.businessType);
|
||||
expect(model.dimensions).toEqual(stored.dimensions);
|
||||
expect(model.redlines).toEqual(stored.redlines);
|
||||
|
||||
// 顶层标识来源于模板。
|
||||
expect(model.id).toBe(template.id);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('当配置权重已归一化(同级启用项之和为 100%)时,往返精确还原原始配置', () => {
|
||||
fc.assert(
|
||||
fc.property(normalizedConfigArb, templateMetaArb, (config, meta) => {
|
||||
const template = saveAsTemplate(config, {
|
||||
id: meta.id,
|
||||
industry: meta.industry,
|
||||
isDefault: meta.isDefault,
|
||||
});
|
||||
const model = instantiateRiskModel(template);
|
||||
|
||||
// 归一化为恒等变换:往返必与原始配置逐项等价。
|
||||
expect(model.name).toBe(config.name);
|
||||
expect(model.businessType).toBe(config.businessType);
|
||||
expect(model.dimensions).toEqual(config.dimensions);
|
||||
expect(model.redlines).toEqual(config.redlines);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Property 41: 评分规则与红线配置校验 的属性化测试(Config_Center,Req 11.3, 11.4)。
|
||||
*
|
||||
* 属性陈述:
|
||||
* - 对任意自定义 Scoring_Rule,未覆盖 Risk_Level 1 至 5 全部级别者,{@link saveConfig}
|
||||
* 必拒绝保存(Req 11.3)。
|
||||
* - 对任意 Redline 配置,缺少唯一标识、触发条件或一票否决后果,或标识重复者,
|
||||
* {@link saveConfig} 必拒绝保存(Req 11.4)。
|
||||
*
|
||||
* 测试策略:以一个结构合法、可成功保存的"基线配置"为起点(确保未施加变异时被接受),
|
||||
* 对每个生成样本施加恰好一种"破坏性变异",使其在评分规则覆盖或红线校验某一维度上
|
||||
* 确定性非法,断言保存被拒绝(status='rejected')并返回对应类别的校验错误:
|
||||
* - dropLevel :删除某 Indicator 的一条 Scoring_Rule(未覆盖 1-5)→ ScoringRuleCoverageError
|
||||
* - dropAllLevels :清空某 Indicator 的全部 Scoring_Rule → ScoringRuleCoverageError
|
||||
* - emptyRedlineId :将某 Redline 标识置空 → RedlineValidationError
|
||||
* - missingTrigger :将某 Redline 触发条件置空 → RedlineValidationError
|
||||
* - missingConsequence:将某 Redline 一票否决后果置空 → RedlineValidationError
|
||||
* - duplicateId :追加一条与已有 Redline 标识重复的红线 → RedlineValidationError
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 41: 评分规则与红线配置校验
|
||||
* Validates: Requirements 11.3, 11.4
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
BUSINESS_TYPE_VALUES,
|
||||
RISK_LEVEL_VALUES,
|
||||
type RiskLevel,
|
||||
} from '../../domain/common.js';
|
||||
import type {
|
||||
Dimension,
|
||||
Indicator,
|
||||
Redline,
|
||||
RiskModelConfig,
|
||||
ScoringRule,
|
||||
} from '../../domain/model.js';
|
||||
import { InMemoryRiskModelConfigStore } from '../configStore.js';
|
||||
import { saveConfig } from '../saveConfig.js';
|
||||
import {
|
||||
ConfigValidationError,
|
||||
RedlineValidationError,
|
||||
ScoringRuleCoverageError,
|
||||
} from '../errors.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 基线构造:生成结构合法、可成功保存的配置(评分规则覆盖 1-5,红线唯一且字段齐全)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 覆盖 Risk_Level 1 至 5 的完整评分规则集合。 */
|
||||
function fullScoringRules(): ScoringRule[] {
|
||||
return RISK_LEVEL_VALUES.map((level: RiskLevel) => ({
|
||||
level,
|
||||
label: `L${level}`,
|
||||
description: `desc-${level}`,
|
||||
}));
|
||||
}
|
||||
|
||||
interface BaseSpec {
|
||||
/** 每个维度下的指标数量;数组长度即维度数量(均 ≥ 1)。 */
|
||||
readonly indicatorCounts: readonly number[];
|
||||
/** 红线数量(≥ 1,以便施加红线相关变异)。 */
|
||||
readonly redlineCount: number;
|
||||
/** 业务类型。 */
|
||||
readonly businessType: (typeof BUSINESS_TYPE_VALUES)[number];
|
||||
}
|
||||
|
||||
/** 由 BaseSpec 构造结构合法、全部启用、可成功保存的基线配置(每次返回独立对象)。 */
|
||||
function buildValidConfig(spec: BaseSpec): RiskModelConfig {
|
||||
const dimensions: Dimension[] = spec.indicatorCounts.map((indCount, d) => {
|
||||
const indicators: Indicator[] = Array.from({ length: indCount }, (_unused, i) => ({
|
||||
id: `d${d}-i${i}`,
|
||||
name: `指标 ${d}-${i}`,
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
scoringRules: fullScoringRules(),
|
||||
evidenceRequired: '证据',
|
||||
askPrompt: '请补充',
|
||||
}));
|
||||
return {
|
||||
id: `d${d}`,
|
||||
name: `维度 ${d}`,
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
indicators,
|
||||
};
|
||||
});
|
||||
|
||||
const redlines: Redline[] = Array.from(
|
||||
{ length: spec.redlineCount },
|
||||
(_unused, r) => ({
|
||||
id: `r${r}`,
|
||||
triggerCondition: `触发条件 ${r}`,
|
||||
consequence: `一票否决 ${r}`,
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
name: '风险模型',
|
||||
businessType: spec.businessType,
|
||||
dimensions,
|
||||
redlines,
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 变异:对合法基线施加恰好一种破坏,使其在评分规则覆盖或红线校验上确定性非法。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type ScoringMutationKind = 'dropLevel' | 'dropAllLevels';
|
||||
type RedlineMutationKind =
|
||||
| 'emptyRedlineId'
|
||||
| 'missingTrigger'
|
||||
| 'missingConsequence'
|
||||
| 'duplicateId';
|
||||
|
||||
interface Mutation {
|
||||
readonly kind: ScoringMutationKind | RedlineMutationKind;
|
||||
/** 维度选择比例(0..1),映射为维度下标。 */
|
||||
readonly dimFrac: number;
|
||||
/** 指标选择比例(0..1),映射为指标下标。 */
|
||||
readonly indFrac: number;
|
||||
/** 评分等级选择比例(0..1),映射为待删除的等级下标。 */
|
||||
readonly levelFrac: number;
|
||||
/** 红线选择比例(0..1),映射为红线下标。 */
|
||||
readonly redlineFrac: number;
|
||||
/** 置空时所用的空白串('' 或仅空白字符,均应被判为缺失)。 */
|
||||
readonly blank: string;
|
||||
}
|
||||
|
||||
/** 将 [0,1) 比例映射为 [0, length) 的合法下标。 */
|
||||
function toIndex(frac: number, length: number): number {
|
||||
if (length <= 0) return 0;
|
||||
const idx = Math.floor(frac * length);
|
||||
return Math.min(idx, length - 1);
|
||||
}
|
||||
|
||||
const SCORING_KINDS: readonly ScoringMutationKind[] = ['dropLevel', 'dropAllLevels'];
|
||||
|
||||
/** 该变异是否属于评分规则类(否则为红线类)。 */
|
||||
function isScoringMutation(kind: Mutation['kind']): kind is ScoringMutationKind {
|
||||
return (SCORING_KINDS as readonly string[]).includes(kind);
|
||||
}
|
||||
|
||||
/** 对配置施加变异(原地修改新构造的对象),返回必定非法的配置。 */
|
||||
function applyMutation(config: RiskModelConfig, mutation: Mutation): RiskModelConfig {
|
||||
const { dimensions, redlines } = config;
|
||||
|
||||
switch (mutation.kind) {
|
||||
case 'dropLevel': {
|
||||
const dim = dimensions[toIndex(mutation.dimFrac, dimensions.length)]!;
|
||||
const ind = dim.indicators[toIndex(mutation.indFrac, dim.indicators.length)]!;
|
||||
const removeAt = toIndex(mutation.levelFrac, ind.scoringRules.length);
|
||||
ind.scoringRules.splice(removeAt, 1);
|
||||
return config;
|
||||
}
|
||||
case 'dropAllLevels': {
|
||||
const dim = dimensions[toIndex(mutation.dimFrac, dimensions.length)]!;
|
||||
const ind = dim.indicators[toIndex(mutation.indFrac, dim.indicators.length)]!;
|
||||
ind.scoringRules = [];
|
||||
return config;
|
||||
}
|
||||
case 'emptyRedlineId': {
|
||||
const redline = redlines[toIndex(mutation.redlineFrac, redlines.length)]!;
|
||||
redline.id = mutation.blank;
|
||||
return config;
|
||||
}
|
||||
case 'missingTrigger': {
|
||||
const redline = redlines[toIndex(mutation.redlineFrac, redlines.length)]!;
|
||||
redline.triggerCondition = mutation.blank;
|
||||
return config;
|
||||
}
|
||||
case 'missingConsequence': {
|
||||
const redline = redlines[toIndex(mutation.redlineFrac, redlines.length)]!;
|
||||
redline.consequence = mutation.blank;
|
||||
return config;
|
||||
}
|
||||
case 'duplicateId': {
|
||||
const source = redlines[toIndex(mutation.redlineFrac, redlines.length)]!;
|
||||
redlines.push({
|
||||
id: source.id,
|
||||
triggerCondition: '另一触发条件',
|
||||
consequence: '另一后果',
|
||||
enabled: true,
|
||||
});
|
||||
return config;
|
||||
}
|
||||
default: {
|
||||
// 穷尽性检查:新增变异种类时编译期报错。
|
||||
const exhaustive: never = mutation.kind;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const baseSpecArb: fc.Arbitrary<BaseSpec> = fc.record({
|
||||
// 1..3 个维度,每个维度 1..3 个指标。
|
||||
indicatorCounts: fc.array(fc.integer({ min: 1, max: 3 }), {
|
||||
minLength: 1,
|
||||
maxLength: 3,
|
||||
}),
|
||||
// ≥ 1 条红线,便于施加红线相关变异。
|
||||
redlineCount: fc.integer({ min: 1, max: 4 }),
|
||||
businessType: fc.constantFrom(...BUSINESS_TYPE_VALUES),
|
||||
});
|
||||
|
||||
const mutationArb: fc.Arbitrary<Mutation> = fc.record({
|
||||
kind: fc.constantFrom<Mutation['kind']>(
|
||||
'dropLevel',
|
||||
'dropAllLevels',
|
||||
'emptyRedlineId',
|
||||
'missingTrigger',
|
||||
'missingConsequence',
|
||||
'duplicateId',
|
||||
),
|
||||
dimFrac: fc.double({ min: 0, max: 0.999_999, noNaN: true }),
|
||||
indFrac: fc.double({ min: 0, max: 0.999_999, noNaN: true }),
|
||||
levelFrac: fc.double({ min: 0, max: 0.999_999, noNaN: true }),
|
||||
redlineFrac: fc.double({ min: 0, max: 0.999_999, noNaN: true }),
|
||||
blank: fc.constantFrom('', ' ', ' ', '\t', '\n'),
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Feature: outsourcing-risk-assessment, Property 41: 评分规则与红线配置校验 (Req 11.3, 11.4)', () => {
|
||||
it('任意评分规则未覆盖 1-5 或红线非法(缺字段/标识重复)的配置必被拒绝保存', () => {
|
||||
fc.assert(
|
||||
fc.property(baseSpecArb, mutationArb, (spec, mutation) => {
|
||||
// 前置:基线配置本身合法、可成功保存(确保变异是被拒绝的唯一来源)。
|
||||
const validStore = new InMemoryRiskModelConfigStore();
|
||||
const validResult = saveConfig(buildValidConfig(spec), validStore);
|
||||
expect(validResult.status).toBe('saved');
|
||||
|
||||
// 对一份独立构造的基线施加变异,断言保存被拒绝并返回校验错误。
|
||||
const invalid = applyMutation(buildValidConfig(spec), mutation);
|
||||
const store = new InMemoryRiskModelConfigStore();
|
||||
const result = saveConfig(invalid, store);
|
||||
|
||||
expect(result.status).toBe('rejected');
|
||||
if (result.status !== 'rejected') {
|
||||
return;
|
||||
}
|
||||
expect(result.error).toBeInstanceOf(ConfigValidationError);
|
||||
// 校验类别与变异类别一致(评分规则覆盖顺序先于红线校验)。
|
||||
if (isScoringMutation(mutation.kind)) {
|
||||
expect(result.error).toBeInstanceOf(ScoringRuleCoverageError);
|
||||
} else {
|
||||
expect(result.error).toBeInstanceOf(RedlineValidationError);
|
||||
}
|
||||
// 拒绝保存:存储未被写入(此前从未保存过有效配置)。
|
||||
expect(store.has('current')).toBe(false);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 示例测试:固定反例,直观佐证关键场景。
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('示例:某 Indicator 评分规则缺少 Risk_Level 3 的配置被拒绝', () => {
|
||||
const config = buildValidConfig({
|
||||
indicatorCounts: [1],
|
||||
redlineCount: 1,
|
||||
businessType: 'BPO',
|
||||
});
|
||||
// 删除 level=3 的评分规则(数组下标 2)。
|
||||
config.dimensions[0]!.indicators[0]!.scoringRules.splice(2, 1);
|
||||
const result = saveConfig(config, new InMemoryRiskModelConfigStore());
|
||||
expect(result.status).toBe('rejected');
|
||||
expect((result as { error: ConfigValidationError }).error).toBeInstanceOf(
|
||||
ScoringRuleCoverageError,
|
||||
);
|
||||
});
|
||||
|
||||
it('示例:Redline 标识重复的配置被拒绝', () => {
|
||||
const config = buildValidConfig({
|
||||
indicatorCounts: [1],
|
||||
redlineCount: 1,
|
||||
businessType: '劳务派遣',
|
||||
});
|
||||
config.redlines.push({
|
||||
id: config.redlines[0]!.id,
|
||||
triggerCondition: '重复红线触发',
|
||||
consequence: '一票否决',
|
||||
enabled: true,
|
||||
});
|
||||
const result = saveConfig(config, new InMemoryRiskModelConfigStore());
|
||||
expect(result.status).toBe('rejected');
|
||||
expect((result as { error: ConfigValidationError }).error).toBeInstanceOf(
|
||||
RedlineValidationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('示例:Redline 缺少触发条件的配置被拒绝', () => {
|
||||
const config = buildValidConfig({
|
||||
indicatorCounts: [1],
|
||||
redlineCount: 1,
|
||||
businessType: '岗位外包',
|
||||
});
|
||||
config.redlines[0]!.triggerCondition = ' ';
|
||||
const result = saveConfig(config, new InMemoryRiskModelConfigStore());
|
||||
expect(result.status).toBe('rejected');
|
||||
expect((result as { error: ConfigValidationError }).error).toBeInstanceOf(
|
||||
RedlineValidationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Property 43: 校验失败保留上次有效配置 的属性化测试(Config_Center,Req 11.6, 11.7)。
|
||||
*
|
||||
* 属性陈述:对任意未通过校验的配置保存请求,存储中的配置必保持为上次有效配置不变,
|
||||
* 并返回指明失败项的校验错误。
|
||||
*
|
||||
* 测试构造:先以一份「合法配置」成功保存({@link saveConfig} 返回 status='saved',
|
||||
* 写入归一化后的有效配置);随后在该合法配置基础上施加一种确定的「破坏」得到非法配置,
|
||||
* 再次保存。断言:
|
||||
* 1. 第二次保存被拒绝(status='rejected');
|
||||
* 2. 存储中的配置仍逐项深相等于第一次保存的有效配置(未被覆盖、未被改动);
|
||||
* 3. 被拒绝结果携带的 `config` 即为上次有效配置;
|
||||
* 4. 返回的校验错误为 {@link ConfigValidationError} 子类,且其标识字段精确指明失败项。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 43: 校验失败保留上次有效配置
|
||||
* Validates: Requirements 11.6, 11.7
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
BUSINESS_TYPE_VALUES,
|
||||
RISK_LEVEL_VALUES,
|
||||
type RiskLevel,
|
||||
} from '../../domain/common.js';
|
||||
import type {
|
||||
Dimension,
|
||||
Indicator,
|
||||
Redline,
|
||||
RiskModelConfig,
|
||||
ScoringRule,
|
||||
} from '../../domain/model.js';
|
||||
import { InMemoryRiskModelConfigStore } from '../configStore.js';
|
||||
import {
|
||||
ConfigValidationError,
|
||||
RedlineValidationError,
|
||||
RequiredFieldError,
|
||||
ScoringRuleCoverageError,
|
||||
} from '../errors.js';
|
||||
import { saveConfig } from '../saveConfig.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:构造「合法配置」(可通过 saveConfig 全部校验并成功归一化保存)。
|
||||
// 全部维度/指标启用且权重 ≥1,确保 normalizeWeights 不会因同级权重和为 0 而拒绝。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const businessTypeArb = fc.constantFrom(...BUSINESS_TYPE_VALUES);
|
||||
|
||||
/** 非空文本(去除首尾空白后长度恒 ≥1)。 */
|
||||
const nonEmptyTextArb = (prefix: string): fc.Arbitrary<string> =>
|
||||
fc.string({ maxLength: 6 }).map((s) => `${prefix}${s}`);
|
||||
|
||||
/** 覆盖 Risk_Level 1 至 5 全部级别的评分规则(满足 Req 11.3)。 */
|
||||
const scoringRulesArb: fc.Arbitrary<ScoringRule[]> = fc.tuple(
|
||||
...RISK_LEVEL_VALUES.map((level: RiskLevel) =>
|
||||
fc.record({
|
||||
level: fc.constant(level),
|
||||
label: fc.string({ maxLength: 6 }),
|
||||
description: fc.string({ maxLength: 12 }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/** 构造单个启用指标,赋予稳定标识与正权重。 */
|
||||
function indicatorArb(id: string): fc.Arbitrary<Indicator> {
|
||||
return fc
|
||||
.record({
|
||||
name: nonEmptyTextArb('指标'),
|
||||
weight: fc.integer({ min: 1, max: 100 }),
|
||||
scoringRules: scoringRulesArb,
|
||||
evidenceRequired: fc.string({ maxLength: 8 }),
|
||||
askPrompt: fc.string({ maxLength: 12 }),
|
||||
})
|
||||
.map((r) => ({
|
||||
id,
|
||||
name: r.name,
|
||||
weight: r.weight,
|
||||
enabled: true,
|
||||
scoringRules: r.scoringRules,
|
||||
evidenceRequired: r.evidenceRequired,
|
||||
askPrompt: r.askPrompt,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 构造单个启用维度(含 1-3 个启用指标),赋予稳定标识与正权重。 */
|
||||
function dimensionArb(id: string): fc.Arbitrary<Dimension> {
|
||||
return fc.integer({ min: 1, max: 3 }).chain((indicatorCount) => {
|
||||
const indicatorArbs = Array.from({ length: indicatorCount }, (_unused, ii) =>
|
||||
indicatorArb(`${id}-i${ii}`),
|
||||
);
|
||||
return fc
|
||||
.record({
|
||||
name: nonEmptyTextArb('维度'),
|
||||
weight: fc.integer({ min: 1, max: 100 }),
|
||||
})
|
||||
.chain((r) =>
|
||||
fc.tuple(...indicatorArbs).map((indicators) => ({
|
||||
id,
|
||||
name: r.name,
|
||||
weight: r.weight,
|
||||
enabled: true,
|
||||
indicators,
|
||||
})),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 构造维度集合(1-3 个,标识 d0、d1…)。 */
|
||||
const dimensionsArb: fc.Arbitrary<Dimension[]> = fc
|
||||
.integer({ min: 1, max: 3 })
|
||||
.chain((dimensionCount) =>
|
||||
fc.tuple(
|
||||
...Array.from({ length: dimensionCount }, (_unused, di) =>
|
||||
dimensionArb(`d${di}`),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/** 构造合法红线集合(0-3 条,标识 r0、r1…,触发条件与后果均非空)。 */
|
||||
const redlinesArb: fc.Arbitrary<Redline[]> = fc
|
||||
.integer({ min: 0, max: 3 })
|
||||
.chain((redlineCount) =>
|
||||
fc.tuple(
|
||||
...Array.from({ length: redlineCount }, (_unused, ri) =>
|
||||
fc.record({
|
||||
id: fc.constant(`r${ri}`),
|
||||
triggerCondition: nonEmptyTextArb('触发'),
|
||||
consequence: nonEmptyTextArb('后果'),
|
||||
enabled: fc.boolean(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const validConfigArb: fc.Arbitrary<RiskModelConfig> = fc.record({
|
||||
name: fc.string({ maxLength: 10 }),
|
||||
businessType: businessTypeArb,
|
||||
dimensions: dimensionsArb,
|
||||
redlines: redlinesArb,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 破坏算子:在合法配置上施加一种确定的破坏,得到非法配置与「预期失败项」。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type CorruptionKind = 'required' | 'coverage' | 'redline';
|
||||
|
||||
interface Corruption {
|
||||
/** 破坏后的非法配置。 */
|
||||
readonly config: RiskModelConfig;
|
||||
/** 预期触发的校验错误类别。 */
|
||||
readonly kind: CorruptionKind;
|
||||
/** 预期校验错误所指明的失败项标识。 */
|
||||
readonly failedItem: string;
|
||||
}
|
||||
|
||||
/** 不依赖共享可变引用的深拷贝(配置仅含可序列化数据)。 */
|
||||
function deepClone(config: RiskModelConfig): RiskModelConfig {
|
||||
return JSON.parse(JSON.stringify(config)) as RiskModelConfig;
|
||||
}
|
||||
|
||||
const CORRUPTION_CHOICES = [0, 1, 2, 3, 4, 5] as const;
|
||||
|
||||
/**
|
||||
* 依据 `choice` 对合法配置施加一种确定破坏,返回非法配置与预期失败项。
|
||||
* 各破坏均针对首个维度/指标,且首维度/指标在合法配置中必然存在。
|
||||
*/
|
||||
function corrupt(valid: RiskModelConfig, choice: number): Corruption {
|
||||
const config = deepClone(valid);
|
||||
const firstDimension = config.dimensions[0] as Dimension;
|
||||
|
||||
switch (choice) {
|
||||
case 0: {
|
||||
// 缺少必填组成项 Dimension。
|
||||
config.dimensions = [];
|
||||
return { config, kind: 'required', failedItem: 'dimensions' };
|
||||
}
|
||||
case 1: {
|
||||
// 维度缺少名称。
|
||||
firstDimension.name = '';
|
||||
return { config, kind: 'required', failedItem: firstDimension.id };
|
||||
}
|
||||
case 2: {
|
||||
// 维度缺少必填组成项 Indicator。
|
||||
firstDimension.indicators = [];
|
||||
return { config, kind: 'required', failedItem: firstDimension.id };
|
||||
}
|
||||
case 3: {
|
||||
// 指标缺少名称。
|
||||
const firstIndicator = firstDimension.indicators[0] as Indicator;
|
||||
firstIndicator.name = '';
|
||||
return { config, kind: 'required', failedItem: firstIndicator.id };
|
||||
}
|
||||
case 4: {
|
||||
// 指标的 Scoring_Rule 未覆盖全部风险等级(移除等级 3)。
|
||||
const firstIndicator = firstDimension.indicators[0] as Indicator;
|
||||
firstIndicator.scoringRules = firstIndicator.scoringRules.filter(
|
||||
(rule) => rule.level !== 3,
|
||||
);
|
||||
return { config, kind: 'coverage', failedItem: firstIndicator.id };
|
||||
}
|
||||
default: {
|
||||
// 追加一条缺少触发条件的红线(红线标识唯一但字段不全)。
|
||||
config.redlines = [
|
||||
...config.redlines,
|
||||
{
|
||||
id: '__bad-redline__',
|
||||
triggerCondition: '',
|
||||
consequence: '后果',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
return { config, kind: 'redline', failedItem: '__bad-redline__' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 43: 校验失败保留上次有效配置 (Req 11.6, 11.7)', () => {
|
||||
it('校验失败拒绝保存,存储保持上次有效配置不变,并返回指明失败项的校验错误', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
validConfigArb,
|
||||
fc.constantFrom(...CORRUPTION_CHOICES),
|
||||
fc.constantFrom('current', 'cfg-a', 'cfg-b'),
|
||||
(validConfig, choice, configId) => {
|
||||
const store = new InMemoryRiskModelConfigStore();
|
||||
|
||||
// 第一次保存合法配置:成功并写入归一化后的有效配置。
|
||||
const first = saveConfig(validConfig, store, { configId });
|
||||
expect(first.status).toBe('saved');
|
||||
|
||||
// 记录此刻存储中的「上次有效配置」深快照,用于事后比对「保持不变」。
|
||||
const storedAfterValid = store.get(configId);
|
||||
expect(storedAfterValid).toBeDefined();
|
||||
const validSnapshot = deepClone(storedAfterValid as RiskModelConfig);
|
||||
|
||||
// 第二次保存非法配置:必被拒绝。
|
||||
const { config: invalidConfig, kind, failedItem } = corrupt(
|
||||
validConfig,
|
||||
choice,
|
||||
);
|
||||
const result = saveConfig(invalidConfig, store, { configId });
|
||||
expect(result.status).toBe('rejected');
|
||||
|
||||
// 存储中的配置保持为上次有效配置不变(未被覆盖、未被改动)。
|
||||
expect(store.get(configId)).toEqual(validSnapshot);
|
||||
|
||||
if (result.status === 'rejected') {
|
||||
// 被拒绝结果携带上次有效配置。
|
||||
expect(result.config).toEqual(validSnapshot);
|
||||
|
||||
// 返回指明失败项的校验错误。
|
||||
expect(result.error).toBeInstanceOf(ConfigValidationError);
|
||||
switch (kind) {
|
||||
case 'required': {
|
||||
expect(result.error).toBeInstanceOf(RequiredFieldError);
|
||||
expect((result.error as RequiredFieldError).failedItem).toBe(
|
||||
failedItem,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'coverage': {
|
||||
expect(result.error).toBeInstanceOf(ScoringRuleCoverageError);
|
||||
expect(
|
||||
(result.error as ScoringRuleCoverageError).indicatorId,
|
||||
).toBe(failedItem);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
expect(result.error).toBeInstanceOf(RedlineValidationError);
|
||||
expect((result.error as RedlineValidationError).redlineId).toBe(
|
||||
failedItem,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Property 39: 停用项保留但不计分 的属性化测试(Config_Center,Req 11.1)。
|
||||
*
|
||||
* 属性陈述:对任意被管理员停用的 Dimension 或 Indicator,
|
||||
* 其配置数据必保留(停用仅翻转 enabled 标志,其余全部配置原样保留),
|
||||
* 且不纳入评分计算(computeRiskScore 排除该项——其"在配置中存在但停用"
|
||||
* 与"从模型中物理移除"对评分结果无差别影响)。
|
||||
*
|
||||
* 验证策略:
|
||||
* - 保留:将 setDimensionEnabled / setIndicatorEnabled 的结果与"仅把目标项
|
||||
* enabled 翻转为 false、其余原样"的期望配置逐项比对(toEqual)。
|
||||
* - 不计分:构造两个 Risk_Model——一个含被停用项(enabled=false),另一个
|
||||
* 将该项从模型中整体移除——以同一 Risk_Level 解析器计分,断言两者得分相等,
|
||||
* 即停用项对评分无任何贡献(Req 4.4 停用项保留配置但不计分)。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 39: 停用项保留但不计分
|
||||
* Validates: Requirements 11.1
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
BUSINESS_TYPE_VALUES,
|
||||
RISK_LEVEL_VALUES,
|
||||
type RiskLevel,
|
||||
} from '../../domain/common.js';
|
||||
import type {
|
||||
Dimension,
|
||||
Indicator,
|
||||
Redline,
|
||||
RiskModel,
|
||||
RiskModelConfig,
|
||||
ScoringRule,
|
||||
} from '../../domain/model.js';
|
||||
import { setDimensionEnabled, setIndicatorEnabled } from '../saveConfig.js';
|
||||
import { computeRiskScore } from '../../scoring/computeRiskScore.js';
|
||||
import type { RiskLevelResolver } from '../../scoring/scoringEngine.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 解析器:由 Indicator.id 确定性派生 Risk_Level(1-5),使不同指标取值多样,
|
||||
// 从而"停用项排除"的比对具有实际区分力(与具体取值来源解耦)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function levelForId(id: string): RiskLevel {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i += 1) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return ((hash % 5) + 1) as RiskLevel;
|
||||
}
|
||||
|
||||
const resolveRiskLevel: RiskLevelResolver = (indicator) => levelForId(indicator.id);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:全部 Dimension/Indicator 均启用且权重为正,保证模型可计分;
|
||||
// 维度数 ≥ 2、每维度指标数 ≥ 2,确保停用任一项后剩余模型仍可计分。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const businessTypeArb = fc.constantFrom(...BUSINESS_TYPE_VALUES);
|
||||
|
||||
/** 覆盖 Risk_Level 1-5 的合法评分规则(内容对计分无影响,仅保证配置真实)。 */
|
||||
const fullScoringRules: ScoringRule[] = RISK_LEVEL_VALUES.map((level) => ({
|
||||
level,
|
||||
label: `L${String(level)}`,
|
||||
description: `desc-${String(level)}`,
|
||||
}));
|
||||
|
||||
const indicatorArb = (id: string): fc.Arbitrary<Indicator> =>
|
||||
fc.record({
|
||||
id: fc.constant(id),
|
||||
name: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
weight: fc.integer({ min: 1, max: 100 }),
|
||||
enabled: fc.constant(true),
|
||||
scoringRules: fc.constant(fullScoringRules),
|
||||
evidenceRequired: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
askPrompt: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
});
|
||||
|
||||
/** 指标数组:按 id 去重,长度 ≥ 2。 */
|
||||
const indicatorsArb: fc.Arbitrary<Indicator[]> = fc
|
||||
.uniqueArray(fc.constantFrom('i1', 'i2', 'i3', 'i4'), { minLength: 2, maxLength: 4 })
|
||||
.chain((ids) => fc.tuple(...ids.map((id) => indicatorArb(id))));
|
||||
|
||||
const dimensionArb = (id: string): fc.Arbitrary<Dimension> =>
|
||||
fc.record({
|
||||
id: fc.constant(id),
|
||||
name: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
weight: fc.integer({ min: 1, max: 100 }),
|
||||
enabled: fc.constant(true),
|
||||
indicators: indicatorsArb,
|
||||
});
|
||||
|
||||
/** 维度数组:按 id 去重,长度 ≥ 2。 */
|
||||
const dimensionsArb: fc.Arbitrary<Dimension[]> = fc
|
||||
.uniqueArray(fc.constantFrom('d1', 'd2', 'd3', 'd4'), { minLength: 2, maxLength: 4 })
|
||||
.chain((ids) => fc.tuple(...ids.map((id) => dimensionArb(id))));
|
||||
|
||||
const redlineArb = (id: string): fc.Arbitrary<Redline> =>
|
||||
fc.record({
|
||||
id: fc.constant(id),
|
||||
triggerCondition: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
consequence: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
enabled: fc.boolean(),
|
||||
});
|
||||
|
||||
const redlinesArb: fc.Arbitrary<Redline[]> = fc
|
||||
.uniqueArray(fc.constantFrom('r1', 'r2'), { minLength: 0, maxLength: 2 })
|
||||
.chain((ids) => fc.tuple(...ids.map((id) => redlineArb(id))));
|
||||
|
||||
const riskModelConfigArb: fc.Arbitrary<RiskModelConfig> = fc.record({
|
||||
name: fc.string({ minLength: 0, maxLength: 8 }),
|
||||
businessType: businessTypeArb,
|
||||
dimensions: dimensionsArb,
|
||||
redlines: redlinesArb,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 助手
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 由配置构造可计分的 Risk_Model(结构透传,computeRiskScore 不会修改入参)。 */
|
||||
function toModel(config: RiskModelConfig): RiskModel {
|
||||
return {
|
||||
id: 'm',
|
||||
name: config.name,
|
||||
businessType: config.businessType,
|
||||
dimensions: config.dimensions,
|
||||
redlines: config.redlines,
|
||||
};
|
||||
}
|
||||
|
||||
/** 选择数组内的一个索引(基于生成的非负整数取模)。 */
|
||||
function pickIndex(length: number, raw: number): number {
|
||||
return raw % length;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 39: 停用项保留但不计分 (Req 11.1)', () => {
|
||||
it('停用 Dimension:配置数据保留,且不纳入评分计算', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
riskModelConfigArb,
|
||||
fc.nat(),
|
||||
(config, rawIndex) => {
|
||||
const targetIndex = pickIndex(config.dimensions.length, rawIndex);
|
||||
const target = config.dimensions[targetIndex];
|
||||
if (target === undefined) return; // 生成器保证 length ≥ 2,仅为类型收窄
|
||||
const targetId = target.id;
|
||||
|
||||
const disabled = setDimensionEnabled(config, targetId, false);
|
||||
|
||||
// —— 保留:仅目标维度 enabled 翻转为 false,其余全部原样保留 ——
|
||||
const expected: RiskModelConfig = {
|
||||
name: config.name,
|
||||
businessType: config.businessType,
|
||||
dimensions: config.dimensions.map((d) =>
|
||||
d.id === targetId ? { ...d, enabled: false } : d,
|
||||
),
|
||||
redlines: config.redlines,
|
||||
};
|
||||
expect(disabled).toEqual(expected);
|
||||
|
||||
// —— 不计分:停用项存在于配置中 vs 从模型中整体移除,得分相等 ——
|
||||
const removed: RiskModelConfig = {
|
||||
name: config.name,
|
||||
businessType: config.businessType,
|
||||
dimensions: config.dimensions.filter((d) => d.id !== targetId),
|
||||
redlines: config.redlines,
|
||||
};
|
||||
const scoreWithDisabled = computeRiskScore(
|
||||
toModel(disabled),
|
||||
resolveRiskLevel,
|
||||
);
|
||||
const scoreWithoutItem = computeRiskScore(
|
||||
toModel(removed),
|
||||
resolveRiskLevel,
|
||||
);
|
||||
expect(scoreWithDisabled).toBe(scoreWithoutItem);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('停用 Indicator:配置数据保留,且不纳入评分计算', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
riskModelConfigArb,
|
||||
fc.nat(),
|
||||
fc.nat(),
|
||||
(config, rawDimIndex, rawIndIndex) => {
|
||||
const dimIndex = pickIndex(config.dimensions.length, rawDimIndex);
|
||||
const targetDim = config.dimensions[dimIndex];
|
||||
if (targetDim === undefined) return;
|
||||
const indIndex = pickIndex(targetDim.indicators.length, rawIndIndex);
|
||||
const targetInd = targetDim.indicators[indIndex];
|
||||
if (targetInd === undefined) return;
|
||||
const dimId = targetDim.id;
|
||||
const indId = targetInd.id;
|
||||
|
||||
const disabled = setIndicatorEnabled(config, dimId, indId, false);
|
||||
|
||||
// —— 保留:仅目标指标 enabled 翻转为 false,其余全部原样保留 ——
|
||||
const expected: RiskModelConfig = {
|
||||
name: config.name,
|
||||
businessType: config.businessType,
|
||||
dimensions: config.dimensions.map((d) =>
|
||||
d.id === dimId
|
||||
? {
|
||||
...d,
|
||||
indicators: d.indicators.map((ind) =>
|
||||
ind.id === indId ? { ...ind, enabled: false } : ind,
|
||||
),
|
||||
}
|
||||
: d,
|
||||
),
|
||||
redlines: config.redlines,
|
||||
};
|
||||
expect(disabled).toEqual(expected);
|
||||
|
||||
// —— 不计分:停用指标存在 vs 从其所属维度移除,得分相等 ——
|
||||
const removed: RiskModelConfig = {
|
||||
name: config.name,
|
||||
businessType: config.businessType,
|
||||
dimensions: config.dimensions.map((d) =>
|
||||
d.id === dimId
|
||||
? { ...d, indicators: d.indicators.filter((ind) => ind.id !== indId) }
|
||||
: d,
|
||||
),
|
||||
redlines: config.redlines,
|
||||
};
|
||||
const scoreWithDisabled = computeRiskScore(
|
||||
toModel(disabled),
|
||||
resolveRiskLevel,
|
||||
);
|
||||
const scoreWithoutItem = computeRiskScore(
|
||||
toModel(removed),
|
||||
resolveRiskLevel,
|
||||
);
|
||||
expect(scoreWithDisabled).toBe(scoreWithoutItem);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 风险模型配置存储抽象与内存实现(Config_Center,Req 11.6, 11.7)。
|
||||
*
|
||||
* 通过抽象的 {@link RiskModelConfigStore} 接口隔离存储介质,使 {@link saveConfig}
|
||||
* 可在校验通过后持久化"上次有效配置",并在后续校验失败时保持该配置不变
|
||||
* (Req 11.7,对应设计 Property 43)。{@link InMemoryRiskModelConfigStore} 为
|
||||
* 进程内默认实现,供单元/集成测试与无外部依赖场景使用。
|
||||
*
|
||||
* 存储按 `configId` 键管理多份配置;同一 `configId` 的后续保存覆盖其上次有效值。
|
||||
*/
|
||||
|
||||
import type { RiskModelConfig } from '../domain/model.js';
|
||||
|
||||
/**
|
||||
* 风险模型配置存储抽象接口。
|
||||
*
|
||||
* 仅承载"已校验通过"的有效配置:{@link saveConfig} 在校验通过后调用 {@link put},
|
||||
* 校验失败时不调用,从而天然保证存储中保留上次有效配置(Req 11.7)。
|
||||
*/
|
||||
export interface RiskModelConfigStore {
|
||||
/** 按配置标识读取上次有效配置;不存在时返回 `undefined`。 */
|
||||
get(configId: string): RiskModelConfig | undefined;
|
||||
/** 写入(覆盖)指定标识的有效配置。 */
|
||||
put(configId: string, config: RiskModelConfig): void;
|
||||
/** 是否存在指定标识的配置。 */
|
||||
has(configId: string): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 进程内风险模型配置存储实现。
|
||||
*
|
||||
* 以 Map 按 `configId` 存储配置。仅用于测试与无外部依赖场景,进程退出后数据不保留。
|
||||
*/
|
||||
export class InMemoryRiskModelConfigStore implements RiskModelConfigStore {
|
||||
private readonly configs = new Map<string, RiskModelConfig>();
|
||||
|
||||
get(configId: string): RiskModelConfig | undefined {
|
||||
return this.configs.get(configId);
|
||||
}
|
||||
|
||||
put(configId: string, config: RiskModelConfig): void {
|
||||
this.configs.set(configId, config);
|
||||
}
|
||||
|
||||
has(configId: string): boolean {
|
||||
return this.configs.has(configId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Config_Center 配置校验错误类型(Req 11.6-11.8)。
|
||||
*
|
||||
* 配置中心在保存或归一化失败时抛出语义化错误,供上层捕获并向评估者返回
|
||||
* 指明失败项的校验错误(Req 11.7)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 配置校验错误基类。所有 Config_Center 校验失败均继承自此类,
|
||||
* 便于上层以单一类型捕获配置相关错误。
|
||||
*/
|
||||
export class ConfigValidationError extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ConfigValidationError';
|
||||
// 维持 instanceof 在编译目标下正确工作。
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 权重校验错误(Req 11.8)。
|
||||
*
|
||||
* 当某同级启用项权重之和为 0 以致无法按比例归一化时抛出,
|
||||
* Config_Center 据此拒绝保存并返回权重校验错误。
|
||||
*/
|
||||
export class WeightValidationError extends ConfigValidationError {
|
||||
public constructor(
|
||||
message = '同级启用项权重之和为 0,无法按比例归一化',
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'WeightValidationError';
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板继承错误(Req 2.7)。
|
||||
*
|
||||
* 当模板继承链中存在循环引用、继承层级超过 5 层,或引用了不存在的父模板
|
||||
* 以致无法解析时抛出。Config_Center 据此不实例化 Risk_Model、终止本次
|
||||
* Assessment,并向评估者返回指明模板继承错误的提示。
|
||||
*/
|
||||
export class TemplateInheritanceError extends ConfigValidationError {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TemplateInheritanceError';
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 无可用模板错误(Req 2.6, 14.5)。
|
||||
*
|
||||
* 当既不存在与(业务类型, 行业)组合精确匹配的 Template,也不存在该业务类型的
|
||||
* 默认 Template 时抛出。Config_Center 据此不实例化 Risk_Model、终止本次
|
||||
* Assessment,并向评估者返回指明无可用模板的提示。
|
||||
*/
|
||||
export class NoAvailableTemplateError extends ConfigValidationError {
|
||||
/** 触发错误的业务类型。 */
|
||||
readonly businessType: string;
|
||||
/** 触发错误的行业标识。 */
|
||||
readonly industry: string;
|
||||
|
||||
public constructor(businessType: string, industry: string) {
|
||||
super(
|
||||
`无可用模板:业务类型「${businessType}」、行业「${industry}」既无精确匹配模板,` +
|
||||
`也无该业务类型的默认模板,终止本次评估`,
|
||||
);
|
||||
this.name = 'NoAvailableTemplateError';
|
||||
this.businessType = businessType;
|
||||
this.industry = industry;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 必填项 / 完整性校验错误(Req 11.6, 11.7)。
|
||||
*
|
||||
* 当待保存配置缺少必填组成项(如无任一 Dimension、Dimension 无 Indicator)或必填
|
||||
* 字段为空(如缺少标识/名称)时抛出。Config_Center 据此拒绝保存、保留上次有效配置
|
||||
* 不变,并返回指明失败项的校验错误。
|
||||
*/
|
||||
export class RequiredFieldError extends ConfigValidationError {
|
||||
/** 触发校验失败的配置项标识(如维度/指标 id 或字段名)。 */
|
||||
readonly failedItem: string;
|
||||
|
||||
public constructor(failedItem: string, message: string) {
|
||||
super(message);
|
||||
this.name = 'RequiredFieldError';
|
||||
this.failedItem = failedItem;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评分规则覆盖校验错误(Req 11.3, 11.7)。
|
||||
*
|
||||
* 当某 Indicator 的自定义 Scoring_Rule 未覆盖 Risk_Level 1 至 5 全部级别时抛出。
|
||||
* Config_Center 据此拒绝保存并返回指明失败指标的校验错误。
|
||||
*/
|
||||
export class ScoringRuleCoverageError extends ConfigValidationError {
|
||||
/** 校验失败的指标标识。 */
|
||||
readonly indicatorId: string;
|
||||
/** 缺失的风险等级列表。 */
|
||||
readonly missingLevels: readonly number[];
|
||||
|
||||
public constructor(indicatorId: string, missingLevels: readonly number[]) {
|
||||
super(
|
||||
`配置校验失败:指标「${indicatorId}」的 Scoring_Rule 未覆盖风险等级 ${missingLevels.join(
|
||||
'、',
|
||||
)}`,
|
||||
);
|
||||
this.name = 'ScoringRuleCoverageError';
|
||||
this.indicatorId = indicatorId;
|
||||
this.missingLevels = [...missingLevels];
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 红线校验错误(Req 11.4, 11.7)。
|
||||
*
|
||||
* 当某条 Redline 缺少唯一标识、触发条件或一票否决后果,或多条 Redline 标识重复时
|
||||
* 抛出。Config_Center 据此拒绝保存并返回指明失败红线的校验错误。
|
||||
*/
|
||||
export class RedlineValidationError extends ConfigValidationError {
|
||||
/** 校验失败的红线标识(标识缺失时为空串)。 */
|
||||
readonly redlineId: string;
|
||||
|
||||
public constructor(redlineId: string, message: string) {
|
||||
super(message);
|
||||
this.name = 'RedlineValidationError';
|
||||
this.redlineId = redlineId;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板数据错误(Req 2.4)。
|
||||
*
|
||||
* 当待实例化的 Template 缺少 Dimension、Indicator、权重或 Scoring_Rule 中任一
|
||||
* 必填组成项,或其同级 Dimension 权重之和 / 同级 Indicator 权重之和不等于 100%
|
||||
* 时抛出。Config_Center 据此不实例化 Risk_Model、终止本次 Assessment,并向
|
||||
* 评估者返回指明模板数据错误的提示。
|
||||
*/
|
||||
export class TemplateDataError extends ConfigValidationError {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TemplateDataError';
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Config_Center 模型配置中心模块聚合导出(Req 11)。
|
||||
*
|
||||
* 配置中心负责权重归一化、模板继承解析、模板匹配回退、风险模型实例化与校验、
|
||||
* 配置保存与另存模板等。本文件统一对外暴露其公开 API。
|
||||
*/
|
||||
|
||||
export * from './configStore.js';
|
||||
export * from './errors.js';
|
||||
export * from './instantiateRiskModel.js';
|
||||
export * from './loadTemplate.js';
|
||||
export * from './normalizeWeights.js';
|
||||
export * from './resolveInheritance.js';
|
||||
export * from './saveConfig.js';
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 风险模型实例化与校验 `instantiateRiskModel`(Config_Center,Req 2.3, 2.4)。
|
||||
*
|
||||
* 将(已完成继承解析的)Template 承载的 `riskModelConfig` 实例化为本次 Assessment
|
||||
* 的运行时 Risk_Model 快照。实例化前先行完整性与权重校验,校验通过方才实例化;
|
||||
* 校验失败则不实例化、终止本次评估并抛出 {@link TemplateDataError}(Req 2.4)。
|
||||
*
|
||||
* 结构保持(Req 2.3):实例化结果完整保留模板定义的全部 Dimension、Indicator、
|
||||
* 权重、Scoring_Rule、Redline、追问话术(askPrompt)及各 Dimension 与 Indicator
|
||||
* 的启用/停用状态,无丢失、无篡改;并对组成项做深拷贝,避免与输入模板共享可变引用。
|
||||
*
|
||||
* 校验(Req 2.4):
|
||||
* 1. 必填项完整性:至少含一个 Dimension;每个 Dimension 须含权重与至少一个
|
||||
* Indicator;每个 Indicator 须含权重,且其 Scoring_Rule 须覆盖 Risk_Level 1 至 5
|
||||
* 全部级别。
|
||||
* 2. 权重和:同级启用 Dimension 权重之和、以及每个**启用** Dimension 下同级启用
|
||||
* Indicator 权重之和,均须等于 100%(两位小数精度)。停用项不计入权重和;停用
|
||||
* Dimension 不纳入评分、其内部指标权重不归一化,故跳过其指标权重和校验(与
|
||||
* {@link normalizeWeights} 及写侧 normalizeConfigWeights 一致,Req 11.1/11.2)。
|
||||
*/
|
||||
|
||||
import { RISK_LEVEL_VALUES } from '../domain/common.js';
|
||||
import type {
|
||||
Dimension,
|
||||
Indicator,
|
||||
Redline,
|
||||
RiskModel,
|
||||
ScoringRule,
|
||||
Template,
|
||||
} from '../domain/model.js';
|
||||
import { TemplateDataError } from './errors.js';
|
||||
|
||||
/** 权重和目标(以 0.01% 为单位的 100.00%),整数比较以规避浮点误差。 */
|
||||
const WEIGHT_TOTAL_UNITS = 10_000;
|
||||
|
||||
/** 判断取值是否为有效(有限、非负)权重数值。 */
|
||||
function isValidWeight(weight: unknown): weight is number {
|
||||
return typeof weight === 'number' && Number.isFinite(weight) && weight >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断同级启用项权重之和是否等于 100%(两位小数精度)。
|
||||
* 仅启用项纳入计算;将和换算为 0.01% 单位后四舍五入与 10000 比较。
|
||||
*/
|
||||
function enabledWeightsSumTo100(
|
||||
siblings: ReadonlyArray<{ weight: number; enabled: boolean }>,
|
||||
): boolean {
|
||||
const sum = siblings
|
||||
.filter((sibling) => sibling.enabled)
|
||||
.reduce((acc, sibling) => acc + sibling.weight, 0);
|
||||
return Math.round(sum * 100) === WEIGHT_TOTAL_UNITS;
|
||||
}
|
||||
|
||||
/** 深拷贝评分规则,避免实例化结果与输入模板共享可变引用。 */
|
||||
function cloneScoringRule(rule: ScoringRule): ScoringRule {
|
||||
return { level: rule.level, label: rule.label, description: rule.description };
|
||||
}
|
||||
|
||||
/** 深拷贝指标,完整保留权重、启停状态、评分规则与追问话术(Req 2.3)。 */
|
||||
function cloneIndicator(indicator: Indicator): Indicator {
|
||||
return {
|
||||
id: indicator.id,
|
||||
name: indicator.name,
|
||||
weight: indicator.weight,
|
||||
enabled: indicator.enabled,
|
||||
scoringRules: indicator.scoringRules.map(cloneScoringRule),
|
||||
evidenceRequired: indicator.evidenceRequired,
|
||||
askPrompt: indicator.askPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
/** 深拷贝维度,完整保留权重、启停状态与其下指标(Req 2.3)。 */
|
||||
function cloneDimension(dimension: Dimension): Dimension {
|
||||
return {
|
||||
id: dimension.id,
|
||||
name: dimension.name,
|
||||
weight: dimension.weight,
|
||||
enabled: dimension.enabled,
|
||||
indicators: dimension.indicators.map(cloneIndicator),
|
||||
};
|
||||
}
|
||||
|
||||
/** 深拷贝红线,完整保留触发条件、后果与启停状态(Req 2.3)。 */
|
||||
function cloneRedline(redline: Redline): Redline {
|
||||
return {
|
||||
id: redline.id,
|
||||
triggerCondition: redline.triggerCondition,
|
||||
consequence: redline.consequence,
|
||||
enabled: redline.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验指标的 Scoring_Rule 是否覆盖 Risk_Level 1 至 5 全部级别(Req 2.4, 11.3)。
|
||||
* @returns 缺失的级别列表(为空表示完整覆盖)。
|
||||
*/
|
||||
function missingScoringLevels(indicator: Indicator): number[] {
|
||||
const present = new Set(indicator.scoringRules.map((rule) => rule.level));
|
||||
return RISK_LEVEL_VALUES.filter((level) => !present.has(level));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验模板必填项完整性与权重和(Req 2.4)。校验失败抛出 {@link TemplateDataError}。
|
||||
*/
|
||||
function validateTemplate(template: Template): void {
|
||||
const { riskModelConfig } = template;
|
||||
const { dimensions } = riskModelConfig;
|
||||
|
||||
// 必填项:至少含一个 Dimension。
|
||||
if (!Array.isArray(dimensions) || dimensions.length === 0) {
|
||||
throw new TemplateDataError(
|
||||
`模板数据错误:模板「${template.id}」缺少必填组成项 Dimension`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const dimension of dimensions) {
|
||||
// 必填项:Dimension 权重。
|
||||
if (!isValidWeight(dimension.weight)) {
|
||||
throw new TemplateDataError(
|
||||
`模板数据错误:维度「${dimension.id}」缺少合法权重`,
|
||||
);
|
||||
}
|
||||
// 必填项:Dimension 须含至少一个 Indicator。
|
||||
if (!Array.isArray(dimension.indicators) || dimension.indicators.length === 0) {
|
||||
throw new TemplateDataError(
|
||||
`模板数据错误:维度「${dimension.id}」缺少必填组成项 Indicator`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const indicator of dimension.indicators) {
|
||||
// 必填项:Indicator 权重。
|
||||
if (!isValidWeight(indicator.weight)) {
|
||||
throw new TemplateDataError(
|
||||
`模板数据错误:指标「${indicator.id}」缺少合法权重`,
|
||||
);
|
||||
}
|
||||
// 必填项:Scoring_Rule 须覆盖 Risk_Level 1 至 5。
|
||||
const missing = missingScoringLevels(indicator);
|
||||
if (missing.length > 0) {
|
||||
throw new TemplateDataError(
|
||||
`模板数据错误:指标「${indicator.id}」的 Scoring_Rule 未覆盖风险等级 ${missing.join(
|
||||
'、',
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 权重和:仅对启用 Dimension 校验其同级启用 Indicator 权重之和须等于 100%。
|
||||
// 停用 Dimension 不纳入评分,其内部指标权重不归一化(与写侧 normalizeConfigWeights
|
||||
// 一致,Req 11.1/11.2/2.4),故跳过其指标权重和校验。
|
||||
if (dimension.enabled && !enabledWeightsSumTo100(dimension.indicators)) {
|
||||
throw new TemplateDataError(
|
||||
`模板数据错误:维度「${dimension.id}」下同级启用 Indicator 权重之和不等于 100%`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 权重和:同级启用 Dimension 权重之和须等于 100%。
|
||||
if (!enabledWeightsSumTo100(dimensions)) {
|
||||
throw new TemplateDataError(
|
||||
`模板数据错误:模板「${template.id}」同级启用 Dimension 权重之和不等于 100%`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Template 实例化为本次 Assessment 的 Risk_Model(Req 2.3, 2.4)。
|
||||
*
|
||||
* @param template 待实例化的模板(应为已完成继承解析的扁平化模板)。
|
||||
* @returns 实例化后的 Risk_Model 运行时快照,完整保留模板全部组成项与启停状态。
|
||||
* @throws {TemplateDataError} 当模板缺少必填组成项,或同级 Dimension / Indicator
|
||||
* 权重之和不等于 100% 时;此时不实例化 Risk_Model(Req 2.4)。
|
||||
*/
|
||||
export function instantiateRiskModel(template: Template): RiskModel {
|
||||
// 先校验后实例化:非法模板一律不实例化并终止(Req 2.4)。
|
||||
validateTemplate(template);
|
||||
|
||||
const { riskModelConfig } = template;
|
||||
return {
|
||||
id: template.id,
|
||||
name: riskModelConfig.name,
|
||||
businessType: riskModelConfig.businessType,
|
||||
dimensions: riskModelConfig.dimensions.map(cloneDimension),
|
||||
redlines: riskModelConfig.redlines.map(cloneRedline),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 模板匹配与回退 `loadTemplate`(Config_Center,Req 2.1, 2.2, 2.6, 14.3, 14.5)。
|
||||
*
|
||||
* 依据评估者确认的(业务类型, 行业)组合从知识库选择 Template,规则确定且分级回退:
|
||||
*
|
||||
* 1. 精确匹配优先(Req 2.1, 14.3):若存在 `businessType` 与 `industry` 同时相等的
|
||||
* Template,则必选中该精确模板,并标注命中行业专用模板(不附回退说明)。
|
||||
* 2. 回退默认模板(Req 2.2, 14.5):若无精确匹配但存在该业务类型的默认 Template
|
||||
* (`isDefault === true`),则选中默认模板,并标注取值为「未匹配行业专用模板」。
|
||||
* 3. 终止(Req 2.6):若精确与默认两者皆无,则不返回模板而抛出
|
||||
* {@link NoAvailableTemplateError},由上层终止本次 Assessment 并提示无可用模板。
|
||||
*
|
||||
* 选择对任意知识库与组合均确定:当同一层级存在多个候选时,按模板 `id` 升序取首项消歧。
|
||||
*/
|
||||
|
||||
import type { BusinessType, Industry } from '../domain/common.js';
|
||||
import type { KnowledgeBase } from '../domain/knowledge.js';
|
||||
import type { Template } from '../domain/model.js';
|
||||
import { NoAvailableTemplateError } from './errors.js';
|
||||
|
||||
/**
|
||||
* 「未匹配行业专用模板」标记取值(Req 2.2, 14.5)。
|
||||
*
|
||||
* 当回退至业务类型默认模板时,本次 Assessment 须输出取值为该常量的标记,
|
||||
* 以告知评估者所用模型并非行业专用。
|
||||
*/
|
||||
export const UNMATCHED_INDUSTRY_TEMPLATE_MARK = '未匹配行业专用模板' as const;
|
||||
|
||||
/**
|
||||
* 模板来源:可直接传入模板数组,或传入 {@link KnowledgeBase} 快照
|
||||
* (由 `KnowledgeBaseStore.snapshot()` 得到)。
|
||||
*/
|
||||
export type TemplateSource = readonly Template[] | KnowledgeBase;
|
||||
|
||||
/**
|
||||
* 模板加载结果。
|
||||
*/
|
||||
export interface LoadTemplateResult {
|
||||
/** 选中的模板。 */
|
||||
template: Template;
|
||||
/**
|
||||
* 是否命中行业专用(精确匹配)模板:
|
||||
* - `true`:精确匹配(Req 2.1);
|
||||
* - `false`:回退至业务类型默认模板(Req 2.2, 14.5)。
|
||||
*/
|
||||
matchedIndustrySpecific: boolean;
|
||||
/**
|
||||
* 回退标记:精确匹配时为 `undefined`;回退默认模板时取值
|
||||
* {@link UNMATCHED_INDUSTRY_TEMPLATE_MARK}。
|
||||
*/
|
||||
unmatchedIndustryMark?: typeof UNMATCHED_INDUSTRY_TEMPLATE_MARK;
|
||||
}
|
||||
|
||||
/** 规范化行业标识用于匹配(首尾空白不敏感,与知识库存储一致)。 */
|
||||
function normalizeIndustry(industry: Industry): string {
|
||||
return industry.trim();
|
||||
}
|
||||
|
||||
/** 将模板来源归一化为模板数组。 */
|
||||
function toTemplateList(source: TemplateSource): readonly Template[] {
|
||||
if (Array.isArray(source)) {
|
||||
return source;
|
||||
}
|
||||
// KnowledgeBase:汇总各行业分区下的模板。
|
||||
return (source as KnowledgeBase).partitions.flatMap(
|
||||
(partition) => partition.templates,
|
||||
);
|
||||
}
|
||||
|
||||
/** 在候选集合中按 `id` 升序取首项,保证选择确定。 */
|
||||
function pickDeterministic(candidates: readonly Template[]): Template | undefined {
|
||||
if (candidates.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载与(业务类型, 行业)组合匹配的 Template,按精确匹配 → 默认模板 → 终止分级回退。
|
||||
*
|
||||
* @param businessType 评估者确认的业务类型。
|
||||
* @param industry 评估者确认的行业标识。
|
||||
* @param source 模板来源(模板数组或 Knowledge_Base 快照)。
|
||||
* @returns 选中的模板及其是否为行业专用匹配与回退标记。
|
||||
* @throws {NoAvailableTemplateError} 当既无精确匹配模板、也无该业务类型默认模板时
|
||||
* (Req 2.6)。
|
||||
*/
|
||||
export function loadTemplate(
|
||||
businessType: BusinessType,
|
||||
industry: Industry,
|
||||
source: TemplateSource,
|
||||
): LoadTemplateResult {
|
||||
const templates = toTemplateList(source);
|
||||
const targetIndustry = normalizeIndustry(industry);
|
||||
|
||||
// 1) 精确匹配优先(Req 2.1, 14.3)。
|
||||
const exactMatches = templates.filter(
|
||||
(template) =>
|
||||
template.businessType === businessType &&
|
||||
normalizeIndustry(template.industry) === targetIndustry,
|
||||
);
|
||||
const exact = pickDeterministic(exactMatches);
|
||||
if (exact !== undefined) {
|
||||
return { template: exact, matchedIndustrySpecific: true };
|
||||
}
|
||||
|
||||
// 2) 回退该业务类型的默认模板(Req 2.2, 14.5)。
|
||||
const defaultMatches = templates.filter(
|
||||
(template) => template.businessType === businessType && template.isDefault,
|
||||
);
|
||||
const fallback = pickDeterministic(defaultMatches);
|
||||
if (fallback !== undefined) {
|
||||
return {
|
||||
template: fallback,
|
||||
matchedIndustrySpecific: false,
|
||||
unmatchedIndustryMark: UNMATCHED_INDUSTRY_TEMPLATE_MARK,
|
||||
};
|
||||
}
|
||||
|
||||
// 3) 皆无:终止并提示无可用模板(Req 2.6)。
|
||||
throw new NoAvailableTemplateError(businessType, industry);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 权重归一化 `normalizeWeights`(Config_Center,Req 11.2, 11.8)。
|
||||
*
|
||||
* 按比例将同级启用项的权重归一化,使其之和等于 100%(保留两位小数),
|
||||
* 同时尽可能保持各项之间的比例不变;同级启用项权重之和为 0 的向量被拒绝
|
||||
* 并抛出 {@link WeightValidationError}(Req 11.8)。
|
||||
*
|
||||
* 停用项(enabled = false)不计入归一化,其权重原样保留(Req 11.1)。
|
||||
*/
|
||||
|
||||
import type { Weight } from '../domain/common.js';
|
||||
import { WeightValidationError } from './errors.js';
|
||||
|
||||
/**
|
||||
* 可归一化的同级项:携带权重与启用状态。
|
||||
* Dimension 与 Indicator 均满足该结构,故可复用同一归一化逻辑。
|
||||
*/
|
||||
export interface NormalizableSibling {
|
||||
/** 当前权重,取值 0 至 100。 */
|
||||
weight: Weight;
|
||||
/** 启用状态;仅启用项纳入归一化。 */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标总量,以 0.01%(百分之一)为单位表示的 100.00%。
|
||||
* 以整数单位计算可避免浮点累计误差,保证归一化后之和精确等于 100.00%。
|
||||
*/
|
||||
const TOTAL_UNITS = 10_000;
|
||||
|
||||
/**
|
||||
* 将同级项的权重按比例归一化,使启用项权重之和等于 100%(两位小数)。
|
||||
*
|
||||
* 算法:
|
||||
* 1. 仅取启用项参与归一化,求其权重之和 `sum`。
|
||||
* 2. 若 `sum <= 0`(含全零或无启用项)则抛出 {@link WeightValidationError}(Req 11.8)。
|
||||
* 3. 以 0.01% 为单位计算每个启用项的精确份额,向下取整后,
|
||||
* 采用最大余数法将剩余单位逐一分配给余数最大的项(同余数按原顺序消歧),
|
||||
* 从而保证两位小数精度下之和精确为 100.00%。
|
||||
* 4. 停用项权重原样保留,不参与归一化。
|
||||
*
|
||||
* @param siblings 同级项数组(含启用与停用项)。
|
||||
* @returns 与输入等长、顺序一致的新数组;启用项权重已归一化,停用项权重不变。
|
||||
* @throws {WeightValidationError} 当同级启用项权重之和为 0 时(Req 11.8)。
|
||||
*/
|
||||
export function normalizeWeights<T extends NormalizableSibling>(
|
||||
siblings: readonly T[],
|
||||
): T[] {
|
||||
const enabled = siblings
|
||||
.map((sibling, index) => ({ sibling, index }))
|
||||
.filter((entry) => entry.sibling.enabled);
|
||||
|
||||
const sum = enabled.reduce((acc, entry) => acc + entry.sibling.weight, 0);
|
||||
|
||||
// 全零向量或无启用项:无法按比例归一化,拒绝(Req 11.8)。
|
||||
if (!(sum > 0)) {
|
||||
throw new WeightValidationError();
|
||||
}
|
||||
|
||||
// 计算每个启用项的精确单位份额与向下取整值。
|
||||
const shares = enabled.map((entry) => {
|
||||
const exactUnits = (entry.sibling.weight / sum) * TOTAL_UNITS;
|
||||
const floorUnits = Math.floor(exactUnits);
|
||||
return {
|
||||
index: entry.index,
|
||||
floorUnits,
|
||||
remainder: exactUnits - floorUnits,
|
||||
};
|
||||
});
|
||||
|
||||
const assignedUnits = shares.reduce((acc, share) => acc + share.floorUnits, 0);
|
||||
let remainingUnits = TOTAL_UNITS - assignedUnits;
|
||||
|
||||
// 最大余数法分配剩余单位:余数降序,余数相同按原顺序(index 升序)确定性消歧。
|
||||
const byRemainder = [...shares].sort(
|
||||
(a, b) => b.remainder - a.remainder || a.index - b.index,
|
||||
);
|
||||
|
||||
for (let k = 0; remainingUnits > 0 && byRemainder.length > 0; k += 1) {
|
||||
const target = byRemainder[k % byRemainder.length];
|
||||
if (target === undefined) {
|
||||
break;
|
||||
}
|
||||
target.floorUnits += 1;
|
||||
remainingUnits -= 1;
|
||||
}
|
||||
|
||||
// 将归一化后的单位换算回百分比(两位小数),按原索引回填。
|
||||
const unitsByIndex = new Map<number, number>(
|
||||
shares.map((share) => [share.index, share.floorUnits]),
|
||||
);
|
||||
|
||||
return siblings.map((sibling, index) => {
|
||||
const units = unitsByIndex.get(index);
|
||||
if (units === undefined) {
|
||||
// 停用项:权重原样保留(Req 11.1)。
|
||||
return { ...sibling };
|
||||
}
|
||||
return { ...sibling, weight: units / 100 };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 模板继承解析 `resolveInheritance`(Config_Center,Req 2.5, 2.7)。
|
||||
*
|
||||
* 沿 `parentTemplateId` 自子模板回溯至根模板,构成继承链;随后自根向子逐层
|
||||
* 合并配置:以父模板的全部组成项配置为基线,再以子模板中存在差异的组成项
|
||||
* 逐项覆盖父模板的对应组成项(Req 2.5)。组成项按稳定标识对齐——
|
||||
* Dimension/Indicator/Redline 按 `id`、ScoringRule 按 `level`——子模板中标识
|
||||
* 匹配者覆盖父模板对应项,标识新增者追加,父模板独有者保留。
|
||||
*
|
||||
* 防护(Req 2.7):继承链中存在循环引用或继承层级超过 5 层时,抛出
|
||||
* {@link TemplateInheritanceError},使上层不实例化 Risk_Model 并终止评估。
|
||||
*/
|
||||
|
||||
import type {
|
||||
Dimension,
|
||||
Indicator,
|
||||
Redline,
|
||||
RiskModelConfig,
|
||||
ScoringRule,
|
||||
Template,
|
||||
} from '../domain/model.js';
|
||||
import { TemplateInheritanceError } from './errors.js';
|
||||
|
||||
/**
|
||||
* 父模板查找函数:依据 `parentTemplateId` 返回对应模板,不存在时返回 `undefined`。
|
||||
*
|
||||
* 以函数形式抽象查找来源,调用方可由 Map/Record/远程存储等任意实现适配,
|
||||
* 例如 `(id) => templatesById.get(id)`。
|
||||
*/
|
||||
export type TemplateLookup = (templateId: string) => Template | undefined;
|
||||
|
||||
/**
|
||||
* 继承层级上限(Req 2.7)。继承链中父模板链接数超过该值即视为非法。
|
||||
*/
|
||||
export const MAX_INHERITANCE_DEPTH = 5;
|
||||
|
||||
/** 深拷贝评分规则,避免解析结果与输入模板共享可变引用。 */
|
||||
function cloneScoringRule(rule: ScoringRule): ScoringRule {
|
||||
return { level: rule.level, label: rule.label, description: rule.description };
|
||||
}
|
||||
|
||||
/** 深拷贝指标。 */
|
||||
function cloneIndicator(indicator: Indicator): Indicator {
|
||||
return {
|
||||
id: indicator.id,
|
||||
name: indicator.name,
|
||||
weight: indicator.weight,
|
||||
enabled: indicator.enabled,
|
||||
scoringRules: indicator.scoringRules.map(cloneScoringRule),
|
||||
evidenceRequired: indicator.evidenceRequired,
|
||||
askPrompt: indicator.askPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
/** 深拷贝维度。 */
|
||||
function cloneDimension(dimension: Dimension): Dimension {
|
||||
return {
|
||||
id: dimension.id,
|
||||
name: dimension.name,
|
||||
weight: dimension.weight,
|
||||
enabled: dimension.enabled,
|
||||
indicators: dimension.indicators.map(cloneIndicator),
|
||||
};
|
||||
}
|
||||
|
||||
/** 深拷贝红线。 */
|
||||
function cloneRedline(redline: Redline): Redline {
|
||||
return {
|
||||
id: redline.id,
|
||||
triggerCondition: redline.triggerCondition,
|
||||
consequence: redline.consequence,
|
||||
enabled: redline.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
/** 深拷贝风险模型配置主体,用作合并基线。 */
|
||||
function cloneConfig(config: RiskModelConfig): RiskModelConfig {
|
||||
return {
|
||||
name: config.name,
|
||||
businessType: config.businessType,
|
||||
dimensions: config.dimensions.map(cloneDimension),
|
||||
redlines: config.redlines.map(cloneRedline),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按稳定标识逐项合并两个组成项数组:
|
||||
* - 父项构成基线并保持原顺序;
|
||||
* - 子项中标识匹配者经 `merge` 覆盖对应父项;
|
||||
* - 子项中标识新增者按子模板顺序追加。
|
||||
*
|
||||
* @param parents 父模板组成项(基线)。
|
||||
* @param children 子模板组成项(覆盖/新增来源)。
|
||||
* @param keyOf 取组成项稳定标识的函数。
|
||||
* @param merge 标识匹配时的逐项合并函数。
|
||||
* @param clone 子项新增时的深拷贝函数。
|
||||
*/
|
||||
function mergeByKey<T, K>(
|
||||
parents: readonly T[],
|
||||
children: readonly T[],
|
||||
keyOf: (item: T) => K,
|
||||
merge: (parent: T, child: T) => T,
|
||||
clone: (item: T) => T,
|
||||
): T[] {
|
||||
const result = parents.map(clone);
|
||||
const indexByKey = new Map<K, number>();
|
||||
result.forEach((item, index) => indexByKey.set(keyOf(item), index));
|
||||
|
||||
for (const child of children) {
|
||||
const key = keyOf(child);
|
||||
const existingIndex = indexByKey.get(key);
|
||||
if (existingIndex !== undefined) {
|
||||
const existing = result[existingIndex];
|
||||
if (existing !== undefined) {
|
||||
result[existingIndex] = merge(existing, child);
|
||||
}
|
||||
} else {
|
||||
indexByKey.set(key, result.length);
|
||||
result.push(clone(child));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 逐项合并评分规则:按 Risk_Level 对齐,子模板同级覆盖、新增级追加。 */
|
||||
function mergeScoringRules(
|
||||
parent: readonly ScoringRule[],
|
||||
child: readonly ScoringRule[],
|
||||
): ScoringRule[] {
|
||||
return mergeByKey(
|
||||
parent,
|
||||
child,
|
||||
(rule) => rule.level,
|
||||
(_parentRule, childRule) => cloneScoringRule(childRule),
|
||||
cloneScoringRule,
|
||||
);
|
||||
}
|
||||
|
||||
/** 合并指标:标量字段以子模板为准覆盖,评分规则按级逐项合并。 */
|
||||
function mergeIndicator(parent: Indicator, child: Indicator): Indicator {
|
||||
return {
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
weight: child.weight,
|
||||
enabled: child.enabled,
|
||||
scoringRules: mergeScoringRules(parent.scoringRules, child.scoringRules),
|
||||
evidenceRequired: child.evidenceRequired,
|
||||
askPrompt: child.askPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
/** 合并维度:标量字段以子模板为准覆盖,指标按 id 逐项合并。 */
|
||||
function mergeDimension(parent: Dimension, child: Dimension): Dimension {
|
||||
return {
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
weight: child.weight,
|
||||
enabled: child.enabled,
|
||||
indicators: mergeByKey(
|
||||
parent.indicators,
|
||||
child.indicators,
|
||||
(indicator) => indicator.id,
|
||||
mergeIndicator,
|
||||
cloneIndicator,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 合并红线:标量字段以子模板为准覆盖。 */
|
||||
function mergeRedline(_parent: Redline, child: Redline): Redline {
|
||||
return cloneRedline(child);
|
||||
}
|
||||
|
||||
/**
|
||||
* 以父配置为基线、子配置逐项覆盖,合并为新的风险模型配置(Req 2.5)。
|
||||
*/
|
||||
function mergeConfig(
|
||||
parent: RiskModelConfig,
|
||||
child: RiskModelConfig,
|
||||
): RiskModelConfig {
|
||||
return {
|
||||
name: child.name,
|
||||
businessType: child.businessType,
|
||||
dimensions: mergeByKey(
|
||||
parent.dimensions,
|
||||
child.dimensions,
|
||||
(dimension) => dimension.id,
|
||||
mergeDimension,
|
||||
cloneDimension,
|
||||
),
|
||||
redlines: mergeByKey(
|
||||
parent.redlines,
|
||||
child.redlines,
|
||||
(redline) => redline.id,
|
||||
mergeRedline,
|
||||
cloneRedline,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模板继承:返回将继承链自根向子逐项合并后的扁平化模板。
|
||||
*
|
||||
* 解析后的模板保留子模板自身的标识与元数据(id/name/businessType/industry/
|
||||
* isDefault),其 `riskModelConfig` 为合并结果;不再携带 `parentTemplateId`,
|
||||
* 以表示该模板已被完全展开。
|
||||
*
|
||||
* @param template 待解析的(子)模板。
|
||||
* @param templateLookup 依据 `parentTemplateId` 解析父模板的查找函数。
|
||||
* @returns 继承解析后的扁平化模板。
|
||||
* @throws {TemplateInheritanceError} 当继承链存在循环引用、层级超过 5 层,
|
||||
* 或引用了不存在的父模板时(Req 2.7)。
|
||||
*/
|
||||
export function resolveInheritance(
|
||||
template: Template,
|
||||
templateLookup: TemplateLookup,
|
||||
): Template {
|
||||
// 自子向根回溯,构建继承链;同时检测循环引用与层级越界(Req 2.7)。
|
||||
const chain: Template[] = [];
|
||||
const visited = new Set<string>();
|
||||
let current: Template | undefined = template;
|
||||
|
||||
while (current !== undefined) {
|
||||
if (visited.has(current.id)) {
|
||||
throw new TemplateInheritanceError(
|
||||
`模板继承链存在循环引用:模板「${current.id}」重复出现`,
|
||||
);
|
||||
}
|
||||
visited.add(current.id);
|
||||
chain.push(current);
|
||||
|
||||
if (chain.length - 1 > MAX_INHERITANCE_DEPTH) {
|
||||
throw new TemplateInheritanceError(
|
||||
`模板继承层级超过上限 ${MAX_INHERITANCE_DEPTH} 层`,
|
||||
);
|
||||
}
|
||||
|
||||
const parentId: string | undefined = current.parentTemplateId;
|
||||
if (parentId === undefined) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parent = templateLookup(parentId);
|
||||
if (parent === undefined) {
|
||||
throw new TemplateInheritanceError(
|
||||
`模板继承链引用了不存在的父模板「${parentId}」`,
|
||||
);
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
// chain 为 [子, ..., 根];自根向子折叠合并配置。
|
||||
const rootToChild = [...chain].reverse();
|
||||
const base = rootToChild[0];
|
||||
if (base === undefined) {
|
||||
// 不可达:chain 至少包含 template 自身。出于类型完备性兜底。
|
||||
throw new TemplateInheritanceError('模板继承链为空,无法解析');
|
||||
}
|
||||
|
||||
let merged = cloneConfig(base.riskModelConfig);
|
||||
for (let i = 1; i < rootToChild.length; i += 1) {
|
||||
const link = rootToChild[i];
|
||||
if (link === undefined) {
|
||||
continue;
|
||||
}
|
||||
merged = mergeConfig(merged, link.riskModelConfig);
|
||||
}
|
||||
|
||||
return {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
businessType: template.businessType,
|
||||
industry: template.industry,
|
||||
isDefault: template.isDefault,
|
||||
riskModelConfig: merged,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* 配置保存、另存模板与启停(Config_Center,Req 11.1, 11.3, 11.4, 11.5, 11.6, 11.7)。
|
||||
*
|
||||
* 本模块实现配置中心的写侧语义:
|
||||
* - {@link saveConfig}:保存前校验自定义 Scoring_Rule 覆盖 Risk_Level 1-5(Req 11.3)、
|
||||
* Redline 标识唯一且必填字段齐全(Req 11.4)、必填项完整性(Req 11.6)与权重合法性
|
||||
* (按比例归一化、拒绝同级启用项权重之和为 0,Req 11.2/11.8);校验通过则归一化后持久化,
|
||||
* 校验失败则拒绝保存、保留上次有效配置不变并返回指明失败项的校验错误(Req 11.7)。
|
||||
* - {@link saveAsTemplate}:将当前(合法)配置另存为自定义 Template(Req 11.5)。
|
||||
* - {@link deriveTemplate}:基于已有 Template 派生新 Template(Req 11.5)。
|
||||
* - {@link setDimensionEnabled} / {@link setIndicatorEnabled}:启用/停用 Dimension 与
|
||||
* Indicator,停用项保留其配置数据但不纳入评分(Req 11.1)。
|
||||
*
|
||||
* 鉴权说明:配置写操作的 RBAC 角色门禁由 `applyConfigChange`(任务 15.1)统一前置,
|
||||
* 本模块聚焦校验与保存语义;各入口可选接收 `actor` 仅作贯穿透传/审计之用,不在此处做角色判定。
|
||||
*/
|
||||
|
||||
import { RISK_LEVEL_VALUES } from '../domain/common.js';
|
||||
import type { Industry } from '../domain/common.js';
|
||||
import type {
|
||||
Dimension,
|
||||
Indicator,
|
||||
Redline,
|
||||
RiskModelConfig,
|
||||
ScoringRule,
|
||||
Template,
|
||||
} from '../domain/model.js';
|
||||
import type { Actor } from '../rbac/roles.js';
|
||||
import type { RiskModelConfigStore } from './configStore.js';
|
||||
import {
|
||||
ConfigValidationError,
|
||||
RedlineValidationError,
|
||||
RequiredFieldError,
|
||||
ScoringRuleCoverageError,
|
||||
} from './errors.js';
|
||||
import { normalizeWeights } from './normalizeWeights.js';
|
||||
|
||||
/** 缺省配置标识。未显式指定 `configId` 时,{@link saveConfig} 使用此键。 */
|
||||
export const DEFAULT_CONFIG_ID = 'current';
|
||||
|
||||
/** 判断字符串是否为非空(去除首尾空白后长度大于 0)。 */
|
||||
function isNonEmpty(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 深拷贝助手:保证返回的配置与输入不共享可变引用(启停/保存均产出新对象)。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cloneScoringRule(rule: ScoringRule): ScoringRule {
|
||||
return { level: rule.level, label: rule.label, description: rule.description };
|
||||
}
|
||||
|
||||
function cloneIndicator(indicator: Indicator): Indicator {
|
||||
return {
|
||||
id: indicator.id,
|
||||
name: indicator.name,
|
||||
weight: indicator.weight,
|
||||
enabled: indicator.enabled,
|
||||
scoringRules: indicator.scoringRules.map(cloneScoringRule),
|
||||
evidenceRequired: indicator.evidenceRequired,
|
||||
askPrompt: indicator.askPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneDimension(dimension: Dimension): Dimension {
|
||||
return {
|
||||
id: dimension.id,
|
||||
name: dimension.name,
|
||||
weight: dimension.weight,
|
||||
enabled: dimension.enabled,
|
||||
indicators: dimension.indicators.map(cloneIndicator),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRedline(redline: Redline): Redline {
|
||||
return {
|
||||
id: redline.id,
|
||||
triggerCondition: redline.triggerCondition,
|
||||
consequence: redline.consequence,
|
||||
enabled: redline.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneConfig(config: RiskModelConfig): RiskModelConfig {
|
||||
return {
|
||||
name: config.name,
|
||||
businessType: config.businessType,
|
||||
dimensions: config.dimensions.map(cloneDimension),
|
||||
redlines: config.redlines.map(cloneRedline),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 校验:必填项完整性、Scoring_Rule 覆盖、Redline 唯一/必填(Req 11.3, 11.4, 11.6)。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 校验必填项完整性:至少一个 Dimension,每个 Dimension 含标识/名称与至少一个 Indicator。 */
|
||||
function validateCompleteness(config: RiskModelConfig): void {
|
||||
if (!Array.isArray(config.dimensions) || config.dimensions.length === 0) {
|
||||
throw new RequiredFieldError(
|
||||
'dimensions',
|
||||
'配置校验失败:缺少必填组成项 Dimension(至少需配置一个维度)',
|
||||
);
|
||||
}
|
||||
|
||||
for (const dimension of config.dimensions) {
|
||||
if (!isNonEmpty(dimension.id)) {
|
||||
throw new RequiredFieldError('dimension.id', '配置校验失败:存在缺少标识的维度');
|
||||
}
|
||||
if (!isNonEmpty(dimension.name)) {
|
||||
throw new RequiredFieldError(
|
||||
dimension.id,
|
||||
`配置校验失败:维度「${dimension.id}」缺少名称`,
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(dimension.indicators) || dimension.indicators.length === 0) {
|
||||
throw new RequiredFieldError(
|
||||
dimension.id,
|
||||
`配置校验失败:维度「${dimension.id}」缺少必填组成项 Indicator`,
|
||||
);
|
||||
}
|
||||
for (const indicator of dimension.indicators) {
|
||||
if (!isNonEmpty(indicator.id)) {
|
||||
throw new RequiredFieldError(
|
||||
'indicator.id',
|
||||
`配置校验失败:维度「${dimension.id}」下存在缺少标识的指标`,
|
||||
);
|
||||
}
|
||||
if (!isNonEmpty(indicator.name)) {
|
||||
throw new RequiredFieldError(
|
||||
indicator.id,
|
||||
`配置校验失败:指标「${indicator.id}」缺少名称`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验每个 Indicator 的 Scoring_Rule 覆盖 Risk_Level 1 至 5 全部级别(Req 11.3)。 */
|
||||
function validateScoringRules(config: RiskModelConfig): void {
|
||||
for (const dimension of config.dimensions) {
|
||||
for (const indicator of dimension.indicators) {
|
||||
const present = new Set(indicator.scoringRules.map((rule) => rule.level));
|
||||
const missing = RISK_LEVEL_VALUES.filter((level) => !present.has(level));
|
||||
if (missing.length > 0) {
|
||||
throw new ScoringRuleCoverageError(indicator.id, missing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验 Redline 标识唯一,且每条含标识、触发条件与一票否决后果(Req 11.4)。 */
|
||||
function validateRedlines(config: RiskModelConfig): void {
|
||||
const seen = new Set<string>();
|
||||
for (const redline of config.redlines) {
|
||||
if (!isNonEmpty(redline.id)) {
|
||||
throw new RedlineValidationError('', '配置校验失败:存在缺少唯一标识的红线');
|
||||
}
|
||||
if (seen.has(redline.id)) {
|
||||
throw new RedlineValidationError(
|
||||
redline.id,
|
||||
`配置校验失败:红线标识「${redline.id}」重复`,
|
||||
);
|
||||
}
|
||||
seen.add(redline.id);
|
||||
if (!isNonEmpty(redline.triggerCondition)) {
|
||||
throw new RedlineValidationError(
|
||||
redline.id,
|
||||
`配置校验失败:红线「${redline.id}」缺少触发条件`,
|
||||
);
|
||||
}
|
||||
if (!isNonEmpty(redline.consequence)) {
|
||||
throw new RedlineValidationError(
|
||||
redline.id,
|
||||
`配置校验失败:红线「${redline.id}」缺少一票否决后果`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按比例归一化配置权重(Req 11.2):
|
||||
* - 顶层在同级启用 Dimension 间归一化;
|
||||
* - 每个启用 Dimension 内部在其同级启用 Indicator 间归一化;
|
||||
* - 停用 Dimension 的内部指标权重原样保留(停用保留配置但不计分,Req 11.1)。
|
||||
*
|
||||
* 同级启用项权重之和为 0 时由 {@link normalizeWeights} 抛出 WeightValidationError(Req 11.8)。
|
||||
*/
|
||||
function normalizeConfigWeights(config: RiskModelConfig): RiskModelConfig {
|
||||
const dimensions = normalizeWeights(config.dimensions).map((dimension) => {
|
||||
const indicators = dimension.enabled
|
||||
? normalizeWeights(dimension.indicators)
|
||||
: dimension.indicators;
|
||||
return { ...dimension, indicators: indicators.map(cloneIndicator) };
|
||||
});
|
||||
return {
|
||||
name: config.name,
|
||||
businessType: config.businessType,
|
||||
dimensions,
|
||||
redlines: config.redlines.map(cloneRedline),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并归一化配置:校验通过返回归一化后的新配置,校验失败抛出
|
||||
* {@link ConfigValidationError} 子类(指明失败项)。供 {@link saveConfig} 与
|
||||
* {@link saveAsTemplate} 复用。
|
||||
*
|
||||
* @throws {ConfigValidationError} 校验未通过(含权重不可归一化)。
|
||||
*/
|
||||
export function validateAndNormalizeConfig(
|
||||
config: RiskModelConfig,
|
||||
): RiskModelConfig {
|
||||
validateCompleteness(config);
|
||||
validateScoringRules(config);
|
||||
validateRedlines(config);
|
||||
return normalizeConfigWeights(config);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// saveConfig(Req 11.6, 11.7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** {@link saveConfig} 可选项。 */
|
||||
export interface SaveConfigOptions {
|
||||
/** 配置标识键;缺省为 {@link DEFAULT_CONFIG_ID}。 */
|
||||
configId?: string;
|
||||
/** 发起保存的操作者(贯穿透传/审计之用;角色门禁由 applyConfigChange 前置)。 */
|
||||
actor?: Actor;
|
||||
}
|
||||
|
||||
/** 保存成功结果(Req 11.6)。 */
|
||||
export interface SaveConfigSaved {
|
||||
readonly status: 'saved';
|
||||
/** 实际写入的配置标识。 */
|
||||
readonly configId: string;
|
||||
/** 持久化的、已归一化的有效配置。 */
|
||||
readonly config: RiskModelConfig;
|
||||
}
|
||||
|
||||
/** 保存被拒绝结果(Req 11.7)。 */
|
||||
export interface SaveConfigRejected {
|
||||
readonly status: 'rejected';
|
||||
/** 指明失败项的校验错误。 */
|
||||
readonly error: ConfigValidationError;
|
||||
/** 上次有效配置(保持不变);若此前从未保存过有效配置则缺省。 */
|
||||
readonly config?: RiskModelConfig;
|
||||
}
|
||||
|
||||
/** {@link saveConfig} 返回的判别联合结果(不抛出校验异常)。 */
|
||||
export type SaveConfigResult = SaveConfigSaved | SaveConfigRejected;
|
||||
|
||||
/**
|
||||
* 保存风险模型配置(Req 11.6, 11.7)。
|
||||
*
|
||||
* 行为:先校验必填项完整性、Scoring_Rule 覆盖 1-5、Redline 唯一/必填与权重合法性
|
||||
* (归一化);校验通过则将归一化后的配置写入 `store` 并返回 {@link SaveConfigSaved};
|
||||
* 任一校验失败则**不写入** `store`(从而保留上次有效配置不变),返回携带指明失败项
|
||||
* 校验错误的 {@link SaveConfigRejected}。
|
||||
*
|
||||
* 校验错误以判别联合承载、不抛出,便于上层在会话内继续处理;非校验类异常(如存储写入
|
||||
* 失败)仍向上抛出。
|
||||
*
|
||||
* @param config 待保存的配置。
|
||||
* @param store 配置存储(保存成功时写入;失败时保持不变,Req 11.7)。
|
||||
* @param options 可选项(configId / actor)。
|
||||
* @returns 保存成功或被拒绝的判别联合结果。
|
||||
*/
|
||||
export function saveConfig(
|
||||
config: RiskModelConfig,
|
||||
store: RiskModelConfigStore,
|
||||
options: SaveConfigOptions = {},
|
||||
): SaveConfigResult {
|
||||
const configId = options.configId ?? DEFAULT_CONFIG_ID;
|
||||
try {
|
||||
const normalized = validateAndNormalizeConfig(config);
|
||||
store.put(configId, normalized);
|
||||
return { status: 'saved', configId, config: normalized };
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigValidationError) {
|
||||
// 拒绝保存:store 未被写入,上次有效配置保持不变(Req 11.7)。
|
||||
const previous = store.get(configId);
|
||||
return previous !== undefined
|
||||
? { status: 'rejected', error, config: previous }
|
||||
: { status: 'rejected', error };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// saveAsTemplate / deriveTemplate(Req 11.5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** {@link saveAsTemplate} 选项。 */
|
||||
export interface SaveAsTemplateOptions {
|
||||
/** 新模板唯一标识。 */
|
||||
id: string;
|
||||
/** 新模板适用行业。 */
|
||||
industry: Industry;
|
||||
/** 模板名称;缺省取配置名称。 */
|
||||
name?: string;
|
||||
/** 是否为该业务类型的默认模板;缺省 false。 */
|
||||
isDefault?: boolean;
|
||||
/** 父模板标识(如基于已有模板另存);缺省不设置。 */
|
||||
parentTemplateId?: string;
|
||||
/** 操作者(透传/审计之用)。 */
|
||||
actor?: Actor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前(合法)配置另存为自定义 Template(Req 11.5)。
|
||||
*
|
||||
* 另存前对配置执行与 {@link saveConfig} 一致的校验与归一化,确保所存模板可被
|
||||
* 后续 `instantiateRiskModel` 直接实例化(往返等价,对应设计 Property 42)。
|
||||
*
|
||||
* @param config 待另存的配置。
|
||||
* @param options 模板元数据(id/industry/name/isDefault/parentTemplateId)。
|
||||
* @returns 承载归一化配置的新模板。
|
||||
* @throws {ConfigValidationError} 当配置未通过校验时(与 saveConfig 校验一致)。
|
||||
*/
|
||||
export function saveAsTemplate(
|
||||
config: RiskModelConfig,
|
||||
options: SaveAsTemplateOptions,
|
||||
): Template {
|
||||
const normalized = validateAndNormalizeConfig(config);
|
||||
const base: Template = {
|
||||
id: options.id,
|
||||
name: options.name ?? config.name,
|
||||
businessType: config.businessType,
|
||||
industry: options.industry,
|
||||
isDefault: options.isDefault ?? false,
|
||||
riskModelConfig: normalized,
|
||||
};
|
||||
return options.parentTemplateId !== undefined
|
||||
? { ...base, parentTemplateId: options.parentTemplateId }
|
||||
: base;
|
||||
}
|
||||
|
||||
/** {@link deriveTemplate} 选项。 */
|
||||
export interface DeriveTemplateOptions {
|
||||
/** 派生模板唯一标识。 */
|
||||
id: string;
|
||||
/** 派生模板名称;缺省在父模板名称后追加"(派生)"。 */
|
||||
name?: string;
|
||||
/** 派生模板适用行业;缺省沿用父模板行业。 */
|
||||
industry?: Industry;
|
||||
/** 是否为该业务类型的默认模板;缺省 false。 */
|
||||
isDefault?: boolean;
|
||||
/**
|
||||
* 派生模板自身承载的配置增量;缺省深拷贝父模板配置。
|
||||
*
|
||||
* 派生模板通过 `parentTemplateId` 指向父模板,其完整配置由 `resolveInheritance`
|
||||
* 自父向子逐项覆盖合并得到,故此处可仅承载差异项。
|
||||
*/
|
||||
riskModelConfig?: RiskModelConfig;
|
||||
/** 操作者(透传/审计之用)。 */
|
||||
actor?: Actor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于已有 Template 派生新 Template(Req 11.5)。
|
||||
*
|
||||
* 派生模板的 `parentTemplateId` 指向父模板 `id`,其余元数据缺省沿用父模板。
|
||||
* 不在此处校验所承载的配置增量——派生模板可仅含差异项,其完整合法性由继承解析
|
||||
* (`resolveInheritance`)与实例化(`instantiateRiskModel`)阶段统一保证。
|
||||
*
|
||||
* @param parent 父模板。
|
||||
* @param options 派生模板元数据与可选配置增量。
|
||||
* @returns 指向父模板的新派生模板。
|
||||
*/
|
||||
export function deriveTemplate(
|
||||
parent: Template,
|
||||
options: DeriveTemplateOptions,
|
||||
): Template {
|
||||
return {
|
||||
id: options.id,
|
||||
name: options.name ?? `${parent.name}(派生)`,
|
||||
businessType: parent.businessType,
|
||||
industry: options.industry ?? parent.industry,
|
||||
parentTemplateId: parent.id,
|
||||
isDefault: options.isDefault ?? false,
|
||||
riskModelConfig:
|
||||
options.riskModelConfig ?? cloneConfig(parent.riskModelConfig),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 启用/停用 Dimension 与 Indicator(Req 11.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 启用或停用某 Dimension(Req 11.1)。
|
||||
*
|
||||
* 仅翻转目标维度的 `enabled` 标志并返回新配置,**完整保留**其余全部配置数据
|
||||
* (停用项保留配置但不计分;归一化与计分排除由保存/评分阶段处理)。
|
||||
*
|
||||
* @param config 当前配置。
|
||||
* @param dimensionId 目标维度标识。
|
||||
* @param enabled 目标启用状态。
|
||||
* @returns 翻转目标维度启停状态后的新配置(与输入不共享可变引用)。
|
||||
* @throws {RequiredFieldError} 当指定维度不存在时。
|
||||
*/
|
||||
export function setDimensionEnabled(
|
||||
config: RiskModelConfig,
|
||||
dimensionId: string,
|
||||
enabled: boolean,
|
||||
): RiskModelConfig {
|
||||
let found = false;
|
||||
const dimensions = config.dimensions.map((dimension) => {
|
||||
const cloned = cloneDimension(dimension);
|
||||
if (dimension.id === dimensionId) {
|
||||
found = true;
|
||||
return { ...cloned, enabled };
|
||||
}
|
||||
return cloned;
|
||||
});
|
||||
if (!found) {
|
||||
throw new RequiredFieldError(
|
||||
dimensionId,
|
||||
`启停失败:未找到维度「${dimensionId}」`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
name: config.name,
|
||||
businessType: config.businessType,
|
||||
dimensions,
|
||||
redlines: config.redlines.map(cloneRedline),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用或停用某 Dimension 下的某 Indicator(Req 11.1)。
|
||||
*
|
||||
* 仅翻转目标指标的 `enabled` 标志并返回新配置,**完整保留**其余全部配置数据
|
||||
* (停用项保留配置但不计分)。
|
||||
*
|
||||
* @param config 当前配置。
|
||||
* @param dimensionId 目标指标所属维度标识。
|
||||
* @param indicatorId 目标指标标识。
|
||||
* @param enabled 目标启用状态。
|
||||
* @returns 翻转目标指标启停状态后的新配置(与输入不共享可变引用)。
|
||||
* @throws {RequiredFieldError} 当指定维度或指标不存在时。
|
||||
*/
|
||||
export function setIndicatorEnabled(
|
||||
config: RiskModelConfig,
|
||||
dimensionId: string,
|
||||
indicatorId: string,
|
||||
enabled: boolean,
|
||||
): RiskModelConfig {
|
||||
let dimensionFound = false;
|
||||
let indicatorFound = false;
|
||||
const dimensions = config.dimensions.map((dimension) => {
|
||||
if (dimension.id !== dimensionId) {
|
||||
return cloneDimension(dimension);
|
||||
}
|
||||
dimensionFound = true;
|
||||
const indicators = dimension.indicators.map((indicator) => {
|
||||
const cloned = cloneIndicator(indicator);
|
||||
if (indicator.id === indicatorId) {
|
||||
indicatorFound = true;
|
||||
return { ...cloned, enabled };
|
||||
}
|
||||
return cloned;
|
||||
});
|
||||
return { ...cloneDimension(dimension), indicators };
|
||||
});
|
||||
if (!dimensionFound) {
|
||||
throw new RequiredFieldError(
|
||||
dimensionId,
|
||||
`启停失败:未找到维度「${dimensionId}」`,
|
||||
);
|
||||
}
|
||||
if (!indicatorFound) {
|
||||
throw new RequiredFieldError(
|
||||
indicatorId,
|
||||
`启停失败:维度「${dimensionId}」下未找到指标「${indicatorId}」`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
name: config.name,
|
||||
businessType: config.businessType,
|
||||
dimensions,
|
||||
redlines: config.redlines.map(cloneRedline),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Property 31: 风险溢价区间合法且随分级单调 的属性化测试(Cost_Engine,Req 8.1)。
|
||||
*
|
||||
* 属性陈述:对任意 Risk_Grade 与 Risk_Score,`estimate` 输出的 riskPremiumRange 满足:
|
||||
* - 区间合法:lower ≤ upper(下界恒不大于上界);
|
||||
* - 单位恒为"百分比";
|
||||
* - 随分级单调非减:沿严重度升序 低 < 中 < 高 < 极高,相邻分级的下界与上界均逐级
|
||||
* 非减(更高分级溢价恒不低于更低分级)。
|
||||
*
|
||||
* 本测试以智能生成器构造任意 Risk_Score(0–100 整数,覆盖端点与区间内取值)与任意
|
||||
* Risk_Grade,并对相邻分级对在相同 score 下比较端点,约束跨分级单调性。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 31: 风险溢价区间合法且随分级单调
|
||||
* Validates: Requirements 8.1
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { estimate } from '../index.js';
|
||||
import {
|
||||
RISK_GRADE_VALUES,
|
||||
type RiskGrade,
|
||||
type RiskScore,
|
||||
} from '../../domain/common.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:Risk_Score 覆盖 [0,100] 整数(含端点);Risk_Grade 取四级之一。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** Risk_Score:0 至 100 的整数,覆盖边界与区间内值。 */
|
||||
const riskScoreArb: fc.Arbitrary<RiskScore> = fc.integer({ min: 0, max: 100 });
|
||||
|
||||
/** Risk_Grade:四级之一。 */
|
||||
const riskGradeArb: fc.Arbitrary<RiskGrade> = fc.constantFrom(...RISK_GRADE_VALUES);
|
||||
|
||||
/** 严重度升序索引:低 < 中 < 高 < 极高。 */
|
||||
const gradeOrder: readonly RiskGrade[] = RISK_GRADE_VALUES;
|
||||
|
||||
describe('Property 31: 风险溢价区间合法且随分级单调 (Req 8.1)', () => {
|
||||
it('对任意分级与评分,riskPremiumRange 合法(lower ≤ upper,单位为百分比)', () => {
|
||||
fc.assert(
|
||||
fc.property(riskGradeArb, riskScoreArb, (grade, score) => {
|
||||
const { riskPremiumRange } = estimate(
|
||||
{ riskGrade: grade, riskScore: score },
|
||||
{},
|
||||
);
|
||||
expect(riskPremiumRange.lower).toBeLessThanOrEqual(riskPremiumRange.upper);
|
||||
expect(riskPremiumRange.unit).toBe('百分比');
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('溢价区间随分级单调非减:更高分级的下界与上界均 ≥ 更低分级', () => {
|
||||
fc.assert(
|
||||
fc.property(riskScoreArb, riskScoreArb, (lowerScore, higherScore) => {
|
||||
// 沿严重度升序逐级比较相邻分级的端点。
|
||||
for (let i = 1; i < gradeOrder.length; i += 1) {
|
||||
const lowerGrade = gradeOrder[i - 1] as RiskGrade;
|
||||
const higherGrade = gradeOrder[i] as RiskGrade;
|
||||
|
||||
const lowerRange = estimate(
|
||||
{ riskGrade: lowerGrade, riskScore: lowerScore },
|
||||
{},
|
||||
).riskPremiumRange;
|
||||
const higherRange = estimate(
|
||||
{ riskGrade: higherGrade, riskScore: higherScore },
|
||||
{},
|
||||
).riskPremiumRange;
|
||||
|
||||
expect(higherRange.lower).toBeGreaterThanOrEqual(lowerRange.lower);
|
||||
expect(higherRange.upper).toBeGreaterThanOrEqual(lowerRange.upper);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Property 32: 费用项非负且标注依据 的属性化测试(Cost_Engine,Req 8.2, 8.6)。
|
||||
*
|
||||
* 属性陈述:对任意成本输入,垫资利息、保险费用、补偿准备金、坏账准备金各项金额
|
||||
* 恒非负,且每一项测算金额必标注其所依据的输入项(basisInputs 非空)与所采用的
|
||||
* 费率或参数来源(rateSource 非空)。
|
||||
*
|
||||
* 本测试在已完成评分(riskScore ∈ [0,100]、riskGrade 取四级之一)的前提下,生成
|
||||
* 任意成本输入项——包括缺省(undefined)、零、负数、小数与超大金额/费率等边界,
|
||||
* 以验证:
|
||||
* 1. 四项命名费用项金额恒非负(即便输入为负,roundMoney 兜底为非负)。
|
||||
* 2. breakdown 中每一项测算金额均标注非空的 basisInputs 与非空的 rateSource。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 32: 费用项非负且标注依据
|
||||
* Validates: Requirements 8.2, 8.6
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { RISK_GRADE_VALUES } from '../../domain/common.js';
|
||||
import { estimate, type CostInputs, type ScoringSnapshot } from '../estimate.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 评分快照:评分已完成(riskScore 0..100 整数、riskGrade 取四级之一)。 */
|
||||
const scoringArb: fc.Arbitrary<ScoringSnapshot> = fc.record({
|
||||
riskScore: fc.integer({ min: 0, max: 100 }),
|
||||
riskGrade: fc.constantFrom(...RISK_GRADE_VALUES),
|
||||
});
|
||||
|
||||
/**
|
||||
* 金额生成器:覆盖零、正负小数与超大金额等边界。
|
||||
* 含负值用于验证"即便输入异常,费用项金额仍兜底为非负"。
|
||||
*/
|
||||
const moneyArb = fc.double({
|
||||
min: -1_000_000,
|
||||
max: 10_000_000,
|
||||
noNaN: true,
|
||||
noDefaultInfinity: true,
|
||||
});
|
||||
|
||||
/** 费率/比例生成器(小数):覆盖零、负值与较大费率等边界。 */
|
||||
const rateArb = fc.double({
|
||||
min: -1,
|
||||
max: 5,
|
||||
noNaN: true,
|
||||
noDefaultInfinity: true,
|
||||
});
|
||||
|
||||
/** 账期月数生成器:覆盖零、负值与较长周期。 */
|
||||
const monthsArb = fc.double({
|
||||
min: -12,
|
||||
max: 120,
|
||||
noNaN: true,
|
||||
noDefaultInfinity: true,
|
||||
});
|
||||
|
||||
/** 仅在值已定义时写入键,以兼容 exactOptionalPropertyTypes 下的可选属性语义。 */
|
||||
function assignDefined<K extends keyof CostInputs>(
|
||||
target: CostInputs,
|
||||
key: K,
|
||||
value: number | undefined,
|
||||
): void {
|
||||
if (value !== undefined) {
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 任意成本输入:每个字段以 option 生成,缺省时省略该键(触发默认值兜底)。
|
||||
* 通过仅写入已定义的键,满足 exactOptionalPropertyTypes 的精确可选属性约束。
|
||||
*/
|
||||
const costInputsArb: fc.Arbitrary<CostInputs> = fc
|
||||
.record({
|
||||
baselineQuote: fc.option(moneyArb, { nil: undefined }),
|
||||
advancePrincipal: fc.option(moneyArb, { nil: undefined }),
|
||||
advanceMonths: fc.option(monthsArb, { nil: undefined }),
|
||||
annualInterestRate: fc.option(rateArb, { nil: undefined }),
|
||||
insuredAmount: fc.option(moneyArb, { nil: undefined }),
|
||||
insuranceRate: fc.option(rateArb, { nil: undefined }),
|
||||
compensationBase: fc.option(moneyArb, { nil: undefined }),
|
||||
compensationReserveRate: fc.option(rateArb, { nil: undefined }),
|
||||
badDebtBase: fc.option(moneyArb, { nil: undefined }),
|
||||
badDebtRate: fc.option(rateArb, { nil: undefined }),
|
||||
})
|
||||
.map((raw) => {
|
||||
const inputs: CostInputs = {};
|
||||
assignDefined(inputs, 'baselineQuote', raw.baselineQuote);
|
||||
assignDefined(inputs, 'advancePrincipal', raw.advancePrincipal);
|
||||
assignDefined(inputs, 'advanceMonths', raw.advanceMonths);
|
||||
assignDefined(inputs, 'annualInterestRate', raw.annualInterestRate);
|
||||
assignDefined(inputs, 'insuredAmount', raw.insuredAmount);
|
||||
assignDefined(inputs, 'insuranceRate', raw.insuranceRate);
|
||||
assignDefined(inputs, 'compensationBase', raw.compensationBase);
|
||||
assignDefined(inputs, 'compensationReserveRate', raw.compensationReserveRate);
|
||||
assignDefined(inputs, 'badDebtBase', raw.badDebtBase);
|
||||
assignDefined(inputs, 'badDebtRate', raw.badDebtRate);
|
||||
return inputs;
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 32: 费用项非负且标注依据 (Req 8.2, 8.6)', () => {
|
||||
it('四项命名费用项金额恒非负,且每项拆解均标注依据输入项与费率/参数来源', () => {
|
||||
fc.assert(
|
||||
fc.property(scoringArb, costInputsArb, (scoring, costInputs) => {
|
||||
const result = estimate(scoring, costInputs);
|
||||
|
||||
// 1. 四项命名费用项金额恒非负(Req 8.2)。
|
||||
expect(result.advanceInterest).toBeGreaterThanOrEqual(0);
|
||||
expect(result.insuranceCost).toBeGreaterThanOrEqual(0);
|
||||
expect(result.compensationReserve).toBeGreaterThanOrEqual(0);
|
||||
expect(result.badDebtReserve).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// 2. 每一项测算金额必标注其依据(Req 8.2, 8.6)。
|
||||
expect(result.breakdown.length).toBeGreaterThan(0);
|
||||
for (const item of result.breakdown) {
|
||||
// 测算金额非负。
|
||||
expect(item.amount).toBeGreaterThanOrEqual(0);
|
||||
// 标注所依据的输入项:非空数组且每项为非空字符串。
|
||||
expect(Array.isArray(item.basisInputs)).toBe(true);
|
||||
expect(item.basisInputs.length).toBeGreaterThan(0);
|
||||
for (const basis of item.basisInputs) {
|
||||
expect(typeof basis).toBe('string');
|
||||
expect(basis.length).toBeGreaterThan(0);
|
||||
}
|
||||
// 标注费率/参数来源:非空字符串。
|
||||
expect(typeof item.rateSource).toBe('string');
|
||||
expect(item.rateSource.length).toBeGreaterThan(0);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Property 33: 风险调整后报价不低于基准且拆解一致(Req 8.3)。
|
||||
*
|
||||
* 属性陈述:
|
||||
* 对任意合法的评分快照(Risk_Score ∈ [0,100]、Risk_Grade ∈ {低,中,高,极高})与任意
|
||||
* 成本输入项组合(各字段可缺失),`estimate` 输出的费用测算结果满足:
|
||||
* - 风险调整后报价恒不低于基准报价(riskAdjustedQuote ≥ baselineQuote);
|
||||
* - 费用拆解明细各项金额之和与风险调整后报价口径一致(在两位小数舍入容差内相等)。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 33: 风险调整后报价不低于基准且拆解一致
|
||||
* Validates: Requirements 8.3
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { RISK_GRADE_VALUES, type RiskGrade } from '../../domain/common.js';
|
||||
import { estimate, type CostInputs, type ScoringSnapshot } from '../estimate.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:合法评分快照与任意(含缺失字段)成本输入项。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 非负有限货币金额(元)。 */
|
||||
const moneyArb: fc.Arbitrary<number> = fc.double({
|
||||
min: 0,
|
||||
max: 10_000_000,
|
||||
noNaN: true,
|
||||
noDefaultInfinity: true,
|
||||
});
|
||||
|
||||
/** 费率/比例(小数,[0,1])。 */
|
||||
const rateArb: fc.Arbitrary<number> = fc.double({
|
||||
min: 0,
|
||||
max: 1,
|
||||
noNaN: true,
|
||||
noDefaultInfinity: true,
|
||||
});
|
||||
|
||||
/** 评分快照:Risk_Score ∈ [0,100] 整数,Risk_Grade 取四级之一。 */
|
||||
const scoringArb: fc.Arbitrary<ScoringSnapshot> = fc.record({
|
||||
riskScore: fc.integer({ min: 0, max: 100 }),
|
||||
riskGrade: fc.constantFrom<RiskGrade>(...RISK_GRADE_VALUES),
|
||||
});
|
||||
|
||||
/**
|
||||
* 任意成本输入项:每个字段独立地“提供或缺失”。
|
||||
* 遵循 exactOptionalPropertyTypes:仅在有值时写入键,避免显式 undefined。
|
||||
*/
|
||||
const costInputsArb: fc.Arbitrary<CostInputs> = fc
|
||||
.record({
|
||||
baselineQuote: fc.option(moneyArb, { nil: undefined }),
|
||||
advancePrincipal: fc.option(moneyArb, { nil: undefined }),
|
||||
advanceMonths: fc.option(fc.integer({ min: 0, max: 36 }), { nil: undefined }),
|
||||
annualInterestRate: fc.option(rateArb, { nil: undefined }),
|
||||
insuredAmount: fc.option(moneyArb, { nil: undefined }),
|
||||
insuranceRate: fc.option(rateArb, { nil: undefined }),
|
||||
compensationBase: fc.option(moneyArb, { nil: undefined }),
|
||||
compensationReserveRate: fc.option(rateArb, { nil: undefined }),
|
||||
badDebtBase: fc.option(moneyArb, { nil: undefined }),
|
||||
badDebtRate: fc.option(rateArb, { nil: undefined }),
|
||||
})
|
||||
.map((fields) => {
|
||||
const inputs: CostInputs = {};
|
||||
if (fields.baselineQuote !== undefined) inputs.baselineQuote = fields.baselineQuote;
|
||||
if (fields.advancePrincipal !== undefined) {
|
||||
inputs.advancePrincipal = fields.advancePrincipal;
|
||||
}
|
||||
if (fields.advanceMonths !== undefined) inputs.advanceMonths = fields.advanceMonths;
|
||||
if (fields.annualInterestRate !== undefined) {
|
||||
inputs.annualInterestRate = fields.annualInterestRate;
|
||||
}
|
||||
if (fields.insuredAmount !== undefined) inputs.insuredAmount = fields.insuredAmount;
|
||||
if (fields.insuranceRate !== undefined) inputs.insuranceRate = fields.insuranceRate;
|
||||
if (fields.compensationBase !== undefined) {
|
||||
inputs.compensationBase = fields.compensationBase;
|
||||
}
|
||||
if (fields.compensationReserveRate !== undefined) {
|
||||
inputs.compensationReserveRate = fields.compensationReserveRate;
|
||||
}
|
||||
if (fields.badDebtBase !== undefined) inputs.badDebtBase = fields.badDebtBase;
|
||||
if (fields.badDebtRate !== undefined) inputs.badDebtRate = fields.badDebtRate;
|
||||
return inputs;
|
||||
});
|
||||
|
||||
describe('Property 33: 风险调整后报价不低于基准且拆解一致', () => {
|
||||
it('riskAdjustedQuote ≥ baselineQuote 且拆解之和与报价口径一致(Req 8.3)', () => {
|
||||
fc.assert(
|
||||
fc.property(scoringArb, costInputsArb, (scoring, costInputs) => {
|
||||
const result = estimate(scoring, costInputs);
|
||||
|
||||
// --- 风险调整后报价恒不低于基准报价 ---
|
||||
expect(Number.isFinite(result.baselineQuote)).toBe(true);
|
||||
expect(Number.isFinite(result.riskAdjustedQuote)).toBe(true);
|
||||
expect(result.riskAdjustedQuote).toBeGreaterThanOrEqual(result.baselineQuote);
|
||||
|
||||
// --- 拆解之和与风险调整后报价口径一致(两位小数舍入容差内)---
|
||||
const breakdownSum = result.breakdown.reduce(
|
||||
(sum, item) => sum + item.amount,
|
||||
0,
|
||||
);
|
||||
// 每项与报价均已规整为两位小数;容差覆盖累加的最小舍入误差。
|
||||
expect(Math.abs(breakdownSum - result.riskAdjustedQuote)).toBeLessThanOrEqual(0.01);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Property 35: 评分未完成拒绝费用测算 的属性化测试(Cost_Engine,Req 8.5)。
|
||||
*
|
||||
* 属性陈述:*对任意*评分快照,只要缺少 Risk_Score 或 Risk_Grade(评分尚未完成),
|
||||
* Cost_Engine *必*拒绝执行费用测算并抛出 {@link ScoringNotCompleteError}("评分未完成");
|
||||
* 当 Risk_Score 与 Risk_Grade 均已产生时,*不会*因评分未完成而抛错。
|
||||
*
|
||||
* 本测试以智能生成器分别构造两类评分快照:
|
||||
* - 未完成:随机决定 riskScore / riskGrade 是否存在,并约束二者至少一项缺失
|
||||
* (缺失字段整体省略键,契合 exactOptionalPropertyTypes)。断言 estimate 抛出
|
||||
* ScoringNotCompleteError,且其 userMessage 含"评分未完成"(Req 8.5)。
|
||||
* - 已完成:riskScore 与 riskGrade 均存在。断言 estimate 不抛出 ScoringNotCompleteError
|
||||
* (携带任意成本输入或不携带均成立)。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 35: 评分未完成拒绝费用测算
|
||||
* Validates: Requirements 8.5
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { estimate, ScoringNotCompleteError, type ScoringSnapshot } from '../index.js';
|
||||
import { RISK_GRADE_VALUES, type RiskGrade } from '../../domain/common.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器与构造辅助。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** Risk_Score 取值(0~100 整数)。 */
|
||||
const riskScoreArb: fc.Arbitrary<number> = fc.integer({ min: 0, max: 100 });
|
||||
|
||||
/** Risk_Grade 取值(四级之一)。 */
|
||||
const riskGradeArb: fc.Arbitrary<RiskGrade> = fc.constantFrom<RiskGrade>(
|
||||
...RISK_GRADE_VALUES,
|
||||
);
|
||||
|
||||
/**
|
||||
* 依"是否存在"按需构造评分快照:仅在存在时写入对应键(缺失则整体省略键,
|
||||
* 契合 exactOptionalPropertyTypes 与 estimate 的 `=== undefined` 前置校验)。
|
||||
*/
|
||||
function buildSnapshot(
|
||||
scorePresent: boolean,
|
||||
scoreValue: number,
|
||||
gradePresent: boolean,
|
||||
gradeValue: RiskGrade,
|
||||
): ScoringSnapshot {
|
||||
const snapshot: ScoringSnapshot = {};
|
||||
if (scorePresent) {
|
||||
snapshot.riskScore = scoreValue;
|
||||
}
|
||||
if (gradePresent) {
|
||||
snapshot.riskGrade = gradeValue;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** 评分未完成快照:约束 Risk_Score / Risk_Grade 至少一项缺失。 */
|
||||
const incompleteSnapshotArb: fc.Arbitrary<ScoringSnapshot> = fc
|
||||
.record({
|
||||
scorePresent: fc.boolean(),
|
||||
gradePresent: fc.boolean(),
|
||||
scoreValue: riskScoreArb,
|
||||
gradeValue: riskGradeArb,
|
||||
})
|
||||
.filter((plan) => !(plan.scorePresent && plan.gradePresent))
|
||||
.map((plan) =>
|
||||
buildSnapshot(plan.scorePresent, plan.scoreValue, plan.gradePresent, plan.gradeValue),
|
||||
);
|
||||
|
||||
/** 评分已完成快照:Risk_Score 与 Risk_Grade 均存在。 */
|
||||
const completeSnapshotArb: fc.Arbitrary<ScoringSnapshot> = fc.record({
|
||||
riskScore: riskScoreArb,
|
||||
riskGrade: riskGradeArb,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 35: 评分未完成拒绝费用测算 (Req 8.5)', () => {
|
||||
it('任意评分未完成快照(缺少 Risk_Score 或 Risk_Grade):estimate 抛 ScoringNotCompleteError', () => {
|
||||
fc.assert(
|
||||
fc.property(incompleteSnapshotArb, (snapshot) => {
|
||||
// 前置:至少一项缺失。
|
||||
expect(
|
||||
snapshot.riskScore === undefined || snapshot.riskGrade === undefined,
|
||||
).toBe(true);
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
estimate(snapshot);
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(ScoringNotCompleteError);
|
||||
expect((thrown as ScoringNotCompleteError).userMessage).toContain(
|
||||
'评分未完成',
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('任意评分已完成快照(Risk_Score 与 Risk_Grade 均存在):estimate 不因评分未完成而抛错', () => {
|
||||
fc.assert(
|
||||
fc.property(completeSnapshotArb, (snapshot) => {
|
||||
expect(() => estimate(snapshot)).not.toThrow(ScoringNotCompleteError);
|
||||
// 携带空成本输入亦不应因评分未完成而抛错。
|
||||
expect(() => estimate(snapshot, {})).not.toThrow(ScoringNotCompleteError);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Property 34: 缺失成本输入兜底为假设 的属性化测试(Cost_Engine,Req 8.4)。
|
||||
*
|
||||
* 属性陈述:*对任意*缺失的成本输入项,Cost_Engine *必*采用行业默认值并将该输入项所
|
||||
* 影响的费用拆解明细项的 Data_Provenance 标注为"智能体假设"。
|
||||
*
|
||||
* 本测试以智能生成器为 10 个成本输入字段各随机决定"是否提供"及其取值,仅将"提供"的
|
||||
* 字段写入 CostInputs(缺失字段整体省略键,契合 exactOptionalPropertyTypes)。对每个
|
||||
* 生成场景断言:
|
||||
* - 行业默认值被采纳:将缺失字段显式补齐为其行业默认值后重算,所有拆解项金额与缺失
|
||||
* 场景逐项相等 —— 证明缺失字段确实取了行业默认值(Req 8.4 前半);
|
||||
* - 来源标注为"智能体假设":对每个缺失字段,其所依赖的拆解明细项 provenance 必为
|
||||
* "智能体假设"(Req 8.4 后半);
|
||||
* - 反向校验:当全部字段均提供时,无任何拆解项被标为"智能体假设"。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 34: 缺失成本输入兜底为假设
|
||||
* Validates: Requirements 8.4
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
estimate,
|
||||
type CostInputs,
|
||||
type ScoringSnapshot,
|
||||
DEFAULT_ADVANCE_MONTHS,
|
||||
DEFAULT_ANNUAL_INTEREST_RATE,
|
||||
DEFAULT_BAD_DEBT_RATE,
|
||||
DEFAULT_BASELINE_QUOTE,
|
||||
DEFAULT_COMPENSATION_RESERVE_RATE,
|
||||
DEFAULT_INSURANCE_RATE,
|
||||
} from '../index.js';
|
||||
import { ASSUMED_PROVENANCE } from '../../domain/provenance.js';
|
||||
import { RISK_GRADE_VALUES, type RiskGrade } from '../../domain/common.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 字段与依赖映射。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 全部成本输入字段。 */
|
||||
const FIELDS = [
|
||||
'baselineQuote',
|
||||
'advancePrincipal',
|
||||
'advanceMonths',
|
||||
'annualInterestRate',
|
||||
'insuredAmount',
|
||||
'insuranceRate',
|
||||
'compensationBase',
|
||||
'compensationReserveRate',
|
||||
'badDebtBase',
|
||||
'badDebtRate',
|
||||
] as const;
|
||||
|
||||
type CostField = (typeof FIELDS)[number];
|
||||
|
||||
/** 每个输入字段所影响的费用拆解明细项名称(缺失该字段时这些项应标"智能体假设")。 */
|
||||
const DEPENDENT_ITEMS: Readonly<Record<CostField, readonly string[]>> = {
|
||||
// 基准报价为各项基数;其缺失直接影响"基准报价"与"风险溢价"两项的来源标注。
|
||||
baselineQuote: ['基准报价', '风险溢价'],
|
||||
advancePrincipal: ['垫资利息'],
|
||||
advanceMonths: ['垫资利息'],
|
||||
annualInterestRate: ['垫资利息'],
|
||||
insuredAmount: ['保险费用'],
|
||||
insuranceRate: ['保险费用'],
|
||||
compensationBase: ['补偿准备金'],
|
||||
compensationReserveRate: ['补偿准备金'],
|
||||
badDebtBase: ['坏账准备金'],
|
||||
badDebtRate: ['坏账准备金'],
|
||||
};
|
||||
|
||||
/** 单字段计划:是否提供该输入,以及(提供时)其取值。 */
|
||||
interface FieldPlan {
|
||||
present: boolean;
|
||||
value: number;
|
||||
}
|
||||
|
||||
type Plan = Record<CostField, FieldPlan>;
|
||||
|
||||
/**
|
||||
* 字段对应的行业默认值。基数类字段(垫资本金/投保基数/补偿基数/坏账基数)的默认值
|
||||
* 回退至有效基准报价 effBaseline。
|
||||
*/
|
||||
function defaultFor(field: CostField, effBaseline: number): number {
|
||||
switch (field) {
|
||||
case 'baselineQuote':
|
||||
return DEFAULT_BASELINE_QUOTE;
|
||||
case 'advanceMonths':
|
||||
return DEFAULT_ADVANCE_MONTHS;
|
||||
case 'annualInterestRate':
|
||||
return DEFAULT_ANNUAL_INTEREST_RATE;
|
||||
case 'insuranceRate':
|
||||
return DEFAULT_INSURANCE_RATE;
|
||||
case 'compensationReserveRate':
|
||||
return DEFAULT_COMPENSATION_RESERVE_RATE;
|
||||
case 'badDebtRate':
|
||||
return DEFAULT_BAD_DEBT_RATE;
|
||||
case 'advancePrincipal':
|
||||
case 'insuredAmount':
|
||||
case 'compensationBase':
|
||||
case 'badDebtBase':
|
||||
return effBaseline;
|
||||
}
|
||||
}
|
||||
|
||||
/** 有效基准报价:提供则取之,否则采用默认基准报价。 */
|
||||
function effectiveBaseline(plan: Plan): number {
|
||||
return plan.baselineQuote.present
|
||||
? plan.baselineQuote.value
|
||||
: DEFAULT_BASELINE_QUOTE;
|
||||
}
|
||||
|
||||
/** 仅将"提供"的字段写入 CostInputs(缺失字段整体省略键)。 */
|
||||
function buildInputs(plan: Plan): CostInputs {
|
||||
const inputs: CostInputs = {};
|
||||
for (const field of FIELDS) {
|
||||
if (plan[field].present) {
|
||||
inputs[field] = plan[field].value;
|
||||
}
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/** 将缺失字段显式补齐为其行业默认值,提供字段保持原值(参照对照组)。 */
|
||||
function buildFullyProvidedInputs(plan: Plan): CostInputs {
|
||||
const effBaseline = effectiveBaseline(plan);
|
||||
const inputs: CostInputs = {};
|
||||
for (const field of FIELDS) {
|
||||
inputs[field] = plan[field].present
|
||||
? plan[field].value
|
||||
: defaultFor(field, effBaseline);
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 金额/基数类取值(非负有限数)。 */
|
||||
const moneyArb: fc.Arbitrary<number> = fc.double({
|
||||
min: 0,
|
||||
max: 1e9,
|
||||
noNaN: true,
|
||||
noDefaultInfinity: true,
|
||||
});
|
||||
|
||||
/** 费率/比例类取值([0,1] 小数)。 */
|
||||
const rateArb: fc.Arbitrary<number> = fc.double({
|
||||
min: 0,
|
||||
max: 1,
|
||||
noNaN: true,
|
||||
noDefaultInfinity: true,
|
||||
});
|
||||
|
||||
/** 账期月数(非负整数)。 */
|
||||
const monthsArb: fc.Arbitrary<number> = fc.integer({ min: 0, max: 36 });
|
||||
|
||||
/** 各字段对应的取值生成器。 */
|
||||
function valueArbFor(field: CostField): fc.Arbitrary<number> {
|
||||
switch (field) {
|
||||
case 'advanceMonths':
|
||||
return monthsArb;
|
||||
case 'annualInterestRate':
|
||||
case 'insuranceRate':
|
||||
case 'compensationReserveRate':
|
||||
case 'badDebtRate':
|
||||
return rateArb;
|
||||
default:
|
||||
return moneyArb;
|
||||
}
|
||||
}
|
||||
|
||||
/** 单字段计划生成器:随机决定是否提供及取值。 */
|
||||
function fieldPlanArb(field: CostField): fc.Arbitrary<FieldPlan> {
|
||||
return fc.record({ present: fc.boolean(), value: valueArbFor(field) });
|
||||
}
|
||||
|
||||
/** 全字段计划生成器。 */
|
||||
const planArb: fc.Arbitrary<Plan> = fc.record(
|
||||
Object.fromEntries(FIELDS.map((f) => [f, fieldPlanArb(f)])) as {
|
||||
[K in CostField]: fc.Arbitrary<FieldPlan>;
|
||||
},
|
||||
);
|
||||
|
||||
/** 评分快照生成器(评分已完成:Risk_Score 与 Risk_Grade 均存在)。 */
|
||||
const scoringArb: fc.Arbitrary<ScoringSnapshot> = fc.record({
|
||||
riskScore: fc.integer({ min: 0, max: 100 }),
|
||||
riskGrade: fc.constantFrom<RiskGrade>(...RISK_GRADE_VALUES),
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 34: 缺失成本输入兜底为假设 (Req 8.4)', () => {
|
||||
it('任意缺失成本输入:采用行业默认值且依赖项标注"智能体假设"', () => {
|
||||
fc.assert(
|
||||
fc.property(scoringArb, planArb, (scoring, plan) => {
|
||||
const partial = estimate(scoring, buildInputs(plan));
|
||||
const fullyProvided = estimate(scoring, buildFullyProvidedInputs(plan));
|
||||
|
||||
const partialByName = new Map(
|
||||
partial.breakdown.map((item) => [item.name, item]),
|
||||
);
|
||||
const fullByName = new Map(
|
||||
fullyProvided.breakdown.map((item) => [item.name, item]),
|
||||
);
|
||||
|
||||
// (1) 采纳行业默认值:缺失场景与"显式补齐为默认值"场景的各拆解项金额逐项相等。
|
||||
for (const item of partial.breakdown) {
|
||||
const counterpart = fullByName.get(item.name);
|
||||
expect(counterpart).toBeDefined();
|
||||
expect(item.amount).toBe(counterpart!.amount);
|
||||
}
|
||||
|
||||
// (2) 来源标注:每个缺失字段所依赖的拆解项 provenance 必为"智能体假设"。
|
||||
for (const field of FIELDS) {
|
||||
if (plan[field].present) {
|
||||
continue;
|
||||
}
|
||||
for (const itemName of DEPENDENT_ITEMS[field]) {
|
||||
const item = partialByName.get(itemName);
|
||||
expect(item).toBeDefined();
|
||||
expect(item!.provenance).toBe(ASSUMED_PROVENANCE);
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('反向校验:全部成本输入均提供时无拆解项被标"智能体假设"', () => {
|
||||
fc.assert(
|
||||
fc.property(scoringArb, planArb, (scoring, plan) => {
|
||||
// 强制所有字段均提供。
|
||||
const allPresent: Plan = Object.fromEntries(
|
||||
FIELDS.map((f) => [f, { present: true, value: plan[f].value }]),
|
||||
) as Plan;
|
||||
const result = estimate(scoring, buildInputs(allPresent));
|
||||
for (const item of result.breakdown) {
|
||||
expect(item.provenance).not.toBe(ASSUMED_PROVENANCE);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 盈利分析引擎单元测试:确定性、口径一致性与各报价模式的关键不变式。
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
analyzeProfitability,
|
||||
type ProfitabilityInputs,
|
||||
} from '../profitability.js';
|
||||
|
||||
const perHeadInputs: ProfitabilityInputs = {
|
||||
businessType: '岗位外包',
|
||||
region: '上海',
|
||||
pricingModel: 'per_head',
|
||||
contractMonths: 12,
|
||||
positions: [
|
||||
{ name: '运维工程师', headcount: 10, monthlyGrossSalary: 10000, unitPrice: 16000 },
|
||||
],
|
||||
attritionMonthlyRate: 0.03,
|
||||
recruitingCostPerHire: 3000,
|
||||
trainingCostPerHead: 2000,
|
||||
accountPeriodMonths: 2,
|
||||
riskGrade: '中',
|
||||
};
|
||||
|
||||
describe('analyzeProfitability — 通用', () => {
|
||||
it('确定性:相同输入产生相同输出', () => {
|
||||
expect(analyzeProfitability(perHeadInputs)).toEqual(analyzeProfitability(perHeadInputs));
|
||||
});
|
||||
|
||||
it('全口径成本 > 应发工资(加载系数 > 1)', () => {
|
||||
const r = analyzeProfitability(perHeadInputs);
|
||||
expect(r.loadingFactor).toBeGreaterThan(1);
|
||||
const pos = r.positions[0]!;
|
||||
expect(pos.perHead.fullyLoaded).toBeGreaterThan(pos.perHead.grossSalary);
|
||||
// 拆解项加总等于全口径(两位小数容差)。
|
||||
const sum =
|
||||
pos.perHead.grossSalary +
|
||||
pos.perHead.socialInsurance +
|
||||
pos.perHead.housingFund +
|
||||
pos.perHead.benefits +
|
||||
pos.perHead.employerInsurance +
|
||||
pos.perHead.recruitingAmortized +
|
||||
pos.perHead.trainingAmortized +
|
||||
pos.perHead.managementAllocated;
|
||||
expect(Math.abs(sum - pos.perHead.fullyLoaded)).toBeLessThan(0.05);
|
||||
});
|
||||
|
||||
it('毛利 = 不含税收入 − 总成本;净利 ≤ 毛利', () => {
|
||||
const m = analyzeProfitability(perHeadInputs).monthly;
|
||||
expect(Math.abs(m.grossProfit - (m.revenueNet - m.totalCost))).toBeLessThan(0.05);
|
||||
expect(m.netProfit).toBeLessThanOrEqual(m.grossProfit + 0.001);
|
||||
});
|
||||
|
||||
it('盈亏平衡单价处净利率≈0', () => {
|
||||
const r = analyzeProfitability(perHeadInputs);
|
||||
const be = r.breakeven.unitPrice;
|
||||
expect(be).toBeDefined();
|
||||
const atBE = analyzeProfitability({
|
||||
...perHeadInputs,
|
||||
positions: [{ ...perHeadInputs.positions[0]!, unitPrice: be! }],
|
||||
});
|
||||
expect(Math.abs(atBE.monthly.netMargin)).toBeLessThan(0.02);
|
||||
});
|
||||
|
||||
it('合同期 = 月度 × 月数', () => {
|
||||
const r = analyzeProfitability(perHeadInputs);
|
||||
expect(r.contract.totalCost).toBeCloseTo(r.monthly.totalCost * 12, 0);
|
||||
});
|
||||
|
||||
it('敏感性:单价 −5% 使净利率下降', () => {
|
||||
const r = analyzeProfitability(perHeadInputs);
|
||||
const row = r.sensitivity.find((s) => s.variable.includes('单价'));
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.shockedNetMargin).toBeLessThan(row!.baseNetMargin);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeProfitability — 报价模式', () => {
|
||||
it('固定总价:收入取合同总额', () => {
|
||||
const r = analyzeProfitability({
|
||||
businessType: '项目制外包',
|
||||
pricingModel: 'fixed_total',
|
||||
contractMonths: 6,
|
||||
contractTotal: 1_200_000,
|
||||
positions: [{ name: '开发', headcount: 5, monthlyGrossSalary: 20000 }],
|
||||
});
|
||||
expect(r.contract.revenueGross).toBeCloseTo(1_200_000, 0);
|
||||
});
|
||||
|
||||
it('成本加成:markup 越高净利越高', () => {
|
||||
const base: ProfitabilityInputs = {
|
||||
businessType: '业务/服务外包',
|
||||
pricingModel: 'cost_plus',
|
||||
positions: [{ name: '客服', headcount: 20, monthlyGrossSalary: 6000 }],
|
||||
markupRate: 0.15,
|
||||
};
|
||||
const low = analyzeProfitability(base).monthly.netMargin;
|
||||
const high = analyzeProfitability({ ...base, markupRate: 0.3 }).monthly.netMargin;
|
||||
expect(high).toBeGreaterThan(low);
|
||||
});
|
||||
|
||||
it('劳务派遣默认差额计税', () => {
|
||||
const r = analyzeProfitability({
|
||||
businessType: '劳务派遣',
|
||||
pricingModel: 'per_head',
|
||||
positions: [{ name: '派遣工', headcount: 30, monthlyGrossSalary: 5000, unitPrice: 6500 }],
|
||||
});
|
||||
expect(r.vatMode).toBe('simplified_diff');
|
||||
});
|
||||
|
||||
it('地域影响社保成本(北京公积金高于广东)', () => {
|
||||
const mk = (region: string): number =>
|
||||
analyzeProfitability({
|
||||
businessType: '岗位外包',
|
||||
pricingModel: 'per_head',
|
||||
region,
|
||||
positions: [{ name: 'x', headcount: 1, monthlyGrossSalary: 10000, unitPrice: 16000 }],
|
||||
}).positions[0]!.perHead.housingFund;
|
||||
expect(mk('北京')).toBeGreaterThan(mk('广东'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeProfitability — 现金流与定价曲线', () => {
|
||||
it('有账期时存在垫资峰值,且峰值出现在合同执行期内', () => {
|
||||
const r = analyzeProfitability(perHeadInputs);
|
||||
expect(r.cashflow.maxAdvance).toBeGreaterThan(0);
|
||||
expect(r.cashflow.peakMonth).toBeGreaterThanOrEqual(1);
|
||||
expect(r.cashflow.points.length).toBe(perHeadInputs.contractMonths! + perHeadInputs.accountPeriodMonths!);
|
||||
});
|
||||
|
||||
it('账期越长,垫资峰值越大', () => {
|
||||
const a = analyzeProfitability({ ...perHeadInputs, accountPeriodMonths: 1 });
|
||||
const b = analyzeProfitability({ ...perHeadInputs, accountPeriodMonths: 3 });
|
||||
expect(b.cashflow.maxAdvance).toBeGreaterThan(a.cashflow.maxAdvance);
|
||||
});
|
||||
|
||||
it('定价曲线随价格系数单调递增(单价越高净利率越高)', () => {
|
||||
const r = analyzeProfitability(perHeadInputs);
|
||||
expect(r.marginCurve.length).toBeGreaterThan(0);
|
||||
for (let i = 1; i < r.marginCurve.length; i += 1) {
|
||||
expect(r.marginCurve[i]!.netMargin).toBeGreaterThan(r.marginCurve[i - 1]!.netMargin);
|
||||
}
|
||||
// priceFactor=1 的点净利率应接近当前测算净利率。
|
||||
const base = r.marginCurve.find((p) => p.priceFactor === 1)!;
|
||||
expect(Math.abs(base.netMargin - r.monthly.netMargin)).toBeLessThan(0.0005);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Cost_Engine 行业默认成本输入与费率参数(Req 8.1, 8.2, 8.4)。
|
||||
*
|
||||
* 当测算所需的成本输入项缺失时,Cost_Engine 采用本模块定义的行业默认值,并将该
|
||||
* 输入项的 Data_Provenance 标注为"智能体假设"(Req 8.4)。本模块集中沉淀两类常量:
|
||||
*
|
||||
* - 风险溢价加价区间按 Risk_Grade 的百分比分级(Req 8.1):四级区间下界恒不大于
|
||||
* 上界,且更高分级的区间端点恒不低于更低分级,满足"随分级单调"(Property 31)。
|
||||
* - 各费用项的默认费率/参数(垫资年化利率、账期月数、保险费率、准备金计提比例等),
|
||||
* 作为缺失输入时的兜底取值与费率来源说明依据(Req 8.2, 8.6)。
|
||||
*/
|
||||
|
||||
import type { Money, RiskGrade } from '../domain/common.js';
|
||||
|
||||
/**
|
||||
* 风险溢价加价区间(百分比)按 Risk_Grade 分级。
|
||||
*
|
||||
* 下界/上界均以**百分点**表示(如 3 表示 3%)。区间随分级单调非减:
|
||||
* 低 ⊆ 中 ⊆ 高 ⊆ 极高 的端点逐级抬升,保证更高分级溢价恒不低于更低分级。
|
||||
*/
|
||||
export interface PremiumBand {
|
||||
/** 区间下界(百分点,≥0)。 */
|
||||
lowerPct: number;
|
||||
/** 区间上界(百分点,≥ lowerPct)。 */
|
||||
upperPct: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 四级 Risk_Grade 对应的风险溢价加价区间(百分点)。
|
||||
*
|
||||
* 低: 0%–3%、中: 3%–8%、高: 8%–15%、极高: 15%–25%。
|
||||
* 相邻分级端点首尾衔接且逐级非减,满足下界≤上界与跨分级单调(Property 31)。
|
||||
*/
|
||||
export const PREMIUM_BANDS: Readonly<Record<RiskGrade, PremiumBand>> = {
|
||||
低: { lowerPct: 0, upperPct: 3 },
|
||||
中: { lowerPct: 3, upperPct: 8 },
|
||||
高: { lowerPct: 8, upperPct: 15 },
|
||||
极高: { lowerPct: 15, upperPct: 25 },
|
||||
};
|
||||
|
||||
/** 默认基准报价(元):缺失合同/报价基准金额时的行业兜底取值。 */
|
||||
export const DEFAULT_BASELINE_QUOTE: Money = 1_000_000;
|
||||
|
||||
/** 默认垫资年化利率(小数,6%)。 */
|
||||
export const DEFAULT_ANNUAL_INTEREST_RATE = 0.06;
|
||||
|
||||
/** 默认垫资周期(账期)月数。 */
|
||||
export const DEFAULT_ADVANCE_MONTHS = 2;
|
||||
|
||||
/** 默认保险费率(小数,1%),以投保基数计提。 */
|
||||
export const DEFAULT_INSURANCE_RATE = 0.01;
|
||||
|
||||
/** 默认补偿准备金计提比例(小数,5%),以补偿基数计提。 */
|
||||
export const DEFAULT_COMPENSATION_RESERVE_RATE = 0.05;
|
||||
|
||||
/** 默认坏账准备金计提比例(小数,2%),以坏账基数计提。 */
|
||||
export const DEFAULT_BAD_DEBT_RATE = 0.02;
|
||||
|
||||
/** 一年的月数,用于将年化利率折算到账期月数。 */
|
||||
export const MONTHS_PER_YEAR = 12;
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Cost_Engine 费用测算错误类型(Req 8.5)。
|
||||
*
|
||||
* 费用测算引擎在评分尚未完成时拒绝执行测算并抛出语义化错误,供上层捕获并向
|
||||
* 评估者返回"评分未完成"的提示。错误处理遵循统一原则:前置校验、错误可解释、
|
||||
* 失败不产生无意义的费用测算结果。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 评分未完成错误(Req 8.5)。
|
||||
*
|
||||
* 当本次 Assessment 的风险评分尚未完成(缺少 Risk_Score 或 Risk_Grade)时抛出。
|
||||
* Cost_Engine 据此拒绝执行费用测算并返回评分未完成的提示。
|
||||
*/
|
||||
export class ScoringNotCompleteError extends Error {
|
||||
/** 面向评估者的可读提示,固定包含"评分未完成"。 */
|
||||
readonly userMessage: string;
|
||||
|
||||
public constructor(
|
||||
message = '评分未完成:请先完成风险评分与分级后再执行费用测算',
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ScoringNotCompleteError';
|
||||
this.userMessage = message;
|
||||
// 维持 instanceof 在编译目标下正确工作。
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Cost_Engine 费用测算(Req 8.1-8.6)。
|
||||
*
|
||||
* 本模块实现 `estimate`:在风险评分完成后,依据 Risk_Grade 与 Risk_Score 计算可量化的
|
||||
* 费用/定价测算,输出 {@link CostEstimate}。核心契约:
|
||||
*
|
||||
* - 前置:评分已完成(Risk_Score 与 Risk_Grade 均已产生),否则抛
|
||||
* {@link ScoringNotCompleteError}(Req 8.5)。
|
||||
* - 风险溢价加价区间依 Risk_Grade + Risk_Score 计算:下界恒不大于上界,且随分级单调
|
||||
* 非减(Req 8.1,Property 31)。
|
||||
* - 垫资利息、保险费用、补偿准备金、坏账准备金各项金额恒非负,且每项标注所依据的
|
||||
* 输入项与所采用的费率/参数来源(Req 8.2, 8.6,Property 32)。
|
||||
* - 输出基准报价与风险调整后报价(后者恒不低于基准),各项成本拆解之和与报价口径
|
||||
* 一致(Req 8.3,Property 33)。
|
||||
* - 缺失的成本输入项采用行业默认值并将其 Data_Provenance 标注为"智能体假设"
|
||||
* (Req 8.4,Property 34)。
|
||||
*
|
||||
* 设计为确定性纯函数:相同输入恒产生相同输出,便于属性化测试约束其正确性。
|
||||
*/
|
||||
|
||||
import type {
|
||||
CostEstimate,
|
||||
CostLineItem,
|
||||
} from '../domain/cost.js';
|
||||
import type {
|
||||
DataProvenance,
|
||||
Money,
|
||||
RiskGrade,
|
||||
RiskScore,
|
||||
} from '../domain/common.js';
|
||||
import type { Assessment } from '../domain/assessment.js';
|
||||
import { ASSUMED_PROVENANCE, isAssumption } from '../domain/provenance.js';
|
||||
import {
|
||||
DEFAULT_ADVANCE_MONTHS,
|
||||
DEFAULT_ANNUAL_INTEREST_RATE,
|
||||
DEFAULT_BAD_DEBT_RATE,
|
||||
DEFAULT_BASELINE_QUOTE,
|
||||
DEFAULT_COMPENSATION_RESERVE_RATE,
|
||||
DEFAULT_INSURANCE_RATE,
|
||||
MONTHS_PER_YEAR,
|
||||
PREMIUM_BANDS,
|
||||
} from './defaults.js';
|
||||
import { ScoringNotCompleteError } from './errors.js';
|
||||
|
||||
/**
|
||||
* 费用测算的成本输入项(CostInputs,Req 8.2-8.4)。
|
||||
*
|
||||
* 全部字段均为可选;任一字段缺失时,Cost_Engine 采用对应行业默认值并将该输入项的
|
||||
* Data_Provenance 标注为"智能体假设"(Req 8.4)。金额单位为人民币元,费率/比例为小数
|
||||
* (如 0.06 表示 6%)。
|
||||
*/
|
||||
export interface CostInputs {
|
||||
/** 基准报价 / 合同基准金额(元);风险溢价与各准备金的计提基数。 */
|
||||
baselineQuote?: Money;
|
||||
/** 垫资本金(元);缺失时默认取基准报价。 */
|
||||
advancePrincipal?: Money;
|
||||
/** 垫资周期(账期)月数;用于将年化利率折算到账期。 */
|
||||
advanceMonths?: number;
|
||||
/** 垫资年化利率(小数)。 */
|
||||
annualInterestRate?: number;
|
||||
/** 投保基数(元);缺失时默认取基准报价。 */
|
||||
insuredAmount?: Money;
|
||||
/** 保险费率(小数)。 */
|
||||
insuranceRate?: number;
|
||||
/** 经济补偿计提基数(元);缺失时默认取基准报价。 */
|
||||
compensationBase?: Money;
|
||||
/** 补偿准备金计提比例(小数)。 */
|
||||
compensationReserveRate?: number;
|
||||
/** 坏账计提基数(元);缺失时默认取基准报价。 */
|
||||
badDebtBase?: Money;
|
||||
/** 坏账准备金计提比例(小数)。 */
|
||||
badDebtRate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评分快照:费用测算所需的评分结果子集。
|
||||
*
|
||||
* 以 {@link Assessment} 的 `riskScore` 与 `riskGrade` 子集为入参,使本函数既可直接接收
|
||||
* 完整 Assessment,也可接收最小评分对象,且与评估记录的其他字段解耦。
|
||||
*/
|
||||
export type ScoringSnapshot = Pick<Assessment, 'riskScore' | 'riskGrade'>;
|
||||
|
||||
/** 单个解析后的数值输入:携带最终取值与其数据来源标注。 */
|
||||
interface ResolvedInput {
|
||||
/** 最终采用的数值(用户输入或行业默认值)。 */
|
||||
value: number;
|
||||
/** 数据来源:缺失而采用默认值时为"智能体假设",否则为"用户输入"。 */
|
||||
provenance: DataProvenance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一个可选数值输入:提供则标"用户输入",缺失则采用默认值并标"智能体假设"(Req 8.4)。
|
||||
*
|
||||
* @param provided 调用方提供的输入值(可能为 undefined)。
|
||||
* @param fallback 缺失时采用的行业默认值。
|
||||
* @returns 解析后的取值与来源标注。
|
||||
*/
|
||||
function resolveInput(provided: number | undefined, fallback: number): ResolvedInput {
|
||||
if (provided === undefined) {
|
||||
return { value: fallback, provenance: ASSUMED_PROVENANCE };
|
||||
}
|
||||
return { value: provided, provenance: '用户输入' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个来源标注:任一为"智能体假设"则结果为"智能体假设",否则为"用户输入"(Req 8.4)。
|
||||
*/
|
||||
function combineProvenance(inputs: ResolvedInput[]): DataProvenance {
|
||||
return inputs.some((input) => isAssumption(input.provenance))
|
||||
? ASSUMED_PROVENANCE
|
||||
: '用户输入';
|
||||
}
|
||||
|
||||
/** 将金额规整为非负、保留两位小数(抵消二进制浮点误差)。 */
|
||||
function roundMoney(value: number): Money {
|
||||
const factor = 100;
|
||||
const rounded = Math.round((value + Number.EPSILON) * factor) / factor;
|
||||
// `+ 0` 将可能出现的负零规范化为正零,确保金额恒为非负。
|
||||
return Math.max(0, rounded) + 0;
|
||||
}
|
||||
|
||||
/** 将来源标注转为人类可读的来源短语,用于费率/参数来源说明(Req 8.2, 8.6)。 */
|
||||
function sourceLabel(provenance: DataProvenance): string {
|
||||
return isAssumption(provenance) ? '行业默认值(智能体假设)' : '用户输入';
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算风险溢价加价区间(百分比)并依 Risk_Score 在区间内定位实际加价率(Req 8.1)。
|
||||
*
|
||||
* 区间取自 {@link PREMIUM_BANDS}[grade],随分级单调;实际加价率在区间内按 Risk_Score
|
||||
* 线性定位(score 0→下界、100→上界),用于风险调整后报价的溢价计算。
|
||||
*
|
||||
* @param grade Risk_Grade。
|
||||
* @param score Risk_Score(0 至 100)。
|
||||
* @returns 区间端点(百分点)与折算后的实际加价率(小数)。
|
||||
*/
|
||||
function computePremium(
|
||||
grade: RiskGrade,
|
||||
score: RiskScore,
|
||||
): { lowerPct: number; upperPct: number; appliedRate: number } {
|
||||
const band = PREMIUM_BANDS[grade];
|
||||
const position = Math.min(1, Math.max(0, score / 100));
|
||||
const appliedPct = band.lowerPct + (band.upperPct - band.lowerPct) * position;
|
||||
return {
|
||||
lowerPct: band.lowerPct,
|
||||
upperPct: band.upperPct,
|
||||
appliedRate: appliedPct / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行费用与定价量化测算(Req 8.1-8.6)。
|
||||
*
|
||||
* @param scoring 评分快照(完整 Assessment 或其 riskScore/riskGrade 子集)。
|
||||
* @param costInputs 成本输入项;任一字段缺失则采用行业默认值并标"智能体假设"(Req 8.4)。
|
||||
* @returns 费用测算结果 {@link CostEstimate}。
|
||||
* @throws {ScoringNotCompleteError} 当评分尚未完成(缺少 Risk_Score 或 Risk_Grade)时(Req 8.5)。
|
||||
*/
|
||||
export function estimate(
|
||||
scoring: ScoringSnapshot,
|
||||
costInputs: CostInputs = {},
|
||||
): CostEstimate {
|
||||
// 前置:评分必须已完成(Req 8.5)。
|
||||
if (scoring.riskScore === undefined || scoring.riskGrade === undefined) {
|
||||
throw new ScoringNotCompleteError();
|
||||
}
|
||||
const riskScore: RiskScore = scoring.riskScore;
|
||||
const riskGrade: RiskGrade = scoring.riskGrade;
|
||||
|
||||
// 解析基准报价(其他基数缺省时回退至基准报价)。
|
||||
const baseline = resolveInput(costInputs.baselineQuote, DEFAULT_BASELINE_QUOTE);
|
||||
|
||||
// 解析各费用项的基数与费率/参数(缺失项采用行业默认值并标"智能体假设")。
|
||||
const advancePrincipal = resolveInput(costInputs.advancePrincipal, baseline.value);
|
||||
const advanceMonths = resolveInput(costInputs.advanceMonths, DEFAULT_ADVANCE_MONTHS);
|
||||
const annualInterestRate = resolveInput(
|
||||
costInputs.annualInterestRate,
|
||||
DEFAULT_ANNUAL_INTEREST_RATE,
|
||||
);
|
||||
const insuredAmount = resolveInput(costInputs.insuredAmount, baseline.value);
|
||||
const insuranceRate = resolveInput(costInputs.insuranceRate, DEFAULT_INSURANCE_RATE);
|
||||
const compensationBase = resolveInput(costInputs.compensationBase, baseline.value);
|
||||
const compensationReserveRate = resolveInput(
|
||||
costInputs.compensationReserveRate,
|
||||
DEFAULT_COMPENSATION_RESERVE_RATE,
|
||||
);
|
||||
const badDebtBase = resolveInput(costInputs.badDebtBase, baseline.value);
|
||||
const badDebtRate = resolveInput(costInputs.badDebtRate, DEFAULT_BAD_DEBT_RATE);
|
||||
|
||||
// 风险溢价加价区间(依 Risk_Grade + Risk_Score,Req 8.1)。
|
||||
const premium = computePremium(riskGrade, riskScore);
|
||||
|
||||
// 各费用项金额(恒非负,Req 8.2)。
|
||||
const baselineQuote = roundMoney(baseline.value);
|
||||
const riskPremiumAmount = roundMoney(baseline.value * premium.appliedRate);
|
||||
const advanceInterest = roundMoney(
|
||||
advancePrincipal.value * annualInterestRate.value * (advanceMonths.value / MONTHS_PER_YEAR),
|
||||
);
|
||||
const insuranceCost = roundMoney(insuredAmount.value * insuranceRate.value);
|
||||
const compensationReserve = roundMoney(
|
||||
compensationBase.value * compensationReserveRate.value,
|
||||
);
|
||||
const badDebtReserve = roundMoney(badDebtBase.value * badDebtRate.value);
|
||||
|
||||
// 风险调整后报价 = 基准报价 + 风险溢价 + 各项成本(恒不低于基准,Req 8.3)。
|
||||
const riskAdjustedQuote = roundMoney(
|
||||
baselineQuote +
|
||||
riskPremiumAmount +
|
||||
advanceInterest +
|
||||
insuranceCost +
|
||||
compensationReserve +
|
||||
badDebtReserve,
|
||||
);
|
||||
|
||||
// 费用拆解:基准报价 + 各加价/成本项,其金额之和与风险调整后报价口径一致(Req 8.3)。
|
||||
const breakdown: CostLineItem[] = [
|
||||
{
|
||||
name: '基准报价',
|
||||
amount: baselineQuote,
|
||||
basisInputs: ['基准报价/合同基准金额'],
|
||||
rateSource: `基准报价取值来源:${sourceLabel(baseline.provenance)}`,
|
||||
provenance: baseline.provenance,
|
||||
},
|
||||
{
|
||||
name: '风险溢价',
|
||||
amount: riskPremiumAmount,
|
||||
basisInputs: ['基准报价/合同基准金额', 'Risk_Grade', 'Risk_Score'],
|
||||
rateSource:
|
||||
`按 Risk_Grade=${riskGrade} 的溢价区间 ` +
|
||||
`${premium.lowerPct}%~${premium.upperPct}% 内依 Risk_Score=${riskScore} ` +
|
||||
`线性定位,实际加价率 ${(premium.appliedRate * 100).toFixed(2)}%`,
|
||||
provenance: baseline.provenance,
|
||||
},
|
||||
{
|
||||
name: '垫资利息',
|
||||
amount: advanceInterest,
|
||||
basisInputs: ['垫资本金', '垫资周期(月)', '年化利率'],
|
||||
rateSource:
|
||||
`年化垫资利率 ${(annualInterestRate.value * 100).toFixed(2)}%` +
|
||||
`(${sourceLabel(annualInterestRate.provenance)}),` +
|
||||
`账期 ${advanceMonths.value} 个月(${sourceLabel(advanceMonths.provenance)})`,
|
||||
provenance: combineProvenance([advancePrincipal, advanceMonths, annualInterestRate]),
|
||||
},
|
||||
{
|
||||
name: '保险费用',
|
||||
amount: insuranceCost,
|
||||
basisInputs: ['投保基数', '保险费率'],
|
||||
rateSource:
|
||||
`保险费率 ${(insuranceRate.value * 100).toFixed(2)}%` +
|
||||
`(${sourceLabel(insuranceRate.provenance)})`,
|
||||
provenance: combineProvenance([insuredAmount, insuranceRate]),
|
||||
},
|
||||
{
|
||||
name: '补偿准备金',
|
||||
amount: compensationReserve,
|
||||
basisInputs: ['经济补偿计提基数', '补偿准备金计提比例'],
|
||||
rateSource:
|
||||
`补偿准备金计提比例 ${(compensationReserveRate.value * 100).toFixed(2)}%` +
|
||||
`(${sourceLabel(compensationReserveRate.provenance)})`,
|
||||
provenance: combineProvenance([compensationBase, compensationReserveRate]),
|
||||
},
|
||||
{
|
||||
name: '坏账准备金',
|
||||
amount: badDebtReserve,
|
||||
basisInputs: ['坏账计提基数', '坏账准备金计提比例'],
|
||||
rateSource:
|
||||
`坏账准备金计提比例 ${(badDebtRate.value * 100).toFixed(2)}%` +
|
||||
`(${sourceLabel(badDebtRate.provenance)})`,
|
||||
provenance: combineProvenance([badDebtBase, badDebtRate]),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
riskPremiumRange: {
|
||||
lower: premium.lowerPct,
|
||||
upper: premium.upperPct,
|
||||
unit: '百分比',
|
||||
},
|
||||
advanceInterest,
|
||||
insuranceCost,
|
||||
compensationReserve,
|
||||
badDebtReserve,
|
||||
baselineQuote,
|
||||
riskAdjustedQuote,
|
||||
breakdown,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Cost_Engine 费用测算引擎模块聚合导出(Req 8)。
|
||||
*
|
||||
* 提供 `estimate` 费用测算入口、成本输入类型、行业默认常量与评分未完成错误。
|
||||
*/
|
||||
|
||||
export * from './estimate.js';
|
||||
export * from './defaults.js';
|
||||
export * from './errors.js';
|
||||
export * from './rates.js';
|
||||
export * from './profitability.js';
|
||||
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* Cost_Engine 盈利分析引擎(确定性纯函数)。
|
||||
*
|
||||
* 按业务类型与报价模式建立「收入(销售结算)账」与「全口径成本账」,产出毛利/净利/
|
||||
* 盈亏平衡/敏感性,并联动风险评分计提风险准备金。税务做价外增值税 + 附加的近似估算。
|
||||
*
|
||||
* 口径与费率为行业近似默认,缺失项采用默认并在 `assumptions` 标注("智能体假设"),
|
||||
* 须经财务复核。本模块不实现任何 UI/IO,便于属性化与单元测试约束其确定性与一致性。
|
||||
*
|
||||
* 首批支持的报价模式:
|
||||
* - per_head 人头费率制(岗位外包/劳务派遣)
|
||||
* - cost_plus 成本加成 / 人月单价(业务/服务外包 T&M)
|
||||
* - fixed_total 固定总价(项目制外包)
|
||||
* - volume 按量/产能(BPO,近似以人月当量计)
|
||||
*/
|
||||
|
||||
import type { RiskGrade } from '../domain/common.js';
|
||||
import {
|
||||
getRegionRates,
|
||||
mergeRates,
|
||||
totalSocialInsuranceRate,
|
||||
type RegionRates,
|
||||
} from './rates.js';
|
||||
|
||||
/** 报价 / 销售结算模式。 */
|
||||
export type PricingModel = 'per_head' | 'cost_plus' | 'fixed_total' | 'volume';
|
||||
|
||||
/** 增值税计税方式。 */
|
||||
export type VatMode = 'general' | 'simplified_diff';
|
||||
|
||||
/** 单个岗位(成本与产能的最小单元)。 */
|
||||
export interface PositionInput {
|
||||
/** 岗位名称。 */
|
||||
name: string;
|
||||
/** 人数。 */
|
||||
headcount: number;
|
||||
/** 应发工资(元/人/月)。 */
|
||||
monthlyGrossSalary: number;
|
||||
/** 社保缴费基数(元/人/月);缺省取应发工资。 */
|
||||
socialInsuranceBase?: number;
|
||||
/** 公积金缴费基数(元/人/月);缺省取应发工资。 */
|
||||
housingFundBase?: number;
|
||||
/** 福利/补贴(元/人/月)。 */
|
||||
monthlyBenefits?: number;
|
||||
/**
|
||||
* 对客单价(元/人/月)。per_head/volume 为对客月单价;cost_plus 为人月单价。
|
||||
* per_head 缺省时可由 managementFeePerHeadMonth 推导(全成本 + 管理费)。
|
||||
*/
|
||||
unitPrice?: number;
|
||||
}
|
||||
|
||||
/** 盈利分析输入。 */
|
||||
export interface ProfitabilityInputs {
|
||||
/** 业务类型(决定默认计税方式等)。 */
|
||||
businessType: string;
|
||||
/** 适用地域(决定社保/公积金/税率默认)。 */
|
||||
region?: string;
|
||||
/** 报价模式。 */
|
||||
pricingModel: PricingModel;
|
||||
/** 合同周期(月),默认 12。 */
|
||||
contractMonths?: number;
|
||||
/** 岗位明细(≥1)。 */
|
||||
positions: readonly PositionInput[];
|
||||
|
||||
/** 派遣管理费制:管理费(元/人/月),用于 per_head 缺单价时推导对客单价。 */
|
||||
managementFeePerHeadMonth?: number;
|
||||
/** 成本加成率(小数),cost_plus 模式。 */
|
||||
markupRate?: number;
|
||||
/** 固定合同总额(含税,元),fixed_total 模式。 */
|
||||
contractTotal?: number;
|
||||
|
||||
/** 月流失率(小数),影响招聘/培训摊销与平均在职月数。 */
|
||||
attritionMonthlyRate?: number;
|
||||
/** 单次招聘成本(元/人)。 */
|
||||
recruitingCostPerHire?: number;
|
||||
/** 人均培训成本(元/人)。 */
|
||||
trainingCostPerHead?: number;
|
||||
/** 雇主责任/工伤补充险费率(按应发工资计,小数),默认 0.01。 */
|
||||
employerLiabilityInsuranceRate?: number;
|
||||
|
||||
/** 管理跨度(1 名管理人员管多少人)。 */
|
||||
managementSpan?: number;
|
||||
/** 管理人员全成本(元/人/月)。 */
|
||||
managementMonthlyCostPerManager?: number;
|
||||
|
||||
/** 账期(月),用于垫资测算。 */
|
||||
accountPeriodMonths?: number;
|
||||
/** 年化利率(小数),默认 0.06。 */
|
||||
annualInterestRate?: number;
|
||||
/** 期间费用率(占收入,小数),默认 0.05。 */
|
||||
periodExpenseRate?: number;
|
||||
/** 有效利用率(小数,BPO/服务外包产能),默认 1。 */
|
||||
utilizationRate?: number;
|
||||
|
||||
/** 计税方式;缺省按业务类型(劳务派遣→差额,其余→一般)。 */
|
||||
vatMode?: VatMode;
|
||||
|
||||
/** 风险分级(联动风险准备金计提)。 */
|
||||
riskGrade?: RiskGrade;
|
||||
|
||||
/** 坏账准备金计提比例(小数,按客户信用等级注入);缺省 0。 */
|
||||
badDebtRate?: number;
|
||||
|
||||
/** 费率覆盖(项目实际手填覆盖地域默认)。 */
|
||||
rateOverrides?: Partial<RegionRates>;
|
||||
}
|
||||
|
||||
/** 单岗位全口径人月成本拆解(元/人/月)。 */
|
||||
export interface PositionCostBreakdown {
|
||||
grossSalary: number;
|
||||
socialInsurance: number;
|
||||
housingFund: number;
|
||||
benefits: number;
|
||||
employerInsurance: number;
|
||||
recruitingAmortized: number;
|
||||
trainingAmortized: number;
|
||||
managementAllocated: number;
|
||||
/** 全口径人月成本合计。 */
|
||||
fullyLoaded: number;
|
||||
}
|
||||
|
||||
/** 单岗位汇总。 */
|
||||
export interface PositionResult {
|
||||
name: string;
|
||||
headcount: number;
|
||||
perHead: PositionCostBreakdown;
|
||||
/** 全成本加载系数 = 全口径/应发。 */
|
||||
loadingFactor: number;
|
||||
monthlyCost: number;
|
||||
monthlyRevenue: number;
|
||||
}
|
||||
|
||||
/** 盈亏平衡。 */
|
||||
export interface Breakeven {
|
||||
/** 盈亏平衡对客单价(per_head/volume,元/人/月)。 */
|
||||
unitPrice?: number;
|
||||
/** 盈亏平衡加成率(cost_plus)。 */
|
||||
markupRate?: number;
|
||||
/** 盈亏平衡利用率(cost_plus/volume,小数)。 */
|
||||
utilization?: number;
|
||||
}
|
||||
|
||||
/** 敏感性单行。 */
|
||||
export interface SensitivityRow {
|
||||
variable: string;
|
||||
baseNetMargin: number;
|
||||
shockedNetMargin: number;
|
||||
deltaPct: number;
|
||||
}
|
||||
|
||||
/** 现金流累计点(月度)。 */
|
||||
export interface CashflowPoint {
|
||||
/** 第几个月(含账期滞后)。 */
|
||||
month: number;
|
||||
/** 累计净现金(流入−流出)。 */
|
||||
cumulative: number;
|
||||
}
|
||||
|
||||
/** 现金流与垫资峰值(外包先垫付成本、账期后收款)。 */
|
||||
export interface Cashflow {
|
||||
/** 峰值垫资需求(合同期内累计净现金的最大负值绝对数)。 */
|
||||
maxAdvance: number;
|
||||
/** 峰值出现的月份。 */
|
||||
peakMonth: number;
|
||||
/** 累计现金流曲线。 */
|
||||
points: CashflowPoint[];
|
||||
}
|
||||
|
||||
/** 盈亏平衡 / 定价空间曲线单点。 */
|
||||
export interface MarginCurvePoint {
|
||||
/** 相对当前报价的价格系数(如 0.95 = −5%)。 */
|
||||
priceFactor: number;
|
||||
/** 对应对客单价(首个岗位口径,无单价概念时为 null)。 */
|
||||
unitPrice: number | null;
|
||||
/** 该价格下的月净利率。 */
|
||||
netMargin: number;
|
||||
}
|
||||
|
||||
/** 盈利分析结果。 */
|
||||
export interface ProfitabilityResult {
|
||||
businessType: string;
|
||||
pricingModel: PricingModel;
|
||||
region: string;
|
||||
contractMonths: number;
|
||||
vatMode: VatMode;
|
||||
|
||||
positions: PositionResult[];
|
||||
totalHeadcount: number;
|
||||
/** 加权全成本加载系数。 */
|
||||
loadingFactor: number;
|
||||
|
||||
/** 月度账(不含税口径的收入与利润)。 */
|
||||
monthly: {
|
||||
revenueGross: number;
|
||||
revenueNet: number;
|
||||
laborCost: number;
|
||||
managementCost: number;
|
||||
totalCost: number;
|
||||
grossProfit: number;
|
||||
grossMargin: number;
|
||||
periodExpense: number;
|
||||
financeCost: number;
|
||||
vat: number;
|
||||
surcharge: number;
|
||||
riskReserve: number;
|
||||
badDebtReserve: number;
|
||||
netProfit: number;
|
||||
netMargin: number;
|
||||
};
|
||||
|
||||
/** 合同期账。 */
|
||||
contract: {
|
||||
revenueGross: number;
|
||||
revenueNet: number;
|
||||
totalCost: number;
|
||||
grossProfit: number;
|
||||
netProfit: number;
|
||||
};
|
||||
|
||||
breakeven: Breakeven;
|
||||
sensitivity: SensitivityRow[];
|
||||
/** 现金流与垫资峰值。 */
|
||||
cashflow: Cashflow;
|
||||
/** 盈亏平衡 / 定价空间曲线(价格系数 → 净利率)。 */
|
||||
marginCurve: MarginCurvePoint[];
|
||||
/** 采用了默认值(智能体假设)的说明。 */
|
||||
assumptions: string[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 常量与工具
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const DEFAULT_CONTRACT_MONTHS = 12;
|
||||
const DEFAULT_INSURANCE_RATE = 0.01;
|
||||
const DEFAULT_ANNUAL_INTEREST = 0.06;
|
||||
const DEFAULT_PERIOD_EXPENSE_RATE = 0.05;
|
||||
const MONTHS_PER_YEAR = 12;
|
||||
|
||||
/** 风险分级 → 风险准备金计提比例(占收入,小数)。 */
|
||||
const RESERVE_RATE_BY_GRADE: Record<RiskGrade, number> = {
|
||||
低: 0,
|
||||
中: 0.02,
|
||||
高: 0.05,
|
||||
极高: 0.08,
|
||||
};
|
||||
|
||||
function round2(v: number): number {
|
||||
return Math.round((v + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
function defaultVatMode(businessType: string): VatMode {
|
||||
return businessType === '劳务派遣' ? 'simplified_diff' : 'general';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 核心计算(不含敏感性,供敏感性扰动复用以避免递归)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
interface CoreOutput {
|
||||
result: Omit<ProfitabilityResult, 'sensitivity' | 'marginCurve'>;
|
||||
}
|
||||
|
||||
function computeCore(inputs: ProfitabilityInputs): CoreOutput {
|
||||
const assumptions: string[] = [];
|
||||
const note = (cond: boolean, msg: string): void => {
|
||||
if (cond) assumptions.push(msg);
|
||||
};
|
||||
|
||||
const contractMonths = inputs.contractMonths ?? DEFAULT_CONTRACT_MONTHS;
|
||||
note(inputs.contractMonths === undefined, `合同周期默认 ${DEFAULT_CONTRACT_MONTHS} 个月`);
|
||||
|
||||
const baseRates = getRegionRates(inputs.region);
|
||||
const rates = mergeRates(baseRates, inputs.rateOverrides);
|
||||
const socialRate = totalSocialInsuranceRate(rates.socialInsurance);
|
||||
|
||||
const insuranceRate = inputs.employerLiabilityInsuranceRate ?? DEFAULT_INSURANCE_RATE;
|
||||
note(inputs.employerLiabilityInsuranceRate === undefined, `雇主责任险费率默认 ${(DEFAULT_INSURANCE_RATE * 100).toFixed(1)}%`);
|
||||
|
||||
const attrition = inputs.attritionMonthlyRate ?? 0;
|
||||
const avgTenureMonths = attrition > 0 ? 1 / attrition : contractMonths;
|
||||
const recruiting = inputs.recruitingCostPerHire ?? 0;
|
||||
const training = inputs.trainingCostPerHead ?? 0;
|
||||
|
||||
const mgmtSpan = inputs.managementSpan ?? 0;
|
||||
const mgmtCostPerMgr = inputs.managementMonthlyCostPerManager ?? 0;
|
||||
const mgmtPerHead = mgmtSpan > 0 ? mgmtCostPerMgr / mgmtSpan : 0;
|
||||
|
||||
const utilization = inputs.utilizationRate ?? 1;
|
||||
const periodRate = inputs.periodExpenseRate ?? DEFAULT_PERIOD_EXPENSE_RATE;
|
||||
note(inputs.periodExpenseRate === undefined, `期间费用率默认 ${(DEFAULT_PERIOD_EXPENSE_RATE * 100).toFixed(0)}%`);
|
||||
const annualInterest = inputs.annualInterestRate ?? DEFAULT_ANNUAL_INTEREST;
|
||||
const accountMonths = inputs.accountPeriodMonths ?? 0;
|
||||
|
||||
const vatMode = inputs.vatMode ?? defaultVatMode(inputs.businessType);
|
||||
const vatRate = vatMode === 'simplified_diff' ? rates.vatSimplifiedRate : rates.vatGeneralRate;
|
||||
|
||||
// —— 逐岗位全口径成本 ——
|
||||
const positions: PositionResult[] = [];
|
||||
let monthlyLaborCost = 0;
|
||||
let monthlyMgmtCost = 0;
|
||||
let monthlyPassthrough = 0; // 差额计税口径:工资+社保+公积金(代缴部分)
|
||||
let totalHeadcount = 0;
|
||||
let weightedLoadingNum = 0;
|
||||
let weightedLoadingDen = 0;
|
||||
|
||||
for (const p of inputs.positions) {
|
||||
const gross = p.monthlyGrossSalary;
|
||||
const socialBase = p.socialInsuranceBase ?? gross;
|
||||
const housingBase = p.housingFundBase ?? gross;
|
||||
const benefits = p.monthlyBenefits ?? 0;
|
||||
|
||||
const social = socialBase * socialRate;
|
||||
const housing = housingBase * rates.housingFund;
|
||||
const employerInsurance = gross * insuranceRate;
|
||||
const recruitingAmort = avgTenureMonths > 0 ? recruiting / avgTenureMonths : 0;
|
||||
const trainingAmort = avgTenureMonths > 0 ? training / avgTenureMonths : 0;
|
||||
|
||||
const fullyLoaded =
|
||||
gross + social + housing + benefits + employerInsurance + recruitingAmort + trainingAmort + mgmtPerHead;
|
||||
|
||||
const perHead: PositionCostBreakdown = {
|
||||
grossSalary: round2(gross),
|
||||
socialInsurance: round2(social),
|
||||
housingFund: round2(housing),
|
||||
benefits: round2(benefits),
|
||||
employerInsurance: round2(employerInsurance),
|
||||
recruitingAmortized: round2(recruitingAmort),
|
||||
trainingAmortized: round2(trainingAmort),
|
||||
managementAllocated: round2(mgmtPerHead),
|
||||
fullyLoaded: round2(fullyLoaded),
|
||||
};
|
||||
|
||||
const monthlyCost = fullyLoaded * p.headcount;
|
||||
|
||||
// —— 收入(按报价模式,单岗位口径仅 per_head/volume/cost_plus(人月单价) 有意义)——
|
||||
const billableUnits = p.headcount * (inputs.pricingModel === 'per_head' ? 1 : utilization);
|
||||
let monthlyRevenue = 0;
|
||||
if (inputs.pricingModel === 'per_head' || inputs.pricingModel === 'volume') {
|
||||
const unit =
|
||||
p.unitPrice ??
|
||||
(inputs.managementFeePerHeadMonth !== undefined
|
||||
? fullyLoaded + inputs.managementFeePerHeadMonth
|
||||
: 0);
|
||||
monthlyRevenue = unit * billableUnits;
|
||||
} else if (inputs.pricingModel === 'cost_plus') {
|
||||
monthlyRevenue =
|
||||
inputs.markupRate !== undefined
|
||||
? monthlyCost * (1 + inputs.markupRate)
|
||||
: (p.unitPrice ?? 0) * billableUnits;
|
||||
}
|
||||
|
||||
positions.push({
|
||||
name: p.name,
|
||||
headcount: p.headcount,
|
||||
perHead,
|
||||
loadingFactor: gross > 0 ? round2(fullyLoaded / gross) : 0,
|
||||
monthlyCost: round2(monthlyCost),
|
||||
monthlyRevenue: round2(monthlyRevenue),
|
||||
});
|
||||
|
||||
monthlyLaborCost += (fullyLoaded - mgmtPerHead) * p.headcount;
|
||||
monthlyMgmtCost += mgmtPerHead * p.headcount;
|
||||
monthlyPassthrough += (gross + social + housing) * p.headcount;
|
||||
totalHeadcount += p.headcount;
|
||||
weightedLoadingNum += fullyLoaded * p.headcount;
|
||||
weightedLoadingDen += gross * p.headcount;
|
||||
}
|
||||
|
||||
const totalMonthlyCost = monthlyLaborCost + monthlyMgmtCost;
|
||||
|
||||
// —— 收入汇总(含税) ——
|
||||
let revenueGrossMonthly: number;
|
||||
if (inputs.pricingModel === 'fixed_total') {
|
||||
const total = inputs.contractTotal ?? 0;
|
||||
note(inputs.contractTotal === undefined, '固定总价缺失,收入按 0 计');
|
||||
revenueGrossMonthly = contractMonths > 0 ? total / contractMonths : 0;
|
||||
} else {
|
||||
revenueGrossMonthly = positions.reduce((s, p) => s + p.monthlyRevenue, 0);
|
||||
}
|
||||
|
||||
// —— 增值税及附加 ——
|
||||
let vat: number;
|
||||
if (vatMode === 'simplified_diff') {
|
||||
const taxBase = Math.max(0, revenueGrossMonthly - monthlyPassthrough);
|
||||
vat = (taxBase / (1 + vatRate)) * vatRate;
|
||||
} else {
|
||||
vat = (revenueGrossMonthly / (1 + vatRate)) * vatRate;
|
||||
}
|
||||
const surcharge = vat * rates.surchargeRate;
|
||||
const revenueNetMonthly = revenueGrossMonthly - (vatMode === 'general' ? vat : 0);
|
||||
|
||||
// —— 毛利 / 净利 ——
|
||||
const grossProfit = revenueNetMonthly - totalMonthlyCost;
|
||||
const periodExpense = revenueNetMonthly * periodRate;
|
||||
const advancePrincipal = totalMonthlyCost * accountMonths;
|
||||
const financeCost = (advancePrincipal * annualInterest) / MONTHS_PER_YEAR;
|
||||
const reserveRate = inputs.riskGrade !== undefined ? RESERVE_RATE_BY_GRADE[inputs.riskGrade] : 0;
|
||||
const riskReserve = revenueNetMonthly * reserveRate;
|
||||
const badDebtRate = inputs.badDebtRate ?? 0;
|
||||
const badDebtReserve = revenueNetMonthly * badDebtRate;
|
||||
note(badDebtRate > 0, `坏账准备金按 ${(badDebtRate * 100).toFixed(1)}%(依客户信用等级)计提`);
|
||||
const netProfit = grossProfit - periodExpense - financeCost - surcharge - riskReserve - badDebtReserve;
|
||||
|
||||
const grossMargin = revenueNetMonthly > 0 ? grossProfit / revenueNetMonthly : 0;
|
||||
const netMargin = revenueNetMonthly > 0 ? netProfit / revenueNetMonthly : 0;
|
||||
|
||||
// —— 盈亏平衡 ——
|
||||
const breakeven: Breakeven = {};
|
||||
if (inputs.pricingModel === 'per_head' || inputs.pricingModel === 'volume') {
|
||||
// 求使单岗位净利=0 的对客单价(以全体加权成本为基础)。
|
||||
const denom = 1 - periodRate - reserveRate - badDebtRate - (vatMode === 'general' ? vatRate / (1 + vatRate) : 0) * rates.surchargeRate;
|
||||
const costPerHeadMonth = totalHeadcount > 0 ? totalMonthlyCost / totalHeadcount : 0;
|
||||
const financePerHead = totalHeadcount > 0 ? financeCost / totalHeadcount : 0;
|
||||
if (denom > 0) {
|
||||
const revNetBE = (costPerHeadMonth + financePerHead) / denom;
|
||||
breakeven.unitPrice = round2(vatMode === 'general' ? revNetBE * (1 + vatRate) : revNetBE);
|
||||
}
|
||||
} else if (inputs.pricingModel === 'cost_plus') {
|
||||
// 求使净利=0 的加成率(近似:覆盖期间费用+财务+附加+准备金)。
|
||||
const overheadRate = periodRate + reserveRate + badDebtRate;
|
||||
breakeven.markupRate = round2(overheadRate / Math.max(0.0001, 1 - overheadRate));
|
||||
}
|
||||
|
||||
// 现金流与垫资峰值:成本按月流出,收款滞后账期月数流入;累计净现金的最低点即峰值垫资需求。
|
||||
const horizon = contractMonths + accountMonths;
|
||||
const cashPoints: CashflowPoint[] = [];
|
||||
let cum = 0;
|
||||
let maxAdvance = 0;
|
||||
let peakMonth = 0;
|
||||
for (let t = 1; t <= horizon; t += 1) {
|
||||
const outflow = t <= contractMonths ? totalMonthlyCost : 0;
|
||||
const serviceMonth = t - accountMonths;
|
||||
const inflow = serviceMonth >= 1 && serviceMonth <= contractMonths ? revenueGrossMonthly : 0;
|
||||
cum += inflow - outflow;
|
||||
cashPoints.push({ month: t, cumulative: round2(cum) });
|
||||
if (cum < -maxAdvance) {
|
||||
maxAdvance = -cum;
|
||||
peakMonth = t;
|
||||
}
|
||||
}
|
||||
const cashflow: Cashflow = { maxAdvance: round2(maxAdvance), peakMonth, points: cashPoints };
|
||||
|
||||
const result: Omit<ProfitabilityResult, 'sensitivity' | 'marginCurve'> = {
|
||||
businessType: inputs.businessType,
|
||||
pricingModel: inputs.pricingModel,
|
||||
region: rates.regionName,
|
||||
contractMonths,
|
||||
vatMode,
|
||||
positions,
|
||||
totalHeadcount,
|
||||
loadingFactor: weightedLoadingDen > 0 ? round2(weightedLoadingNum / weightedLoadingDen) : 0,
|
||||
monthly: {
|
||||
revenueGross: round2(revenueGrossMonthly),
|
||||
revenueNet: round2(revenueNetMonthly),
|
||||
laborCost: round2(monthlyLaborCost),
|
||||
managementCost: round2(monthlyMgmtCost),
|
||||
totalCost: round2(totalMonthlyCost),
|
||||
grossProfit: round2(grossProfit),
|
||||
grossMargin: round2(grossMargin * 10000) / 10000,
|
||||
periodExpense: round2(periodExpense),
|
||||
financeCost: round2(financeCost),
|
||||
vat: round2(vat),
|
||||
surcharge: round2(surcharge),
|
||||
riskReserve: round2(riskReserve),
|
||||
badDebtReserve: round2(badDebtReserve),
|
||||
netProfit: round2(netProfit),
|
||||
netMargin: round2(netMargin * 10000) / 10000,
|
||||
},
|
||||
contract: {
|
||||
revenueGross: round2(revenueGrossMonthly * contractMonths),
|
||||
revenueNet: round2(revenueNetMonthly * contractMonths),
|
||||
totalCost: round2(totalMonthlyCost * contractMonths),
|
||||
grossProfit: round2(grossProfit * contractMonths),
|
||||
netProfit: round2(netProfit * contractMonths),
|
||||
},
|
||||
breakeven,
|
||||
cashflow,
|
||||
assumptions,
|
||||
};
|
||||
|
||||
return { result };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 敏感性
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function netMarginOf(inputs: ProfitabilityInputs): number {
|
||||
return computeCore(inputs).result.monthly.netMargin;
|
||||
}
|
||||
|
||||
function buildSensitivity(inputs: ProfitabilityInputs, baseNetMargin: number): SensitivityRow[] {
|
||||
const rows: SensitivityRow[] = [];
|
||||
const add = (variable: string, shocked: ProfitabilityInputs): void => {
|
||||
const m = netMarginOf(shocked);
|
||||
rows.push({
|
||||
variable,
|
||||
baseNetMargin: Math.round(baseNetMargin * 1000) / 1000,
|
||||
shockedNetMargin: Math.round(m * 1000) / 1000,
|
||||
deltaPct: Math.round((m - baseNetMargin) * 1000) / 10, // 百分点,1 位小数
|
||||
});
|
||||
};
|
||||
|
||||
// 单价 −5%
|
||||
add('对客单价 −5%', {
|
||||
...inputs,
|
||||
positions: inputs.positions.map((p) => ({
|
||||
...p,
|
||||
...(p.unitPrice !== undefined ? { unitPrice: p.unitPrice * 0.95 } : {}),
|
||||
})),
|
||||
...(inputs.contractTotal !== undefined ? { contractTotal: inputs.contractTotal * 0.95 } : {}),
|
||||
...(inputs.managementFeePerHeadMonth !== undefined
|
||||
? { managementFeePerHeadMonth: inputs.managementFeePerHeadMonth * 0.95 }
|
||||
: {}),
|
||||
...(inputs.markupRate !== undefined ? { markupRate: inputs.markupRate - 0.05 } : {}),
|
||||
});
|
||||
|
||||
// 社保基数 +10%
|
||||
add('社保基数 +10%', {
|
||||
...inputs,
|
||||
positions: inputs.positions.map((p) => ({
|
||||
...p,
|
||||
socialInsuranceBase: (p.socialInsuranceBase ?? p.monthlyGrossSalary) * 1.1,
|
||||
})),
|
||||
});
|
||||
|
||||
// 月流失率 +5 个百分点
|
||||
add('月流失率 +5 个百分点', {
|
||||
...inputs,
|
||||
attritionMonthlyRate: (inputs.attritionMonthlyRate ?? 0) + 0.05,
|
||||
});
|
||||
|
||||
// 账期 +1 月
|
||||
add('账期 +1 个月', {
|
||||
...inputs,
|
||||
accountPeriodMonths: (inputs.accountPeriodMonths ?? 0) + 1,
|
||||
});
|
||||
|
||||
// 利用率 −10 个百分点
|
||||
add('利用率 −10 个百分点', {
|
||||
...inputs,
|
||||
utilizationRate: Math.max(0, (inputs.utilizationRate ?? 1) - 0.1),
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 盈亏平衡 / 定价空间曲线
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 按价格系数缩放收入相关输入(单价/加成/合同总额/管理费)。 */
|
||||
function scalePrice(inputs: ProfitabilityInputs, factor: number): ProfitabilityInputs {
|
||||
return {
|
||||
...inputs,
|
||||
positions: inputs.positions.map((p) => ({
|
||||
...p,
|
||||
...(p.unitPrice !== undefined ? { unitPrice: p.unitPrice * factor } : {}),
|
||||
})),
|
||||
...(inputs.contractTotal !== undefined ? { contractTotal: inputs.contractTotal * factor } : {}),
|
||||
...(inputs.managementFeePerHeadMonth !== undefined
|
||||
? { managementFeePerHeadMonth: inputs.managementFeePerHeadMonth * factor }
|
||||
: {}),
|
||||
...(inputs.markupRate !== undefined ? { markupRate: (1 + inputs.markupRate) * factor - 1 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建价格系数 → 净利率曲线(用于盈亏平衡与定价空间可视化)。 */
|
||||
function buildMarginCurve(inputs: ProfitabilityInputs): MarginCurvePoint[] {
|
||||
const factors = [0.85, 0.9, 0.95, 1, 1.05, 1.1, 1.15, 1.2];
|
||||
const baseUnit = inputs.positions[0]?.unitPrice;
|
||||
return factors.map((f) => ({
|
||||
priceFactor: f,
|
||||
unitPrice: baseUnit !== undefined ? round2(baseUnit * f) : null,
|
||||
netMargin: Math.round(netMarginOf(scalePrice(inputs, f)) * 10000) / 10000,
|
||||
}));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 公开入口
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* 盈利分析:收入(销售结算)+ 全口径成本 → 毛利/净利/盈亏平衡/敏感性/现金流 + 风险联动。
|
||||
*
|
||||
* @param inputs 盈利分析输入。
|
||||
* @returns 盈利分析结果(含敏感性、现金流与定价空间曲线)。
|
||||
*/
|
||||
export function analyzeProfitability(inputs: ProfitabilityInputs): ProfitabilityResult {
|
||||
const { result } = computeCore(inputs);
|
||||
const sensitivity = buildSensitivity(inputs, result.monthly.netMargin);
|
||||
const marginCurve = buildMarginCurve(inputs);
|
||||
return { ...result, sensitivity, marginCurve };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 分地域费率表:社保(单位部分各险种)、公积金、增值税及附加(Cost_Engine 盈利分析)。
|
||||
*
|
||||
* 设计:内置一套「全国近似默认(CN)」+ 若干地域覆盖(上海/北京/广东),并预留 `rateOverrides`
|
||||
* 让调用方按项目实际手填覆盖。所有费率为**行业近似默认值**,随地区与年度变化,**须经财务复核**,
|
||||
* 系统据此标注"智能体假设"。取数 {@link getRegionRates} 按地域回退到全国默认。
|
||||
*/
|
||||
|
||||
/** 社保单位部分各险种费率(小数)。 */
|
||||
export interface SocialInsuranceRates {
|
||||
/** 养老(单位)。 */
|
||||
pension: number;
|
||||
/** 医疗(单位,含生育并轨地区可合并)。 */
|
||||
medical: number;
|
||||
/** 失业(单位)。 */
|
||||
unemployment: number;
|
||||
/** 工伤(单位,按行业风险浮动,取基准)。 */
|
||||
injury: number;
|
||||
/** 生育(单位;与医疗并轨地区填 0)。 */
|
||||
maternity: number;
|
||||
}
|
||||
|
||||
/** 某地域的完整费率参数。 */
|
||||
export interface RegionRates {
|
||||
/** 地域名(展示用)。 */
|
||||
regionName: string;
|
||||
/** 社保单位部分各险种费率。 */
|
||||
socialInsurance: SocialInsuranceRates;
|
||||
/** 公积金单位部分比例(小数)。 */
|
||||
housingFund: number;
|
||||
/** 增值税一般计税税率(现代服务业,小数)。 */
|
||||
vatGeneralRate: number;
|
||||
/** 增值税简易/差额计税征收率(如劳务派遣差额,小数)。 */
|
||||
vatSimplifiedRate: number;
|
||||
/** 附加税费合计(占增值税额的比例:城建+教育附加+地方教育附加,小数)。 */
|
||||
surchargeRate: number;
|
||||
}
|
||||
|
||||
/** 社保单位部分合计费率。 */
|
||||
export function totalSocialInsuranceRate(s: SocialInsuranceRates): number {
|
||||
return s.pension + s.medical + s.unemployment + s.injury + s.maternity;
|
||||
}
|
||||
|
||||
/** 全国近似默认费率(CN)。须经财务按属地与年度复核。 */
|
||||
export const NATIONAL_DEFAULT_RATES: RegionRates = {
|
||||
regionName: '全国(默认)',
|
||||
socialInsurance: {
|
||||
pension: 0.16,
|
||||
medical: 0.095,
|
||||
unemployment: 0.005,
|
||||
injury: 0.004,
|
||||
maternity: 0.01,
|
||||
},
|
||||
housingFund: 0.07,
|
||||
vatGeneralRate: 0.06,
|
||||
vatSimplifiedRate: 0.05,
|
||||
surchargeRate: 0.12,
|
||||
};
|
||||
|
||||
/** 地域费率覆盖表(仅列与全国默认有差异的地域;其余回退全国默认)。 */
|
||||
export const REGION_RATES: Readonly<Record<string, RegionRates>> = {
|
||||
CN: NATIONAL_DEFAULT_RATES,
|
||||
上海: {
|
||||
regionName: '上海',
|
||||
socialInsurance: { pension: 0.16, medical: 0.1, unemployment: 0.005, injury: 0.0026, maternity: 0.01 },
|
||||
housingFund: 0.07,
|
||||
vatGeneralRate: 0.06,
|
||||
vatSimplifiedRate: 0.05,
|
||||
surchargeRate: 0.12,
|
||||
},
|
||||
北京: {
|
||||
regionName: '北京',
|
||||
socialInsurance: { pension: 0.16, medical: 0.09, unemployment: 0.005, injury: 0.002, maternity: 0.008 },
|
||||
housingFund: 0.12,
|
||||
vatGeneralRate: 0.06,
|
||||
vatSimplifiedRate: 0.05,
|
||||
surchargeRate: 0.12,
|
||||
},
|
||||
广东: {
|
||||
regionName: '广东',
|
||||
socialInsurance: { pension: 0.14, medical: 0.045, unemployment: 0.008, injury: 0.0016, maternity: 0.01 },
|
||||
housingFund: 0.05,
|
||||
vatGeneralRate: 0.06,
|
||||
vatSimplifiedRate: 0.05,
|
||||
surchargeRate: 0.12,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 按地域取费率;未知地域回退全国默认。地域名做包含匹配(如"上海市"命中"上海")。
|
||||
*/
|
||||
export function getRegionRates(region: string | undefined): RegionRates {
|
||||
if (region === undefined || region.trim() === '') {
|
||||
return NATIONAL_DEFAULT_RATES;
|
||||
}
|
||||
const key = Object.keys(REGION_RATES).find((k) => k !== 'CN' && region.includes(k));
|
||||
return key !== undefined ? REGION_RATES[key] ?? NATIONAL_DEFAULT_RATES : NATIONAL_DEFAULT_RATES;
|
||||
}
|
||||
|
||||
/** 以覆盖项合并费率(用于项目实际手填覆盖默认)。 */
|
||||
export function mergeRates(base: RegionRates, override?: Partial<RegionRates>): RegionRates {
|
||||
if (override === undefined) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
socialInsurance: { ...base.socialInsurance, ...(override.socialInsurance ?? {}) },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Data_Provenance 单调性属性测试(Req 3.7)。
|
||||
*
|
||||
* Property 16: 智能体假设标注单调永久 —— 对任意已被标注为"智能体假设"的数据点,
|
||||
* 施加任意后续补充、更新或重算操作序列后,其 Data_Provenance 恒保持"智能体假设",
|
||||
* 不被改回其他取值。
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { DATA_PROVENANCE_VALUES, type DataProvenance } from '../common.js';
|
||||
import {
|
||||
ASSUMED_PROVENANCE,
|
||||
isAssumption,
|
||||
transitionProvenance,
|
||||
} from '../provenance.js';
|
||||
|
||||
/** 任意合法 DataProvenance 取值的生成器。 */
|
||||
const provenanceArb: fc.Arbitrary<DataProvenance> = fc.constantFrom(
|
||||
...DATA_PROVENANCE_VALUES,
|
||||
);
|
||||
|
||||
describe('Property 16: 智能体假设标注单调永久 (Req 3.7)', () => {
|
||||
// Feature: outsourcing-risk-assessment, Property 16: 智能体假设标注单调永久
|
||||
it('对任意已标注"智能体假设"的数据点,施加任意后续转移序列后恒保持"智能体假设"', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// 一旦标注为"智能体假设",随后是任意长度的后续转移意图序列。
|
||||
fc.array(provenanceArb, { maxLength: 20 }),
|
||||
(subsequentTransitions) => {
|
||||
// 起点:数据点已被标注为"智能体假设"。
|
||||
let current: DataProvenance = ASSUMED_PROVENANCE;
|
||||
|
||||
// 施加任意后续补充/更新/重算操作序列。
|
||||
for (const next of subsequentTransitions) {
|
||||
current = transitionProvenance(current, next);
|
||||
// 在每一步之后标注都必须仍为"智能体假设",从不被改回其他取值。
|
||||
expect(current).toBe(ASSUMED_PROVENANCE);
|
||||
expect(isAssumption(current)).toBe(true);
|
||||
}
|
||||
|
||||
// 序列结束后仍恒为"智能体假设"。
|
||||
return current === ASSUMED_PROVENANCE;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// Feature: outsourcing-risk-assessment, Property 16: 智能体假设标注单调永久
|
||||
it('从任意初始来源出发,一旦经历"智能体假设"标注则其后恒不可逆', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
provenanceArb,
|
||||
fc.array(provenanceArb, { maxLength: 20 }),
|
||||
(initial, transitions) => {
|
||||
let current = initial;
|
||||
let everAssumed = isAssumption(current);
|
||||
|
||||
for (const next of transitions) {
|
||||
current = transitionProvenance(current, next);
|
||||
if (next === ASSUMED_PROVENANCE) {
|
||||
everAssumed = true;
|
||||
}
|
||||
// 不变式:只要曾经标注为"智能体假设",此后恒保持"智能体假设"。
|
||||
if (everAssumed) {
|
||||
expect(current).toBe(ASSUMED_PROVENANCE);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 评估、可解释性、热力图与关键风险实体(Req 4-10, 17, 18)。
|
||||
*
|
||||
* Assessment 是一次完整评估记录;ScoringItem 承载可解释性三要素与来源/置信标注;
|
||||
* RedlineResult 记录红线校验结论;HeatmapCell / RiskItem 支撑可视化与关键风险排序。
|
||||
*/
|
||||
|
||||
import type {
|
||||
BusinessType,
|
||||
Confidence,
|
||||
DataProvenance,
|
||||
Industry,
|
||||
RiskGrade,
|
||||
RiskLevel,
|
||||
RiskScore,
|
||||
} from './common.js';
|
||||
import type { CostEstimate } from './cost.js';
|
||||
import type { RiskModel } from './model.js';
|
||||
import type { Region } from './region.js';
|
||||
|
||||
/**
|
||||
* 可接受性结论(Req 9.1)。
|
||||
* 决策表:红线命中→不可接受;未命中时 低/中→可接受、高→有条件接受、极高→不可接受。
|
||||
*/
|
||||
export type Acceptability = '可接受' | '有条件接受' | '不可接受';
|
||||
|
||||
/** Acceptability 的全部取值。 */
|
||||
export const ACCEPTABILITY_VALUES = ['可接受', '有条件接受', '不可接受'] as const;
|
||||
|
||||
/**
|
||||
* 红线校验状态(Req 6.1, 6.5)。
|
||||
* 数据缺失或来源为"智能体假设"以致无法判定时标"待核实",不计命中。
|
||||
*/
|
||||
export type RedlineStatus = '命中' | '未命中' | '待核实';
|
||||
|
||||
/** RedlineStatus 的全部取值。 */
|
||||
export const REDLINE_STATUS_VALUES = ['命中', '未命中', '待核实'] as const;
|
||||
|
||||
/**
|
||||
* 评分项(ScoringItem,Req 4.1, 4.6, 18.1-18.4)。
|
||||
* 每个评分项可追溯到维度、指标、评分规则、数据点取值、来源与置信度。
|
||||
*/
|
||||
export interface ScoringItem {
|
||||
/** 所属维度标识。 */
|
||||
dimensionId: string;
|
||||
/** 所属指标标识。 */
|
||||
indicatorId: string;
|
||||
/** 单项风险等级(1 至 5)。 */
|
||||
riskLevel: RiskLevel;
|
||||
/** 评分项得分 = riskLevel × indicatorWeight(Req 4.1)。 */
|
||||
score: number;
|
||||
/** 数据来源标注(Req 4.6, 18.4)。 */
|
||||
provenance: DataProvenance;
|
||||
/** 置信度,取值 [0,1]、两位小数(Req 4.6, 18.4)。 */
|
||||
confidence: Confidence;
|
||||
/** 非空判定依据:引用 Dimension/Indicator/ScoringRule 及数据点取值(Req 18.1)。 */
|
||||
rationale: string;
|
||||
/** 非空风险影响说明(Req 18.2)。 */
|
||||
riskImpact: string;
|
||||
/** 非空建议(Req 18.3)。 */
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 红线校验结果(RedlineResult,Req 6.1, 6.3, 6.5)。
|
||||
*/
|
||||
export interface RedlineResult {
|
||||
/** 对应红线标识。 */
|
||||
redlineId: string;
|
||||
/** 校验状态:命中 / 未命中 / 待核实。 */
|
||||
status: RedlineStatus;
|
||||
/** 被触发的条件描述(命中时给出,Req 6.3)。 */
|
||||
triggeredCondition?: string;
|
||||
/** 对应判定依据数据(命中时给出,Req 6.3;待核实时说明无法判定原因,Req 6.5)。 */
|
||||
evidenceData?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 热力图单元格(HeatmapCell,Req 7.1)。
|
||||
* 以 Dimension 为行、Indicator 为列、Risk_Level(1-5)为严重度。
|
||||
*/
|
||||
export interface HeatmapCell {
|
||||
/** 行:所属维度标识。 */
|
||||
dimensionId: string;
|
||||
/** 列:指标标识。 */
|
||||
indicatorId: string;
|
||||
/** 严重度:风险等级(1 至 5)。 */
|
||||
riskLevel: RiskLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键风险项(RiskItem,Req 7.2-7.5)。Top N 关键风险清单中的单项。
|
||||
*/
|
||||
export interface RiskItem {
|
||||
/** 所属维度标识。 */
|
||||
dimensionId: string;
|
||||
/** 指标标识。 */
|
||||
indicatorId: string;
|
||||
/** 评分项得分(排序主键,降序)。 */
|
||||
score: number;
|
||||
/** 判定依据(Req 7.5)。 */
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assessment 元数据(Req 17.1)。检索与组合看板的索引字段。
|
||||
*/
|
||||
export interface AssessmentMetadata {
|
||||
/** 业务类型。 */
|
||||
businessType: BusinessType;
|
||||
/** 行业。 */
|
||||
industry: Industry;
|
||||
/** 采用的地域。 */
|
||||
region: Region;
|
||||
/** 风险总分。 */
|
||||
riskScore: RiskScore;
|
||||
/** 风险分级。 */
|
||||
riskGrade: RiskGrade;
|
||||
/** 创建时间(ISO 8601 字符串)。 */
|
||||
createdAt: string;
|
||||
/** 评估者身份标识。 */
|
||||
assessorId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次完整的项目风险评估记录(Assessment,Req 17.1)。
|
||||
*
|
||||
* 字段在评估流程推进中逐步填充;评分前部分结果字段可能尚未产生,
|
||||
* 故 riskScore/riskGrade/costEstimate/acceptability/report 等以可选标注。
|
||||
*/
|
||||
export interface Assessment {
|
||||
/** 评估唯一标识。 */
|
||||
id: string;
|
||||
/** 项目描述原文。 */
|
||||
projectDescription: string;
|
||||
/** 确认后的业务类型。 */
|
||||
businessType: BusinessType;
|
||||
/** 确认后的行业。 */
|
||||
industry: Industry;
|
||||
/** 采用的地域;默认 CN(Req 16.4-16.5)。 */
|
||||
region: Region;
|
||||
/** 实例化的风险模型快照。 */
|
||||
riskModel: RiskModel;
|
||||
/** 评分项集合。 */
|
||||
scoringItems: ScoringItem[];
|
||||
/** 归一化风险总分(评分完成后产生)。 */
|
||||
riskScore?: RiskScore;
|
||||
/** 风险分级(评分完成后产生)。 */
|
||||
riskGrade?: RiskGrade;
|
||||
/** 红线校验结果集合。 */
|
||||
redlineResults: RedlineResult[];
|
||||
/** 费用测算结果(费用测算完成后产生)。 */
|
||||
costEstimate?: CostEstimate;
|
||||
/** 可接受性结论(策略完成后产生)。 */
|
||||
acceptability?: Acceptability;
|
||||
/** 评估元数据。 */
|
||||
metadata: AssessmentMetadata;
|
||||
/** 创建时间(ISO 8601 字符串)。 */
|
||||
createdAt: string;
|
||||
/** 评估者身份标识。 */
|
||||
assessorId: string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 配置变更审计实体(Req 12.4, 12.5)。
|
||||
*
|
||||
* 成功提交记录变更项;被拒绝请求记录原因。时间精确到秒。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 审计动作类型。
|
||||
* - 变更提交:成功提交的配置变更(Req 12.4)。
|
||||
* - 变更拒绝:被拒绝的配置修改请求(Req 12.5)。
|
||||
*/
|
||||
export type ConfigAuditAction = '变更提交' | '变更拒绝';
|
||||
|
||||
/** ConfigAuditAction 的全部取值。 */
|
||||
export const CONFIG_AUDIT_ACTION_VALUES = ['变更提交', '变更拒绝'] as const;
|
||||
|
||||
/**
|
||||
* 配置变更审计条目(ConfigAuditEntry,Req 12.4, 12.5)。
|
||||
*/
|
||||
export interface ConfigAuditEntry {
|
||||
/** 操作者身份标识。 */
|
||||
actorId: string;
|
||||
/** 时间戳,精确到秒(ISO 8601 字符串)。 */
|
||||
timestamp: string;
|
||||
/** 审计动作:变更提交 / 变更拒绝。 */
|
||||
action: ConfigAuditAction;
|
||||
/** 成功时记录发生变更的配置项标识(Req 12.4)。 */
|
||||
changedConfigKeys: string[];
|
||||
/** 拒绝时记录的原因(Req 12.5)。 */
|
||||
reason?: string;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 通用领域基础类型:枚举式联合类型、数值标量别名与来源/置信度标注。
|
||||
*
|
||||
* 这些类型贯穿全链路(分类 → 配置 → 评分 → 费用 → 策略 → 报告 → 持久化),
|
||||
* 是上层各引擎共享的词汇表。取值严格对齐 requirements.md 与 design.md。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 数据来源标注(三态)。
|
||||
* - 用户输入:评估者直接录入。
|
||||
* - 外部数据:经 External_Data_Adapter 成功获取。
|
||||
* - 智能体假设:追问耗尽或回退后仍缺失时采用行业默认值的标注。
|
||||
*
|
||||
* 不变式(Req 3.7):一旦标注为"智能体假设"则永久保留,不可被改回其他取值。
|
||||
*/
|
||||
export type DataProvenance = '用户输入' | '外部数据' | '智能体假设';
|
||||
|
||||
/** DataProvenance 的全部取值,便于运行时校验与遍历。 */
|
||||
export const DATA_PROVENANCE_VALUES = ['用户输入', '外部数据', '智能体假设'] as const;
|
||||
|
||||
/**
|
||||
* 五类外包业务类型(Req 1.1)。
|
||||
*/
|
||||
export type BusinessType =
|
||||
| '岗位外包'
|
||||
| '劳务派遣'
|
||||
| '业务/服务外包'
|
||||
| 'BPO'
|
||||
| '项目制外包';
|
||||
|
||||
/** BusinessType 的全部取值。 */
|
||||
export const BUSINESS_TYPE_VALUES = [
|
||||
'岗位外包',
|
||||
'劳务派遣',
|
||||
'业务/服务外包',
|
||||
'BPO',
|
||||
'项目制外包',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 行业标记;无法判定时取字面量 "未识别"(Req 1.2)。
|
||||
*/
|
||||
export type Industry = string;
|
||||
|
||||
/** 行业无法判定时的占位标记。 */
|
||||
export const INDUSTRY_UNRECOGNIZED = '未识别' as const;
|
||||
|
||||
/**
|
||||
* 单项风险等级,取值 1 至 5 的整数(Req 4.1)。
|
||||
*/
|
||||
export type RiskLevel = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
/** RiskLevel 的全部取值。 */
|
||||
export const RISK_LEVEL_VALUES = [1, 2, 3, 4, 5] as const;
|
||||
|
||||
/**
|
||||
* 归一化后的风险总分,取值范围 0 至 100 的整数(Req 4.3)。
|
||||
* 以别名标注语义;值域约束由 Scoring_Engine 保证。
|
||||
*/
|
||||
export type RiskScore = number;
|
||||
|
||||
/**
|
||||
* 风险分级(四级互斥且完备,Req 5)。
|
||||
* 区间约定:[0,25]→低、(25,50]→中、(50,75]→高、(75,100]→极高。
|
||||
*/
|
||||
export type RiskGrade = '低' | '中' | '高' | '极高';
|
||||
|
||||
/** RiskGrade 的全部取值(按严重度升序)。 */
|
||||
export const RISK_GRADE_VALUES = ['低', '中', '高', '极高'] as const;
|
||||
|
||||
/**
|
||||
* 权重,取值 0 至 100;同级启用项归一化后之和为 100%(Req 11.2)。
|
||||
*/
|
||||
export type Weight = number;
|
||||
|
||||
/**
|
||||
* 置信度,取值范围 [0, 1]、保留两位小数(Req 1.3, 4.6, 15.2, 18.4)。
|
||||
*/
|
||||
export type Confidence = number;
|
||||
|
||||
/**
|
||||
* 金额(货币标量,单位由上下文约定,首版为人民币元)。非负约束由 Cost_Engine 保证。
|
||||
*/
|
||||
export type Money = number;
|
||||
|
||||
/**
|
||||
* 数值区间,下界恒不大于上界(用于风险溢价区间等,Req 8.1)。
|
||||
*/
|
||||
export interface Range {
|
||||
/** 区间下界。 */
|
||||
lower: number;
|
||||
/** 区间上界,恒满足 upper ≥ lower。 */
|
||||
upper: number;
|
||||
/** 区间单位:金额或百分比(Req 8.1 风险溢价可为金额区间或百分比区间)。 */
|
||||
unit: '金额' | '百分比';
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 费用与定价测算实体(Req 8)。
|
||||
*
|
||||
* Cost_Engine 在评分完成后,依据 Risk_Grade 与风险评分输出可量化的费用/定价测算。
|
||||
*/
|
||||
|
||||
import type { DataProvenance, Money, Range } from './common.js';
|
||||
|
||||
/**
|
||||
* 费用拆解明细项(Req 8.2, 8.6)。每一项标注其所依据的输入项与费率/参数来源。
|
||||
*/
|
||||
export interface CostLineItem {
|
||||
/** 拆解项名称,如"垫资利息"。 */
|
||||
name: string;
|
||||
/** 该项测算金额(非负,Req 8.2)。 */
|
||||
amount: Money;
|
||||
/** 计算所依据的输入项标识/描述(Req 8.6)。 */
|
||||
basisInputs: string[];
|
||||
/** 所采用的费率或参数来源说明(Req 8.2)。 */
|
||||
rateSource: string;
|
||||
/** 该项输入的数据来源标注(缺失成本输入兜底为"智能体假设",Req 8.4)。 */
|
||||
provenance: DataProvenance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用测算结果(CostEstimate,Req 8.1-8.6)。
|
||||
*/
|
||||
export interface CostEstimate {
|
||||
/** 风险溢价加价区间:金额或百分比区间,下界≤上界且随分级单调(Req 8.1)。 */
|
||||
riskPremiumRange: Range;
|
||||
/** 垫资利息(非负)。 */
|
||||
advanceInterest: Money;
|
||||
/** 保险费用(非负)。 */
|
||||
insuranceCost: Money;
|
||||
/** 补偿准备金(非负)。 */
|
||||
compensationReserve: Money;
|
||||
/** 坏账准备金(非负)。 */
|
||||
badDebtReserve: Money;
|
||||
/** 基准报价。 */
|
||||
baselineQuote: Money;
|
||||
/** 风险调整后报价,恒不低于基准报价(Req 8.3)。 */
|
||||
riskAdjustedQuote: Money;
|
||||
/** 各项成本拆解,拆解之和与报价口径一致(Req 8.2, 8.3, 8.6)。 */
|
||||
breakdown: CostLineItem[];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 领域类型聚合导出。
|
||||
*
|
||||
* 按关注点拆分为多个模块(common / region / model / cost / assessment / knowledge / audit),
|
||||
* 统一从此处对外暴露,供上层各引擎(配置中心、评分、费用、策略、报告、持久化等)复用。
|
||||
*/
|
||||
|
||||
export * from './common.js';
|
||||
export * from './provenance.js';
|
||||
export * from './region.js';
|
||||
export * from './model.js';
|
||||
export * from './cost.js';
|
||||
export * from './assessment.js';
|
||||
export * from './knowledge.js';
|
||||
export * from './audit.js';
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 分行业知识库实体(Req 14)。
|
||||
*
|
||||
* 按行业标识分区存储;每个行业分区须包含五类必备内容,缺任一类拒绝创建分区。
|
||||
*/
|
||||
|
||||
import type { Industry } from './common.js';
|
||||
import type { Indicator, Redline, Template } from './model.js';
|
||||
|
||||
/**
|
||||
* 权重模板项:为某指标或维度预设的权重基线。
|
||||
*/
|
||||
export interface WeightTemplateEntry {
|
||||
/** 目标维度或指标标识。 */
|
||||
targetId: string;
|
||||
/** 预设权重值(0 至 100)。 */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 典型案例:行业内的参考评估案例。
|
||||
*/
|
||||
export interface CaseStudy {
|
||||
/** 案例唯一标识。 */
|
||||
id: string;
|
||||
/** 案例标题。 */
|
||||
title: string;
|
||||
/** 案例描述/要点。 */
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 追问话术项:行业相关的补全问题文案。
|
||||
*/
|
||||
export interface AskPrompt {
|
||||
/** 关联指标标识。 */
|
||||
indicatorId: string;
|
||||
/** 问题文案。 */
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行业分区(IndustryPartition,Req 14.1, 14.4)。
|
||||
* 五类必备内容:Indicator、权重模板、Redline、典型案例、追问话术;缺任一类非法。
|
||||
*/
|
||||
export interface IndustryPartition {
|
||||
/** 行业标识。 */
|
||||
industryId: Industry;
|
||||
/** 指标集合。 */
|
||||
indicators: Indicator[];
|
||||
/** 权重模板集合。 */
|
||||
weightTemplates: WeightTemplateEntry[];
|
||||
/** 红线集合。 */
|
||||
redlines: Redline[];
|
||||
/** 典型案例集合。 */
|
||||
cases: CaseStudy[];
|
||||
/** 追问话术集合。 */
|
||||
askPrompts: AskPrompt[];
|
||||
/** 该分区下的模板集合。 */
|
||||
templates: Template[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 分行业知识库(KnowledgeBase,Req 14.1)。
|
||||
* 无匹配行业分区时回退默认分区并标注未匹配(Req 14.5)。
|
||||
*/
|
||||
export interface KnowledgeBase {
|
||||
/** 行业分区集合。 */
|
||||
partitions: IndustryPartition[];
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 风险模型配置实体(Req 2, 4, 11, 14)。
|
||||
*
|
||||
* 三层结构:RiskModel → Dimension → Indicator → ScoringRule,外加 Redline 红线集合。
|
||||
* 指标体系以结构化配置存储,Scoring_Engine 读取任意合法配置即可完成评分(配置驱动)。
|
||||
*/
|
||||
|
||||
import type { BusinessType, Industry, RiskLevel, Weight } from './common.js';
|
||||
|
||||
/**
|
||||
* 评分项(Scoring_Rule):定义某一 Risk_Level 的判定标准(Req 11.3)。
|
||||
* 合法 Indicator 须覆盖 Risk_Level 1 至 5 全部级别。
|
||||
*/
|
||||
export interface ScoringRule {
|
||||
/** 该评分项对应的风险等级(1 至 5)。 */
|
||||
level: RiskLevel;
|
||||
/** 等级标签,如"低风险"。 */
|
||||
label: string;
|
||||
/** 判定标准描述。 */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 风险指标(Indicator,Req 11.1, 11.3)。可增删、启停、调权。
|
||||
*/
|
||||
export interface Indicator {
|
||||
/** 指标唯一标识(在所属 Dimension 内稳定)。 */
|
||||
id: string;
|
||||
/** 指标名称。 */
|
||||
name: string;
|
||||
/** 指标权重,取值 0 至 100;同级启用项归一化后之和为 100%。 */
|
||||
weight: Weight;
|
||||
/** 启用状态;停用保留配置但不计分(Req 11.1, 4.4)。 */
|
||||
enabled: boolean;
|
||||
/** 评分规则,覆盖 Risk_Level 1-5(长度应为 5,Req 11.3)。 */
|
||||
scoringRules: ScoringRule[];
|
||||
/** 证据要求描述:判定 Risk_Level 所需证据。 */
|
||||
evidenceRequired: string;
|
||||
/** 追问话术:信息缺口时向评估者提出的问题文案。 */
|
||||
askPrompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 风险维度(Dimension,Req 11.1)。可增删、启停、调权。
|
||||
*/
|
||||
export interface Dimension {
|
||||
/** 维度唯一标识。 */
|
||||
id: string;
|
||||
/** 维度名称,如"客户风险"。 */
|
||||
name: string;
|
||||
/** 维度权重,取值 0 至 100;同级启用项归一化后之和为 100%。 */
|
||||
weight: Weight;
|
||||
/** 启用状态;停用保留配置但不计分(Req 11.1, 4.4)。 */
|
||||
enabled: boolean;
|
||||
/** 维度下的指标集合。 */
|
||||
indicators: Indicator[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 红线规则(Redline,Req 6, 11.4)。命中即触发一票否决,独立于分值通道。
|
||||
*/
|
||||
export interface Redline {
|
||||
/** 红线唯一标识(Req 11.4,标识重复非法)。 */
|
||||
id: string;
|
||||
/** 触发条件(独立于 Risk_Score / Risk_Grade)。 */
|
||||
triggerCondition: string;
|
||||
/** 一票否决后果说明。 */
|
||||
consequence: string;
|
||||
/** 启用状态。 */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 风险模型(RiskModel,Req 2.3)。Template 实例化后的运行时快照。
|
||||
*/
|
||||
export interface RiskModel {
|
||||
/** 模型唯一标识。 */
|
||||
id: string;
|
||||
/** 模型名称。 */
|
||||
name: string;
|
||||
/** 适用业务类型。 */
|
||||
businessType: BusinessType;
|
||||
/** 风险维度集合。 */
|
||||
dimensions: Dimension[];
|
||||
/** 红线规则集合。 */
|
||||
redlines: Redline[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 风险模型模板(Template,Req 2.1-2.7)。按业务类型与行业组织,支持继承。
|
||||
*/
|
||||
export interface Template {
|
||||
/** 模板唯一标识。 */
|
||||
id: string;
|
||||
/** 模板名称。 */
|
||||
name: string;
|
||||
/** 适用业务类型。 */
|
||||
businessType: BusinessType;
|
||||
/** 适用行业。 */
|
||||
industry: Industry;
|
||||
/** 父模板标识;用于继承。环引用或继承层级 > 5 非法(Req 2.5, 2.7)。 */
|
||||
parentTemplateId?: string;
|
||||
/** 是否为该业务类型的默认模板(Req 2.2)。 */
|
||||
isDefault: boolean;
|
||||
/** 模板承载的风险模型配置(维度/指标/权重/评分规则/红线)。 */
|
||||
riskModelConfig: RiskModelConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板承载的风险模型配置主体。
|
||||
* 与 RiskModel 结构一致,但作为可继承/可覆盖的配置存在(尚未实例化为本次评估快照)。
|
||||
*/
|
||||
export interface RiskModelConfig {
|
||||
/** 模型名称。 */
|
||||
name: string;
|
||||
/** 适用业务类型。 */
|
||||
businessType: BusinessType;
|
||||
/** 风险维度集合。 */
|
||||
dimensions: Dimension[];
|
||||
/** 红线规则集合。 */
|
||||
redlines: Redline[];
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Data_Provenance 三态工具与 Confidence 值域约束。
|
||||
*
|
||||
* 贯穿全链路(分类 → 配置 → 评分 → 费用 → 策略 → 报告 → 持久化)的来源/置信度
|
||||
* 标注辅助函数。两条核心不变式在此集中实现,供上层各引擎复用:
|
||||
*
|
||||
* - 单调永久(Req 3.7):一旦某数据点的 Data_Provenance 被标注为"智能体假设",
|
||||
* 则在其后施加任意补充、更新或重算操作序列后恒保持"智能体假设",不可被改回
|
||||
* "用户输入"或"外部数据"。
|
||||
* - Confidence 值域(Req 4.6, 18.4):置信度恒落在区间 [0, 1] 内并保留两位小数。
|
||||
*/
|
||||
|
||||
import {
|
||||
type Confidence,
|
||||
type DataProvenance,
|
||||
DATA_PROVENANCE_VALUES,
|
||||
} from './common.js';
|
||||
|
||||
/** "智能体假设"来源标注常量(降级/兜底取值的语义标记)。 */
|
||||
export const ASSUMED_PROVENANCE = '智能体假设' as const;
|
||||
|
||||
/** Confidence 取值下界。 */
|
||||
export const CONFIDENCE_MIN = 0 as const;
|
||||
|
||||
/** Confidence 取值上界。 */
|
||||
export const CONFIDENCE_MAX = 1 as const;
|
||||
|
||||
/** Confidence 保留的小数位数。 */
|
||||
export const CONFIDENCE_DECIMALS = 2 as const;
|
||||
|
||||
/**
|
||||
* 运行时判断给定值是否为合法的 DataProvenance 取值。
|
||||
*
|
||||
* @param value 任意待校验值。
|
||||
* @returns 当且仅当 value 为三态取值之一时返回 true。
|
||||
*/
|
||||
export function isDataProvenance(value: unknown): value is DataProvenance {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(DATA_PROVENANCE_VALUES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某来源标注是否为"智能体假设"。
|
||||
*
|
||||
* @param provenance 来源标注。
|
||||
* @returns 当且仅当标注为"智能体假设"时返回 true。
|
||||
*/
|
||||
export function isAssumption(provenance: DataProvenance): boolean {
|
||||
return provenance === ASSUMED_PROVENANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算来源标注的转移结果,强制"智能体假设"单调永久(Req 3.7)。
|
||||
*
|
||||
* 不变式:若当前标注已为"智能体假设",则无论意图转移到何种取值,结果恒为
|
||||
* "智能体假设";否则按意图转移到 `next`。该函数为纯函数、幂等且无副作用,
|
||||
* 可安全用于任意补充/更新/重算操作序列。
|
||||
*
|
||||
* @param current 当前来源标注。
|
||||
* @param next 意图转移到的来源标注。
|
||||
* @returns 应用单调永久约束后的来源标注。
|
||||
* @throws {TypeError} 当 `current` 或 `next` 不是合法的 DataProvenance 取值时。
|
||||
*/
|
||||
export function transitionProvenance(
|
||||
current: DataProvenance,
|
||||
next: DataProvenance,
|
||||
): DataProvenance {
|
||||
if (!isDataProvenance(current)) {
|
||||
throw new TypeError(`非法的当前 Data_Provenance 取值:${String(current)}`);
|
||||
}
|
||||
if (!isDataProvenance(next)) {
|
||||
throw new TypeError(`非法的目标 Data_Provenance 取值:${String(next)}`);
|
||||
}
|
||||
|
||||
// 单调永久:一旦标注为"智能体假设",不可被改回其他取值。
|
||||
return current === ASSUMED_PROVENANCE ? ASSUMED_PROVENANCE : next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数据点标注/重标注为"智能体假设"(兜底降级时调用)。
|
||||
*
|
||||
* 等价于 `transitionProvenance(current, '智能体假设')`,且由于目标即为
|
||||
* "智能体假设",结果恒为"智能体假设",与 current 无关。
|
||||
*
|
||||
* @param current 当前来源标注(用于运行时校验)。
|
||||
* @returns 恒为"智能体假设"。
|
||||
* @throws {TypeError} 当 `current` 不是合法的 DataProvenance 取值时。
|
||||
*/
|
||||
export function markAsAssumption(current: DataProvenance): DataProvenance {
|
||||
return transitionProvenance(current, ASSUMED_PROVENANCE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断给定数值是否为合法的 Confidence:有限实数且落在 [0, 1] 区间内。
|
||||
*
|
||||
* 注意:本函数仅校验值域,不校验小数位数(小数位由 {@link normalizeConfidence}
|
||||
* 负责规整)。
|
||||
*
|
||||
* @param value 任意待校验数值。
|
||||
* @returns 当且仅当 value 为 [0, 1] 内的有限数时返回 true。
|
||||
*/
|
||||
export function isValidConfidence(value: number): boolean {
|
||||
return (
|
||||
typeof value === 'number' &&
|
||||
Number.isFinite(value) &&
|
||||
value >= CONFIDENCE_MIN &&
|
||||
value <= CONFIDENCE_MAX
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规整任意有限数为合法 Confidence:先夹取到 [0, 1],再四舍五入到两位小数(Req 4.6, 18.4)。
|
||||
*
|
||||
* 该函数保证输出恒满足 Confidence 值域约束,可作为所有引擎产出置信度前的统一出口。
|
||||
*
|
||||
* @param value 原始置信度数值。
|
||||
* @returns 落在 [0, 1] 内且保留两位小数的 Confidence。
|
||||
* @throws {RangeError} 当 value 非有限数(NaN / ±Infinity)时。
|
||||
*/
|
||||
export function normalizeConfidence(value: number): Confidence {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new RangeError(`Confidence 必须为有限数,收到:${String(value)}`);
|
||||
}
|
||||
|
||||
const clamped = Math.min(CONFIDENCE_MAX, Math.max(CONFIDENCE_MIN, value));
|
||||
const factor = 10 ** CONFIDENCE_DECIMALS;
|
||||
// 加上极小 epsilon 抵消二进制浮点误差,确保 .005 这类边界稳定向上取整。
|
||||
const rounded = Math.round((clamped + Number.EPSILON) * factor) / factor;
|
||||
|
||||
// 再次夹取,防止四舍五入后因浮点表示极端越界。
|
||||
return Math.min(CONFIDENCE_MAX, Math.max(CONFIDENCE_MIN, rounded));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 地域参数与地域化合规规则集(Req 16)。
|
||||
*
|
||||
* Region 标识本次 Assessment 适用的合规规则集;ComplianceRuleSet 按 Region 加载。
|
||||
* 首版实现中国大陆(CN)规则;跨境通过新增 ComplianceRuleSet 扩展而不改判定引擎。
|
||||
*/
|
||||
|
||||
import type { Money } from './common.js';
|
||||
|
||||
/**
|
||||
* 地域参数(Req 16.4-16.5)。首版取值为中国大陆(CN)。
|
||||
*/
|
||||
export interface Region {
|
||||
/** 地域代码,如 'CN'。 */
|
||||
code: string;
|
||||
/** 地域名称,如 '中国大陆'。 */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** 中国大陆地域常量(系统默认 Region,Req 16.5)。 */
|
||||
export const REGION_CN: Region = { code: 'CN', name: '中国大陆' };
|
||||
|
||||
/**
|
||||
* 社保缴费基数规则。
|
||||
*/
|
||||
export interface SocialInsuranceBaseRule {
|
||||
/** 社保缴费基数下限(Req 16.1)。 */
|
||||
lowerBound: Money;
|
||||
}
|
||||
|
||||
/**
|
||||
* 经济补偿规则(N 与 N+1,Req 16.1-16.2)。
|
||||
* 以工作年限折算月数:N 规则与 N+1 规则各自的附加月数。
|
||||
*/
|
||||
export interface EconomicCompensationRule {
|
||||
/** N 规则:按工作年限折算的补偿月数系数描述/取值。 */
|
||||
nRule: string;
|
||||
/** N+1 规则:在 N 基础上额外增加的月数。 */
|
||||
nPlusOneRule: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当地最低工资标准,按地区/城市标识映射到月最低工资金额(Req 16.1)。
|
||||
*/
|
||||
export interface MinimumWageRule {
|
||||
/** 按地区标识到最低工资金额的映射。 */
|
||||
byLocality: Record<string, Money>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 地域化合规规则集(Req 16.1)。
|
||||
* Region 无对应规则集时拒绝合规判定/费用测算并提示暂不支持(Req 16.6)。
|
||||
*/
|
||||
export interface ComplianceRuleSet {
|
||||
/** 适用地域。 */
|
||||
region: Region;
|
||||
/** 社保缴费基数下限规则。 */
|
||||
socialInsuranceBase: SocialInsuranceBaseRule;
|
||||
/** 经济补偿 N / N+1 规则。 */
|
||||
economicCompensation: EconomicCompensationRule;
|
||||
/** 劳务派遣用工比例上限(中国大陆为 0.10,即 10%)。 */
|
||||
dispatchRatioCap: number;
|
||||
/** 当地最低工资标准。 */
|
||||
minimumWage: MinimumWageRule;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 外包项目风险评估 AI 智能体 - 包入口。
|
||||
*
|
||||
* 核心领域类型已在 `src/domain` 下按关注点拆分定义并从此处统一导出。
|
||||
* 配置中心、评分引擎等模块将在后续任务中添加并从此处导出。
|
||||
*/
|
||||
|
||||
/** 当前实现版本号。 */
|
||||
export const VERSION = '0.1.0';
|
||||
|
||||
export * from './domain/index.js';
|
||||
export * from './classifier/index.js';
|
||||
export * from './compliance/index.js';
|
||||
export * from './config/index.js';
|
||||
export * from './scoring/index.js';
|
||||
export * from './cost/index.js';
|
||||
export * from './strategy/index.js';
|
||||
export * from './report/index.js';
|
||||
export * from './question/index.js';
|
||||
export * from './persistence/index.js';
|
||||
export * from './adapters/index.js';
|
||||
export * from './knowledge/index.js';
|
||||
export * from './rbac/index.js';
|
||||
export * from './orchestrator/index.js';
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Property 50: 行业分区内容完备性校验 的属性化测试(Knowledge_Base,Req 14.1, 14.4)。
|
||||
*
|
||||
* 属性陈述:对任意行业分区,合法分区必包含 Indicator、权重模板、Redline、典型案例、
|
||||
* 追问话术全部五类内容;对任意缺少其中任一类内容的新增分区请求,System 必拒绝创建、
|
||||
* 返回指明缺失内容类别的校验错误,并保持已有分区不变。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 50: 行业分区内容完备性校验
|
||||
*
|
||||
* Validates: Requirements 14.1, 14.4
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import type {
|
||||
AskPrompt,
|
||||
CaseStudy,
|
||||
IndustryPartition,
|
||||
WeightTemplateEntry,
|
||||
} from '../../domain/knowledge.js';
|
||||
import type { Indicator, Redline, Template } from '../../domain/model.js';
|
||||
import {
|
||||
IncompletePartitionError,
|
||||
KnowledgeBaseStore,
|
||||
PARTITION_CATEGORY_VALUES,
|
||||
findMissingCategories,
|
||||
type PartitionCategory,
|
||||
} from '../index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:非空集合(保证某类别"齐备")。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const indicatorArb: fc.Arbitrary<Indicator> = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
weight: fc.double({ min: 0, max: 100, noNaN: true }),
|
||||
enabled: fc.boolean(),
|
||||
scoringRules: fc.constant([]),
|
||||
evidenceRequired: fc.string({ maxLength: 8 }),
|
||||
askPrompt: fc.string({ maxLength: 8 }),
|
||||
});
|
||||
|
||||
const weightTemplateArb: fc.Arbitrary<WeightTemplateEntry> = fc.record({
|
||||
targetId: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
weight: fc.double({ min: 0, max: 100, noNaN: true }),
|
||||
});
|
||||
|
||||
const redlineArb: fc.Arbitrary<Redline> = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
triggerCondition: fc.string({ maxLength: 8 }),
|
||||
consequence: fc.string({ maxLength: 8 }),
|
||||
enabled: fc.boolean(),
|
||||
});
|
||||
|
||||
const caseArb: fc.Arbitrary<CaseStudy> = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
title: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
summary: fc.string({ maxLength: 8 }),
|
||||
});
|
||||
|
||||
const askPromptArb: fc.Arbitrary<AskPrompt> = fc.record({
|
||||
indicatorId: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
prompt: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
});
|
||||
|
||||
const templatesArb: fc.Arbitrary<Template[]> = fc.constant([]);
|
||||
|
||||
/** 行业标识生成器(非空、规范化后稳定)。 */
|
||||
const industryArb: fc.Arbitrary<string> = fc
|
||||
.string({ minLength: 1, maxLength: 10 })
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
/**
|
||||
* 生成一个五类内容齐备的合法行业分区。
|
||||
* 每个集合至少含 1 项,确保 findMissingCategories 视其为齐备。
|
||||
*/
|
||||
function completePartitionArb(
|
||||
industry: fc.Arbitrary<string> = industryArb,
|
||||
): fc.Arbitrary<IndustryPartition> {
|
||||
return fc.record({
|
||||
industryId: industry,
|
||||
indicators: fc.array(indicatorArb, { minLength: 1, maxLength: 3 }),
|
||||
weightTemplates: fc.array(weightTemplateArb, { minLength: 1, maxLength: 3 }),
|
||||
redlines: fc.array(redlineArb, { minLength: 1, maxLength: 3 }),
|
||||
cases: fc.array(caseArb, { minLength: 1, maxLength: 3 }),
|
||||
askPrompts: fc.array(askPromptArb, { minLength: 1, maxLength: 3 }),
|
||||
templates: templatesArb,
|
||||
});
|
||||
}
|
||||
|
||||
/** 类别 → 承载字段名 的映射,用于针对性置空。 */
|
||||
const CATEGORY_FIELD: Record<PartitionCategory, keyof IndustryPartition> = {
|
||||
Indicator: 'indicators',
|
||||
权重模板: 'weightTemplates',
|
||||
Redline: 'redlines',
|
||||
典型案例: 'cases',
|
||||
追问话术: 'askPrompts',
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Property 50
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 50: 行业分区内容完备性校验 (Req 14.1, 14.4)', () => {
|
||||
// Feature: outsourcing-risk-assessment, Property 50: 行业分区内容完备性校验
|
||||
it('五类齐备的合法分区必被接受', () => {
|
||||
fc.assert(
|
||||
fc.property(completePartitionArb(), (partition) => {
|
||||
const store = new KnowledgeBaseStore();
|
||||
expect(findMissingCategories(partition)).toEqual([]);
|
||||
expect(() => store.addPartition(partition)).not.toThrow();
|
||||
expect(store.has(partition.industryId)).toBe(true);
|
||||
expect(store.size).toBe(1);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
// Feature: outsourcing-risk-assessment, Property 50: 行业分区内容完备性校验
|
||||
it('缺任一类的分区请求必被拒绝并指明缺失类别', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
completePartitionArb(),
|
||||
// 至少置空一类(非空的类别子集)。
|
||||
fc
|
||||
.subarray([...PARTITION_CATEGORY_VALUES], { minLength: 1 })
|
||||
.map((arr) => arr as PartitionCategory[]),
|
||||
(complete, categoriesToEmpty) => {
|
||||
const broken: IndustryPartition = { ...complete };
|
||||
for (const category of categoriesToEmpty) {
|
||||
broken[CATEGORY_FIELD[category]] = [] as never;
|
||||
}
|
||||
|
||||
const store = new KnowledgeBaseStore();
|
||||
let thrown: unknown;
|
||||
try {
|
||||
store.addPartition(broken);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
|
||||
// 必拒绝并抛出 IncompletePartitionError。
|
||||
expect(thrown).toBeInstanceOf(IncompletePartitionError);
|
||||
const error = thrown as IncompletePartitionError;
|
||||
// 错误必指明全部被置空(缺失)的类别。
|
||||
expect(new Set(error.missingCategories)).toEqual(
|
||||
new Set(categoriesToEmpty),
|
||||
);
|
||||
for (const category of categoriesToEmpty) {
|
||||
expect(error.userMessage).toContain(category);
|
||||
}
|
||||
// 拒绝创建:分区未被登记。
|
||||
expect(store.has(broken.industryId)).toBe(false);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
// Feature: outsourcing-risk-assessment, Property 50: 行业分区内容完备性校验
|
||||
it('校验失败时保持已有分区不变', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
completePartitionArb(),
|
||||
completePartitionArb(),
|
||||
fc
|
||||
.subarray([...PARTITION_CATEGORY_VALUES], { minLength: 1 })
|
||||
.map((arr) => arr as PartitionCategory[]),
|
||||
(existing, candidate, categoriesToEmpty) => {
|
||||
// 先登记一个合法分区作为既有状态。
|
||||
const store = new KnowledgeBaseStore([existing]);
|
||||
const snapshotBefore = store.snapshot();
|
||||
const sizeBefore = store.size;
|
||||
|
||||
// 构造一个缺类别的新增请求(确保与既有分区行业不同以排除覆盖语义)。
|
||||
const broken: IndustryPartition = {
|
||||
...candidate,
|
||||
industryId: `${candidate.industryId}#new`,
|
||||
};
|
||||
for (const category of categoriesToEmpty) {
|
||||
broken[CATEGORY_FIELD[category]] = [] as never;
|
||||
}
|
||||
|
||||
expect(() => store.addPartition(broken)).toThrow(
|
||||
IncompletePartitionError,
|
||||
);
|
||||
|
||||
// 已有分区不变:数量不变、内容快照不变、未引入新分区。
|
||||
expect(store.size).toBe(sizeBefore);
|
||||
expect(store.has(existing.industryId)).toBe(true);
|
||||
expect(store.has(broken.industryId)).toBe(false);
|
||||
expect(store.snapshot()).toEqual(snapshotBefore);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 集成测试:知识库行业分区运行时扩展(Knowledge_Base × Scoring_Engine,Req 14.2)。
|
||||
*
|
||||
* 验证目标(Req 14.2):
|
||||
* WHERE 操作者角色为 Administrator, WHEN 运行时新增一个行业分区,
|
||||
* THE System SHALL 在不修改 Scoring_Engine 源代码(亦无需重编译)的前提下
|
||||
* 使该行业分区的 Template 可被 System 加载并被评分引擎消费。
|
||||
*
|
||||
* 测试方式(配置驱动 / config-driven):
|
||||
* - 以既有知识库为起点,由 Administrator 经 RBAC 守卫在运行时 addPartition 新增分区;
|
||||
* - 直接复用**未经任何修改**的 Scoring_Engine 导出函数
|
||||
* (scoreIndicator / scoreDimension / computeRiskScore)消费新分区 Template 的配置;
|
||||
* - 断言无需触碰评分引擎源码即可对新增行业的 Template 完成端到端评分。
|
||||
*
|
||||
* 本测试不导入任何"行业专用"的评分逻辑——评分引擎仅依赖 Template 承载的结构化配置,
|
||||
* 因此新增行业分区不会、也不需要改动 Scoring_Engine。
|
||||
*
|
||||
* Validates: Requirements 14.2
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BusinessType, Industry, RiskLevel } from '../../domain/common.js';
|
||||
import type { IndustryPartition } from '../../domain/knowledge.js';
|
||||
import type {
|
||||
Dimension,
|
||||
Indicator,
|
||||
RiskModel,
|
||||
Template,
|
||||
} from '../../domain/model.js';
|
||||
import { KnowledgeBaseStore } from '../index.js';
|
||||
import {
|
||||
computeRiskScore,
|
||||
scoreDimension,
|
||||
scoreIndicator,
|
||||
type RiskLevelResolver,
|
||||
} from '../../scoring/index.js';
|
||||
import { isAdministrator, requireRole, type Actor } from '../../rbac/index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 构造器:合法且五类齐备的行业分区(含一个可被评分引擎消费的 Template)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 覆盖 Risk_Level 1-5 的评分规则(满足 Indicator 合法性)。 */
|
||||
function fullScoringRules() {
|
||||
return ([1, 2, 3, 4, 5] as const).map((level) => ({
|
||||
level,
|
||||
label: `L${level}`,
|
||||
description: `level ${level}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildIndicator(id: string, weight: number, enabled = true): Indicator {
|
||||
return {
|
||||
id,
|
||||
name: `指标-${id}`,
|
||||
weight,
|
||||
enabled,
|
||||
scoringRules: fullScoringRules(),
|
||||
evidenceRequired: '证据',
|
||||
askPrompt: '请补充信息',
|
||||
};
|
||||
}
|
||||
|
||||
function buildDimension(id: string, weight: number, indicators: Indicator[]): Dimension {
|
||||
return { id, name: `维度-${id}`, weight, enabled: true, indicators };
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定行业构造一个 Template,并据其打包成五类齐备的合法行业分区。
|
||||
* Template 的 riskModelConfig 即评分引擎所消费的结构化配置(配置驱动扩展的载体)。
|
||||
*/
|
||||
function buildPartitionWithTemplate(
|
||||
industry: Industry,
|
||||
businessType: BusinessType = '岗位外包',
|
||||
): IndustryPartition {
|
||||
const dimensions: Dimension[] = [
|
||||
buildDimension('d1', 60, [
|
||||
buildIndicator('d1-i1', 70),
|
||||
buildIndicator('d1-i2', 30),
|
||||
]),
|
||||
buildDimension('d2', 40, [buildIndicator('d2-i1', 100)]),
|
||||
];
|
||||
|
||||
const template: Template = {
|
||||
id: `tmpl-${industry}`,
|
||||
name: `${industry}-默认模板`,
|
||||
businessType,
|
||||
industry,
|
||||
isDefault: true,
|
||||
riskModelConfig: {
|
||||
name: `${industry}-风险模型`,
|
||||
businessType,
|
||||
dimensions,
|
||||
redlines: [
|
||||
{
|
||||
id: `rl-${industry}`,
|
||||
triggerCondition: '触发条件',
|
||||
consequence: '一票否决',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
industryId: industry,
|
||||
indicators: dimensions.flatMap((d) => d.indicators),
|
||||
weightTemplates: [{ targetId: 'd1', weight: 60 }],
|
||||
redlines: template.riskModelConfig.redlines,
|
||||
cases: [{ id: 'c1', title: '案例', summary: '要点' }],
|
||||
askPrompts: [{ indicatorId: 'd1-i1', prompt: '请补充' }],
|
||||
templates: [template],
|
||||
};
|
||||
}
|
||||
|
||||
/** 由 Template 的 riskModelConfig 实例化运行时 RiskModel(不涉及评分引擎源码)。 */
|
||||
function instantiateRiskModel(template: Template): RiskModel {
|
||||
return {
|
||||
id: `model-${template.id}`,
|
||||
name: template.riskModelConfig.name,
|
||||
businessType: template.riskModelConfig.businessType,
|
||||
dimensions: template.riskModelConfig.dimensions,
|
||||
redlines: template.riskModelConfig.redlines,
|
||||
};
|
||||
}
|
||||
|
||||
/** 恒定 Risk_Level 解析器(用于端点与一般用例)。 */
|
||||
function constantResolver(level: RiskLevel): RiskLevelResolver {
|
||||
return () => level;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 集成测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('集成:知识库行业分区运行时扩展(Req 14.2)', () => {
|
||||
const administrator: Actor = { id: 'admin-1', role: 'Administrator' };
|
||||
|
||||
it('Administrator 运行时新增行业分区后,其 Template 可被加载', () => {
|
||||
// 既有知识库:仅含一个默认行业分区。
|
||||
const store = new KnowledgeBaseStore([
|
||||
buildPartitionWithTemplate('默认'),
|
||||
]);
|
||||
const sizeBefore = store.size;
|
||||
const newIndustry = '医疗器械'; // 既有配置中不存在的全新行业。
|
||||
expect(store.has(newIndustry)).toBe(false);
|
||||
|
||||
// 运行时新增:仅 Administrator 可执行(RBAC 守卫,Req 12 / 14.2 前置条件)。
|
||||
expect(isAdministrator(administrator.role)).toBe(true);
|
||||
requireRole('Administrator', administrator);
|
||||
store.addPartition(buildPartitionWithTemplate(newIndustry));
|
||||
|
||||
// 新分区及其 Template 可被加载。
|
||||
expect(store.size).toBe(sizeBefore + 1);
|
||||
const partition = store.get(newIndustry);
|
||||
expect(partition).toBeDefined();
|
||||
expect(partition?.templates).toHaveLength(1);
|
||||
const template = partition?.templates[0];
|
||||
expect(template?.industry).toBe(newIndustry);
|
||||
expect(template?.riskModelConfig.dimensions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('未经修改的 Scoring_Engine 直接消费运行时新增 Template 的配置完成评分', () => {
|
||||
const store = new KnowledgeBaseStore();
|
||||
const newIndustry = '跨境电商';
|
||||
requireRole('Administrator', administrator);
|
||||
store.addPartition(buildPartitionWithTemplate(newIndustry));
|
||||
|
||||
const template = store.get(newIndustry)?.templates[0];
|
||||
expect(template).toBeDefined();
|
||||
|
||||
// 关键断言:用原样导入的评分引擎函数消费"运行时新增分区"的 Template 配置,
|
||||
// 无需新增/修改任何评分引擎源码即可得到合法 Risk_Score。
|
||||
const riskModel = instantiateRiskModel(template!);
|
||||
const score = computeRiskScore(riskModel, constantResolver(3));
|
||||
expect(Number.isInteger(score)).toBe(true);
|
||||
expect(score).toBeGreaterThanOrEqual(0);
|
||||
expect(score).toBeLessThanOrEqual(100);
|
||||
|
||||
// 评分项/维度层函数同样可直接消费新分区指标配置。
|
||||
const firstDimension = riskModel.dimensions[0]!;
|
||||
const firstIndicator = firstDimension.indicators[0]!;
|
||||
expect(scoreIndicator(firstIndicator, 4)).toBe(4 * firstIndicator.weight);
|
||||
expect(scoreDimension(firstDimension, constantResolver(2))).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('新增行业 Template 满足评分引擎端点不变式(全1→0,全5→100)', () => {
|
||||
const store = new KnowledgeBaseStore();
|
||||
const newIndustry = '新能源';
|
||||
requireRole('Administrator', administrator);
|
||||
store.addPartition(buildPartitionWithTemplate(newIndustry));
|
||||
|
||||
const riskModel = instantiateRiskModel(store.get(newIndustry)!.templates[0]!);
|
||||
// 端点行为与具体行业/权重无关,证明评分引擎配置驱动、对新行业开箱即用。
|
||||
expect(computeRiskScore(riskModel, constantResolver(1))).toBe(0);
|
||||
expect(computeRiskScore(riskModel, constantResolver(5))).toBe(100);
|
||||
});
|
||||
|
||||
it('多次运行时扩展互不影响,各新增行业 Template 均可独立加载并评分', () => {
|
||||
const store = new KnowledgeBaseStore([buildPartitionWithTemplate('默认')]);
|
||||
const industries: Industry[] = ['制造业', '物流', '金融科技'];
|
||||
|
||||
for (const industry of industries) {
|
||||
requireRole('Administrator', administrator);
|
||||
store.addPartition(buildPartitionWithTemplate(industry));
|
||||
}
|
||||
|
||||
// 既有默认分区 + 三个运行时新增分区。
|
||||
expect(store.size).toBe(1 + industries.length);
|
||||
for (const industry of industries) {
|
||||
const template = store.get(industry)?.templates[0];
|
||||
expect(template?.industry).toBe(industry);
|
||||
const score = computeRiskScore(
|
||||
instantiateRiskModel(template!),
|
||||
constantResolver(3),
|
||||
);
|
||||
expect(score).toBeGreaterThanOrEqual(0);
|
||||
expect(score).toBeLessThanOrEqual(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 行业分区五类必备内容类别(Req 14.1, 14.4)。
|
||||
*
|
||||
* 合法行业分区须同时具备以下全部五类内容;缺任一类即视为不完备,拒绝创建。
|
||||
*/
|
||||
|
||||
import type { IndustryPartition } from '../domain/knowledge.js';
|
||||
|
||||
/**
|
||||
* 行业分区内容类别标识(面向用户的可读名称,用于校验错误中指明缺失类别)。
|
||||
*/
|
||||
export type PartitionCategory =
|
||||
| 'Indicator'
|
||||
| '权重模板'
|
||||
| 'Redline'
|
||||
| '典型案例'
|
||||
| '追问话术';
|
||||
|
||||
/** 五类必备内容类别的全部取值(顺序对齐 Req 14.1 表述)。 */
|
||||
export const PARTITION_CATEGORY_VALUES = [
|
||||
'Indicator',
|
||||
'权重模板',
|
||||
'Redline',
|
||||
'典型案例',
|
||||
'追问话术',
|
||||
] as const satisfies readonly PartitionCategory[];
|
||||
|
||||
/**
|
||||
* 各类别到其在 {@link IndustryPartition} 中承载字段的映射。
|
||||
*
|
||||
* 校验时据此读取对应集合:集合为空(或缺失)即认定该类别缺失。
|
||||
*/
|
||||
const CATEGORY_FIELDS: ReadonlyArray<{
|
||||
readonly category: PartitionCategory;
|
||||
readonly field: keyof IndustryPartition;
|
||||
}> = [
|
||||
{ category: 'Indicator', field: 'indicators' },
|
||||
{ category: '权重模板', field: 'weightTemplates' },
|
||||
{ category: 'Redline', field: 'redlines' },
|
||||
{ category: '典型案例', field: 'cases' },
|
||||
{ category: '追问话术', field: 'askPrompts' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 找出某行业分区缺失的内容类别(Req 14.1, 14.4)。
|
||||
*
|
||||
* 判定规则:对应字段不是数组、或为空数组,即视为该类别缺失。
|
||||
*
|
||||
* @returns 缺失类别列表(顺序对齐 {@link PARTITION_CATEGORY_VALUES});为空表示五类齐备。
|
||||
*/
|
||||
export function findMissingCategories(
|
||||
partition: IndustryPartition,
|
||||
): PartitionCategory[] {
|
||||
const missing: PartitionCategory[] = [];
|
||||
for (const { category, field } of CATEGORY_FIELDS) {
|
||||
const value = partition[field];
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
missing.push(category);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某行业分区是否五类内容齐备(不缺任一类)。
|
||||
*/
|
||||
export function isPartitionComplete(partition: IndustryPartition): boolean {
|
||||
return findMissingCategories(partition).length === 0;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 知识库模块错误类型。
|
||||
*
|
||||
* 错误处理遵循统一原则:输入校验前置、错误可解释、失败不破坏既有状态。
|
||||
*/
|
||||
|
||||
import type { Industry } from '../domain/common.js';
|
||||
import type { PartitionCategory } from './category.js';
|
||||
|
||||
/**
|
||||
* 新增行业分区缺少五类必备内容中的任一类时抛出(Req 14.4)。
|
||||
*
|
||||
* System 据此拒绝创建该行业分区、返回指明缺失内容类别的校验错误,
|
||||
* 并保持 Knowledge_Base 中已有分区不变。
|
||||
*/
|
||||
export class IncompletePartitionError extends Error {
|
||||
/** 触发错误的行业标识。 */
|
||||
readonly industryId: Industry;
|
||||
/** 缺失的内容类别列表(至少一项)。 */
|
||||
readonly missingCategories: readonly PartitionCategory[];
|
||||
/** 面向操作者的可读提示,明确指出缺失的类别。 */
|
||||
readonly userMessage: string;
|
||||
|
||||
constructor(industryId: Industry, missingCategories: readonly PartitionCategory[]) {
|
||||
const categoryList = missingCategories.join('、');
|
||||
const userMessage = `行业分区「${industryId}」缺少必备内容类别:${categoryList},拒绝创建`;
|
||||
super(userMessage);
|
||||
this.name = 'IncompletePartitionError';
|
||||
this.industryId = industryId;
|
||||
this.missingCategories = [...missingCategories];
|
||||
this.userMessage = userMessage;
|
||||
// 维持原型链(编译目标低于 ES2015 时的兼容保障)。
|
||||
Object.setPrototypeOf(this, IncompletePartitionError.prototype);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Knowledge_Base 分行业知识库模块(Req 14)。
|
||||
*
|
||||
* 职责(本任务覆盖 Req 14.1, 14.4):
|
||||
* - 按行业标识分区存储 Indicator、权重模板、Redline、典型案例、追问话术五类内容
|
||||
* ({@link KnowledgeBaseStore})。
|
||||
* - 行业分区五类内容完备性校验({@link findMissingCategories} / {@link isPartitionComplete})。
|
||||
* - 新增分区缺任一类时拒绝创建、返回指明缺失类别的校验错误,且保持已有分区不变
|
||||
* ({@link IncompletePartitionError})。
|
||||
*/
|
||||
|
||||
export * from './category.js';
|
||||
export * from './errors.js';
|
||||
export * from './store.js';
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 分行业知识库存储:按行业标识分区存储五类内容并校验完备性(Req 14.1, 14.4)。
|
||||
*
|
||||
* - 按行业标识分区存储 Indicator、权重模板、Redline、典型案例、追问话术五类内容(Req 14.1)。
|
||||
* - 新增分区缺任一类内容时拒绝创建、抛出指明缺失类别的校验错误,
|
||||
* 且保持已有分区不变(Req 14.4,{@link IncompletePartitionError})。
|
||||
*/
|
||||
|
||||
import type { Industry } from '../domain/common.js';
|
||||
import type { IndustryPartition, KnowledgeBase } from '../domain/knowledge.js';
|
||||
import { findMissingCategories } from './category.js';
|
||||
import { IncompletePartitionError } from './errors.js';
|
||||
|
||||
/** 规范化行业标识用于分区键匹配(首尾空白不敏感)。 */
|
||||
function normalizeIndustryId(industryId: Industry): string {
|
||||
return industryId.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分行业知识库存储(Knowledge_Base,Req 14.1, 14.4)。
|
||||
*
|
||||
* 以行业标识为键登记 {@link IndustryPartition};新增分区前强制完备性校验,
|
||||
* 校验不通过则拒绝并保持已有分区不变。
|
||||
*/
|
||||
export class KnowledgeBaseStore {
|
||||
/** 以规范化行业标识为键的分区表。 */
|
||||
private readonly partitions = new Map<string, IndustryPartition>();
|
||||
|
||||
/**
|
||||
* @param initial 初始登记的行业分区集合(默认空)。
|
||||
* @throws {IncompletePartitionError} 当任一初始分区缺少五类必备内容时。
|
||||
*/
|
||||
constructor(initial: readonly IndustryPartition[] = []) {
|
||||
for (const partition of initial) {
|
||||
this.addPartition(partition);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增一个行业分区(Req 14.1, 14.4)。
|
||||
*
|
||||
* 先做五类内容完备性校验:缺任一类则拒绝创建、抛出
|
||||
* {@link IncompletePartitionError}(指明缺失类别),且不修改任何已有分区。
|
||||
* 校验通过后按行业标识登记该分区(同一行业再次新增将覆盖其内容)。
|
||||
*
|
||||
* @throws {IncompletePartitionError} 当该分区缺少五类必备内容中的任一类时。
|
||||
*/
|
||||
addPartition(partition: IndustryPartition): void {
|
||||
const missing = findMissingCategories(partition);
|
||||
if (missing.length > 0) {
|
||||
// 校验失败:拒绝创建,不改动 this.partitions(保持已有分区不变)。
|
||||
throw new IncompletePartitionError(partition.industryId, missing);
|
||||
}
|
||||
this.partitions.set(normalizeIndustryId(partition.industryId), partition);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否存在与给定行业标识匹配的分区。
|
||||
*/
|
||||
has(industryId: Industry): boolean {
|
||||
return this.partitions.has(normalizeIndustryId(industryId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按行业标识获取分区;不存在时返回 undefined。
|
||||
*/
|
||||
get(industryId: Industry): IndustryPartition | undefined {
|
||||
return this.partitions.get(normalizeIndustryId(industryId));
|
||||
}
|
||||
|
||||
/** 已登记分区数量。 */
|
||||
get size(): number {
|
||||
return this.partitions.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出当前知识库的不可变快照(Req 14.1)。
|
||||
*/
|
||||
snapshot(): KnowledgeBase {
|
||||
return { partitions: [...this.partitions.values()] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* LLM 接入单元测试:schema 校验与降级语义(mock fetch,不发真实请求)。
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getLlmConfig } from '../config.js';
|
||||
import { llmClassify } from '../classify.js';
|
||||
import { createQwenIndicatorAdapter } from '../indicatorAdapter.js';
|
||||
|
||||
/** 构造一个返回给定 content 的 fetch mock(OpenAI 兼容响应体)。 */
|
||||
function mockFetchOnceWithContent(content: string): void {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ choices: [{ message: { content } }] }),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.DASHSCOPE_API_KEY = 'test-key';
|
||||
process.env.LLM_MODEL = 'qwen-plus';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.DASHSCOPE_API_KEY;
|
||||
});
|
||||
|
||||
describe('getLlmConfig', () => {
|
||||
it('提供 API Key 时启用', () => {
|
||||
expect(getLlmConfig().enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('无 API Key 时禁用', () => {
|
||||
delete process.env.DASHSCOPE_API_KEY;
|
||||
delete process.env.QWEN_API_KEY;
|
||||
delete process.env.LLM_API_KEY;
|
||||
expect(getLlmConfig().enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('llmClassify', () => {
|
||||
it('合法输出 → 返回规整后的分类结果', async () => {
|
||||
mockFetchOnceWithContent(
|
||||
JSON.stringify({
|
||||
businessType: '劳务派遣',
|
||||
businessTypeConfidence: 0.92,
|
||||
industry: '制造业',
|
||||
industryConfidence: 0.81,
|
||||
}),
|
||||
);
|
||||
const result = await llmClassify('为某制造企业提供产线劳务派遣');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.businessType).toBe('劳务派遣');
|
||||
expect(result?.businessTypeConfidence).toBe(0.92);
|
||||
expect(result?.industry).toBe('制造业');
|
||||
expect(result?.needsBusinessTypeConfirm).toBe(false);
|
||||
});
|
||||
|
||||
it('低置信度 → 置确认标志并给出候选', async () => {
|
||||
mockFetchOnceWithContent(
|
||||
JSON.stringify({
|
||||
businessType: 'BPO',
|
||||
businessTypeConfidence: 0.41,
|
||||
industry: '未识别',
|
||||
industryConfidence: 0,
|
||||
}),
|
||||
);
|
||||
const result = await llmClassify('一些含糊的项目描述文本内容');
|
||||
expect(result?.needsBusinessTypeConfirm).toBe(true);
|
||||
expect(result?.businessTypeCandidates.length).toBeGreaterThanOrEqual(1);
|
||||
expect(result?.industry).toBe('未识别');
|
||||
});
|
||||
|
||||
it('非法业务类型 → 返回 null(交由规则兜底)', async () => {
|
||||
mockFetchOnceWithContent(
|
||||
JSON.stringify({ businessType: '不存在的类型', businessTypeConfidence: 0.9 }),
|
||||
);
|
||||
expect(await llmClassify('xxxxxxxxxx')).toBeNull();
|
||||
});
|
||||
|
||||
it('非 JSON 内容 → 返回 null', async () => {
|
||||
mockFetchOnceWithContent('抱歉我无法回答');
|
||||
expect(await llmClassify('xxxxxxxxxx')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createQwenIndicatorAdapter', () => {
|
||||
it('仅保留 1-5 整数等级与目标指标,丢弃非法项', async () => {
|
||||
mockFetchOnceWithContent(
|
||||
JSON.stringify({
|
||||
indicators: [
|
||||
{ id: 'customer-credit', level: 4, confidence: 0.8, rationale: 'x' },
|
||||
{ id: 'payment-ability', level: 9, confidence: 0.8 }, // 越界 → 丢弃
|
||||
{ id: 'not-a-real-indicator', level: 3, confidence: 0.8 }, // 非目标 → 丢弃
|
||||
{ id: 'gross-margin', level: 2.5, confidence: 0.8 }, // 非整数 → 丢弃
|
||||
],
|
||||
}),
|
||||
);
|
||||
const adapter = createQwenIndicatorAdapter('某项目描述');
|
||||
const points = await adapter.fetch({
|
||||
subjectName: '外包项目',
|
||||
indicatorIds: ['customer-credit', 'payment-ability', 'gross-margin'],
|
||||
});
|
||||
expect(points).toHaveLength(1);
|
||||
expect(points[0]?.indicatorId).toBe('customer-credit');
|
||||
expect(points[0]?.value).toBe(4);
|
||||
expect(points[0]?.provenance).toBe('外部数据');
|
||||
});
|
||||
|
||||
it('无目标指标时不调用模型,返回空', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const adapter = createQwenIndicatorAdapter('某项目描述');
|
||||
const points = await adapter.fetch({ subjectName: '外包项目', indicatorIds: [] });
|
||||
expect(points).toEqual([]);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* LLM 业务类型/行业识别。
|
||||
*
|
||||
* 用 LLM 替代关键词匹配做自由文本理解,但输出经严格 schema 校验后才采用:
|
||||
* - businessType 必须是五类合法业务类型之一;
|
||||
* - 置信度夹取到 [0,1] 两位小数;
|
||||
* - 按与规则分类器一致的阈值(0.6)推导 needsConfirm 标志与候选列表。
|
||||
*
|
||||
* 任意失败(未配置 / 调用异常 / 输出非法)返回 null,调用方据此回退到规则 `classify`。
|
||||
*/
|
||||
|
||||
import { isBusinessType } from '../classifier/index.js';
|
||||
import {
|
||||
CONFIRMATION_CONFIDENCE_THRESHOLD,
|
||||
MAX_CANDIDATES,
|
||||
toConfidence,
|
||||
type ClassificationResult,
|
||||
type ScoredCandidate,
|
||||
} from '../classifier/index.js';
|
||||
import {
|
||||
BUSINESS_TYPE_VALUES,
|
||||
INDUSTRY_UNRECOGNIZED,
|
||||
type BusinessType,
|
||||
type Industry,
|
||||
} from '../domain/common.js';
|
||||
import { chatJSON } from './client.js';
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
'你是外包项目风险评估系统的业务分类器。',
|
||||
'根据项目描述判断「业务类型」与「所属行业」,并给出 0-1 的置信度。',
|
||||
`业务类型必须从以下五类中选择其一:${BUSINESS_TYPE_VALUES.join('、')}。`,
|
||||
'行业用简短中文词(如 制造业、信息技术、金融业、物流业、零售业、客服服务 等);无法判断时填 "未识别"。',
|
||||
'只输出 JSON,结构为:',
|
||||
'{"businessType":"<五类之一>","businessTypeConfidence":0.0,"industry":"<行业或未识别>","industryConfidence":0.0}',
|
||||
].join('\n');
|
||||
|
||||
/** 从 LLM 原始输出构造候选列表(仅含被选中项,保证至少一项且 ≤ MAX_CANDIDATES)。 */
|
||||
function singleCandidate<T extends string>(label: T, confidence: number): ScoredCandidate<T>[] {
|
||||
return [{ label, confidence: toConfidence(confidence) }].slice(0, MAX_CANDIDATES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 LLM 识别业务类型与行业;失败或输出非法时返回 null(交由规则分类器兜底)。
|
||||
*/
|
||||
export async function llmClassify(
|
||||
description: string,
|
||||
): Promise<ClassificationResult | null> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await chatJSON([
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: `项目描述:\n${description}` },
|
||||
]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof raw !== 'object' || raw === null) {
|
||||
return null;
|
||||
}
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
const businessTypeRaw = obj.businessType;
|
||||
if (typeof businessTypeRaw !== 'string' || !isBusinessType(businessTypeRaw)) {
|
||||
return null;
|
||||
}
|
||||
const businessType: BusinessType = businessTypeRaw;
|
||||
|
||||
const businessTypeConfidence = toConfidence(Number(obj.businessTypeConfidence ?? 0));
|
||||
|
||||
const industryRaw = typeof obj.industry === 'string' ? obj.industry.trim() : '';
|
||||
const industryDeterminable = industryRaw !== '' && industryRaw !== INDUSTRY_UNRECOGNIZED;
|
||||
const industry: Industry = industryDeterminable ? industryRaw : INDUSTRY_UNRECOGNIZED;
|
||||
const industryConfidence = industryDeterminable
|
||||
? toConfidence(Number(obj.industryConfidence ?? 0))
|
||||
: 0;
|
||||
|
||||
const needsBusinessTypeConfirm =
|
||||
businessTypeConfidence < CONFIRMATION_CONFIDENCE_THRESHOLD;
|
||||
const needsIndustryConfirm =
|
||||
industryDeterminable && industryConfidence < CONFIRMATION_CONFIDENCE_THRESHOLD;
|
||||
|
||||
return {
|
||||
businessType,
|
||||
businessTypeConfidence,
|
||||
businessTypeCandidates: needsBusinessTypeConfirm
|
||||
? singleCandidate(businessType, businessTypeConfidence)
|
||||
: [],
|
||||
needsBusinessTypeConfirm,
|
||||
industry,
|
||||
industryConfidence,
|
||||
industryCandidates: needsIndustryConfirm
|
||||
? singleCandidate(industry, industryConfidence)
|
||||
: [],
|
||||
needsIndustryConfirm,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* LLM 客户端:调用 DashScope 的 OpenAI 兼容 Chat Completions 接口,强制 JSON 输出。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 超时由 AbortController 控制(默认取配置值),超时即拒绝,交由上层降级。
|
||||
* - 优先使用 `response_format: { type: 'json_object' }`;并对返回内容做健壮 JSON 解析
|
||||
* (兼容模型偶发的 ```json 代码块包裹)。
|
||||
* - 任何失败(未配置 / 网络 / 非 2xx / 解析失败)均抛出 {@link LlmError},调用方据此回退。
|
||||
*/
|
||||
|
||||
import { getLlmConfig } from './config.js';
|
||||
|
||||
/** LLM 调用错误(网络、超时、非 2xx、解析失败等统一归类)。 */
|
||||
export class LlmError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'LlmError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 对话消息。 */
|
||||
export interface ChatMessage {
|
||||
readonly role: 'system' | 'user' | 'assistant';
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
/** chatJSON 选项。 */
|
||||
export interface ChatJsonOptions {
|
||||
/** 采样温度,默认 0.2(偏确定性)。 */
|
||||
readonly temperature?: number;
|
||||
/** 覆盖超时(毫秒)。 */
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** 从可能含代码块包裹的文本中健壮地解析 JSON 对象。 */
|
||||
function parseJsonLoose(content: string): unknown {
|
||||
const trimmed = content.trim();
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
// 回退:提取首个 { 到末个 } 的子串再解析(兼容 ```json 包裹)。
|
||||
const start = trimmed.indexOf('{');
|
||||
const end = trimmed.lastIndexOf('}');
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
return JSON.parse(trimmed.slice(start, end + 1));
|
||||
}
|
||||
throw new LlmError('LLM 返回内容无法解析为 JSON');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 LLM 并返回解析后的 JSON(unknown,由调用方做 schema 校验)。
|
||||
*
|
||||
* @throws {LlmError} 未配置 / 网络失败 / 超时 / 非 2xx / 内容解析失败。
|
||||
*/
|
||||
export async function chatJSON(
|
||||
messages: readonly ChatMessage[],
|
||||
options: ChatJsonOptions = {},
|
||||
): Promise<unknown> {
|
||||
const cfg = getLlmConfig();
|
||||
if (!cfg.enabled) {
|
||||
throw new LlmError('LLM 未配置(缺少 API Key)');
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = options.timeoutMs ?? cfg.timeoutMs;
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${cfg.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${cfg.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: cfg.model,
|
||||
messages,
|
||||
temperature: options.temperature ?? 0.2,
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new LlmError(`LLM HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: ReadonlyArray<{ message?: { content?: unknown } }>;
|
||||
};
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string' || content.trim() === '') {
|
||||
throw new LlmError('LLM 响应缺少文本内容');
|
||||
}
|
||||
return parseJsonLoose(content);
|
||||
} catch (err) {
|
||||
if (err instanceof LlmError) {
|
||||
throw err;
|
||||
}
|
||||
const name = err instanceof Error ? err.name : '';
|
||||
if (name === 'AbortError') {
|
||||
throw new LlmError(`LLM 请求超时(>${timeoutMs}ms)`);
|
||||
}
|
||||
throw new LlmError(`LLM 请求失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* LLM 运行配置(通义千问 / DashScope 兼容模式)。
|
||||
*
|
||||
* 配置一律取自环境变量(由 {@link ./env.loadEnvFile} 从 .env 注入或来自真实环境变量),
|
||||
* 不在源码中硬编码任何密钥。未提供 API Key 时 `enabled` 为 false,调用方据此完全
|
||||
* 跳过 LLM 并使用确定性规则引擎。
|
||||
*/
|
||||
|
||||
/** LLM 配置快照。 */
|
||||
export interface LlmConfig {
|
||||
/** 是否启用 LLM(当且仅当提供了非空 API Key)。 */
|
||||
readonly enabled: boolean;
|
||||
/** API Key(DashScope)。 */
|
||||
readonly apiKey: string;
|
||||
/** OpenAI 兼容端点基址。 */
|
||||
readonly baseUrl: string;
|
||||
/** 模型名。 */
|
||||
readonly model: string;
|
||||
/** 单次请求超时(毫秒)。 */
|
||||
readonly timeoutMs: number;
|
||||
}
|
||||
|
||||
/** DashScope OpenAI 兼容模式默认端点。 */
|
||||
const DEFAULT_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
|
||||
/** 默认模型(通义千问 plus,兼顾质量与成本)。 */
|
||||
const DEFAULT_MODEL = 'qwen-plus';
|
||||
|
||||
/** 默认超时(毫秒)。 */
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* 读取当前 LLM 配置。支持多种 Key 环境变量名以便兼容不同部署习惯。
|
||||
*/
|
||||
export function getLlmConfig(): LlmConfig {
|
||||
const apiKey =
|
||||
process.env.DASHSCOPE_API_KEY ??
|
||||
process.env.QWEN_API_KEY ??
|
||||
process.env.LLM_API_KEY ??
|
||||
'';
|
||||
|
||||
const timeoutRaw = Number(process.env.LLM_TIMEOUT_MS);
|
||||
const timeoutMs =
|
||||
Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : DEFAULT_TIMEOUT_MS;
|
||||
|
||||
return {
|
||||
enabled: apiKey.trim() !== '',
|
||||
apiKey: apiKey.trim(),
|
||||
baseUrl: process.env.LLM_BASE_URL ?? DEFAULT_BASE_URL,
|
||||
model: process.env.LLM_MODEL ?? DEFAULT_MODEL,
|
||||
timeoutMs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 通义千问 text-embedding(DashScope API)。
|
||||
*
|
||||
* 模型:text-embedding-v3(1536 维)。
|
||||
* 用于项目描述的语义向量化,供向量相似检索。
|
||||
*/
|
||||
|
||||
import { getLlmConfig } from './config.js';
|
||||
|
||||
const EMBEDDING_URL = 'https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding';
|
||||
|
||||
/** 对单条文本生成 embedding 向量(1536 维)。 */
|
||||
export async function generateEmbedding(text: string): Promise<number[] | null> {
|
||||
const cfg = getLlmConfig();
|
||||
if (!cfg.enabled) return null;
|
||||
|
||||
try {
|
||||
const res = await fetch(EMBEDDING_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${cfg.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'text-embedding-v3',
|
||||
input: { texts: [text.slice(0, 2000)] },
|
||||
parameters: { dimension: 1024 },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn('embedding API error:', res.status, await res.text().catch(() => ''));
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json() as {
|
||||
output?: { embeddings?: Array<{ embedding?: number[] }> };
|
||||
};
|
||||
const vec = data.output?.embeddings?.[0]?.embedding;
|
||||
return Array.isArray(vec) && vec.length === 1024 ? vec : null;
|
||||
} catch (err) {
|
||||
console.warn('embedding error:', err instanceof Error ? err.message : err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 极简 .env 加载器(无第三方依赖)。
|
||||
*
|
||||
* 在服务端进程启动时调用,将 .env 中的 KEY=VALUE 注入 process.env(不覆盖已存在的环境变量,
|
||||
* 故真实环境变量优先于 .env 文件)。文件不存在时静默跳过。
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* 读取并解析 .env 文件,将键值注入 process.env。
|
||||
*
|
||||
* @param path .env 文件路径(相对当前工作目录),默认 `.env`。
|
||||
*/
|
||||
export function loadEnvFile(path = '.env'): void {
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(path, 'utf8');
|
||||
} catch {
|
||||
// 无 .env 文件:静默跳过(可改用真实环境变量)。
|
||||
return;
|
||||
}
|
||||
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (line === '' || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const eq = line.indexOf('=');
|
||||
if (eq === -1) {
|
||||
continue;
|
||||
}
|
||||
const key = line.slice(0, eq).trim();
|
||||
let value = line.slice(eq + 1).trim();
|
||||
// 去除成对引号。
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (key !== '' && process.env[key] === undefined) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* LLM 岗位明细抽取:从项目描述中解析出岗位名称与人数。
|
||||
*
|
||||
* 用于评估向导第 5 步「报价与成本」的岗位明细预填。仅做语言理解(抽取),
|
||||
* 不做任何金额/成本推断(那由人工填写或成本引擎计算)。失败时返回空数组(降级)。
|
||||
*/
|
||||
|
||||
import { getLlmConfig } from './config.js';
|
||||
import { chatJSON } from './client.js';
|
||||
|
||||
/** 抽取出的单个岗位。 */
|
||||
export interface ExtractedPosition {
|
||||
/** 岗位名称。 */
|
||||
name: string;
|
||||
/** 人数。 */
|
||||
headcount: number;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `你是外包项目岗位信息抽取助手。从用户提供的项目描述中,抽取所有提到的岗位及其人数。
|
||||
|
||||
规则:
|
||||
1. 只抽取明确提到人数的具体岗位(如"服务器运维20人")。
|
||||
2. 忽略"共X人""总计X人"这类汇总数字,只保留分项岗位。
|
||||
3. 岗位名称保留原文的简洁表述(如"服务器运维""网络安全""桌面运维")。
|
||||
4. 若描述只给总人数而无岗位拆分,返回一个名为"外包人员"的岗位,人数为总数。
|
||||
5. 严格按 JSON 格式输出,不要任何额外文字。
|
||||
|
||||
输出格式:
|
||||
{"positions":[{"name":"岗位名","headcount":人数}]}`;
|
||||
|
||||
/**
|
||||
* 从项目描述抽取岗位明细。LLM 未启用或失败时返回空数组。
|
||||
*/
|
||||
export async function extractPositions(projectDescription: string): Promise<ExtractedPosition[]> {
|
||||
const cfg = getLlmConfig();
|
||||
if (!cfg.enabled) return [];
|
||||
|
||||
try {
|
||||
const raw = await chatJSON([
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: projectDescription.slice(0, 2000) },
|
||||
]);
|
||||
const parsed = raw as { positions?: Array<{ name?: unknown; headcount?: unknown }> };
|
||||
if (!Array.isArray(parsed.positions)) return [];
|
||||
return parsed.positions
|
||||
.map((p) => ({
|
||||
name: typeof p.name === 'string' ? p.name.trim() : '',
|
||||
headcount: typeof p.headcount === 'number' ? Math.round(p.headcount) : Number(p.headcount) || 0,
|
||||
}))
|
||||
.filter((p) => p.name !== '' && p.headcount > 0)
|
||||
.slice(0, 20);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* LLM 接入模块聚合导出(通义千问 / DashScope 兼容模式)。
|
||||
*
|
||||
* 职责:自由文本理解(业务分类、指标抽取)以增强确定性引擎;所有输出经 schema 校验,
|
||||
* 失败自动回退到规则引擎,纯函数评分/分级/红线/费用链路不受影响。
|
||||
*/
|
||||
|
||||
export { loadEnvFile } from './env.js';
|
||||
export { getLlmConfig, type LlmConfig } from './config.js';
|
||||
export { chatJSON, LlmError, type ChatMessage } from './client.js';
|
||||
export { llmClassify } from './classify.js';
|
||||
export { createQwenIndicatorAdapter } from './indicatorAdapter.js';
|
||||
export { prefillIndicators, type IndicatorSuggestion } from './prefill.js';
|
||||
export {
|
||||
synthesize,
|
||||
computeDivergence,
|
||||
type SynthesisContext,
|
||||
type SynthesisResult,
|
||||
} from './synthesis.js';
|
||||
export { INDICATOR_META, type IndicatorMeta } from './indicatorMeta.js';
|
||||
export { generateEmbedding } from './embedding.js';
|
||||
export { extractPositions, type ExtractedPosition } from './extractPositions.js';
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* LLM 指标抽取适配器:实现 {@link DataSourceAdapter},从项目描述为「缺口指标」抽取
|
||||
* Risk_Level(1-5)。产出的数据点标注 Data_Provenance="外部数据" 并带置信度,接入编排器
|
||||
* 的 `externalData` 路径后,由 `fetchWithFallback` 统一处理超时/失败降级
|
||||
* (外部数据 → 用户输入 → 智能体假设),纯函数评分引擎完全不受影响。
|
||||
*
|
||||
* 防幻觉:LLM 仅输出离散等级 + 依据 + 置信度;任何不在 1-5 整数范围或非目标指标的项
|
||||
* 一律丢弃(丢弃项回到缺口,由追问/默认值兜底)。评分、分级、红线、费用仍为确定性计算。
|
||||
*/
|
||||
|
||||
import { normalizeConfidence } from '../domain/provenance.js';
|
||||
import {
|
||||
EXTERNAL_DATA_PROVENANCE,
|
||||
type DataPoint,
|
||||
type DataSourceAdapter,
|
||||
type DataSourceQuery,
|
||||
type ExternalDataCategory,
|
||||
} from '../adapters/index.js';
|
||||
import { chatJSON } from './client.js';
|
||||
import { INDICATOR_META } from './indicatorMeta.js';
|
||||
|
||||
/**
|
||||
* 指标抽取数据点的占位类别。指标取值不属于资信/征信等外部数据类别,下游仅消费
|
||||
* value/confidence/indicatorId,故此字段为满足类型而设的占位值,不参与任何逻辑。
|
||||
*/
|
||||
const PLACEHOLDER_CATEGORY: ExternalDataCategory = '企业资信';
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
'你是外包项目风险评估系统的风险指标分析器。',
|
||||
'根据「项目描述」为给定的每个风险指标判定 Risk_Level(1=极低风险 … 5=极高风险,必须是 1 到 5 的整数)。',
|
||||
'判级要点:风险越大等级越高;信息不足以判断的指标请不要臆造,直接省略该指标(不要输出)。',
|
||||
'只输出 JSON,结构为:',
|
||||
'{"indicators":[{"id":"<指标id>","level":3,"confidence":0.0,"rationale":"<一句中文依据>"}]}',
|
||||
].join('\n');
|
||||
|
||||
interface ExtractedIndicator {
|
||||
id: string;
|
||||
level: number;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/** 校验并规整 LLM 返回的单个指标项;非法返回 null。 */
|
||||
function parseIndicator(raw: unknown, allowedIds: ReadonlySet<string>): ExtractedIndicator | null {
|
||||
if (typeof raw !== 'object' || raw === null) {
|
||||
return null;
|
||||
}
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const id = obj.id;
|
||||
if (typeof id !== 'string' || !allowedIds.has(id)) {
|
||||
return null;
|
||||
}
|
||||
const level = Number(obj.level);
|
||||
if (!Number.isInteger(level) || level < 1 || level > 5) {
|
||||
return null;
|
||||
}
|
||||
const confidence = normalizeConfidence(Number(obj.confidence ?? 0.6));
|
||||
return { id, level, confidence };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个 LLM 指标抽取适配器。适配器闭包捕获项目描述,`fetch` 依据查询中的
|
||||
* indicatorIds(缺口指标)向 LLM 取每个指标的 Risk_Level。
|
||||
*
|
||||
* @param description 项目描述原文。
|
||||
*/
|
||||
export function createQwenIndicatorAdapter(description: string): DataSourceAdapter {
|
||||
return {
|
||||
sourceId: 'qwen-indicator-extractor',
|
||||
sourceName: '通义千问·指标抽取',
|
||||
async fetch(query: DataSourceQuery): Promise<DataPoint[]> {
|
||||
const ids = query.indicatorIds ?? [];
|
||||
const targets = ids
|
||||
.map((id) => INDICATOR_META.get(id))
|
||||
.filter((m): m is NonNullable<typeof m> => m !== undefined);
|
||||
if (targets.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const indicatorBrief = targets.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
dimension: m.dimensionName,
|
||||
关注点: m.askPrompt,
|
||||
分级含义: m.levels.map((l) => `${l.level}=${l.label}`).join(';'),
|
||||
}));
|
||||
|
||||
const userContent = [
|
||||
`项目描述:\n${description}`,
|
||||
'',
|
||||
`请为以下指标判定 Risk_Level:\n${JSON.stringify(indicatorBrief, null, 2)}`,
|
||||
].join('\n');
|
||||
|
||||
// 抛出由上层 fetchWithFallback 捕获并降级,这里不吞异常。
|
||||
const raw = await chatJSON([
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userContent },
|
||||
]);
|
||||
|
||||
const allowed = new Set(targets.map((t) => t.id));
|
||||
const list =
|
||||
typeof raw === 'object' && raw !== null && Array.isArray((raw as Record<string, unknown>).indicators)
|
||||
? ((raw as Record<string, unknown>).indicators as unknown[])
|
||||
: [];
|
||||
|
||||
const points: DataPoint[] = [];
|
||||
for (const item of list) {
|
||||
const parsed = parseIndicator(item, allowed);
|
||||
if (parsed === null) {
|
||||
continue;
|
||||
}
|
||||
points.push({
|
||||
field: parsed.id,
|
||||
value: parsed.level,
|
||||
category: PLACEHOLDER_CATEGORY,
|
||||
provenance: EXTERNAL_DATA_PROVENANCE,
|
||||
confidence: parsed.confidence,
|
||||
indicatorId: parsed.id,
|
||||
});
|
||||
}
|
||||
return points;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 指标元数据:从默认模板派生「指标 id → 名称/所属维度/评分语义」映射,
|
||||
* 供 LLM 在从项目描述抽取 Risk_Level 时理解每个指标的含义与判级标准。
|
||||
*
|
||||
* 五种业务类型采用差异化指标,故需遍历**全部**模板合并指标定义,确保每个指标 id 均有元数据。
|
||||
*/
|
||||
|
||||
import { DEFAULT_TEMPLATES } from '../server/templates/defaultTemplates.js';
|
||||
|
||||
/** 单个指标的语义元数据。 */
|
||||
export interface IndicatorMeta {
|
||||
/** 指标标识。 */
|
||||
readonly id: string;
|
||||
/** 指标名称。 */
|
||||
readonly name: string;
|
||||
/** 所属维度名称。 */
|
||||
readonly dimensionName: string;
|
||||
/** 证据要求说明。 */
|
||||
readonly evidenceRequired: string;
|
||||
/** 追问话术(描述该指标关注点)。 */
|
||||
readonly askPrompt: string;
|
||||
/** 1-5 级评分含义(level → 描述)。 */
|
||||
readonly levels: ReadonlyArray<{ level: number; label: string; description: string }>;
|
||||
}
|
||||
|
||||
function buildIndicatorMeta(): ReadonlyMap<string, IndicatorMeta> {
|
||||
const map = new Map<string, IndicatorMeta>();
|
||||
for (const template of DEFAULT_TEMPLATES) {
|
||||
for (const dimension of template.riskModelConfig.dimensions) {
|
||||
for (const indicator of dimension.indicators) {
|
||||
if (map.has(indicator.id)) {
|
||||
continue;
|
||||
}
|
||||
map.set(indicator.id, {
|
||||
id: indicator.id,
|
||||
name: indicator.name,
|
||||
dimensionName: dimension.name,
|
||||
evidenceRequired: indicator.evidenceRequired,
|
||||
askPrompt: indicator.askPrompt,
|
||||
levels: indicator.scoringRules.map((r) => ({
|
||||
level: r.level,
|
||||
label: r.label,
|
||||
description: r.description,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 指标 id → 元数据 的静态映射。 */
|
||||
export const INDICATOR_META: ReadonlyMap<string, IndicatorMeta> = buildIndicatorMeta();
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* LLM 指标预填:从项目描述为一组指标预判 Risk_Level(1-5)+ 依据 + 置信度。
|
||||
*
|
||||
* 用于"自适应追问"步骤的默认答案——用户只需确认或修改。与 indicatorAdapter 的区别:
|
||||
* 此处返回更丰富的信息(含 rationale)供 UI 展示,且不接入编排器的外部数据通道。
|
||||
* 输出经严格校验:仅保留目标指标且 level 为 1-5 整数;失败/越界项省略。
|
||||
*/
|
||||
|
||||
import { normalizeConfidence } from '../domain/provenance.js';
|
||||
import { chatJSON } from './client.js';
|
||||
import { INDICATOR_META } from './indicatorMeta.js';
|
||||
|
||||
/** 单个指标的 LLM 预填建议。 */
|
||||
export interface IndicatorSuggestion {
|
||||
/** 指标标识。 */
|
||||
indicatorId: string;
|
||||
/** 建议 Risk_Level(1-5 整数)。 */
|
||||
level: number;
|
||||
/** 置信度(0-1,两位小数)。 */
|
||||
confidence: number;
|
||||
/** 一句话判级依据。 */
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
'你是外包项目风险评估系统的风险指标分析器。',
|
||||
'根据「项目描述」为给定的每个风险指标判定 Risk_Level(1=极低风险 … 5=极高风险,必须是 1 到 5 的整数)。',
|
||||
'风险越大等级越高。信息不足以判断的指标请省略(不要输出该项),不要臆造。',
|
||||
'只输出 JSON:{"indicators":[{"id":"<指标id>","level":3,"confidence":0.0,"rationale":"<一句中文依据>"}]}',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* 为给定指标集合从项目描述预填判级建议。失败时返回空数组(不抛出)。
|
||||
*
|
||||
* @param description 项目描述原文。
|
||||
* @param indicatorIds 需要预填的指标 id 集合。
|
||||
*/
|
||||
export async function prefillIndicators(
|
||||
description: string,
|
||||
indicatorIds: readonly string[],
|
||||
): Promise<IndicatorSuggestion[]> {
|
||||
const targets = indicatorIds
|
||||
.map((id) => INDICATOR_META.get(id))
|
||||
.filter((m): m is NonNullable<typeof m> => m !== undefined);
|
||||
if (targets.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const brief = targets.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
dimension: m.dimensionName,
|
||||
关注点: m.askPrompt,
|
||||
分级含义: m.levels.map((l) => `${l.level}=${l.label}`).join(';'),
|
||||
}));
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await chatJSON([
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{
|
||||
role: 'user',
|
||||
content: `项目描述:\n${description}\n\n请为以下指标判定 Risk_Level:\n${JSON.stringify(brief, null, 2)}`,
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allowed = new Set(targets.map((t) => t.id));
|
||||
const list =
|
||||
typeof raw === 'object' && raw !== null && Array.isArray((raw as Record<string, unknown>).indicators)
|
||||
? ((raw as Record<string, unknown>).indicators as unknown[])
|
||||
: [];
|
||||
|
||||
const out: IndicatorSuggestion[] = [];
|
||||
for (const item of list) {
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
continue;
|
||||
}
|
||||
const obj = item as Record<string, unknown>;
|
||||
const id = obj.id;
|
||||
if (typeof id !== 'string' || !allowed.has(id)) {
|
||||
continue;
|
||||
}
|
||||
const level = Number(obj.level);
|
||||
if (!Number.isInteger(level) || level < 1 || level > 5) {
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
indicatorId: id,
|
||||
level,
|
||||
confidence: normalizeConfidence(Number(obj.confidence ?? 0.6)),
|
||||
rationale: typeof obj.rationale === 'string' ? obj.rationale : '',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* LLM 综合研判:在确定性评分完成后,由 LLM 给出独立的整体研判,作为评分的**补充**。
|
||||
*
|
||||
* 关键护栏:
|
||||
* - 不改动 Risk_Score 数字,仅产出"倾向分级 + 理由 + 交叉风险 + 补充接受条件";
|
||||
* - 与引擎分级并排呈现;偏差由 {@link computeDivergence} 标记,提示人工复核;
|
||||
* - 输出经 schema 校验,失败返回 null(前端据此隐藏研判卡片,不影响主流程)。
|
||||
*/
|
||||
|
||||
import { RISK_GRADE_VALUES } from '../domain/common.js';
|
||||
import type { RiskGrade } from '../domain/common.js';
|
||||
import { normalizeConfidence } from '../domain/provenance.js';
|
||||
import { chatJSON } from './client.js';
|
||||
|
||||
/** 综合研判的输入上下文(评分结果摘要)。 */
|
||||
export interface SynthesisContext {
|
||||
projectDescription: string;
|
||||
businessType: string;
|
||||
industry: string;
|
||||
riskScore: number;
|
||||
riskGrade: RiskGrade;
|
||||
acceptability: string;
|
||||
/** 各指标摘要:维度/指标/等级/来源。 */
|
||||
items: ReadonlyArray<{
|
||||
dimensionName: string;
|
||||
indicatorName: string;
|
||||
riskLevel: number;
|
||||
provenance: string;
|
||||
}>;
|
||||
/** 命中的红线 id(如有)。 */
|
||||
hitRedlines?: readonly string[];
|
||||
}
|
||||
|
||||
/** LLM 综合研判结果。 */
|
||||
export interface SynthesisResult {
|
||||
/** LLM 独立倾向分级。 */
|
||||
suggestedGrade: RiskGrade;
|
||||
/** 研判置信度(0-1)。 */
|
||||
confidence: number;
|
||||
/** 整体研判说明。 */
|
||||
overall: string;
|
||||
/** 加权模型可能遗漏的交叉/组合风险。 */
|
||||
crossRisks: string[];
|
||||
/** 补充的接受条件建议。 */
|
||||
suggestedConditions: string[];
|
||||
/** 是否与引擎分级出现偏差(需人工复核)。 */
|
||||
divergent: boolean;
|
||||
}
|
||||
|
||||
function isRiskGrade(v: unknown): v is RiskGrade {
|
||||
return typeof v === 'string' && (RISK_GRADE_VALUES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
/** 引擎分级与 LLM 倾向分级是否显著偏差(相邻一级以内视为一致)。 */
|
||||
export function computeDivergence(engine: RiskGrade, llm: RiskGrade): boolean {
|
||||
const order: Record<RiskGrade, number> = { 低: 0, 中: 1, 高: 2, 极高: 3 };
|
||||
return Math.abs(order[engine] - order[llm]) >= 2;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
'你是资深外包风控专家。系统已用确定性加权模型算出风险分与分级。',
|
||||
'请基于评分摘要给出**独立的整体研判**,作为评分的补充(不要改动分数本身)。',
|
||||
'重点:识别加权模型可能低估的「交叉/组合风险」(多个因素叠加放大的风险),并给出补充接受条件。',
|
||||
`倾向分级必须是:${RISK_GRADE_VALUES.join('、')} 之一。`,
|
||||
'只输出 JSON:{"suggestedGrade":"<分级>","confidence":0.0,"overall":"<整体研判>","crossRisks":["..."],"suggestedConditions":["..."]}',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* 生成 LLM 综合研判。失败或非法返回 null。
|
||||
*
|
||||
* @param ctx 评分结果上下文。
|
||||
*/
|
||||
export async function synthesize(ctx: SynthesisContext): Promise<SynthesisResult | null> {
|
||||
const itemsBrief = ctx.items
|
||||
.map((i) => `${i.dimensionName}/${i.indicatorName}=L${i.riskLevel}(${i.provenance})`)
|
||||
.join(';');
|
||||
|
||||
const userContent = [
|
||||
`业务类型:${ctx.businessType};行业:${ctx.industry}`,
|
||||
`引擎风险分:${ctx.riskScore};引擎分级:${ctx.riskGrade};可接受性:${ctx.acceptability}`,
|
||||
ctx.hitRedlines && ctx.hitRedlines.length > 0
|
||||
? `命中红线:${ctx.hitRedlines.join('、')}`
|
||||
: '未命中红线',
|
||||
`项目描述:${ctx.projectDescription}`,
|
||||
`各指标等级:${itemsBrief}`,
|
||||
].join('\n');
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await chatJSON(
|
||||
[
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userContent },
|
||||
],
|
||||
{ temperature: 0.3 },
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof raw !== 'object' || raw === null) {
|
||||
return null;
|
||||
}
|
||||
const obj = raw as Record<string, unknown>;
|
||||
if (!isRiskGrade(obj.suggestedGrade)) {
|
||||
return null;
|
||||
}
|
||||
const suggestedGrade = obj.suggestedGrade;
|
||||
const toStrArray = (v: unknown): string[] =>
|
||||
Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||
|
||||
return {
|
||||
suggestedGrade,
|
||||
confidence: normalizeConfidence(Number(obj.confidence ?? 0.6)),
|
||||
overall: typeof obj.overall === 'string' ? obj.overall : '',
|
||||
crossRisks: toStrArray(obj.crossRisks),
|
||||
suggestedConditions: toStrArray(obj.suggestedConditions),
|
||||
divergent: computeDivergence(ctx.riskGrade, suggestedGrade),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 端到端评估流程集成测试(任务 16.2)。
|
||||
*
|
||||
* 自动化测试覆盖从「项目描述输入」到「报告生成与持久化」的完整流程:
|
||||
*
|
||||
* 项目描述输入 → 分类/确认(Classifier)
|
||||
* → 模板加载/继承/实例化(Config_Center)
|
||||
* → 自适应追问(已知信息 + 评估者作答 + 追问耗尽行业默认兜底)
|
||||
* → 评分/归一化/分级/红线(Scoring_Engine)
|
||||
* → 费用测算(Cost_Engine)
|
||||
* → 应对策略/可接受性(Strategy_Engine)
|
||||
* → 报告生成(Report_Generator,Req 10.1)
|
||||
* → 持久化(Persistence,Req 17.1)
|
||||
*
|
||||
* 这是经由 `runAssessment` 编排入口驱动的集成测试(非属性测试),断言:
|
||||
* - 产出合法的 Risk_Score(0-100 整数)与 Risk_Grade(四级之一)。
|
||||
* - 报告包含全部八个规定章节(Req 10.1),未命中红线时明确标注"无红线命中"。
|
||||
* - 评估被持久化且可经 store 回读(saveResult 成功;store 可检索,Req 17.1)。
|
||||
*
|
||||
* 时钟与存储均显式注入(确定性时钟 + InMemoryAssessmentStore),保证可断言持久化结果。
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { runAssessment, type AnswerProvider } from '../index.js';
|
||||
import {
|
||||
InMemoryAssessmentStore,
|
||||
type PersistedAssessment,
|
||||
} from '../../persistence/index.js';
|
||||
import { NO_REDLINE_HIT_LABEL, type Report } from '../../report/index.js';
|
||||
import { RISK_GRADE_VALUES } from '../../domain/index.js';
|
||||
import type {
|
||||
BusinessType,
|
||||
Industry,
|
||||
Indicator,
|
||||
Redline,
|
||||
ScoringRule,
|
||||
Template,
|
||||
} from '../../domain/index.js';
|
||||
import { indicatorKey, type KnownData } from '../../question/index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 测试夹具:业务类型/行业、确定性时钟、覆盖五级的合法模板。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const BUSINESS_TYPE: BusinessType = '业务/服务外包';
|
||||
const INDUSTRY: Industry = '信息技术';
|
||||
|
||||
/** 固定 ISO 时钟,供持久化与元数据创建时间断言。 */
|
||||
const FIXED_NOW = '2024-01-01T00:00:00.000Z';
|
||||
|
||||
/** 覆盖 Risk_Level 1 至 5 的完整评分规则(合法 Indicator 必备)。 */
|
||||
function fullScoringRules(): ScoringRule[] {
|
||||
return [1, 2, 3, 4, 5].map((level) => ({
|
||||
level: level as ScoringRule['level'],
|
||||
label: `等级 ${String(level)}`,
|
||||
description: `Risk_Level ${String(level)} 判定标准`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 构造一个启用指标(权重 50,覆盖全部评分规则)。 */
|
||||
function indicator(id: string, name: string): Indicator {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
weight: 50,
|
||||
enabled: true,
|
||||
scoringRules: fullScoringRules(),
|
||||
evidenceRequired: `${name}的判定证据`,
|
||||
askPrompt: `请补充「${name}」相关信息`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造结构合法、权重归一(同级和为 100%)、覆盖五级评分规则的精确匹配模板。
|
||||
*
|
||||
* 维度 d1 / d2 各权重 50,各含两个权重 50 的启用指标;含一条启用红线。
|
||||
*/
|
||||
function buildTemplate(): Template {
|
||||
const redline: Redline = {
|
||||
id: 'rl-1',
|
||||
triggerCondition: '供应商存在重大失信记录',
|
||||
consequence: '一票否决,不予合作',
|
||||
enabled: true,
|
||||
};
|
||||
return {
|
||||
id: 'tpl-it-service',
|
||||
name: '信息技术服务外包模板',
|
||||
businessType: BUSINESS_TYPE,
|
||||
industry: INDUSTRY,
|
||||
isDefault: true,
|
||||
riskModelConfig: {
|
||||
name: '信息技术服务外包风险模型',
|
||||
businessType: BUSINESS_TYPE,
|
||||
dimensions: [
|
||||
{
|
||||
id: 'd1',
|
||||
name: '客户与商务风险',
|
||||
weight: 50,
|
||||
enabled: true,
|
||||
indicators: [
|
||||
indicator('d1-i1', '客户资信'),
|
||||
indicator('d1-i2', '合同条款'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'd2',
|
||||
name: '合规与用工风险',
|
||||
weight: 50,
|
||||
enabled: true,
|
||||
indicators: [
|
||||
indicator('d2-i1', '社保合规'),
|
||||
indicator('d2-i2', '用工资质'),
|
||||
],
|
||||
},
|
||||
],
|
||||
redlines: [redline],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 预置「已知信息」:视为用户输入的两个指标判定。 */
|
||||
function buildKnownData(): KnownData {
|
||||
return new Map([
|
||||
[indicatorKey('d1', 'd1-i1'), 4],
|
||||
[indicatorKey('d1', 'd1-i2'), 2],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估者作答提供者:对 d2-i1 给出可判定回答(Risk_Level 5),
|
||||
* 对 d2-i2 不作答(保留缺口,交由追问耗尽行业默认值兜底 → 标注"智能体假设")。
|
||||
*/
|
||||
const answerProvider: AnswerProvider = ({ indicator: ind }) => {
|
||||
if (ind.id === 'd2-i1') {
|
||||
return { text: '社保按当地基数足额缴纳,无欠缴记录', determination: 5 };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** 断言报告包含全部八个规定章节且各自携带非空标题(Req 10.1)。 */
|
||||
function expectAllReportSections(report: Report): void {
|
||||
expect(report.projectOverview.title).toBeTruthy();
|
||||
expect(report.riskScoreGrade.title).toBeTruthy();
|
||||
expect(report.heatmap.title).toBeTruthy();
|
||||
expect(report.dimensionDetails.title).toBeTruthy();
|
||||
expect(report.keyRisksAndRedlines.title).toBeTruthy();
|
||||
expect(report.acceptabilityConclusion.title).toBeTruthy();
|
||||
expect(report.responsePlan.title).toBeTruthy();
|
||||
expect(report.assumptionsAndGaps.title).toBeTruthy();
|
||||
}
|
||||
|
||||
describe('runAssessment 端到端评估流程集成测试(任务 16.2)', () => {
|
||||
it('从项目描述输入贯穿至报告生成与持久化,产出合法评分/分级、完整报告并可回读(Req 10.1, 17.1)', async () => {
|
||||
const store = new InMemoryAssessmentStore();
|
||||
const projectDescription =
|
||||
'本项目拟将公司核心业务系统的运维与开发工作整体外包给某信息技术服务供应商,' +
|
||||
'涉及客户数据处理、社保用工合规与合同商务条款等多方面风险,需进行全面评估。';
|
||||
|
||||
const result = await runAssessment({
|
||||
projectDescription,
|
||||
templates: [buildTemplate()],
|
||||
confirmation: { businessType: BUSINESS_TYPE, industry: INDUSTRY },
|
||||
assessorId: 'assessor-007',
|
||||
assessmentId: 'assessment-e2e-16-2',
|
||||
knownData: buildKnownData(),
|
||||
answerProvider,
|
||||
industryDefault: () => 3,
|
||||
maxAskRounds: 3,
|
||||
costInputs: { baselineQuote: 1_000_000 },
|
||||
store,
|
||||
now: () => FIXED_NOW,
|
||||
});
|
||||
|
||||
// --- 分类与确认驱动模板加载(精确匹配行业专用模板) ---
|
||||
expect(result.confirmed.businessType).toBe(BUSINESS_TYPE);
|
||||
expect(result.confirmed.industry).toBe(INDUSTRY);
|
||||
expect(result.templateLoad.matchedIndustrySpecific).toBe(true);
|
||||
expect(result.templateLoad.template.id).toBe('tpl-it-service');
|
||||
|
||||
// --- 地域默认 CN(请求未指定 Region,Req 16.4-16.5) ---
|
||||
expect(result.regionResolution.isSystemDefault).toBe(true);
|
||||
expect(result.regionResolution.region.code).toBe('CN');
|
||||
|
||||
// --- 合法 Risk_Score(0-100 整数)与 Risk_Grade(四级之一) ---
|
||||
expect(Number.isInteger(result.riskScore)).toBe(true);
|
||||
expect(result.riskScore).toBeGreaterThanOrEqual(0);
|
||||
expect(result.riskScore).toBeLessThanOrEqual(100);
|
||||
expect(RISK_GRADE_VALUES).toContain(result.riskGrade);
|
||||
|
||||
// --- 追问 + 兜底后无残留缺口(每个启用指标均有取值) ---
|
||||
expect(result.residualGaps).toEqual([]);
|
||||
|
||||
// --- 报告包含全部八个规定章节(Req 10.1) ---
|
||||
expectAllReportSections(result.report);
|
||||
|
||||
// 章节一:项目概要忠实反映输入与判定。
|
||||
expect(result.report.projectOverview.projectDescription).toBe(projectDescription);
|
||||
expect(result.report.projectOverview.businessType).toBe(BUSINESS_TYPE);
|
||||
expect(result.report.projectOverview.industry).toBe(INDUSTRY);
|
||||
expect(result.report.projectOverview.region.code).toBe('CN');
|
||||
|
||||
// 章节二:分值与分级与编排产出一致。
|
||||
expect(result.report.riskScoreGrade.riskScore).toBe(result.riskScore);
|
||||
expect(result.report.riskScoreGrade.riskGrade).toBe(result.riskGrade);
|
||||
|
||||
// 章节三/四:热力图与维度明细覆盖两个维度的四个指标。
|
||||
expect(result.report.heatmap.cells.length).toBe(4);
|
||||
expect(result.report.dimensionDetails.dimensions.map((d) => d.dimensionId)).toEqual([
|
||||
'd1',
|
||||
'd2',
|
||||
]);
|
||||
|
||||
// 章节五:未配置红线规则解析器,红线安全标注为"待核实"(不计命中)→ "无红线命中"。
|
||||
expect(result.report.keyRisksAndRedlines.hasRedlineHit).toBe(false);
|
||||
expect(result.report.keyRisksAndRedlines.redlineSummary).toBe(NO_REDLINE_HIT_LABEL);
|
||||
expect(result.report.keyRisksAndRedlines.topKeyRisks.length).toBeGreaterThan(0);
|
||||
|
||||
// 章节八:d2-i2 未作答 → 行业默认值兜底 → 标注"智能体假设"并列入信息缺口。
|
||||
const gapKeys = result.report.assumptionsAndGaps.items.map(
|
||||
(item) => `${item.dimensionId}.${item.indicatorId}`,
|
||||
);
|
||||
expect(gapKeys).toContain('d2.d2-i2');
|
||||
for (const item of result.report.assumptionsAndGaps.items) {
|
||||
expect(item.provenance).toBe('智能体假设');
|
||||
expect(item.recommendation).toBeTruthy();
|
||||
}
|
||||
|
||||
// --- 持久化成功且可经 store 回读(Req 17.1) ---
|
||||
expect(result.saveResult.ok).toBe(true);
|
||||
if (!result.saveResult.ok) {
|
||||
throw new Error('持久化应当成功');
|
||||
}
|
||||
expect(result.saveResult.id).toBe('assessment-e2e-16-2');
|
||||
|
||||
expect(store.has('assessment-e2e-16-2')).toBe(true);
|
||||
const persisted: PersistedAssessment | undefined = store.get('assessment-e2e-16-2');
|
||||
expect(persisted).toBeDefined();
|
||||
expect(persisted?.assessment.id).toBe('assessment-e2e-16-2');
|
||||
expect(persisted?.assessment.projectDescription).toBe(projectDescription);
|
||||
|
||||
// 元数据齐备(Req 17.1:业务类型/行业/Region/Risk_Score/Risk_Grade/创建时间/评估者)。
|
||||
const metadata = persisted?.assessment.metadata;
|
||||
expect(metadata?.businessType).toBe(BUSINESS_TYPE);
|
||||
expect(metadata?.industry).toBe(INDUSTRY);
|
||||
expect(metadata?.region.code).toBe('CN');
|
||||
expect(metadata?.riskScore).toBe(result.riskScore);
|
||||
expect(metadata?.riskGrade).toBe(result.riskGrade);
|
||||
expect(metadata?.createdAt).toBe(FIXED_NOW);
|
||||
expect(metadata?.assessorId).toBe('assessor-007');
|
||||
|
||||
// 报告快照随评估一并存档,可回读且与编排产出等价。
|
||||
expect(persisted?.report).toEqual(result.report);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Assessment Orchestrator 应用编排层模块聚合导出(任务 16.1)。
|
||||
*
|
||||
* 对外暴露端到端评估编排入口 `runAssessment` 及其输入/输出类型与可插拔依赖类型
|
||||
* (评估者作答、外部数据降级回退、红线校验配置等)。编排层组合既有领域引擎,
|
||||
* 不实现任何业务规则。
|
||||
*/
|
||||
|
||||
export * from './orchestrator.js';
|
||||
@@ -0,0 +1,746 @@
|
||||
/**
|
||||
* Assessment Orchestrator 端到端评估流程编排(应用编排层,任务 16.1)。
|
||||
*
|
||||
* 本模块实现 `runAssessment`:将既有领域引擎按 design.md「评估流程编排(端到端)」
|
||||
* 时序串联为一次完整评估,连接全部组件且不重复任何引擎逻辑——仅组合各引擎已导出的
|
||||
* 纯函数/服务:
|
||||
*
|
||||
* 分类(Classifier.classify / confirmClassification,Req 1.7)
|
||||
* → 模板加载与回退(Config_Center.loadTemplate,Req 2.1)
|
||||
* → 继承解析 + 实例化(resolveInheritance / instantiateRiskModel,Req 2.x)
|
||||
* → 自适应追问(Question_Engine.identifyGaps / generateQuestions / answerQuestion,
|
||||
* 含 External_Data_Adapter 降级回退 fetchWithFallback,Req 3.1, 15.x)
|
||||
* 与追问耗尽兜底(applyDefaultsOnExhaust)
|
||||
* → 评分 / 归一化 / 分级 / 红线(Scoring_Engine.buildScoringItem /
|
||||
* computeRiskScore / classifyGrade / checkRedlines,Req 4.3, 6.x)
|
||||
* → 费用测算(Cost_Engine.estimate,Req 8.1)
|
||||
* → 应对策略与可接受性(Strategy_Engine.decide,Req 9.1)
|
||||
* → 报告生成(Report_Generator.generate,Req 10.1)
|
||||
* → 持久化(Persistence.save,Req 17.1)
|
||||
*
|
||||
* 地域(Region)经 Compliance.resolveRegion 记录并默认 CN(Req 16.4)。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 编排层不实现任何业务规则,全部委托给领域引擎,保证「指标体系与评分引擎解耦」与
|
||||
* 各引擎已验证的正确性属性在端到端流程中得以复用。
|
||||
* - 追问环节既支持评估者作答({@link AnswerProvider}),也支持经可插拔的
|
||||
* External_Data_Adapter 取数并在失败/超时时降级回退(Req 15.3, 15.4)。
|
||||
* - 编排为 `async`:唯一的异步来源是外部数据取数;未配置外部数据源时亦可完整运行。
|
||||
*/
|
||||
|
||||
import type {
|
||||
Acceptability,
|
||||
Assessment,
|
||||
AssessmentMetadata,
|
||||
BusinessType,
|
||||
DataProvenance,
|
||||
Industry,
|
||||
RedlineResult,
|
||||
RiskGrade,
|
||||
RiskLevel,
|
||||
RiskScore,
|
||||
ScoringItem,
|
||||
Region,
|
||||
} from '../domain/index.js';
|
||||
import { RISK_LEVEL_VALUES } from '../domain/index.js';
|
||||
import type { Dimension, Indicator } from '../domain/index.js';
|
||||
import {
|
||||
classify,
|
||||
confirmClassification,
|
||||
type ClassificationResult,
|
||||
type ConfirmedClassification,
|
||||
} from '../classifier/index.js';
|
||||
import {
|
||||
instantiateRiskModel,
|
||||
loadTemplate,
|
||||
resolveInheritance,
|
||||
type LoadTemplateResult,
|
||||
type TemplateLookup,
|
||||
type TemplateSource,
|
||||
} from '../config/index.js';
|
||||
import type { Template, RiskModel } from '../domain/index.js';
|
||||
import {
|
||||
applyDefaultsOnExhaust,
|
||||
answerQuestion,
|
||||
createAskRoundPolicy,
|
||||
EMPTY_ASK_ROUND_COUNTS,
|
||||
generateQuestions,
|
||||
identifyGaps,
|
||||
indicatorKey,
|
||||
recordAsk,
|
||||
type AskRoundCounts,
|
||||
type AskRoundPolicy,
|
||||
type Answer,
|
||||
type IndustryDefaultProvider,
|
||||
type KnownData,
|
||||
type RiskLevelDetermination,
|
||||
} from '../question/index.js';
|
||||
import {
|
||||
buildScoringItem,
|
||||
checkRedlines,
|
||||
classifyGrade,
|
||||
computeRiskScore,
|
||||
type RedlineConditionResolver,
|
||||
type RedlineDataContext,
|
||||
type RiskLevelResolver,
|
||||
} from '../scoring/index.js';
|
||||
import { estimate, type CostInputs } from '../cost/index.js';
|
||||
import { decide, hasRedlineHit } from '../strategy/index.js';
|
||||
import { generate, type Report } from '../report/index.js';
|
||||
import {
|
||||
save,
|
||||
InMemoryAssessmentStore,
|
||||
type AssessmentStore,
|
||||
type SaveResult,
|
||||
} from '../persistence/index.js';
|
||||
import {
|
||||
fetchWithFallback,
|
||||
type DataSourceAdapter,
|
||||
type DataSourceQuery,
|
||||
type FallbackInput,
|
||||
type ResolvedDataPoint,
|
||||
} from '../adapters/index.js';
|
||||
import { resolveRegion, type RegionResolution } from '../compliance/index.js';
|
||||
|
||||
/** 默认评估者标识(未提供时使用)。 */
|
||||
const DEFAULT_ASSESSOR_ID = 'unknown';
|
||||
|
||||
/** 默认兜底风险等级(行业默认值缺省时采用,落在 [1,5])。 */
|
||||
const DEFAULT_FALLBACK_RISK_LEVEL: RiskLevel = 3;
|
||||
|
||||
/** "智能体假设"兜底取值的缺省置信度。 */
|
||||
const ASSUMPTION_CONFIDENCE = 0;
|
||||
|
||||
/** 评估者作答的缺省置信度。 */
|
||||
const USER_INPUT_CONFIDENCE = 1;
|
||||
|
||||
/**
|
||||
* 评估者作答提供者:为某缺口指标的追问问题提供回答。
|
||||
*
|
||||
* 返回 `undefined` 表示评估者本轮未作答(保留缺口,后续轮次或兜底处理)。
|
||||
*/
|
||||
export type AnswerProvider = (context: {
|
||||
/** 缺口指标所属维度。 */
|
||||
dimension: Dimension;
|
||||
/** 缺口指标。 */
|
||||
indicator: Indicator;
|
||||
/** 当前追问轮次(从 1 起计)。 */
|
||||
round: number;
|
||||
}) => Answer | undefined;
|
||||
|
||||
/**
|
||||
* 外部数据取数与降级回退配置(Req 15.x)。
|
||||
*
|
||||
* 编排层经 {@link fetchWithFallback} 取数,并将解析后的数据点映射为指标的 Risk_Level
|
||||
* 判定。取数失败/超时时由 fallback 链路降级(外部数据 → 用户输入 → 智能体假设),
|
||||
* 其来源标注被原样带入评分项。
|
||||
*/
|
||||
export interface ExternalDataConfig {
|
||||
/** 可插拔外部数据源适配器。 */
|
||||
adapter: DataSourceAdapter;
|
||||
/** 依据当前缺口指标集合构造查询请求。 */
|
||||
buildQuery: (gaps: readonly Indicator[]) => DataSourceQuery;
|
||||
/**
|
||||
* 将解析后的数据点映射为 Risk_Level 判定。
|
||||
* 返回合法 RiskLevel 时该数据点用于回填指标取值;返回 `undefined` 表示无法据此判定。
|
||||
*/
|
||||
toDetermination: (point: ResolvedDataPoint) => RiskLevelDetermination;
|
||||
/** 用户输入回退候选(Req 15.3)。 */
|
||||
userInputs?: readonly FallbackInput[];
|
||||
/** 智能体假设兜底候选(Req 15.4)。 */
|
||||
assumptions?: readonly FallbackInput[];
|
||||
/** 单次请求超时上限(毫秒),默认 10 秒(Req 15.1)。 */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 红线校验配置:将红线触发条件映射为可计算规则并提供数据上下文(Req 6.x)。
|
||||
*
|
||||
* 缺省时编排层以「无可计算规则 + 空数据上下文」运行,全部启用红线被安全标注为
|
||||
* "待核实"(不计命中),不影响其余流程。
|
||||
*/
|
||||
export interface RedlineConfig {
|
||||
/** 红线触发条件评估规则解析器。 */
|
||||
resolveCondition: RedlineConditionResolver;
|
||||
/** 红线相关数据点查询上下文。 */
|
||||
dataContext: RedlineDataContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* `runAssessment` 的输入参数。
|
||||
*/
|
||||
export interface RunAssessmentInput {
|
||||
/** 项目描述原文(< 10 有效字符将由 Classifier 拒绝,Req 1.6)。 */
|
||||
projectDescription: string;
|
||||
/** 模板来源:模板数组或 Knowledge_Base 快照(供 loadTemplate 与继承解析)。 */
|
||||
templates: TemplateSource;
|
||||
/**
|
||||
* 父模板查找函数(供 resolveInheritance)。缺省时由 `templates` 按 id 自动构建。
|
||||
*/
|
||||
templateLookup?: TemplateLookup;
|
||||
/** 采用的地域;未指定时默认 CN 并标注系统默认(Req 16.4-16.5)。 */
|
||||
region?: Region | null;
|
||||
/** 评估者身份标识。 */
|
||||
assessorId?: string;
|
||||
/** 评估标识;缺省时自动生成。 */
|
||||
assessmentId?: string;
|
||||
/**
|
||||
* 分类确认/修改值(Req 1.7)。缺省字段回退到 Classifier 的判定值。
|
||||
* 后续模板加载以确认值为依据。
|
||||
*/
|
||||
confirmation?: { businessType?: BusinessType; industry?: Industry };
|
||||
/**
|
||||
* 预先已知的指标判定(视为"用户输入")。键为 {@link indicatorKey} 复合键。
|
||||
*/
|
||||
knownData?: KnownData;
|
||||
/** 评估者作答提供者(追问环节)。 */
|
||||
answerProvider?: AnswerProvider;
|
||||
/** 外部数据取数与降级回退配置(Req 15.x)。 */
|
||||
externalData?: ExternalDataConfig;
|
||||
/** 行业默认值提供者(追问耗尽兜底,Req 3.6)。缺省时统一取 {@link DEFAULT_FALLBACK_RISK_LEVEL}。 */
|
||||
industryDefault?: IndustryDefaultProvider;
|
||||
/** 兜底风险等级(无行业默认值时采用)。 */
|
||||
defaultRiskLevel?: RiskLevel;
|
||||
/** 单指标最大追问轮次(正整数,默认 3,Req 3.5)。 */
|
||||
maxAskRounds?: number;
|
||||
/** 费用测算成本输入(缺失项由 Cost_Engine 兜底为"智能体假设",Req 8.4)。 */
|
||||
costInputs?: CostInputs;
|
||||
/** 红线校验配置(Req 6.x)。 */
|
||||
redline?: RedlineConfig;
|
||||
/** Top N 关键风险的 N(1-50,默认 10,Req 7.2)。 */
|
||||
topN?: number;
|
||||
/** 评估存储;缺省时使用进程内存储。 */
|
||||
store?: AssessmentStore;
|
||||
/** 时间提供者(返回 ISO 字符串),便于测试注入确定性时钟。 */
|
||||
now?: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `runAssessment` 的返回结果:贯穿全链路的关键产物。
|
||||
*/
|
||||
export interface RunAssessmentResult {
|
||||
/** 持久化的完整评估记录。 */
|
||||
assessment: Assessment;
|
||||
/** 生成的结构化报告。 */
|
||||
report: Report;
|
||||
/** 原始分类结果。 */
|
||||
classification: ClassificationResult;
|
||||
/** 确认后的业务类型与行业(驱动模板加载)。 */
|
||||
confirmed: ConfirmedClassification;
|
||||
/** 模板加载结果(含是否回退默认模板)。 */
|
||||
templateLoad: LoadTemplateResult;
|
||||
/** 地域解析结果(含是否系统默认)。 */
|
||||
regionResolution: RegionResolution;
|
||||
/** 归一化风险总分。 */
|
||||
riskScore: RiskScore;
|
||||
/** 风险分级。 */
|
||||
riskGrade: RiskGrade;
|
||||
/** 可接受性结论。 */
|
||||
acceptability: Acceptability;
|
||||
/** 兜底后仍残留的信息缺口(正常应为空)。 */
|
||||
residualGaps: Indicator[];
|
||||
/** 持久化结果(成功/失败)。 */
|
||||
saveResult: SaveResult;
|
||||
}
|
||||
|
||||
/** 运行时判断给定值是否为合法 RiskLevel(1 至 5)。 */
|
||||
function isRiskLevel(value: unknown): value is RiskLevel {
|
||||
return (
|
||||
typeof value === 'number' && (RISK_LEVEL_VALUES as readonly number[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** 将模板来源归一化为模板数组(用于构建默认 templateLookup)。 */
|
||||
function toTemplateArray(source: TemplateSource): readonly Template[] {
|
||||
if (Array.isArray(source)) {
|
||||
return source;
|
||||
}
|
||||
// 非数组即 KnowledgeBase 快照:汇总各行业分区下的模板。
|
||||
const knowledgeBase = source as Exclude<TemplateSource, readonly Template[]>;
|
||||
return knowledgeBase.partitions.flatMap((partition) => partition.templates);
|
||||
}
|
||||
|
||||
/** 由模板来源构建按 id 索引的父模板查找函数。 */
|
||||
function buildTemplateLookup(source: TemplateSource): TemplateLookup {
|
||||
const byId = new Map<string, Template>();
|
||||
for (const template of toTemplateArray(source)) {
|
||||
byId.set(template.id, template);
|
||||
}
|
||||
return (id) => byId.get(id);
|
||||
}
|
||||
|
||||
/** 单个指标在评估中解析得到的取值信息。 */
|
||||
interface ResolvedIndicatorValue {
|
||||
level: RiskLevel;
|
||||
provenance: DataProvenance;
|
||||
confidence: number;
|
||||
dataPointValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编排层内部的「已知信息工作台」:跟踪 knownData 及每个指标的来源/置信/取值描述。
|
||||
*
|
||||
* knownData 以 ReadonlyMap 在引擎间传递,本工作台以可变映射聚合编排过程中的元信息,
|
||||
* 供最终构造评分项({@link buildScoringItem})时回填来源与置信。
|
||||
*/
|
||||
class KnownDataWorkbench {
|
||||
knownData: KnownData;
|
||||
private readonly provenance = new Map<string, DataProvenance>();
|
||||
private readonly confidence = new Map<string, number>();
|
||||
private readonly dataPointValue = new Map<string, string>();
|
||||
|
||||
constructor(seed?: KnownData) {
|
||||
this.knownData = new Map(seed ?? []);
|
||||
// 预置的已知信息视为"用户输入"。
|
||||
for (const [key, det] of this.knownData) {
|
||||
if (isRiskLevel(det)) {
|
||||
this.provenance.set(key, '用户输入');
|
||||
this.confidence.set(key, USER_INPUT_CONFIDENCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否已为某键解析到合法 Risk_Level。 */
|
||||
has(key: string): boolean {
|
||||
return isRiskLevel(this.knownData.get(key));
|
||||
}
|
||||
|
||||
/** 写入一个指标的判定与元信息。 */
|
||||
set(
|
||||
key: string,
|
||||
level: RiskLevel,
|
||||
provenance: DataProvenance,
|
||||
confidence: number,
|
||||
dataPointValue?: string,
|
||||
): void {
|
||||
const updated = new Map<string, RiskLevelDetermination>(this.knownData);
|
||||
updated.set(key, level);
|
||||
this.knownData = updated;
|
||||
this.provenance.set(key, provenance);
|
||||
this.confidence.set(key, confidence);
|
||||
if (dataPointValue !== undefined) {
|
||||
this.dataPointValue.set(key, dataPointValue);
|
||||
}
|
||||
}
|
||||
|
||||
/** 以引擎返回的 knownData 替换工作台快照(如 answerQuestion / applyDefaults 的结果)。 */
|
||||
replace(knownData: KnownData): void {
|
||||
this.knownData = knownData;
|
||||
}
|
||||
|
||||
/** 标注某键的来源/置信/取值(不改动 knownData 判定本身)。 */
|
||||
annotate(
|
||||
key: string,
|
||||
provenance: DataProvenance,
|
||||
confidence: number,
|
||||
dataPointValue?: string,
|
||||
): void {
|
||||
this.provenance.set(key, provenance);
|
||||
this.confidence.set(key, confidence);
|
||||
if (dataPointValue !== undefined) {
|
||||
this.dataPointValue.set(key, dataPointValue);
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取某键的来源标注(缺省"用户输入")。 */
|
||||
provenanceOf(key: string): DataProvenance {
|
||||
return this.provenance.get(key) ?? '用户输入';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析某指标的最终取值;缺省时回退到兜底等级并标注"智能体假设"。
|
||||
* 保证评分链路对每个启用指标恒有可用取值。
|
||||
*/
|
||||
resolve(key: string, fallbackLevel: RiskLevel): ResolvedIndicatorValue {
|
||||
const det = this.knownData.get(key);
|
||||
if (isRiskLevel(det)) {
|
||||
const value: ResolvedIndicatorValue = {
|
||||
level: det,
|
||||
provenance: this.provenance.get(key) ?? '用户输入',
|
||||
confidence: this.confidence.get(key) ?? USER_INPUT_CONFIDENCE,
|
||||
};
|
||||
const dpv = this.dataPointValue.get(key);
|
||||
return dpv !== undefined ? { ...value, dataPointValue: dpv } : value;
|
||||
}
|
||||
return {
|
||||
level: fallbackLevel,
|
||||
provenance: '智能体假设',
|
||||
confidence: ASSUMPTION_CONFIDENCE,
|
||||
dataPointValue: '行业默认值(未获取到判定信息)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { KnownDataWorkbench };
|
||||
|
||||
/** 缺口指标在风险模型中的定位(其所属维度与自身)。 */
|
||||
interface IndicatorLocation {
|
||||
dimension: Dimension;
|
||||
indicator: Indicator;
|
||||
}
|
||||
|
||||
/** 构建:复合键 → 指标定位;指标对象 → 所属维度。 */
|
||||
function buildIndicatorIndex(riskModel: RiskModel): {
|
||||
byKey: Map<string, IndicatorLocation>;
|
||||
dimensionOf: Map<Indicator, Dimension>;
|
||||
byIndicatorId: Map<string, IndicatorLocation[]>;
|
||||
} {
|
||||
const byKey = new Map<string, IndicatorLocation>();
|
||||
const dimensionOf = new Map<Indicator, Dimension>();
|
||||
const byIndicatorId = new Map<string, IndicatorLocation[]>();
|
||||
for (const dimension of riskModel.dimensions) {
|
||||
for (const indicator of dimension.indicators) {
|
||||
const loc: IndicatorLocation = { dimension, indicator };
|
||||
byKey.set(indicatorKey(dimension.id, indicator.id), loc);
|
||||
dimensionOf.set(indicator, dimension);
|
||||
const list = byIndicatorId.get(indicator.id);
|
||||
if (list === undefined) {
|
||||
byIndicatorId.set(indicator.id, [loc]);
|
||||
} else {
|
||||
list.push(loc);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { byKey, dimensionOf, byIndicatorId };
|
||||
}
|
||||
|
||||
/**
|
||||
* 经 External_Data_Adapter 取数并降级回退,将解析数据点回填到工作台(Req 15.x)。
|
||||
*
|
||||
* 对每个解析数据点:经 `toDetermination` 映射为 Risk_Level;命中合法等级且对应键尚未
|
||||
* 有判定时,按数据点自身的来源标注(外部数据/用户输入/智能体假设)回填到所有同标识
|
||||
* 指标。取数失败/超时由 fallback 链路降级,编排流程不中断(Req 15.4)。
|
||||
*/
|
||||
async function seedExternalData(
|
||||
config: ExternalDataConfig,
|
||||
gaps: readonly Indicator[],
|
||||
workbench: KnownDataWorkbench,
|
||||
byIndicatorId: Map<string, IndicatorLocation[]>,
|
||||
): Promise<void> {
|
||||
const query = config.buildQuery(gaps);
|
||||
const result = await fetchWithFallback({
|
||||
adapter: config.adapter,
|
||||
query,
|
||||
...(config.userInputs !== undefined ? { userInputs: config.userInputs } : {}),
|
||||
...(config.assumptions !== undefined ? { assumptions: config.assumptions } : {}),
|
||||
...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}),
|
||||
});
|
||||
|
||||
for (const point of result.dataPoints) {
|
||||
const level = config.toDetermination(point);
|
||||
if (!isRiskLevel(level)) {
|
||||
continue;
|
||||
}
|
||||
const id = point.indicatorId ?? point.field;
|
||||
const locations = byIndicatorId.get(id) ?? [];
|
||||
for (const { dimension, indicator } of locations) {
|
||||
const key = indicatorKey(dimension.id, indicator.id);
|
||||
if (workbench.has(key)) {
|
||||
continue;
|
||||
}
|
||||
workbench.set(key, level, point.provenance, point.confidence, String(point.value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 驱动自适应追问循环(Req 3.1-3.4):逐轮识别缺口、生成问题并处理评估者作答。
|
||||
*
|
||||
* 每轮对当前全部缺口指标生成问题并 {@link recordAsk} 计入轮次(即便未作答,轮次亦累加,
|
||||
* 以保证达上限后可触发兜底)。接受的回答更新 knownData 并标注"用户输入"。
|
||||
* 当无剩余缺口或达到最大轮次时结束。返回累计的追问轮次计数。
|
||||
*/
|
||||
function runQuestionLoop(
|
||||
riskModel: RiskModel,
|
||||
workbench: KnownDataWorkbench,
|
||||
policy: AskRoundPolicy,
|
||||
byKey: Map<string, IndicatorLocation>,
|
||||
answerProvider?: AnswerProvider,
|
||||
): AskRoundCounts {
|
||||
let askRounds: AskRoundCounts = EMPTY_ASK_ROUND_COUNTS;
|
||||
|
||||
for (let round = 1; round <= policy.maxRounds; round += 1) {
|
||||
const gaps = identifyGaps(riskModel, workbench.knownData);
|
||||
if (gaps.length === 0) {
|
||||
break;
|
||||
}
|
||||
const questions = generateQuestions(riskModel, gaps, round);
|
||||
for (const question of questions) {
|
||||
const key = indicatorKey(question.dimensionId, question.indicatorId);
|
||||
const loc = byKey.get(key);
|
||||
if (loc === undefined) {
|
||||
continue;
|
||||
}
|
||||
askRounds = recordAsk(askRounds, policy, question.dimensionId, question.indicatorId);
|
||||
|
||||
const answer = answerProvider?.({
|
||||
dimension: loc.dimension,
|
||||
indicator: loc.indicator,
|
||||
round,
|
||||
});
|
||||
if (answer === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const update = answerQuestion(
|
||||
riskModel,
|
||||
workbench.knownData,
|
||||
{ dimensionId: question.dimensionId, indicatorId: question.indicatorId },
|
||||
answer,
|
||||
);
|
||||
if (update.accepted) {
|
||||
workbench.replace(update.knownData);
|
||||
workbench.annotate(key, '用户输入', USER_INPUT_CONFIDENCE, answer.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return askRounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对追问耗尽后仍残留的缺口指标采用行业默认值兜底并标注"智能体假设"(Req 3.5, 3.6)。
|
||||
*/
|
||||
function applyDefaultsForRemainingGaps(
|
||||
riskModel: RiskModel,
|
||||
workbench: KnownDataWorkbench,
|
||||
askRounds: AskRoundCounts,
|
||||
policy: AskRoundPolicy,
|
||||
industryDefault: IndustryDefaultProvider,
|
||||
dimensionOf: Map<Indicator, Dimension>,
|
||||
): void {
|
||||
const remaining = identifyGaps(riskModel, workbench.knownData);
|
||||
for (const indicator of remaining) {
|
||||
const dimension = dimensionOf.get(indicator);
|
||||
if (dimension === undefined) {
|
||||
continue;
|
||||
}
|
||||
const key = indicatorKey(dimension.id, indicator.id);
|
||||
const result = applyDefaultsOnExhaust(
|
||||
riskModel,
|
||||
{ dimensionId: dimension.id, indicatorId: indicator.id },
|
||||
workbench.knownData,
|
||||
askRounds,
|
||||
industryDefault,
|
||||
{ policy, currentProvenance: workbench.provenanceOf(key) },
|
||||
);
|
||||
if (result.applied) {
|
||||
workbench.replace(result.knownData);
|
||||
workbench.annotate(
|
||||
key,
|
||||
result.provenance,
|
||||
ASSUMPTION_CONFIDENCE,
|
||||
'行业默认值(追问轮次耗尽兜底)',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为风险模型每个启用维度下的启用指标构造评分项,并建立指标对象 → Risk_Level 解析映射。
|
||||
*
|
||||
* 评分项经 {@link buildScoringItem} 产出(含得分、来源、置信与可解释三要素);解析映射
|
||||
* 供 {@link computeRiskScore} / buildHeatmap / topKeyRisks 等以指标对象按需取 Risk_Level。
|
||||
*/
|
||||
function buildScoring(
|
||||
riskModel: RiskModel,
|
||||
workbench: KnownDataWorkbench,
|
||||
fallbackLevel: RiskLevel,
|
||||
): { scoringItems: ScoringItem[]; levelByIndicator: Map<Indicator, RiskLevel> } {
|
||||
const scoringItems: ScoringItem[] = [];
|
||||
const levelByIndicator = new Map<Indicator, RiskLevel>();
|
||||
|
||||
for (const dimension of riskModel.dimensions) {
|
||||
for (const indicator of dimension.indicators) {
|
||||
if (!indicator.enabled) {
|
||||
continue;
|
||||
}
|
||||
const key = indicatorKey(dimension.id, indicator.id);
|
||||
const resolved = workbench.resolve(key, fallbackLevel);
|
||||
levelByIndicator.set(indicator, resolved.level);
|
||||
|
||||
// 仅启用维度下的启用指标计入评分项(与 computeRiskScore 计分口径一致)。
|
||||
if (!dimension.enabled) {
|
||||
continue;
|
||||
}
|
||||
scoringItems.push(
|
||||
buildScoringItem({
|
||||
dimension,
|
||||
indicator,
|
||||
riskLevel: resolved.level,
|
||||
provenance: resolved.provenance,
|
||||
confidence: resolved.confidence,
|
||||
...(resolved.dataPointValue !== undefined
|
||||
? { dataPointValue: resolved.dataPointValue }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { scoringItems, levelByIndicator };
|
||||
}
|
||||
|
||||
/** 生成评估标识(无外部依赖:时间戳 + 随机后缀)。 */
|
||||
function generateAssessmentId(): string {
|
||||
const random = Math.random().toString(36).slice(2, 10);
|
||||
return `assessment-${Date.now().toString(36)}-${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 端到端运行一次外包项目风险评估(任务 16.1)。
|
||||
*
|
||||
* 按 design.md 的评估流程时序串联全部领域引擎:分类 → 模板加载/继承/实例化 →
|
||||
* 自适应追问(含外部数据降级回退与耗尽兜底)→ 评分/归一化/分级/红线 → 费用测算 →
|
||||
* 应对策略/可接受性 → 报告生成 → 持久化。编排层不实现业务规则,全部委托引擎。
|
||||
*
|
||||
* @param input 评估输入与各环节可选依赖。
|
||||
* @returns 贯穿全链路的关键产物(评估记录、报告、分类、评分、持久化结果等)。
|
||||
* @throws {InsufficientInputError} 项目描述信息不足时(Classifier,Req 1.6)。
|
||||
* @throws {NoAvailableTemplateError} 无可用模板时(Config_Center,Req 2.6)。
|
||||
* @throws {TemplateInheritanceError | TemplateDataError} 模板继承/数据非法时(Req 2.4, 2.7)。
|
||||
* @throws {ScoringError} 无任何可计分启用项时(Scoring_Engine,Req 4.5)。
|
||||
*/
|
||||
export async function runAssessment(
|
||||
input: RunAssessmentInput,
|
||||
): Promise<RunAssessmentResult> {
|
||||
const now = input.now ?? (() => new Date().toISOString());
|
||||
const assessorId = input.assessorId ?? DEFAULT_ASSESSOR_ID;
|
||||
const fallbackLevel = input.defaultRiskLevel ?? DEFAULT_FALLBACK_RISK_LEVEL;
|
||||
|
||||
// 1) 分类与确认(Req 1.x)。
|
||||
const classification = classify(input.projectDescription);
|
||||
const confirmed = confirmClassification(
|
||||
input.confirmation?.businessType ?? classification.businessType,
|
||||
input.confirmation?.industry ?? classification.industry,
|
||||
);
|
||||
|
||||
// 2) 地域记录与默认(Req 16.4-16.5)。
|
||||
const regionResolution = resolveRegion(input.region ?? null);
|
||||
|
||||
// 3) 模板加载与回退 → 继承解析 → 实例化(Req 2.x)。
|
||||
const templateLoad = loadTemplate(
|
||||
confirmed.businessType,
|
||||
confirmed.industry,
|
||||
input.templates,
|
||||
);
|
||||
const templateLookup = input.templateLookup ?? buildTemplateLookup(input.templates);
|
||||
const resolvedTemplate = resolveInheritance(templateLoad.template, templateLookup);
|
||||
const riskModel = instantiateRiskModel(resolvedTemplate);
|
||||
|
||||
const { byKey, dimensionOf, byIndicatorId } = buildIndicatorIndex(riskModel);
|
||||
|
||||
// 4) 自适应追问:外部数据降级回退 → 评估者作答 → 耗尽兜底(Req 3.x, 15.x)。
|
||||
const workbench = new KnownDataWorkbench(input.knownData);
|
||||
const policy = createAskRoundPolicy(input.maxAskRounds);
|
||||
|
||||
if (input.externalData !== undefined) {
|
||||
const gaps = identifyGaps(riskModel, workbench.knownData);
|
||||
await seedExternalData(input.externalData, gaps, workbench, byIndicatorId);
|
||||
}
|
||||
|
||||
const askRounds = runQuestionLoop(
|
||||
riskModel,
|
||||
workbench,
|
||||
policy,
|
||||
byKey,
|
||||
input.answerProvider,
|
||||
);
|
||||
|
||||
const industryDefault: IndustryDefaultProvider =
|
||||
input.industryDefault ?? (() => fallbackLevel);
|
||||
applyDefaultsForRemainingGaps(
|
||||
riskModel,
|
||||
workbench,
|
||||
askRounds,
|
||||
policy,
|
||||
industryDefault,
|
||||
dimensionOf,
|
||||
);
|
||||
|
||||
// 5) 评分项构造与归一化/分级(Req 4.x, 5.x)。
|
||||
const { scoringItems, levelByIndicator } = buildScoring(
|
||||
riskModel,
|
||||
workbench,
|
||||
fallbackLevel,
|
||||
);
|
||||
const resolveRiskLevel: RiskLevelResolver = (indicator) =>
|
||||
levelByIndicator.get(indicator) ?? fallbackLevel;
|
||||
|
||||
const riskScore = computeRiskScore(riskModel, resolveRiskLevel);
|
||||
const riskGrade: RiskGrade = classifyGrade(riskScore);
|
||||
|
||||
// 6) 红线校验(独立于分值通道,Req 6.x)。
|
||||
const resolveCondition: RedlineConditionResolver =
|
||||
input.redline?.resolveCondition ?? (() => undefined);
|
||||
const dataContext: RedlineDataContext =
|
||||
input.redline?.dataContext ?? { get: () => undefined };
|
||||
const redlineResults: RedlineResult[] = checkRedlines(
|
||||
riskModel.redlines,
|
||||
resolveCondition,
|
||||
dataContext,
|
||||
);
|
||||
|
||||
// 7) 费用测算(Req 8.x)。
|
||||
const costEstimate = estimate(
|
||||
{ riskScore, riskGrade },
|
||||
input.costInputs ?? {},
|
||||
);
|
||||
|
||||
// 8) 可接受性结论(Req 9.1;红线最高优先)。
|
||||
const acceptability: Acceptability = decide(riskGrade, hasRedlineHit(redlineResults));
|
||||
|
||||
// 9) 组装评估记录。
|
||||
const createdAt = now();
|
||||
const assessmentId = input.assessmentId ?? generateAssessmentId();
|
||||
const metadata: AssessmentMetadata = {
|
||||
businessType: confirmed.businessType,
|
||||
industry: confirmed.industry,
|
||||
region: regionResolution.region,
|
||||
riskScore,
|
||||
riskGrade,
|
||||
createdAt,
|
||||
assessorId,
|
||||
};
|
||||
const assessment: Assessment = {
|
||||
id: assessmentId,
|
||||
projectDescription: input.projectDescription,
|
||||
businessType: confirmed.businessType,
|
||||
industry: confirmed.industry,
|
||||
region: regionResolution.region,
|
||||
riskModel,
|
||||
scoringItems,
|
||||
riskScore,
|
||||
riskGrade,
|
||||
redlineResults,
|
||||
costEstimate,
|
||||
acceptability,
|
||||
metadata,
|
||||
createdAt,
|
||||
assessorId,
|
||||
};
|
||||
|
||||
// 10) 报告生成(Req 10.1)。
|
||||
const report = generate(
|
||||
assessment,
|
||||
input.topN !== undefined ? { topN: input.topN } : {},
|
||||
);
|
||||
|
||||
// 11) 持久化(Req 17.1, 17.2)。
|
||||
const store = input.store ?? new InMemoryAssessmentStore();
|
||||
const saveResult = save(assessment, store, report);
|
||||
|
||||
return {
|
||||
assessment,
|
||||
report,
|
||||
classification,
|
||||
confirmed,
|
||||
templateLoad,
|
||||
regionResolution,
|
||||
riskScore,
|
||||
riskGrade,
|
||||
acceptability,
|
||||
residualGaps: identifyGaps(riskModel, workbench.knownData),
|
||||
saveResult,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Property 59: 跨项目对比的数量约束与内容 的属性化测试(Persistence,Req 17.5, 17.6)。
|
||||
*
|
||||
* 属性陈述:对任意被选中的 Assessment 集合——
|
||||
* - 当数量不少于 2 时,`compare` 必返回每个被选中 Assessment 的 Risk_Grade、Risk_Score
|
||||
* 与关键风险(key risks)对比数据(Req 17.5);
|
||||
* - 当数量少于 2 时,`compare` 必拒绝对比并以 {@link ComparisonSelectionError} 提示
|
||||
* 至少需选择 2 个评估(Req 17.6)。
|
||||
*
|
||||
* 本测试以与被测实现相互独立的方式构造若干持久化评估记录,注入内存存储后调用 `compare`:
|
||||
* 对 ≥2 选中断言条目数量、逐项标识/分级/总分对应正确,且关键风险按得分降序且与评分项一致;
|
||||
* 对 <2 选中断言抛出 ComparisonSelectionError、提示含"至少需选择 2 个"且记录实际选中数量。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 59: 跨项目对比的数量约束与内容
|
||||
* Validates: Requirements 17.5, 17.6
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
BUSINESS_TYPE_VALUES,
|
||||
DATA_PROVENANCE_VALUES,
|
||||
RISK_GRADE_VALUES,
|
||||
RISK_LEVEL_VALUES,
|
||||
type BusinessType,
|
||||
type DataProvenance,
|
||||
type RiskGrade,
|
||||
type RiskLevel,
|
||||
} from '../../domain/common.js';
|
||||
import type {
|
||||
Assessment,
|
||||
AssessmentMetadata,
|
||||
ScoringItem,
|
||||
} from '../../domain/assessment.js';
|
||||
import type { RiskModel } from '../../domain/model.js';
|
||||
import type { Region } from '../../domain/region.js';
|
||||
import {
|
||||
ComparisonSelectionError,
|
||||
InMemoryAssessmentStore,
|
||||
MIN_COMPARISON_SELECTION,
|
||||
compare,
|
||||
type PersistedAssessment,
|
||||
} from '../index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:构造完成态 Assessment(含齐备元数据与评分项)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const businessTypeArb = fc.constantFrom<BusinessType>(
|
||||
...(BUSINESS_TYPE_VALUES as readonly BusinessType[]),
|
||||
);
|
||||
|
||||
const riskGradeArb = fc.constantFrom<RiskGrade>(
|
||||
...(RISK_GRADE_VALUES as readonly RiskGrade[]),
|
||||
);
|
||||
|
||||
const riskLevelArb = fc.constantFrom<RiskLevel>(
|
||||
...(RISK_LEVEL_VALUES as readonly RiskLevel[]),
|
||||
);
|
||||
|
||||
const provenanceArb = fc.constantFrom<DataProvenance>(
|
||||
...(DATA_PROVENANCE_VALUES as readonly DataProvenance[]),
|
||||
);
|
||||
|
||||
/** 置信度:[0,1] 两位小数。 */
|
||||
const confidenceArb = fc
|
||||
.integer({ min: 0, max: 100 })
|
||||
.map((n) => Number((n / 100).toFixed(2)));
|
||||
|
||||
/** 归一化风险总分:[0,100] 整数。 */
|
||||
const riskScoreArb = fc.integer({ min: 0, max: 100 });
|
||||
|
||||
/** ISO 8601 时间字符串。 */
|
||||
const isoDateArb = fc
|
||||
.date({ min: new Date('2000-01-01T00:00:00Z'), max: new Date('2100-01-01T00:00:00Z') })
|
||||
.map((d) => d.toISOString());
|
||||
|
||||
const regionArb: fc.Arbitrary<Region> = fc.record({
|
||||
code: fc.constantFrom('CN', 'US', 'EU', 'JP'),
|
||||
name: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
});
|
||||
|
||||
const scoringItemArb: fc.Arbitrary<ScoringItem> = fc.record({
|
||||
dimensionId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
indicatorId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
riskLevel: riskLevelArb,
|
||||
score: fc.double({ min: 0, max: 500, noNaN: true }),
|
||||
provenance: provenanceArb,
|
||||
confidence: confidenceArb,
|
||||
rationale: fc.string({ minLength: 1, maxLength: 12 }),
|
||||
riskImpact: fc.string({ minLength: 1, maxLength: 12 }),
|
||||
recommendation: fc.string({ minLength: 1, maxLength: 12 }),
|
||||
});
|
||||
|
||||
const riskModelArb: fc.Arbitrary<RiskModel> = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
businessType: businessTypeArb,
|
||||
dimensions: fc.constant([]),
|
||||
redlines: fc.constant([]),
|
||||
});
|
||||
|
||||
const metadataArb: fc.Arbitrary<AssessmentMetadata> = fc.record({
|
||||
businessType: businessTypeArb,
|
||||
industry: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
region: regionArb,
|
||||
riskScore: riskScoreArb,
|
||||
riskGrade: riskGradeArb,
|
||||
createdAt: isoDateArb,
|
||||
assessorId: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
});
|
||||
|
||||
/** 完成态 Assessment(id 由外部注入以保证唯一)。 */
|
||||
function assessmentArb(id: string): fc.Arbitrary<Assessment> {
|
||||
return fc.record({
|
||||
id: fc.constant(id),
|
||||
projectDescription: fc.string({ minLength: 1, maxLength: 40 }),
|
||||
businessType: businessTypeArb,
|
||||
industry: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
region: regionArb,
|
||||
riskModel: riskModelArb,
|
||||
scoringItems: fc.array(scoringItemArb, { maxLength: 6 }),
|
||||
riskScore: riskScoreArb,
|
||||
riskGrade: riskGradeArb,
|
||||
redlineResults: fc.constant([]),
|
||||
metadata: metadataArb,
|
||||
createdAt: isoDateArb,
|
||||
assessorId: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 一组 id 唯一的完成态 Assessment。 */
|
||||
function uniqueAssessmentsArb(
|
||||
minLength: number,
|
||||
maxLength: number,
|
||||
): fc.Arbitrary<Assessment[]> {
|
||||
return fc
|
||||
.uniqueArray(fc.string({ minLength: 1, maxLength: 10 }), {
|
||||
minLength,
|
||||
maxLength,
|
||||
})
|
||||
.chain((ids) => fc.tuple(...ids.map((id) => assessmentArb(id))));
|
||||
}
|
||||
|
||||
/** 将一组 Assessment 注入新建的内存存储并返回。 */
|
||||
function storeWith(assessments: readonly Assessment[]): InMemoryAssessmentStore {
|
||||
const store = new InMemoryAssessmentStore();
|
||||
for (const assessment of assessments) {
|
||||
const record: PersistedAssessment = {
|
||||
assessment,
|
||||
savedAt: assessment.createdAt,
|
||||
};
|
||||
store.put(record);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
/** 关键风险对比键:用于无序多重集比对(含得分次序无关的稳定排序)。 */
|
||||
function riskKey(item: {
|
||||
dimensionId: string;
|
||||
indicatorId: string;
|
||||
score: number;
|
||||
rationale: string;
|
||||
}): string {
|
||||
return [item.dimensionId, item.indicatorId, item.score, item.rationale].join('\u0000');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 59: 跨项目对比的数量约束与内容 (Req 17.5, 17.6)', () => {
|
||||
it('选中 ≥2 个时逐项返回 Risk_Grade、Risk_Score 与关键风险对比数据 (Req 17.5)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
uniqueAssessmentsArb(MIN_COMPARISON_SELECTION, 6).chain((assessments) =>
|
||||
fc.record({
|
||||
assessments: fc.constant(assessments),
|
||||
// 选中其子集(保持顺序),至少 2 个。
|
||||
selectedIds: fc.subarray(
|
||||
assessments.map((a) => a.id),
|
||||
{ minLength: MIN_COMPARISON_SELECTION },
|
||||
),
|
||||
}),
|
||||
),
|
||||
({ assessments, selectedIds }) => {
|
||||
const store = storeWith(assessments);
|
||||
const byId = new Map(assessments.map((a) => [a.id, a]));
|
||||
|
||||
const result = compare(selectedIds, store);
|
||||
|
||||
// 条目数量与选中数量一致,且逐项顺序对应。
|
||||
expect(result.entries).toHaveLength(selectedIds.length);
|
||||
|
||||
selectedIds.forEach((id, index) => {
|
||||
const entry = result.entries[index];
|
||||
expect(entry).toBeDefined();
|
||||
if (entry === undefined) return;
|
||||
|
||||
const source = byId.get(id);
|
||||
expect(source).toBeDefined();
|
||||
if (source === undefined) return;
|
||||
|
||||
// 标识、分级、总分取自被选中 Assessment 的元数据(Req 17.5)。
|
||||
expect(entry.assessmentId).toBe(id);
|
||||
expect(entry.riskGrade).toBe(source.metadata.riskGrade);
|
||||
expect(entry.riskScore).toBe(source.metadata.riskScore);
|
||||
|
||||
// 关键风险数量与评分项一致(未限定 Top N)。
|
||||
expect(entry.keyRisks).toHaveLength(source.scoringItems.length);
|
||||
|
||||
// 关键风险按得分降序。
|
||||
for (let j = 1; j < entry.keyRisks.length; j += 1) {
|
||||
const prev = entry.keyRisks[j - 1];
|
||||
const cur = entry.keyRisks[j];
|
||||
if (prev === undefined || cur === undefined) continue;
|
||||
expect(prev.score).toBeGreaterThanOrEqual(cur.score);
|
||||
}
|
||||
|
||||
// 关键风险内容与评分项派生数据等价(与次序无关的多重集比对)。
|
||||
const actualKeys = entry.keyRisks.map(riskKey).sort();
|
||||
const expectedKeys = source.scoringItems
|
||||
.map((item) => riskKey(item))
|
||||
.sort();
|
||||
expect(actualKeys).toStrictEqual(expectedKeys);
|
||||
});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('选中 <2 个时拒绝对比并提示至少需选择 2 个评估 (Req 17.6)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// 0 或 1 个选中 id,外加存储中可有任意数量的已存记录。
|
||||
fc.record({
|
||||
stored: uniqueAssessmentsArb(0, 4),
|
||||
selectedIds: fc.array(fc.string({ minLength: 1, maxLength: 10 }), {
|
||||
maxLength: MIN_COMPARISON_SELECTION - 1,
|
||||
}),
|
||||
}),
|
||||
({ stored, selectedIds }) => {
|
||||
const store = storeWith(stored);
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
compare(selectedIds, store);
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
// 必拒绝:抛出 ComparisonSelectionError。
|
||||
expect(thrown).toBeInstanceOf(ComparisonSelectionError);
|
||||
if (!(thrown instanceof ComparisonSelectionError)) return;
|
||||
|
||||
// 提示至少需选择 2 个评估,并记录实际选中数量。
|
||||
expect(thrown.userMessage).toContain('至少需选择 2 个');
|
||||
expect(thrown.selectedCount).toBe(selectedIds.length);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Property 58: 复评保留原评估 的属性化测试(Persistence,Req 17.3, 17.4)。
|
||||
*
|
||||
* 属性陈述:对任意历史 Assessment,复评必基于其输入创建一个新 Assessment(新标识)
|
||||
* 且原 Assessment 保持不变;对任意引用不存在历史 Assessment 的复评请求,必被拒绝
|
||||
* 并返回该评估不存在提示。
|
||||
*
|
||||
* 本测试以与被测实现相互独立的方式构造历史 Assessment,存入 store 后调用 `reassess`,
|
||||
* 断言:(1) 新评估具有与原标识不同的全新标识;(2) 新评估保留原始输入(项目描述、业务类型、
|
||||
* 行业、地域、风险模型)且重置派生结果;(3) 原 Assessment 在存储中保持逐字段不变;
|
||||
* (4) 引用不存在的标识时抛出 AssessmentNotFoundError 且不修改存储。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 58: 复评保留原评估
|
||||
* Validates: Requirements 17.3, 17.4
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
BUSINESS_TYPE_VALUES,
|
||||
DATA_PROVENANCE_VALUES,
|
||||
RISK_GRADE_VALUES,
|
||||
RISK_LEVEL_VALUES,
|
||||
type BusinessType,
|
||||
type DataProvenance,
|
||||
type RiskGrade,
|
||||
type RiskLevel,
|
||||
} from '../../domain/common.js';
|
||||
import {
|
||||
REDLINE_STATUS_VALUES,
|
||||
type Assessment,
|
||||
type AssessmentMetadata,
|
||||
type RedlineResult,
|
||||
type RedlineStatus,
|
||||
type ScoringItem,
|
||||
} from '../../domain/assessment.js';
|
||||
import type { RiskModel } from '../../domain/model.js';
|
||||
import type { Region } from '../../domain/region.js';
|
||||
import {
|
||||
AssessmentNotFoundError,
|
||||
InMemoryAssessmentStore,
|
||||
reassess,
|
||||
} from '../index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:构造历史 Assessment(输入 + 评分结果 + 齐备元数据)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const businessTypeArb = fc.constantFrom<BusinessType>(
|
||||
...(BUSINESS_TYPE_VALUES as readonly BusinessType[]),
|
||||
);
|
||||
|
||||
const riskGradeArb = fc.constantFrom<RiskGrade>(
|
||||
...(RISK_GRADE_VALUES as readonly RiskGrade[]),
|
||||
);
|
||||
|
||||
const riskLevelArb = fc.constantFrom<RiskLevel>(
|
||||
...(RISK_LEVEL_VALUES as readonly RiskLevel[]),
|
||||
);
|
||||
|
||||
const provenanceArb = fc.constantFrom<DataProvenance>(
|
||||
...(DATA_PROVENANCE_VALUES as readonly DataProvenance[]),
|
||||
);
|
||||
|
||||
const redlineStatusArb = fc.constantFrom<RedlineStatus>(
|
||||
...(REDLINE_STATUS_VALUES as readonly RedlineStatus[]),
|
||||
);
|
||||
|
||||
/** 置信度:[0,1] 两位小数。 */
|
||||
const confidenceArb = fc
|
||||
.integer({ min: 0, max: 100 })
|
||||
.map((n) => Number((n / 100).toFixed(2)));
|
||||
|
||||
/** 归一化风险总分:[0,100] 整数。 */
|
||||
const riskScoreArb = fc.integer({ min: 0, max: 100 });
|
||||
|
||||
/** ISO 8601 创建时间字符串。 */
|
||||
const isoDateArb = fc
|
||||
.date({
|
||||
min: new Date('2000-01-01T00:00:00Z'),
|
||||
max: new Date('2100-01-01T00:00:00Z'),
|
||||
})
|
||||
.map((d) => d.toISOString());
|
||||
|
||||
const regionArb: fc.Arbitrary<Region> = fc.record({
|
||||
code: fc.constantFrom('CN', 'US', 'EU', 'JP'),
|
||||
name: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
});
|
||||
|
||||
const scoringItemArb: fc.Arbitrary<ScoringItem> = fc.record({
|
||||
dimensionId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
indicatorId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
riskLevel: riskLevelArb,
|
||||
score: fc.double({ min: 0, max: 500, noNaN: true }),
|
||||
provenance: provenanceArb,
|
||||
confidence: confidenceArb,
|
||||
rationale: fc.string({ minLength: 1, maxLength: 12 }),
|
||||
riskImpact: fc.string({ minLength: 1, maxLength: 12 }),
|
||||
recommendation: fc.string({ minLength: 1, maxLength: 12 }),
|
||||
});
|
||||
|
||||
const redlineResultArb: fc.Arbitrary<RedlineResult> = fc.record({
|
||||
redlineId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
status: redlineStatusArb,
|
||||
});
|
||||
|
||||
const riskModelArb: fc.Arbitrary<RiskModel> = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
businessType: businessTypeArb,
|
||||
dimensions: fc.constant([]),
|
||||
redlines: fc.constant([]),
|
||||
});
|
||||
|
||||
const metadataArb: fc.Arbitrary<AssessmentMetadata> = fc.record({
|
||||
businessType: businessTypeArb,
|
||||
industry: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
region: regionArb,
|
||||
riskScore: riskScoreArb,
|
||||
riskGrade: riskGradeArb,
|
||||
createdAt: isoDateArb,
|
||||
assessorId: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
});
|
||||
|
||||
/** 历史(完成态)Assessment:输入、评分结果与齐备元数据。 */
|
||||
const assessmentArb: fc.Arbitrary<Assessment> = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 10 }),
|
||||
projectDescription: fc.string({ minLength: 1, maxLength: 40 }),
|
||||
businessType: businessTypeArb,
|
||||
industry: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
region: regionArb,
|
||||
riskModel: riskModelArb,
|
||||
scoringItems: fc.array(scoringItemArb, { maxLength: 5 }),
|
||||
riskScore: riskScoreArb,
|
||||
riskGrade: riskGradeArb,
|
||||
redlineResults: fc.array(redlineResultArb, { maxLength: 5 }),
|
||||
metadata: metadataArb,
|
||||
createdAt: isoDateArb,
|
||||
assessorId: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
});
|
||||
|
||||
/** 序列化深拷贝,用作"原评估不变"的基准快照。 */
|
||||
function snapshot<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 58: 复评保留原评估 (Req 17.3, 17.4)', () => {
|
||||
it('复评基于原输入创建新标识评估并重置派生结果,原评估保持不变 (Req 17.3)', () => {
|
||||
fc.assert(
|
||||
fc.property(assessmentArb, fc.string({ minLength: 1, maxLength: 12 }), (
|
||||
assessment,
|
||||
newId,
|
||||
) => {
|
||||
const store = new InMemoryAssessmentStore();
|
||||
store.put({ assessment, savedAt: new Date().toISOString() });
|
||||
|
||||
// 复评前对原评估拍快照(深拷贝),用于事后比对原评估未被改动。
|
||||
const before = snapshot(assessment);
|
||||
|
||||
const reassessed = reassess(assessment.id, store, {
|
||||
idFactory: () => newId,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
assessorId: assessment.assessorId,
|
||||
});
|
||||
|
||||
// 全新标识:当注入标识与原标识不同时,新评估标识必与原标识不同。
|
||||
expect(reassessed.id).toBe(newId);
|
||||
if (newId !== assessment.id) {
|
||||
expect(reassessed.id).not.toBe(assessment.id);
|
||||
}
|
||||
|
||||
// 保留原输入:项目描述、业务类型、行业、地域、风险模型逐字段等价。
|
||||
expect(reassessed.projectDescription).toBe(assessment.projectDescription);
|
||||
expect(reassessed.businessType).toBe(assessment.businessType);
|
||||
expect(reassessed.industry).toBe(assessment.industry);
|
||||
expect(reassessed.region).toStrictEqual(assessment.region);
|
||||
expect(reassessed.riskModel).toStrictEqual(assessment.riskModel);
|
||||
|
||||
// 重置派生结果:评分项与红线结果清空,未保留分级/费用/可接受性等结论。
|
||||
expect(reassessed.scoringItems).toStrictEqual([]);
|
||||
expect(reassessed.redlineResults).toStrictEqual([]);
|
||||
|
||||
// 原评估在存储中保持不变(逐字段等价于复评前快照)。
|
||||
const persisted = store.get(assessment.id);
|
||||
expect(persisted?.assessment).toStrictEqual(before);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('新评估与原记录隔离:修改复评结果不影响原评估 (Req 17.3)', () => {
|
||||
fc.assert(
|
||||
fc.property(assessmentArb, (assessment) => {
|
||||
const store = new InMemoryAssessmentStore();
|
||||
store.put({ assessment, savedAt: new Date().toISOString() });
|
||||
const before = snapshot(assessment);
|
||||
|
||||
const reassessed = reassess(assessment.id, store);
|
||||
|
||||
// 缺省标识工厂亦产生与原标识不同的全新标识。
|
||||
expect(reassessed.id).not.toBe(assessment.id);
|
||||
|
||||
// 改动复评结果的可变输入(深拷贝字段),原评估不应受影响。
|
||||
reassessed.region.name = `${reassessed.region.name}-mutated`;
|
||||
reassessed.riskModel.name = `${reassessed.riskModel.name}-mutated`;
|
||||
reassessed.scoringItems.push({
|
||||
dimensionId: 'x',
|
||||
indicatorId: 'y',
|
||||
riskLevel: 1,
|
||||
score: 0,
|
||||
provenance: DATA_PROVENANCE_VALUES[0] as DataProvenance,
|
||||
confidence: 0,
|
||||
rationale: 'r',
|
||||
riskImpact: 'i',
|
||||
recommendation: 'c',
|
||||
});
|
||||
|
||||
const persisted = store.get(assessment.id);
|
||||
expect(persisted?.assessment).toStrictEqual(before);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('引用不存在的历史评估时拒绝复评并提示评估不存在,存储不变 (Req 17.4)', () => {
|
||||
fc.assert(
|
||||
fc.property(assessmentArb, fc.string({ minLength: 1, maxLength: 12 }), (
|
||||
assessment,
|
||||
suffix,
|
||||
) => {
|
||||
const store = new InMemoryAssessmentStore();
|
||||
store.put({ assessment, savedAt: new Date().toISOString() });
|
||||
const before = snapshot(assessment);
|
||||
|
||||
// 构造一个确定不在存储中的标识。
|
||||
const missingId = `${assessment.id}-missing-${suffix}`;
|
||||
fc.pre(missingId !== assessment.id);
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
reassess(missingId, store);
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
// 必抛出 AssessmentNotFoundError,且错误携带被引用标识与可读提示。
|
||||
expect(thrown).toBeInstanceOf(AssessmentNotFoundError);
|
||||
const notFound = thrown as AssessmentNotFoundError;
|
||||
expect(notFound.assessmentId).toBe(missingId);
|
||||
expect(notFound.userMessage).toContain('不存在');
|
||||
|
||||
// 拒绝复评不应改动已有存储记录。
|
||||
expect(store.get(assessment.id)?.assessment).toStrictEqual(before);
|
||||
expect(store.get(missingId)).toBeUndefined();
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 持久化失败集成测试(Persistence,Req 17.2)。
|
||||
*
|
||||
* 场景:底层 {@link AssessmentStore} 的写入(`put`)失败(抛出异常)时,
|
||||
* `save` 不得丢失本次评估——必须保留会话内的评估数据并返回存储失败错误。
|
||||
*
|
||||
* 本测试注入一个 `put` 必抛异常的 {@link AssessmentStore} 实现,调用 `save`
|
||||
* 后断言:返回 `ok:false`、`error` 为 {@link StorageError}(携带评估标识与底层
|
||||
* 原因、含面向评估者的"存储失败"提示),且 `retained` 恰为传入的原评估对象
|
||||
* (引用未变、数据无损保留于当前会话)。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment
|
||||
* Validates: Requirements 17.2
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Assessment } from '../../domain/assessment.js';
|
||||
import type { RiskModel } from '../../domain/model.js';
|
||||
import type { Region } from '../../domain/region.js';
|
||||
import {
|
||||
InMemoryAssessmentStore,
|
||||
StorageError,
|
||||
save,
|
||||
type AssessmentStore,
|
||||
type PersistedAssessment,
|
||||
} from '../index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 测试夹具:完成态 Assessment 与必抛异常的存储实现
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 注入的底层存储原始异常,用于校验 StorageError 的 cause 透传。 */
|
||||
const STORE_FAILURE = new Error('底层存储介质不可用');
|
||||
|
||||
/**
|
||||
* 写入必失败的存储实现:`put` 抛出 {@link STORE_FAILURE}。
|
||||
*
|
||||
* 用于验证 Req 17.2——存储失败时 `save` 捕获异常、保留会话数据并返回错误。
|
||||
* `getCallCount` 暴露 `put` 被调用次数,便于断言确实尝试了写入。
|
||||
*/
|
||||
class FailingAssessmentStore implements AssessmentStore {
|
||||
putCallCount = 0;
|
||||
|
||||
put(_record: PersistedAssessment): void {
|
||||
this.putCallCount += 1;
|
||||
throw STORE_FAILURE;
|
||||
}
|
||||
|
||||
get(_id: string): PersistedAssessment | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
has(_id: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
getAll(): PersistedAssessment[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const REGION_CN: Region = { code: 'CN', name: '中国' };
|
||||
|
||||
const RISK_MODEL: RiskModel = {
|
||||
id: 'model-1',
|
||||
name: '默认模型',
|
||||
businessType: '项目制外包',
|
||||
dimensions: [],
|
||||
redlines: [],
|
||||
};
|
||||
|
||||
/** 构造一个完成态评估(输入 + 评分结果 + 齐备元数据)。 */
|
||||
function buildCompletedAssessment(): Assessment {
|
||||
return {
|
||||
id: 'assessment-001',
|
||||
projectDescription: '某项目制外包项目的风险评估',
|
||||
businessType: '项目制外包',
|
||||
industry: '金融',
|
||||
region: REGION_CN,
|
||||
riskModel: RISK_MODEL,
|
||||
scoringItems: [
|
||||
{
|
||||
dimensionId: 'D1',
|
||||
indicatorId: 'I1',
|
||||
riskLevel: 3,
|
||||
score: 30,
|
||||
provenance: '用户输入',
|
||||
confidence: 0.9,
|
||||
rationale: '依据指标 I1 的取值判定',
|
||||
riskImpact: '可能造成中等程度影响',
|
||||
recommendation: '建议加强合同条款',
|
||||
},
|
||||
],
|
||||
riskScore: 42,
|
||||
riskGrade: '中',
|
||||
redlineResults: [{ redlineId: 'R1', status: '未命中' }],
|
||||
metadata: {
|
||||
businessType: '项目制外包',
|
||||
industry: '金融',
|
||||
region: REGION_CN,
|
||||
riskScore: 42,
|
||||
riskGrade: '中',
|
||||
createdAt: '2024-05-01T08:00:00.000Z',
|
||||
assessorId: 'user-42',
|
||||
},
|
||||
createdAt: '2024-05-01T08:00:00.000Z',
|
||||
assessorId: 'user-42',
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 集成测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('持久化失败集成测试 (Req 17.2)', () => {
|
||||
it('存储写入失败时返回存储失败错误并保留会话评估数据', () => {
|
||||
const assessment = buildCompletedAssessment();
|
||||
const store = new FailingAssessmentStore();
|
||||
|
||||
const result = save(assessment, store);
|
||||
|
||||
// 确实尝试了写入。
|
||||
expect(store.putCallCount).toBe(1);
|
||||
|
||||
// 返回失败结果(不抛出)。
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
|
||||
// 错误为 StorageError,携带评估标识、底层原因与面向评估者的提示。
|
||||
expect(result.error).toBeInstanceOf(StorageError);
|
||||
expect(result.error.assessmentId).toBe(assessment.id);
|
||||
expect(result.error.userMessage).toContain('存储失败');
|
||||
expect(result.error.cause).toBe(STORE_FAILURE);
|
||||
|
||||
// 保留的会话数据恰为传入的原评估对象(引用未变、数据无损)。
|
||||
expect(result.retained).toBe(assessment);
|
||||
expect(result.retained).toStrictEqual(buildCompletedAssessment());
|
||||
});
|
||||
|
||||
it('存储失败包含报告时同样保留会话数据并返回错误', () => {
|
||||
const assessment = buildCompletedAssessment();
|
||||
const store = new FailingAssessmentStore();
|
||||
const report = { summary: '报告正文', sections: [1, 2, 3] };
|
||||
|
||||
const result = save(assessment, store, report);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
|
||||
expect(result.error).toBeInstanceOf(StorageError);
|
||||
expect(result.retained).toBe(assessment);
|
||||
// 失败后会话内原评估对象未被修改。
|
||||
expect(result.retained).toStrictEqual(buildCompletedAssessment());
|
||||
});
|
||||
|
||||
it('对照:可用存储下相同评估持久化成功(确认失败由存储注入所致)', () => {
|
||||
const assessment = buildCompletedAssessment();
|
||||
const store = new InMemoryAssessmentStore();
|
||||
|
||||
const result = save(assessment, store);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.id).toBe(assessment.id);
|
||||
expect(store.get(assessment.id)).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Property 57: 评估持久化往返 的属性化测试(Persistence,Req 17.1)。
|
||||
*
|
||||
* 属性陈述:对任意完成的 Assessment,持久化存储后再读取必得到等价记录(往返保真),
|
||||
* 且其元数据必至少包含业务类型、行业、Region、Risk_Score、Risk_Grade、创建时间与
|
||||
* 评估者身份(七项齐备)。
|
||||
*
|
||||
* 本测试以与被测实现相互独立的方式构造完成态 Assessment,调用 `save` 持久化后
|
||||
* 经 `store.get` 读回,断言读回记录与存档快照逐字段等价、原始评估数据无损往返,
|
||||
* 且元数据七项字段均存在且取值正确;并覆盖含报告快照的往返。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 57: 评估持久化往返
|
||||
* Validates: Requirements 17.1
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
BUSINESS_TYPE_VALUES,
|
||||
DATA_PROVENANCE_VALUES,
|
||||
RISK_GRADE_VALUES,
|
||||
RISK_LEVEL_VALUES,
|
||||
type BusinessType,
|
||||
type DataProvenance,
|
||||
type RiskGrade,
|
||||
type RiskLevel,
|
||||
} from '../../domain/common.js';
|
||||
import {
|
||||
REDLINE_STATUS_VALUES,
|
||||
type Assessment,
|
||||
type AssessmentMetadata,
|
||||
type RedlineResult,
|
||||
type RedlineStatus,
|
||||
type ScoringItem,
|
||||
} from '../../domain/assessment.js';
|
||||
import type { RiskModel } from '../../domain/model.js';
|
||||
import type { Region } from '../../domain/region.js';
|
||||
import { InMemoryAssessmentStore, save } from '../index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:构造完成态 Assessment(输入 + 评分结果 + 齐备元数据)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const businessTypeArb = fc.constantFrom<BusinessType>(
|
||||
...(BUSINESS_TYPE_VALUES as readonly BusinessType[]),
|
||||
);
|
||||
|
||||
const riskGradeArb = fc.constantFrom<RiskGrade>(
|
||||
...(RISK_GRADE_VALUES as readonly RiskGrade[]),
|
||||
);
|
||||
|
||||
const riskLevelArb = fc.constantFrom<RiskLevel>(
|
||||
...(RISK_LEVEL_VALUES as readonly RiskLevel[]),
|
||||
);
|
||||
|
||||
const provenanceArb = fc.constantFrom<DataProvenance>(
|
||||
...(DATA_PROVENANCE_VALUES as readonly DataProvenance[]),
|
||||
);
|
||||
|
||||
const redlineStatusArb = fc.constantFrom<RedlineStatus>(
|
||||
...(REDLINE_STATUS_VALUES as readonly RedlineStatus[]),
|
||||
);
|
||||
|
||||
/** 置信度:[0,1] 两位小数。 */
|
||||
const confidenceArb = fc
|
||||
.integer({ min: 0, max: 100 })
|
||||
.map((n) => Number((n / 100).toFixed(2)));
|
||||
|
||||
/** 归一化风险总分:[0,100] 整数。 */
|
||||
const riskScoreArb = fc.integer({ min: 0, max: 100 });
|
||||
|
||||
/** ISO 8601 创建时间字符串。 */
|
||||
const isoDateArb = fc
|
||||
.date({ min: new Date('2000-01-01T00:00:00Z'), max: new Date('2100-01-01T00:00:00Z') })
|
||||
.map((d) => d.toISOString());
|
||||
|
||||
const regionArb: fc.Arbitrary<Region> = fc.record({
|
||||
code: fc.constantFrom('CN', 'US', 'EU', 'JP'),
|
||||
name: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
});
|
||||
|
||||
const scoringItemArb: fc.Arbitrary<ScoringItem> = fc.record({
|
||||
dimensionId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
indicatorId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
riskLevel: riskLevelArb,
|
||||
score: fc.double({ min: 0, max: 500, noNaN: true }),
|
||||
provenance: provenanceArb,
|
||||
confidence: confidenceArb,
|
||||
rationale: fc.string({ minLength: 1, maxLength: 12 }),
|
||||
riskImpact: fc.string({ minLength: 1, maxLength: 12 }),
|
||||
recommendation: fc.string({ minLength: 1, maxLength: 12 }),
|
||||
});
|
||||
|
||||
// 仅含必填字段,避免可选字段以 undefined 形式参与序列化往返比对。
|
||||
const redlineResultArb: fc.Arbitrary<RedlineResult> = fc.record({
|
||||
redlineId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
status: redlineStatusArb,
|
||||
});
|
||||
|
||||
const riskModelArb: fc.Arbitrary<RiskModel> = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
businessType: businessTypeArb,
|
||||
dimensions: fc.constant([]),
|
||||
redlines: fc.constant([]),
|
||||
});
|
||||
|
||||
const metadataArb: fc.Arbitrary<AssessmentMetadata> = fc.record({
|
||||
businessType: businessTypeArb,
|
||||
industry: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
region: regionArb,
|
||||
riskScore: riskScoreArb,
|
||||
riskGrade: riskGradeArb,
|
||||
createdAt: isoDateArb,
|
||||
assessorId: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
});
|
||||
|
||||
/** 完成态 Assessment:输入、评分结果(riskScore/riskGrade 均已产生)与齐备元数据。 */
|
||||
const completedAssessmentArb: fc.Arbitrary<Assessment> = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 10 }),
|
||||
projectDescription: fc.string({ minLength: 1, maxLength: 40 }),
|
||||
businessType: businessTypeArb,
|
||||
industry: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
region: regionArb,
|
||||
riskModel: riskModelArb,
|
||||
scoringItems: fc.array(scoringItemArb, { maxLength: 5 }),
|
||||
riskScore: riskScoreArb,
|
||||
riskGrade: riskGradeArb,
|
||||
redlineResults: fc.array(redlineResultArb, { maxLength: 5 }),
|
||||
metadata: metadataArb,
|
||||
createdAt: isoDateArb,
|
||||
assessorId: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
});
|
||||
|
||||
/** 元数据七项字段,用于断言完整性(Req 17.1)。 */
|
||||
const METADATA_FIELDS = [
|
||||
'businessType',
|
||||
'industry',
|
||||
'region',
|
||||
'riskScore',
|
||||
'riskGrade',
|
||||
'createdAt',
|
||||
'assessorId',
|
||||
] as const;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 57: 评估持久化往返 (Req 17.1)', () => {
|
||||
it('save 后读回得到与存档快照等价的记录且原始评估无损往返', () => {
|
||||
fc.assert(
|
||||
fc.property(completedAssessmentArb, (assessment) => {
|
||||
const store = new InMemoryAssessmentStore();
|
||||
|
||||
const result = save(assessment, store);
|
||||
|
||||
// 持久化必成功(内存存储不会抛出)。
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
// 读回记录存在,且与 save 返回的存档记录逐字段等价。
|
||||
const readBack = store.get(assessment.id);
|
||||
expect(readBack).toBeDefined();
|
||||
expect(readBack).toStrictEqual(result.record);
|
||||
|
||||
// 往返保真:读回评估与原始评估(序列化等价)一致。
|
||||
const normalizedOriginal = JSON.parse(
|
||||
JSON.stringify(assessment),
|
||||
) as Assessment;
|
||||
expect(readBack?.assessment).toStrictEqual(normalizedOriginal);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('读回记录的元数据必含七项字段且取值与原始评估一致', () => {
|
||||
fc.assert(
|
||||
fc.property(completedAssessmentArb, (assessment) => {
|
||||
const store = new InMemoryAssessmentStore();
|
||||
const result = save(assessment, store);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const metadata = store.get(assessment.id)?.assessment.metadata;
|
||||
expect(metadata).toBeDefined();
|
||||
if (metadata === undefined) return;
|
||||
|
||||
// 七项字段均存在且非 undefined。
|
||||
for (const field of METADATA_FIELDS) {
|
||||
expect(metadata[field]).not.toBeUndefined();
|
||||
}
|
||||
|
||||
// 元数据取值与原始评估提供的元数据等价。
|
||||
expect(metadata).toStrictEqual(
|
||||
JSON.parse(JSON.stringify(assessment.metadata)),
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('含报告快照时报告随评估一并无损往返', () => {
|
||||
fc.assert(
|
||||
fc.property(completedAssessmentArb, fc.jsonValue(), (assessment, report) => {
|
||||
const store = new InMemoryAssessmentStore();
|
||||
const result = save(assessment, store, report);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const readBack = store.get(assessment.id);
|
||||
expect(readBack?.report).toStrictEqual(
|
||||
JSON.parse(JSON.stringify(report)),
|
||||
);
|
||||
expect(readBack?.assessment).toStrictEqual(
|
||||
JSON.parse(JSON.stringify(assessment)),
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Property 60: 检索结果满足过滤条件 的属性化测试(Persistence,Req 17.7)。
|
||||
*
|
||||
* 属性陈述:对任意历史数据集与按业务类型、行业、Risk_Grade 或创建时间范围
|
||||
* 组合(AND)的检索条件,`search` 返回的全部 Assessment 均满足该检索条件,
|
||||
* 且结果恰为满足条件的子集(无匹配项被遗漏,无不匹配项被纳入);
|
||||
* 当不存在任何匹配时返回空结果集。
|
||||
*
|
||||
* 本测试以与被测实现相互独立的方式:
|
||||
* 1. 直接对每个返回项逐字段断言其满足全部已给定的过滤条件(结果均满足);
|
||||
* 2. 以独立的参考判定函数计算"应当匹配"的子集,按 id 集合与 `search` 结果比对
|
||||
* (无遗漏、无多余 — 即结果恰为匹配子集);
|
||||
* 3. 针对必然无匹配的条件(创建时间下界设于远未来)断言返回空集。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 60: 检索结果满足过滤条件
|
||||
* Validates: Requirements 17.7
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
BUSINESS_TYPE_VALUES,
|
||||
RISK_GRADE_VALUES,
|
||||
type BusinessType,
|
||||
type Industry,
|
||||
type RiskGrade,
|
||||
} from '../../domain/common.js';
|
||||
import type { Assessment } from '../../domain/assessment.js';
|
||||
import type { RiskModel } from '../../domain/model.js';
|
||||
import { REGION_CN } from '../../domain/region.js';
|
||||
import {
|
||||
InMemoryAssessmentStore,
|
||||
search,
|
||||
type SearchFilters,
|
||||
} from '../index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 取值池:约束到较小空间,使过滤条件能真实命中子集(既有匹配也有不匹配)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 行业取值池(含占位"未识别"),与过滤条件取自同一池以产生交集。 */
|
||||
const INDUSTRY_POOL: readonly Industry[] = ['制造业', '金融业', '互联网', '未识别'];
|
||||
|
||||
/** 创建时间取值池(ISO 8601),跨度内便于命中时间范围条件。 */
|
||||
const CREATED_AT_POOL: readonly string[] = [
|
||||
'2021-01-15T00:00:00.000Z',
|
||||
'2022-06-30T12:00:00.000Z',
|
||||
'2023-03-10T08:30:00.000Z',
|
||||
'2024-11-01T23:59:59.000Z',
|
||||
];
|
||||
|
||||
/** 过滤用的时间端点池(含池外端点,触发部分/全部命中或落空)。 */
|
||||
const FILTER_DATE_POOL: readonly string[] = [
|
||||
'2020-01-01T00:00:00.000Z',
|
||||
'2022-01-01T00:00:00.000Z',
|
||||
'2023-06-01T00:00:00.000Z',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
];
|
||||
|
||||
/** 评估均采用的常量风险模型填充(不参与过滤)。 */
|
||||
const FILLER_RISK_MODEL: RiskModel = {
|
||||
id: 'm',
|
||||
name: 'model',
|
||||
businessType: '岗位外包',
|
||||
dimensions: [],
|
||||
redlines: [],
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const businessTypeArb = fc.constantFrom<BusinessType>(
|
||||
...(BUSINESS_TYPE_VALUES as readonly BusinessType[]),
|
||||
);
|
||||
const industryArb = fc.constantFrom<Industry>(...INDUSTRY_POOL);
|
||||
const riskGradeArb = fc.constantFrom<RiskGrade>(
|
||||
...(RISK_GRADE_VALUES as readonly RiskGrade[]),
|
||||
);
|
||||
const createdAtArb = fc.constantFrom<string>(...CREATED_AT_POOL);
|
||||
|
||||
/** 单条评估的可过滤字段规格。 */
|
||||
interface AssessmentSpec {
|
||||
readonly businessType: BusinessType;
|
||||
readonly industry: Industry;
|
||||
readonly riskGrade: RiskGrade;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
const assessmentSpecArb: fc.Arbitrary<AssessmentSpec> = fc.record({
|
||||
businessType: businessTypeArb,
|
||||
industry: industryArb,
|
||||
riskGrade: riskGradeArb,
|
||||
createdAt: createdAtArb,
|
||||
});
|
||||
|
||||
/** 以规格构造完成态 Assessment;元数据为检索字段来源(与顶层保持一致)。 */
|
||||
function buildAssessment(id: string, spec: AssessmentSpec): Assessment {
|
||||
return {
|
||||
id,
|
||||
projectDescription: `project-${id}`,
|
||||
businessType: spec.businessType,
|
||||
industry: spec.industry,
|
||||
region: REGION_CN,
|
||||
riskModel: FILLER_RISK_MODEL,
|
||||
scoringItems: [],
|
||||
riskScore: 50,
|
||||
riskGrade: spec.riskGrade,
|
||||
redlineResults: [],
|
||||
metadata: {
|
||||
businessType: spec.businessType,
|
||||
industry: spec.industry,
|
||||
region: REGION_CN,
|
||||
riskScore: 50,
|
||||
riskGrade: spec.riskGrade,
|
||||
createdAt: spec.createdAt,
|
||||
assessorId: 'assessor',
|
||||
},
|
||||
createdAt: spec.createdAt,
|
||||
assessorId: 'assessor',
|
||||
};
|
||||
}
|
||||
|
||||
/** 评估数据集生成器:分配唯一 id,避免按 id 存储时相互覆盖。 */
|
||||
const assessmentsArb: fc.Arbitrary<Assessment[]> = fc
|
||||
.array(assessmentSpecArb, { maxLength: 12 })
|
||||
.map((specs) => specs.map((spec, index) => buildAssessment(`a${index}`, spec)));
|
||||
|
||||
/** 过滤条件生成器:每字段以约 50% 概率给定;从不显式赋 undefined(exactOptionalPropertyTypes)。 */
|
||||
const filtersArb: fc.Arbitrary<SearchFilters> = fc
|
||||
.record({
|
||||
businessType: fc.option(businessTypeArb, { nil: undefined }),
|
||||
industry: fc.option(industryArb, { nil: undefined }),
|
||||
riskGrade: fc.option(riskGradeArb, { nil: undefined }),
|
||||
createdFrom: fc.option(fc.constantFrom(...FILTER_DATE_POOL), { nil: undefined }),
|
||||
createdTo: fc.option(fc.constantFrom(...FILTER_DATE_POOL), { nil: undefined }),
|
||||
})
|
||||
.map((parts) => ({
|
||||
...(parts.businessType !== undefined ? { businessType: parts.businessType } : {}),
|
||||
...(parts.industry !== undefined ? { industry: parts.industry } : {}),
|
||||
...(parts.riskGrade !== undefined ? { riskGrade: parts.riskGrade } : {}),
|
||||
...(parts.createdFrom !== undefined ? { createdFrom: parts.createdFrom } : {}),
|
||||
...(parts.createdTo !== undefined ? { createdTo: parts.createdTo } : {}),
|
||||
}));
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 独立参考判定:用于计算"应当匹配"的子集(不引用被测实现)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function referenceMatches(assessment: Assessment, filters: SearchFilters): boolean {
|
||||
const { businessType, industry, riskGrade, createdAt } = assessment.metadata;
|
||||
if (filters.businessType !== undefined && businessType !== filters.businessType) {
|
||||
return false;
|
||||
}
|
||||
if (filters.industry !== undefined && industry !== filters.industry) {
|
||||
return false;
|
||||
}
|
||||
if (filters.riskGrade !== undefined && riskGrade !== filters.riskGrade) {
|
||||
return false;
|
||||
}
|
||||
if (filters.createdFrom !== undefined || filters.createdTo !== undefined) {
|
||||
const created = Date.parse(createdAt);
|
||||
if (Number.isNaN(created)) return false;
|
||||
if (filters.createdFrom !== undefined && created < Date.parse(filters.createdFrom)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.createdTo !== undefined && created > Date.parse(filters.createdTo)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildStore(assessments: readonly Assessment[]): InMemoryAssessmentStore {
|
||||
const store = new InMemoryAssessmentStore();
|
||||
for (const assessment of assessments) {
|
||||
store.put({ assessment, savedAt: '2024-01-01T00:00:00.000Z' });
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 60: 检索结果满足过滤条件 (Req 17.7)', () => {
|
||||
it('每个返回项满足全部已给定条件,且结果恰为匹配子集(无遗漏/无多余)', () => {
|
||||
fc.assert(
|
||||
fc.property(assessmentsArb, filtersArb, (assessments, filters) => {
|
||||
const store = buildStore(assessments);
|
||||
const result = search(store, filters);
|
||||
|
||||
// (1) 结果均满足:对每个返回项逐字段断言满足所给条件。
|
||||
for (const item of result) {
|
||||
if (filters.businessType !== undefined) {
|
||||
expect(item.metadata.businessType).toBe(filters.businessType);
|
||||
}
|
||||
if (filters.industry !== undefined) {
|
||||
expect(item.metadata.industry).toBe(filters.industry);
|
||||
}
|
||||
if (filters.riskGrade !== undefined) {
|
||||
expect(item.metadata.riskGrade).toBe(filters.riskGrade);
|
||||
}
|
||||
const created = Date.parse(item.metadata.createdAt);
|
||||
if (filters.createdFrom !== undefined) {
|
||||
expect(created).toBeGreaterThanOrEqual(Date.parse(filters.createdFrom));
|
||||
}
|
||||
if (filters.createdTo !== undefined) {
|
||||
expect(created).toBeLessThanOrEqual(Date.parse(filters.createdTo));
|
||||
}
|
||||
}
|
||||
|
||||
// (2) 结果恰为匹配子集:与独立参考判定计算的期望 id 集合一致。
|
||||
const expectedIds = assessments
|
||||
.filter((assessment) => referenceMatches(assessment, filters))
|
||||
.map((assessment) => assessment.id)
|
||||
.sort();
|
||||
const actualIds = result.map((item) => item.id).sort();
|
||||
expect(actualIds).toStrictEqual(expectedIds);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('无匹配时返回空结果集(创建时间下界设于远未来)', () => {
|
||||
fc.assert(
|
||||
fc.property(assessmentsArb, filtersArb, (assessments, baseFilters) => {
|
||||
const store = buildStore(assessments);
|
||||
// 叠加一个所有数据都无法满足的下界,强制无匹配。
|
||||
const filters: SearchFilters = {
|
||||
...baseFilters,
|
||||
createdFrom: '2999-01-01T00:00:00.000Z',
|
||||
};
|
||||
expect(search(store, filters)).toStrictEqual([]);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user