From efb27127e27860e5d3b4c56e1ba2fee43ac5be88 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Thu, 30 Jul 2026 09:07:16 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E9=97=A8=E5=BA=97?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E5=88=86=E8=AF=84=E5=88=86=E5=85=AC=E5=BC=8F?= =?UTF-8?q?=20-=20=E6=AD=A3=E6=80=81=E5=88=86=E5=B8=83(=E5=9D=87=E5=80=BC6?= =?UTF-8?q?3,=20=E6=A0=87=E5=87=86=E5=B7=AE9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/SituationalAwarenessPage.tsx | 10 ++-- server/src/routes/situational-awareness.ts | 49 +++++++++++-------- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/client/src/pages/SituationalAwarenessPage.tsx b/client/src/pages/SituationalAwarenessPage.tsx index 7a28d74..be2635c 100644 --- a/client/src/pages/SituationalAwarenessPage.tsx +++ b/client/src/pages/SituationalAwarenessPage.tsx @@ -240,7 +240,7 @@ function HealthScoreTab({ navigate }: { navigate: (path: string) => void }) { { key: 'health_score', label: '健康分' }, { key: 'received', label: '实收' }, { key: 'avg_margin', label: '毛利率' }, - { key: 'over_cost_rate', label: '超耗率' }, + { key: 'avg_cost_variance_pct', label: '成本差异率' }, { key: 'repeat_rate_pct', label: '复购率' }, { key: 'task_completion_rate', label: '完成率' }, ]} @@ -268,7 +268,7 @@ function HealthScoreTab({ navigate }: { navigate: (path: string) => void }) { { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, { key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) }, { key: 'avg_margin', label: '毛利率', align: 'right', render: (r) => `${r.avg_margin}%` }, - { key: 'over_cost_rate', label: '超耗率', align: 'right', render: (r) => `${r.over_cost_rate}%` }, + { key: 'avg_cost_variance_pct', label: '成本差异率', align: 'right', render: (r) => `${r.avg_cost_variance_pct}%` }, { key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => `${r.repeat_rate_pct}%` }, { key: 'task_completion_rate', label: '任务完成率', align: 'right', render: (r) => `${r.task_completion_rate}%` }, ]} @@ -457,7 +457,7 @@ function CorrelationTab({ navigate }: { navigate: (path: string) => void }) { const costRiskScatter = costRiskRows.map((r: any) => ({ name: r.store_name, x: Number(r.avg_daily_received || 0), - y: Number(r.over_cost_rate || 0), + y: Number(r.avg_cost_variance_pct || 0), risk: r.risk_level, fill: r.risk_level === '红色' ? '#ef4444' : r.risk_level === '黄色' ? '#eab308' : '#22c55e', })) @@ -488,7 +488,7 @@ function CorrelationTab({ navigate }: { navigate: (path: string) => void }) { sortOptions={[ { key: 'received', label: '实收' }, { key: 'avg_margin', label: '毛利率' }, - { key: 'over_cost_rate', label: '超耗率' }, + { key: 'avg_cost_variance_pct', label: '成本差异率' }, ]} statusFilterKey="risk_level" statusFilterLabel="全部风险" @@ -503,7 +503,7 @@ function CorrelationTab({ navigate }: { navigate: (path: string) => void }) { { key: 'risk_level', label: '风险', align: 'center', render: (r) => r.risk_level ? : '-' }, { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, { key: 'avg_margin', label: '毛利率', align: 'right', render: (r) => `${r.avg_margin}%` }, - { key: 'over_cost_rate', label: '超耗率', align: 'right', render: (r) => `${r.over_cost_rate}%` }, + { key: 'avg_cost_variance_pct', label: '成本差异率', align: 'right', render: (r) => `${r.avg_cost_variance_pct}%` }, { key: 'correlation_status', label: '关联状态', align: 'center', render: (r) => ( {r.correlation_status} diff --git a/server/src/routes/situational-awareness.ts b/server/src/routes/situational-awareness.ts index e1b6525..a67dbd9 100644 --- a/server/src/routes/situational-awareness.ts +++ b/server/src/routes/situational-awareness.ts @@ -17,18 +17,17 @@ router.get('/health-score', async (req: AuthRequest, res) => { FROM analytics.mv_store_risk_rating WHERE received IS NOT NULL ), - max_rev AS ( - SELECT max(avg_daily_received) AS max_avg FROM base WHERE avg_daily_received > 0 - ), cost AS ( SELECT store_code, - round(sum(CASE WHEN variance_to_theoretical_pct > 0 THEN 1 ELSE 0 END)::numeric / nullif(count(*), 0) * 100, 1) AS over_cost_rate + round(avg(LEAST(variance_to_theoretical_pct, 50))::numeric, 1) AS avg_cost_variance_pct FROM analytics.v_store_theoretical_actual_cost_april + WHERE variance_to_theoretical_pct IS NOT NULL GROUP BY store_code ), member AS ( - SELECT store_code, repeat_rate_pct + SELECT store_code, max(repeat_rate_pct) AS repeat_rate_pct FROM analytics.v_store_repeat_summary_monthly + GROUP BY store_code ), task AS ( SELECT store_code, @@ -43,26 +42,35 @@ router.get('/health-score', async (req: AuthRequest, res) => { b.store_code, b.store_name, b.received, b.bill_count, b.avg_bill_value, b.risk_level, COALESCE(b.theoretical_margin_pct, 0) AS avg_margin, - COALESCE(c.over_cost_rate, 0) AS over_cost_rate, + COALESCE(c.avg_cost_variance_pct, 20) AS avg_cost_variance_pct, COALESCE(m.repeat_rate_pct, 0) AS repeat_rate_pct, COALESCE(b.member_bill_share_pct, 0) AS member_share_pct, COALESCE(t.completion_rate, 0) AS task_completion_rate, LEAST(100, GREATEST(0, - 25 * CASE WHEN b.avg_daily_received > 0 THEN LEAST(1.0, b.avg_daily_received / mx.max_avg) ELSE 0 END - + 20 * GREATEST(0, LEAST(1.0, COALESCE(b.theoretical_margin_pct, 0) / 70)) * GREATEST(0, 1 - COALESCE(c.over_cost_rate, 0) / 100) - + CASE b.risk_level WHEN '绿色' THEN 20 WHEN '黄色' THEN 12 WHEN '红色' THEN 4 ELSE 10 END - + 10 * GREATEST(0, LEAST(1.0, COALESCE(m.repeat_rate_pct, 0) / 80)) + -- 1. 营收规模 20分:30000=满分,中位18642≈12分 + 20 * LEAST(1.0, b.avg_daily_received / 30000) + -- 2. 成本控制 20分:差异率0%=满分,50%=0分,中位19%≈12分 + + 20 * GREATEST(0, 1 - LEAST(COALESCE(c.avg_cost_variance_pct, 20), 50) / 50) + -- 3. 毛利率 15分:60%=0分,75%=满分,均值71.5%≈11.5分 + + 15 * LEAST(1.0, GREATEST(0, COALESCE(b.theoretical_margin_pct, 0) - 60) / 15) + -- 4. 风险等级 10分 + + CASE b.risk_level WHEN '绿色' THEN 10 WHEN '黄色' THEN 6 WHEN '红色' THEN 2 ELSE 4 END + -- 5. 复购率 10分:40%=满分,中位35%≈8.75分 + + 10 * LEAST(1.0, COALESCE(m.repeat_rate_pct, 0) / 40) + -- 6. 会员占比 10分:20%=满分,中位12.5%≈6.25分 + + 10 * LEAST(1.0, COALESCE(b.member_bill_share_pct, 0) / 20) + -- 7. 任务完成 10分 + 10 * COALESCE(t.completion_rate, 0) / 100 - + 10 * GREATEST(0, LEAST(1.0, COALESCE(b.member_bill_share_pct, 0) / 60)) + -- 8. 客单价 5分:40元=满分,中位33.4≈4.2分 + + 5 * LEAST(1.0, COALESCE(b.avg_bill_value, 0) / 40) ))::numeric(5,1) AS health_score FROM base b - CROSS JOIN max_rev mx LEFT JOIN cost c ON b.store_code = c.store_code LEFT JOIN member m ON b.store_code = m.store_code LEFT JOIN task t ON b.store_code = t.store_code ) SELECT store_code, store_name, received, bill_count, avg_bill_value, - risk_level, avg_margin, over_cost_rate, repeat_rate_pct, + risk_level, avg_margin, avg_cost_variance_pct, repeat_rate_pct, member_share_pct, task_completion_rate, health_score, CASE WHEN health_score >= 75 THEN '健康' WHEN health_score >= 55 THEN '亚健康' @@ -272,26 +280,27 @@ router.get('/correlation', async (req: AuthRequest, res) => { r.store_code, r.store_name, r.risk_level, r.received, r.avg_daily_received, COALESCE(r.theoretical_margin_pct, 0) AS avg_margin, - COALESCE(c.over_cost_rate, 0) AS over_cost_rate, + COALESCE(c.avg_cost_variance_pct, 20) AS avg_cost_variance_pct, CASE - WHEN r.risk_level = '红色' AND COALESCE(c.over_cost_rate, 0) > 30 THEN '成本失控+高风险' + WHEN r.risk_level = '红色' AND COALESCE(c.avg_cost_variance_pct, 20) > 30 THEN '成本失控+高风险' WHEN r.risk_level = '红色' THEN '高风险' - WHEN r.risk_level = '黄色' AND COALESCE(c.over_cost_rate, 0) > 20 THEN '成本偏高+中风险' - WHEN COALESCE(c.over_cost_rate, 0) > 30 THEN '成本失控' + WHEN r.risk_level = '黄色' AND COALESCE(c.avg_cost_variance_pct, 20) > 25 THEN '成本偏高+中风险' + WHEN COALESCE(c.avg_cost_variance_pct, 20) > 30 THEN '成本失控' ELSE '正常' END AS correlation_status FROM analytics.mv_store_risk_rating r LEFT JOIN ( SELECT store_code, - round(sum(CASE WHEN variance_to_theoretical_pct > 0 THEN 1 ELSE 0 END)::numeric / nullif(count(*), 0) * 100, 1) AS over_cost_rate + round(avg(LEAST(variance_to_theoretical_pct, 50))::numeric, 1) AS avg_cost_variance_pct FROM analytics.v_store_theoretical_actual_cost_april + WHERE variance_to_theoretical_pct IS NOT NULL GROUP BY store_code ) c ON r.store_code = c.store_code WHERE r.received IS NOT NULL ORDER BY - CASE WHEN r.risk_level = '红色' AND COALESCE(c.over_cost_rate, 0) > 30 THEN 0 + CASE WHEN r.risk_level = '红色' AND COALESCE(c.avg_cost_variance_pct, 20) > 30 THEN 0 WHEN r.risk_level = '红色' THEN 1 - WHEN r.risk_level = '黄色' AND COALESCE(c.over_cost_rate, 0) > 20 THEN 2 + WHEN r.risk_level = '黄色' AND COALESCE(c.avg_cost_variance_pct, 20) > 25 THEN 2 ELSE 3 END, r.received DESC NULLS LAST `)