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
+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,
+35 -14
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 && 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(); })
.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} /> {(calibration.currentBase * 100).toFixed(1)}% {(calibration.suggestedBase * 100).toFixed(1)}%
</button>
)
)}
{role === '管理层' && !consistent && (
<button
type="button"
onClick={() => {
applyCalibration()
.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} />
</button>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginTop: `${space(2)}px`, fontSize: '11px', lineHeight: 1.6 }}>
</div>
)}
</div>
);