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.fn_store_risk_rating($${params.length + 1}) r ON s.store_code = r.store_code WHERE 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.fn_store_risk_rating($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 result = await query(` SELECT store_code, store_name, action_priority, problem_count, problem_combination, received, scale_tier, business_type FROM analytics.cache_store_priority 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 `) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) router.get('/stores/quadrant', async (req: AuthRequest, res) => { try { const result = await query(`SELECT * FROM analytics.v_store_benchmark ORDER BY management_quadrant, avg_daily_received DESC`) 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 [scorecard, risk, platform, benchmark, action] = await Promise.all([ query(`SELECT * FROM analytics.mv_store_scorecard WHERE store_code = $1`, [code]), query(`SELECT * FROM analytics.fn_store_risk_rating($1) WHERE store_code = $2`, [parseMonth(req), code]), query(`SELECT * FROM analytics.fn_store_platform_economics($2) WHERE store_code = $1`, [code, parseMonth(req)]), query(`SELECT * FROM analytics.fn_store_benchmark_composite($2) WHERE store_code = $1`, [code, parseMonth(req)]), query(`SELECT * FROM analytics.fn_store_action_priority_deep($1) WHERE store_code = $2`, [parseMonth(req), code]), ]) if (scorecard.rows.length === 0) { return sendError(res, 'Store not found', 404) } 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 result = await query(` SELECT closed_at::date AS business_date, count(*) AS bill_count, round(sum(received_total), 2) AS received, round(sum(received_total) / count(*), 2) AS avg_bill_value, round(sum(discount_total) / nullif(sum(consumption), 0) * 100, 2) AS discount_rate_pct FROM analytics.bill_fact WHERE store_code = $1 AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month') AND closed_at IS NOT NULL GROUP BY closed_at::date ORDER BY business_date `, [code, 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 * FROM analytics.fn_store_theoretical_actual_cost($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(`SELECT * FROM analytics.fn_inventory_finance_category($1) ORDER BY store_code`, [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 * FROM analytics.fn_store_area_efficiency($1) ORDER BY 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.fn_store_platform_economics($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 result = await query(`SELECT * FROM analytics.v_member_comparison`) 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.mv_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.fn_dish_sku_abc($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.fn_dish_pair_summary($1) ORDER BY pair_count DESC LIMIT 50`, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) router.get('/risk/anomaly', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) const countResult = await query(`SELECT count(*) AS total FROM analytics.v_anomaly_bills`) const result = await query(`SELECT * FROM analytics.v_anomaly_bills ORDER BY closed_at DESC LIMIT $1 OFFSET $2`, [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('/risk/zero-received', async (req: AuthRequest, res) => { try { const storeCode = req.query.store_code as string let sql = `SELECT * FROM analytics.v_zero_received_detail` const params: any[] = [] if (storeCode) { sql += ` WHERE store_code = $1` params.push(storeCode) } sql += ` ORDER BY closed_at 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 result = await query(`SELECT * FROM analytics.v_cashier_risk ORDER BY anomaly_rate_pct DESC NULLS LAST`) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) router.get('/marketing/plans', async (req: AuthRequest, res) => { try { const result = await query(`SELECT * FROM analytics.v_marketing_plan_summary ORDER BY received DESC`) 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.fn_store_benchmark_composite($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 result = await query(`SELECT * FROM analytics.v_weekday_summary ORDER BY weekday_no`) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) router.get('/time/hourly', async (req: AuthRequest, res) => { try { const result = await query(`SELECT * FROM analytics.v_hourly_summary ORDER BY closing_hour`) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) router.get('/channel', async (req: AuthRequest, res) => { try { const result = await query(`SELECT * FROM analytics.v_channel_daily ORDER BY business_date`) 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.fn_inventory_cost_classified($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 result = await query(`SELECT * FROM analytics.v_store_meal_opportunity WHERE store_code = $1 ORDER BY bill_count DESC`, [req.params.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.fn_store_category_mix($1) WHERE 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.fn_store_category_mix($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.fn_store_theoretical_actual_cost($1) WHERE 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.fn_inventory_cost_classified($1) WHERE 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.fn_inventory_cost_classified($1) WHERE 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.fn_store_member_opportunity($2) WHERE 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 countResult = await query(`SELECT count(*) AS total FROM analytics.v_anomaly_bills WHERE store_code = $1`, [req.params.code]) const result = await query(`SELECT * FROM analytics.v_anomaly_bills WHERE store_code = $1 ORDER BY closed_at DESC LIMIT $2 OFFSET $3`, [req.params.code, 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, res) => { try { const result = await query(`SELECT * FROM analytics.mv_region_summary ORDER BY total_received DESC`) 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.fn_store_site_profile($1) WHERE 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.fn_site_segment_benchmark($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.fn_store_site_replication($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.fn_store_overlap_risk($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.fn_district_site_benchmark($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.fn_store_risk_rating($1) 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(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.fn_store_risk_rating($1) 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 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 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 analytics.fn_store_risk_rating($1) 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 analytics.fn_store_risk_rating($1) 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 analytics.fn_store_risk_rating($1) 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 analytics.fn_dish_sku_abc($1) ), sku_samples AS ( SELECT dish_name, category_level1, received_amount, bill_count, abc_class FROM analytics.fn_dish_sku_abc($1) 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 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.fn_store_risk_rating($1) 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.fn_store_risk_rating($1) WHERE 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.fn_store_risk_rating($1) WHERE 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 sendSuccess(res, { overview: { ...ov, received: parseFloat(ov?.received), bill_count: parseInt(ov?.bill_count), avg_bill_value: parseFloat(ov?.avg_bill_value), discount_rate_pct: parseFloat(ov?.discount_rate_pct), theoretical_margin_pct: parseFloat(ov?.theoretical_margin_pct), member_bills: parseInt(ov?.member_bills), member_share_pct: parseFloat(ov?.member_share_pct) }, daily: dailyRows, waterfall: { ...wf, received: parseFloat(wf?.received), food_cost: parseFloat(wf?.food_cost), wage: parseFloat(wf?.wage), rent: parseFloat(wf?.rent), utility: parseFloat(wf?.utility), dorm: parseFloat(wf?.dorm), commission: parseFloat(wf?.commission), other_expense: parseFloat(wf?.other_expense), total_expense: parseFloat(wf?.total_expense), store_contribution: parseFloat(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 [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 distribution_detail_records d WHERE d.business_date >= $1::date AND d.business_date < ($1::date + INTERVAL '1 month') AND NOT d.is_return AND d.item_code IS NOT NULL 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 distribution_detail_records d WHERE d.business_date >= $1::date AND d.business_date < ($1::date + INTERVAL '1 month') AND NOT d.is_return AND d.item_code IS NOT NULL 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 distribution_detail_records WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month') ) 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 distribution_detail_records d WHERE d.business_date >= $1::date AND d.business_date < ($1::date + INTERVAL '1 month') AND NOT d.is_return AND d.item_code IS NOT NULL 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 distribution_detail_records d WHERE d.business_date >= $1::date AND d.business_date < ($1::date + INTERVAL '1 month') AND NOT d.is_return AND d.item_code IS NOT NULL 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 distribution_detail_records d WHERE d.business_date >= $1::date AND d.business_date < ($1::date + INTERVAL '1 month') AND NOT d.is_return AND d.item_code IS NOT NULL GROUP BY COALESCE(NULLIF(d.minor_category,''),'未分类') ), item_cat AS ( SELECT DISTINCT item_code, COALESCE(NULLIF(minor_category,''),'未分类') AS minor_category FROM distribution_detail_records WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month') AND item_code IS NOT NULL ), 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 ) 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 { // 创建临时表存储聚合销量,避免重复全表扫描550万行 await client.query(` CREATE TEMP TABLE IF NOT EXISTS tmp_sales_agg AS SELECT d.store_code, d.store_name, sk.sku_code, d.dish_name, round(sum(d.sales_quantity)::numeric,4) AS qty, round(sum(d.gross_amount)::numeric,2) AS amt, round(avg(d.unit_price)::numeric,2) AS avg_unit_price FROM dish_sales_details d JOIN analytics.dim_sku sk ON d.dish_name = sk.standard_name WHERE d.ordered_at >= $1::date AND d.ordered_at < ($1::date + INTERVAL '1 month') ${storeCode ? 'AND d.store_code = $2' : ''} GROUP BY d.store_code, d.store_name, sk.sku_code, d.dish_name `, 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 (SELECT count(*) FROM (SELECT DISTINCT dish_name FROM dish_sales_details WHERE ordered_at >= $1::date AND ordered_at < ($1::date + INTERVAL '1 month')) t) AS total_dish_count, (SELECT round(sum(gross_amount)::numeric,2) FROM dish_sales_details WHERE ordered_at >= $1::date AND ordered_at < ($1::date + INTERVAL '1 month')) AS total_sales_amt `, [month]) 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 item_code AS material_code, round(avg(unit_price_excl_tax)::numeric,4) AS unit_price FROM central_kitchen_recipe_consumption WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month') GROUP BY item_code ) ic ON b.material_code = ic.material_code 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 b.material_code IN ( SELECT product_code FROM central_kitchen_processing_cost WHERE report_month = $1::date ) OR 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 COALESCE(d.ck_product_code, a.product_code) AS product_code, COALESCE(d.ck_product_name, a.product_name) AS product_name, COALESCE(d.unit, '') AS unit, COALESCE(d.demand_qty, 0) AS demand_qty, COALESCE(d.store_count, 0) AS store_count, COALESCE(a.actual_inbound_qty, 0) AS actual_inbound_qty, COALESCE(a.actual_cost, 0) AS actual_cost FROM ck_demand d FULL OUTER JOIN ck_actual a ON d.ck_product_code = a.product_code 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_code NOT IN (SELECT ck_product_code FROM ck_demand WHERE ck_product_code IS NOT NULL) AND a.product_name NOT IN (SELECT ck_product_name FROM ck_demand WHERE ck_product_name IS NOT NULL) ) 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 distribution_detail_records d WHERE d.business_date >= $1::date AND d.business_date < ($1::date + INTERVAL '1 month') AND NOT d.is_return AND d.item_code IS NOT NULL AND d.distribution_center_code = '3' GROUP BY d.item_code, d.item_name ), 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') 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(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 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), 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