feat: 月度模式全面参数化 - 移除硬编码日期,前后端按月动态查询

This commit is contained in:
freedakgmail
2026-08-01 13:10:22 +08:00
parent a90c133578
commit dab06ba109
43 changed files with 9135 additions and 309 deletions
+47
View File
@@ -35,6 +35,53 @@ export function parseMonth(req: Request): string {
return month.length === 7 ? `${month}-01` : month
}
export interface DateRange {
start: string
end: string
mode: 'month' | 'quarter' | 'halfyear' | 'year' | 'custom'
label: string
}
export function parseDateRange(req: Request): DateRange {
const mode = ((req.query.range_mode as string) || 'month') as 'month' | 'quarter' | 'halfyear' | 'year' | 'custom'
const month = parseMonth(req)
const startDate = new Date(month)
const addMonths = (d: Date, n: number): string => {
const r = new Date(d)
r.setMonth(r.getMonth() + n)
return r.toISOString().slice(0, 10)
}
switch (mode) {
case 'quarter':
return { start: month, end: addMonths(startDate, 3), mode, label: '季度' }
case 'halfyear':
return { start: month, end: addMonths(startDate, 6), mode, label: '半年' }
case 'year':
return { start: month, end: addMonths(startDate, 12), mode, label: '全年' }
case 'custom': {
const start = (req.query.start_date as string) || month
const end = (req.query.end_date as string) || addMonths(new Date(start), 1)
return { start, end, mode, label: '自定义' }
}
default:
return { start: month, end: addMonths(startDate, 1), mode, label: '月度' }
}
}
export function prevYearMonth(month: string): string {
const d = new Date(month)
d.setFullYear(d.getFullYear() - 1)
return d.toISOString().slice(0, 10)
}
export function prevMonth(month: string): string {
const d = new Date(month)
d.setMonth(d.getMonth() - 1)
return d.toISOString().slice(0, 10)
}
export function parsePagination(req: Request) {
const page = parseInt((req.query.page as string) || '1')
const pageSize = parseInt((req.query.page_size as string) || '50')
+12 -9
View File
@@ -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()
@@ -725,6 +725,7 @@ router.get('/unmatched-materials', async (req: AuthRequest, res) => {
// 门店成本总览
router.get('/store-overview', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
count(*) AS total_stores,
@@ -733,8 +734,8 @@ router.get('/store-overview', async (req: AuthRequest, res) => {
count(*) FILTER (WHERE variance_level = '绿色-基本正常') AS green_count,
count(*) FILTER (WHERE variance_level = '灰色-口径异常') AS gray_count,
round(sum(food_cost_variance)::numeric, 2) AS total_variance
FROM analytics.mv_store_theoretical_actual_cost_april
`)
FROM analytics.fn_store_theoretical_actual_cost($1)
`, [month])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
@@ -744,16 +745,17 @@ router.get('/store-overview', async (req: AuthRequest, res) => {
// 门店地图数据
router.get('/store-map', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT c.store_code, c.store_name, c.variance_level,
round(c.food_cost_variance::numeric, 2) AS variance,
round(c.theoretical_cost_rate_pct::numeric, 2) AS theo_cost_rate,
round(c.actual_food_cost_rate_pct::numeric, 2) AS actual_cost_rate,
l.latitude_gcj02, l.longitude_gcj02
FROM analytics.mv_store_theoretical_actual_cost_april c
FROM analytics.fn_store_theoretical_actual_cost($1) c
LEFT JOIN analytics.v_store_location_operating l ON l.store_code = c.store_code
WHERE l.latitude_gcj02 IS NOT NULL
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -777,7 +779,8 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
const validSorts = ['food_cost_variance', 'theoretical_cost_rate_pct', 'actual_food_cost_rate_pct', 'variance_to_theoretical_pct', 'negative_item_lines', 'store_name']
const sortCol = validSorts.includes(sort) ? sort : 'food_cost_variance'
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_theoretical_actual_cost_april ${where}`)
const month = parseMonth(req)
const countResult = await query(`SELECT count(*) FROM analytics.fn_store_theoretical_actual_cost($1) ${where}`, [month])
const total = countResult.rows[0].count
const result = await query(`
@@ -788,10 +791,10 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
round(food_cost_variance::numeric, 2) AS variance_amount,
negative_item_lines,
variance_level
FROM analytics.mv_store_theoretical_actual_cost_april
FROM analytics.fn_store_theoretical_actual_cost($1)
${where}
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
`, [pageSize, offset])
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $2 OFFSET $3
`, [month, pageSize, offset])
sendSuccess(res, result.rows, { page, pageSize, total })
} catch (err: any) {
sendError(res, err.message)
+1516 -86
View File
File diff suppressed because it is too large Load Diff
+29 -23
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'
import { query } from '../config/database.js'
import { sendSuccess, sendError } from '../middleware/error.js'
import { sendSuccess, sendError, parseMonth } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
@@ -9,24 +9,26 @@ const router = Router()
router.get('/health-score', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH base AS (
SELECT store_code, store_name, received, bill_count,
avg_bill_value, avg_daily_received, theoretical_margin_pct,
member_bill_share_pct, risk_level
FROM analytics.mv_store_risk_rating
FROM analytics.fn_store_risk_rating($1)
WHERE received IS NOT NULL
),
cost AS (
SELECT store_code,
round(avg(LEAST(variance_to_theoretical_pct, 50))::numeric, 1) AS avg_cost_variance_pct
FROM analytics.mv_store_theoretical_actual_cost_april
FROM analytics.fn_store_theoretical_actual_cost($1)
WHERE variance_to_theoretical_pct IS NOT NULL
GROUP BY store_code
),
member AS (
SELECT store_code, max(repeat_rate_pct) AS repeat_rate_pct
FROM analytics.mv_store_repeat_summary_monthly
WHERE month_start = $1::date
GROUP BY store_code
),
task AS (
@@ -35,6 +37,7 @@ router.get('/health-score', async (req: AuthRequest, res) => {
count(*) FILTER (WHERE status = '已验收' AND verification_result = '达标') AS passed_tasks,
round(count(*) FILTER (WHERE status IN ('已验收', '已回滚'))::numeric / nullif(count(*), 0) * 100, 1) AS completion_rate
FROM analytics.store_task
WHERE is_simulated = false OR is_simulated IS NULL
GROUP BY store_code
),
scored AS (
@@ -78,7 +81,7 @@ router.get('/health-score', async (req: AuthRequest, res) => {
END AS health_status
FROM scored
ORDER BY health_score DESC
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -89,6 +92,7 @@ router.get('/health-score', async (req: AuthRequest, res) => {
router.get('/alerts', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const alerts: any[] = []
// 1. 营收异动预警:日营收连续低于月均70%
@@ -180,12 +184,12 @@ router.get('/alerts', async (req: AuthRequest, res) => {
round(p.taobao_cost_rate_pct::numeric, 1) AS taobao_rate,
round(p.jd_cost_rate_pct::numeric, 1) AS jd_rate,
round((COALESCE(p.meituan_received, 0) + COALESCE(p.taobao_received, 0) + COALESCE(p.jd_received, 0)) / nullif(sc.received, 0) * 100, 1) AS platform_share
FROM analytics.v_store_platform_economics p
FROM analytics.fn_store_platform_economics($1) p
JOIN analytics.v_store_scorecard sc ON p.store_code = sc.store_code
WHERE (COALESCE(p.meituan_received, 0) + COALESCE(p.taobao_received, 0) + COALESCE(p.jd_received, 0)) / nullif(sc.received, 0) > 0.4
ORDER BY platform_share DESC
LIMIT 10
`)
`, [month])
platformAlerts.rows.forEach((r: any) => {
alerts.push({
type: 'platform',
@@ -237,6 +241,7 @@ router.get('/alerts', async (req: AuthRequest, res) => {
router.get('/correlation', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
// 客流-人力匹配度:每门店每小时"每人在岗产出账单数"
// mv_store_hourly_staffing 可能不存在,容错处理
let staffingRows: any[] = []
@@ -274,7 +279,7 @@ router.get('/correlation', async (req: AuthRequest, res) => {
staffingRows = []
}
// 门店维度汇总:成本-风险关联(用mv_store_risk_rating替代v_store_scorecard
// 门店维度汇总:成本-风险关联
const costRisk = await query(`
SELECT
r.store_code, r.store_name, r.risk_level,
@@ -288,11 +293,11 @@ router.get('/correlation', async (req: AuthRequest, res) => {
WHEN COALESCE(c.avg_cost_variance_pct, 20) > 30 THEN '成本失控'
ELSE '正常'
END AS correlation_status
FROM analytics.mv_store_risk_rating r
FROM analytics.fn_store_risk_rating($1) r
LEFT JOIN (
SELECT store_code,
round(avg(LEAST(variance_to_theoretical_pct, 50))::numeric, 1) AS avg_cost_variance_pct
FROM analytics.mv_store_theoretical_actual_cost_april
FROM analytics.fn_store_theoretical_actual_cost($1)
WHERE variance_to_theoretical_pct IS NOT NULL
GROUP BY store_code
) c ON r.store_code = c.store_code
@@ -303,9 +308,9 @@ router.get('/correlation', async (req: AuthRequest, res) => {
WHEN r.risk_level = '黄色' AND COALESCE(c.avg_cost_variance_pct, 20) > 25 THEN 2
ELSE 3 END,
r.received DESC NULLS LAST
`)
`, [month])
// 会员-平台-营收三角(用mv_store_risk_rating替代v_store_scorecard
// 会员-平台-营收三角
const channelRisk = await query(`
SELECT
r.store_code, r.store_name, r.received,
@@ -318,19 +323,19 @@ router.get('/correlation', async (req: AuthRequest, res) => {
WHEN COALESCE(r.member_bill_share_pct, 0) > 50 THEN '会员驱动型'
ELSE '均衡型'
END AS channel_status
FROM analytics.mv_store_risk_rating r
FROM analytics.fn_store_risk_rating($1) r
LEFT JOIN (
SELECT pe.store_code,
round((COALESCE(pe.meituan_received, 0) + COALESCE(pe.taobao_received, 0) + COALESCE(pe.jd_received, 0)) / nullif(r2.received, 0) * 100, 1) AS platform_share
FROM analytics.v_store_platform_economics pe
JOIN analytics.mv_store_risk_rating r2 ON pe.store_code = r2.store_code
FROM analytics.fn_store_platform_economics($1) pe
JOIN analytics.fn_store_risk_rating($1) r2 ON pe.store_code = r2.store_code
) p ON r.store_code = p.store_code
LEFT JOIN analytics.mv_store_repeat_summary_monthly m ON r.store_code = m.store_code
LEFT JOIN analytics.mv_store_repeat_summary_monthly m ON r.store_code = m.store_code AND m.month_start = $1::date
WHERE r.received IS NOT NULL
ORDER BY
CASE WHEN COALESCE(p.platform_share, 0) > 40 AND COALESCE(r.member_bill_share_pct, 0) < 20 THEN 0 ELSE 1 END,
r.received DESC
`)
`, [month])
// 考勤-营收关联
const hrRevenue = await query(`
@@ -390,6 +395,7 @@ router.get('/correlation', async (req: AuthRequest, res) => {
router.get('/forecast', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
// 客流预测:基于历史4周小时数据,按工作日/周末+小时维度计算P85
const trafficForecast = await query(`
WITH daily_hourly AS (
@@ -399,7 +405,7 @@ router.get('/forecast', async (req: AuthRequest, res) => {
count(*) AS bills
FROM bill_records
WHERE c175 IS NOT NULL AND c175 != ''
AND c175 >= '2026/04/01' AND c175 < '2026/05/01'
AND c175 >= $1 AND c175 < $1::date + interval '1 month'
GROUP BY hour, day_type, bill_date
),
hourly_stats AS (
@@ -419,7 +425,7 @@ router.get('/forecast', async (req: AuthRequest, res) => {
END AS stability
FROM hourly_stats
ORDER BY day_type, hour
`)
`, [month])
// 成本趋势:菜品成本差异恶化TOP
const costTrend = await query(`
@@ -444,15 +450,15 @@ router.get('/forecast', async (req: AuthRequest, res) => {
const turnoverAlert = await query(`
SELECT org_level5 AS store_name,
count(*) AS total_emp,
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= '2026-04-01') AS left_count,
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count,
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 1) AS turnover_rate
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= $1) AS left_count,
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1) AS new_count,
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= $1)::numeric / nullif(count(*), 0) * 100, 1) AS turnover_rate
FROM salary_detail_records
WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''
GROUP BY org_level5
HAVING count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= '2026-04-01') > 0
HAVING count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= $1) > 0
ORDER BY turnover_rate DESC
`)
`, [month])
sendSuccess(res, {
traffic_forecast: trafficForecast.rows,
+30 -21
View File
@@ -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()
@@ -150,8 +150,9 @@ router.get('/traffic-heatmap-staffing', async (req: AuthRequest, res) => {
router.get('/dow-traffic', async (req: AuthRequest, res) => {
try {
const storeName = req.query.store as string
let where = "WHERE c175 IS NOT NULL AND c175 != '' AND c175 >= '2026/04/01' AND c175 < '2026/05/01'"
const params: any[] = []
const month = parseMonth(req)
const params: any[] = [month]
let where = `WHERE c175 IS NOT NULL AND c175 != '' AND c175 >= $1 AND c175 < $1::date + interval '1 month'`
if (storeName) {
params.push(storeName)
where += ` AND c003 = $${params.length}`
@@ -220,6 +221,7 @@ router.get('/traffic-overview', async (req: AuthRequest, res) => {
router.get('/staffing-match', async (req: AuthRequest, res) => {
try {
const storeName = req.query.store as string || '七里庄店'
const month = parseMonth(req)
const result = await query(`
WITH punch_times AS (
SELECT employee_code, d.day_num, d.day_val
@@ -290,7 +292,7 @@ router.get('/staffing-match', async (req: AuthRequest, res) => {
round(sum(c178::numeric), 0) AS total_guests
FROM bill_records
WHERE c003 = $1 AND c175 IS NOT NULL AND c175 != ''
AND c175::timestamp >= '2026-04-01' AND c175::timestamp < '2026-05-01'
AND c175::timestamp >= $2 AND c175::timestamp < $2::date + interval '1 month'
GROUP BY hour
)
SELECT s.hour,
@@ -308,7 +310,7 @@ router.get('/staffing-match', async (req: AuthRequest, res) => {
FROM hourly_staff_avg s
LEFT JOIN hourly_bills b ON s.hour = b.hour
ORDER BY s.hour
`, [storeName])
`, [storeName, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -443,6 +445,7 @@ router.get('/scheduling-suggestion', async (req: AuthRequest, res) => {
const storeName = req.query.store as string || '七里庄店'
const frontTarget = parseInt(req.query.front_target as string) || 15
const kitchenTarget = parseInt(req.query.kitchen_target as string) || 25
const month = parseMonth(req)
const result = await query(`
WITH daily_hourly AS (
@@ -452,7 +455,7 @@ router.get('/scheduling-suggestion', async (req: AuthRequest, res) => {
count(*) AS bills
FROM bill_records
WHERE c003 = $1 AND c175 IS NOT NULL AND c175 != ''
AND c175 >= '2026/04/01' AND c175 < '2026/05/01'
AND c175 >= $4 AND c175 < $4::date + interval '1 month'
GROUP BY hour, day_type, bill_date
),
hourly_stats AS (
@@ -584,7 +587,7 @@ router.get('/scheduling-suggestion', async (req: AuthRequest, res) => {
LEFT JOIN current_other co ON hs.hour = co.hour
LEFT JOIN current_total ct ON hs.hour = ct.hour
ORDER BY hs.hour, hs.day_type
`, [storeName, frontTarget, kitchenTarget])
`, [storeName, frontTarget, kitchenTarget, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -697,6 +700,7 @@ router.get('/employee-analysis', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const sort = (req.query.sort as string) || 'gross_pay'
const month = parseMonth(req)
const order = (req.query.order as string) || 'desc'
const storeName = req.query.store as string
@@ -717,6 +721,8 @@ router.get('/employee-analysis', async (req: AuthRequest, res) => {
params.push(storeName)
where += ` AND org_level5 = $${params.length}`
}
params.push(month)
const monthParam = `$${params.length}`
const countResult = await query(`SELECT count(*) FROM salary_detail_records ${where}`, params)
const total = countResult.rows[0].count
@@ -743,8 +749,8 @@ router.get('/employee-analysis', async (req: AuthRequest, res) => {
round(perf_amount / nullif(gross_pay, 0) * 100, 2) AS perf_rate,
round(gross_pay / nullif(actual_hours, 0), 2) AS effective_hourly_rate,
CASE
WHEN leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01' THEN '离职'
WHEN hire_date IS NOT NULL AND hire_date != '' AND hire_date >= '2026-04-01' THEN '新员工'
WHEN leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= ${monthParam} THEN '离职'
WHEN hire_date IS NOT NULL AND hire_date != '' AND hire_date >= ${monthParam} THEN '新员工'
ELSE '在职'
END AS emp_status
FROM salary_detail_records
@@ -788,18 +794,19 @@ router.get('/position-salary-compare', async (req: AuthRequest, res) => {
// 离职率统计
router.get('/turnover-stats', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT org_level5 AS store_name,
count(*) AS total_emp,
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01') AS left_count,
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count,
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate,
round(count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 2) AS new_hire_rate
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1) AS left_count,
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1) AS new_count,
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1)::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate,
round(count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1)::numeric / nullif(count(*), 0) * 100, 2) AS new_hire_rate
FROM salary_detail_records
WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''
GROUP BY org_level5
ORDER BY turnover_rate DESC NULLS LAST
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -810,6 +817,7 @@ router.get('/turnover-stats', async (req: AuthRequest, res) => {
router.get('/overall-analysis', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [efficiency, attendance, turnover, trafficOverview, mealPeriod] = await Promise.all([
query(`
WITH salary_stats AS (
@@ -851,13 +859,13 @@ router.get('/overall-analysis', async (req: AuthRequest, res) => {
query(`
SELECT org_level5 AS store_name,
count(*) AS total_emp,
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01') AS left_count,
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count,
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1) AS left_count,
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1) AS new_count,
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1)::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate
FROM salary_detail_records
WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''
GROUP BY org_level5
`),
`, [month]),
query(`
WITH hourly AS (
SELECT store_name, hour, sum(bills) AS bills
@@ -1095,6 +1103,7 @@ router.get('/overall-analysis', async (req: AuthRequest, res) => {
router.get('/staffing-forecast', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [roleStats, storeRevenue, storeTraffic] = await Promise.all([
query(`
SELECT
@@ -1114,12 +1123,12 @@ router.get('/staffing-forecast', async (req: AuthRequest, res) => {
round(avg(actual_attend)::numeric, 1) AS avg_attend,
round(avg(actual_hours)::numeric, 0) AS avg_hours,
round(sum(actual_hours)::numeric, 0) AS total_hours,
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01') AS left_count,
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1) AS left_count,
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1) AS new_count
FROM salary_detail_records
WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''
GROUP BY 1, 2
`),
`, [month]),
query(`
SELECT s.salary_name AS store_name, r.revenue, r.bill_count
FROM (SELECT DISTINCT org_level5 AS salary_name FROM salary_detail_records WHERE org_level2='西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '') s
+89 -70
View File
@@ -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 })
+13 -6
View File
@@ -21,6 +21,12 @@ router.get('/', async (req: AuthRequest, res) => {
const params: any[] = [month]
let paramIdx = 2
// 默认排除模拟验收记录,除非显式请求
const includeSimulated = req.query.include_simulated === 'true'
if (!includeSimulated) {
conditions.push(`(is_simulated = false OR is_simulated IS NULL)`)
}
if (priority) {
conditions.push(`priority = $${paramIdx++}`)
params.push(priority)
@@ -81,7 +87,7 @@ router.post('/', async (req: AuthRequest, res) => {
router.post('/auto-generate', async (req: AuthRequest, res) => {
try {
const month = req.body.month ? (req.body.month.length === 7 ? `${req.body.month}-01` : req.body.month) : '2026-05-01'
const month = req.body.month ? (req.body.month.length === 7 ? `${req.body.month}-01` : req.body.month) : parseMonth(req)
const result = await query(`SELECT * FROM analytics.f_generate_store_tasks($1)`, [month])
sendSuccess(res, result.rows[0] || { generated: 0, message: 'No tasks generated' })
} catch (err: any) {
@@ -585,7 +591,7 @@ router.get('/monthly-review/completion', async (req: AuthRequest, res) => {
count(*) FILTER (WHERE verification_result = '未改善') as failed,
ROUND(count(*) FILTER (WHERE status = '已验收' OR status = '已回滚') * 100.0 / NULLIF(count(*), 0), 1) as completion_rate
FROM analytics.store_task
WHERE plan_month = $1
WHERE plan_month = $1 AND (is_simulated = false OR is_simulated IS NULL)
GROUP BY priority
ORDER BY priority
`, [month])
@@ -609,22 +615,23 @@ router.get('/monthly-review/activity-list', async (req: AuthRequest, res) => {
router.get('/monthly-review/sku-governance', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const abcResult = await query(`
SELECT abc_class, count(*) as sku_count,
ROUND(sum(revenue_share_pct), 1) as total_revenue_share,
ROUND(avg(revenue_share_pct), 2) as avg_revenue_share
FROM analytics.v_dish_sku_abc_april
FROM analytics.fn_dish_sku_abc($1)
GROUP BY abc_class
ORDER BY abc_class
`)
`, [month])
const longtailResult = await query(`
SELECT dish_name, category_level1, bill_count, sales_quantity,
received_amount, revenue_share_pct, cumulative_revenue_share
FROM analytics.v_dish_sku_abc_april
FROM analytics.fn_dish_sku_abc($1)
WHERE abc_class = 'C-长尾'
ORDER BY received_amount ASC
LIMIT 50
`)
`, [month])
sendSuccess(res, { summary: abcResult.rows, longtail: longtailResult.rows })
} catch (err: any) { sendError(res, err.message) }
})