feat: 连锁餐饮数字本体与AI经营智脑 - 新增分析页面、ETL脚本、总结文档
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user