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