feat: 菜品成本分析模块 - 10个Tab + 31个分析API + 7个调整管理API

- 后端: 新增 cost-analysis.ts 路由,含31个分析端点和7个调整管理端点
- 前端: 新增 CostAnalysisPage 主页面 + 10个Tab组件
  - Tab1-9: 成本总览/菜品盈利/原料差异/BOM配方/供应链/包装耗材/数据质量/门店成本/可视化探索
  - Tab10: 调整管理(诊断快照+调整记录+效果验证)
- 修复: 毛利率和损耗率从简单平均改为加权计算
- 新增: Tabs组件、侧边栏菜单项、路由注册
- 新增: 3张数据库表(诊断快照/调整记录/验证结果)
This commit is contained in:
freedakgmail
2026-07-28 16:56:45 +08:00
parent 722db28d06
commit 3d89baf65d
22 changed files with 4137 additions and 5 deletions
+2
View File
@@ -6,6 +6,7 @@ import { errorHandler, notFoundHandler } from './middleware/error.js'
import authRoutes from './routes/auth.js'
import dataRoutes from './routes/data.js'
import taskRoutes from './routes/tasks.js'
import costAnalysisRoutes from './routes/cost-analysis.js'
const app = express()
const PORT = parseInt(process.env.PORT || '3333')
@@ -31,6 +32,7 @@ app.use((req, res, next) => {
app.use('/api', dataRoutes)
app.use('/api/tasks', taskRoutes)
app.use('/api/cost-analysis', costAnalysisRoutes)
app.use(notFoundHandler)
app.use(errorHandler)
+978
View File
@@ -0,0 +1,978 @@
import { Router } from 'express'
import { query } from '../config/database.js'
import { sendSuccess, sendError, parsePagination } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
// ============ Tab1: 成本总览 ============
// 成本总览指标
router.get('/overview', async (req: AuthRequest, res) => {
try {
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
`)
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
// 品类成本对比
router.get('/category-comparison', async (req: AuthRequest, res) => {
try {
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
GROUP BY category_level1 ORDER BY total_sales DESC
`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// 毛利率偏差分布
router.get('/margin-deviation', async (req: AuthRequest, res) => {
try {
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
GROUP BY deviation_band ORDER BY cnt DESC
`)
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 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
ORDER BY cost_variance_amount ${order} LIMIT $1
`, [limit])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// ============ Tab2: 菜品盈利分析 ============
// 菜单工程矩阵
router.get('/menu-engineering', async (req: AuthRequest, res) => {
try {
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(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)
AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary WHERE 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)
AND actual_margin_rate_pct < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary WHERE 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)
AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct > 0) THEN '低销高利品'
ELSE '低销低利品'
END AS menu_type
FROM public.dish_cost_analysis_summary
WHERE actual_margin_rate_pct IS NOT NULL
`)
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
let where = 'WHERE actual_margin_rate_pct IS NOT NULL'
const params: any[] = []
if (category) { params.push(category); where += ` AND category_level1 = $${params.length}` }
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 sales_amount DESC 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 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
ORDER BY theoretical_margin_rate_pct ASC
`, [threshold])
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 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
ORDER BY m.loss_amount ASC
`, [dishCode])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// 损耗率分布
router.get('/loss-distribution', async (req: AuthRequest, res) => {
try {
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
GROUP BY loss_band ORDER BY cnt DESC
`)
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 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
WHERE m.loss_quantity < 0
GROUP BY material_name, material_type
ORDER BY ${order} ASC LIMIT $1
`, [limit])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// 物料类型损耗对比
router.get('/material-type-loss', async (req: AuthRequest, res) => {
try {
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
GROUP BY material_type ORDER BY total_loss_amount ASC
`)
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 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
ORDER BY material_count DESC 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 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 sales_amount DESC 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 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
ORDER BY sales_amount ASC, unique_material_count DESC 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 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
WHERE material_name ~* '餐盒|餐具|打包|纸巾|餐盒|调味包|餐盒|碗|袋|杯|盒'
`)
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 countResult = await query(`
SELECT count(DISTINCT material_name) FROM public.dish_cost_analysis_material_detail
WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒'
`)
const total = countResult.rows[0].count
const result = await query(`
SELECT material_name,
count(DISTINCT summary_id) AS dish_count,
round(sum(theoretical_quantity)::numeric, 2) AS total_qty,
round(sum(theoretical_amount)::numeric, 2) AS total_cost,
round(sum(loss_quantity)::numeric, 2) AS loss_qty,
round((sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate,
CASE
WHEN sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100 < -20 THEN '核查'
WHEN sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100 < -5 THEN '关注'
ELSE '正常'
END AS status
FROM public.dish_cost_analysis_material_detail
WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒'
GROUP BY material_name
ORDER BY total_cost DESC LIMIT $1 OFFSET $2
`, [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 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 analytics.v_dish_cost_analysis_latest_summary WHERE matched_to_dish_sales_by_name = true) AS matched_dishes,
(SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_summary) 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 WHERE actual_margin_rate_pct < 0) AS negative_margin_count,
(SELECT count(*) FROM public.dish_cost_analysis_summary WHERE theoretical_margin_rate_pct < 0) AS negative_theo_margin_count,
(SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_material_detail WHERE matched_to_inventory_by_name = true) AS matched_materials,
(SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_material_detail) AS total_materials
`)
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 countResult = await query(`SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_summary WHERE matched_to_dish_sales_by_name = false`)
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 analytics.v_dish_cost_analysis_latest_summary
WHERE matched_to_dish_sales_by_name = false
ORDER BY sales_amount DESC NULLS LAST LIMIT $1 OFFSET $2
`, [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 countResult = await query(`SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_material_detail WHERE matched_to_inventory_by_name = false`)
const total = countResult.rows[0].count
const result = await query(`
SELECT material_name, material_type,
count(DISTINCT summary_id) AS dish_count,
round(sum(loss_amount)::numeric, 2) AS loss_amount
FROM analytics.v_dish_cost_analysis_latest_material_detail
WHERE matched_to_inventory_by_name = false
GROUP BY material_name, material_type
ORDER BY dish_count DESC LIMIT $1 OFFSET $2
`, [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 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.v_store_theoretical_actual_cost_april
`)
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
// 门店地图数据
router.get('/store-map', async (req: AuthRequest, res) => {
try {
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.v_store_theoretical_actual_cost_april c
LEFT JOIN analytics.v_store_location_operating l ON l.store_code = c.store_code
WHERE l.latitude_gcj02 IS NOT NULL
`)
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 countResult = await query(`SELECT count(*) FROM analytics.v_store_theoretical_actual_cost_april`)
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.v_store_theoretical_actual_cost_april
ORDER BY food_cost_variance DESC LIMIT $1 OFFSET $2
`, [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 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
`)
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
let where = 'WHERE 1=1'
const params: any[] = []
if (priority) { params.push(priority); where += ` AND priority = $${params.length}` }
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 CASE priority WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END, 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
let where = 'WHERE 1=1'
const params: any[] = []
if (status) { params.push(status); where += ` AND status = $${params.length}` }
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 effective_date DESC, 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