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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user