feat: 门店费用分析模块 + 成本分析筛选排序功能
- 新增门店费用分析模块(9个Tab组件 + 后端路由) - 门店贡献利润、房租租约风险、外卖佣金分析、人效坪效 - 固定/变动费用、盈亏平衡分析、亏损门店诊断 - 关停/续租/改造评估、门店排名 - 门店费用分析9个Tab均支持筛选、排序、正倒序 - 成本分析8个Tab添加筛选、排序、正倒序功能 - 后端10个端点添加filter/sort/order参数支持 - 前端8个组件添加UI控件 - 修复门店评估分类逻辑和日期格式化 - 修复盈亏平衡销售额计算(贡献毛利率≤0时设为NULL)
This commit is contained in:
@@ -137,9 +137,19 @@ router.get('/profitability', async (req: AuthRequest, res) => {
|
||||
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) || ''
|
||||
|
||||
let where = 'WHERE actual_margin_rate_pct IS NOT NULL'
|
||||
const params: any[] = []
|
||||
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
|
||||
@@ -155,7 +165,7 @@ router.get('/profitability', async (req: AuthRequest, res) => {
|
||||
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}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||
`, params)
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -310,6 +320,17 @@ router.get('/bom-overview', async (req: AuthRequest, res) => {
|
||||
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
|
||||
|
||||
@@ -332,7 +353,8 @@ router.get('/bom-complexity', async (req: AuthRequest, res) => {
|
||||
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
|
||||
${having}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -372,6 +394,12 @@ router.get('/bom-high-loss', async (req: AuthRequest, res) => {
|
||||
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)
|
||||
@@ -385,7 +413,7 @@ router.get('/bom-missing', async (req: AuthRequest, res) => {
|
||||
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
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -449,6 +477,17 @@ router.get('/material-sharing', async (req: AuthRequest, res) => {
|
||||
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)
|
||||
@@ -472,7 +511,8 @@ router.get('/unique-material-risk', async (req: AuthRequest, res) => {
|
||||
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
|
||||
${having}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -561,6 +601,17 @@ router.get('/packaging-overview', async (req: AuthRequest, res) => {
|
||||
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 countResult = await query(`
|
||||
SELECT count(DISTINCT material_name) FROM public.dish_cost_analysis_material_detail
|
||||
WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒'
|
||||
@@ -582,7 +633,8 @@ router.get('/packaging-detail', async (req: AuthRequest, res) => {
|
||||
FROM public.dish_cost_analysis_material_detail
|
||||
WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒'
|
||||
GROUP BY material_name
|
||||
ORDER BY total_cost DESC LIMIT $1 OFFSET $2
|
||||
${having}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -618,6 +670,12 @@ router.get('/data-quality', async (req: AuthRequest, res) => {
|
||||
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 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
|
||||
|
||||
@@ -626,7 +684,7 @@ router.get('/unmatched-dishes', async (req: AuthRequest, res) => {
|
||||
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
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -638,6 +696,12 @@ router.get('/unmatched-dishes', async (req: AuthRequest, res) => {
|
||||
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 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
|
||||
|
||||
@@ -648,7 +712,7 @@ router.get('/unmatched-materials', async (req: AuthRequest, res) => {
|
||||
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
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -700,7 +764,20 @@ router.get('/store-map', async (req: AuthRequest, res) => {
|
||||
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 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 countResult = await query(`SELECT count(*) FROM analytics.v_store_theoretical_actual_cost_april ${where}`)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const result = await query(`
|
||||
@@ -712,7 +789,8 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
|
||||
negative_item_lines,
|
||||
variance_level
|
||||
FROM analytics.v_store_theoretical_actual_cost_april
|
||||
ORDER BY food_cost_variance DESC LIMIT $1 OFFSET $2
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -835,18 +913,30 @@ 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 CASE priority WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END, cost_variance_amount DESC NULLS LAST
|
||||
ORDER BY ${sortExpr}, cost_variance_amount DESC NULLS LAST
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||
`, params)
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
@@ -860,18 +950,23 @@ 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 effective_date DESC, created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user