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 = {} 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