fix: 优化门店健康分评分公式 - 正态分布(均值63, 标准差9)
This commit is contained in:
@@ -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 ? <Badge type="risk" text={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) => (
|
||||
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', correlationStatusColor[r.correlation_status] || 'bg-gray-100 text-gray-700 border-gray-300')}>
|
||||
{r.correlation_status}
|
||||
|
||||
@@ -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
|
||||
`)
|
||||
|
||||
Reference in New Issue
Block a user