Files
SBrainCO/server/src/routes/data.ts
T

2450 lines
119 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router } from 'express'
import { query } from '../config/database.js'
import pool from '../config/database.js'
import { sendSuccess, sendError, parseMonth, parsePagination, parseDateRange, prevYearMonth, prevMonth } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
router.get('/overview', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await 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])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/overview/daily', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await 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])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const riskLevel = req.query.risk_level as string
const quadrant = req.query.quadrant as string
let sql = `SELECT * FROM analytics.v_store_scorecard`
const params: any[] = []
const conditions: string[] = []
if (riskLevel) {
const m = parseMonth(req)
sql = `SELECT s.* FROM analytics.v_store_scorecard s
JOIN analytics.mv_store_risk_rating_monthly r ON s.store_code = r.store_code
WHERE r.month_start = $${params.length + 1} AND r.risk_level = $${params.length + 2}`
params.push(m, riskLevel)
}
if (quadrant) {
if (params.length > 0) {
conditions.push(`b.management_quadrant = $${params.length + 1}`)
sql = sql.replace('FROM analytics.v_store_scorecard s', 'FROM analytics.v_store_scorecard s JOIN analytics.v_store_benchmark b ON s.store_code = b.store_code')
} else {
sql = `SELECT s.* FROM analytics.v_store_scorecard s
JOIN analytics.v_store_benchmark b ON s.store_code = b.store_code
WHERE b.management_quadrant = $1`
params.push(quadrant)
}
}
sql += ` ORDER BY received DESC NULLS LAST`
const countResult = await query(`SELECT count(*) AS total FROM (${sql}) t`, params)
sql += ` LIMIT $${params.length + 1} OFFSET $${params.length + 2}`
params.push(pageSize, offset)
const result = await query(sql, params)
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/risk', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 ORDER BY risk_level, received DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/priority', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT store_code, store_name, action_priority, problem_count, problem_combination,
received, scale_tier, business_type
FROM analytics.mv_store_action_priority_deep_monthly
WHERE month_start = $1
ORDER BY CASE action_priority
WHEN 'P0-修复数据口径' THEN 1
WHEN 'P0-综合专项整改' THEN 2
WHEN 'P1-重点整改' THEN 3
WHEN 'P2-单项改善' THEN 4
WHEN '标杆候选' THEN 5
ELSE 6
END, received DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/quadrant', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_benchmark_composite_monthly WHERE month_start = $1 ORDER BY benchmark_score DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/:code', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const month = parseMonth(req)
const [scorecard, risk, platform, benchmark, action] = await Promise.all([
query(`
SELECT c002 AS store_code, c003 AS store_name,
count(*) AS bill_count,
count(DISTINCT c176::date) AS active_days,
round(sum(COALESCE(NULLIF(c114,'')::numeric,0)), 2) AS received,
round(sum(COALESCE(NULLIF(c114,'')::numeric,0)) / NULLIF(count(DISTINCT c176::date), 0), 2) AS avg_daily_received,
round(sum(COALESCE(NULLIF(c114,'')::numeric,0)) / count(*), 2) AS avg_bill_value,
round(sum(COALESCE(NULLIF(c114,'')::numeric,0)) / NULLIF(sum(COALESCE(NULLIF(c178,'')::numeric,0)), 0), 2) AS avg_guest_value,
round(sum(COALESCE(NULLIF(c068,'')::numeric,0)) / NULLIF(sum(COALESCE(NULLIF(c009,'')::numeric,0)), 0) * 100, 2) AS discount_rate_pct,
round(sum(COALESCE(NULLIF(c181,'')::numeric,0)) / NULLIF(sum(COALESCE(NULLIF(c114,'')::numeric,0)), 0) * 100, 2) AS theoretical_margin_pct,
round(count(*) FILTER (WHERE NULLIF(c185,'') IS NOT NULL)::numeric / count(*) * 100, 2) AS member_bill_share_pct
FROM bill_records
WHERE c002 = $1
AND c176 IS NOT NULL AND c176 != ''
AND c176::timestamp >= $2::date
AND c176::timestamp < ($2::date + interval '1 month')
GROUP BY c002, c003
`, [code, month]),
query(`SELECT * FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 AND store_code = $2`, [month, code]),
query(`SELECT * FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $2 AND store_code = $1`, [code, month]),
query(`SELECT * FROM analytics.mv_store_benchmark_composite_monthly WHERE month_start = $2 AND store_code = $1`, [code, month]),
query(`SELECT * FROM analytics.mv_store_action_priority_deep_monthly WHERE month_start = $1 AND store_code = $2`, [month, code]),
])
if (scorecard.rows.length === 0) {
const storeInfo = await query(`SELECT store_code, store_name FROM analytics.dim_store WHERE store_code = $1`, [code])
if (storeInfo.rows.length === 0) {
return sendError(res, 'Store not found', 404)
}
const si = storeInfo.rows[0] as any
return sendSuccess(res, {
scorecard: { store_code: si.store_code, store_name: si.store_name, bill_count: 0, active_days: 0, received: 0, avg_daily_received: 0, avg_bill_value: 0, avg_guest_value: 0, discount_rate_pct: 0, theoretical_margin_pct: 0, member_bill_share_pct: 0 },
risk: null,
platform: null,
benchmark: null,
action: { management_quadrant: '问题门店', risk_level: null },
})
}
const sc = scorecard.rows[0] as any
const rk = risk.rows[0] as any
const act = action.rows[0] as any
// 计算经营象限
const medianRev = 18642
const medianMargin = 70.0
const dailyRev = parseFloat(rk?.avg_daily_received) || 0
const margin = parseFloat(rk?.theoretical_margin_pct) || 0
const quadrant = dailyRev >= medianRev && margin >= medianMargin ? '明星门店'
: dailyRev >= medianRev && margin < medianMargin ? '现金牛门店'
: dailyRev < medianRev && margin >= medianMargin ? '潜力门店'
: '问题门店'
sendSuccess(res, {
scorecard: sc,
risk: rk,
platform: platform.rows[0],
benchmark: benchmark.rows[0],
action: { ...act, management_quadrant: quadrant, risk_level: rk?.risk_level },
})
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/:code/daily', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const month = parseMonth(req)
const storeInfo = await query(`SELECT store_name FROM analytics.mv_store_risk_rating_monthly WHERE store_code = $1 AND month_start = $2::date LIMIT 1`, [code, month])
if (storeInfo.rows.length === 0) { sendSuccess(res, []); return }
const storeName = storeInfo.rows[0].store_name
const result = await query(`
SELECT c176::date AS business_date,
count(*) AS bill_count,
round(sum(${RECEIVED_COLS}), 2) AS received,
round(sum(${RECEIVED_COLS}) / count(*), 2) AS avg_bill_value,
round(sum(COALESCE(c068::numeric,0)) / nullif(sum(c009::numeric), 0) * 100, 2) AS discount_rate_pct
FROM bill_records
WHERE c003 = $1
AND c176 IS NOT NULL AND c176 != ''
AND c176::timestamp >= $2::date
AND c176::timestamp < ($2::date + interval '1 month')
GROUP BY c176::date
ORDER BY business_date
`, [storeName, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/cost/comparison', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT store_code, store_name, bill_count, sales_consumption, sales_received, theoretical_cost, theoretical_profit, actual_food_cost, actual_total_cost, packaging_cost, operating_supply_cost, repair_equipment_cost, new_retail_cost, ending_inventory_amount, negative_item_lines, negative_consumption_amount, food_cost_variance, theoretical_cost_rate_pct, actual_food_cost_rate_pct, variance_to_theoretical_pct, cost_rate_gap_pct, comparison_status, variance_level FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1 ORDER BY variance_to_theoretical_pct DESC NULLS LAST`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/cost/category-benchmark', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH store_cat AS (
SELECT sales_store_name AS store_name, finance_category,
round(sum(consumption_amount)::numeric, 2) AS actual_category_cost
FROM analytics.mv_inventory_cost_classified_monthly WHERE month_start = $1
GROUP BY sales_store_name, finance_category
),
store_sales AS (
SELECT store_name, max(sales_received) AS sales_received
FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1
GROUP BY store_name
),
cat_sales AS (
SELECT sc.store_name, sc.finance_category, sc.actual_category_cost,
CASE WHEN ss.sales_received > 0 THEN round(sc.actual_category_cost / ss.sales_received * 10000, 2) ELSE NULL END AS cost_per_10k_sales
FROM store_cat sc JOIN store_sales ss ON sc.store_name = ss.store_name
),
peer AS (
SELECT finance_category, percentile_cont(0.5) WITHIN GROUP (ORDER BY cost_per_10k_sales) AS peer_median
FROM cat_sales WHERE cost_per_10k_sales IS NOT NULL GROUP BY finance_category
)
SELECT cs.store_name, cs.finance_category, cs.actual_category_cost,
cs.cost_per_10k_sales,
round(pm.peer_median::numeric, 2) AS peer_median_cost_per_10k,
CASE WHEN cs.cost_per_10k_sales IS NOT NULL AND pm.peer_median IS NOT NULL
THEN round((cs.cost_per_10k_sales - pm.peer_median)::numeric, 2) ELSE NULL END AS excess_vs_peer_per_10k
FROM cat_sales cs LEFT JOIN peer pm ON cs.finance_category = pm.finance_category
ORDER BY cs.store_name
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/cost/inventory', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT a.store_code, a.store_name, a.business_type, a.scale_tier, a.estimated_inventory_days,
t.ending_inventory_amount, t.negative_item_lines
FROM analytics.mv_store_area_efficiency_monthly a
LEFT JOIN analytics.mv_store_theoretical_actual_cost_monthly t
ON a.store_code = t.store_code AND a.month_start = t.month_start
WHERE a.month_start = $1
ORDER BY a.estimated_inventory_days DESC NULLS LAST
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/platform/economics', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $1 ORDER BY meituan_received DESC NULLS LAST`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/member/comparison', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
CASE WHEN member_id IS NULL THEN '非会员' ELSE '会员' END AS customer_type,
count(*) AS bill_count,
round(sum(received_total), 2) AS received,
round(avg(received_total), 2) AS avg_bill_value,
round(avg(discount_total), 2) AS avg_discount,
round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(theoretical_profit) / NULLIF(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct
FROM analytics.bill_fact
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY CASE WHEN member_id IS NULL THEN '非会员' ELSE '会员' END
ORDER BY customer_type
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/member/repeat', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.v_store_repeat_summary_monthly WHERE month_start = $1::date ORDER BY repeat_rate_pct DESC NULLS LAST`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/sku/abc', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_dish_sku_abc_monthly WHERE month_start = $1 ORDER BY cumulative_revenue_share`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/sku/category', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.category_summary ORDER BY amount DESC`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/sku/attach', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_dish_pair_summary_monthly WHERE month_start = $1 ORDER BY pair_count DESC LIMIT 50`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
const RECEIVED_COLS = `COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0)`
router.get('/risk/anomaly', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const month = parseMonth(req)
const storeName = req.query.store as string
const reason = req.query.reason as string
const cashier = req.query.cashier 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 (reason) {
conditions.push(`anomaly_reason = $${paramIdx}`)
params.push(reason)
paramIdx++
}
if (cashier) {
conditions.push(`cashier ILIKE $${paramIdx}`)
params.push(`%${cashier}%`)
paramIdx++
}
const whereClause = conditions.join(' AND ')
const countResult = await query(`SELECT count(*) AS total, round(sum(consumption),2) AS sum_consumption, round(sum(discount_total),2) AS sum_discount, round(sum(received_total),2) AS sum_received FROM mv_risk_anomaly WHERE ${whereClause}`, params)
const result = await query(`
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
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 })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/risk/zero-received', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const storeName = req.query.store as string
let sql = `
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)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/risk/cashier', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT store_name, cashier, bill_count,
received,
(anomaly_no_received + anomaly_discount_over + anomaly_unbalanced) AS anomaly_bills,
round((anomaly_no_received + anomaly_discount_over + anomaly_unbalanced)::numeric / bill_count * 100, 2) AS anomaly_rate_pct,
consumption_total
FROM mv_risk_cashier
WHERE month = to_char($1::date, 'YYYY-MM') AND cashier IS NOT NULL AND cashier != ''
ORDER BY (anomaly_no_received + anomaly_discount_over + anomaly_unbalanced) DESC NULLS LAST
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/marketing/plans', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT marketing_plan,
count(*) AS bill_count,
round(sum(consumption)::numeric, 2) AS consumption,
round(sum(discount_total)::numeric, 2) AS discounts,
round(sum(received_total)::numeric, 2) AS received,
round(avg(received_total), 2) AS avg_bill_value,
round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(theoretical_profit) / NULLIF(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct
FROM analytics.bill_fact
WHERE marketing_plan IS NOT NULL AND marketing_plan != ''
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY marketing_plan
ORDER BY received DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/benchmark/composite', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_benchmark_composite_monthly WHERE month_start = $1 ORDER BY benchmark_score DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/time/weekday', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT weekday_no, weekday, bill_count, received, avg_bill_value, avg_duration_minutes
FROM mv_time_weekday
WHERE month = to_char($1::date, 'YYYY-MM')
ORDER BY weekday_no
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/time/hourly', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT closing_hour, bill_count, received, avg_bill_value, avg_duration_minutes
FROM mv_time_hourly
WHERE month = to_char($1::date, 'YYYY-MM')
ORDER BY closing_hour
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/channel', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT business_date, cash, alipay, wechat, meituan, unionpay, douyin, credit, jd_delivery, meituan_delivery, taobao_delivery
FROM mv_channel_daily
WHERE month = to_char($1::date, 'YYYY-MM')
ORDER BY business_date
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/data-quality', async (req: AuthRequest, res) => {
try {
const billStats = await query(`
SELECT
count(*) AS total_bills,
count(*) FILTER (WHERE bill_no IS NULL OR bill_no = '') AS missing_bill_no,
count(*) FILTER (WHERE store_code IS NULL OR store_code = '') AS missing_store_code,
count(*) FILTER (WHERE consumption = 0 OR consumption IS NULL) AS zero_consumption,
count(*) FILTER (WHERE received_total < 0) AS negative_received,
count(*) FILTER (WHERE received_total = 0 OR received_total IS NULL) AS zero_received,
count(*) FILTER (WHERE discount_total > consumption AND consumption > 0) AS discount_gt_consumption,
count(DISTINCT store_code) AS store_count,
min(closed_at)::text AS min_date,
max(closed_at)::text AS max_date
FROM analytics.bill_fact
`)
const dishStats = await query(`
SELECT
count(*) AS total_dish_records,
count(*) FILTER (WHERE store_code IS NULL OR store_code = '') AS dish_missing_store,
count(*) FILTER (WHERE dish_name IS NULL OR dish_name = '') AS dish_missing_dish
FROM public.dish_sales_details
`)
const month = parseMonth(req)
const inventoryStats = await query(`
SELECT
count(*) FILTER (WHERE consumption_amount < 0) AS negative_consumption_count,
round(sum(consumption_amount) FILTER (WHERE consumption_amount < 0)::numeric, 2) AS negative_consumption_amount
FROM analytics.mv_inventory_cost_classified_monthly
WHERE month_start = $1
`, [month])
const result = { ...billStats.rows[0], ...dishStats.rows[0], ...inventoryStats.rows[0] }
sendSuccess(res, result)
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 门店详情页扩展 API (Phase 9)
// ============================================================
router.get('/stores/:code/meal-period', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const code = req.params.code
const result = await query(`
WITH network AS (
SELECT meal_period,
count(*) AS network_bills,
(sum(received_total) / NULLIF(count(*), 0)) AS network_avg_bill
FROM analytics.bill_fact
WHERE meal_period IS NOT NULL
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY meal_period
), store_meal AS (
SELECT store_code, store_name, meal_period,
count(*) AS bill_count,
sum(received_total) AS received,
(sum(received_total) / NULLIF(count(*), 0)) AS avg_bill
FROM analytics.bill_fact
WHERE meal_period IS NOT NULL
AND store_code = $2
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY store_code, store_name, meal_period
)
SELECT s.store_code, s.store_name, s.meal_period,
s.bill_count,
round(s.received, 2) AS received,
round(s.avg_bill, 2) AS avg_bill,
round(n.network_avg_bill, 2) AS network_avg_bill,
round(GREATEST(n.network_avg_bill - s.avg_bill, 0) * s.bill_count, 2) AS avg_bill_uplift_scenario,
round((s.avg_bill / n.network_avg_bill - 1) * 100, 2) AS vs_network_pct
FROM store_meal s
JOIN network n ON s.meal_period = n.meal_period
ORDER BY s.bill_count DESC
`, [month, code])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.get('/stores/:code/category-mix', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [result, companyResult] = await Promise.all([
query(`SELECT * FROM analytics.mv_store_category_mix_monthly WHERE month_start = $1 AND store_code = $2`, [month, req.params.code]),
query(`
SELECT
SUM(consumption) as total_consumption,
SUM(lanzhou_noodle) as total_noodle,
SUM(western_staple) as total_western,
SUM(delivery_package) as total_delivery,
SUM(cold_dishes) as total_cold,
SUM(silk_road_food) as total_silk
FROM analytics.mv_store_category_mix_monthly WHERE month_start = $1
`, [month]),
])
sendSuccess(res, { store: result.rows[0], company: companyResult.rows[0] })
} catch (err: any) { sendError(res, err.message) }
})
router.get('/stores/:code/cost', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [costResult, catResult] = await Promise.all([
query(`SELECT * FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1 AND store_code = $2`, [month, req.params.code]),
query(`
WITH store_cat AS (
SELECT finance_category AS category_name,
round(sum(consumption_amount)::numeric, 2) AS consumption_amount,
round(sum(consumption_amount)::numeric / nullif(sum(sum(consumption_amount)) OVER (), 0) * 100, 1) AS actual_cost_rate_pct
FROM analytics.mv_inventory_cost_classified_monthly
WHERE month_start = $1 AND sales_store_code = $2 AND finance_category IS NOT NULL
GROUP BY finance_category
ORDER BY sum(consumption_amount) DESC
),
company_cat AS (
SELECT finance_category,
round(sum(consumption_amount)::numeric / nullif(sum(sum(consumption_amount)) OVER (), 0) * 100, 1) AS benchmark_rate_pct
FROM analytics.mv_inventory_cost_classified_monthly
WHERE month_start = $1 AND finance_category IS NOT NULL
GROUP BY finance_category
)
SELECT s.category_name, s.consumption_amount, s.actual_cost_rate_pct,
COALESCE(c.benchmark_rate_pct, 0) AS benchmark_rate_pct,
round(s.actual_cost_rate_pct - COALESCE(c.benchmark_rate_pct, 0), 1) AS variance_pct
FROM store_cat s
LEFT JOIN company_cat c ON s.category_name = c.finance_category
ORDER BY s.consumption_amount DESC
`, [month, req.params.code]),
])
sendSuccess(res, { cost: costResult.rows[0], categories: catResult.rows })
} catch (err: any) { sendError(res, err.message) }
})
router.get('/stores/:code/member', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [oppResult, repeatResult, monthlyResult] = await Promise.all([
query(`SELECT * FROM analytics.mv_store_member_opportunity_monthly WHERE month_start = $2 AND store_code = $1`, [req.params.code, month]),
query(`SELECT * FROM analytics.mv_store_repeat_summary_monthly WHERE store_code = $1 AND month_start = $2::date`, [req.params.code, month]),
query(`
SELECT store_code, store_name, count(*) as member_count,
sum(orders) as total_orders, sum(received) as total_received,
avg(orders) as avg_orders, avg(received) as avg_received
FROM analytics.v_store_member_monthly_activity
WHERE store_code = $1 AND month_start = $2::date
GROUP BY store_code, store_name
`, [req.params.code, month]),
])
sendSuccess(res, { opportunity: oppResult.rows[0], repeat: repeatResult.rows[0], monthly: monthlyResult.rows[0] })
} catch (err: any) { sendError(res, err.message) }
})
router.get('/stores/:code/anomalies', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const month = parseMonth(req)
const code = req.params.code
const countResult = await query(`SELECT count(*) AS total FROM mv_risk_anomaly WHERE store_name = (SELECT store_name FROM analytics.dim_store WHERE store_code = $1) AND month = to_char($2::date, 'YYYY-MM')`, [code, month])
const result = await query(`SELECT * FROM mv_risk_anomaly WHERE store_name = (SELECT store_name FROM analytics.dim_store WHERE store_code = $1) AND month = to_char($2::date, 'YYYY-MM') ORDER BY closed_at DESC LIMIT $3 OFFSET $4`, [code, month, pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
} catch (err: any) { sendError(res, err.message) }
})
// 区域汇总
router.get('/region/summary', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT d.region,
count(DISTINCT b.store_code) AS store_count,
COALESCE(sum(b.received_total), 0) AS total_received,
count(*) AS total_bill_count,
round(avg(b.received_total), 2) AS avg_bill_value,
round(sum(b.discount_total) / NULLIF(sum(b.consumption), 0) * 100, 2) AS avg_discount_rate,
round(sum(b.theoretical_profit) / NULLIF(sum(b.received_total), 0) * 100, 2) AS avg_margin_rate,
0 AS avg_anomaly_rate,
count(DISTINCT CASE WHEN r.risk_level = '红色' THEN b.store_code END) AS red_count,
count(DISTINCT CASE WHEN r.risk_level = '黄色' THEN b.store_code END) AS yellow_count,
count(DISTINCT CASE WHEN r.risk_level = '绿色' THEN b.store_code END) AS green_count
FROM analytics.bill_fact b
JOIN analytics.dim_store d ON d.store_code = b.store_code
LEFT JOIN analytics.mv_store_risk_rating_monthly r ON r.store_code = b.store_code AND r.month_start = $1::date
WHERE d.region IS NOT NULL AND d.region <> ''
AND b.closed_at >= $1::date AND b.closed_at < ($1::date + interval '1 month')
GROUP BY d.region
ORDER BY total_received DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 门店选址分析 — 门店选址画像
router.get('/site-selection/profile', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_site_profile_monthly WHERE month_start = $1 AND business_type='标准门店' AND received>0 ORDER BY received DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 门店选址分析 — 分段基准(场景×面积)
router.get('/site-selection/segment-benchmark', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_site_segment_benchmark_monthly WHERE month_start = $1 ORDER BY avg_received_per_sqm DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 门店选址分析 — 复制评分
router.get('/site-selection/replication', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_site_replication_monthly WHERE month_start = $1 ORDER BY site_replication_score DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 门店选址分析 — 重叠风险
router.get('/site-selection/overlap-risk', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_overlap_risk_monthly WHERE month_start = $1 ORDER BY distance_km`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 门店选址分析 — 区域基准
router.get('/site-selection/district-benchmark', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_district_site_benchmark_monthly WHERE month_start = $1 ORDER BY total_received DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 利润瀑布数据
router.get('/overview/profit-waterfall', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH full_scope AS (
SELECT
r.received,
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,
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
WHERE r.month_start = $1 AND r.received IS NOT NULL AND e.operating_expense IS NOT NULL
)
SELECT
round(sum(received)::numeric, 2) AS received,
round(sum(actual_food_cost)::numeric, 2) AS food_cost,
round(sum(wage_expense)::numeric, 2) AS wage,
round(sum(rent_expense)::numeric, 2) AS rent,
round(sum(utility_expense)::numeric, 2) AS utility,
round(sum(dorm_expense)::numeric, 2) AS dorm,
round(sum(delivery_commission_expense)::numeric, 2) AS commission,
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
FROM full_scope
`, [month])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
// 门店利润排名
router.get('/overview/store-profit-ranking', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
r.store_code,
r.store_name,
round(r.received::numeric, 2) AS received,
round(e.actual_food_cost::numeric, 2) AS food_cost,
round(e.operating_expense::numeric, 2) AS expense,
round((r.received - e.actual_food_cost - e.operating_expense)::numeric, 2) AS store_contribution,
round((r.received - e.actual_food_cost - e.operating_expense) / nullif(r.received, 0) * 100, 2) AS contribution_margin_pct,
r.risk_level
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
WHERE r.month_start = $1 AND r.received IS NOT NULL AND r.received > 0 AND e.operating_expense IS NOT NULL
ORDER BY store_contribution DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// 利润机会池
router.get('/overview/profit-opportunity', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH risk AS (
SELECT * FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1
),
sku_abc AS (
SELECT * FROM analytics.mv_dish_sku_abc_monthly WHERE month_start = $1
),
standard_stores AS (
SELECT r.store_code, r.store_name, r.received,
e.actual_food_cost, e.operating_expense,
e.wage_expense, e.utility_expense,
r.theoretical_margin_pct, r.discount_rate_pct
FROM risk r
LEFT JOIN analytics.mv_store_operating_expense_monthly e
ON r.store_code = e.sales_store_code AND e.report_month = $1::date
WHERE r.received IS NOT NULL AND r.received > 0 AND e.operating_expense IS NOT NULL
),
cost_diff AS (
SELECT
round(sum(actual_food_cost)::numeric, 2) AS actual_cost,
round(sum(received * (1 - COALESCE(theoretical_margin_pct, 0) / 100))::numeric, 2) AS theoretical_cost
FROM standard_stores
),
cost_diff_stores AS (
SELECT store_name, received,
round(actual_food_cost::numeric, 2) AS actual_food_cost,
round((received * (1 - COALESCE(theoretical_margin_pct, 0) / 100))::numeric, 2) AS theoretical_cost,
round((actual_food_cost - received * (1 - COALESCE(theoretical_margin_pct, 0) / 100))::numeric, 2) AS diff_amount,
round((actual_food_cost / nullif(received, 0) * 100)::numeric, 2) AS actual_cost_rate,
round((received * (1 - COALESCE(theoretical_margin_pct, 0) / 100) / nullif(received, 0) * 100)::numeric, 2) AS theoretical_cost_rate
FROM standard_stores
WHERE actual_food_cost IS NOT NULL
ORDER BY (actual_food_cost - received * (1 - COALESCE(theoretical_margin_pct, 0) / 100)) DESC
LIMIT 5
),
labor AS (
SELECT
round(sum(wage_expense)::numeric, 2) AS total_wage,
round(sum(received)::numeric, 2) AS total_received
FROM standard_stores
),
labor_stores AS (
SELECT store_name, received,
round(wage_expense::numeric, 2) AS wage,
round((wage_expense / nullif(received, 0) * 100)::numeric, 2) AS wage_rate
FROM standard_stores
WHERE wage_expense IS NOT NULL
ORDER BY wage_expense / nullif(received, 0) DESC
LIMIT 5
),
energy AS (
SELECT
round(sum(utility_expense)::numeric, 2) AS total_utility,
round(sum(received)::numeric, 2) AS total_received
FROM standard_stores
),
energy_stores AS (
SELECT store_name, received,
round(utility_expense::numeric, 2) AS utility,
round((utility_expense / nullif(received, 0) * 100)::numeric, 2) AS utility_rate
FROM standard_stores
WHERE utility_expense IS NOT NULL
ORDER BY utility_expense / nullif(received, 0) DESC
LIMIT 5
),
discount AS (
SELECT
count(*) FILTER (WHERE discount_rate_pct > 25) AS high_discount_stores,
round(sum(CASE WHEN discount_rate_pct > 25 THEN received * discount_rate_pct / nullif(100 - discount_rate_pct, 0) * (discount_rate_pct - 25) / nullif(discount_rate_pct, 0) ELSE 0 END)::numeric, 2) AS theoretical_saving
FROM risk
WHERE received IS NOT NULL AND received > 0
),
discount_stores AS (
SELECT store_name, received,
round(discount_rate_pct::numeric, 2) AS discount_rate,
round((received * discount_rate_pct / nullif(100 - discount_rate_pct, 0) * (discount_rate_pct - 25) / nullif(discount_rate_pct, 0))::numeric, 2) AS potential_saving
FROM risk
WHERE received IS NOT NULL AND received > 0 AND discount_rate_pct > 25
ORDER BY discount_rate_pct DESC
LIMIT 5
),
platform AS (
SELECT
round(sum(delivery_commission_expense)::numeric, 2) AS total_commission,
round(sum(received)::numeric, 2) AS platform_received
FROM analytics.mv_store_operating_expense_monthly
WHERE report_month = $1::date AND delivery_commission_expense IS NOT NULL
),
platform_stores AS (
SELECT e.sales_store_code AS store_code, e.received,
round(e.delivery_commission_expense::numeric, 2) AS commission,
round((e.delivery_commission_expense / nullif(e.received, 0) * 100)::numeric, 2) AS commission_rate
FROM analytics.mv_store_operating_expense_monthly e
WHERE e.report_month = $1::date AND e.delivery_commission_expense IS NOT NULL
ORDER BY e.delivery_commission_expense / nullif(e.received, 0) DESC
LIMIT 5
),
sku AS (
SELECT
count(*) FILTER (WHERE received_amount < 1000 AND bill_count < 30) AS low_sku_count,
round(sum(received_amount) FILTER (WHERE received_amount < 1000 AND bill_count < 30)::numeric, 2) AS low_sku_revenue
FROM sku_abc
),
sku_samples AS (
SELECT dish_name, category_level1, received_amount, bill_count, abc_class
FROM sku_abc
WHERE received_amount < 1000 AND bill_count < 30
ORDER BY received_amount ASC
LIMIT 10
)
SELECT
json_build_object(
'items', json_build_array(
json_build_object(
'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),
'confidence', '中高',
'owner', '商品/供应链/门店',
'evidence', '采购价差、用量差、盘点差、报损差',
'detail', (SELECT
'实际食材成本' || (SELECT actual_cost FROM cost_diff) || '元 vs 理论成本' || (SELECT theoretical_cost FROM cost_diff) || '元,差异' || round((SELECT actual_cost - theoretical_cost FROM cost_diff)::numeric, 2) || '元(成本率' || round((SELECT actual_cost FROM cost_diff) / nullif((SELECT sum(received) FROM standard_stores), 0) * 100, 2) || '% vs 理论' || round((SELECT theoretical_cost FROM cost_diff) / nullif((SELECT sum(received) FROM standard_stores), 0) * 100, 2) || '%)。\n\n' ||
'差异TOP5门店(需优先排查):\n' ||
string_agg(store_name || ':差异' || diff_amount || '元(实际成本率' || actual_cost_rate || '% vs 理论' || theoretical_cost_rate || '%,实收' || received || '元)', '\n') ||
'\n\n行动指向:\n1)上述5家门店实际成本率均远超理论值,需逐店排查采购单价与BOM标准价差异;\n2)盘点差——核查月末盘点与系统库存一致性,差异>3%需复盘;\n3)报损差——对比报损记录与行业基准,报损率>2%的门店需检查存储和加工流程;\n4)30天目标:将TOP5门店成本率降低5个百分点,预计回收' || round((SELECT (actual_cost - theoretical_cost) FROM cost_diff) * 0.30, 2) || '元。'
FROM cost_diff_stores)
),
json_build_object(
'category', '标准店人工优化',
'baseline', (SELECT round(total_wage / total_received * 100, 2) FROM labor),
'target_pct', 100,
'opportunity', round((SELECT total_wage * (1 - 24.0 / nullif(total_wage / total_received * 100, 0)) FROM labor), 2),
'confidence', '中',
'owner', '运营/人力',
'evidence', '工时、工资、餐段销售与服务质量',
'detail', (SELECT
'标准店人工合计' || (SELECT total_wage FROM labor) || '元,费率' || round((SELECT total_wage FROM labor) / nullif((SELECT total_received FROM labor), 0) * 100, 2) || '%,目标降至24%。\n\n' ||
'人工费率TOP5门店(需重点督导):\n' ||
string_agg(store_name || ':人工' || wage || '元(费率' || wage_rate || '%,实收' || received || '元)', '\n') ||
'\n\n行动指向:\n1)上述门店人工费率远超24%目标,需核查排班与实际打卡工时,识别冗余工时;\n2)低峰时段用小时工替代月薪员工,预计可降费率2-3个百分点;\n3)对费率>30%的门店启动人效专项督导,要求店长提交排班优化方案;\n4)30天目标:TOP5门店人工费率平均降低2个百分点。'
FROM labor_stores)
),
json_build_object(
'category', '标准店能源优化',
'baseline', (SELECT round(total_utility / total_received * 100, 2) FROM energy),
'target_pct', 100,
'opportunity', round((SELECT total_utility * (1 - 5.0 / nullif(total_utility / total_received * 100, 0)) FROM energy), 2),
'confidence', '中',
'owner', '工程/门店',
'evidence', '账单/抄表、面积、营业时长',
'detail', (SELECT
'标准店水电合计' || (SELECT total_utility FROM energy) || '元,费率' || round((SELECT total_utility FROM energy) / nullif((SELECT total_received FROM energy), 0) * 100, 2) || '%,目标降至5%。\n\n' ||
'水电费率TOP5门店(需排查设备):\n' ||
string_agg(store_name || ':水电' || utility || '元(费率' || utility_rate || '%,实收' || received || '元)', '\n') ||
'\n\n行动指向:\n1)上述门店水电费率远超5%目标,需排查是否存在设备老化、管道泄漏或空调空转;\n2)对比近3个月水电账单,波动>20%的门店需现场检查;\n3)缩短非营业时段的照明和空调,预计可降费率0.3-0.5个百分点;\n4)30天目标:TOP5门店水电费率平均降低0.5个百分点。'
FROM energy_stores)
),
json_build_object(
'category', '高优惠门店治理',
'baseline', (SELECT high_discount_stores FROM discount),
'target_pct', 100,
'opportunity', (SELECT theoretical_saving FROM discount),
'confidence', '中低',
'owner', '营销/运营',
'evidence', '活动贡献、账单和复购不恶化',
'detail', (SELECT
'优惠率>25%的门店共' || (SELECT high_discount_stores FROM discount) || '家,理论可节约' || (SELECT theoretical_saving FROM discount) || '元/月。\n\n' ||
'高优惠TOP5门店(需活动ROI评估):\n' ||
COALESCE(string_agg(store_name || ':优惠率' || discount_rate || '%(实收' || received || '元,理论可节约' || potential_saving || '元)', '\n'), '无符合条件的门店') ||
'\n\n行动指向:\n1)上述门店优惠率远超25%红线,需逐个活动对比优惠前后实收与客流变化;\n2)将优惠率从>25%降至20%以内,用会员积分/储值优惠替代直接打折;\n3)优惠率>20%的活动需总部审批,未审批的活动立即停用;\n4)30天目标:TOP5门店优惠率平均降低5个百分点。'
FROM discount_stores)
),
json_build_object(
'category', '平台佣金优化',
'baseline', (SELECT round(total_commission / nullif(platform_received, 0) * 100, 2) FROM platform),
'target_pct', 100,
'opportunity', GREATEST(round((SELECT total_commission * (1 - 20.0 / nullif(total_commission / nullif(platform_received, 0) * 100, 0)) FROM platform), 2), 0),
'confidence', '中低',
'owner', '外卖/采购',
'evidence', '平台结算单与订单对账',
'detail', (SELECT
'平台佣金合计' || (SELECT total_commission FROM platform) || '元,佣金率' || round((SELECT total_commission FROM platform) / nullif((SELECT platform_received FROM platform), 0) * 100, 2) || '%。\n\n' ||
'佣金费率TOP5门店(需对账核查):\n' ||
COALESCE(string_agg(store_code || ':佣金' || commission || '元(费率' || commission_rate || '%,实收' || received || '元)', '\n'), '无数据') ||
'\n\n行动指向:\n1)当前整体佣金率6.08%已低于20%目标,暂无大幅优化空间;\n2)上述门店佣金费率偏高,需逐月核对平台结算单与订单明细,识别多扣佣金;\n3)平台活动费与佣金应分离核算,避免活动费被计入佣金;\n4)提升自配送比例,降低对平台配送依赖;\n5)30天目标:完成TOP5门店平台结算单对账。'
FROM platform_stores)
),
json_build_object(
'category', 'SKU复杂度压缩',
'baseline', (SELECT low_sku_count FROM sku),
'target_pct', 100,
'opportunity', round((SELECT low_sku_revenue FROM sku) * 0.15, 2),
'confidence', '低',
'owner', '商品/运营',
'evidence', '停用低效SKU减少独有原料库存、报损和采购复杂度',
'detail', (SELECT
'月收入<1000元且账单<30笔的低效SKU共' || (SELECT low_sku_count FROM sku) || '个,合计收入' || (SELECT low_sku_revenue FROM sku) || '元。\n\n' ||
'低效SKU示例(候选停用):\n' ||
COALESCE(string_agg(dish_name || '' || category_level1 || '):收入' || received_amount || '元/' || bill_count || '笔,ABC类' || abc_class, '\n'), '无数据') ||
'\n\n行动指向:\n1)上述SKU月收入极低,停用后可减少独有原料库存和报损;\n2)逐个排查停用SKU的独有原料,计算可释放库存金额;\n3)每店SKU数减少15-20%,新品准入需通过收入预测和原料复用率审核;\n430天目标:完成' || (SELECT low_sku_count FROM sku) || '个低效SKU的停用评估。'
FROM sku_samples)
)
)
) AS data
`, [month])
const rows = result.rows[0] as any
sendSuccess(res, rows?.data)
} catch (err: any) {
sendError(res, err.message)
}
})
// 同比:当前月 vs 去年同月
router.get('/overview/yoy', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const prevYear = prevYearMonth(month)
const [current, previous] = await Promise.all([
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 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`, [prevYear])
])
const c = current.rows[0] as any
const p = previous.rows[0] as any
if (!c) { sendSuccess(res, null); return }
const calcChange = (cur: number, prev: number | null) => {
if (prev === null || prev === 0) return null
return Math.round(((cur - prev) / Math.abs(prev)) * 100 * 100) / 100
}
sendSuccess(res, {
current: c,
previous: p,
yoy: p ? {
received_change_pct: calcChange(parseFloat(c.received), parseFloat(p.received)),
bill_count_change_pct: calcChange(parseInt(c.bill_count), parseInt(p.bill_count)),
avg_bill_value_change_pct: calcChange(parseFloat(c.avg_bill_value), parseFloat(p.avg_bill_value)),
discount_rate_change: Math.round((parseFloat(c.discount_rate_pct) - parseFloat(p.discount_rate_pct)) * 100) / 100,
} : null
})
} catch (err: any) { sendError(res, err.message) }
})
// 环比:当前月 vs 上月
router.get('/overview/mom', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const prevMo = prevMonth(month)
const [current, previous] = await Promise.all([
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 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`, [prevMo])
])
const c = current.rows[0] as any
const p = previous.rows[0] as any
if (!c) { sendSuccess(res, null); return }
const calcChange = (cur: number, prev: number | null) => {
if (prev === null || prev === 0) return null
return Math.round(((cur - prev) / Math.abs(prev)) * 100 * 100) / 100
}
sendSuccess(res, {
current: c,
previous: p,
mom: p ? {
received_change_pct: calcChange(parseFloat(c.received), parseFloat(p.received)),
bill_count_change_pct: calcChange(parseInt(c.bill_count), parseInt(p.bill_count)),
avg_bill_value_change_pct: calcChange(parseFloat(c.avg_bill_value), parseFloat(p.avg_bill_value)),
discount_rate_change: Math.round((parseFloat(c.discount_rate_pct) - parseFloat(p.discount_rate_pct)) * 100) / 100,
} : null
})
} catch (err: any) { sendError(res, err.message) }
})
// 月度趋势
router.get('/overview/trend', async (req: AuthRequest, res) => {
try {
const endMonth = parseMonth(req)
const months = parseInt(req.query.months as string) || 12
const startDate = new Date(endMonth)
startDate.setMonth(startDate.getMonth() - months + 1)
const startMonth = startDate.toISOString().slice(0, 10)
const result = await query(`
SELECT month, 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 AND month <= $2::date
ORDER BY month
`, [startMonth, endMonth])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 利润趋势(多月瀑布汇总)
router.get('/overview/profit-trend', async (req: AuthRequest, res) => {
try {
const endMonth = parseMonth(req)
const months = parseInt(req.query.months as string) || 6
const startDate = new Date(endMonth)
startDate.setMonth(startDate.getMonth() - months + 1)
const startMonth = startDate.toISOString().slice(0, 10)
const result = await query(`
WITH monthly AS (
SELECT e.report_month,
round(sum(r.received)::numeric, 2) AS received,
round(sum(e.actual_food_cost)::numeric, 2) AS food_cost,
round(sum(e.wage_expense)::numeric, 2) AS wage,
round(sum(e.rent_expense)::numeric, 2) AS rent,
round(sum(e.utility_expense)::numeric, 2) AS utility,
round(sum(e.operating_expense)::numeric, 2) AS total_expense,
round(sum(r.received) - sum(e.actual_food_cost) - sum(e.operating_expense), 2) AS store_contribution
FROM analytics.v_store_scorecard r
JOIN analytics.mv_store_operating_expense_monthly e
ON r.store_code = e.sales_store_code
WHERE e.report_month >= $1::date AND e.report_month <= $2::date
AND e.operating_expense IS NOT NULL AND r.received > 0
GROUP BY e.report_month
ORDER BY e.report_month
)
SELECT *,
round(store_contribution / nullif(received, 0) * 100, 2) AS contribution_margin_pct
FROM monthly
`, [startMonth, endMonth])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 时间段汇总
router.get('/overview/period', async (req: AuthRequest, res) => {
try {
const { start, end, mode, label } = parseDateRange(req)
const result = await query(`
SELECT
sum(bill_count)::bigint AS bill_count,
round(sum(received)::numeric, 2) AS received,
round(sum(discounts)::numeric, 2) AS discounts,
round(sum(discounts) / nullif(sum(received) + sum(discounts), 0) * 100, 2) AS discount_rate_pct,
round(sum(received) / nullif(sum(bill_count), 0), 2) AS avg_bill_value,
round(sum(guests)::numeric, 0) AS guests
FROM analytics.v_store_daily
WHERE business_date >= $1::date AND business_date < $2::date
`, [start, end])
sendSuccess(res, { ...result.rows[0], range_mode: mode, range_label: label, start_date: start, end_date: end })
} catch (err: any) { sendError(res, err.message) }
})
// 渠道收入结构
router.get('/revenue/channel', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
business_date,
round(cash::numeric, 2) AS cash,
round(alipay::numeric, 2) AS alipay,
round(wechat::numeric, 2) AS wechat,
round(meituan::numeric, 2) AS meituan,
round(unionpay::numeric, 2) AS unionpay,
round(douyin::numeric, 2) AS douyin,
round(credit::numeric, 2) AS credit,
round(jd_delivery::numeric, 2) AS jd_delivery,
round(meituan_delivery::numeric, 2) AS meituan_delivery,
round(taobao_delivery::numeric, 2) AS taobao_delivery
FROM analytics.v_channel_daily
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
ORDER BY business_date
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// 时段收入分布
router.get('/revenue/meal-period', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
meal_period,
sum(bill_count)::bigint AS bill_count,
round(sum(received)::numeric, 2) AS received,
round(sum(received) / nullif(sum(bill_count), 0), 2) AS avg_bill_value
FROM analytics.v_meal_period_daily
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
GROUP BY meal_period
ORDER BY sum(received) DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// 门店营收排名
router.get('/revenue/store-ranking', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
store_code,
store_name,
sum(bill_count)::bigint AS bill_count,
round(sum(received)::numeric, 2) AS received,
round(sum(discounts)::numeric, 2) AS discounts,
round(sum(discounts) / nullif(sum(received) + sum(discounts), 0) * 100, 2) AS avg_discount_rate_pct,
round(sum(received) / nullif(sum(bill_count), 0), 2) AS avg_bill_value,
round(sum(guests)::numeric, 0) AS guests,
round(avg(theoretical_margin_pct)::numeric, 2) AS avg_theoretical_margin_pct
FROM analytics.v_store_daily
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
GROUP BY store_code, store_name
ORDER BY sum(received) DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// 日度营收汇总
router.get('/revenue/daily-summary', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
business_date,
sum(bill_count)::bigint AS bill_count,
round(sum(received)::numeric, 2) AS received,
round(sum(discounts)::numeric, 2) AS discounts,
round(sum(discounts) / nullif(sum(received) + sum(discounts), 0) * 100, 2) AS discount_rate_pct,
round(sum(received) / nullif(sum(bill_count), 0), 2) AS avg_bill_value,
round(sum(guests)::numeric, 0) AS guests
FROM analytics.v_store_daily
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
GROUP BY business_date
ORDER BY business_date
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// 银行授信报告
router.get('/bank/report', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const nextMonthDate = new Date(month)
nextMonthDate.setMonth(nextMonthDate.getMonth() + 1)
const nextMonth = nextMonthDate.toISOString().slice(0, 10)
const [overview, daily, waterfall, risk, channel, storeRanking] = await Promise.all([
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 full_scope AS (
SELECT r.received, 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
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
WHERE r.received IS NOT NULL AND e.operating_expense IS NOT NULL
)
SELECT round(sum(received)::numeric,2) AS received, round(sum(food_cost)::numeric,2) AS food_cost, round(sum(wage)::numeric,2) AS wage,
round(sum(rent)::numeric,2) AS rent, round(sum(utility)::numeric,2) AS utility, round(sum(dorm)::numeric,2) AS dorm,
round(sum(commission)::numeric,2) AS commission, round(sum(card_fee+repair)::numeric,2) AS other_expense,
round(sum(operating_expense)::numeric,2) AS total_expense,
round(sum(received)-sum(food_cost)-sum(operating_expense),2) AS store_contribution
FROM full_scope
`, [month]),
query(`SELECT risk_level, count(*) AS store_count, round(sum(received)::numeric,2) AS total_received, round(avg(theoretical_margin_pct)::numeric,2) AS avg_margin_pct, round(avg(discount_rate_pct)::numeric,2) AS avg_discount_pct FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 AND received IS NOT NULL GROUP BY risk_level ORDER BY risk_level`, [month]),
query(`SELECT round(sum(cash)::numeric,2) AS cash, round(sum(alipay)::numeric,2) AS alipay, round(sum(wechat)::numeric,2) AS wechat, round(sum(meituan)::numeric,2) AS meituan, round(sum(unionpay)::numeric,2) AS unionpay, round(sum(douyin)::numeric,2) AS douyin, round(sum(credit)::numeric,2) AS credit, round(sum(jd_delivery)::numeric,2) AS jd_delivery, round(sum(meituan_delivery)::numeric,2) AS meituan_delivery, round(sum(taobao_delivery)::numeric,2) AS taobao_delivery FROM analytics.v_channel_daily WHERE business_date >= $1::date AND business_date < $2::date`, [month, nextMonth]),
query(`SELECT store_code, store_name, round(received::numeric,2) AS received, bill_count, round(avg_bill_value::numeric,2) AS avg_bill_value, round(discount_rate_pct::numeric,2) AS discount_rate_pct, round(theoretical_margin_pct::numeric,2) AS theoretical_margin_pct, risk_level FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 AND received IS NOT NULL ORDER BY received DESC`, [month]),
])
const ov = overview.rows[0] as any
const dailyRows = daily.rows.map((r: any) => ({
...r,
received: parseFloat(r.received),
bill_count: parseInt(r.bill_count),
avg_bill_value: parseFloat(r.avg_bill_value),
discount_rate_pct: parseFloat(r.discount_rate_pct),
}))
const wf = waterfall.rows[0] as any
const riskRows = risk.rows.map((r: any) => ({
...r,
store_count: parseInt(r.store_count),
total_received: parseFloat(r.total_received),
}))
const ch = channel.rows[0] as any
const channelTotals: Record<string, number> = {}
if (ch) {
for (const [k, v] of Object.entries(ch)) {
const val = parseFloat(v as string)
if (val > 0) channelTotals[k] = val
}
}
const storeRows = storeRanking.rows.map((r: any) => ({
...r,
received: parseFloat(r.received),
bill_count: parseInt(r.bill_count),
avg_bill_value: parseFloat(r.avg_bill_value),
discount_rate_pct: parseFloat(r.discount_rate_pct),
theoretical_margin_pct: parseFloat(r.theoretical_margin_pct),
}))
// 计算日度营收波动率
const receivedArr = dailyRows.map((d: any) => d.received)
const meanRev = receivedArr.reduce((a: number, b: number) => a + b, 0) / (receivedArr.length || 1)
const variance = receivedArr.reduce((s: number, v: number) => s + Math.pow(v - meanRev, 2), 0) / (receivedArr.length || 1)
const stdDev = Math.sqrt(variance)
const cv = meanRev > 0 ? stdDev / meanRev : 0
const f = (v: any) => v === null || v === undefined ? 0 : parseFloat(v)
const i = (v: any) => v === null || v === undefined ? 0 : parseInt(v)
sendSuccess(res, {
overview: { ...ov, received: f(ov?.received), bill_count: i(ov?.bill_count), avg_bill_value: f(ov?.avg_bill_value), discount_rate_pct: f(ov?.discount_rate_pct), theoretical_margin_pct: f(ov?.theoretical_margin_pct), member_bills: i(ov?.member_bills), member_share_pct: f(ov?.member_share_pct) },
daily: dailyRows,
waterfall: { ...wf, received: f(wf?.received), food_cost: f(wf?.food_cost), wage: f(wf?.wage), rent: f(wf?.rent), utility: f(wf?.utility), dorm: f(wf?.dorm), commission: f(wf?.commission), other_expense: f(wf?.other_expense), total_expense: f(wf?.total_expense), store_contribution: f(wf?.store_contribution) },
risk: riskRows,
channel: channelTotals,
stores: storeRows,
stability: { mean_daily_revenue: meanRev, std_dev: stdDev, cv: cv, days: receivedArr.length },
})
} catch (err: any) {
sendError(res, err.message)
}
})
// 中央厨房成本驾驶舱
router.get('/central-kitchen/dashboard', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [summary, reconciliation, products, categoryCost, recipeEfficiency, mfgPool, yieldAnalysis] = await Promise.all([
// 汇总指标
query(`
SELECT
count(*) AS product_count,
round(sum(inbound_quantity)::numeric,2) AS total_inbound_qty,
round(sum(theoretical_cost)::numeric,2) AS theoretical_cost,
round(sum(standard_cost)::numeric,2) AS standard_cost,
round(sum(material_actual_cost)::numeric,2) AS material_actual_cost,
round(max(manufacturing_cost_pool)::numeric,2) AS manufacturing_cost_pool,
round(sum(allocated_manufacturing_cost)::numeric,2) AS allocated_manufacturing_cost,
round(sum(full_manufacturing_cost)::numeric,2) AS full_manufacturing_cost,
round(avg(NULLIF(full_unit_cost,0))::numeric,4) AS avg_unit_cost,
round((sum(material_actual_cost) - sum(theoretical_cost))::numeric,2) AS efficiency_variance,
round((sum(material_actual_cost) - sum(theoretical_cost)) / NULLIF(sum(theoretical_cost),0) * 100::numeric,2) AS efficiency_variance_pct,
round((sum(material_actual_cost) - sum(standard_cost)) / NULLIF(sum(standard_cost),0) * 100::numeric,2) AS standard_variance_pct,
round(max(manufacturing_cost_pool) / NULLIF(sum(material_actual_cost),0) * 100::numeric,2) AS mfg_cost_rate
FROM analytics.v_central_kitchen_product_full_cost
WHERE report_month = $1::date
`, [month]),
// 成本对账瀑布
query(`
SELECT
count(*) AS product_lines,
round(sum(p.theoretical_cost)::numeric,2) AS theoretical_cost,
round(sum(p.standard_cost)::numeric,2) AS standard_cost,
round(sum(p.material_actual_cost)::numeric,2) AS material_actual_cost,
round(max(p.manufacturing_cost_pool)::numeric,2) AS manufacturing_cost_pool,
round(sum(p.allocated_manufacturing_cost)::numeric,2) AS allocated_manufacturing_cost,
round(sum(p.full_manufacturing_cost)::numeric,2) AS full_manufacturing_cost,
round(sum(c.inbound_quantity * c.inbound_avg_unit_price)::numeric,2) AS calculated_inbound_value,
round((sum(c.inbound_quantity * c.inbound_avg_unit_price) - sum(p.full_manufacturing_cost))::numeric,2) AS manufacturing_margin
FROM analytics.v_central_kitchen_product_full_cost p
JOIN central_kitchen_processing_cost c ON p.report_month = c.report_month AND p.product_code = c.product_code
WHERE p.report_month = $1::date
`, [month]),
// 产品明细列表
query(`
SELECT
p.product_code,
p.product_name,
p.recipe_name,
p.category_minor,
p.unit,
round(p.inbound_quantity::numeric,2) AS inbound_quantity,
round(p.theoretical_cost::numeric,2) AS theoretical_cost,
round(p.standard_cost::numeric,2) AS standard_cost,
round(p.material_actual_cost::numeric,2) AS material_actual_cost,
round(p.allocated_manufacturing_cost::numeric,2) AS allocated_manufacturing_cost,
round(p.full_manufacturing_cost::numeric,2) AS full_manufacturing_cost,
round(p.full_unit_cost::numeric,4) AS full_unit_cost,
round(((p.material_actual_cost - p.theoretical_cost) / NULLIF(p.theoretical_cost,0) * 100)::numeric,2) AS efficiency_variance_pct,
round(((p.material_actual_cost - p.standard_cost) / NULLIF(p.standard_cost,0) * 100)::numeric,2) AS standard_variance_pct,
round((p.allocated_manufacturing_cost / NULLIF(p.material_actual_cost,0) * 100)::numeric,2) AS mfg_allocation_pct,
round((c.inbound_quantity * c.inbound_avg_unit_price)::numeric,2) AS inbound_value,
round((c.inbound_quantity * c.inbound_avg_unit_price - p.full_manufacturing_cost)::numeric,2) AS product_margin
FROM analytics.v_central_kitchen_product_full_cost p
JOIN central_kitchen_processing_cost c ON p.report_month = c.report_month AND p.product_code = c.product_code
WHERE p.report_month = $1::date
ORDER BY p.full_manufacturing_cost DESC
`, [month]),
// 品类成本结构
query(`
SELECT
COALESCE(NULLIF(category_minor,''),'未分类') AS category_minor,
count(*) AS product_count,
round(sum(inbound_quantity)::numeric,2) AS total_qty,
round(sum(theoretical_cost)::numeric,2) AS theoretical_cost,
round(sum(material_actual_cost)::numeric,2) AS material_actual_cost,
round(sum(allocated_manufacturing_cost)::numeric,2) AS allocated_mfg_cost,
round(sum(full_manufacturing_cost)::numeric,2) AS full_cost,
round(avg(NULLIF(full_unit_cost,0))::numeric,4) AS avg_unit_cost,
round(((sum(material_actual_cost) - sum(theoretical_cost)) / NULLIF(sum(theoretical_cost),0) * 100)::numeric,2) AS efficiency_var_pct
FROM analytics.v_central_kitchen_product_full_cost
WHERE report_month = $1::date
GROUP BY category_minor
ORDER BY full_cost DESC
`, [month]),
// 配方效率分析(Top超耗)
query(`
SELECT
recipe_name,
item_name,
specification,
unit,
round(sum(theoretical_quantity)::numeric,2) AS theoretical_qty,
round(sum(net_quantity)::numeric,2) AS actual_qty,
round(sum(issue_quantity)::numeric,2) AS issue_qty,
round(sum(theoretical_amount)::numeric,2) AS theoretical_amt,
round(sum(issue_amount)::numeric,2) AS actual_amt,
round(((sum(issue_amount) - sum(theoretical_amount)) / NULLIF(sum(theoretical_amount),0) * 100)::numeric,2) AS variance_pct,
round(avg(actual_yield)::numeric,4) AS avg_actual_yield,
round(avg(recipe_yield)::numeric,4) AS avg_recipe_yield,
round((avg(actual_yield) - avg(recipe_yield))::numeric,4) AS yield_diff
FROM central_kitchen_recipe_consumption
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
GROUP BY recipe_name, item_name, specification, unit
HAVING sum(theoretical_amount) > 0
ORDER BY abs((sum(issue_amount) - sum(theoretical_amount)) / NULLIF(sum(theoretical_amount),0)) DESC
LIMIT 20
`, [month]),
// 制造费用池明细
query(`
SELECT
cost_type,
cost_subtype,
source_type,
source_reference,
round(source_amount::numeric,2) AS source_amount,
round(central_kitchen_share_pct::numeric,4) AS share_pct,
round(allocated_amount::numeric,2) AS allocated_amount,
allocation_method,
is_provisional,
include_in_rebuilt_cost,
note
FROM central_kitchen_manufacturing_cost_pool
WHERE report_month = $1::date
ORDER BY include_in_rebuilt_cost DESC, allocated_amount DESC
`, [month]),
// 出成率分析
query(`
SELECT
recipe_name,
product_name,
specification,
unit,
round(sum(theoretical_inbound_quantity)::numeric,2) AS theoretical_inbound_qty,
round(sum(actual_inbound_quantity)::numeric,2) AS actual_inbound_qty,
round(avg(achievement_rate)::numeric,4) AS avg_achievement_rate,
round(avg(expected_quantity_variance_rate)::numeric,4) AS avg_expected_var_rate,
round(sum(inbound_quantity)::numeric,2) AS total_inbound_qty,
round(sum(return_quantity)::numeric,2) AS total_return_qty,
round(sum(inbound_amount)::numeric,2) AS total_inbound_amt,
round(sum(return_amount)::numeric,2) AS total_return_amt,
round((sum(return_quantity) / NULLIF(sum(inbound_quantity),0) * 100)::numeric,2) AS return_rate
FROM central_kitchen_finished_receipt
WHERE receipt_date >= $1::date AND receipt_date < ($1::date + INTERVAL '1 month')
GROUP BY recipe_name, product_name, specification, unit
ORDER BY avg_expected_var_rate ASC
LIMIT 20
`, [month]),
])
const s = summary.rows[0] as any
const r = reconciliation.rows[0] as any
const parseNum = (v: any) => v ? parseFloat(v) : 0
const parseIntSafe = (v: any) => v ? parseInt(v) : 0
sendSuccess(res, {
summary: {
product_count: parseIntSafe(s?.product_count),
total_inbound_qty: parseNum(s?.total_inbound_qty),
theoretical_cost: parseNum(s?.theoretical_cost),
standard_cost: parseNum(s?.standard_cost),
material_actual_cost: parseNum(s?.material_actual_cost),
manufacturing_cost_pool: parseNum(s?.manufacturing_cost_pool),
allocated_manufacturing_cost: parseNum(s?.allocated_manufacturing_cost),
full_manufacturing_cost: parseNum(s?.full_manufacturing_cost),
avg_unit_cost: parseNum(s?.avg_unit_cost),
efficiency_variance: parseNum(s?.efficiency_variance),
efficiency_variance_pct: parseNum(s?.efficiency_variance_pct),
standard_variance_pct: parseNum(s?.standard_variance_pct),
mfg_cost_rate: parseNum(s?.mfg_cost_rate),
},
reconciliation: {
product_lines: parseIntSafe(r?.product_lines),
theoretical_cost: parseNum(r?.theoretical_cost),
standard_cost: parseNum(r?.standard_cost),
material_actual_cost: parseNum(r?.material_actual_cost),
manufacturing_cost_pool: parseNum(r?.manufacturing_cost_pool),
allocated_manufacturing_cost: parseNum(r?.allocated_manufacturing_cost),
full_manufacturing_cost: parseNum(r?.full_manufacturing_cost),
calculated_inbound_value: parseNum(r?.calculated_inbound_value),
manufacturing_margin: parseNum(r?.manufacturing_margin),
},
products: products.rows.map((row: any) => ({
...row,
inbound_quantity: parseNum(row.inbound_quantity),
theoretical_cost: parseNum(row.theoretical_cost),
standard_cost: parseNum(row.standard_cost),
material_actual_cost: parseNum(row.material_actual_cost),
allocated_manufacturing_cost: parseNum(row.allocated_manufacturing_cost),
full_manufacturing_cost: parseNum(row.full_manufacturing_cost),
full_unit_cost: parseNum(row.full_unit_cost),
efficiency_variance_pct: parseNum(row.efficiency_variance_pct),
standard_variance_pct: parseNum(row.standard_variance_pct),
mfg_allocation_pct: parseNum(row.mfg_allocation_pct),
inbound_value: parseNum(row.inbound_value),
product_margin: parseNum(row.product_margin),
})),
categoryCost: categoryCost.rows.map((row: any) => ({
...row,
total_qty: parseNum(row.total_qty),
theoretical_cost: parseNum(row.theoretical_cost),
material_actual_cost: parseNum(row.material_actual_cost),
allocated_mfg_cost: parseNum(row.allocated_mfg_cost),
full_cost: parseNum(row.full_cost),
avg_unit_cost: parseNum(row.avg_unit_cost),
efficiency_var_pct: parseNum(row.efficiency_var_pct),
product_count: parseIntSafe(row.product_count),
})),
recipeEfficiency: recipeEfficiency.rows.map((row: any) => ({
...row,
theoretical_qty: parseNum(row.theoretical_qty),
actual_qty: parseNum(row.actual_qty),
issue_qty: parseNum(row.issue_qty),
theoretical_amt: parseNum(row.theoretical_amt),
actual_amt: parseNum(row.actual_amt),
variance_pct: parseNum(row.variance_pct),
avg_actual_yield: parseNum(row.avg_actual_yield),
avg_recipe_yield: parseNum(row.avg_recipe_yield),
yield_diff: parseNum(row.yield_diff),
})),
mfgPool: mfgPool.rows.map((row: any) => ({
...row,
source_amount: parseNum(row.source_amount),
share_pct: parseNum(row.share_pct),
allocated_amount: parseNum(row.allocated_amount),
is_provisional: row.is_provisional,
include_in_rebuilt_cost: row.include_in_rebuilt_cost,
})),
yieldAnalysis: yieldAnalysis.rows.map((row: any) => ({
...row,
theoretical_inbound_qty: parseNum(row.theoretical_inbound_qty),
actual_inbound_qty: parseNum(row.actual_inbound_qty),
avg_achievement_rate: parseNum(row.avg_achievement_rate),
avg_expected_var_rate: parseNum(row.avg_expected_var_rate),
total_inbound_qty: parseNum(row.total_inbound_qty),
total_return_qty: parseNum(row.total_return_qty),
total_inbound_amt: parseNum(row.total_inbound_amt),
total_return_amt: parseNum(row.total_return_amt),
return_rate: parseNum(row.return_rate),
})),
})
} catch (err: any) {
sendError(res, err.message)
}
})
// 配送—倒挤成本对账
router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
// 检查配送物化视图是否包含当前月份数据
const distMvCheck = await query(`SELECT 1 FROM mv_distribution_monthly WHERE report_month = $1::date LIMIT 1`, [month])
if (distMvCheck.rows.length === 0) {
await query(`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_distribution_monthly`)
}
const [summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation] = await Promise.all([
// 汇总
query(`
WITH dist AS (
SELECT d.store_code, d.item_code,
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax
FROM mv_distribution_monthly d
WHERE d.report_month = $1::date
GROUP BY d.store_code, d.item_code
),
inv AS (
SELECT f.store_code, f.material_code,
round(sum(f.opening_quantity)::numeric,2) AS opening_qty,
round(sum(f.opening_amount)::numeric,2) AS opening_amt,
round(sum(f.consumption_quantity)::numeric,2) AS consumption_qty,
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
round(sum(f.ending_quantity)::numeric,2) AS ending_qty,
round(sum(f.ending_amount)::numeric,2) AS ending_amt
FROM analytics.fact_inventory_snapshot f
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
GROUP BY f.store_code, f.material_code
),
recon AS (
SELECT COALESCE(d.store_code, i.store_code) AS store_code,
COALESCE(d.item_code, i.material_code) AS item_code,
COALESCE(d.dist_qty,0) AS dist_qty,
COALESCE(d.dist_amt,0) AS dist_amt,
COALESCE(d.dist_cost_excl_tax,0) AS dist_cost_excl_tax,
COALESCE(i.opening_qty,0) AS opening_qty,
COALESCE(i.opening_amt,0) AS opening_amt,
COALESCE(i.consumption_qty,0) AS consumption_qty,
COALESCE(i.consumption_amt,0) AS consumption_amt,
COALESCE(i.ending_qty,0) AS ending_qty,
COALESCE(i.ending_amt,0) AS ending_amt
FROM dist d FULL OUTER JOIN inv i ON d.store_code = i.store_code AND d.item_code = i.material_code
)
SELECT
count(*) AS total_lines,
count(*) FILTER(WHERE dist_qty > 0 AND consumption_qty > 0) AS matched_lines,
count(*) FILTER(WHERE dist_qty > 0 AND consumption_qty = 0) AS dist_only_lines,
count(*) FILTER(WHERE dist_qty = 0 AND consumption_qty > 0) AS inv_only_lines,
round(sum(dist_amt)::numeric,2) AS total_dist_amt,
round(sum(dist_cost_excl_tax)::numeric,2) AS total_dist_cost_excl_tax,
round(sum(opening_amt)::numeric,2) AS total_opening_amt,
round(sum(consumption_amt)::numeric,2) AS total_consumption_amt,
round(sum(ending_amt)::numeric,2) AS total_ending_amt,
round((sum(opening_amt) + sum(dist_amt) - sum(ending_amt))::numeric,2) AS reverse_consumption_amt,
round((sum(consumption_amt) - (sum(opening_amt) + sum(dist_amt) - sum(ending_amt)))::numeric,2) AS variance_amt,
round((sum(consumption_amt) - (sum(opening_amt) + sum(dist_amt) - sum(ending_amt))) / NULLIF(sum(consumption_amt),0) * 100::numeric,2) AS variance_pct
FROM recon
`, [month]),
// 门店维度对账
query(`
WITH dist AS (
SELECT d.store_code,
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax
FROM mv_distribution_monthly d
WHERE d.report_month = $1::date
GROUP BY d.store_code
),
inv AS (
SELECT f.store_code,
round(sum(f.opening_amount)::numeric,2) AS opening_amt,
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
round(sum(f.ending_amount)::numeric,2) AS ending_amt,
count(*) FILTER(WHERE f.is_negative) AS neg_inventory_count
FROM analytics.fact_inventory_snapshot f
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
GROUP BY f.store_code
),
store_names AS (
SELECT DISTINCT store_code, store_name FROM mv_distribution_monthly
WHERE report_month = $1::date
)
SELECT COALESCE(d.store_code, i.store_code) AS store_code,
sn.store_name,
COALESCE(d.dist_qty,0) AS dist_qty,
COALESCE(d.dist_amt,0) AS dist_amt,
COALESCE(d.dist_cost_excl_tax,0) AS dist_cost_excl_tax,
COALESCE(i.opening_amt,0) AS opening_amt,
COALESCE(i.consumption_amt,0) AS consumption_amt,
COALESCE(i.ending_amt,0) AS ending_amt,
round((COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0))::numeric,2) AS reverse_consumption_amt,
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0)))::numeric,2) AS variance_amt,
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0))) / NULLIF(COALESCE(i.consumption_amt,0),0) * 100::numeric,2) AS variance_pct,
COALESCE(i.neg_inventory_count,0) AS neg_inventory_count
FROM dist d FULL OUTER JOIN inv i ON d.store_code = i.store_code
LEFT JOIN store_names sn ON COALESCE(d.store_code, i.store_code) = sn.store_code
ORDER BY abs(COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0))) DESC
`, [month]),
// Top差异品项
query(`
WITH dist AS (
SELECT d.store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt
FROM mv_distribution_monthly d
WHERE d.report_month = $1::date
GROUP BY d.store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category
),
inv AS (
SELECT f.store_code, f.material_code,
round(sum(f.opening_amount)::numeric,2) AS opening_amt,
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
round(sum(f.ending_amount)::numeric,2) AS ending_amt
FROM analytics.fact_inventory_snapshot f
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
GROUP BY f.store_code, f.material_code
)
SELECT d.store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
d.dist_qty, d.dist_amt,
COALESCE(i.opening_amt,0) AS opening_amt,
COALESCE(i.consumption_amt,0) AS consumption_amt,
COALESCE(i.ending_amt,0) AS ending_amt,
round((COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0))::numeric,2) AS reverse_consumption_amt,
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0)))::numeric,2) AS variance_amt,
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0))) / NULLIF(COALESCE(i.consumption_amt,0),0) * 100::numeric,2) AS variance_pct
FROM dist d LEFT JOIN inv i ON d.store_code = i.store_code AND d.item_code = i.material_code
WHERE COALESCE(i.consumption_amt,0) > 0
ORDER BY abs(COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0))) DESC
LIMIT 30
`, [month]),
// 未匹配品项(有配送无库存耗用)
query(`
WITH dist_items AS (
SELECT DISTINCT d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
count(DISTINCT d.store_code) AS store_count
FROM mv_distribution_monthly d
WHERE d.report_month = $1::date
GROUP BY d.item_code, d.item_name, d.unit, d.major_category, d.minor_category
),
inv_items AS (
SELECT DISTINCT material_code FROM analytics.fact_inventory_snapshot
WHERE snapshot_date >= $1::date AND snapshot_date < ($1::date + INTERVAL '1 month')
)
SELECT d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
d.dist_qty, d.dist_amt, d.store_count
FROM dist_items d
WHERE d.item_code NOT IN (SELECT material_code FROM inv_items)
ORDER BY d.dist_amt DESC
LIMIT 20
`, [month]),
// 品类维度对账
query(`
WITH dist AS (
SELECT COALESCE(NULLIF(d.minor_category,''),'未分类') AS minor_category,
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax,
count(DISTINCT d.item_code) AS item_count
FROM mv_distribution_monthly d
WHERE d.report_month = $1::date
GROUP BY COALESCE(NULLIF(d.minor_category,''),'未分类')
),
item_cat AS (
SELECT DISTINCT item_code, COALESCE(NULLIF(minor_category,''),'未分类') AS minor_category
FROM mv_distribution_monthly
WHERE report_month = $1::date
),
inv AS (
SELECT ic.minor_category,
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
round(sum(f.ending_amount)::numeric,2) AS ending_amt
FROM analytics.fact_inventory_snapshot f
JOIN item_cat ic ON f.material_code = ic.item_code
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
GROUP BY ic.minor_category
)
SELECT COALESCE(d.minor_category, i.minor_category) AS minor_category,
COALESCE(d.item_count,0) AS item_count,
COALESCE(d.dist_qty,0) AS dist_qty,
COALESCE(d.dist_amt,0) AS dist_amt,
COALESCE(d.dist_cost_excl_tax,0) AS dist_cost_excl_tax,
COALESCE(i.consumption_amt,0) AS consumption_amt,
COALESCE(i.ending_amt,0) AS ending_amt,
round((COALESCE(i.consumption_amt,0) - COALESCE(d.dist_amt,0) + COALESCE(i.ending_amt,0))::numeric,2) AS reverse_opening_amt,
round((COALESCE(i.consumption_amt,0) - COALESCE(d.dist_amt,0))::numeric,2) AS variance_amt,
round((COALESCE(i.consumption_amt,0) - COALESCE(d.dist_amt,0)) / NULLIF(COALESCE(d.dist_amt,0),0) * 100::numeric,2) AS variance_pct
FROM dist d FULL OUTER JOIN inv i ON d.minor_category = i.minor_category
ORDER BY COALESCE(d.dist_amt,0) DESC
`, [month]),
])
const s = summary.rows[0] as any
const parseNum = (v: any) => v ? parseFloat(v) : 0
const parseIntSafe = (v: any) => v ? parseInt(v) : 0
sendSuccess(res, {
summary: {
total_lines: parseIntSafe(s?.total_lines),
matched_lines: parseIntSafe(s?.matched_lines),
dist_only_lines: parseIntSafe(s?.dist_only_lines),
inv_only_lines: parseIntSafe(s?.inv_only_lines),
total_dist_amt: parseNum(s?.total_dist_amt),
total_dist_cost_excl_tax: parseNum(s?.total_dist_cost_excl_tax),
total_opening_amt: parseNum(s?.total_opening_amt),
total_consumption_amt: parseNum(s?.total_consumption_amt),
total_ending_amt: parseNum(s?.total_ending_amt),
reverse_consumption_amt: parseNum(s?.reverse_consumption_amt),
variance_amt: parseNum(s?.variance_amt),
variance_pct: parseNum(s?.variance_pct),
},
storeReconciliation: storeReconciliation.rows.map((row: any) => ({
...row,
dist_qty: parseNum(row.dist_qty),
dist_amt: parseNum(row.dist_amt),
dist_cost_excl_tax: parseNum(row.dist_cost_excl_tax),
opening_amt: parseNum(row.opening_amt),
consumption_amt: parseNum(row.consumption_amt),
ending_amt: parseNum(row.ending_amt),
reverse_consumption_amt: parseNum(row.reverse_consumption_amt),
variance_amt: parseNum(row.variance_amt),
variance_pct: parseNum(row.variance_pct),
neg_inventory_count: parseIntSafe(row.neg_inventory_count),
})),
topVariances: topVariances.rows.map((row: any) => ({
...row,
dist_qty: parseNum(row.dist_qty),
dist_amt: parseNum(row.dist_amt),
opening_amt: parseNum(row.opening_amt),
consumption_amt: parseNum(row.consumption_amt),
ending_amt: parseNum(row.ending_amt),
reverse_consumption_amt: parseNum(row.reverse_consumption_amt),
variance_amt: parseNum(row.variance_amt),
variance_pct: parseNum(row.variance_pct),
})),
unmatchedItems: unmatchedItems.rows.map((row: any) => ({
...row,
dist_qty: parseNum(row.dist_qty),
dist_amt: parseNum(row.dist_amt),
store_count: parseIntSafe(row.store_count),
})),
categoryReconciliation: categoryReconciliation.rows.map((row: any) => ({
...row,
item_count: parseIntSafe(row.item_count),
dist_qty: parseNum(row.dist_qty),
dist_amt: parseNum(row.dist_amt),
dist_cost_excl_tax: parseNum(row.dist_cost_excl_tax),
consumption_amt: parseNum(row.consumption_amt),
ending_amt: parseNum(row.ending_amt),
reverse_opening_amt: parseNum(row.reverse_opening_amt),
variance_amt: parseNum(row.variance_amt),
variance_pct: parseNum(row.variance_pct),
})),
})
} catch (err: any) {
sendError(res, err.message)
}
})
// 多级BOM成本穿透
router.get('/central-kitchen/bom-penetration', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const productCode = (req.query.productCode as string) || ''
const [productList, bomTree, bomSummary, multiLevelChains] = await Promise.all([
// 产品列表(含BOM层级数)
query(`
WITH bom_materials AS (
SELECT DISTINCT rc.recipe_name, rc.item_code AS material_code
FROM central_kitchen_recipe_consumption rc
WHERE rc.business_date >= $1::date AND rc.business_date < ($1::date + INTERVAL '1 month')
),
finished_products AS (
SELECT product_code, product_name FROM central_kitchen_processing_cost
WHERE report_month = $1::date
),
multi_level AS (
SELECT bm.material_code
FROM bom_materials bm
JOIN finished_products fp ON bm.material_code = fp.product_code
)
SELECT pc.product_code,
pc.product_name,
pc.category_minor,
pc.unit,
round(pc.inbound_quantity::numeric,2) AS inbound_quantity,
round(pc.theoretical_cost::numeric,2) AS theoretical_cost,
round(pc.actual_cost::numeric,2) AS actual_cost,
round(pc.standard_cost::numeric,2) AS standard_cost,
CASE WHEN pc.product_code IN (SELECT material_code FROM multi_level) THEN true ELSE false END AS has_multi_level_bom,
(SELECT count(DISTINCT rc.item_code) FROM central_kitchen_recipe_consumption rc
WHERE rc.business_date >= $1::date AND rc.business_date < ($1::date + INTERVAL '1 month')
AND rc.recipe_name = pc.recipe_name) AS material_count
FROM central_kitchen_processing_cost pc
WHERE pc.report_month = $1::date
ORDER BY pc.actual_cost DESC
`, [month]),
// BOM树:展开选中产品的配方
query(`
WITH RECURSIVE
recipe_agg AS (
SELECT rc.recipe_name, rc.item_code AS material_code, rc.item_name AS material_name,
rc.unit,
round(sum(rc.theoretical_quantity)::numeric,4) AS theoretical_qty,
round(sum(rc.issue_quantity)::numeric,4) AS issue_qty,
round(sum(rc.theoretical_amount)::numeric,2) AS theoretical_amt,
round(sum(rc.issue_amount)::numeric,2) AS issue_amt,
round(avg(rc.unit_price_excl_tax)::numeric,4) AS avg_unit_price
FROM central_kitchen_recipe_consumption rc
WHERE rc.business_date >= $1::date AND rc.business_date < ($1::date + INTERVAL '1 month')
GROUP BY rc.recipe_name, rc.item_code, rc.item_name, rc.unit
),
bom_tree AS (
-- Level 1: 直接原材料
SELECT
pc.product_code AS root_product_code,
pc.product_name AS root_product_name,
pc.recipe_name AS root_recipe,
1 AS level,
ra.material_code,
ra.material_name,
ra.unit,
ra.theoretical_qty,
ra.issue_qty,
ra.theoretical_amt,
ra.issue_amt,
ra.avg_unit_price,
CASE WHEN pc2.product_code IS NOT NULL THEN true ELSE false END AS is_finished_product,
ra.material_code AS path,
'' AS parent_material_code
FROM central_kitchen_processing_cost pc
JOIN recipe_agg ra ON pc.recipe_name = ra.recipe_name
LEFT JOIN central_kitchen_processing_cost pc2 ON ra.material_code = pc2.product_code AND pc2.report_month = $1::date
WHERE pc.report_month = $1::date
${productCode ? 'AND pc.product_code = $2' : ''}
UNION ALL
-- Level 2+: 递归展开半成品
SELECT
bt.root_product_code,
bt.root_product_name,
bt.root_recipe,
bt.level + 1,
ra.material_code,
ra.material_name,
ra.unit,
ra.theoretical_qty,
ra.issue_qty,
ra.theoretical_amt,
ra.issue_amt,
ra.avg_unit_price,
CASE WHEN pc2.product_code IS NOT NULL THEN true ELSE false END AS is_finished_product,
bt.path || ' -> ' || ra.material_code,
bt.material_code AS parent_material_code
FROM bom_tree bt
JOIN central_kitchen_processing_cost pc ON bt.material_code = pc.product_code AND pc.report_month = $1::date
JOIN recipe_agg ra ON pc.recipe_name = ra.recipe_name
LEFT JOIN central_kitchen_processing_cost pc2 ON ra.material_code = pc2.product_code AND pc2.report_month = $1::date
WHERE bt.is_finished_product AND bt.level < 5
${productCode ? '' : 'AND bt.level < 1'}
)
SELECT * FROM bom_tree
ORDER BY root_product_code, level, theoretical_amt DESC
${productCode ? '' : 'LIMIT 200'}
`, productCode ? [month, productCode] : [month]),
// BOM汇总:每个产品的BOM成本结构
query(`
WITH bom AS (
SELECT pc.product_code, pc.product_name,
round(sum(rc.theoretical_amount)::numeric,2) AS bom_theoretical_amt,
round(sum(rc.issue_amount)::numeric,2) AS bom_issue_amt,
count(DISTINCT rc.item_code) AS material_count,
count(DISTINCT rc.item_code) FILTER(WHERE pc2.product_code IS NOT NULL) AS sub_product_count,
round(sum(rc.theoretical_amount) / NULLIF(pc.inbound_quantity, 0)::numeric,4) AS bom_unit_theoretical_cost,
round(sum(rc.issue_amount) / NULLIF(pc.inbound_quantity, 0)::numeric,4) AS bom_unit_issue_cost
FROM central_kitchen_processing_cost pc
JOIN central_kitchen_recipe_consumption rc ON pc.recipe_name = rc.recipe_name
AND rc.business_date >= $1::date AND rc.business_date < ($1::date + INTERVAL '1 month')
LEFT JOIN central_kitchen_processing_cost pc2 ON rc.item_code = pc2.product_code AND pc2.report_month = $1::date
WHERE pc.report_month = $1::date
GROUP BY pc.product_code, pc.product_name, pc.inbound_quantity
)
SELECT b.*,
round((b.bom_issue_amt - b.bom_theoretical_amt)::numeric,2) AS variance_amt,
round((b.bom_issue_amt - b.bom_theoretical_amt) / NULLIF(b.bom_theoretical_amt, 0) * 100::numeric,2) AS variance_pct
FROM bom b
ORDER BY b.bom_theoretical_amt DESC
`, [month]),
// 多级BOM链:识别半成品依赖链
query(`
WITH bom_materials AS (
SELECT DISTINCT rc.recipe_name, rc.item_code AS material_code, rc.item_name AS material_name
FROM central_kitchen_recipe_consumption rc
WHERE rc.business_date >= $1::date AND rc.business_date < ($1::date + INTERVAL '1 month')
),
finished_products AS (
SELECT product_code, product_name, recipe_name FROM central_kitchen_processing_cost
WHERE report_month = $1::date
)
SELECT fp.product_code AS finished_code,
fp.product_name AS finished_name,
fp.recipe_name AS finished_recipe,
bm.material_code AS sub_product_code,
bm.material_name AS sub_product_name,
(SELECT fp2.product_name FROM finished_products fp2 WHERE fp2.product_code = bm.material_code) AS sub_product_finished_name,
(SELECT fp2.recipe_name FROM finished_products fp2 WHERE fp2.product_code = bm.material_code) AS sub_product_recipe
FROM finished_products fp
JOIN bom_materials bm ON fp.recipe_name = bm.recipe_name
WHERE bm.material_code IN (SELECT product_code FROM finished_products)
ORDER BY fp.product_name
`, [month]),
])
const parseNum = (v: any) => v ? parseFloat(v) : 0
const parseIntSafe = (v: any) => v ? parseInt(v) : 0
sendSuccess(res, {
productList: productList.rows.map((row: any) => ({
...row,
inbound_quantity: parseNum(row.inbound_quantity),
theoretical_cost: parseNum(row.theoretical_cost),
actual_cost: parseNum(row.actual_cost),
standard_cost: parseNum(row.standard_cost),
material_count: parseIntSafe(row.material_count),
has_multi_level_bom: row.has_multi_level_bom,
})),
bomTree: bomTree.rows.map((row: any) => ({
...row,
level: parseIntSafe(row.level),
theoretical_qty: parseNum(row.theoretical_qty),
issue_qty: parseNum(row.issue_qty),
theoretical_amt: parseNum(row.theoretical_amt),
issue_amt: parseNum(row.issue_amt),
avg_unit_price: parseNum(row.avg_unit_price),
is_finished_product: row.is_finished_product,
})),
bomSummary: bomSummary.rows.map((row: any) => ({
...row,
bom_theoretical_amt: parseNum(row.bom_theoretical_amt),
bom_issue_amt: parseNum(row.bom_issue_amt),
material_count: parseIntSafe(row.material_count),
sub_product_count: parseIntSafe(row.sub_product_count),
bom_unit_theoretical_cost: parseNum(row.bom_unit_theoretical_cost),
bom_unit_issue_cost: parseNum(row.bom_unit_issue_cost),
variance_amt: parseNum(row.variance_amt),
variance_pct: parseNum(row.variance_pct),
})),
multiLevelChains: multiLevelChains.rows.map((row: any) => ({
...row,
})),
})
} catch (err: any) {
sendError(res, err.message)
}
})
// 销量驱动生产与要货计划
router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const storeCode = (req.query.storeCode as string) || ''
// 使用单一连接确保临时表在所有查询中可见
const client = await pool.connect()
try {
// 检查物化视图是否包含当前月份数据,如缺失则刷新
const mvCheck = await client.query(`
SELECT 1 FROM mv_dish_sales_monthly WHERE month = $1::date LIMIT 1
`, [month])
if (mvCheck.rows.length === 0) {
// 物化视图中没有该月数据,刷新物化视图
await client.query(`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dish_sales_monthly`)
}
// 检查配送物化视图是否包含当前月份数据
const distMvCheck = await client.query(`
SELECT 1 FROM mv_distribution_monthly WHERE report_month = $1::date LIMIT 1
`, [month])
if (distMvCheck.rows.length === 0) {
await client.query(`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_distribution_monthly`)
}
// 从物化视图创建临时表(索引扫描,毫秒级)
await client.query(`
CREATE TEMP TABLE IF NOT EXISTS tmp_sales_agg AS
SELECT store_code, store_name, sku_code, dish_name, qty, amt, avg_unit_price
FROM mv_dish_sales_monthly
WHERE month = $1::date
${storeCode ? 'AND store_code = $2' : ''}
`, storeCode ? [month, storeCode] : [month])
await client.query(`CREATE INDEX IF NOT EXISTS idx_tmp_sales_sku ON tmp_sales_agg(sku_code)`)
await client.query(`CREATE INDEX IF NOT EXISTS idx_tmp_sales_store ON tmp_sales_agg(store_code)`)
// 总销量(全部从临时表获取,无需再扫描原表)
const totalSales = await client.query(`
SELECT
count(DISTINCT dish_name) AS total_dish_count,
round(sum(amt)::numeric,2) AS total_sales_amt
FROM tmp_sales_agg
`)
const [summaryResult, skuSalesResult, materialDemandResult, storeDemandResult, ckProductionPlanResult, productionCoordResult] = await Promise.all([
// 汇总(使用临时表)
client.query(`
WITH bom_skus AS (
SELECT DISTINCT sku_code FROM analytics.fact_recipe_bom
)
SELECT
count(*) AS total_sales_lines,
count(DISTINCT s.dish_name) AS matched_sku_count,
round(sum(s.qty)::numeric,2) AS matched_qty,
round(sum(s.amt)::numeric,2) AS matched_amt,
(SELECT count(DISTINCT material_code) FROM analytics.fact_recipe_bom) AS total_bom_materials
FROM tmp_sales_agg s
JOIN bom_skus bs ON s.sku_code = bs.sku_code
`),
// SKU销量明细(使用临时表)
client.query(`
WITH sales AS (
SELECT dish_name,
round(sum(qty)::numeric,2) AS qty,
round(sum(amt)::numeric,2) AS amt,
count(DISTINCT store_code) AS store_count,
round(avg(avg_unit_price)::numeric,2) AS avg_unit_price
FROM tmp_sales_agg
GROUP BY dish_name
)
SELECT s.dish_name,
sk.sku_code,
s.qty,
s.amt,
s.store_count,
s.avg_unit_price,
CASE WHEN b.sku_code IS NOT NULL THEN true ELSE false END AS has_bom,
COALESCE(b.material_count, 0) AS material_count
FROM sales s
LEFT JOIN analytics.dim_sku sk ON s.dish_name = sk.standard_name
LEFT JOIN (
SELECT sku_code, count(DISTINCT material_code) AS material_count
FROM analytics.fact_recipe_bom
GROUP BY sku_code
) b ON sk.sku_code = b.sku_code
ORDER BY s.amt DESC
LIMIT 50
`),
// 原料需求(使用临时表 × BOM展开,价格通过名称匹配配送记录)
client.query(`
WITH demand AS (
SELECT s.store_code, b.material_code,
m.material_name,
b.unit,
round(sum(s.qty * b.standard_gross_quantity)::numeric,4) AS demand_qty,
round(sum(s.qty * b.standard_gross_quantity * COALESCE(ic.unit_price, 0))::numeric,2) AS demand_amt
FROM tmp_sales_agg s
JOIN analytics.fact_recipe_bom b ON s.sku_code = b.sku_code
LEFT JOIN analytics.dim_material m ON b.material_code = m.material_code
LEFT JOIN (
SELECT d.item_name, round(sum(d.outbound_total_amount)::numeric / NULLIF(sum(d.total_quantity), 0), 4) AS unit_price
FROM mv_distribution_monthly d
WHERE d.report_month = $1::date
GROUP BY d.item_name
) ic ON m.material_name = ic.item_name
GROUP BY s.store_code, b.material_code, m.material_name, b.unit
)
SELECT
material_code,
material_name,
unit,
round(sum(demand_qty)::numeric,4) AS total_demand_qty,
round(sum(demand_amt)::numeric,2) AS total_demand_amt,
count(DISTINCT store_code) AS store_count,
round(avg(demand_qty)::numeric,4) AS avg_store_demand
FROM demand
GROUP BY material_code, material_name, unit
ORDER BY total_demand_amt DESC NULLS LAST
LIMIT 100
`, [month]),
// 门店维度需求(使用临时表)
client.query(`
WITH store_demand AS (
SELECT s.store_code, s.store_name,
count(DISTINCT s.sku_code) AS sku_count,
round(sum(s.qty)::numeric,2) AS total_qty,
round(sum(s.amt)::numeric,2) AS total_amt,
round(sum(s.qty * b.standard_gross_quantity)::numeric,4) AS total_material_demand_qty
FROM tmp_sales_agg s
LEFT JOIN analytics.fact_recipe_bom b ON s.sku_code = b.sku_code
GROUP BY s.store_code, s.store_name
)
SELECT sd.store_code, sd.store_name,
sd.sku_count,
sd.total_qty,
sd.total_amt,
round(sd.total_material_demand_qty::numeric,4) AS total_material_demand_qty,
COALESCE(inv.ending_qty, 0) AS ending_inventory_qty,
COALESCE(inv.ending_amt, 0) AS ending_inventory_amt,
round((sd.total_material_demand_qty - COALESCE(inv.ending_qty, 0))::numeric,4) AS suggested_order_qty
FROM store_demand sd
LEFT JOIN (
SELECT store_code,
round(sum(ending_quantity)::numeric,4) AS ending_qty,
round(sum(ending_amount)::numeric,2) AS ending_amt
FROM analytics.fact_inventory_snapshot
WHERE snapshot_date >= $1::date AND snapshot_date < ($1::date + INTERVAL '1 month')
GROUP BY store_code
) inv ON sd.store_code = inv.store_code
ORDER BY sd.total_amt DESC
`, [month]),
// 中央厨房生产计划(使用临时表,通过名称匹配实际入库数据)
client.query(`
WITH ck_demand AS (
SELECT b.material_code AS ck_product_code,
m.material_name AS ck_product_name,
b.unit,
round(sum(s.qty * b.standard_gross_quantity)::numeric,4) AS demand_qty,
count(DISTINCT s.store_code) AS store_count
FROM tmp_sales_agg s
JOIN analytics.fact_recipe_bom b ON s.sku_code = b.sku_code
JOIN analytics.dim_material m ON b.material_code = m.material_code
WHERE m.material_name IN (
SELECT product_name FROM central_kitchen_processing_cost WHERE report_month = $1::date
)
GROUP BY b.material_code, m.material_name, b.unit
),
ck_actual AS (
SELECT product_code, product_name,
round(sum(inbound_quantity)::numeric,4) AS actual_inbound_qty,
round(sum(actual_cost)::numeric,2) AS actual_cost
FROM central_kitchen_processing_cost
WHERE report_month = $1::date
GROUP BY product_code, product_name
),
ck_combined AS (
SELECT d.ck_product_code AS product_code,
d.ck_product_name AS product_name,
COALESCE(d.unit, '') AS unit,
d.demand_qty,
d.store_count,
COALESCE(a.actual_inbound_qty, 0) AS actual_inbound_qty,
COALESCE(a.actual_cost, 0) AS actual_cost
FROM ck_demand d
LEFT JOIN ck_actual a ON d.ck_product_name = a.product_name
UNION ALL
SELECT a.product_code, a.product_name, '',
0, 0, a.actual_inbound_qty, a.actual_cost
FROM ck_actual a
WHERE a.product_name NOT IN (SELECT ck_product_name FROM ck_demand)
)
SELECT product_code, product_name, unit,
demand_qty, store_count, actual_inbound_qty, actual_cost,
round((demand_qty - actual_inbound_qty)::numeric,4) AS variance_qty,
CASE WHEN actual_inbound_qty > 0
THEN round((demand_qty - actual_inbound_qty) / actual_inbound_qty * 100::numeric, 2)
ELSE NULL END AS variance_pct
FROM ck_combined
ORDER BY demand_qty DESC
`, [month]),
// 生产与配送协同
client.query(`
WITH ck_production AS (
SELECT pc.product_code, pc.product_name,
round(sum(pc.inbound_quantity)::numeric,4) AS inbound_qty,
round(sum(pc.actual_cost)::numeric,2) AS actual_cost,
round(sum(pc.theoretical_cost)::numeric,2) AS theoretical_cost
FROM central_kitchen_processing_cost pc
WHERE pc.report_month = $1::date
GROUP BY pc.product_code, pc.product_name
),
ck_distribution AS (
SELECT d.item_code, d.item_name,
round(sum(d.total_quantity)::numeric,4) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt
FROM mv_distribution_monthly d
WHERE d.report_month = $1::date
AND d.distribution_center_code = '2'
GROUP BY d.item_code, d.item_name
),
ck_inventory AS (
SELECT f.material_code,
round(sum(f.consumption_quantity)::numeric,4) AS consumption_qty,
round(sum(f.ending_quantity)::numeric,4) AS ending_qty
FROM analytics.fact_inventory_snapshot f
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
AND f.store_code = '2'
GROUP BY f.material_code
),
other_wh_inventory AS (
SELECT f.material_code,
round(sum(f.consumption_quantity)::numeric,4) AS consumption_qty,
round(sum(f.ending_quantity)::numeric,4) AS ending_qty
FROM analytics.fact_inventory_snapshot f
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
AND f.store_code IN (
SELECT cost_unit_code FROM inventory_store_mapping
WHERE sales_store_code IS NULL OR sales_store_code = ''
)
AND f.store_code != '2'
GROUP BY f.material_code
),
store_consumption AS (
SELECT f.material_code,
round(sum(f.consumption_quantity)::numeric,4) AS consumption_qty,
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
round(sum(f.ending_quantity)::numeric,4) AS ending_qty,
round(sum(f.ending_amount)::numeric,2) AS ending_amt
FROM analytics.fact_inventory_snapshot f
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
AND f.store_code NOT IN (
SELECT cost_unit_code FROM inventory_store_mapping
WHERE sales_store_code IS NULL OR sales_store_code = ''
)
GROUP BY f.material_code
)
SELECT COALESCE(p.product_code, dist.item_code) AS product_code,
COALESCE(p.product_name, dist.item_name) AS product_name,
COALESCE(p.inbound_qty, 0) AS inbound_qty,
COALESCE(p.actual_cost, 0) AS actual_cost,
COALESCE(p.theoretical_cost, 0) AS theoretical_cost,
COALESCE(dist.dist_qty, 0) AS dist_qty,
COALESCE(dist.dist_amt, 0) AS dist_amt,
COALESCE(ck.consumption_qty, 0) AS ck_consumption_qty,
COALESCE(ck.ending_qty, 0) AS ck_ending_qty,
COALESCE(owh.consumption_qty, 0) AS other_wh_consumption_qty,
COALESCE(owh.ending_qty, 0) AS other_wh_ending_qty,
COALESCE(sc.consumption_qty, 0) AS consumption_qty,
COALESCE(sc.consumption_amt, 0) AS consumption_amt,
COALESCE(sc.ending_qty, 0) AS ending_qty,
COALESCE(sc.ending_amt, 0) AS ending_amt,
round((COALESCE(dist.dist_qty, 0) - COALESCE(p.inbound_qty, 0))::numeric,4) AS production_distribution_gap,
round((COALESCE(sc.consumption_qty, 0) - COALESCE(dist.dist_qty, 0))::numeric,4) AS distribution_consumption_gap,
CASE WHEN COALESCE(p.inbound_qty, 0) > 0
THEN round(COALESCE(dist.dist_qty, 0) / COALESCE(p.inbound_qty, 0) * 100::numeric, 2)
ELSE NULL END AS completion_distribution_rate,
CASE WHEN COALESCE(dist.dist_qty, 0) > 0
THEN round(COALESCE(sc.consumption_qty, 0) / COALESCE(dist.dist_qty, 0) * 100::numeric, 2)
ELSE NULL END AS distribution_consumption_rate,
CASE WHEN COALESCE(sc.consumption_qty, 0) > 0
THEN round(COALESCE(sc.ending_qty, 0) / COALESCE(sc.consumption_qty, 0) * 100::numeric, 2)
ELSE NULL END AS inventory_accumulation_rate
FROM ck_production p
FULL OUTER JOIN ck_distribution dist ON p.product_code = dist.item_code
FULL OUTER JOIN ck_inventory ck ON COALESCE(p.product_code, dist.item_code) = ck.material_code
FULL OUTER JOIN other_wh_inventory owh ON COALESCE(p.product_code, dist.item_code) = owh.material_code
FULL OUTER JOIN store_consumption sc ON COALESCE(p.product_code, dist.item_code) = sc.material_code
ORDER BY COALESCE(p.actual_cost, 0) DESC
LIMIT 50
`, [month]),
])
const parseNum = (v: any) => v ? parseFloat(v) : 0
const parseIntSafe = (v: any) => v ? parseInt(v) : 0
const s = summaryResult.rows[0] as any
const ts = totalSales.rows[0] as any
sendSuccess(res, {
summary: {
total_sales_lines: parseIntSafe(s?.total_sales_lines),
matched_sku_count: parseIntSafe(s?.matched_sku_count),
total_dish_count: parseIntSafe(ts?.total_dish_count),
matched_qty: parseNum(s?.matched_qty),
matched_amt: parseNum(s?.matched_amt),
total_sales_amt: parseNum(ts?.total_sales_amt),
total_bom_materials: parseIntSafe(s?.total_bom_materials),
bom_coverage_pct: ts?.total_dish_count > 0 ? parseNum((s?.matched_sku_count / ts?.total_dish_count * 100).toFixed(2)) : 0,
},
skuSales: skuSalesResult.rows.map((row: any) => ({
...row,
qty: parseNum(row.qty),
amt: parseNum(row.amt),
store_count: parseIntSafe(row.store_count),
avg_unit_price: parseNum(row.avg_unit_price),
material_count: parseIntSafe(row.material_count),
has_bom: row.has_bom,
})),
materialDemand: materialDemandResult.rows.map((row: any) => ({
...row,
total_demand_qty: parseNum(row.total_demand_qty),
total_demand_amt: parseNum(row.total_demand_amt),
store_count: parseIntSafe(row.store_count),
avg_store_demand: parseNum(row.avg_store_demand),
})),
storeDemand: storeDemandResult.rows.map((row: any) => ({
...row,
sku_count: parseIntSafe(row.sku_count),
total_qty: parseNum(row.total_qty),
total_amt: parseNum(row.total_amt),
total_material_demand_qty: parseNum(row.total_material_demand_qty),
ending_inventory_qty: parseNum(row.ending_inventory_qty),
ending_inventory_amt: parseNum(row.ending_inventory_amt),
suggested_order_qty: parseNum(row.suggested_order_qty),
})),
ckProductionPlan: ckProductionPlanResult.rows.map((row: any) => ({
...row,
demand_qty: parseNum(row.demand_qty),
actual_inbound_qty: parseNum(row.actual_inbound_qty),
actual_cost: parseNum(row.actual_cost),
variance_qty: parseNum(row.variance_qty),
variance_pct: row.variance_pct ? parseNum(row.variance_pct) : null,
store_count: parseIntSafe(row.store_count),
})),
productionCoord: productionCoordResult.rows.map((row: any) => ({
...row,
inbound_qty: parseNum(row.inbound_qty),
actual_cost: parseNum(row.actual_cost),
theoretical_cost: parseNum(row.theoretical_cost),
dist_qty: parseNum(row.dist_qty),
dist_amt: parseNum(row.dist_amt),
ck_consumption_qty: parseNum(row.ck_consumption_qty),
ck_ending_qty: parseNum(row.ck_ending_qty),
other_wh_consumption_qty: parseNum(row.other_wh_consumption_qty),
other_wh_ending_qty: parseNum(row.other_wh_ending_qty),
consumption_qty: parseNum(row.consumption_qty),
consumption_amt: parseNum(row.consumption_amt),
ending_qty: parseNum(row.ending_qty),
ending_amt: parseNum(row.ending_amt),
production_distribution_gap: parseNum(row.production_distribution_gap),
distribution_consumption_gap: parseNum(row.distribution_consumption_gap),
completion_distribution_rate: row.completion_distribution_rate ? parseNum(row.completion_distribution_rate) : null,
distribution_consumption_rate: row.distribution_consumption_rate ? parseNum(row.distribution_consumption_rate) : null,
inventory_accumulation_rate: row.inventory_accumulation_rate ? parseNum(row.inventory_accumulation_rate) : null,
})),
})
} finally {
client.release()
}
} catch (err: any) {
sendError(res, err.message)
}
})
export default router