fix: 修复门店评估数据不一致问题

- 恢复被误删的5家门店旧账单数据(58377行)
- 合并哈马尔罕大钟寺店费用到大钟寺店(0022)
- 修改v_store_operating_expense_monthly视图按sales_store_code聚合费用
- 修复利润机会池食材成本差异为负时opportunity不为负
- 修复前端totalOpportunity只累加正数机会
- 刷新bill_fact/mv_store_risk_rating_monthly等物化视图
- 新增PromotionPage和promotion路由
This commit is contained in:
freedakgmail
2026-08-11 07:26:11 +08:00
parent 353dad24a2
commit 3f1c291486
30 changed files with 4521 additions and 149 deletions
+2
View File
@@ -21,6 +21,7 @@ import storeGradeRoutes from './routes/store-grade.js'
import productRoutes from './routes/product.js'
import enterpriseRoutes from './routes/enterprise.js'
import intelligenceRoutes from './routes/intelligence.js'
import promotionRoutes from './routes/promotion.js'
import { startScheduler } from './scheduler/index.js'
import { registerAllTaskHandlers } from './scheduler/task-handlers.js'
@@ -77,6 +78,7 @@ app.use('/api/store-grade', storeGradeRoutes)
app.use('/api/product', productRoutes)
app.use('/api/enterprise', enterpriseRoutes)
app.use('/api/intelligence', intelligenceRoutes)
app.use('/api/promotion', promotionRoutes)
app.use(notFoundHandler)
app.use(errorHandler)
+121
View File
@@ -599,6 +599,127 @@ router.get('/inventory/turnover', async (req: AuthRequest, res) => {
}
})
// ============================================================
// 9.5 临期商品预警与优化
// ============================================================
router.get('/inventory/near-expiry', 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 nearExpiryResult = await query(`
SELECT
fis.store_code,
ds.store_name,
dm.material_name,
dm.material_code,
dm.category,
round(fis.ending_quantity::numeric, 2) as stock_qty,
round(fis.ending_amount::numeric, 2) as stock_value,
round(fis.consumption_amount::numeric, 2) as monthly_consumption,
round(fis.waste_amount::numeric, 2) as waste_value,
CASE
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
THEN round((fis.ending_amount / fis.consumption_amount * 30)::numeric, 1)
ELSE NULL
END as estimated_days_to_consume,
CASE
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3 THEN '紧急'
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7 THEN '预警'
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 14 THEN '关注'
ELSE '正常'
END as urgency_level,
CASE
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3 THEN '立即促销出清或报损'
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7 THEN '加入临期套餐或打折'
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 14 THEN '减少采购量,优先消耗'
ELSE '正常周转'
END as suggested_action
FROM analytics.fact_inventory_snapshot fis
LEFT JOIN analytics.dim_store ds ON fis.store_code = ds.store_code
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.ending_amount > 0
${storeFilter}
ORDER BY
CASE
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3 THEN 1
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7 THEN 2
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 14 THEN 3
ELSE 4
END,
fis.ending_amount DESC
LIMIT 200
`, params)
// 临期汇总
const summary = {
total_items: nearExpiryResult.rows.length,
urgent: nearExpiryResult.rows.filter((r: any) => r.urgency_level === '紧急').length,
warning: nearExpiryResult.rows.filter((r: any) => r.urgency_level === '预警').length,
watch: nearExpiryResult.rows.filter((r: any) => r.urgency_level === '关注').length,
urgent_value: nearExpiryResult.rows
.filter((r: any) => r.urgency_level === '紧急')
.reduce((s: number, r: any) => s + Number(r.stock_value || 0), 0),
warning_value: nearExpiryResult.rows
.filter((r: any) => r.urgency_level === '预警')
.reduce((s: number, r: any) => s + Number(r.stock_value || 0), 0),
potential_loss: nearExpiryResult.rows
.filter((r: any) => r.urgency_level === '紧急' || r.urgency_level === '预警')
.reduce((s: number, r: any) => s + Number(r.stock_value || 0) * 0.5, 0),
}
// 按门店汇总临期风险
const storeRisk = await query(`
SELECT
fis.store_code,
ds.store_name,
count(*) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3) as urgent_items,
count(*) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7
AND (fis.ending_amount / fis.consumption_amount * 30) > 3) as warning_items,
round(sum(fis.ending_amount) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7)::numeric, 2) as at_risk_value,
round(sum(fis.waste_amount)::numeric, 2) as total_waste
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'
AND fis.ending_amount > 0
${storeFilter}
GROUP BY fis.store_code, ds.store_name
HAVING count(*) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7) > 0
ORDER BY at_risk_value DESC
`, params)
sendSuccess(res, {
summary,
near_expiry: nearExpiryResult.rows,
store_risk: storeRisk.rows,
})
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 10. 营销ROI基础版
// ============================================================
+65 -35
View File
@@ -409,6 +409,10 @@ router.get('/risk/anomaly', async (req: AuthRequest, res) => {
const storeName = req.query.store as string
const reason = req.query.reason as string
const cashier = req.query.cashier as string
const sort = (req.query.sort as string) || 'closed_at'
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
const allowedSort = ['consumption', 'discount_total', 'received_total', 'closed_at']
const sortCol = allowedSort.includes(sort) ? sort : 'closed_at'
const conditions: string[] = [`month = to_char($1::date, 'YYYY-MM')`]
const params: any[] = [month]
@@ -435,7 +439,7 @@ router.get('/risk/anomaly', async (req: AuthRequest, res) => {
SELECT store_name, bill_no, meal_period, consumption, discount_total, received_total, cashier, closed_at, anomaly_reason
FROM mv_risk_anomaly
WHERE ${whereClause}
ORDER BY closed_at DESC
ORDER BY ${sortCol} ${order}
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`, [...params, pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize, sum_consumption: countResult.rows[0].sum_consumption, sum_discount: countResult.rows[0].sum_discount, sum_received: countResult.rows[0].sum_received })
@@ -446,22 +450,43 @@ router.get('/risk/anomaly', async (req: AuthRequest, res) => {
router.get('/risk/zero-received', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const month = parseMonth(req)
const storeName = req.query.store as string
let sql = `
const type = req.query.type as string
const conditions: string[] = [`month = to_char($1::date, 'YYYY-MM')`]
const params: any[] = [month]
let paramIdx = 2
if (storeName) {
conditions.push(`store_name = $${paramIdx}`)
params.push(storeName)
paramIdx++
}
if (type) {
conditions.push(`zero_received_type = $${paramIdx}`)
params.push(type)
paramIdx++
}
const whereClause = conditions.join(' AND ')
const baseConditions = [`month = to_char($1::date, 'YYYY-MM')`]
const baseParams: any[] = [month]
if (storeName) {
baseConditions.push(`store_name = $2`)
baseParams.push(storeName)
}
const baseWhere = baseConditions.join(' AND ')
const countResult = await query(`SELECT count(*) FILTER (WHERE zero_received_type = '全额优惠') AS full_discount, count(*) FILTER (WHERE zero_received_type = '无消费无优惠') AS empty_bill FROM mv_risk_zero WHERE ${baseWhere}`, baseParams)
const totalResult = await query(`SELECT count(*) AS total FROM mv_risk_zero WHERE ${whereClause}`, params)
const result = await query(`
SELECT store_name, bill_no, meal_period, consumption, discount_total,
0 AS received_total, cashier, closed_at, zero_received_type
FROM mv_risk_zero
WHERE month = to_char($1::date, 'YYYY-MM')
`
const params: any[] = [month]
if (storeName) {
sql += ` AND store_name = $2`
params.push(storeName)
}
sql += ` ORDER BY consumption DESC LIMIT 200`
const result = await query(sql, params)
sendSuccess(res, result.rows)
WHERE ${whereClause}
ORDER BY consumption DESC
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`, [...params, pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(totalResult.rows[0].total), full_discount: parseInt(countResult.rows[0].full_discount), empty_bill: parseInt(countResult.rows[0].empty_bill), page, page_size: pageSize })
} catch (err: any) {
sendError(res, err.message)
}
@@ -1007,8 +1032,9 @@ router.get('/overview/profit-waterfall', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH bill_discount AS (
WITH bill_consumption AS (
SELECT store_code,
round(sum(consumption)::numeric, 2) AS consumption,
round(sum(discount_total)::numeric, 2) AS discount
FROM analytics.bill_fact
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
@@ -1017,23 +1043,23 @@ router.get('/overview/profit-waterfall', async (req: AuthRequest, res) => {
full_scope AS (
SELECT
r.received,
e.consumption,
COALESCE(bd.discount, e.consumption - e.received) AS discount,
e.actual_food_cost,
e.wage_expense,
e.rent_expense,
e.utility_expense,
e.dorm_expense,
e.delivery_commission_expense,
e.card_fee_expense,
e.repair_clean_expense,
e.operating_expense,
COALESCE(e.consumption, bc.consumption) AS consumption,
COALESCE(bc.discount, e.consumption - e.received, 0) AS discount,
COALESCE(e.actual_food_cost, 0) AS actual_food_cost,
COALESCE(e.wage_expense, 0) AS wage_expense,
COALESCE(e.rent_expense, 0) AS rent_expense,
COALESCE(e.utility_expense, 0) AS utility_expense,
COALESCE(e.dorm_expense, 0) AS dorm_expense,
COALESCE(e.delivery_commission_expense, 0) AS delivery_commission_expense,
COALESCE(e.card_fee_expense, 0) AS card_fee_expense,
COALESCE(e.repair_clean_expense, 0) AS repair_clean_expense,
COALESCE(e.operating_expense, 0) AS operating_expense,
CASE WHEN e.operating_expense IS NOT NULL THEN true ELSE false END AS has_expense
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::date
LEFT JOIN bill_discount bd ON bd.store_code = r.store_code
WHERE r.month_start = $1 AND r.received IS NOT NULL AND e.operating_expense IS NOT NULL
LEFT JOIN bill_consumption bc ON bc.store_code = r.store_code
WHERE r.month_start = $1 AND r.received IS NOT NULL
)
SELECT
round(sum(consumption)::numeric, 2) AS consumption,
@@ -1048,7 +1074,8 @@ router.get('/overview/profit-waterfall', async (req: AuthRequest, res) => {
round(sum(card_fee_expense + repair_clean_expense)::numeric, 2) AS other_expense,
round(sum(operating_expense)::numeric, 2) AS total_expense,
round(sum(received) - sum(actual_food_cost) - sum(operating_expense), 2) AS store_contribution,
count(*) AS covered_stores
count(*) AS covered_stores,
count(*) FILTER (WHERE has_expense) AS stores_with_expense
FROM full_scope
`, [month])
sendSuccess(res, result.rows[0])
@@ -1204,7 +1231,7 @@ router.get('/overview/profit-opportunity', async (req: AuthRequest, res) => {
'category', '食材成本差异回收',
'baseline', (SELECT round((actual_cost - theoretical_cost)::numeric, 2) FROM cost_diff),
'target_pct', 30,
'opportunity', round((SELECT (actual_cost - theoretical_cost) FROM cost_diff) * 0.30, 2),
'opportunity', GREATEST(round((SELECT (actual_cost - theoretical_cost) FROM cost_diff) * 0.30, 2), 0),
'confidence', '中高',
'owner', '商品/供应链/门店',
'evidence', '采购价差、用量差、盘点差、报损差',
@@ -1551,22 +1578,25 @@ router.get('/bank/report', async (req: AuthRequest, res) => {
query(`SELECT bill_count, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bills, member_share_pct FROM analytics.mv_overview_monthly WHERE month = $1::date`, [month]),
query(`SELECT business_date, bill_count, received, avg_bill_value, discount_rate_pct FROM analytics.mv_overview_daily WHERE month = $1::date ORDER BY business_date`, [month]),
query(`
WITH bill_discount AS (
WITH bill_consumption AS (
SELECT store_code,
round(sum(consumption)::numeric, 2) AS consumption,
round(sum(discount_total)::numeric, 2) AS discount
FROM analytics.bill_fact
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY store_code
),
full_scope AS (
SELECT r.received, e.consumption, COALESCE(bd.discount, e.consumption - e.received) AS discount,
e.actual_food_cost AS food_cost, e.wage_expense AS wage, e.rent_expense AS rent,
e.utility_expense AS utility, e.dorm_expense AS dorm, e.delivery_commission_expense AS commission,
e.card_fee_expense AS card_fee, e.repair_clean_expense AS repair, e.operating_expense AS operating_expense
SELECT r.received,
COALESCE(e.consumption, bc.consumption) AS consumption,
COALESCE(bc.discount, e.consumption - e.received, 0) AS discount,
COALESCE(e.actual_food_cost, 0) AS food_cost, COALESCE(e.wage_expense, 0) AS wage, COALESCE(e.rent_expense, 0) AS rent,
COALESCE(e.utility_expense, 0) AS utility, COALESCE(e.dorm_expense, 0) AS dorm, COALESCE(e.delivery_commission_expense, 0) AS commission,
COALESCE(e.card_fee_expense, 0) AS card_fee, COALESCE(e.repair_clean_expense, 0) AS repair, COALESCE(e.operating_expense, 0) AS operating_expense
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::date
LEFT JOIN bill_discount bd ON bd.store_code = r.store_code
WHERE r.received IS NOT NULL AND e.operating_expense IS NOT NULL
LEFT JOIN bill_consumption bc ON bc.store_code = r.store_code
WHERE r.received IS NOT NULL
)
SELECT round(sum(consumption)::numeric,2) AS consumption, round(sum(discount)::numeric,2) AS discount,
round(sum(received)::numeric,2) AS received, round(sum(food_cost)::numeric,2) AS food_cost, round(sum(wage)::numeric,2) AS wage,
+121
View File
@@ -443,4 +443,125 @@ router.get('/anonymous-ranking', async (req: AuthRequest, res) => {
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 供应商价格对标 (T-206)
// ============================================================
// 同SKU跨供应商价格对比
router.get('/supplier-benchmark', async (req: AuthRequest, res) => {
try {
const result = await query(`
WITH sku_prices AS (
SELECT
poi.sku_code,
poi.sku_name,
po.supplier_code,
sm.supplier_name,
sm.grade AS supplier_grade,
poi.unit_price,
po.order_date,
po.po_number
FROM analytics.v3_purchase_order_item poi
JOIN analytics.v3_purchase_order po ON poi.po_id = po.id
LEFT JOIN analytics.v3_supplier_master sm ON po.supplier_code = sm.supplier_code
WHERE po.status IN ('delivered', 'settled')
),
sku_stats AS (
SELECT
sku_code,
sku_name,
count(DISTINCT supplier_code) AS supplier_count,
round(min(unit_price)::numeric, 2) AS min_price,
round(max(unit_price)::numeric, 2) AS max_price,
round(avg(unit_price)::numeric, 2) AS avg_price,
round((max(unit_price) - min(unit_price))::numeric, 2) AS price_spread,
round(CASE
WHEN avg(unit_price) > 0
THEN (max(unit_price) - min(unit_price)) / avg(unit_price) * 100
ELSE 0
END::numeric, 1) AS price_variance_pct
FROM sku_prices
GROUP BY sku_code, sku_name
HAVING count(DISTINCT supplier_code) >= 2
),
best_price AS (
SELECT DISTINCT ON (sku_code)
sku_code, supplier_code AS best_supplier_code, supplier_name AS best_supplier_name, unit_price AS best_price
FROM sku_prices
ORDER BY sku_code, unit_price ASC
),
worst_price AS (
SELECT DISTINCT ON (sku_code)
sku_code, supplier_code AS worst_supplier_code, supplier_name AS worst_supplier_name, unit_price AS worst_price
FROM sku_prices
ORDER BY sku_code, unit_price DESC
)
SELECT
ss.sku_code,
ss.sku_name,
ss.supplier_count,
ss.min_price,
ss.max_price,
ss.avg_price,
ss.price_spread,
ss.price_variance_pct,
bp.best_supplier_name,
bp.best_price,
wp.worst_supplier_name,
wp.worst_price,
round((ss.max_price - ss.min_price)::numeric, 2) AS potential_savings_per_unit,
round(CASE
WHEN ss.avg_price > 0
THEN (ss.max_price - ss.min_price) / ss.avg_price * 100
ELSE 0
END::numeric, 1) AS savings_pct,
CASE
WHEN ss.price_variance_pct > 20 THEN '价格差异大'
WHEN ss.price_variance_pct > 10 THEN '有优化空间'
ELSE '价格稳定'
END AS benchmark_status
FROM sku_stats ss
LEFT JOIN best_price bp ON ss.sku_code = bp.sku_code
LEFT JOIN worst_price wp ON ss.sku_code = wp.sku_code
ORDER BY ss.price_variance_pct DESC NULLS LAST
`)
const summary = {
total_skus: result.rows.length,
high_variance: result.rows.filter((r: any) => r.benchmark_status === '价格差异大').length,
optimizable: result.rows.filter((r: any) => r.benchmark_status === '有优化空间').length,
stable: result.rows.filter((r: any) => r.benchmark_status === '价格稳定').length,
total_potential_savings: result.rows.reduce((s: number, r: any) => s + Number(r.potential_savings_per_unit || 0), 0),
}
sendSuccess(res, { summary, benchmarks: result.rows })
} catch (err: any) { sendError(res, err.message) }
})
// 单SKU供应商价格历史趋势
router.get('/supplier-benchmark/:skuCode', async (req: AuthRequest, res) => {
try {
const { skuCode } = req.params
const result = await query(`
SELECT
poi.sku_code,
poi.sku_name,
po.supplier_code,
sm.supplier_name,
sm.grade AS supplier_grade,
poi.unit_price,
po.order_date,
po.po_number
FROM analytics.v3_purchase_order_item poi
JOIN analytics.v3_purchase_order po ON poi.po_id = po.id
LEFT JOIN analytics.v3_supplier_master sm ON po.supplier_code = sm.supplier_code
WHERE poi.sku_code = $1 AND po.status IN ('delivered', 'settled')
ORDER BY po.order_date DESC
LIMIT 50
`, [skuCode])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
export default router
+275
View File
@@ -0,0 +1,275 @@
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. 促销增量利润总览
// ============================================================
router.get('/overview', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH plan_stats AS (
SELECT
count(*) as bill_count,
round(sum(received_total)::numeric, 2) as total_revenue,
round(sum(discount_total)::numeric, 2) as total_discount,
round(sum(theoretical_profit)::numeric, 2) as total_profit,
round(avg(received_total)::numeric, 2) as avg_bill_value,
round(avg(theoretical_margin) * 100, 2) as avg_margin_pct,
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 != ''
),
no_plan_stats AS (
SELECT
count(*) as bill_count,
round(sum(received_total)::numeric, 2) as total_revenue,
round(sum(discount_total)::numeric, 2) as total_discount,
round(sum(theoretical_profit)::numeric, 2) as total_profit,
round(avg(received_total)::numeric, 2) as avg_bill_value,
round(avg(theoretical_margin) * 100, 2) as avg_margin_pct
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.bill_count as promo_bills,
ps.total_revenue as promo_revenue,
ps.total_discount as promo_discount,
ps.total_profit as promo_profit,
ps.avg_bill_value as promo_avg_bill,
ps.avg_margin_pct as promo_margin_pct,
ps.member_bills as promo_member_bills,
nps.bill_count as baseline_bills,
nps.total_revenue as baseline_revenue,
nps.total_discount as baseline_discount,
nps.total_profit as baseline_profit,
nps.avg_bill_value as baseline_avg_bill,
nps.avg_margin_pct as baseline_margin_pct,
round(((ps.avg_bill_value - nps.avg_bill_value) / NULLIF(nps.avg_bill_value, 0) * 100)::numeric, 1) as bill_uplift_pct,
round((ps.avg_bill_value - nps.avg_bill_value)::numeric, 2) as bill_uplift_amount,
round(((ps.bill_count::numeric / NULLIF(ps.bill_count + nps.bill_count, 0)) * 100)::numeric, 1) as promo_share_pct,
round((ps.total_profit - ps.total_discount)::numeric, 2) as promo_net_profit,
round((nps.total_profit - nps.total_discount)::numeric, 2) as baseline_net_profit,
round(((ps.total_profit - ps.total_discount) - (nps.total_profit - nps.total_discount))::numeric, 2) as incremental_net_profit,
round(((ps.avg_bill_value - nps.avg_bill_value) * ps.bill_count)::numeric, 2) as incremental_revenue,
round(ps.total_discount::numeric, 2) as promotion_cost,
round(((ps.avg_bill_value - nps.avg_bill_value) * ps.bill_count - ps.total_discount)::numeric, 2) as net_incremental_profit,
round(CASE
WHEN ps.total_discount > 0
THEN ((ps.avg_bill_value - nps.avg_bill_value) * ps.bill_count - ps.total_discount) / ps.total_discount
ELSE NULL
END::numeric, 2) as promo_roi
FROM plan_stats ps
CROSS JOIN no_plan_stats nps
`, [month])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 2. 按促销方案对比
// ============================================================
router.get('/by-plan', 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(sum(discount_total)::numeric, 2) as total_discount,
round(sum(theoretical_profit)::numeric, 2) as total_profit,
round(avg(received_total)::numeric, 2) as avg_bill_value,
round(avg(theoretical_margin) * 100, 2) as avg_margin_pct,
round(avg(discount_total)::numeric, 2) as avg_discount,
count(DISTINCT member_id) FILTER (WHERE member_id IS NOT NULL AND member_id != '') as member_bills,
count(DISTINCT store_code) as store_count
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
round(avg(received_total)::numeric, 2) as avg_bill_value,
round(avg(theoretical_margin) * 100, 2) as avg_margin_pct
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.total_discount,
ps.total_profit,
ps.avg_bill_value,
ps.avg_margin_pct,
ps.avg_discount,
ps.member_bills,
ps.store_count,
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_uplift_pct,
round((ps.avg_bill_value - nps.avg_bill_value)::numeric, 2) as bill_uplift_amount,
round(((ps.avg_bill_value - nps.avg_bill_value) * ps.bill_count)::numeric, 2) as incremental_revenue,
round(ps.total_discount::numeric, 2) as promotion_cost,
round(((ps.avg_bill_value - nps.avg_bill_value) * ps.bill_count - ps.total_discount)::numeric, 2) as net_incremental_profit,
round(CASE
WHEN ps.total_discount > 0
THEN ((ps.avg_bill_value - nps.avg_bill_value) * ps.bill_count - ps.total_discount) / ps.total_discount
ELSE NULL
END::numeric, 2) as promo_roi,
round((ps.total_profit - ps.total_discount)::numeric, 2) as net_profit
FROM plan_stats ps
CROSS JOIN no_plan_stats nps
ORDER BY ps.total_revenue DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 3. 按门店促销效果
// ============================================================
router.get('/by-store', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const { page, pageSize, offset } = parsePagination(req)
const countResult = await query(`
SELECT count(DISTINCT store_code) as total
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 != ''
`, [month])
const total = countResult.rows[0].count
const result = await query(`
WITH store_plan AS (
SELECT
store_code,
count(*) as promo_bills,
round(sum(received_total)::numeric, 2) as promo_revenue,
round(sum(discount_total)::numeric, 2) as promo_discount,
round(sum(theoretical_profit)::numeric, 2) as promo_profit,
round(avg(received_total)::numeric, 2) as promo_avg_bill,
count(DISTINCT marketing_plan) as plan_count
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 store_code
),
store_baseline AS (
SELECT
store_code,
round(avg(received_total)::numeric, 2) as baseline_avg_bill,
count(*) as baseline_bills
FROM analytics.bill_fact
WHERE opened_at >= $1 AND opened_at < $1::date + interval '1 month'
AND (marketing_plan IS NULL OR marketing_plan = '')
GROUP BY store_code
)
SELECT
COALESCE(ds.store_name, sp.store_code) as store_name,
sp.store_code,
sp.promo_bills,
sp.promo_revenue,
sp.promo_discount,
sp.promo_profit,
sp.promo_avg_bill,
sp.plan_count,
COALESCE(sb.baseline_avg_bill, 0) as baseline_avg_bill,
sb.baseline_bills,
round((sp.promo_avg_bill - COALESCE(sb.baseline_avg_bill, sp.promo_avg_bill))::numeric, 2) as bill_uplift,
round(CASE
WHEN sb.baseline_avg_bill > 0
THEN (sp.promo_avg_bill - sb.baseline_avg_bill) / sb.baseline_avg_bill * 100
ELSE NULL
END::numeric, 1) as uplift_pct,
round(((sp.promo_avg_bill - COALESCE(sb.baseline_avg_bill, sp.promo_avg_bill)) * sp.promo_bills)::numeric, 2) as incremental_revenue,
round((sp.promo_discount)::numeric, 2) as promotion_cost,
round(((sp.promo_avg_bill - COALESCE(sb.baseline_avg_bill, sp.promo_avg_bill)) * sp.promo_bills - sp.promo_discount)::numeric, 2) as net_incremental_profit,
round(CASE
WHEN sp.promo_discount > 0
THEN ((sp.promo_avg_bill - COALESCE(sb.baseline_avg_bill, sp.promo_avg_bill)) * sp.promo_bills - sp.promo_discount) / sp.promo_discount
ELSE NULL
END::numeric, 2) as promo_roi
FROM store_plan sp
LEFT JOIN store_baseline sb ON sp.store_code = sb.store_code
LEFT JOIN analytics.dim_store ds ON sp.store_code = ds.store_code
ORDER BY net_incremental_profit DESC NULLS LAST
LIMIT $2 OFFSET $3
`, [month, pageSize, offset])
sendSuccess(res, result.rows, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 4. 促销日趋势
// ============================================================
router.get('/daily-trend', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
opened_at::date as business_date,
CASE WHEN marketing_plan IS NOT NULL AND marketing_plan != '' THEN '促销' ELSE '非促销' END as day_type,
count(*) as bill_count,
round(sum(received_total)::numeric, 2) as revenue,
round(avg(received_total)::numeric, 2) as avg_bill_value,
round(sum(discount_total)::numeric, 2) as discount
FROM analytics.bill_fact
WHERE opened_at >= $1 AND opened_at < $1::date + interval '1 month'
GROUP BY opened_at::date, CASE WHEN marketing_plan IS NOT NULL AND marketing_plan != '' THEN '促销' ELSE '非促销' END
ORDER BY business_date, day_type
`, [month])
const grouped: Record<string, any> = {}
for (const row of result.rows) {
const date = String(row.business_date).substring(0, 10)
if (!grouped[date]) grouped[date] = { date, promo: null, baseline: null }
if (row.day_type === '促销') {
grouped[date].promo = row
} else {
grouped[date].baseline = row
}
}
const trend = Object.values(grouped).map((d: any) => ({
date: d.date,
promo_bills: d.promo?.bill_count || 0,
promo_revenue: d.promo?.revenue || 0,
promo_avg_bill: d.promo?.avg_bill_value || 0,
promo_discount: d.promo?.discount || 0,
baseline_bills: d.baseline?.bill_count || 0,
baseline_revenue: d.baseline?.revenue || 0,
baseline_avg_bill: d.baseline?.avg_bill_value || 0,
uplift: d.promo && d.baseline ? Math.round((d.promo.avg_bill_value - d.baseline.avg_bill_value) * 100) / 100 : 0,
}))
sendSuccess(res, trend)
} catch (err: any) {
sendError(res, err.message)
}
})
export default router
+72 -72
View File
@@ -115,29 +115,78 @@ router.get('/alerts', async (req: AuthRequest, res) => {
const month = parseMonth(req)
const alerts: any[] = []
// 1. 营收异动预警:日营收连续低于月均70%
const revenueAlerts = await query(`
WITH daily_stats AS (
SELECT closed_at::date AS business_date,
count(*) AS bill_count,
sum(received_total) AS received
FROM analytics.bill_fact
WHERE closed_at >= (SELECT max(closed_at)::date - interval '30 days' FROM analytics.bill_fact)
AND closed_at IS NOT NULL
GROUP BY closed_at::date
ORDER BY business_date
),
monthly_avg AS (
SELECT avg(received) AS avg_received
FROM daily_stats
)
SELECT d.business_date::text, d.received, m.avg_received,
round(d.received / nullif(m.avg_received, 0) * 100, 1) AS ratio
FROM daily_stats d, monthly_avg m
WHERE d.received < m.avg_received * 0.7
ORDER BY d.business_date DESC
LIMIT 10
`, [], { skipScope: true })
// 1-5. 并行查询所有预警类型
const [revenueAlerts, costAlerts, hrAlerts, platformAlerts, taskAlerts] = await Promise.all([
// 1. 营收异动预警:日营收连续低于月均70%
query(`
WITH daily_stats AS (
SELECT business_date, bill_count, received
FROM analytics.mv_daily_revenue
WHERE business_date >= (SELECT max(business_date) - interval '30 days' FROM analytics.mv_daily_revenue)
ORDER BY business_date
),
monthly_avg AS (
SELECT avg(received) AS avg_received
FROM daily_stats
)
SELECT d.business_date::text, d.received, m.avg_received,
round(d.received / nullif(m.avg_received, 0) * 100, 1) AS ratio
FROM daily_stats d, monthly_avg m
WHERE d.received < m.avg_received * 0.7
ORDER BY d.business_date DESC
LIMIT 10
`, [], { skipScope: true }),
// 2. 成本异动预警:成本差异>30%的菜品
query(`
SELECT dish_name, dish_code, category_level1,
round(cost_variance_amount::numeric, 2) AS variance,
round((cost_variance_amount / nullif(theoretical_cost, 0) * 100)::numeric, 2) AS variance_pct,
round(sales_amount::numeric, 2) AS sales_amount
FROM public.dish_cost_analysis_summary
WHERE cost_variance_amount > 0
AND theoretical_cost > 0
AND (cost_variance_amount / theoretical_cost) > 0.3
ORDER BY variance_pct DESC
LIMIT 10
`, [], { skipScope: true }),
// 3. 人力异动预警:考勤异常员工
query(`
SELECT s.org_level5 AS store_name, count(*) AS alert_count,
count(*) FILTER (WHERE s.absent_days > 0) AS absent_count,
count(*) FILTER (WHERE s.late_deduction > 0 OR s.no_punch_deduction > 0) AS punch_issue_count
FROM salary_detail_records s
WHERE s.org_level2 = '西部马华品牌门店' AND s.org_level5 IS NOT NULL AND s.org_level5 != ''
AND (s.absent_days > 0 OR s.late_deduction > 0 OR s.no_punch_deduction > 0)
GROUP BY s.org_level5
HAVING count(*) > 3
ORDER BY alert_count DESC
LIMIT 10
`),
// 4. 平台依赖预警:外卖佣金占比>40%
query(`
SELECT p.store_code, p.store_name,
round(p.meituan_cost_rate_pct::numeric, 1) AS meituan_rate,
round(p.taobao_cost_rate_pct::numeric, 1) AS taobao_rate,
round(p.jd_cost_rate_pct::numeric, 1) AS jd_rate,
round((COALESCE(p.meituan_received, 0) + COALESCE(p.taobao_received, 0) + COALESCE(p.jd_received, 0)) / nullif(sc.received, 0) * 100, 1) AS platform_share
FROM analytics.mv_store_platform_economics_monthly p
JOIN analytics.v_store_scorecard sc ON p.store_code = sc.store_code
WHERE p.month_start = $1 AND (COALESCE(p.meituan_received, 0) + COALESCE(p.taobao_received, 0) + COALESCE(p.jd_received, 0)) / nullif(sc.received, 0) > 0.4
ORDER BY platform_share DESC
LIMIT 10
`, [month]),
// 5. 任务逾期预警
query(`
SELECT store_code, store_name, priority, problem_indicator, deadline::text AS deadline,
current_date - deadline::date AS overdue_days
FROM analytics.store_task
WHERE status NOT IN ('已验收', '已回滚')
AND deadline < current_date
ORDER BY overdue_days DESC
LIMIT 10
`),
])
revenueAlerts.rows.forEach((r: any) => {
alerts.push({
type: 'revenue',
@@ -149,19 +198,6 @@ router.get('/alerts', async (req: AuthRequest, res) => {
})
})
// 2. 成本异动预警:成本差异>30%的菜品
const costAlerts = await query(`
SELECT dish_name, dish_code, category_level1,
round(cost_variance_amount::numeric, 2) AS variance,
round((cost_variance_amount / nullif(theoretical_cost, 0) * 100)::numeric, 2) AS variance_pct,
round(sales_amount::numeric, 2) AS sales_amount
FROM public.dish_cost_analysis_summary
WHERE cost_variance_amount > 0
AND theoretical_cost > 0
AND (cost_variance_amount / theoretical_cost) > 0.3
ORDER BY variance_pct DESC
LIMIT 10
`, [], { skipScope: true })
costAlerts.rows.forEach((r: any) => {
alerts.push({
type: 'cost',
@@ -173,19 +209,6 @@ router.get('/alerts', async (req: AuthRequest, res) => {
})
})
// 3. 人力异动预警:考勤异常员工
const hrAlerts = await query(`
SELECT s.org_level5 AS store_name, count(*) AS alert_count,
count(*) FILTER (WHERE s.absent_days > 0) AS absent_count,
count(*) FILTER (WHERE s.late_deduction > 0 OR s.no_punch_deduction > 0) AS punch_issue_count
FROM salary_detail_records s
WHERE s.org_level2 = '西部马华品牌门店' AND s.org_level5 IS NOT NULL AND s.org_level5 != ''
AND (s.absent_days > 0 OR s.late_deduction > 0 OR s.no_punch_deduction > 0)
GROUP BY s.org_level5
HAVING count(*) > 3
ORDER BY alert_count DESC
LIMIT 10
`)
hrAlerts.rows.forEach((r: any) => {
alerts.push({
type: 'hr',
@@ -197,19 +220,6 @@ router.get('/alerts', async (req: AuthRequest, res) => {
})
})
// 4. 平台依赖预警:外卖佣金占比>40%
const platformAlerts = await query(`
SELECT p.store_code, p.store_name,
round(p.meituan_cost_rate_pct::numeric, 1) AS meituan_rate,
round(p.taobao_cost_rate_pct::numeric, 1) AS taobao_rate,
round(p.jd_cost_rate_pct::numeric, 1) AS jd_rate,
round((COALESCE(p.meituan_received, 0) + COALESCE(p.taobao_received, 0) + COALESCE(p.jd_received, 0)) / nullif(sc.received, 0) * 100, 1) AS platform_share
FROM analytics.mv_store_platform_economics_monthly p
JOIN analytics.v_store_scorecard sc ON p.store_code = sc.store_code
WHERE p.month_start = $1 AND (COALESCE(p.meituan_received, 0) + COALESCE(p.taobao_received, 0) + COALESCE(p.jd_received, 0)) / nullif(sc.received, 0) > 0.4
ORDER BY platform_share DESC
LIMIT 10
`, [month])
platformAlerts.rows.forEach((r: any) => {
alerts.push({
type: 'platform',
@@ -221,16 +231,6 @@ router.get('/alerts', async (req: AuthRequest, res) => {
})
})
// 5. 任务逾期预警
const taskAlerts = await query(`
SELECT store_code, store_name, priority, problem_indicator, deadline::text AS deadline,
current_date - deadline::date AS overdue_days
FROM analytics.store_task
WHERE status NOT IN ('已验收', '已回滚')
AND deadline < current_date
ORDER BY overdue_days DESC
LIMIT 10
`)
taskAlerts.rows.forEach((r: any) => {
alerts.push({
type: 'task',
+5 -4
View File
@@ -12,8 +12,9 @@ router.get('/overview', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH bill_discount AS (
WITH bill_consumption AS (
SELECT store_code,
round(sum(consumption)::numeric, 2) AS consumption,
round(sum(discount_total)::numeric, 2) AS discount
FROM analytics.bill_fact
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
@@ -24,8 +25,8 @@ router.get('/overview', async (req: AuthRequest, res) => {
r.store_code,
r.store_name,
r.received,
e.consumption,
COALESCE(bd.discount, e.consumption - e.received) AS discount,
COALESCE(e.consumption, bc.consumption) AS consumption,
COALESCE(bc.discount, e.consumption - e.received, 0) AS discount,
r.bill_count,
r.theoretical_margin_pct,
e.operating_expense,
@@ -44,7 +45,7 @@ router.get('/overview', async (req: AuthRequest, res) => {
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::date
LEFT JOIN bill_discount bd ON bd.store_code = r.store_code
LEFT JOIN bill_consumption bc ON bc.store_code = r.store_code
WHERE r.month_start = $1 AND r.received IS NOT NULL
)
SELECT
+118
View File
@@ -142,4 +142,122 @@ router.get('/traffic-light', async (req: AuthRequest, res) => {
}
})
// ============================================================
// 门店关停测算
// ============================================================
router.get('/closure-analysis', async (req: AuthRequest, res) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
const result = await query(`
WITH store_financials AS (
SELECT
g.store_code,
g.store_name,
g.region,
g.grade,
g.overall_score,
g.revenue_achievement_pct,
COALESCE(r.received, 0) AS monthly_revenue,
COALESCE(r.bill_count, 0) AS bill_count,
COALESCE(r.theoretical_margin_pct, 0) AS margin_pct,
COALESCE(e.actual_store_contribution, 0) AS monthly_profit,
COALESCE(e.rent_expense, 0) AS monthly_rent,
COALESCE(e.wage_expense, 0) AS monthly_wage,
COALESCE(e.utility_expense, 0) AS monthly_utility,
COALESCE(e.operating_expense, 0) AS monthly_opex,
COALESCE(a.area_sqm, 0) AS area_sqm,
COALESCE(a.lease_expiry_date, NULL) AS lease_expiry_date
FROM analytics.v3_store_grade g
LEFT JOIN analytics.mv_store_risk_rating r
ON r.store_code = g.store_code AND r.month_start = $1::date
LEFT JOIN analytics.mv_store_operating_expense_monthly e
ON e.sales_store_code = g.store_code AND e.report_month = $1::date
LEFT JOIN analytics.mv_store_area_efficiency_monthly a
ON a.store_code = g.store_code AND a.month_start = $1::date
WHERE date_trunc('month', g.grade_month) = date_trunc('month', $1::date)
)
SELECT
store_code,
store_name,
region,
grade,
overall_score,
revenue_achievement_pct,
round(monthly_revenue::numeric, 2) AS monthly_revenue,
bill_count,
round(margin_pct::numeric, 2) AS margin_pct,
round(monthly_profit::numeric, 2) AS monthly_profit,
round(monthly_rent::numeric, 2) AS monthly_rent,
round(monthly_wage::numeric, 2) AS monthly_wage,
round(monthly_utility::numeric, 2) AS monthly_utility,
round(monthly_opex::numeric, 2) AS monthly_opex,
area_sqm,
lease_expiry_date,
-- 关停成本估算
round((monthly_wage * 1.5)::numeric, 2) AS severance_cost,
round((monthly_rent * 2)::numeric, 2) AS lease_termination_cost,
round((monthly_revenue * 0.05)::numeric, 2) AS asset_disposal_cost,
round((monthly_wage * 1.5 + monthly_rent * 2 + monthly_revenue * 0.05)::numeric, 2) AS total_closure_cost,
-- 继续经营12个月亏损
round(CASE
WHEN monthly_profit < 0 THEN monthly_profit * 12
ELSE 0
END::numeric, 2) AS projected_12m_loss,
-- 关停后月节省
round(CASE
WHEN monthly_profit < 0 THEN ABS(monthly_profit)
ELSE 0
END::numeric, 2) AS monthly_savings_if_closed,
-- 关停回本月数
CASE
WHEN monthly_profit < 0 AND (monthly_wage * 1.5 + monthly_rent * 2 + monthly_revenue * 0.05) > 0
THEN round((monthly_wage * 1.5 + monthly_rent * 2 + monthly_revenue * 0.05) / ABS(monthly_profit)::numeric, 1)
ELSE NULL
END AS payback_months,
-- 建议
CASE
WHEN monthly_profit < 0 AND overall_score < 50 THEN '建议关停'
WHEN monthly_profit < 0 AND overall_score >= 50 AND overall_score < 70 THEN '整改观察3个月'
WHEN monthly_profit >= 0 AND overall_score < 70 THEN '重点整改'
ELSE '持续经营'
END AS recommendation,
CASE
WHEN monthly_profit < 0 AND overall_score < 50 THEN '持续亏损且综合评分D级,关停可止损'
WHEN monthly_profit < 0 AND overall_score >= 50 AND overall_score < 70 THEN '亏损但有一定基础,限期整改后评估'
WHEN monthly_profit >= 0 AND overall_score < 70 THEN '微利但评分偏低,需提升运营效率'
ELSE '经营正常,维持现状'
END AS reason
FROM store_financials
ORDER BY
CASE
WHEN monthly_profit < 0 AND overall_score < 50 THEN 1
WHEN monthly_profit < 0 AND overall_score >= 50 AND overall_score < 70 THEN 2
WHEN monthly_profit >= 0 AND overall_score < 70 THEN 3
ELSE 4
END,
monthly_profit ASC
`, [month])
const summary = {
total_stores: result.rows.length,
recommend_close: result.rows.filter((r: any) => r.recommendation === '建议关停').length,
recommend_rectify: result.rows.filter((r: any) => r.recommendation === '整改观察3个月').length,
total_closure_cost: result.rows
.filter((r: any) => r.recommendation === '建议关停')
.reduce((s: number, r: any) => s + Number(r.total_closure_cost || 0), 0),
total_projected_loss: result.rows
.filter((r: any) => r.recommendation === '建议关停')
.reduce((s: number, r: any) => s + Number(r.projected_12m_loss || 0), 0),
total_monthly_savings: result.rows
.filter((r: any) => r.recommendation === '建议关停')
.reduce((s: number, r: any) => s + Number(r.monthly_savings_if_closed || 0), 0),
}
sendSuccess(res, { summary, stores: result.rows })
} catch (err: any) {
sendError(res, err.message)
}
})
export default router