feat: 门店费用分析模块 + 成本分析筛选排序功能

- 新增门店费用分析模块(9个Tab组件 + 后端路由)
  - 门店贡献利润、房租租约风险、外卖佣金分析、人效坪效
  - 固定/变动费用、盈亏平衡分析、亏损门店诊断
  - 关停/续租/改造评估、门店排名
- 门店费用分析9个Tab均支持筛选、排序、正倒序
- 成本分析8个Tab添加筛选、排序、正倒序功能
  - 后端10个端点添加filter/sort/order参数支持
  - 前端8个组件添加UI控件
- 修复门店评估分类逻辑和日期格式化
- 修复盈亏平衡销售额计算(贡献毛利率≤0时设为NULL)
This commit is contained in:
freedakgmail
2026-07-29 08:22:09 +08:00
parent 3d89baf65d
commit 42e83696a0
24 changed files with 2304 additions and 32 deletions
+2
View File
@@ -7,6 +7,7 @@ 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'
import storeExpenseRoutes from './routes/store-expense.js'
const app = express()
const PORT = parseInt(process.env.PORT || '3333')
@@ -33,6 +34,7 @@ app.use((req, res, next) => {
app.use('/api', dataRoutes)
app.use('/api/tasks', taskRoutes)
app.use('/api/cost-analysis', costAnalysisRoutes)
app.use('/api/store-expense', storeExpenseRoutes)
app.use(notFoundHandler)
app.use(errorHandler)
+106 -11
View File
@@ -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) {
+834
View File
@@ -0,0 +1,834 @@
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_stores,
count(*) FILTER (WHERE actual_store_contribution > 0) AS profitable_stores,
count(*) FILTER (WHERE actual_store_contribution <= 0 AND received > 0) AS loss_stores,
count(*) FILTER (WHERE received = 0) AS zero_sales_stores,
round(sum(received)::numeric, 2) AS total_received,
round(sum(operating_expense)::numeric, 2) AS total_expense,
round(sum(wage_expense)::numeric, 2) AS total_wage,
round(sum(rent_expense)::numeric, 2) AS total_rent,
round(sum(utility_expense)::numeric, 2) AS total_utility,
round(sum(dorm_expense)::numeric, 2) AS total_dorm,
round(sum(delivery_commission_expense)::numeric, 2) AS total_commission,
round(sum(card_fee_expense)::numeric, 2) AS total_card_fee,
round(sum(repair_clean_expense)::numeric, 2) AS total_repair,
round(sum(actual_food_cost)::numeric, 2) AS total_food_cost,
round(sum(actual_store_contribution)::numeric, 2) AS total_contribution,
round(sum(area_sqm)::numeric, 2) AS total_area,
round((1 - sum(operating_expense) / nullif(sum(received), 0)) * 100, 2) AS overall_expense_rate_pct,
round((1 - (sum(actual_food_cost) + sum(operating_expense)) / nullif(sum(received), 0)) * 100, 2) AS overall_contribution_rate_pct,
round(sum(wage_expense) / nullif(sum(received), 0) * 100, 2) AS overall_wage_rate_pct,
round(sum(rent_expense) / nullif(sum(received), 0) * 100, 2) AS overall_rent_rate_pct,
round(sum(utility_expense) / nullif(sum(received), 0) * 100, 2) AS overall_utility_rate_pct
FROM analytics.mv_store_operating_expense_monthly
WHERE report_month = DATE '2026-04-01'
`)
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
// 费用结构分析
router.get('/expense-structure', async (req: AuthRequest, res) => {
try {
const result = await query(`
SELECT account_name,
round(amount::numeric, 2) AS amount,
round(expense_share_pct::numeric, 2) AS expense_share_pct,
nonzero_cost_unit_count
FROM analytics.v_operating_expense_account_monthly
WHERE report_month = DATE '2026-04-01'
ORDER BY amount DESC
`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// ============ Tab2: 门店费用率排名 ============
// 门店费用率排名
router.get('/store-ranking', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const sort = (req.query.sort as string) || 'operating_expense_rate_pct'
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
const filter = (req.query.filter as string) || ''
let where = `WHERE report_month = DATE '2026-04-01'`
const params: any[] = []
if (filter === 'loss') {
where += ` AND actual_store_contribution <= 0 AND received > 0`
} else if (filter === 'profitable') {
where += ` AND actual_store_contribution > 0`
} else if (filter === 'zero_sales') {
where += ` AND received = 0`
}
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
const total = countResult.rows[0].count
const validSorts = ['operating_expense_rate_pct', 'wage_rate_pct', 'rent_rate_pct', 'utility_rate_pct', 'received', 'operating_expense', 'actual_store_contribution', 'actual_store_contribution_rate_pct', 'received_per_sqm']
const sortCol = validSorts.includes(sort) ? sort : 'operating_expense_rate_pct'
const result = await query(`
SELECT sales_store_code, sales_store_name,
round(received::numeric, 2) AS received,
round(operating_expense::numeric, 2) AS operating_expense,
round(operating_expense_rate_pct::numeric, 2) AS operating_expense_rate_pct,
round(wage_expense::numeric, 2) AS wage_expense,
round(wage_rate_pct::numeric, 2) AS wage_rate_pct,
round(rent_expense::numeric, 2) AS rent_expense,
round(rent_rate_pct::numeric, 2) AS rent_rate_pct,
round(utility_expense::numeric, 2) AS utility_expense,
round(utility_rate_pct::numeric, 2) AS utility_rate_pct,
round(actual_food_cost::numeric, 2) AS actual_food_cost,
round(actual_store_contribution::numeric, 2) AS actual_store_contribution,
round(actual_store_contribution_rate_pct::numeric, 2) AS actual_store_contribution_rate_pct,
round(area_sqm::numeric, 2) AS area_sqm,
round(received_per_sqm::numeric, 2) AS received_per_sqm,
lease_expiry_date
FROM analytics.mv_store_operating_expense_monthly
${where}
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
`, [pageSize, offset])
sendSuccess(res, result.rows, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============ Tab3: 门店贡献利润 ============
// 门店贡献利润分析
router.get('/store-contribution', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const sort = (req.query.sort as string) || 'actual_store_contribution_rate_pct'
const order = (req.query.order as string) === 'desc' ? 'DESC' : 'ASC'
const filter = (req.query.filter as string) || ''
let where = `WHERE report_month = DATE '2026-04-01'`
if (filter === 'loss') {
where += ` AND actual_store_contribution <= 0 AND received > 0`
} else if (filter === 'profitable') {
where += ` AND actual_store_contribution > 0`
} else if (filter === 'zero_sales') {
where += ` AND received = 0`
} else {
where += ` AND received > 0`
}
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
const total = countResult.rows[0].count
const validSorts = ['received', 'actual_store_contribution', 'actual_store_contribution_rate_pct', 'theoretical_store_contribution', 'theoretical_store_contribution_rate_pct', 'contribution_variance', 'operating_expense_rate_pct', 'wage_rate_pct', 'rent_rate_pct', 'received_per_sqm']
const sortCol = validSorts.includes(sort) ? sort : 'actual_store_contribution_rate_pct'
const result = await query(`
SELECT sales_store_code, sales_store_name,
round(received::numeric, 2) AS received,
round(theoretical_cost::numeric, 2) AS theoretical_cost,
round(actual_food_cost::numeric, 2) AS actual_food_cost,
round(operating_expense::numeric, 2) AS operating_expense,
round(theoretical_store_contribution::numeric, 2) AS theoretical_store_contribution,
round(theoretical_store_contribution_rate_pct::numeric, 2) AS theoretical_store_contribution_rate_pct,
round(actual_store_contribution::numeric, 2) AS actual_store_contribution,
round(actual_store_contribution_rate_pct::numeric, 2) AS actual_store_contribution_rate_pct,
round((actual_store_contribution - theoretical_store_contribution)::numeric, 2) AS contribution_variance
FROM analytics.mv_store_operating_expense_monthly
${where}
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
`, [pageSize, offset])
sendSuccess(res, result.rows, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============ Tab4: 房租及租约风险 ============
// 房租及租约风险
router.get('/rent-risk', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const sort = (req.query.sort as string) || 'rent_rate_pct'
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
const filter = (req.query.filter as string) || ''
const riskOnly = req.query.risk_only === 'true'
let where = `WHERE report_month = DATE '2026-04-01' AND rent_expense IS NOT NULL`
if (filter === 'loss') {
where += ` AND actual_store_contribution <= 0 AND received > 0`
} else if (filter === 'profitable') {
where += ` AND actual_store_contribution > 0`
} else if (filter === 'zero_sales') {
where += ` AND received = 0`
}
if (riskOnly) {
where += ` AND (lease_expiry_date <= DATE '2026-12-31' OR rent_rate_pct > 20)`
}
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
const total = countResult.rows[0].count
const validSorts = ['rent_rate_pct', 'rent_expense', 'received', 'received_per_sqm', 'lease_expiry_date', 'operating_expense_rate_pct', 'actual_store_contribution']
const sortCol = validSorts.includes(sort) ? sort : 'rent_rate_pct'
const orderBy = sortCol === 'lease_expiry_date'
? `CASE WHEN lease_expiry_date IS NULL THEN 1 ELSE 0 END, lease_expiry_date ASC NULLS LAST`
: `${sortCol} ${order} NULLS LAST`
const result = await query(`
SELECT sales_store_code, sales_store_name,
round(received::numeric, 2) AS received,
round(rent_expense::numeric, 2) AS rent_expense,
round(rent_rate_pct::numeric, 2) AS rent_rate_pct,
round(area_sqm::numeric, 2) AS area_sqm,
round(received_per_sqm::numeric, 2) AS received_per_sqm,
lease_expiry_date,
CASE
WHEN lease_expiry_date <= DATE '2026-06-30' THEN '即将到期'
WHEN lease_expiry_date <= DATE '2026-12-31' THEN '年内到期'
WHEN lease_expiry_date <= DATE '2027-06-30' THEN '明年上半年到期'
WHEN rent_rate_pct > 20 THEN '租金占比偏高'
ELSE '正常'
END AS risk_level,
CASE
WHEN lease_expiry_date <= DATE '2026-06-30' THEN '需立即启动续租谈判或关停评估'
WHEN lease_expiry_date <= DATE '2026-12-31' THEN '需提前规划续租或迁址方案'
WHEN rent_rate_pct > 25 THEN '租金严重偏高,建议谈判降租或迁址'
WHEN rent_rate_pct > 20 THEN '租金占比偏高,关注续租条件'
ELSE '保持关注'
END AS suggestion
FROM analytics.mv_store_operating_expense_monthly
${where}
ORDER BY ${orderBy}
LIMIT $1 OFFSET $2
`, [pageSize, offset])
sendSuccess(res, result.rows, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============ Tab5: 外卖佣金分析 ============
// 外卖佣金分析
router.get('/delivery-commission', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const sort = (req.query.sort as string) || 'commission_to_delivery_sales_pct'
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
const filter = (req.query.filter as string) || ''
let where = `WHERE report_month = DATE '2026-04-01' AND delivery_received > 0`
if (filter === 'loss') {
where += ` AND actual_store_contribution <= 0`
} else if (filter === 'profitable') {
where += ` AND actual_store_contribution > 0`
}
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
const total = countResult.rows[0].count
const validSorts = ['commission_to_delivery_sales_pct', 'delivery_sales_share_pct', 'delivery_received', 'delivery_commission_expense', 'combined_platform_cost_rate_pct', 'received', 'actual_store_contribution']
const sortCol = validSorts.includes(sort) ? sort : 'commission_to_delivery_sales_pct'
const result = await query(`
SELECT sales_store_code, sales_store_name,
round(received::numeric, 2) AS received,
round(delivery_received::numeric, 2) AS delivery_received,
round(delivery_sales_share_pct::numeric, 2) AS delivery_sales_share_pct,
round(delivery_commission_expense::numeric, 2) AS delivery_commission_expense,
round(commission_to_delivery_sales_pct::numeric, 2) AS commission_to_delivery_sales_pct,
round(combined_platform_cost_rate_pct::numeric, 2) AS combined_platform_cost_rate_pct,
CASE
WHEN delivery_sales_share_pct > 40 THEN '重度依赖外卖'
WHEN delivery_sales_share_pct > 25 THEN '外卖占比较高'
WHEN delivery_sales_share_pct > 10 THEN '外卖占比正常'
ELSE '以堂食为主'
END AS delivery_type,
CASE
WHEN commission_to_delivery_sales_pct > 25 THEN '佣金率偏高,建议优化平台策略'
WHEN delivery_sales_share_pct > 40 THEN '外卖依赖度高,建议提升堂食客流'
WHEN combined_platform_cost_rate_pct > 30 THEN '综合平台成本率高,需评估外卖盈利性'
ELSE '外卖运营正常'
END AS suggestion
FROM analytics.mv_store_operating_expense_monthly
${where}
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
`, [pageSize, offset])
sendSuccess(res, result.rows, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============ Tab6: 人效坪效 ============
// 人效坪效分析
router.get('/efficiency', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const sort = (req.query.sort as string) || 'received_per_sqm'
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
const filter = (req.query.filter as string) || ''
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0 AND area_sqm IS NOT NULL`
if (filter === 'loss') {
where += ` AND actual_store_contribution <= 0`
} else if (filter === 'profitable') {
where += ` AND actual_store_contribution > 0`
}
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
const total = countResult.rows[0].count
const validSorts = ['received_per_sqm', 'wage_rate_pct', 'received', 'wage_expense', 'bill_count', 'avg_ticket_size', 'actual_store_contribution', 'operating_expense_rate_pct']
const sortCol = validSorts.includes(sort) ? sort : 'received_per_sqm'
const result = await query(`
SELECT sales_store_code, sales_store_name,
round(received::numeric, 2) AS received,
round(area_sqm::numeric, 2) AS area_sqm,
round(received_per_sqm::numeric, 2) AS received_per_sqm,
round(wage_expense::numeric, 2) AS wage_expense,
round(wage_rate_pct::numeric, 2) AS wage_rate_pct,
round(received / nullif(wage_expense, 0)::numeric, 2) AS revenue_per_wage,
round(bill_count::numeric, 0) AS bill_count,
round(received / nullif(bill_count, 0)::numeric, 2) AS avg_ticket_size,
CASE
WHEN received_per_sqm < 1000 THEN '坪效偏低'
WHEN received_per_sqm < 2000 THEN '坪效一般'
WHEN received_per_sqm < 4000 THEN '坪效良好'
ELSE '坪效优秀'
END AS efficiency_level,
CASE
WHEN wage_rate_pct > 35 THEN '人工成本率偏高,需优化排班或提升营业额'
WHEN received_per_sqm < 1000 AND area_sqm > 400 THEN '面积大产出低,考虑缩减面积或改造'
WHEN wage_rate_pct > 25 AND wage_rate_pct <= 35 THEN '人工成本率中等偏高,关注人效'
ELSE '运营效率正常'
END AS suggestion
FROM analytics.mv_store_operating_expense_monthly
${where}
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
`, [pageSize, offset])
sendSuccess(res, result.rows, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============ Tab7: 固定/变动费用拆分 ============
// 固定/变动费用拆分
router.get('/fixed-variable', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const sort = (req.query.sort as string) || 'fixed_rate_pct'
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
const filter = (req.query.filter as string) || ''
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0`
if (filter === 'loss') {
where += ` AND actual_store_contribution <= 0`
} else if (filter === 'profitable') {
where += ` AND actual_store_contribution > 0`
}
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
const total = countResult.rows[0].count
const validSorts = ['fixed_rate_pct', 'variable_rate_pct', 'received', 'fixed_expense', 'variable_expense', 'contribution_after_variable', 'break_even_sales', 'actual_store_contribution', 'operating_expense_rate_pct']
const sortCol = validSorts.includes(sort) ? sort : 'fixed_rate_pct'
const result = await query(`
SELECT sales_store_code, sales_store_name,
round(received::numeric, 2) AS received,
round((rent_expense + dorm_expense + repair_clean_expense * 0.5)::numeric, 2) AS fixed_expense,
round((wage_expense + utility_expense + delivery_commission_expense + card_fee_expense + repair_clean_expense * 0.5)::numeric, 2) AS variable_expense,
round(rent_expense::numeric, 2) AS rent_expense,
round(dorm_expense::numeric, 2) AS dorm_expense,
round(wage_expense::numeric, 2) AS wage_expense,
round(utility_expense::numeric, 2) AS utility_expense,
round(delivery_commission_expense::numeric, 2) AS delivery_commission_expense,
round(card_fee_expense::numeric, 2) AS card_fee_expense,
round(repair_clean_expense::numeric, 2) AS repair_clean_expense,
round((rent_expense + dorm_expense + repair_clean_expense * 0.5) / nullif(received, 0) * 100::numeric, 2) AS fixed_rate_pct,
round((wage_expense + utility_expense + delivery_commission_expense + card_fee_expense + repair_clean_expense * 0.5) / nullif(received, 0) * 100::numeric, 2) AS variable_rate_pct,
round((received - actual_food_cost - wage_expense - utility_expense - delivery_commission_expense - card_fee_expense - repair_clean_expense * 0.5)::numeric, 2) AS contribution_after_variable,
round((rent_expense + dorm_expense + repair_clean_expense * 0.5)::numeric, 2) AS break_even_sales
FROM analytics.mv_store_operating_expense_monthly
${where}
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
`, [pageSize, offset])
sendSuccess(res, result.rows, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============ Tab8: 盈亏平衡分析 ============
// 盈亏平衡分析
router.get('/break-even', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const sort = (req.query.sort as string) || 'safety_margin_pct'
const order = (req.query.order as string) === 'desc' ? 'DESC' : 'ASC'
const filter = (req.query.filter as string) || ''
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0`
if (filter === 'loss') {
where += ` AND actual_store_contribution <= 0`
} else if (filter === 'profitable') {
where += ` AND actual_store_contribution > 0`
}
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
const total = countResult.rows[0].count
const validSorts = ['safety_margin_pct', 'break_even_sales', 'sales_gap', 'food_cost_rate_pct', 'expense_rate_pct', 'received', 'actual_store_contribution', 'operating_expense_rate_pct']
const sortCol = validSorts.includes(sort) ? sort : 'safety_margin_pct'
const result = await query(`
WITH base AS (
SELECT sales_store_code, sales_store_name,
received, actual_food_cost, operating_expense,
actual_food_cost / nullif(received, 0) AS food_cost_ratio,
operating_expense / nullif(received, 0) AS expense_ratio,
1 - actual_food_cost / nullif(received, 0) AS contribution_margin_ratio,
CASE
WHEN 1 - actual_food_cost / nullif(received, 0) <= 0 THEN NULL
ELSE operating_expense / nullif(1 - actual_food_cost / nullif(received, 0), 0)
END AS break_even_sales
FROM analytics.mv_store_operating_expense_monthly
${where}
)
SELECT sales_store_code, sales_store_name,
round(received::numeric, 2) AS received,
round(actual_food_cost::numeric, 2) AS actual_food_cost,
round(operating_expense::numeric, 2) AS operating_expense,
round((food_cost_ratio * 100)::numeric, 2) AS food_cost_rate_pct,
round((expense_ratio * 100)::numeric, 2) AS expense_rate_pct,
round(break_even_sales::numeric, 2) AS break_even_sales,
round((received - break_even_sales)::numeric, 2) AS sales_gap,
round(((received - break_even_sales) / nullif(break_even_sales, 0) * 100)::numeric, 2) AS safety_margin_pct,
CASE
WHEN contribution_margin_ratio <= 0 THEN '无法盈利'
WHEN received < break_even_sales THEN '未达盈亏平衡'
WHEN (received - break_even_sales) / nullif(break_even_sales, 0) * 100 < 15 THEN '接近盈亏平衡'
WHEN (received - break_even_sales) / nullif(break_even_sales, 0) * 100 < 30 THEN '安全边际较低'
ELSE '安全边际充足'
END AS safety_status
FROM base
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
`, [pageSize, offset])
sendSuccess(res, result.rows, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============ Tab9: 亏损门店诊断 ============
// 亏损门店诊断 - 自动分析亏损原因并给出建议
router.get('/loss-diagnosis', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const sort = (req.query.sort as string) || 'actual_store_contribution'
const order = (req.query.order as string) === 'desc' ? 'DESC' : 'ASC'
const filter = (req.query.filter as string) || ''
let where = `WHERE report_month = DATE '2026-04-01' AND actual_store_contribution <= 0 AND received > 0`
if (filter === 'P0') {
where += ` AND actual_store_contribution_rate_pct < -20`
} else if (filter === 'P1') {
where += ` AND actual_store_contribution_rate_pct >= -20 AND actual_store_contribution_rate_pct < -5`
} else if (filter === 'P2') {
where += ` AND actual_store_contribution_rate_pct >= -5 AND actual_store_contribution_rate_pct <= 0`
}
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
const total = countResult.rows[0].count
const validSorts = ['actual_store_contribution', 'actual_store_contribution_rate_pct', 'received', 'wage_rate_pct', 'rent_rate_pct', 'utility_rate_pct', 'received_per_sqm', 'operating_expense_rate_pct']
const sortCol = validSorts.includes(sort) ? sort : 'actual_store_contribution'
const result = await query(`
SELECT sales_store_code, sales_store_name,
round(received::numeric, 2) AS received,
round(actual_food_cost::numeric, 2) AS actual_food_cost,
round(operating_expense::numeric, 2) AS operating_expense,
round(actual_store_contribution::numeric, 2) AS actual_store_contribution,
round(actual_store_contribution_rate_pct::numeric, 2) AS actual_store_contribution_rate_pct,
round(wage_expense::numeric, 2) AS wage_expense,
round(wage_rate_pct::numeric, 2) AS wage_rate_pct,
round(rent_expense::numeric, 2) AS rent_expense,
round(rent_rate_pct::numeric, 2) AS rent_rate_pct,
round(utility_expense::numeric, 2) AS utility_expense,
round(utility_rate_pct::numeric, 2) AS utility_rate_pct,
round(dorm_expense::numeric, 2) AS dorm_expense,
round(delivery_commission_expense::numeric, 2) AS delivery_commission_expense,
round(delivery_received::numeric, 2) AS delivery_received,
round(delivery_sales_share_pct::numeric, 2) AS delivery_sales_share_pct,
round(area_sqm::numeric, 2) AS area_sqm,
round(received_per_sqm::numeric, 2) AS received_per_sqm,
round(bill_count::numeric, 0) AS bill_count,
lease_expiry_date,
round(theoretical_cost::numeric, 2) AS theoretical_cost,
round(theoretical_store_contribution::numeric, 2) AS theoretical_store_contribution,
round(theoretical_store_contribution_rate_pct::numeric, 2) AS theoretical_store_contribution_rate_pct,
CASE
WHEN received < 10000 THEN '收入极低'
WHEN received < 50000 THEN '收入偏低'
ELSE '收入正常'
END AS revenue_status,
CASE
WHEN actual_food_cost / nullif(received, 0) * 100 > 50 THEN '食材成本率过高'
WHEN actual_food_cost / nullif(received, 0) * 100 > 40 THEN '食材成本率偏高'
ELSE '食材成本率正常'
END AS food_cost_status,
CASE
WHEN wage_rate_pct > 50 THEN '人工成本率严重过高'
WHEN wage_rate_pct > 35 THEN '人工成本率过高'
WHEN wage_rate_pct > 25 THEN '人工成本率偏高'
ELSE '人工成本率正常'
END AS wage_status,
CASE
WHEN rent_rate_pct > 25 THEN '租金占比严重过高'
WHEN rent_rate_pct > 20 THEN '租金占比过高'
WHEN rent_rate_pct > 15 THEN '租金占比偏高'
ELSE '租金占比正常'
END AS rent_status,
CASE
WHEN utility_rate_pct > 15 THEN '水电燃气率过高'
WHEN utility_rate_pct > 10 THEN '水电燃气率偏高'
ELSE '水电燃气率正常'
END AS utility_status,
CASE
WHEN delivery_sales_share_pct > 40 THEN '外卖依赖度过高'
WHEN delivery_sales_share_pct > 25 THEN '外卖占比较高'
ELSE '外卖占比正常'
END AS delivery_status,
CASE
WHEN received_per_sqm < 500 THEN '坪效极低'
WHEN received_per_sqm < 1000 THEN '坪效偏低'
ELSE '坪效正常'
END AS efficiency_status,
CASE
WHEN lease_expiry_date IS NOT NULL AND lease_expiry_date <= DATE '2026-12-31' THEN '租约即将到期'
ELSE '租约正常'
END AS lease_status,
CASE
WHEN theoretical_store_contribution <= 0 THEN '理论即亏损'
WHEN theoretical_store_contribution > 0 AND actual_store_contribution <= 0 THEN '实际超耗导致亏损'
ELSE '费用过高导致亏损'
END AS loss_type
FROM analytics.mv_store_operating_expense_monthly
${where}
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
`, [pageSize, offset])
// 为每个亏损门店生成诊断原因和建议
const rows = result.rows.map((r: any) => {
const reasons: string[] = []
const suggestions: string[] = []
const actions: string[] = []
// 收入问题
if (r.revenue_status === '收入极低') {
reasons.push('门店收入极低,可能处于停业或半停业状态')
suggestions.push('核查门店是否正常营业,评估是否值得继续经营')
actions.push('核查营业状态')
} else if (r.revenue_status === '收入偏低') {
reasons.push('门店收入偏低,无法覆盖固定费用')
suggestions.push('提升客流和客单价,开展营销活动拉动销售')
actions.push('提升营业额')
}
// 食材成本问题
if (r.food_cost_status === '食材成本率过高') {
reasons.push(`食材成本率高达${Math.round(r.actual_food_cost / r.received * 100)}%,远超行业标杆`)
suggestions.push('核查BOM配方准确性、物料损耗和采购成本,优化高成本菜品')
actions.push('优化食材成本')
} else if (r.food_cost_status === '食材成本率偏高') {
reasons.push(`食材成本率${Math.round(r.actual_food_cost / r.received * 100)}%偏高`)
suggestions.push('关注物料损耗和超耗菜品,优化配方和份量')
actions.push('控制食材成本')
}
// 人工成本问题
if (r.wage_status === '人工成本率严重过高') {
reasons.push(`人工成本率${r.wage_rate_pct}%严重过高`)
suggestions.push('大幅优化排班、减少冗余人员,考虑合并岗位或引入兼职')
actions.push('优化人工配置')
} else if (r.wage_status === '人工成本率过高') {
reasons.push(`人工成本率${r.wage_rate_pct}%过高`)
suggestions.push('优化排班效率,评估人员配置是否合理')
actions.push('优化排班')
} else if (r.wage_status === '人工成本率偏高') {
reasons.push(`人工成本率${r.wage_rate_pct}%偏高`)
suggestions.push('关注人效指标,适度优化排班')
actions.push('关注人效')
}
// 租金问题
if (r.rent_status === '租金占比严重过高') {
reasons.push(`租金占比${r.rent_rate_pct}%严重过高`)
suggestions.push('与房东谈判降租,或评估迁址/关停方案')
actions.push('谈判降租或迁址')
} else if (r.rent_status === '租金占比过高') {
reasons.push(`租金占比${r.rent_rate_pct}%过高`)
suggestions.push('关注续租条件,争取降低租金')
actions.push('关注租金')
}
// 水电燃气问题
if (r.utility_status === '水电燃气率过高') {
reasons.push(`水电燃气率${r.utility_rate_pct}%过高`)
suggestions.push('排查是否有漏水漏气,优化能源使用')
actions.push('排查能源浪费')
}
// 外卖依赖问题
if (r.delivery_status === '外卖依赖度过高') {
reasons.push(`外卖占比${r.delivery_sales_share_pct}%过高,佣金侵蚀利润`)
suggestions.push('提升堂食客流,优化外卖平台策略,减少佣金支出')
actions.push('降低外卖依赖')
} else if (r.delivery_status === '外卖占比较高') {
reasons.push(`外卖占比${r.delivery_sales_share_pct}%较高`)
suggestions.push('关注外卖佣金成本,提升堂食比例')
actions.push('优化外卖结构')
}
// 坪效问题
if (r.efficiency_status === '坪效极低') {
reasons.push(`坪效仅${r.received_per_sqm}元/㎡,面积利用率极低`)
suggestions.push('考虑缩减面积、改造布局或迁址至更小店面')
actions.push('缩减面积或迁址')
} else if (r.efficiency_status === '坪效偏低') {
reasons.push(`坪效${r.received_per_sqm}元/㎡偏低`)
suggestions.push('优化空间利用,提升单位面积产出')
actions.push('提升坪效')
}
// 租约到期问题
if (r.lease_status === '租约即将到期') {
reasons.push(`租约将于${r.lease_expiry_date}到期`)
suggestions.push('立即启动续租谈判或关停评估,把握决策窗口')
actions.push('启动续租评估')
}
// 亏损类型综合判断
if (r.loss_type === '理论即亏损') {
reasons.unshift('按理论成本计算已亏损,定价或成本结构存在根本问题')
suggestions.unshift('重新评估菜品定价策略和成本结构,必要时调整菜单价格')
actions.unshift('调整定价策略')
} else if (r.loss_type === '实际超耗导致亏损') {
reasons.unshift('理论贡献为正但实际亏损,超耗是主要亏损原因')
suggestions.unshift('重点排查物料超耗、损耗和浪费,参考菜品成本分析模块')
actions.unshift('治理物料超耗')
} else if (r.loss_type === '费用过高导致亏损') {
reasons.unshift('食材成本正常但费用过高导致亏损')
suggestions.unshift('重点削减人工、租金等费用支出')
actions.unshift('削减费用')
}
// 优先级判断
let priority = 'P3'
if (r.actual_store_contribution_rate_pct < -20) priority = 'P0'
else if (r.actual_store_contribution_rate_pct < -5) priority = 'P1'
else if (r.actual_store_contribution_rate_pct < 0) priority = 'P2'
return {
...r,
diagnosis_reasons: reasons.join(''),
diagnosis_suggestions: suggestions.join(''),
suggested_actions: actions.join('、'),
priority
}
})
sendSuccess(res, rows, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============ Tab10: 门店关停/续租/改造评估 ============
// 门店关停/续租/改造评估
router.get('/store-evaluation', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const sort = (req.query.sort as string) || 'actual_store_contribution_rate_pct'
const order = (req.query.order as string) === 'desc' ? 'DESC' : 'ASC'
const filter = (req.query.filter as string) || ''
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0`
if (filter === 'loss') {
where += ` AND actual_store_contribution <= 0`
} else if (filter === 'profitable') {
where += ` AND actual_store_contribution > 0`
}
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
const total = countResult.rows[0].count
const validSorts = ['actual_store_contribution_rate_pct', 'received', 'actual_store_contribution', 'received_per_sqm', 'wage_rate_pct', 'rent_rate_pct', 'operating_expense_rate_pct']
const sortCol = validSorts.includes(sort) ? sort : 'actual_store_contribution_rate_pct'
const result = await query(`
SELECT sales_store_code, sales_store_name,
round(received::numeric, 2) AS received,
round(actual_food_cost::numeric, 2) AS actual_food_cost,
round(operating_expense::numeric, 2) AS operating_expense,
round(actual_store_contribution::numeric, 2) AS actual_store_contribution,
round(actual_store_contribution_rate_pct::numeric, 2) AS actual_store_contribution_rate_pct,
round(wage_rate_pct::numeric, 2) AS wage_rate_pct,
round(rent_rate_pct::numeric, 2) AS rent_rate_pct,
round(received_per_sqm::numeric, 2) AS received_per_sqm,
round(area_sqm::numeric, 2) AS area_sqm,
lease_expiry_date,
round(theoretical_store_contribution::numeric, 2) AS theoretical_store_contribution,
CASE
WHEN received = 0 OR received < 5000 THEN '关停评估'
WHEN actual_store_contribution_rate_pct < -20 AND lease_expiry_date <= DATE '2026-12-31' THEN '关停评估'
WHEN actual_store_contribution_rate_pct < -10 AND received_per_sqm < 1000 THEN '关停评估'
WHEN actual_store_contribution_rate_pct < 0 AND lease_expiry_date <= DATE '2026-12-31' THEN '关停或迁址评估'
WHEN actual_store_contribution_rate_pct < 0 AND received_per_sqm < 1000 AND area_sqm > 400 THEN '改造评估'
WHEN actual_store_contribution_rate_pct < 0 AND wage_rate_pct > 35 THEN '改造评估'
WHEN actual_store_contribution_rate_pct < 0 THEN '关注观察'
WHEN lease_expiry_date <= DATE '2026-06-30' THEN '续租评估'
WHEN actual_store_contribution_rate_pct > 0 AND actual_store_contribution_rate_pct < 10 THEN '关注观察'
ELSE '正常经营'
END AS evaluation_type,
CASE
WHEN received = 0 OR received < 5000 THEN 'P0'
WHEN actual_store_contribution_rate_pct < -20 THEN 'P0'
WHEN actual_store_contribution_rate_pct < -10 THEN 'P1'
WHEN actual_store_contribution_rate_pct < 0 THEN 'P2'
WHEN actual_store_contribution_rate_pct < 10 THEN 'P3'
ELSE 'P4'
END AS priority
FROM analytics.mv_store_operating_expense_monthly
${where}
ORDER BY
CASE
WHEN received = 0 OR received < 5000 THEN 0
WHEN actual_store_contribution_rate_pct < -20 THEN 1
WHEN actual_store_contribution_rate_pct < -10 THEN 2
WHEN actual_store_contribution_rate_pct < 0 THEN 3
WHEN actual_store_contribution_rate_pct < 10 THEN 4
ELSE 5
END,
${sortCol} ${order} NULLS LAST
LIMIT $1 OFFSET $2
`, [pageSize, offset])
// 格式化日期
const fmtDate = (d: any) => d ? new Date(d).toLocaleDateString('zh-CN') : '-'
// 为每个门店生成评估详情和建议
const rows = result.rows.map((r: any) => {
let detail = ''
let suggestion = ''
switch (r.evaluation_type) {
case '关停评估':
detail = r.received < 5000
? `月收入仅${r.received}元,基本无营业活动,继续经营只会增加亏损`
: `贡献利润率${r.actual_store_contribution_rate_pct}%,严重亏损且租约即将到期,关停可止损`
suggestion = r.received < 5000
? '建议立即关停,减少固定费用支出;若为新店筹备期则明确开业时间表'
: '建议到期不再续租,提前制定关停计划(人员分流、设备调拨、会员退款)'
break
case '关停或迁址评估':
detail = `贡献利润率${r.actual_store_contribution_rate_pct}%,亏损运营且租约${fmtDate(r.lease_expiry_date)}到期`
suggestion = '评估周边市场潜力:若商圈有潜力则谈判降租续营,否则到期关停并寻找更低租金位置迁址'
break
case '改造评估':
if (r.received_per_sqm < 1000 && r.area_sqm > 400) {
detail = `坪效${r.received_per_sqm}元/㎡偏低,面积${r.area_sqm}㎡过大,空间利用率不足`
suggestion = '考虑缩减营业面积(退租部分区域),或改造空间布局增加外卖/零售功能'
} else {
detail = `人工成本率${r.wage_rate_pct}%偏高且持续亏损,运营效率需提升`
suggestion = '优化人员配置和排班制度,引入数字化点餐减少人工依赖,必要时进行门店改造'
}
break
case '续租评估':
detail = `租约${fmtDate(r.lease_expiry_date)}即将到期,当前贡献利润率${r.actual_store_contribution_rate_pct}%`
suggestion = '若盈利则尽快启动续租谈判锁定租金,若微利则谈判降租条件,设定续租租金上限'
break
case '关注观察':
detail = r.actual_store_contribution_rate_pct < 0
? `贡献利润率${r.actual_store_contribution_rate_pct}%,持续亏损需关注`
: `贡献利润率仅${r.actual_store_contribution_rate_pct}%,盈利能力薄弱`
suggestion = r.actual_store_contribution_rate_pct < 0
? '分析亏损原因,制定改善计划提升营业额或降低费用,避免亏损扩大'
: '持续监控经营状况,制定改善计划提升营业额或降低费用,避免滑入亏损'
break
default:
detail = '经营状况正常'
suggestion = '保持现有运营水平,持续优化费用结构'
}
return { ...r, lease_expiry_date: r.lease_expiry_date ? new Date(r.lease_expiry_date).toISOString().split('T')[0] : null, evaluation_detail: detail, evaluation_suggestion: suggestion }
})
// 全量统计各评估类型数量
const statsResult = await query(`
SELECT
count(*) FILTER (WHERE received = 0 OR received < 5000 OR (actual_store_contribution_rate_pct < -20 AND lease_expiry_date <= DATE '2026-12-31') OR (actual_store_contribution_rate_pct < -10 AND received_per_sqm < 1000)) AS "关停评估",
count(*) FILTER (WHERE actual_store_contribution_rate_pct < 0 AND received_per_sqm < 1000 AND area_sqm > 400) + count(*) FILTER (WHERE actual_store_contribution_rate_pct < 0 AND wage_rate_pct > 35) AS "改造评估",
count(*) FILTER (WHERE lease_expiry_date <= DATE '2026-06-30' AND actual_store_contribution_rate_pct >= 0) AS "续租评估",
count(*) FILTER (WHERE actual_store_contribution_rate_pct < 0 AND lease_expiry_date > DATE '2026-12-31' AND NOT (received_per_sqm < 1000 AND area_sqm > 400) AND wage_rate_pct <= 35) + count(*) FILTER (WHERE actual_store_contribution_rate_pct > 0 AND actual_store_contribution_rate_pct < 10) AS "关注观察"
FROM analytics.mv_store_operating_expense_monthly
${where}
`)
const evalStats = statsResult.rows[0]
sendSuccess(res, rows, { page, pageSize, total, evalStats })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============ 数据质量 ============
// 费用数据质量
router.get('/data-quality', async (req: AuthRequest, res) => {
try {
const result = await query(`
SELECT * FROM analytics.v_operating_expense_data_quality
`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
export default router