外包风险评估系统:领域引擎+前端+服务端持久化与生产部署
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解 - 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护) - 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理 - 专业图标体系替换全部emoji、看板美化 - 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC - 444单测+204前端测试+51 e2e
This commit is contained in:
@@ -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 ?? {}) },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user