feat: 月度模式全面参数化 - 移除硬编码日期,前后端按月动态查询
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
import { query } from '../config/database.js'
|
||||
import { sendSuccess, sendError, parsePagination } from '../middleware/error.js'
|
||||
import { sendSuccess, sendError, parsePagination, parseMonth } from '../middleware/error.js'
|
||||
import type { AuthRequest } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -10,6 +10,7 @@ const router = Router()
|
||||
// 费用总览指标
|
||||
router.get('/overview', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const result = await query(`
|
||||
WITH full_scope AS (
|
||||
SELECT
|
||||
@@ -18,29 +19,29 @@ router.get('/overview', async (req: AuthRequest, res) => {
|
||||
r.received,
|
||||
r.bill_count,
|
||||
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,
|
||||
e.operating_expense,
|
||||
e.wage_expense,
|
||||
e.rent_expense,
|
||||
e.utility_expense,
|
||||
e.dorm_expense,
|
||||
e.delivery_commission_expense,
|
||||
e.card_fee_expense,
|
||||
e.repair_clean_expense,
|
||||
e.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
|
||||
CASE WHEN e.operating_expense IS NOT NULL THEN (r.received - e.actual_food_cost - e.operating_expense) ELSE NULL END AS actual_store_contribution,
|
||||
CASE WHEN e.operating_expense IS NOT NULL THEN (r.received - r.received * (1 - COALESCE(r.theoretical_margin_pct, 0) / 100) - e.operating_expense) ELSE NULL END AS theoretical_store_contribution,
|
||||
e.area_sqm
|
||||
FROM analytics.fn_store_risk_rating($1) 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'
|
||||
ON r.store_code = e.sales_store_code AND e.report_month = $1::date
|
||||
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,
|
||||
count(*) FILTER (WHERE actual_store_contribution <= 0 AND received > 0 AND actual_store_contribution IS NOT NULL) AS loss_stores,
|
||||
count(*) FILTER (WHERE operating_expense IS NULL) AS no_expense_stores,
|
||||
sum(bill_count)::bigint AS total_bills,
|
||||
round(sum(received)::numeric, 2) AS total_received,
|
||||
round(sum(received) / nullif(sum(bill_count), 0), 2) AS avg_bill_value,
|
||||
@@ -69,7 +70,7 @@ router.get('/overview', async (req: AuthRequest, res) => {
|
||||
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
|
||||
`)
|
||||
`, [month])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
@@ -79,15 +80,16 @@ router.get('/overview', async (req: AuthRequest, res) => {
|
||||
// 费用结构分析
|
||||
router.get('/expense-structure', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
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'
|
||||
WHERE report_month = $1::date
|
||||
ORDER BY amount DESC
|
||||
`)
|
||||
`, [month])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
@@ -103,9 +105,10 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
|
||||
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) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01'`
|
||||
const params: any[] = []
|
||||
let where = `WHERE report_month = $1::date`
|
||||
const params: any[] = [month]
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0 AND received > 0`
|
||||
} else if (filter === 'profitable') {
|
||||
@@ -114,7 +117,7 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
|
||||
where += ` AND received = 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
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']
|
||||
@@ -140,7 +143,7 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [pageSize, offset])
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -157,8 +160,10 @@ router.get('/store-contribution', async (req: AuthRequest, res) => {
|
||||
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) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01'`
|
||||
let where = `WHERE report_month = $1::date`
|
||||
const params: any[] = [month]
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0 AND received > 0`
|
||||
} else if (filter === 'profitable') {
|
||||
@@ -169,7 +174,7 @@ router.get('/store-contribution', async (req: AuthRequest, res) => {
|
||||
where += ` AND received > 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
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']
|
||||
@@ -188,8 +193,8 @@ router.get('/store-contribution', async (req: AuthRequest, res) => {
|
||||
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])
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -207,8 +212,10 @@ router.get('/rent-risk', async (req: AuthRequest, res) => {
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
const riskOnly = req.query.risk_only === 'true'
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND rent_expense IS NOT NULL`
|
||||
let where = `WHERE report_month = $1::date AND rent_expense IS NOT NULL`
|
||||
const params: any[] = [month]
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0 AND received > 0`
|
||||
} else if (filter === 'profitable') {
|
||||
@@ -217,10 +224,10 @@ router.get('/rent-risk', async (req: AuthRequest, res) => {
|
||||
where += ` AND received = 0`
|
||||
}
|
||||
if (riskOnly) {
|
||||
where += ` AND (lease_expiry_date <= DATE '2026-12-31' OR rent_rate_pct > 20)`
|
||||
where += ` AND (lease_expiry_date <= $1::date + interval '12 months' OR rent_rate_pct > 20)`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
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']
|
||||
@@ -238,15 +245,15 @@ router.get('/rent-risk', async (req: AuthRequest, res) => {
|
||||
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 lease_expiry_date <= $1::date + interval '3 months' THEN '即将到期'
|
||||
WHEN lease_expiry_date <= $1::date + interval '12 months' THEN '年内到期'
|
||||
WHEN lease_expiry_date <= $1::date + interval '18 months' 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 lease_expiry_date <= $1::date + interval '3 months' THEN '需立即启动续租谈判或关停评估'
|
||||
WHEN lease_expiry_date <= $1::date + interval '12 months' THEN '需提前规划续租或迁址方案'
|
||||
WHEN rent_rate_pct > 25 THEN '租金严重偏高,建议谈判降租或迁址'
|
||||
WHEN rent_rate_pct > 20 THEN '租金占比偏高,关注续租条件'
|
||||
ELSE '保持关注'
|
||||
@@ -254,8 +261,8 @@ router.get('/rent-risk', async (req: AuthRequest, res) => {
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -272,15 +279,17 @@ router.get('/delivery-commission', async (req: AuthRequest, res) => {
|
||||
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) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND delivery_received > 0`
|
||||
let where = `WHERE report_month = $1::date AND delivery_received > 0`
|
||||
const params: any[] = [month]
|
||||
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 countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
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']
|
||||
@@ -308,8 +317,8 @@ router.get('/delivery-commission', async (req: AuthRequest, res) => {
|
||||
END AS suggestion
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -326,15 +335,17 @@ router.get('/efficiency', async (req: AuthRequest, res) => {
|
||||
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) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0 AND area_sqm IS NOT NULL`
|
||||
let where = `WHERE report_month = $1::date AND received > 0 AND area_sqm IS NOT NULL`
|
||||
const params: any[] = [month]
|
||||
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 countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
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']
|
||||
@@ -364,8 +375,8 @@ router.get('/efficiency', async (req: AuthRequest, res) => {
|
||||
END AS suggestion
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -382,15 +393,17 @@ router.get('/fixed-variable', async (req: AuthRequest, res) => {
|
||||
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) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0`
|
||||
let where = `WHERE report_month = $1::date AND received > 0`
|
||||
const params: any[] = [month]
|
||||
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 countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
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']
|
||||
@@ -414,8 +427,8 @@ router.get('/fixed-variable', async (req: AuthRequest, res) => {
|
||||
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])
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -432,15 +445,17 @@ router.get('/break-even', async (req: AuthRequest, res) => {
|
||||
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) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0`
|
||||
let where = `WHERE report_month = $1::date AND received > 0`
|
||||
const params: any[] = [month]
|
||||
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 countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
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']
|
||||
@@ -477,8 +492,8 @@ router.get('/break-even', async (req: AuthRequest, res) => {
|
||||
ELSE '安全边际充足'
|
||||
END AS safety_status
|
||||
FROM base
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -495,8 +510,10 @@ router.get('/loss-diagnosis', async (req: AuthRequest, res) => {
|
||||
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) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND actual_store_contribution <= 0 AND received > 0`
|
||||
let where = `WHERE report_month = $1::date AND actual_store_contribution <= 0 AND received > 0`
|
||||
const params: any[] = [month]
|
||||
if (filter === 'P0') {
|
||||
where += ` AND actual_store_contribution_rate_pct < -20`
|
||||
} else if (filter === 'P1') {
|
||||
@@ -505,7 +522,7 @@ router.get('/loss-diagnosis', async (req: AuthRequest, res) => {
|
||||
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 countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
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']
|
||||
@@ -573,7 +590,7 @@ router.get('/loss-diagnosis', async (req: AuthRequest, res) => {
|
||||
ELSE '坪效正常'
|
||||
END AS efficiency_status,
|
||||
CASE
|
||||
WHEN lease_expiry_date IS NOT NULL AND lease_expiry_date <= DATE '2026-12-31' THEN '租约即将到期'
|
||||
WHEN lease_expiry_date IS NOT NULL AND lease_expiry_date <= $1::date + interval '12 months' THEN '租约即将到期'
|
||||
ELSE '租约正常'
|
||||
END AS lease_status,
|
||||
CASE
|
||||
@@ -583,8 +600,8 @@ router.get('/loss-diagnosis', async (req: AuthRequest, res) => {
|
||||
END AS loss_type
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
// 为每个亏损门店生成诊断原因和建议
|
||||
const rows = result.rows.map((r: any) => {
|
||||
@@ -721,15 +738,17 @@ router.get('/store-evaluation', async (req: AuthRequest, res) => {
|
||||
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) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0`
|
||||
let where = `WHERE report_month = $1::date AND received > 0`
|
||||
const params: any[] = [month]
|
||||
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 countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
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']
|
||||
@@ -750,13 +769,13 @@ router.get('/store-evaluation', async (req: AuthRequest, res) => {
|
||||
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 < -20 AND lease_expiry_date <= $1::date + interval '12 months' 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 lease_expiry_date <= $1::date + interval '12 months' 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 lease_expiry_date <= $1::date + interval '3 months' THEN '续租评估'
|
||||
WHEN actual_store_contribution_rate_pct > 0 AND actual_store_contribution_rate_pct < 10 THEN '关注观察'
|
||||
ELSE '正常经营'
|
||||
END AS evaluation_type,
|
||||
@@ -780,8 +799,8 @@ router.get('/store-evaluation', async (req: AuthRequest, res) => {
|
||||
ELSE 5
|
||||
END,
|
||||
${sortCol} ${order} NULLS LAST
|
||||
LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
// 格式化日期
|
||||
const fmtDate = (d: any) => d ? new Date(d).toLocaleDateString('zh-CN') : '-'
|
||||
@@ -836,13 +855,13 @@ router.get('/store-evaluation', async (req: AuthRequest, res) => {
|
||||
// 全量统计各评估类型数量
|
||||
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 received = 0 OR received < 5000 OR (actual_store_contribution_rate_pct < -20 AND lease_expiry_date <= $1::date + interval '12 months') 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 "关注观察"
|
||||
count(*) FILTER (WHERE lease_expiry_date <= $1::date + interval '3 months' AND actual_store_contribution_rate_pct >= 0) AS "续租评估",
|
||||
count(*) FILTER (WHERE actual_store_contribution_rate_pct < 0 AND lease_expiry_date > $1::date + interval '12 months' 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}
|
||||
`)
|
||||
`, params)
|
||||
const evalStats = statsResult.rows[0]
|
||||
|
||||
sendSuccess(res, rows, { page, pageSize, total, evalStats })
|
||||
|
||||
Reference in New Issue
Block a user