fix(calibration): 校准幂等+已校准状态显示,解决重复提示
根因:预测准确度卡的偏差来自历史回填项目的预测vs实际,属既成事实, 不会因校准改变;且原 apply 公式 next=current+dev 会累加,反复点越推越高。 修复: - 校准建议/应用均基于未校准原始基准(env/默认)计算,保证幂等 - GET /api/calibration 返回 uncalibratedBase 与 calibrated 标志 - 卡片:已校准时显示「已按当前偏差校准:基准X%」绿色状态,不再出现按钮; 未校准时按钮明示「X% → Y%」 - 补充说明:历史偏差不会因校准改变,校准仅调整后续承接建议阈值
This commit is contained in:
+20
-5
@@ -358,6 +358,18 @@ function targetNetMargin(): number {
|
|||||||
return Number.isFinite(v) && v > 0 ? v : DEFAULT_TARGET_NET_MARGIN;
|
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
|
* POST /api/assessments/classify
|
||||||
* 输入项目描述,返回业务类型与行业识别结果。
|
* 输入项目描述,返回业务类型与行业识别结果。
|
||||||
@@ -885,6 +897,7 @@ app.get('/api/accuracy', async (c) => {
|
|||||||
*/
|
*/
|
||||||
app.get('/api/calibration', async (c) => {
|
app.get('/api/calibration', async (c) => {
|
||||||
const current = targetNetMargin();
|
const current = targetNetMargin();
|
||||||
|
const uncalibrated = uncalibratedTargetNetMargin();
|
||||||
let suggested = current;
|
let suggested = current;
|
||||||
let bias: string | null = null;
|
let bias: string | null = null;
|
||||||
let deviationPct: number | null = null;
|
let deviationPct: number | null = null;
|
||||||
@@ -893,16 +906,18 @@ app.get('/api/calibration', async (c) => {
|
|||||||
bias = acc.bias;
|
bias = acc.bias;
|
||||||
deviationPct = acc.avgDeviationPct;
|
deviationPct = acc.avgDeviationPct;
|
||||||
if (acc.avgDeviationPct !== null && acc.count > 0) {
|
if (acc.avgDeviationPct !== null && acc.count > 0) {
|
||||||
// 预测偏乐观(dev>0)→ 抬高要求的目标净利率以补偿;偏保守则下调。夹取 [2%,30%]。
|
// 预测偏乐观(dev>0)→ 抬高要求的目标净利率以补偿;偏保守则下调。基于未校准基准,保证幂等。
|
||||||
suggested = Math.min(0.3, Math.max(0.02, Math.round((current + acc.avgDeviationPct / 100) * 1000) / 1000));
|
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
|
* POST /api/calibration/apply
|
||||||
* 应用校准:将目标净利率基准设为据预测偏差计算的建议值(管理层)。
|
* 应用校准:将目标净利率基准设为据预测偏差计算的建议值(管理层)。基于未校准基准,幂等。
|
||||||
*/
|
*/
|
||||||
app.post('/api/calibration/apply', requireRole('管理层'), async (c) => {
|
app.post('/api/calibration/apply', requireRole('管理层'), async (c) => {
|
||||||
if (pool === null) return c.json({ error: '未配置数据库' }, 400);
|
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);
|
return c.json({ error: '暂无足够的实际值回填数据用于校准' }, 400);
|
||||||
}
|
}
|
||||||
const current = targetNetMargin();
|
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);
|
await setSetting(pool, 'targetMarginBase', next);
|
||||||
calibratedTargetBase = next;
|
calibratedTargetBase = next;
|
||||||
return c.json({ appliedBase: next, previousBase: current, deviationPct: acc.avgDeviationPct });
|
return c.json({ appliedBase: next, previousBase: current, deviationPct: acc.avgDeviationPct });
|
||||||
|
|||||||
@@ -566,6 +566,18 @@ export async function applyCalibration(): Promise<{ appliedBase: number; previou
|
|||||||
return request('POST', '/api/calibration/apply', {});
|
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(
|
export async function submitRedlineVerdict(
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import {
|
|||||||
space,
|
space,
|
||||||
typographyStyle,
|
typographyStyle,
|
||||||
} from '../design-system/components/styles.js';
|
} 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 type { AssessmentListItem, WorkflowStatus, DraftItem, AssignmentRecord } from '../api/client.js';
|
||||||
import { useAuthStore } from '../stores/authStore.js';
|
import { useAuthStore } from '../stores/authStore.js';
|
||||||
import { GuideBanner } from '../app/Guidance.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 [overdue, setOverdue] = useState<Array<{ id: string; project: string; overdueHours: number }>>([]);
|
||||||
const [rejectStats, setRejectStats] = useState<Array<{ reasonType: string; count: 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 [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(() => {
|
useEffect(() => {
|
||||||
@@ -157,12 +162,13 @@ export function Dashboard(): JSX.Element {
|
|||||||
}
|
}
|
||||||
// 组合分析(准确度/驳回Top/到期/超时)面向风控与管理层;销售视图不展示(避免跨人数据)。
|
// 组合分析(准确度/驳回Top/到期/超时)面向风控与管理层;销售视图不展示(避免跨人数据)。
|
||||||
if (role === '商务/销售') {
|
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 {
|
} else {
|
||||||
fetch(`${API_BASE}/api/assessments/expiring`).then((r) => r.json()).then(setExpiring).catch(() => setExpiring([]));
|
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/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/reject-reason-stats`).then((r) => r.json()).then(setRejectStats).catch(() => setRejectStats([]));
|
||||||
fetch(`${API_BASE}/api/accuracy`).then((r) => r.json()).then(setAccuracy).catch(() => undefined);
|
fetch(`${API_BASE}/api/accuracy`).then((r) => r.json()).then(setAccuracy).catch(() => undefined);
|
||||||
|
fetchCalibration().then(setCalibration).catch(() => setCalibration(null));
|
||||||
}
|
}
|
||||||
// 草稿箱(仅销售展示):列出当前用户的向导草稿。
|
// 草稿箱(仅销售展示):列出当前用户的向导草稿。
|
||||||
if (role === '商务/销售') {
|
if (role === '商务/销售') {
|
||||||
@@ -472,21 +478,36 @@ export function Dashboard(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
{!consistent && (
|
{!consistent && (
|
||||||
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginTop: `${space(2)}px`, lineHeight: 1.6 }}>
|
<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>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
applyCalibration()
|
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 : '校准失败'));
|
.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 }}
|
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>
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{role === '管理层' && !consistent && (
|
||||||
|
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginTop: `${space(2)}px`, fontSize: '11px', lineHeight: 1.6 }}>
|
||||||
|
注:上方偏差来自已回填项目的历史预测与实际对比,属既成事实,不会因校准而改变;校准仅调整后续评估的目标净利率基准(影响承接建议阈值)。
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user