feat: 连锁餐饮数字本体与AI经营智脑 - 新增分析页面、ETL脚本、总结文档

This commit is contained in:
freedakgmail
2026-08-02 10:54:48 +08:00
parent 73f12f40d1
commit a1a8b7ee5e
19 changed files with 3453 additions and 2 deletions
+2
View File
@@ -10,6 +10,7 @@ import costAnalysisRoutes from './routes/cost-analysis.js'
import storeExpenseRoutes from './routes/store-expense.js'
import smartSchedulingRoutes from './routes/smart-scheduling.js'
import situationalAwarenessRoutes from './routes/situational-awareness.js'
import analyticsEnhancedRoutes from './routes/analytics-enhanced.js'
const app = express()
const PORT = parseInt(process.env.PORT || '3333')
@@ -39,6 +40,7 @@ app.use('/api/cost-analysis', costAnalysisRoutes)
app.use('/api/store-expense', storeExpenseRoutes)
app.use('/api/smart-scheduling', smartSchedulingRoutes)
app.use('/api/situational-awareness', situationalAwarenessRoutes)
app.use('/api/analytics-enhanced', analyticsEnhancedRoutes)
app.use(notFoundHandler)
app.use(errorHandler)
+843
View File
@@ -0,0 +1,843 @@
import { Router } from 'express'
import { query } from '../config/database.js'
import { sendSuccess, sendError, parseMonth, parsePagination } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
// ============================================================
// 1. 会员LTV与分层
// ============================================================
router.get('/member/ltv', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const { page, pageSize, offset } = parsePagination(req)
const status = (req.query.status as string) || ''
const level = (req.query.level as string) || ''
let whereClause = 'WHERE 1=1'
const params: any[] = []
if (status) {
params.push(status)
whereClause += ` AND dm.status = $${params.length}`
}
if (level) {
params.push(level)
whereClause += ` AND dm.member_level = $${params.length}`
}
const countResult = await query(`SELECT count(*) as total FROM analytics.dim_member dm ${whereClause}`, params)
const total = Number(countResult.rows[0].total)
params.push(pageSize, offset)
const result = await query(`
SELECT dm.member_id, dm.register_channel, dm.register_store, ds.store_name as register_store_name,
dm.register_date, dm.member_level, dm.total_orders, round(dm.total_revenue::numeric, 2) as total_revenue,
dm.last_order_date, dm.status, dm.tags
FROM analytics.dim_member dm
LEFT JOIN analytics.dim_store ds ON dm.register_store = ds.store_code
${whereClause}
ORDER BY dm.total_revenue DESC NULLS LAST
LIMIT $${params.length - 1} OFFSET $${params.length}
`, params)
// 汇总统计
const summaryResult = await query(`
SELECT
count(*) as total_members,
count(*) FILTER (WHERE status = '活跃') as active_count,
count(*) FILTER (WHERE status = '沉睡') as dormant_count,
count(*) FILTER (WHERE status = '流失') as churned_count,
round(avg(total_revenue)::numeric, 2) as avg_ltv,
round(sum(total_revenue)::numeric, 2) as total_revenue,
round(avg(total_orders)::numeric, 1) as avg_orders
FROM analytics.dim_member
`)
// 等级分布
const levelDist = await query(`
SELECT member_level, count(*) as count,
round(avg(total_revenue)::numeric, 2) as avg_revenue,
round(avg(total_orders)::numeric, 1) as avg_orders
FROM analytics.dim_member GROUP BY member_level ORDER BY
CASE member_level WHEN '钻石' THEN 1 WHEN '金卡' THEN 2 WHEN '银卡' THEN 3 WHEN '普通' THEN 4 ELSE 5 END
`)
sendSuccess(res, {
summary: summaryResult.rows[0],
level_distribution: levelDist.rows,
members: result.rows,
}, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 2. 菜单工程行动清单
// ============================================================
router.get('/menu-engineering/actions', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
dish_name,
abc_class,
received_amount as revenue,
bill_count as order_count,
realized_unit_price as avg_price,
category_level1 as category_l1,
category_level2 as category_l2,
CASE
WHEN abc_class LIKE 'A%' AND bill_count > 0 THEN '保留并推广'
WHEN abc_class LIKE 'A%' AND bill_count <= 0 THEN '调查停售原因'
WHEN abc_class LIKE 'B%' THEN '优化提升'
WHEN abc_class LIKE 'C%' AND received_amount < 1000 THEN '考虑淘汰'
WHEN abc_class LIKE 'C%' THEN '降本或提价'
ELSE '观察'
END as action,
CASE
WHEN abc_class LIKE 'A%' AND realized_unit_price > 0 THEN '高人气高收入,维持品质,可考虑提价测试'
WHEN abc_class LIKE 'B%' AND bill_count > 50 THEN '有潜力,优化呈现和推荐话术'
WHEN abc_class LIKE 'C%' AND received_amount < 500 THEN '长尾SKU,建议下架或季节性供应'
WHEN abc_class LIKE 'C%' THEN '收入偏低,尝试降本或搭配套餐'
ELSE '持续观察'
END as suggestion
FROM analytics.mv_dish_sku_abc_monthly
WHERE month_start = $1
ORDER BY
CASE WHEN abc_class LIKE 'A%' THEN 1 WHEN abc_class LIKE 'B%' THEN 2 WHEN abc_class LIKE 'C%' THEN 3 ELSE 4 END,
received_amount DESC NULLS LAST
`, [month])
// 汇总
const summary = {
total_skus: result.rows.length,
class_a: result.rows.filter((r: any) => r.abc_class?.startsWith('A')).length,
class_b: result.rows.filter((r: any) => r.abc_class?.startsWith('B')).length,
class_c: result.rows.filter((r: any) => r.abc_class?.startsWith('C')).length,
recommend_eliminate: result.rows.filter((r: any) => r.action === '考虑淘汰').length,
recommend_promote: result.rows.filter((r: any) => r.action === '保留并推广').length,
}
sendSuccess(res, { summary, actions: result.rows })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 3. 菜品搭配分析(套餐推荐)
// ============================================================
router.get('/dish-pair/recommendations', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const { page, pageSize, offset } = parsePagination(req)
const minPairCount = parseInt((req.query.min_count as string) || '10')
const countResult = await query(`
SELECT count(*) as total FROM analytics.mv_dish_pair_summary_monthly
WHERE month_start = $1 AND pair_count >= $2
`, [month, minPairCount])
const total = Number(countResult.rows[0].total)
const result = await query(`
SELECT
dish_a, dish_b, pair_count,
dish_a_revenue, dish_b_revenue,
combined_revenue,
round(pair_count::numeric / NULLIF((SELECT max(pair_count) FROM analytics.mv_dish_pair_summary_monthly WHERE month_start = $1), 0) * 100, 1) as affinity_pct
FROM analytics.mv_dish_pair_summary_monthly
WHERE month_start = $1 AND pair_count >= $2
ORDER BY pair_count DESC
LIMIT $3 OFFSET $4
`, [month, minPairCount, pageSize, offset])
sendSuccess(res, result.rows, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 4. 区域间对比排名
// ============================================================
router.get('/region/comparison', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH store_metrics AS (
SELECT
ds.region,
count(DISTINCT r.store_code) as store_count,
sum(r.received) as total_revenue,
round(avg(r.avg_bill_value)::numeric, 2) as avg_bill_value,
sum(r.bill_count) as bill_count,
round(avg(r.theoretical_margin_pct)::numeric, 2) as avg_margin,
round(sum(r.bill_count * r.member_bill_share_pct / 100)::numeric, 0) as member_count
FROM analytics.mv_store_risk_rating_monthly r
JOIN analytics.dim_store ds ON r.store_code = ds.store_code
WHERE r.month_start = $1
AND ds.region IS NOT NULL AND ds.region != '未知区域'
GROUP BY ds.region
),
expense_metrics AS (
SELECT
ds.region,
avg(oe.operating_expense_rate_pct) as avg_expense_rate,
avg(oe.rent_rate_pct) as avg_rent_rate,
avg(oe.wage_rate_pct) as avg_wage_rate
FROM analytics.mv_store_operating_expense_monthly oe
JOIN analytics.dim_store ds ON oe.sales_store_code = ds.store_code
WHERE oe.report_month = $1
AND ds.region IS NOT NULL AND ds.region != '未知区域'
GROUP BY ds.region
)
SELECT
sm.region,
sm.store_count,
round(sm.total_revenue::numeric, 2) as total_revenue,
round((sm.total_revenue / sm.store_count)::numeric, 2) as revenue_per_store,
round(sm.avg_bill_value::numeric, 2) as avg_bill_value,
sm.bill_count,
round(sm.avg_margin::numeric, 2) as avg_margin_pct,
sm.member_count,
round((sm.member_count::numeric / NULLIF(sm.bill_count, 0) * 100)::numeric, 1) as member_penetration_pct,
round(em.avg_expense_rate::numeric, 2) as avg_expense_rate,
round(em.avg_rent_rate::numeric, 2) as avg_rent_rate,
round(em.avg_wage_rate::numeric, 2) as avg_wage_rate
FROM store_metrics sm
LEFT JOIN expense_metrics em ON sm.region = em.region
ORDER BY sm.total_revenue DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 5. 区域KPI达成预测
// ============================================================
router.get('/region/kpi-forecast', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH region_actual AS (
SELECT
ds.region,
round(sum(r.received)::numeric, 2) as actual_revenue,
sum(r.bill_count) as actual_bills,
max(r.active_days) as elapsed_days,
round(sum(r.received) / NULLIF(max(r.active_days), 0)::numeric, 2) as avg_daily_revenue
FROM analytics.mv_store_risk_rating_monthly r
JOIN analytics.dim_store ds ON r.store_code = ds.store_code
WHERE r.month_start = $1
AND ds.region IS NOT NULL AND ds.region != '未知区域'
GROUP BY ds.region
),
region_target AS (
SELECT
ds.region,
round(sum(t.revenue_target)::numeric, 2) as target_revenue,
round(sum(t.bill_count_target)::numeric, 0) as target_bills
FROM analytics.dim_store_target t
JOIN analytics.dim_store ds ON t.store_code = ds.store_code
WHERE t.target_month = $1
AND ds.region IS NOT NULL AND ds.region != '未知区域'
GROUP BY ds.region
),
calendar AS (
SELECT count(*) as total_days
FROM analytics.dim_calendar
WHERE date_value >= $1::date AND date_value < $1::date + interval '1 month'
AND is_operating_day = true
)
SELECT
ra.region,
ra.actual_revenue,
rt.target_revenue,
ra.actual_bills,
rt.target_bills,
ra.elapsed_days,
c.total_days as working_days,
ra.avg_daily_revenue,
round((ra.avg_daily_revenue * c.total_days)::numeric, 2) as forecast_revenue,
round((ra.actual_revenue / NULLIF(rt.target_revenue, 0) * 100)::numeric, 1) as achievement_pct,
round((ra.actual_bills / NULLIF(rt.target_bills, 0) * 100)::numeric, 1) as bill_achievement_pct
FROM region_actual ra
LEFT JOIN region_target rt ON ra.region = rt.region
CROSS JOIN calendar c
ORDER BY ra.actual_revenue DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 6. 每日销售目标分解
// ============================================================
router.get('/store/daily-target', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const storeCode = (req.query.store_code as string) || ''
if (!storeCode) {
return sendError(res, 'store_code is required')
}
// 该门店历史日均收入(按餐段)
const mealPeriodStats = await query(`
SELECT
meal_period,
round(avg(daily_revenue)::numeric, 2) as avg_revenue,
round(avg(bill_count)::numeric, 0) as avg_bills,
round(avg(avg_bill_value)::numeric, 2) as avg_bill_value
FROM (
SELECT
meal_period,
date_trunc('day', opened_at)::date as day,
sum(received_total) as daily_revenue,
count(*) as bill_count,
round(avg(received_total)::numeric, 2) as avg_bill_value
FROM analytics.bill_fact
WHERE store_code = $1
AND opened_at >= $2 AND opened_at < $2::date + interval '1 month'
GROUP BY meal_period, date_trunc('day', opened_at)::date
) t
GROUP BY meal_period
ORDER BY
CASE meal_period WHEN '早市' THEN 1 WHEN '午市' THEN 2 WHEN '下午茶' THEN 3 WHEN '晚市' THEN 4 WHEN '夜宵' THEN 5 ELSE 6 END
`, [storeCode, month])
// 该门店历史日均收入(按星期)
const weekdayStats = await query(`
SELECT
extract(dow from opened_at)::int as weekday,
round(avg(daily_revenue)::numeric, 2) as avg_revenue,
round(avg(bill_count)::numeric, 0) as avg_bills
FROM (
SELECT
date_trunc('day', opened_at)::date as day,
extract(dow from opened_at)::int as weekday,
sum(received_total) as daily_revenue,
count(*) as bill_count
FROM analytics.bill_fact
WHERE store_code = $1
AND opened_at >= $2 AND opened_at < $2::date + interval '1 month'
GROUP BY date_trunc('day', opened_at)::date, extract(dow from opened_at)::int
) t
GROUP by weekday
ORDER BY weekday
`, [storeCode, month])
// 月度总目标(基于历史日均 * 当月天数)
const totalAvg = await query(`
SELECT
round(avg(daily_revenue)::numeric, 2) as avg_daily_revenue,
count(DISTINCT date_trunc('day', opened_at)::date) as active_days,
round(sum(daily_revenue)::numeric, 2) as total_revenue
FROM (
SELECT date_trunc('day', opened_at)::date as day, sum(received_total) as daily_revenue
FROM analytics.bill_fact
WHERE store_code = $1 AND opened_at >= $2 AND opened_at < $2::date + interval '1 month'
GROUP BY date_trunc('day', opened_at)::date
) t
`, [storeCode, month])
const workingDays = await query(`
SELECT count(*) as total_days FROM analytics.dim_calendar
WHERE date_value >= $1::date AND date_value < $1::date + interval '1 month' AND is_operating_day = true
`, [month])
const avgDaily = Number(totalAvg.rows[0]?.avg_daily_revenue || 0)
const forecastTotal = avgDaily * Number(workingDays.rows[0]?.total_days || 30)
sendSuccess(res, {
monthly_target: Math.round(forecastTotal),
avg_daily_revenue: avgDaily,
meal_period_breakdown: mealPeriodStats.rows,
weekday_breakdown: weekdayStats.rows,
})
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 7. 门店会员活跃度面板
// ============================================================
router.get('/store/member-activity', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const storeCode = (req.query.store_code as string) || ''
if (!storeCode) {
return sendError(res, 'store_code is required')
}
// 该门店会员活跃度
const memberStats = await query(`
WITH store_members AS (
SELECT
bf.member_id,
count(DISTINCT bf.bill_no) as orders,
sum(bf.received_total) as revenue,
max(bf.opened_at) as last_visit,
min(bf.opened_at) as first_visit
FROM analytics.bill_fact bf
WHERE bf.store_code = $1
AND bf.opened_at >= $2 AND bf.opened_at < $2::date + interval '1 month'
AND bf.member_id IS NOT NULL AND bf.member_id != ''
GROUP BY bf.member_id
)
SELECT
count(*) as total_members,
count(*) FILTER (WHERE orders >= 3) as frequent_members,
count(*) FILTER (WHERE orders = 1) as one_time_members,
round(avg(orders)::numeric, 1) as avg_orders,
round(avg(revenue)::numeric, 2) as avg_revenue,
round(sum(revenue)::numeric, 2) as total_revenue
FROM store_members
`, [storeCode, month])
// 会员等级分布
const levelDist = await query(`
SELECT
CASE
WHEN bf.member_level IN ('1') THEN '普通'
WHEN bf.member_level IN ('2','3') THEN '银卡'
WHEN bf.member_level IN ('4','5') THEN '金卡'
WHEN bf.member_level IN ('6','7','LV6','LV7') THEN '钻石'
ELSE '普通'
END as standard_level,
count(DISTINCT bf.member_id) as count,
round(sum(bf.received_total)::numeric, 2) as revenue
FROM analytics.bill_fact bf
WHERE bf.store_code = $1
AND bf.opened_at >= $2 AND bf.opened_at < $2::date + interval '1 month'
AND bf.member_id IS NOT NULL AND bf.member_id != ''
GROUP BY 1
ORDER BY
CASE standard_level WHEN '钻石' THEN 1 WHEN '金卡' THEN 2 WHEN '银卡' THEN 3 WHEN '普通' THEN 4 ELSE 5 END
`, [storeCode, month])
// 会员 vs 非会员
const comparison = await query(`
SELECT
CASE WHEN member_id IS NOT NULL AND member_id != '' THEN '会员' ELSE '非会员' END as customer_type,
count(*) as bill_count,
round(sum(received_total)::numeric, 2) as revenue,
round(avg(received_total)::numeric, 2) as avg_bill_value
FROM analytics.bill_fact
WHERE store_code = $1
AND opened_at >= $2 AND opened_at < $2::date + interval '1 month'
GROUP BY 1
`, [storeCode, month])
sendSuccess(res, {
stats: memberStats.rows[0],
level_distribution: levelDist.rows,
comparison: comparison.rows,
})
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 8. 员工绩效分析
// ============================================================
router.get('/employee/performance', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const position = (req.query.position as string) || ''
const status = (req.query.status as string) || ''
let whereClause = 'WHERE 1=1'
const params: any[] = []
if (position) {
params.push(position)
whereClause += ` AND de.position = $${params.length}`
}
if (status) {
params.push(status)
whereClause += ` AND de.status = $${params.length}`
}
const countResult = await query(`SELECT count(*) as total FROM analytics.dim_employee ${whereClause}`, params)
const total = Number(countResult.rows[0].total)
params.push(pageSize, offset)
const result = await query(`
SELECT
de.employee_id,
de.position,
de.hire_date,
de.leave_date,
de.status,
s.salary_period,
round(s.gross_pay::numeric, 2) as gross_pay,
round(s.net_pay::numeric, 2) as net_pay,
round(s.perf_score::numeric, 2) as perf_score,
round(s.actual_attend::numeric, 0) as actual_attend,
round(s.expected_attend::numeric, 0) as expected_attend,
round(s.overtime_pay::numeric, 2) as overtime_pay,
round(s.bonus::numeric, 2) as bonus,
s.org_level2,
s.org_level3
FROM analytics.dim_employee de
LEFT JOIN public.salary_detail_records s ON de.employee_id = s.employee_code
${whereClause}
ORDER BY s.gross_pay DESC NULLS LAST
LIMIT $${params.length - 1} OFFSET $${params.length}
`, params)
// 岗位汇总
const positionSummary = await query(`
SELECT
de.position,
count(*) as headcount,
count(*) FILTER (WHERE de.status = '在职') as active_count,
round(avg(s.gross_pay)::numeric, 2) as avg_gross_pay,
round(avg(s.perf_score)::numeric, 2) as avg_perf_score,
round(avg(s.actual_attend::numeric / NULLIF(s.expected_attend, 0) * 100)::numeric, 1) as avg_attendance_rate
FROM analytics.dim_employee de
LEFT JOIN public.salary_detail_records s ON de.employee_id = s.employee_code
GROUP BY de.position
ORDER BY count(*) DESC
`)
sendSuccess(res, {
position_summary: positionSummary.rows,
employees: result.rows,
}, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 9. 库存周转与损耗趋势
// ============================================================
router.get('/inventory/turnover', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const storeCode = (req.query.store_code as string) || ''
let storeFilter = ''
const params: any[] = [month]
if (storeCode) {
params.push(storeCode)
storeFilter = `AND fis.store_code = $${params.length}`
}
// 库存周转概览
const turnoverResult = await query(`
SELECT
fis.store_code,
ds.store_name,
round(sum(fis.opening_amount)::numeric, 2) as opening_value,
round(sum(fis.purchase_amount)::numeric, 2) as purchase_value,
round(sum(fis.consumption_amount)::numeric, 2) as consumption_value,
round(sum(fis.ending_amount)::numeric, 2) as ending_value,
round(sum(fis.waste_amount)::numeric, 2) as waste_value,
round(avg(fis.ending_amount)::numeric, 2) as avg_ending,
round(sum(fis.consumption_amount)::numeric / NULLIF(avg(fis.ending_amount) * count(*), 0) * 30, 1) as turnover_days
FROM analytics.fact_inventory_snapshot fis
LEFT JOIN analytics.dim_store ds ON fis.store_code = ds.store_code
WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
${storeFilter}
GROUP BY fis.store_code, ds.store_name
ORDER BY turnover_days ASC
`, params)
// 损耗TOP
const wasteTop = await query(`
SELECT
dm.material_name,
round(sum(fis.waste_quantity)::numeric, 2) as waste_qty,
round(sum(fis.waste_amount)::numeric, 2) as waste_value,
count(DISTINCT fis.store_code) as affected_stores
FROM analytics.fact_inventory_snapshot fis
LEFT JOIN analytics.dim_material dm ON fis.material_code = dm.material_code
WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
AND fis.waste_amount > 0
${storeFilter}
GROUP BY dm.material_name
ORDER BY waste_value DESC
LIMIT 20
`, params)
sendSuccess(res, {
turnover: turnoverResult.rows,
waste_top: wasteTop.rows,
})
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 10. 营销ROI基础版
// ============================================================
router.get('/marketing/roi', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
// 各营销方案的效果对比
const result = await query(`
WITH plan_stats AS (
SELECT
marketing_plan,
count(*) as bill_count,
round(sum(received_total)::numeric, 2) as total_revenue,
round(avg(received_total)::numeric, 2) as avg_bill_value,
round(avg(theoretical_margin) * 100, 2) as avg_margin,
round(avg(discount_total)::numeric, 2) as avg_discount,
round(sum(discount_total)::numeric, 2) as total_discount,
count(DISTINCT member_id) FILTER (WHERE member_id IS NOT NULL AND member_id != '') as member_bills
FROM analytics.bill_fact
WHERE opened_at >= $1 AND opened_at < $1::date + interval '1 month'
AND marketing_plan IS NOT NULL AND marketing_plan != ''
GROUP BY marketing_plan
),
no_plan_stats AS (
SELECT
count(*) as bill_count,
round(avg(received_total)::numeric, 2) as avg_bill_value,
round(avg(theoretical_margin) * 100, 2) as avg_margin,
round(avg(discount_total)::numeric, 2) as avg_discount
FROM analytics.bill_fact
WHERE opened_at >= $1 AND opened_at < $1::date + interval '1 month'
AND (marketing_plan IS NULL OR marketing_plan = '')
)
SELECT
ps.marketing_plan,
ps.bill_count,
ps.total_revenue,
ps.avg_bill_value,
ps.avg_margin,
ps.total_discount,
ps.avg_discount,
ps.member_bills,
round((ps.member_bills::numeric / NULLIF(ps.bill_count, 0) * 100)::numeric, 1) as member_share_pct,
round(((ps.avg_bill_value - nps.avg_bill_value) / NULLIF(nps.avg_bill_value, 0) * 100)::numeric, 1) as bill_value_uplift_pct,
round((ps.avg_margin - nps.avg_margin)::numeric, 2) as margin_delta
FROM plan_stats ps
CROSS JOIN no_plan_stats nps
ORDER BY ps.total_revenue DESC
`, [month])
// 无营销方案基准
const baseline = await query(`
SELECT
count(*) as bill_count,
round(avg(received_total)::numeric, 2) as avg_bill_value,
round(avg(theoretical_margin) * 100, 2) as avg_margin,
round(avg(discount_total)::numeric, 2) as avg_discount
FROM analytics.bill_fact
WHERE opened_at >= $1 AND opened_at < $1::date + interval '1 month'
AND (marketing_plan IS NULL OR marketing_plan = '')
`, [month])
sendSuccess(res, {
baseline: baseline.rows[0],
campaigns: result.rows,
})
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 11. 辖区巡检计划生成
// ============================================================
router.get('/region/inspection-plan', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
rr.store_code,
rr.store_name,
rr.risk_level,
rr.primary_issue,
rr.bill_count,
round(rr.received::numeric, 2) as received,
rr.anomaly_rate_pct,
rr.discount_rate_pct,
rr.member_bill_share_pct,
ds.region,
CASE rr.risk_level
WHEN '高风险' THEN 1
WHEN '中风险' THEN 2
WHEN '低风险' THEN 3
ELSE 4
END as priority,
CASE rr.risk_level
WHEN '高风险' THEN '本周必须巡检'
WHEN '中风险' THEN '两周内巡检'
WHEN '低风险' THEN '月度例行巡检'
ELSE '季度巡检'
END as inspection_frequency,
CASE rr.risk_level
WHEN '高风险' THEN '重点检查: ' || COALESCE(rr.primary_issue, '综合风险')
WHEN '中风险' THEN '关注: ' || COALESCE(rr.primary_issue, '常规指标')
ELSE '常规检查'
END as focus_area
FROM analytics.mv_store_risk_rating_monthly rr
LEFT JOIN analytics.dim_store ds ON rr.store_code = ds.store_code
WHERE rr.month_start = $1
ORDER BY priority, rr.received ASC
`, [month])
const summary = {
total_stores: result.rows.length,
high_risk: result.rows.filter((r: any) => r.risk_level === '高风险').length,
medium_risk: result.rows.filter((r: any) => r.risk_level === '中风险').length,
low_risk: result.rows.filter((r: any) => r.risk_level === '低风险').length,
this_week: result.rows.filter((r: any) => r.priority === 1).length,
}
sendSuccess(res, { summary, plan: result.rows })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 12. 统一KPI达成率(支持门店/区域/总部三个维度)
// ============================================================
router.get('/kpi', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const level = (req.query.level as string) || 'hq' // hq | region | store
const region = (req.query.region as string) || ''
const storeCode = (req.query.store_code as string) || ''
if (level === 'store' && storeCode) {
// 单门店KPI
const result = await query(`
SELECT
r.store_code,
r.store_name,
round(r.received::numeric, 2) as actual_revenue,
round(r.bill_count::numeric, 0) as actual_bills,
t.revenue_target,
t.bill_count_target,
t.profit_target,
e.actual_store_contribution as actual_profit,
round((r.received / NULLIF(t.revenue_target, 0) * 100)::numeric, 1) as revenue_achievement_pct,
round(CASE
WHEN t.profit_target > 0 THEN e.actual_store_contribution / t.profit_target * 100
WHEN t.profit_target < 0 THEN (2 * ABS(t.profit_target) - ABS(e.actual_store_contribution)) / ABS(t.profit_target) * 100
ELSE NULL
END::numeric, 1) as profit_achievement_pct,
round((e.actual_store_contribution / NULLIF(r.received, 0) * 100)::numeric, 2) as profit_margin_pct
FROM analytics.mv_store_risk_rating_monthly r
LEFT JOIN analytics.dim_store_target t ON r.store_code = t.store_code AND t.target_month = $1
LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1
WHERE r.month_start = $1 AND r.store_code = $2
`, [month, storeCode])
sendSuccess(res, result.rows[0] || {})
} else if (level === 'region') {
// 按区域汇总KPI
const result = await query(`
WITH actual AS (
SELECT
ds.region,
round(sum(r.received)::numeric, 2) as actual_revenue,
sum(r.bill_count) as actual_bills,
round(sum(e.actual_store_contribution)::numeric, 2) as actual_profit
FROM analytics.mv_store_risk_rating_monthly r
JOIN analytics.dim_store ds ON r.store_code = ds.store_code
LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1
WHERE r.month_start = $1
AND ds.region IS NOT NULL AND ds.region != '未知区域'
GROUP BY ds.region
),
target AS (
SELECT
ds.region,
round(sum(t.revenue_target)::numeric, 2) as revenue_target,
round(sum(t.bill_count_target)::numeric, 0) as bill_count_target,
round(sum(t.profit_target)::numeric, 2) as profit_target
FROM analytics.dim_store_target t
JOIN analytics.dim_store ds ON t.store_code = ds.store_code
WHERE t.target_month = $1
AND ds.region IS NOT NULL AND ds.region != '未知区域'
GROUP BY ds.region
)
SELECT
a.region,
a.actual_revenue,
a.actual_bills,
a.actual_profit,
t.revenue_target,
t.bill_count_target,
t.profit_target,
round((a.actual_revenue / NULLIF(t.revenue_target, 0) * 100)::numeric, 1) as revenue_achievement_pct,
round(CASE
WHEN t.profit_target > 0 THEN a.actual_profit / t.profit_target * 100
WHEN t.profit_target < 0 THEN (2 * ABS(t.profit_target) - ABS(a.actual_profit)) / ABS(t.profit_target) * 100
ELSE NULL
END::numeric, 1) as profit_achievement_pct,
round((a.actual_profit / NULLIF(a.actual_revenue, 0) * 100)::numeric, 2) as profit_margin_pct
FROM actual a
LEFT JOIN target t ON a.region = t.region
ORDER BY a.actual_revenue DESC
`, [month])
sendSuccess(res, result.rows)
} else {
// 总部汇总KPI
const result = await query(`
WITH actual AS (
SELECT
round(sum(r.received)::numeric, 2) as actual_revenue,
sum(r.bill_count) as actual_bills,
round(sum(e.actual_store_contribution)::numeric, 2) as actual_profit
FROM analytics.mv_store_risk_rating_monthly r
LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1
WHERE r.month_start = $1
),
target AS (
SELECT
round(sum(t.revenue_target)::numeric, 2) as revenue_target,
round(sum(t.bill_count_target)::numeric, 0) as bill_count_target,
round(sum(t.profit_target)::numeric, 2) as profit_target
FROM analytics.dim_store_target t
WHERE t.target_month = $1
)
SELECT
a.actual_revenue,
a.actual_bills,
a.actual_profit,
t.revenue_target,
t.bill_count_target,
t.profit_target,
round((a.actual_revenue / NULLIF(t.revenue_target, 0) * 100)::numeric, 1) as revenue_achievement_pct,
round(CASE
WHEN t.profit_target > 0 THEN a.actual_profit / t.profit_target * 100
WHEN t.profit_target < 0 THEN (2 * ABS(t.profit_target) - ABS(a.actual_profit)) / ABS(t.profit_target) * 100
ELSE NULL
END::numeric, 1) as profit_achievement_pct,
round((a.actual_profit / NULLIF(a.actual_revenue, 0) * 100)::numeric, 2) as profit_margin_pct
FROM actual a
CROSS JOIN target t
`, [month])
sendSuccess(res, result.rows[0] || {})
}
} catch (err: any) {
sendError(res, err.message)
}
})
export default router