From 8bac14ef44b865451e2f2545a378d25e224ffea8 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Sun, 14 Jun 2026 11:01:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(calibration):=20=E6=A0=A1=E5=87=86=E5=B9=82?= =?UTF-8?q?=E7=AD=89+=E5=B7=B2=E6=A0=A1=E5=87=86=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=98=BE=E7=A4=BA,=E8=A7=A3=E5=86=B3=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:预测准确度卡的偏差来自历史回填项目的预测vs实际,属既成事实, 不会因校准改变;且原 apply 公式 next=current+dev 会累加,反复点越推越高。 修复: - 校准建议/应用均基于未校准原始基准(env/默认)计算,保证幂等 - GET /api/calibration 返回 uncalibratedBase 与 calibrated 标志 - 卡片:已校准时显示「已按当前偏差校准:基准X%」绿色状态,不再出现按钮; 未校准时按钮明示「X% → Y%」 - 补充说明:历史偏差不会因校准改变,校准仅调整后续承接建议阈值 --- src/server/index.ts | 25 +++++++++++++++---- web/src/api/client.ts | 12 +++++++++ web/src/pages/Dashboard.tsx | 49 ++++++++++++++++++++++++++----------- 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 9de6369..4105cca 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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 }); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5aa6693..ca786aa 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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, diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 4b3797d..9df370a 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -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>([]); const [rejectStats, setRejectStats] = useState>([]); 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(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 { {!consistent && (
- {accuracy.bias === '预测偏乐观' ? '建议下调报价预期或上调成本假设进行校准。' : '成本假设或偏保守,可适当下调目标基准。'} + {accuracy.bias === '预测偏乐观' ? '建议上调目标净利率基准以补偿系统性乐观偏差。' : '预测偏保守,可适当下调目标净利率基准。'} + {calibration !== null && ( + 当前基准 {(calibration.currentBase * 100).toFixed(1)}% + {!calibration.calibrated && <>,建议调整为 {(calibration.suggestedBase * 100).toFixed(1)}%}。 + )}
)} + {role === '管理层' && !consistent && calibration !== null && ( + calibration.calibrated ? ( +
+ 已按当前偏差校准:目标净利率基准 {(calibration.currentBase * 100).toFixed(1)}% +
+ ) : ( + + ) + )} {role === '管理层' && !consistent && ( - +
+ 注:上方偏差来自已回填项目的历史预测与实际对比,属既成事实,不会因校准而改变;校准仅调整后续评估的目标净利率基准(影响承接建议阈值)。 +
)} );