fix(calibration): 校准幂等+已校准状态显示,解决重复提示

根因:预测准确度卡的偏差来自历史回填项目的预测vs实际,属既成事实,
不会因校准改变;且原 apply 公式 next=current+dev 会累加,反复点越推越高。

修复:
- 校准建议/应用均基于未校准原始基准(env/默认)计算,保证幂等
- GET /api/calibration 返回 uncalibratedBase 与 calibrated 标志
- 卡片:已校准时显示「已按当前偏差校准:基准X%」绿色状态,不再出现按钮;
  未校准时按钮明示「X% → Y%」
- 补充说明:历史偏差不会因校准改变,校准仅调整后续承接建议阈值
This commit is contained in:
freedakgmail
2026-06-14 11:01:55 +08:00
parent c715dbb306
commit 8bac14ef44
3 changed files with 67 additions and 19 deletions
+20 -5
View File
@@ -358,6 +358,18 @@ function targetNetMargin(): number {
return Number.isFinite(v) && v > 0 ? v : DEFAULT_TARGET_NET_MARGIN;
}
/** 未校准的原始目标净利率基准(env/默认,忽略校准覆盖)。校准始终基于此基准做一次性补偿,保证幂等。 */
function uncalibratedTargetNetMargin(): number {
const v = Number(process.env.TARGET_NET_MARGIN);
return Number.isFinite(v) && v > 0 ? v : DEFAULT_TARGET_NET_MARGIN;
}
/** 据系统性偏差计算建议的目标净利率基准(基于未校准基准,夹取 [2%,30%])。 */
function suggestedTargetBase(deviationPct: number): number {
const base = uncalibratedTargetNetMargin();
return Math.min(0.3, Math.max(0.02, Math.round((base + deviationPct / 100) * 1000) / 1000));
}
/**
* POST /api/assessments/classify
* 输入项目描述,返回业务类型与行业识别结果。
@@ -885,6 +897,7 @@ app.get('/api/accuracy', async (c) => {
*/
app.get('/api/calibration', async (c) => {
const current = targetNetMargin();
const uncalibrated = uncalibratedTargetNetMargin();
let suggested = current;
let bias: string | null = null;
let deviationPct: number | null = null;
@@ -893,16 +906,18 @@ app.get('/api/calibration', async (c) => {
bias = acc.bias;
deviationPct = acc.avgDeviationPct;
if (acc.avgDeviationPct !== null && acc.count > 0) {
// 预测偏乐观(dev>0)→ 抬高要求的目标净利率以补偿;偏保守则下调。夹取 [2%,30%]
suggested = Math.min(0.3, Math.max(0.02, Math.round((current + acc.avgDeviationPct / 100) * 1000) / 1000));
// 预测偏乐观(dev>0)→ 抬高要求的目标净利率以补偿;偏保守则下调。基于未校准基准,保证幂等
suggested = suggestedTargetBase(acc.avgDeviationPct);
}
}
return c.json({ currentBase: current, suggestedBase: suggested, bias, deviationPct });
// 已校准:当前基准已等于(或非常接近)据当前偏差计算的建议基准,无需再次应用。
const calibrated = calibratedTargetBase !== null && Math.abs(current - suggested) < 1e-6;
return c.json({ currentBase: current, suggestedBase: suggested, uncalibratedBase: uncalibrated, calibrated, bias, deviationPct });
});
/**
* POST /api/calibration/apply
* 应用校准:将目标净利率基准设为据预测偏差计算的建议值(管理层)。
* 应用校准:将目标净利率基准设为据预测偏差计算的建议值(管理层)。基于未校准基准,幂等。
*/
app.post('/api/calibration/apply', requireRole('管理层'), async (c) => {
if (pool === null) return c.json({ error: '未配置数据库' }, 400);
@@ -911,7 +926,7 @@ app.post('/api/calibration/apply', requireRole('管理层'), async (c) => {
return c.json({ error: '暂无足够的实际值回填数据用于校准' }, 400);
}
const current = targetNetMargin();
const next = Math.min(0.3, Math.max(0.02, Math.round((current + acc.avgDeviationPct / 100) * 1000) / 1000));
const next = suggestedTargetBase(acc.avgDeviationPct);
await setSetting(pool, 'targetMarginBase', next);
calibratedTargetBase = next;
return c.json({ appliedBase: next, previousBase: current, deviationPct: acc.avgDeviationPct });
+12
View File
@@ -566,6 +566,18 @@ export async function applyCalibration(): Promise<{ appliedBase: number; previou
return request('POST', '/api/calibration/apply', {});
}
/** 查询当前目标净利率基准与据偏差计算的建议基准、是否已校准。 */
export async function fetchCalibration(): Promise<{
currentBase: number;
suggestedBase: number;
uncalibratedBase: number;
calibrated: boolean;
bias: string | null;
deviationPct: number | null;
}> {
return request('GET', '/api/calibration');
}
/** 风控/管理层对「待核实」红线进行人工裁定(命中/未命中),闭环判定。 */
export async function submitRedlineVerdict(
id: string,
+27 -6
View File
@@ -17,7 +17,10 @@ import {
space,
typographyStyle,
} from '../design-system/components/styles.js';
import { fetchAssessmentsPage, fetchSummary, archiveAssessment, applyCalibration, listDrafts, deleteDraftApi, fetchAssignments, API_BASE } from '../api/client.js';
import { fetchAssessmentsPage, fetchSummary, archiveAssessment, applyCalibration, fetchCalibration, listDrafts, deleteDraftApi, fetchAssignments, API_BASE } from '../api/client.js';
// 校准状态类型(目标净利率基准)。
type CalibrationState = { currentBase: number; suggestedBase: number; uncalibratedBase: number; calibrated: boolean; bias: string | null; deviationPct: number | null };
import type { AssessmentListItem, WorkflowStatus, DraftItem, AssignmentRecord } from '../api/client.js';
import { useAuthStore } from '../stores/authStore.js';
import { GuideBanner } from '../app/Guidance.js';
@@ -116,6 +119,8 @@ export function Dashboard(): JSX.Element {
const [overdue, setOverdue] = useState<Array<{ id: string; project: string; overdueHours: number }>>([]);
const [rejectStats, setRejectStats] = useState<Array<{ reasonType: string; count: number }>>([]);
const [accuracy, setAccuracy] = useState<{ count: number; avgPredictedPct: number | null; avgActualPct: number | null; avgDeviationPct: number | null; bias: string | null }>({ count: 0, avgPredictedPct: null, avgActualPct: null, avgDeviationPct: null, bias: null });
// 校准状态(目标净利率基准)。
const [calibration, setCalibration] = useState<CalibrationState | null>(null);
// 搜索防抖
useEffect(() => {
@@ -157,12 +162,13 @@ export function Dashboard(): JSX.Element {
}
// 组合分析(准确度/驳回Top/到期/超时)面向风控与管理层;销售视图不展示(避免跨人数据)。
if (role === '商务/销售') {
setExpiring([]); setOverdue([]); setRejectStats([]); setAccuracy({ count: 0, avgPredictedPct: null, avgActualPct: null, avgDeviationPct: null, bias: null });
setExpiring([]); setOverdue([]); setRejectStats([]); setAccuracy({ count: 0, avgPredictedPct: null, avgActualPct: null, avgDeviationPct: null, bias: null }); setCalibration(null);
} else {
fetch(`${API_BASE}/api/assessments/expiring`).then((r) => r.json()).then(setExpiring).catch(() => setExpiring([]));
fetch(`${API_BASE}/api/assessments/overdue`).then((r) => r.json()).then(setOverdue).catch(() => setOverdue([]));
fetch(`${API_BASE}/api/reject-reason-stats`).then((r) => r.json()).then(setRejectStats).catch(() => setRejectStats([]));
fetch(`${API_BASE}/api/accuracy`).then((r) => r.json()).then(setAccuracy).catch(() => undefined);
fetchCalibration().then(setCalibration).catch(() => setCalibration(null));
}
// 草稿箱(仅销售展示):列出当前用户的向导草稿。
if (role === '商务/销售') {
@@ -472,21 +478,36 @@ export function Dashboard(): JSX.Element {
</div>
{!consistent && (
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginTop: `${space(2)}px`, lineHeight: 1.6 }}>
{accuracy.bias === '预测偏乐观' ? '建议下调报价预期或上调成本假设进行校准。' : '成本假设或偏保守,可适当下调目标基准。'}
{accuracy.bias === '预测偏乐观' ? '建议上调目标净利率基准以补偿系统性乐观偏差。' : '预测偏保守,可适当下调目标净利率基准。'}
{calibration !== null && (
<span> <strong>{(calibration.currentBase * 100).toFixed(1)}%</strong>
{!calibration.calibrated && <> <strong style={{ color: accent }}>{(calibration.suggestedBase * 100).toFixed(1)}%</strong></>}</span>
)}
</div>
)}
{role === '管理层' && !consistent && (
{role === '管理层' && !consistent && calibration !== null && (
calibration.calibrated ? (
<div style={{ marginTop: `${space(3)}px`, display: 'inline-flex', alignItems: 'center', gap: 6, padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, backgroundColor: 'rgba(16,128,61,0.10)', color: '#15803D', ...typographyStyle('caption'), fontWeight: 600 }}>
<Icon name="check-circle" size={14} /> {(calibration.currentBase * 100).toFixed(1)}%
</div>
) : (
<button
type="button"
onClick={() => {
applyCalibration()
.then((r) => { setError(null); setNotice(`已应用校准:目标净利率基准 ${(r.previousBase * 100).toFixed(1)}% → ${(r.appliedBase * 100).toFixed(1)}%`); loadAux(); })
.then((r) => { setError(null); setNotice(`已应用校准:目标净利率基准 ${(r.previousBase * 100).toFixed(1)}% → ${(r.appliedBase * 100).toFixed(1)}%(影响后续承接建议阈值)`); loadAux(); })
.catch((e: unknown) => setError(e instanceof Error ? e.message : '校准失败'));
}}
style={{ marginTop: `${space(3)}px`, display: 'inline-flex', alignItems: 'center', gap: 6, padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${accent}`, background: 'transparent', color: accent, cursor: 'pointer', fontFamily: FONT_FAMILY, ...typographyStyle('caption'), fontWeight: 600 }}
>
<Icon name="settings" size={14} />
<Icon name="settings" size={14} /> {(calibration.currentBase * 100).toFixed(1)}% {(calibration.suggestedBase * 100).toFixed(1)}%
</button>
)
)}
{role === '管理层' && !consistent && (
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginTop: `${space(2)}px`, fontSize: '11px', lineHeight: 1.6 }}>
</div>
)}
</div>
);