初始化:连锁餐饮数字化运营管理平台
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
import { Router } from 'express'
|
||||
import { query } from '../config/database.js'
|
||||
import { sendSuccess, sendError, parseMonth, parsePagination } from '../middleware/error.js'
|
||||
import type { AuthRequest } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/overview', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const sql = `
|
||||
SELECT count(*) AS bill_count,
|
||||
round(sum(received_total), 2) AS received,
|
||||
round(sum(received_total) / count(*), 2) AS avg_bill_value,
|
||||
round(sum(discount_total) / nullif(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
|
||||
round(sum(theoretical_profit) / nullif(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct,
|
||||
count(*) FILTER (WHERE member_id IS NOT NULL) AS member_bills,
|
||||
round(count(*) FILTER (WHERE member_id IS NOT NULL)::numeric / count(*) * 100, 2) AS member_share_pct
|
||||
FROM analytics.bill_fact
|
||||
WHERE closed_at >= $1::date
|
||||
AND closed_at < ($1::date + interval '1 month')
|
||||
AND closed_at IS NOT NULL
|
||||
`
|
||||
const result = await query(sql, [month])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/overview/daily', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const sql = `
|
||||
SELECT closed_at::date AS business_date,
|
||||
count(*) AS bill_count,
|
||||
round(sum(received_total), 2) AS received,
|
||||
round(sum(received_total) / count(*), 2) AS avg_bill_value,
|
||||
round(sum(discount_total) / nullif(sum(consumption), 0) * 100, 2) AS discount_rate_pct
|
||||
FROM analytics.bill_fact
|
||||
WHERE closed_at >= $1::date
|
||||
AND closed_at < ($1::date + interval '1 month')
|
||||
AND closed_at IS NOT NULL
|
||||
GROUP BY closed_at::date
|
||||
ORDER BY business_date
|
||||
`
|
||||
const result = await query(sql, [month])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/stores', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const riskLevel = req.query.risk_level as string
|
||||
const quadrant = req.query.quadrant as string
|
||||
|
||||
let sql = `SELECT * FROM analytics.v_store_scorecard`
|
||||
const params: any[] = []
|
||||
const conditions: string[] = []
|
||||
|
||||
if (riskLevel) {
|
||||
sql = `SELECT s.* FROM analytics.v_store_scorecard s
|
||||
JOIN analytics.v_store_risk_rating r ON s.store_code = r.store_code
|
||||
WHERE r.risk_level = $1`
|
||||
params.push(riskLevel)
|
||||
}
|
||||
|
||||
if (quadrant) {
|
||||
if (params.length > 0) {
|
||||
conditions.push(`b.management_quadrant = $${params.length + 1}`)
|
||||
sql = sql.replace('FROM analytics.v_store_scorecard s', 'FROM analytics.v_store_scorecard s JOIN analytics.v_store_benchmark b ON s.store_code = b.store_code')
|
||||
} else {
|
||||
sql = `SELECT s.* FROM analytics.v_store_scorecard s
|
||||
JOIN analytics.v_store_benchmark b ON s.store_code = b.store_code
|
||||
WHERE b.management_quadrant = $1`
|
||||
params.push(quadrant)
|
||||
}
|
||||
}
|
||||
|
||||
sql += ` ORDER BY received DESC NULLS LAST`
|
||||
const countResult = await query(`SELECT count(*) AS total FROM (${sql}) t`, params)
|
||||
sql += ` LIMIT $${params.length + 1} OFFSET $${params.length + 2}`
|
||||
params.push(pageSize, offset)
|
||||
|
||||
const result = await query(sql, params)
|
||||
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/stores/risk', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v_store_risk_rating ORDER BY risk_level, received DESC`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/stores/priority', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`
|
||||
SELECT store_code, store_name, action_priority, problem_count, problem_combination,
|
||||
received, scale_tier, business_type
|
||||
FROM analytics.cache_store_priority
|
||||
ORDER BY CASE action_priority
|
||||
WHEN 'P0-修复数据口径' THEN 1
|
||||
WHEN 'P0-综合专项整改' THEN 2
|
||||
WHEN 'P1-重点整改' THEN 3
|
||||
WHEN 'P2-单项改善' THEN 4
|
||||
WHEN '标杆候选' THEN 5
|
||||
ELSE 6
|
||||
END, received DESC
|
||||
`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/stores/quadrant', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v_store_benchmark ORDER BY management_quadrant, avg_daily_received DESC`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/stores/:code', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const code = req.params.code
|
||||
const scorecard = await query(`SELECT * FROM analytics.v_store_scorecard WHERE store_code = $1`, [code])
|
||||
const risk = await query(`SELECT * FROM analytics.v_store_risk_rating WHERE store_code = $1`, [code])
|
||||
const platform = await query(`SELECT * FROM analytics.v_store_platform_economics WHERE store_code = $1`, [code])
|
||||
const benchmark = await query(`SELECT * FROM analytics.v_store_benchmark WHERE store_code = $1`, [code])
|
||||
const action = await query(`SELECT * FROM analytics.v_store_action_list WHERE store_code = $1`, [code])
|
||||
const execution = await query(`SELECT * FROM analytics.v_store_execution_priority WHERE store_code = $1`, [code])
|
||||
|
||||
if (scorecard.rows.length === 0) {
|
||||
return sendError(res, 'Store not found', 404)
|
||||
}
|
||||
|
||||
sendSuccess(res, {
|
||||
scorecard: scorecard.rows[0],
|
||||
risk: risk.rows[0],
|
||||
platform: platform.rows[0],
|
||||
benchmark: benchmark.rows[0],
|
||||
action: action.rows[0],
|
||||
execution: execution.rows[0],
|
||||
})
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/stores/:code/daily', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const code = req.params.code
|
||||
const month = parseMonth(req)
|
||||
const result = await query(`
|
||||
SELECT closed_at::date AS business_date,
|
||||
count(*) AS bill_count,
|
||||
round(sum(received_total), 2) AS received,
|
||||
round(sum(received_total) / count(*), 2) AS avg_bill_value,
|
||||
round(sum(discount_total) / nullif(sum(consumption), 0) * 100, 2) AS discount_rate_pct
|
||||
FROM analytics.bill_fact
|
||||
WHERE store_code = $1
|
||||
AND closed_at >= $2::date
|
||||
AND closed_at < ($2::date + interval '1 month')
|
||||
AND closed_at IS NOT NULL
|
||||
GROUP BY closed_at::date
|
||||
ORDER BY business_date
|
||||
`, [code, month])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/cost/comparison', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v_store_theoretical_actual_cost_april ORDER BY variance_to_theoretical_pct DESC NULLS LAST`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/cost/category-benchmark', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v_store_category_cost_benchmark_april ORDER BY store_code`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/cost/inventory', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v_store_inventory_efficiency_april ORDER BY estimated_inventory_days DESC NULLS LAST`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/platform/economics', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v_store_platform_economics ORDER BY meituan_received DESC NULLS LAST`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/member/comparison', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v_member_comparison`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/member/repeat', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v_store_repeat_summary_monthly ORDER BY repeat_rate_pct DESC NULLS LAST`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/sku/abc', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v_dish_sku_abc_april ORDER BY cumulative_revenue_share`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/sku/category', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.category_summary ORDER BY amount DESC`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/sku/attach', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.dish_pair_summary_april ORDER BY pair_count DESC LIMIT 50`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/risk/anomaly', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const countResult = await query(`SELECT count(*) AS total FROM analytics.v_anomaly_bills`)
|
||||
const result = await query(`SELECT * FROM analytics.v_anomaly_bills ORDER BY closed_at DESC LIMIT $1 OFFSET $2`, [pageSize, offset])
|
||||
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/risk/zero-received', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const storeCode = req.query.store_code as string
|
||||
let sql = `SELECT * FROM analytics.v_zero_received_detail`
|
||||
const params: any[] = []
|
||||
if (storeCode) {
|
||||
sql += ` WHERE store_code = $1`
|
||||
params.push(storeCode)
|
||||
}
|
||||
sql += ` ORDER BY closed_at DESC LIMIT 200`
|
||||
const result = await query(sql, params)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/risk/cashier', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v_cashier_risk ORDER BY anomaly_rate_pct DESC NULLS LAST`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/marketing/plans', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v_marketing_plan_summary ORDER BY received DESC`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/benchmark/composite', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v_store_benchmark_composite ORDER BY benchmark_score DESC`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/time/weekday', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v_weekday_summary ORDER BY weekday_no`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/time/hourly', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v_hourly_summary ORDER BY closing_hour`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/channel', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.v_channel_daily ORDER BY business_date`)
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/data-quality', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const billStats = await query(`
|
||||
SELECT
|
||||
count(*) AS total_bills,
|
||||
count(*) FILTER (WHERE bill_no IS NULL OR bill_no = '') AS missing_bill_no,
|
||||
count(*) FILTER (WHERE store_code IS NULL OR store_code = '') AS missing_store_code,
|
||||
count(*) FILTER (WHERE consumption = 0 OR consumption IS NULL) AS zero_consumption,
|
||||
count(*) FILTER (WHERE received_total < 0) AS negative_received,
|
||||
count(DISTINCT store_code) AS store_count,
|
||||
min(closed_at)::text AS min_date,
|
||||
max(closed_at)::text AS max_date
|
||||
FROM analytics.bill_fact
|
||||
`)
|
||||
const dishStats = await query(`
|
||||
SELECT
|
||||
count(*) AS total_dish_records,
|
||||
count(*) FILTER (WHERE store_code IS NULL OR store_code = '') AS dish_missing_store,
|
||||
count(*) FILTER (WHERE dish_name IS NULL OR dish_name = '') AS dish_missing_dish
|
||||
FROM public.dish_sales_details
|
||||
`)
|
||||
const result = { ...billStats.rows[0], ...dishStats.rows[0] }
|
||||
sendSuccess(res, result)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user