import { Router } from 'express' import { query } from '../config/database.js' import { sendSuccess, sendError, parsePagination, parseMonth } from '../middleware/error.js' import type { AuthRequest } from '../middleware/auth.js' const router = Router() // ============ Tab1: 成本总览 ============ // 成本总览指标 router.get('/overview', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const result = await query(` SELECT count(*) AS total_dishes, count(*) FILTER (WHERE theoretical_margin_rate_pct < 50) AS low_margin_dishes, count(*) FILTER (WHERE cost_variance_amount > 0) AS over_cost_dishes, round((1 - sum(theoretical_cost) / nullif(sum(sales_amount), 0)) * 100, 2) AS avg_theo_margin, round((1 - sum(actual_cost) / nullif(sum(sales_amount), 0)) * 100, 2) AS avg_actual_margin, round(sum(sales_amount)::numeric, 2) AS total_sales, round(sum(theoretical_cost)::numeric, 2) AS total_theo_cost, round(sum(actual_cost)::numeric, 2) AS total_actual_cost, round(sum(cost_variance_amount)::numeric, 2) AS total_variance FROM public.dish_cost_analysis_summary WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) `, [month]) sendSuccess(res, result.rows[0]) } catch (err: any) { sendError(res, err.message) } }) // 品类成本对比 router.get('/category-comparison', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const result = await query(` SELECT category_level1, count(*) AS dishes, round((1 - sum(theoretical_cost) / nullif(sum(sales_amount), 0)) * 100, 2) AS avg_theo_margin, round((1 - sum(actual_cost) / nullif(sum(sales_amount), 0)) * 100, 2) AS avg_actual_margin, round(sum(sales_amount)::numeric, 2) AS total_sales, round(sum(theoretical_cost)::numeric, 2) AS total_theo_cost, round(sum(actual_cost)::numeric, 2) AS total_actual_cost, round(sum(cost_variance_amount)::numeric, 2) AS total_variance FROM public.dish_cost_analysis_summary WHERE category_level1 IS NOT NULL AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) GROUP BY category_level1 ORDER BY total_sales DESC `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // 毛利率偏差分布 router.get('/margin-deviation', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const result = await query(` SELECT CASE WHEN (actual_margin_rate_pct - theoretical_margin_rate_pct) > 10 THEN '实际高于理论>10%' WHEN (actual_margin_rate_pct - theoretical_margin_rate_pct) BETWEEN 0 AND 10 THEN '实际略高0-10%' WHEN (actual_margin_rate_pct - theoretical_margin_rate_pct) BETWEEN -10 AND 0 THEN '实际略低0-10%' WHEN (actual_margin_rate_pct - theoretical_margin_rate_pct) BETWEEN -30 AND -10 THEN '实际偏低10-30%' WHEN (actual_margin_rate_pct - theoretical_margin_rate_pct) < -30 THEN '实际严重偏低>30%' END AS deviation_band, count(*) AS cnt FROM public.dish_cost_analysis_summary WHERE theoretical_margin_rate_pct IS NOT NULL AND actual_margin_rate_pct IS NOT NULL AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) GROUP BY deviation_band ORDER BY cnt DESC `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // 成本差异TOP榜 router.get('/variance-top', async (req: AuthRequest, res) => { try { const limit = parseInt((req.query.limit as string) || '50') const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC' const month = parseMonth(req) const result = await query(` SELECT dish_name, dish_code, category_level1, round(theoretical_margin_rate_pct::numeric, 2) AS theo_margin, round(actual_margin_rate_pct::numeric, 2) AS actual_margin, round(cost_variance_amount::numeric, 2) AS cost_variance, round(sales_amount::numeric, 2) AS sales_amount, CASE WHEN actual_margin_rate_pct < 0 THEN '数据异常' WHEN cost_variance_amount > 0 AND theoretical_cost > 0 AND (cost_variance_amount / theoretical_cost) > 0.2 THEN '紧急' WHEN cost_variance_amount > 0 AND theoretical_cost > 0 AND (cost_variance_amount / theoretical_cost) > 0.1 THEN '整改' WHEN cost_variance_amount > 0 THEN '关注' ELSE '正常' END AS cost_tier FROM public.dish_cost_analysis_summary WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1) ORDER BY cost_variance_amount ${order} LIMIT $1 `, [limit, month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // ============ Tab2: 菜品盈利分析 ============ // 菜单工程矩阵 router.get('/menu-engineering', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const result = await query(` WITH imp AS (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) SELECT dish_name, dish_code, category_level1, round(sales_quantity::numeric, 2) AS sales_quantity, round(sales_amount::numeric, 2) AS sales_amount, round(actual_margin_rate_pct::numeric, 2) AS actual_margin, CASE WHEN actual_margin_rate_pct < 0 THEN '数据异常' WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id) AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id AND actual_margin_rate_pct > 0) THEN '明星盈利品' WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id) AND actual_margin_rate_pct < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id AND actual_margin_rate_pct > 0) THEN '高销低利品' WHEN sales_quantity < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id) AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id AND actual_margin_rate_pct > 0) THEN '低销高利品' ELSE '低销低利品' END AS menu_type FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id AND actual_margin_rate_pct IS NOT NULL `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // 菜品盈利明细 router.get('/profitability', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) const category = req.query.category as string const menuType = req.query.type as string const sort = (req.query.sort as string) || 'sales_amount' const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC' const filter = (req.query.filter as string) || '' const month = parseMonth(req) let where = 'WHERE actual_margin_rate_pct IS NOT NULL AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)' const params: any[] = [month] if (category) { params.push(category); where += ` AND category_level1 = $${params.length}` } if (filter === 'profitable') { where += ` AND actual_margin_rate_pct > 0` } else if (filter === 'loss') { where += ` AND actual_margin_rate_pct <= 0` } else if (filter === 'high_variance') { where += ` AND cost_variance_amount > 0` } const validSorts = ['sales_amount', 'sales_quantity', 'theoretical_margin_rate_pct', 'actual_margin_rate_pct', 'cost_variance_amount', 'theoretical_cost', 'actual_cost', 'price'] const sortCol = validSorts.includes(sort) ? sort : 'sales_amount' const countResult = await query(`SELECT count(*) FROM public.dish_cost_analysis_summary ${where}`, params) const total = countResult.rows[0].count params.push(pageSize, offset) const result = await query(` SELECT dish_name, dish_code, category_level1, round(sales_quantity::numeric, 2) AS sales_quantity, round(sales_amount::numeric, 2) AS sales_amount, round((sales_amount - theoretical_cost)::numeric, 2) AS theo_profit, round((sales_amount - actual_cost)::numeric, 2) AS actual_profit, round(theoretical_margin_rate_pct::numeric, 2) AS theo_margin, round(actual_margin_rate_pct::numeric, 2) AS actual_margin, round(sales_amount / nullif(sum(sales_amount) OVER (), 0) * 100, 2) AS revenue_contribution FROM public.dish_cost_analysis_summary ${where} ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length - 1} OFFSET $${params.length} `, params) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) } }) // 定价合理性 router.get('/pricing', async (req: AuthRequest, res) => { try { const threshold = parseFloat((req.query.threshold as string) || '50') const month = parseMonth(req) const result = await query(` SELECT dish_name, dish_code, category_level1, round(price::numeric, 2) AS price, round(theoretical_cost::numeric, 2) AS theo_cost, round((theoretical_cost / nullif(price, 0) * 100)::numeric, 2) AS theo_cost_rate, round(theoretical_margin_rate_pct::numeric, 2) AS theo_margin, round(actual_margin_rate_pct::numeric, 2) AS actual_margin, round(sales_amount::numeric, 2) AS sales_amount FROM public.dish_cost_analysis_summary WHERE theoretical_margin_rate_pct < $1 AND sales_amount > 1000 AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1) ORDER BY theoretical_margin_rate_pct ASC `, [threshold, month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // ============ Tab3: 原料差异分析 ============ // 菜品原料差异分解 router.get('/material-variance', async (req: AuthRequest, res) => { try { const dishCode = req.query.dish_code as string if (!dishCode) return sendError(res, 'dish_code is required') const month = parseMonth(req) const result = await query(` SELECT m.material_name, m.material_unit, round(m.theoretical_quantity::numeric, 4) AS theo_qty, round(m.actual_quantity::numeric, 4) AS actual_qty, round(m.loss_quantity::numeric, 4) AS loss_qty, round(m.loss_quantity_rate_pct::numeric, 2) AS loss_rate, round(m.theoretical_amount::numeric, 2) AS theo_amount, round(m.actual_amount::numeric, 2) AS actual_amount, round(m.loss_amount::numeric, 2) AS loss_amount, CASE WHEN m.loss_quantity_rate_pct < -100 THEN '分摊遗漏' WHEN m.loss_quantity_rate_pct < -30 THEN '份量超标' WHEN m.actual_quantity = 0 THEN '未使用' ELSE '正常损耗' END AS reason FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE s.dish_code = $1 AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1) ORDER BY m.loss_amount ASC `, [dishCode, month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // 损耗率分布 router.get('/loss-distribution', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const result = await query(` SELECT CASE WHEN loss_quantity_rate_pct = 0 THEN '无损耗' WHEN loss_quantity_rate_pct BETWEEN -1 AND 0 THEN '轻微(-1~0%)' WHEN loss_quantity_rate_pct BETWEEN -10 AND -1 THEN '轻度(-1~-10%)' WHEN loss_quantity_rate_pct BETWEEN -30 AND -10 THEN '中度(-10~-30%)' WHEN loss_quantity_rate_pct BETWEEN -100 AND -30 THEN '严重(-30~-100%)' WHEN loss_quantity_rate_pct < -100 THEN '极重(<-100%)' ELSE '正向(>0%)' END AS loss_band, count(*) AS cnt FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) GROUP BY loss_band ORDER BY cnt DESC `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // 超耗物料TOP榜 router.get('/material-loss-top', async (req: AuthRequest, res) => { try { const limit = parseInt((req.query.limit as string) || '50') const order = (req.query.order as string) === 'rate' ? 'avg_loss_rate' : 'total_loss_qty' const month = parseMonth(req) const result = await query(` SELECT material_name, material_type, count(DISTINCT m.summary_id) AS dish_count, round(sum(loss_quantity)::numeric, 2) AS total_loss_qty, round((sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate, round(max(loss_quantity_rate_pct)::numeric, 2) AS max_loss_rate, round(sum(loss_amount)::numeric, 2) AS total_loss_amount FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE m.loss_quantity < 0 AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1) GROUP BY material_name, material_type ORDER BY ${order} ASC LIMIT $1 `, [limit, month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // 物料类型损耗对比 router.get('/material-type-loss', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const result = await query(` SELECT material_type, count(*) AS cnt, round((sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate, round(sum(loss_amount)::numeric, 2) AS total_loss_amount, count(DISTINCT material_name) AS unique_materials FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) GROUP BY material_type ORDER BY total_loss_amount ASC `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // ============ Tab4: BOM与配方 ============ // BOM总览 router.get('/bom-overview', async (req: AuthRequest, res) => { try { const result = await query(` SELECT (SELECT count(*) FROM analytics.dim_sku) AS total_sku, (SELECT count(DISTINCT sku_code) FROM analytics.fact_recipe_bom) AS sku_with_bom, (SELECT count(*) FROM analytics.dim_sku) - (SELECT count(DISTINCT sku_code) FROM analytics.fact_recipe_bom) AS sku_without_bom, round((SELECT count(DISTINCT sku_code)::numeric FROM analytics.fact_recipe_bom) / nullif((SELECT count(*) FROM analytics.dim_sku), 0) * 100, 2) AS coverage_pct, (SELECT count(*) FROM analytics.fact_recipe_bom) AS total_bom_rows, (SELECT count(*) FROM analytics.fact_recipe_bom WHERE waste_rate > 20) AS high_loss_bom, (SELECT count(*) FROM analytics.fact_recipe_bom WHERE waste_rate > 100) AS extreme_loss_bom, (SELECT round(avg(cnt)::numeric, 1) FROM (SELECT count(*) AS cnt FROM analytics.fact_recipe_bom GROUP BY sku_code) t) AS avg_materials_per_sku `) sendSuccess(res, result.rows[0]) } catch (err: any) { sendError(res, err.message) } }) // BOM复杂度 router.get('/bom-complexity', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) const sort = (req.query.sort as string) || 'material_count' const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC' const filter = (req.query.filter as string) || '' let having = '' if (filter === 'high_loss') { having = 'HAVING count(*) FILTER (WHERE b.waste_rate > 20) > 0' } else if (filter === 'unique_heavy') { having = 'HAVING count(*) FILTER (WHERE ref_count = 1) > 3' } const validSorts = ['material_count', 'semi_finished_count', 'unique_material_count', 'high_loss_count', 'zero_actual_count'] const sortCol = validSorts.includes(sort) ? sort : 'material_count' const countResult = await query(`SELECT count(DISTINCT sku_code) FROM analytics.fact_recipe_bom`) const total = countResult.rows[0].count const result = await query(` SELECT b.sku_code, s.standard_name AS dish_name, s.category_l1, count(*) AS material_count, count(*) FILTER (WHERE dm.major_category = '半成品') AS semi_finished_count, count(*) FILTER (WHERE ref_count = 1) AS unique_material_count, count(DISTINCT b.unit) AS unit_count, count(*) FILTER (WHERE b.waste_rate > 20) AS high_loss_count, count(*) FILTER (WHERE b.standard_net_quantity = 0) AS zero_actual_count, CASE WHEN count(*) <= 5 THEN '低复杂度' WHEN count(*) <= 10 THEN '正常' WHEN count(*) <= 15 THEN '较复杂' ELSE '重点评审' END AS complexity_level FROM analytics.fact_recipe_bom b JOIN analytics.dim_sku s ON s.sku_code = b.sku_code JOIN analytics.dim_material dm ON dm.material_code = b.material_code LEFT JOIN (SELECT material_code, count(DISTINCT sku_code) AS ref_count FROM analytics.fact_recipe_bom GROUP BY material_code) r ON r.material_code = b.material_code GROUP BY b.sku_code, s.standard_name, s.category_l1 ${having} ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2 `, [pageSize, offset]) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) } }) // 高损耗BOM预警 router.get('/bom-high-loss', async (req: AuthRequest, res) => { try { const threshold = parseFloat((req.query.threshold as string) || '20') const result = await query(` SELECT b.sku_code, s.standard_name AS dish_name, dm.material_name, round(b.waste_rate::numeric, 2) AS waste_rate, round(b.yield_rate::numeric, 2) AS yield_rate, round(b.standard_gross_quantity::numeric, 8) AS gross_qty, round(b.standard_net_quantity::numeric, 8) AS net_qty, b.unit, CASE WHEN b.waste_rate > 100 THEN '极端' WHEN b.waste_rate > 50 THEN '严重' ELSE '关注' END AS alert_level FROM analytics.fact_recipe_bom b JOIN analytics.dim_sku s ON s.sku_code = b.sku_code JOIN analytics.dim_material dm ON dm.material_code = b.material_code WHERE b.waste_rate >= $1 ORDER BY b.waste_rate DESC LIMIT 100 `, [threshold]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // 缺BOM的SKU清单 router.get('/bom-missing', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) const sort = (req.query.sort as string) || 'sales_amount' const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC' const validSorts = ['sales_amount', 'sales_quantity', 'standard_name', 'category_l1'] const sortCol = validSorts.includes(sort) ? sort : 'sales_amount' const countResult = await query(` SELECT count(*) FROM analytics.dim_sku s WHERE s.sku_code NOT IN (SELECT DISTINCT sku_code FROM analytics.fact_recipe_bom) `) const total = countResult.rows[0].count const result = await query(` SELECT s.sku_code, s.standard_name AS dish_name, s.category_l1, COALESCE(round(d.sales_amount::numeric, 2), 0) AS sales_amount, COALESCE(round(d.sales_quantity::numeric, 2), 0) AS sales_quantity FROM analytics.dim_sku s LEFT JOIN public.dish_cost_analysis_summary d ON d.dish_code = s.sku_code WHERE s.sku_code NOT IN (SELECT DISTINCT sku_code FROM analytics.fact_recipe_bom) ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2 `, [pageSize, offset]) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) } }) // 菜品物料构成 router.get('/bom-composition', async (req: AuthRequest, res) => { try { const skuCode = req.query.sku_code as string if (!skuCode) return sendError(res, 'sku_code is required') const result = await query(` SELECT dm.material_name, dm.major_category AS material_type, round(b.standard_gross_quantity::numeric, 8) AS gross_qty, round(b.standard_net_quantity::numeric, 8) AS net_qty, b.unit, round(b.waste_rate::numeric, 2) AS waste_rate, round(b.yield_rate::numeric, 2) AS yield_rate, round(m.theoretical_amount::numeric, 2) AS theo_amount, round(m.theoretical_amount / nullif(sum(m.theoretical_amount) OVER (PARTITION BY m.summary_id), 0) * 100, 2) AS cost_share FROM analytics.fact_recipe_bom b JOIN analytics.dim_material dm ON dm.material_code = b.material_code LEFT JOIN public.dish_cost_analysis_material_detail m ON m.material_name = dm.material_name WHERE b.sku_code = $1 ORDER BY theo_amount DESC NULLS LAST `, [skuCode]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // ============ Tab5: 供应链与精简 ============ // 原料共用度 router.get('/material-sharing', async (req: AuthRequest, res) => { try { const limit = parseInt((req.query.limit as string) || '20') const result = await query(` SELECT dm.material_name, dm.major_category, count(DISTINCT b.sku_code) AS sku_count, CASE WHEN count(DISTINCT b.sku_code) > 50 THEN '核心原料' WHEN count(DISTINCT b.sku_code) = 1 THEN '独有原料' ELSE '普通共用' END AS sharing_type FROM analytics.fact_recipe_bom b JOIN analytics.dim_material dm ON dm.material_code = b.material_code GROUP BY dm.material_name, dm.major_category ORDER BY sku_count DESC LIMIT $1 `, [limit]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // 独有原料风险 router.get('/unique-material-risk', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) const sort = (req.query.sort as string) || 'sales_amount' const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC' const filter = (req.query.filter as string) || '' let having = '' if (filter === 'high_risk') { having = 'HAVING COALESCE(d.sales_amount, 0) < 5000 AND count(*) > 3' } else if (filter === 'low_sales') { having = 'HAVING COALESCE(d.sales_amount, 0) < 5000' } const validSorts = ['sales_amount', 'unique_material_count', 'standard_name', 'category_l1'] const sortCol = validSorts.includes(sort) ? sort : 'sales_amount' const countResult = await query(` SELECT count(DISTINCT b.sku_code) FROM analytics.fact_recipe_bom b WHERE b.material_code IN (SELECT material_code FROM analytics.fact_recipe_bom GROUP BY material_code HAVING count(DISTINCT sku_code) = 1) `) const total = countResult.rows[0].count const result = await query(` WITH unique_materials AS ( SELECT material_code FROM analytics.fact_recipe_bom GROUP BY material_code HAVING count(DISTINCT sku_code) = 1 ) SELECT b.sku_code, s.standard_name AS dish_name, s.category_l1, COALESCE(round(d.sales_amount::numeric, 2), 0) AS sales_amount, count(*) AS unique_material_count, CASE WHEN COALESCE(d.sales_amount, 0) < 5000 AND count(*) > 3 THEN '高' WHEN COALESCE(d.sales_amount, 0) < 5000 THEN '中' ELSE '低' END AS risk_level FROM analytics.fact_recipe_bom b JOIN analytics.dim_sku s ON s.sku_code = b.sku_code LEFT JOIN public.dish_cost_analysis_summary d ON d.dish_code = b.sku_code WHERE b.material_code IN (SELECT material_code FROM unique_materials) GROUP BY b.sku_code, s.standard_name, s.category_l1, d.sales_amount ${having} ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2 `, [pageSize, offset]) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) } }) // SKU精简模拟 router.post('/sku-simplify-simulate', async (req: AuthRequest, res) => { try { const { sku_codes } = req.body if (!sku_codes || !Array.isArray(sku_codes) || sku_codes.length === 0) { return sendError(res, 'sku_codes array is required') } const result = await query(` WITH target_skus AS (SELECT unnest($1::text[]) AS sku_code) SELECT (SELECT round(sum(sales_amount)::numeric, 2) FROM public.dish_cost_analysis_summary WHERE dish_code IN (SELECT sku_code FROM target_skus)) AS revenue_impact, (SELECT round(sum(sales_amount - actual_cost)::numeric, 2) FROM public.dish_cost_analysis_summary WHERE dish_code IN (SELECT sku_code FROM target_skus)) AS profit_impact, (SELECT count(DISTINCT material_code) FROM analytics.fact_recipe_bom WHERE sku_code IN (SELECT sku_code FROM target_skus) AND material_code NOT IN (SELECT material_code FROM analytics.fact_recipe_bom WHERE sku_code NOT IN (SELECT sku_code FROM target_skus))) AS releasable_materials, (SELECT count(DISTINCT material_code) FROM analytics.fact_recipe_bom WHERE sku_code IN (SELECT sku_code FROM target_skus) AND material_code IN (SELECT material_code FROM analytics.fact_recipe_bom WHERE sku_code NOT IN (SELECT sku_code FROM target_skus))) AS shared_materials, (SELECT count(*) FROM public.dish_cost_analysis_summary WHERE dish_code IN (SELECT sku_code FROM target_skus) AND actual_margin_rate_pct < 0) AS negative_margin_count `, [sku_codes]) const releasable = await query(` WITH target_skus AS (SELECT unnest($1::text[]) AS sku_code) SELECT dm.material_name, dm.major_category, round(sum(m.actual_amount)::numeric, 2) AS inventory_value FROM analytics.fact_recipe_bom b JOIN analytics.dim_material dm ON dm.material_code = b.material_code LEFT JOIN public.dish_cost_analysis_material_detail m ON m.material_name = dm.material_name WHERE b.sku_code IN (SELECT sku_code FROM target_skus) AND b.material_code NOT IN (SELECT material_code FROM analytics.fact_recipe_bom WHERE sku_code NOT IN (SELECT sku_code FROM target_skus)) GROUP BY dm.material_name, dm.major_category ORDER BY inventory_value DESC NULLS LAST LIMIT 50 `, [sku_codes]) sendSuccess(res, { summary: result.rows[0], releasable_materials: releasable.rows }) } catch (err: any) { sendError(res, err.message) } }) // BOM驱动物料需求预测 router.get('/material-demand', async (req: AuthRequest, res) => { try { const limit = parseInt((req.query.limit as string) || '50') const result = await query(` SELECT dm.material_name, dm.major_category, dm.base_unit, round(sum(b.standard_gross_quantity * dcs.sales_quantity)::numeric, 2) AS estimated_demand, count(DISTINCT b.sku_code) AS dish_count FROM analytics.fact_recipe_bom b JOIN analytics.dim_material dm ON dm.material_code = b.material_code JOIN public.dish_cost_analysis_summary dcs ON dcs.dish_code = b.sku_code GROUP BY dm.material_name, dm.major_category, dm.base_unit ORDER BY estimated_demand DESC LIMIT $1 `, [limit]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // ============ Tab6: 包装耗材 ============ // 包装总览 router.get('/packaging-overview', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const result = await query(` SELECT count(DISTINCT material_name) AS packaging_types, round(sum(theoretical_amount)::numeric, 2) AS total_cost, round(sum(loss_quantity)::numeric, 2) AS total_loss_qty, count(*) FILTER (WHERE loss_quantity_rate_pct < -20) AS high_loss_count FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE material_name ~* '餐盒|餐具|打包|纸巾|餐盒|调味包|餐盒|碗|袋|杯|盒' AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) `, [month]) sendSuccess(res, result.rows[0]) } catch (err: any) { sendError(res, err.message) } }) // 包装明细 router.get('/packaging-detail', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) const sort = (req.query.sort as string) || 'total_cost' const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC' const filter = (req.query.filter as string) || '' let having = '' if (filter === 'high_loss') { having = 'HAVING sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100 < -20' } else if (filter === 'normal') { having = 'HAVING sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100 >= -5' } const validSorts = ['total_cost', 'total_qty', 'loss_qty', 'avg_loss_rate', 'dish_count', 'material_name'] const sortCol = validSorts.includes(sort) ? sort : 'total_cost' const month = parseMonth(req) const countResult = await query(` SELECT count(DISTINCT material_name) FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒' AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) `, [month]) const total = countResult.rows[0].count const result = await query(` SELECT material_name, count(DISTINCT m.summary_id) AS dish_count, round(sum(m.theoretical_quantity)::numeric, 2) AS total_qty, round(sum(m.theoretical_amount)::numeric, 2) AS total_cost, round(sum(m.loss_quantity)::numeric, 2) AS loss_qty, round((sum(m.loss_quantity) / nullif(sum(m.theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate, CASE WHEN sum(m.loss_quantity) / nullif(sum(m.theoretical_quantity), 0) * 100 < -20 THEN '核查' WHEN sum(m.loss_quantity) / nullif(sum(m.theoretical_quantity), 0) * 100 < -5 THEN '关注' ELSE '正常' END AS status FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒' AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) GROUP BY material_name ${having} ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $2 OFFSET $3 `, [month, pageSize, offset]) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) } }) // ============ Tab7: 数据质量 ============ // 数据质量总览 router.get('/data-quality', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const result = await query(` SELECT (SELECT count(*) FROM analytics.dim_sku) AS total_sku, (SELECT count(DISTINCT sku_code) FROM analytics.fact_recipe_bom) AS sku_with_bom, round((SELECT count(DISTINCT sku_code)::numeric FROM analytics.fact_recipe_bom) / nullif((SELECT count(*) FROM analytics.dim_sku), 0) * 100, 2) AS bom_coverage, (SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND EXISTS (SELECT 1 FROM public.dish_sales_details d WHERE d.dish_name = s.dish_name)) AS matched_dishes, (SELECT count(*) FROM public.dish_cost_analysis_summary WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)) AS total_dishes, (SELECT count(*) FROM analytics.fact_recipe_bom WHERE standard_net_quantity = 0) AS zero_actual_bom, (SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND s.actual_margin_rate_pct < 0) AS negative_margin_count, (SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND s.theoretical_margin_rate_pct < 0) AS negative_theo_margin_count, (SELECT count(*) FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND EXISTS (SELECT 1 FROM public.inventory_cost_records i WHERE i.material_name = m.material_name)) AS matched_materials, (SELECT count(*) FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)) AS total_materials `, [month]) sendSuccess(res, result.rows[0]) } catch (err: any) { sendError(res, err.message) } }) // 未匹配菜品 router.get('/unmatched-dishes', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) const sort = (req.query.sort as string) || 'sales_amount' const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC' const validSorts = ['sales_amount', 'dish_name', 'dish_code', 'category_level1'] const sortCol = validSorts.includes(sort) ? sort : 'sales_amount' const month = parseMonth(req) const countResult = await query(`SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND NOT EXISTS (SELECT 1 FROM public.dish_sales_details d WHERE d.dish_name = s.dish_name)`, [month]) const total = countResult.rows[0].count const result = await query(` SELECT dish_name, dish_code, category_level1, round(sales_amount::numeric, 2) AS sales_amount FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND NOT EXISTS (SELECT 1 FROM public.dish_sales_details d WHERE d.dish_name = s.dish_name) ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $2 OFFSET $3 `, [month, pageSize, offset]) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) } }) // 未匹配物料 router.get('/unmatched-materials', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) const sort = (req.query.sort as string) || 'dish_count' const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC' const validSorts = ['dish_count', 'loss_amount', 'material_name', 'material_type'] const sortCol = validSorts.includes(sort) ? sort : 'dish_count' const month = parseMonth(req) const countResult = await query(` SELECT count(*) FROM ( SELECT material_name, material_type FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND NOT EXISTS (SELECT 1 FROM public.inventory_cost_records i WHERE i.material_name = m.material_name) GROUP BY material_name, material_type ) t `, [month]) const total = countResult.rows[0].count const result = await query(` SELECT material_name, material_type, count(DISTINCT m.summary_id) AS dish_count, round(sum(loss_amount)::numeric, 2) AS loss_amount FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND NOT EXISTS (SELECT 1 FROM public.inventory_cost_records i WHERE i.material_name = m.material_name) GROUP BY material_name, material_type ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $2 OFFSET $3 `, [month, pageSize, offset]) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) } }) // ============ Tab8: 门店成本 ============ // 门店成本总览 router.get('/store-overview', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const result = await query(` SELECT count(*) AS total_stores, count(*) FILTER (WHERE variance_level = '红色-严重超耗') AS red_count, count(*) FILTER (WHERE variance_level = '橙色-明显超耗') AS orange_count, count(*) FILTER (WHERE variance_level = '绿色-基本正常') AS green_count, count(*) FILTER (WHERE variance_level = '灰色-口径异常') AS gray_count, round(sum(food_cost_variance)::numeric, 2) AS total_variance FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1 `, [month]) sendSuccess(res, result.rows[0]) } catch (err: any) { sendError(res, err.message) } }) // 门店地图数据 router.get('/store-map', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const result = await query(` SELECT c.store_code, c.store_name, c.variance_level, round(c.food_cost_variance::numeric, 2) AS variance, round(c.theoretical_cost_rate_pct::numeric, 2) AS theo_cost_rate, round(c.actual_food_cost_rate_pct::numeric, 2) AS actual_cost_rate, l.latitude_gcj02, l.longitude_gcj02 FROM analytics.mv_store_theoretical_actual_cost_monthly c LEFT JOIN analytics.v_store_location_operating l ON l.store_code = c.store_code WHERE c.month_start = $1 AND l.latitude_gcj02 IS NOT NULL `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // 门店超耗排名 router.get('/store-ranking', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) const sort = (req.query.sort as string) || 'food_cost_variance' const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC' const filter = (req.query.filter as string) || '' let where = '' if (filter === 'red') { where = "WHERE variance_level = '红色-严重超耗'" } else if (filter === 'orange') { where = "WHERE variance_level = '橙色-明显超耗'" } else if (filter === 'green') { where = "WHERE variance_level = '绿色-基本正常'" } else if (filter === 'gray') { where = "WHERE variance_level = '灰色-口径异常'" } const validSorts = ['food_cost_variance', 'theoretical_cost_rate_pct', 'actual_food_cost_rate_pct', 'variance_to_theoretical_pct', 'negative_item_lines', 'store_name'] const sortCol = validSorts.includes(sort) ? sort : 'food_cost_variance' const month = parseMonth(req) const countResult = await query(`SELECT count(*) FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1 ${where}`, [month]) const total = countResult.rows[0].count const result = await query(` SELECT store_code, store_name, round(theoretical_cost_rate_pct::numeric, 2) AS theo_cost_rate, round(actual_food_cost_rate_pct::numeric, 2) AS actual_cost_rate, round(variance_to_theoretical_pct::numeric, 2) AS variance_pct, round(food_cost_variance::numeric, 2) AS variance_amount, negative_item_lines, variance_level FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1 ${where} ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $2 OFFSET $3 `, [month, pageSize, offset]) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) } }) // ============ Tab9: 可视化探索 ============ // 散点图数据 router.get('/scatter', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const result = await query(` SELECT dish_name, dish_code, category_level1, round(sales_quantity::numeric, 2) AS x, round(cost_variance_amount::numeric, 2) AS y, round(sales_amount::numeric, 2) AS size FROM public.dish_cost_analysis_summary WHERE cost_variance_amount IS NOT NULL AND sales_quantity IS NOT NULL AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // ============ Tab10: 调整管理 ============ // 生成诊断快照 router.post('/diagnosis/generate', async (req: AuthRequest, res) => { try { const diagDate = new Date().toISOString().slice(0, 10) await query(`DELETE FROM public.dish_diagnosis_snapshot WHERE diagnosis_date = $1`, [diagDate]) const result = await query(` INSERT INTO public.dish_diagnosis_snapshot (diagnosis_date, dish_code, dish_name, category_l1, sales_amount, sales_quantity, theoretical_margin_pct, actual_margin_pct, cost_variance_amount, cost_tier, bom_complexity_score, unique_material_count, waste_rate_avg, diagnosis_type, diagnosis_detail, suggested_action, priority) SELECT $1::date, s.dish_code, s.dish_name, s.category_level1, round(s.sales_amount::numeric, 2), round(s.sales_quantity::numeric, 2), round(s.theoretical_margin_rate_pct::numeric, 2), round(s.actual_margin_rate_pct::numeric, 2), round(s.cost_variance_amount::numeric, 2), CASE WHEN s.actual_margin_rate_pct < 0 THEN '数据异常' WHEN s.cost_variance_amount > 0 AND s.theoretical_cost > 0 AND (s.cost_variance_amount / s.theoretical_cost) > 0.2 THEN '紧急' WHEN s.cost_variance_amount > 0 AND s.theoretical_cost > 0 AND (s.cost_variance_amount / s.theoretical_cost) > 0.1 THEN '整改' WHEN s.cost_variance_amount > 0 THEN '关注' ELSE '正常' END, COALESCE(bom.cnt, 0), COALESCE(bom.unique_cnt, 0), COALESCE(round(bom.avg_waste::numeric, 2), 0), CASE WHEN s.actual_margin_rate_pct < 0 THEN '数据异常' WHEN s.theoretical_margin_rate_pct < 0 THEN '负毛利' WHEN s.theoretical_margin_rate_pct < 30 AND s.actual_margin_rate_pct > s.theoretical_margin_rate_pct THEN '低毛利-定价偏低' WHEN s.theoretical_margin_rate_pct < 50 AND s.actual_margin_rate_pct < s.theoretical_margin_rate_pct THEN '低毛利-成本超耗' WHEN s.cost_variance_amount > 0 AND COALESCE(bom.avg_waste, 0) > 20 THEN '高超耗-份量超标' WHEN s.cost_variance_amount > 0 AND COALESCE(bom.avg_waste, 0) > 100 THEN '高超耗-分摊异常' WHEN COALESCE(bom.cnt, 0) > 15 AND s.sales_quantity < 5 THEN '配方复杂-低销量' WHEN COALESCE(bom.unique_cnt, 0) > 3 AND s.sales_amount < 5000 THEN '独有原料风险' WHEN s.cost_variance_amount > 0 THEN '成本差异' ELSE '正常' END, CASE WHEN s.actual_margin_rate_pct < 0 THEN '实际毛利率为负,需先核查BOM/单位/分摊' WHEN s.theoretical_margin_rate_pct < 0 THEN '理论毛利率为负,定价低于标准成本' WHEN s.theoretical_margin_rate_pct < 30 THEN '理论毛利率低于30%,定价偏低' WHEN s.theoretical_margin_rate_pct < 50 AND s.actual_margin_rate_pct < s.theoretical_margin_rate_pct THEN '实际成本超理论,存在超耗' WHEN s.cost_variance_amount > 0 AND COALESCE(bom.avg_waste, 0) > 20 THEN '物料损耗率超过20%' ELSE '成本基本正常' END, CASE WHEN s.actual_margin_rate_pct < 0 THEN 'fix_data' WHEN s.theoretical_margin_rate_pct < 0 THEN 'price_up' WHEN s.theoretical_margin_rate_pct < 30 AND s.actual_margin_rate_pct > s.theoretical_margin_rate_pct THEN 'price_up' WHEN s.theoretical_margin_rate_pct < 50 AND s.actual_margin_rate_pct < s.theoretical_margin_rate_pct THEN 'recipe_optimize' WHEN s.cost_variance_amount > 0 AND COALESCE(bom.avg_waste, 0) > 20 THEN 'portion_reduce' WHEN COALESCE(bom.cnt, 0) > 15 AND s.sales_quantity < 5 THEN 'delist' WHEN COALESCE(bom.unique_cnt, 0) > 3 AND s.sales_amount < 5000 THEN 'evaluate_delist' WHEN s.cost_variance_amount > 0 THEN 'monitor' ELSE 'keep' END, CASE WHEN s.actual_margin_rate_pct < 0 THEN 'P0' WHEN s.theoretical_margin_rate_pct < 0 THEN 'P0' WHEN s.cost_variance_amount > 0 AND s.theoretical_cost > 0 AND (s.cost_variance_amount / s.theoretical_cost) > 0.2 THEN 'P0' WHEN s.theoretical_margin_rate_pct < 50 THEN 'P1' WHEN COALESCE(bom.cnt, 0) > 15 AND s.sales_quantity < 5 THEN 'P2' WHEN COALESCE(bom.unique_cnt, 0) > 3 AND s.sales_amount < 5000 THEN 'P2' ELSE 'P3' END FROM public.dish_cost_analysis_summary s LEFT JOIN ( SELECT b.sku_code, count(*) AS cnt, count(*) FILTER (WHERE r.ref_count = 1) AS unique_cnt, sum(b.waste_rate * b.standard_gross_quantity) / nullif(sum(b.standard_gross_quantity), 0) AS avg_waste FROM analytics.fact_recipe_bom b LEFT JOIN (SELECT material_code, count(DISTINCT sku_code) AS ref_count FROM analytics.fact_recipe_bom GROUP BY material_code) r ON r.material_code = b.material_code GROUP BY b.sku_code ) bom ON bom.sku_code = s.dish_code WHERE s.dish_code IS NOT NULL `, [diagDate]) const countResult = await query(`SELECT count(*) FROM public.dish_diagnosis_snapshot WHERE diagnosis_date = $1`, [diagDate]) sendSuccess(res, { generated: countResult.rows[0].count, date: diagDate }) } catch (err: any) { sendError(res, err.message) } }) // 获取诊断建议列表 router.get('/diagnosis', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) const priority = req.query.priority as string const sort = (req.query.sort as string) || 'priority' const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC' let where = 'WHERE 1=1' const params: any[] = [] if (priority) { params.push(priority); where += ` AND priority = $${params.length}` } const validSorts = ['priority', 'cost_variance_amount', 'sales_amount', 'actual_margin_pct', 'theoretical_margin_pct', 'dish_name'] let sortExpr: string if (sort === 'priority') { sortExpr = `CASE priority WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END ${order}` } else if (validSorts.includes(sort)) { sortExpr = `${sort} ${order} NULLS LAST` } else { sortExpr = `CASE priority WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END ${order}` } const countResult = await query(`SELECT count(*) FROM public.dish_diagnosis_snapshot ${where}`, params) const total = countResult.rows[0].count params.push(pageSize, offset) const result = await query(` SELECT * FROM public.dish_diagnosis_snapshot ${where} ORDER BY ${sortExpr}, cost_variance_amount DESC NULLS LAST LIMIT $${params.length - 1} OFFSET $${params.length} `, params) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) } }) // 获取调整记录列表 router.get('/adjustment', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) const status = req.query.status as string const sort = (req.query.sort as string) || 'effective_date' const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC' let where = 'WHERE 1=1' const params: any[] = [] if (status) { params.push(status); where += ` AND status = $${params.length}` } const validSorts = ['effective_date', 'created_at', 'dish_name', 'adjustment_type', 'before_theoretical_margin', 'after_target_margin'] const sortCol = validSorts.includes(sort) ? sort : 'effective_date' const countResult = await query(`SELECT count(*) FROM public.dish_adjustment_log ${where}`, params) const total = countResult.rows[0].count params.push(pageSize, offset) const result = await query(` SELECT * FROM public.dish_adjustment_log ${where} ORDER BY ${sortCol} ${order} NULLS LAST, created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length} `, params) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) } }) // 创建调整记录 router.post('/adjustment', async (req: AuthRequest, res) => { try { const { dish_code, dish_name, adjustment_type, before_price, before_theoretical_cost, before_theoretical_margin, before_sales_avg_daily, before_cost_variance, after_price, after_theoretical_cost, after_target_margin, target_cost_reduction, effective_date, decided_by, reason, diagnosis_id } = req.body if (!dish_code || !dish_name || !adjustment_type || !effective_date) { return sendError(res, 'dish_code, dish_name, adjustment_type, effective_date are required') } const result = await query(` INSERT INTO public.dish_adjustment_log (dish_code, dish_name, adjustment_type, before_price, before_theoretical_cost, before_theoretical_margin, before_sales_avg_daily, before_cost_variance, after_price, after_theoretical_cost, after_target_margin, target_cost_reduction, effective_date, decided_by, reason, diagnosis_id, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'planned') RETURNING id `, [dish_code, dish_name, adjustment_type, before_price, before_theoretical_cost, before_theoretical_margin, before_sales_avg_daily, before_cost_variance, after_price, after_theoretical_cost, after_target_margin, target_cost_reduction, effective_date, decided_by, reason, diagnosis_id]) sendSuccess(res, { id: result.rows[0].id }) } catch (err: any) { sendError(res, err.message) } }) // 更新调整状态 router.put('/adjustment/:id', async (req: AuthRequest, res) => { try { const id = parseInt(req.params.id) const { status } = req.body const result = await query(` UPDATE public.dish_adjustment_log SET status = $1, updated_at = now() WHERE id = $2 RETURNING id `, [status, id]) if (result.rowCount === 0) return sendError(res, 'Adjustment not found', 404) sendSuccess(res, { id: result.rows[0].id }) } catch (err: any) { sendError(res, err.message) } }) // 获取验证数据(销量对比) router.get('/adjustment/:id/verify', async (req: AuthRequest, res) => { try { const id = parseInt(req.params.id) const adjResult = await query(`SELECT * FROM public.dish_adjustment_log WHERE id = $1`, [id]) if (adjResult.rowCount === 0) return sendError(res, 'Adjustment not found', 404) const adj = adjResult.rows[0] const effectiveDate = adj.effective_date const dishName = adj.dish_name const beforeData = await query(` SELECT (closed_at)::date AS day, count(*) AS bills, round(sum(gross_amount)::numeric, 2) AS sales FROM public.dish_sales_details WHERE dish_name = $1 AND closed_at >= ($2::date - interval '7 days') AND closed_at < $2::date GROUP BY day ORDER BY day `, [dishName, effectiveDate]) const afterData = await query(` SELECT (closed_at)::date AS day, count(*) AS bills, round(sum(gross_amount)::numeric, 2) AS sales FROM public.dish_sales_details WHERE dish_name = $1 AND closed_at >= $2::date AND closed_at < ($2::date + interval '7 days') GROUP BY day ORDER BY day `, [dishName, effectiveDate]) const beforeAvg = beforeData.rows.length > 0 ? beforeData.rows.reduce((s, r) => s + Number(r.bills), 0) / beforeData.rows.length : 0 const afterAvg = afterData.rows.length > 0 ? afterData.rows.reduce((s, r) => s + Number(r.bills), 0) / afterData.rows.length : 0 const changePct = beforeAvg > 0 ? ((afterAvg - beforeAvg) / beforeAvg * 100) : null sendSuccess(res, { adjustment: adj, before: beforeData.rows, after: afterData.rows, before_avg_daily: Math.round(beforeAvg * 100) / 100, after_avg_daily: Math.round(afterAvg * 100) / 100, sales_change_pct: changePct !== null ? Math.round(changePct * 100) / 100 : null, }) } catch (err: any) { sendError(res, err.message) } }) // 提交验证结果 router.post('/adjustment/:id/result', async (req: AuthRequest, res) => { try { const id = parseInt(req.params.id) const { period_type, period_start, period_end, actual_sales_avg_daily, actual_sales_change_pct, actual_margin, actual_margin_change, actual_cost_variance, actual_cost_change_pct, target_achieved, assessment, notes } = req.body const result = await query(` INSERT INTO public.dish_adjustment_result (adjustment_id, period_type, period_start, period_end, actual_sales_avg_daily, actual_sales_change_pct, actual_margin, actual_margin_change, actual_cost_variance, actual_cost_change_pct, target_achieved, assessment, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id `, [id, period_type, period_start, period_end, actual_sales_avg_daily, actual_sales_change_pct, actual_margin, actual_margin_change, actual_cost_variance, actual_cost_change_pct, target_achieved, assessment, notes]) sendSuccess(res, { id: result.rows[0].id }) } catch (err: any) { sendError(res, err.message) } }) export default router