Files
SBrainCO/server/src/routes/store-expense.ts
T

866 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(`
WITH full_scope AS (
SELECT
r.store_code,
r.store_name,
r.received,
r.theoretical_margin_pct,
COALESCE(e.operating_expense, 0) AS operating_expense,
COALESCE(e.wage_expense, 0) AS wage_expense,
COALESCE(e.rent_expense, 0) AS rent_expense,
COALESCE(e.utility_expense, 0) AS utility_expense,
COALESCE(e.dorm_expense, 0) AS dorm_expense,
COALESCE(e.delivery_commission_expense, 0) AS delivery_commission_expense,
COALESCE(e.card_fee_expense, 0) AS card_fee_expense,
COALESCE(e.repair_clean_expense, 0) AS repair_clean_expense,
COALESCE(e.actual_food_cost, 0) AS actual_food_cost,
COALESCE(e.theoretical_cost, r.received * (1 - COALESCE(r.theoretical_margin_pct, 0) / 100)) AS theoretical_cost,
COALESCE(e.actual_store_contribution, r.received - COALESCE(e.actual_food_cost, 0) - COALESCE(e.operating_expense, 0)) AS actual_store_contribution,
COALESCE(e.theoretical_store_contribution, r.received - r.received * (1 - COALESCE(r.theoretical_margin_pct, 0) / 100) - COALESCE(e.operating_expense, 0)) AS theoretical_store_contribution,
COALESCE(e.area_sqm, 0) AS area_sqm
FROM analytics.mv_store_risk_rating r
LEFT JOIN analytics.mv_store_operating_expense_monthly e
ON r.store_code = e.sales_store_code AND e.report_month = DATE '2026-04-01'
WHERE r.received IS NOT NULL
)
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 operating_expense = 0) AS no_expense_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(theoretical_cost)::numeric, 2) AS total_theoretical_cost,
round(sum(actual_store_contribution)::numeric, 2) AS total_contribution,
round(sum(theoretical_store_contribution)::numeric, 2) AS total_theoretical_contribution,
round(sum(received) - sum(theoretical_cost) - sum(operating_expense), 2) AS theoretical_net_profit,
round(sum(received) - sum(actual_food_cost) - sum(operating_expense), 2) AS actual_net_profit,
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(received) - sum(theoretical_cost) - sum(operating_expense)) / nullif(sum(received), 0) * 100, 2) AS theoretical_net_margin_pct,
round((sum(received) - sum(actual_food_cost) - sum(operating_expense)) / nullif(sum(received), 0) * 100, 2) AS actual_net_margin_pct,
round(sum(theoretical_cost) / nullif(sum(received), 0) * 100, 2) AS theoretical_food_cost_rate_pct,
round(sum(actual_food_cost) / nullif(sum(received), 0) * 100, 2) AS actual_food_cost_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 full_scope
`)
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