2450 lines
119 KiB
TypeScript
2450 lines
119 KiB
TypeScript
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%,新品准入需通过收入预测和原料复用率审核;\n4)30天目标:完成' || (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
|