风险与内控管理优化: 异常账单服务端分页/筛选/收银员搜索/合计, 收银员TOP15按异常账单数排序, 双击跳转明细, MonthPicker靠右, 侧边栏底部间距

This commit is contained in:
freedakgmail
2026-08-01 21:28:15 +08:00
parent ac8e39e2f9
commit d724a565f5
60 changed files with 3154 additions and 2561 deletions
+103 -55
View File
@@ -10,6 +10,7 @@ const router = Router()
// 成本总览指标
router.get('/overview', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
count(*) AS total_dishes,
@@ -22,7 +23,8 @@ router.get('/overview', async (req: AuthRequest, res) => {
round(sum(actual_cost)::numeric, 2) AS total_actual_cost,
round(sum(cost_variance_amount)::numeric, 2) AS total_variance
FROM public.dish_cost_analysis_summary
`)
WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
`, [month])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
@@ -32,6 +34,7 @@ router.get('/overview', async (req: AuthRequest, res) => {
// 品类成本对比
router.get('/category-comparison', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT category_level1,
count(*) AS dishes,
@@ -43,8 +46,9 @@ router.get('/category-comparison', async (req: AuthRequest, res) => {
round(sum(cost_variance_amount)::numeric, 2) AS total_variance
FROM public.dish_cost_analysis_summary
WHERE category_level1 IS NOT NULL
AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
GROUP BY category_level1 ORDER BY total_sales DESC
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -54,6 +58,7 @@ router.get('/category-comparison', async (req: AuthRequest, res) => {
// 毛利率偏差分布
router.get('/margin-deviation', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
CASE
@@ -66,8 +71,9 @@ router.get('/margin-deviation', async (req: AuthRequest, res) => {
count(*) AS cnt
FROM public.dish_cost_analysis_summary
WHERE theoretical_margin_rate_pct IS NOT NULL AND actual_margin_rate_pct IS NOT NULL
AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
GROUP BY deviation_band ORDER BY cnt DESC
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -79,6 +85,7 @@ router.get('/variance-top', async (req: AuthRequest, res) => {
try {
const limit = parseInt((req.query.limit as string) || '50')
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
const month = parseMonth(req)
const result = await query(`
SELECT dish_name, dish_code, category_level1,
round(theoretical_margin_rate_pct::numeric, 2) AS theo_margin,
@@ -93,8 +100,9 @@ router.get('/variance-top', async (req: AuthRequest, res) => {
ELSE '正常'
END AS cost_tier
FROM public.dish_cost_analysis_summary
WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1)
ORDER BY cost_variance_amount ${order} LIMIT $1
`, [limit])
`, [limit, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -106,24 +114,26 @@ router.get('/variance-top', async (req: AuthRequest, res) => {
// 菜单工程矩阵
router.get('/menu-engineering', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH imp AS (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
SELECT dish_name, dish_code, category_level1,
round(sales_quantity::numeric, 2) AS sales_quantity,
round(sales_amount::numeric, 2) AS sales_amount,
round(actual_margin_rate_pct::numeric, 2) AS actual_margin,
CASE
WHEN actual_margin_rate_pct < 0 THEN '数据异常'
WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary)
AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct > 0) THEN '明星盈利品'
WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary)
AND actual_margin_rate_pct < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct > 0) THEN '高销低利品'
WHEN sales_quantity < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary)
AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct > 0) THEN '低销高利品'
WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id)
AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id AND actual_margin_rate_pct > 0) THEN '明星盈利品'
WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id)
AND actual_margin_rate_pct < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id AND actual_margin_rate_pct > 0) THEN '高销低利品'
WHEN sales_quantity < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id)
AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id AND actual_margin_rate_pct > 0) THEN '低销高利品'
ELSE '低销低利品'
END AS menu_type
FROM public.dish_cost_analysis_summary
WHERE actual_margin_rate_pct IS NOT NULL
`)
FROM public.dish_cost_analysis_summary s, imp
WHERE s.import_id = imp.import_id AND actual_margin_rate_pct IS NOT NULL
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -141,8 +151,9 @@ router.get('/profitability', async (req: AuthRequest, res) => {
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
const filter = (req.query.filter as string) || ''
let where = 'WHERE actual_margin_rate_pct IS NOT NULL'
const params: any[] = []
const month = parseMonth(req)
let where = 'WHERE actual_margin_rate_pct IS NOT NULL AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)'
const params: any[] = [month]
if (category) { params.push(category); where += ` AND category_level1 = $${params.length}` }
if (filter === 'profitable') { where += ` AND actual_margin_rate_pct > 0` }
else if (filter === 'loss') { where += ` AND actual_margin_rate_pct <= 0` }
@@ -177,6 +188,7 @@ router.get('/profitability', async (req: AuthRequest, res) => {
router.get('/pricing', async (req: AuthRequest, res) => {
try {
const threshold = parseFloat((req.query.threshold as string) || '50')
const month = parseMonth(req)
const result = await query(`
SELECT dish_name, dish_code, category_level1,
round(price::numeric, 2) AS price,
@@ -187,8 +199,9 @@ router.get('/pricing', async (req: AuthRequest, res) => {
round(sales_amount::numeric, 2) AS sales_amount
FROM public.dish_cost_analysis_summary
WHERE theoretical_margin_rate_pct < $1 AND sales_amount > 1000
AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1)
ORDER BY theoretical_margin_rate_pct ASC
`, [threshold])
`, [threshold, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -202,6 +215,7 @@ router.get('/material-variance', async (req: AuthRequest, res) => {
try {
const dishCode = req.query.dish_code as string
if (!dishCode) return sendError(res, 'dish_code is required')
const month = parseMonth(req)
const result = await query(`
SELECT m.material_name, m.material_unit,
@@ -221,8 +235,9 @@ router.get('/material-variance', async (req: AuthRequest, res) => {
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE s.dish_code = $1
AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1)
ORDER BY m.loss_amount ASC
`, [dishCode])
`, [dishCode, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -232,6 +247,7 @@ router.get('/material-variance', async (req: AuthRequest, res) => {
// 损耗率分布
router.get('/loss-distribution', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
CASE
@@ -244,9 +260,11 @@ router.get('/loss-distribution', async (req: AuthRequest, res) => {
ELSE '正向(>0%)'
END AS loss_band,
count(*) AS cnt
FROM public.dish_cost_analysis_material_detail
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
GROUP BY loss_band ORDER BY cnt DESC
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -258,6 +276,7 @@ router.get('/material-loss-top', async (req: AuthRequest, res) => {
try {
const limit = parseInt((req.query.limit as string) || '50')
const order = (req.query.order as string) === 'rate' ? 'avg_loss_rate' : 'total_loss_qty'
const month = parseMonth(req)
const result = await query(`
SELECT material_name, material_type,
count(DISTINCT m.summary_id) AS dish_count,
@@ -266,10 +285,12 @@ router.get('/material-loss-top', async (req: AuthRequest, res) => {
round(max(loss_quantity_rate_pct)::numeric, 2) AS max_loss_rate,
round(sum(loss_amount)::numeric, 2) AS total_loss_amount
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE m.loss_quantity < 0
AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1)
GROUP BY material_name, material_type
ORDER BY ${order} ASC LIMIT $1
`, [limit])
`, [limit, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -279,15 +300,18 @@ router.get('/material-loss-top', async (req: AuthRequest, res) => {
// 物料类型损耗对比
router.get('/material-type-loss', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT material_type,
count(*) AS cnt,
round((sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate,
round(sum(loss_amount)::numeric, 2) AS total_loss_amount,
count(DISTINCT material_name) AS unique_materials
FROM public.dish_cost_analysis_material_detail
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
GROUP BY material_type ORDER BY total_loss_amount ASC
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -582,15 +606,18 @@ router.get('/material-demand', async (req: AuthRequest, res) => {
// 包装总览
router.get('/packaging-overview', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
count(DISTINCT material_name) AS packaging_types,
round(sum(theoretical_amount)::numeric, 2) AS total_cost,
round(sum(loss_quantity)::numeric, 2) AS total_loss_qty,
count(*) FILTER (WHERE loss_quantity_rate_pct < -20) AS high_loss_count
FROM public.dish_cost_analysis_material_detail
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE material_name ~* '餐盒|餐具|打包|纸巾|餐盒|调味包|餐盒|碗|袋|杯|盒'
`)
AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
`, [month])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
@@ -612,30 +639,35 @@ router.get('/packaging-detail', async (req: AuthRequest, res) => {
const validSorts = ['total_cost', 'total_qty', 'loss_qty', 'avg_loss_rate', 'dish_count', 'material_name']
const sortCol = validSorts.includes(sort) ? sort : 'total_cost'
const month = parseMonth(req)
const countResult = await query(`
SELECT count(DISTINCT material_name) FROM public.dish_cost_analysis_material_detail
SELECT count(DISTINCT material_name) FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒'
`)
AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
`, [month])
const total = countResult.rows[0].count
const result = await query(`
SELECT material_name,
count(DISTINCT summary_id) AS dish_count,
round(sum(theoretical_quantity)::numeric, 2) AS total_qty,
round(sum(theoretical_amount)::numeric, 2) AS total_cost,
round(sum(loss_quantity)::numeric, 2) AS loss_qty,
round((sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate,
count(DISTINCT m.summary_id) AS dish_count,
round(sum(m.theoretical_quantity)::numeric, 2) AS total_qty,
round(sum(m.theoretical_amount)::numeric, 2) AS total_cost,
round(sum(m.loss_quantity)::numeric, 2) AS loss_qty,
round((sum(m.loss_quantity) / nullif(sum(m.theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate,
CASE
WHEN sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100 < -20 THEN '核查'
WHEN sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100 < -5 THEN '关注'
WHEN sum(m.loss_quantity) / nullif(sum(m.theoretical_quantity), 0) * 100 < -20 THEN '核查'
WHEN sum(m.loss_quantity) / nullif(sum(m.theoretical_quantity), 0) * 100 < -5 THEN '关注'
ELSE '正常'
END AS status
FROM public.dish_cost_analysis_material_detail
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒'
AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
GROUP BY material_name
${having}
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)
@@ -647,19 +679,20 @@ router.get('/packaging-detail', async (req: AuthRequest, res) => {
// 数据质量总览
router.get('/data-quality', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
(SELECT count(*) FROM analytics.dim_sku) AS total_sku,
(SELECT count(DISTINCT sku_code) FROM analytics.fact_recipe_bom) AS sku_with_bom,
round((SELECT count(DISTINCT sku_code)::numeric FROM analytics.fact_recipe_bom) / nullif((SELECT count(*) FROM analytics.dim_sku), 0) * 100, 2) AS bom_coverage,
(SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_summary WHERE matched_to_dish_sales_by_name = true) AS matched_dishes,
(SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_summary) AS total_dishes,
(SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND EXISTS (SELECT 1 FROM public.dish_sales_details d WHERE d.dish_name = s.dish_name)) AS matched_dishes,
(SELECT count(*) FROM public.dish_cost_analysis_summary WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)) AS total_dishes,
(SELECT count(*) FROM analytics.fact_recipe_bom WHERE standard_net_quantity = 0) AS zero_actual_bom,
(SELECT count(*) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct < 0) AS negative_margin_count,
(SELECT count(*) FROM public.dish_cost_analysis_summary WHERE theoretical_margin_rate_pct < 0) AS negative_theo_margin_count,
(SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_material_detail WHERE matched_to_inventory_by_name = true) AS matched_materials,
(SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_material_detail) AS total_materials
`)
(SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND s.actual_margin_rate_pct < 0) AS negative_margin_count,
(SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND s.theoretical_margin_rate_pct < 0) AS negative_theo_margin_count,
(SELECT count(*) FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND EXISTS (SELECT 1 FROM public.inventory_cost_records i WHERE i.material_name = m.material_name)) AS matched_materials,
(SELECT count(*) FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)) AS total_materials
`, [month])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
@@ -676,16 +709,17 @@ router.get('/unmatched-dishes', async (req: AuthRequest, res) => {
const validSorts = ['sales_amount', 'dish_name', 'dish_code', 'category_level1']
const sortCol = validSorts.includes(sort) ? sort : 'sales_amount'
const countResult = await query(`SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_summary WHERE matched_to_dish_sales_by_name = false`)
const month = parseMonth(req)
const countResult = await query(`SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND NOT EXISTS (SELECT 1 FROM public.dish_sales_details d WHERE d.dish_name = s.dish_name)`, [month])
const total = countResult.rows[0].count
const result = await query(`
SELECT dish_name, dish_code, category_level1,
round(sales_amount::numeric, 2) AS sales_amount
FROM analytics.v_dish_cost_analysis_latest_summary
WHERE matched_to_dish_sales_by_name = false
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
`, [pageSize, offset])
FROM public.dish_cost_analysis_summary s
WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND NOT EXISTS (SELECT 1 FROM public.dish_sales_details d WHERE d.dish_name = s.dish_name)
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)
@@ -702,18 +736,30 @@ router.get('/unmatched-materials', async (req: AuthRequest, res) => {
const validSorts = ['dish_count', 'loss_amount', 'material_name', 'material_type']
const sortCol = validSorts.includes(sort) ? sort : 'dish_count'
const countResult = await query(`SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_material_detail WHERE matched_to_inventory_by_name = false`)
const month = parseMonth(req)
const countResult = await query(`
SELECT count(*) FROM (
SELECT material_name, material_type
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
AND NOT EXISTS (SELECT 1 FROM public.inventory_cost_records i WHERE i.material_name = m.material_name)
GROUP BY material_name, material_type
) t
`, [month])
const total = countResult.rows[0].count
const result = await query(`
SELECT material_name, material_type,
count(DISTINCT summary_id) AS dish_count,
count(DISTINCT m.summary_id) AS dish_count,
round(sum(loss_amount)::numeric, 2) AS loss_amount
FROM analytics.v_dish_cost_analysis_latest_material_detail
WHERE matched_to_inventory_by_name = false
FROM public.dish_cost_analysis_material_detail m
JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id
WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
AND NOT EXISTS (SELECT 1 FROM public.inventory_cost_records i WHERE i.material_name = m.material_name)
GROUP BY material_name, material_type
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)
@@ -808,6 +854,7 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
// 散点图数据
router.get('/scatter', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT dish_name, dish_code, category_level1,
round(sales_quantity::numeric, 2) AS x,
@@ -815,7 +862,8 @@ router.get('/scatter', async (req: AuthRequest, res) => {
round(sales_amount::numeric, 2) AS size
FROM public.dish_cost_analysis_summary
WHERE cost_variance_amount IS NOT NULL AND sales_quantity IS NOT NULL
`)
AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
+341 -72
View File
@@ -80,10 +80,12 @@ router.get('/stores/risk', async (req: AuthRequest, res) => {
router.get('/stores/priority', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
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
FROM analytics.mv_store_action_priority_deep_monthly
WHERE month_start = $1
ORDER BY CASE action_priority
WHEN 'P0-修复数据口径' THEN 1
WHEN 'P0-综合专项整改' THEN 2
@@ -92,7 +94,7 @@ router.get('/stores/priority', async (req: AuthRequest, res) => {
WHEN '标杆候选' THEN 5
ELSE 6
END, received DESC
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -101,7 +103,8 @@ router.get('/stores/priority', async (req: AuthRequest, res) => {
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`)
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_benchmark_composite_monthly WHERE month_start = $1 ORDER BY benchmark_score DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -111,16 +114,43 @@ router.get('/stores/quadrant', async (req: AuthRequest, res) => {
router.get('/stores/:code', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const month = parseMonth(req)
const [scorecard, risk, platform, benchmark, action] = await Promise.all([
query(`SELECT * FROM analytics.mv_store_scorecard WHERE store_code = $1`, [code]),
query(`SELECT * FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 AND store_code = $2`, [parseMonth(req), code]),
query(`SELECT * FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $2 AND store_code = $1`, [code, parseMonth(req)]),
query(`SELECT * FROM analytics.mv_store_benchmark_composite_monthly WHERE month_start = $2 AND store_code = $1`, [code, parseMonth(req)]),
query(`SELECT * FROM analytics.mv_store_action_priority_deep_monthly WHERE month_start = $1 AND store_code = $2`, [parseMonth(req), code]),
query(`
SELECT store_code, store_name,
count(*) AS bill_count,
count(DISTINCT closed_at::date) AS active_days,
sum(received_total) AS received,
round(sum(received_total) / NULLIF(count(DISTINCT closed_at::date), 0), 2) AS avg_daily_received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(sum(received_total) / NULLIF(sum(guest_count), 0), 2) AS avg_guest_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,
round(count(*) FILTER (WHERE member_id IS NOT NULL)::numeric / count(*) * 100, 2) AS member_bill_share_pct
FROM analytics.fact_bill
WHERE store_code = $1
AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month')
GROUP BY store_code, store_name
`, [code, month]),
query(`SELECT * FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 AND store_code = $2`, [month, code]),
query(`SELECT * FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $2 AND store_code = $1`, [code, month]),
query(`SELECT * FROM analytics.mv_store_benchmark_composite_monthly WHERE month_start = $2 AND store_code = $1`, [code, month]),
query(`SELECT * FROM analytics.mv_store_action_priority_deep_monthly WHERE month_start = $1 AND store_code = $2`, [month, code]),
])
if (scorecard.rows.length === 0) {
return sendError(res, 'Store not found', 404)
const storeInfo = await query(`SELECT store_code, store_name FROM analytics.dim_store WHERE store_code = $1`, [code])
if (storeInfo.rows.length === 0) {
return sendError(res, 'Store not found', 404)
}
const si = storeInfo.rows[0] as any
return sendSuccess(res, {
scorecard: { store_code: si.store_code, store_name: si.store_name, bill_count: 0, active_days: 0, received: 0, avg_daily_received: 0, avg_bill_value: 0, avg_guest_value: 0, discount_rate_pct: 0, theoretical_margin_pct: 0, member_bill_share_pct: 0 },
risk: null,
platform: null,
benchmark: null,
action: { management_quadrant: '问题门店', risk_level: null },
})
}
const sc = scorecard.rows[0] as any
@@ -159,7 +189,7 @@ router.get('/stores/:code/daily', async (req: AuthRequest, res) => {
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
FROM analytics.fact_bill
WHERE store_code = $1
AND closed_at >= $2::date
AND closed_at < ($2::date + interval '1 month')
@@ -176,7 +206,7 @@ router.get('/stores/:code/daily', async (req: AuthRequest, res) => {
router.get('/cost/comparison', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1 ORDER BY variance_to_theoretical_pct DESC NULLS LAST`, [month])
const result = await query(`SELECT store_code, store_name, bill_count, sales_consumption, sales_received, theoretical_cost, theoretical_profit, actual_food_cost, actual_total_cost, packaging_cost, operating_supply_cost, repair_equipment_cost, new_retail_cost, ending_inventory_amount, negative_item_lines, negative_consumption_amount, food_cost_variance, theoretical_cost_rate_pct, actual_food_cost_rate_pct, variance_to_theoretical_pct, cost_rate_gap_pct, comparison_status, variance_level FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1 ORDER BY variance_to_theoretical_pct DESC NULLS LAST`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -186,7 +216,35 @@ router.get('/cost/comparison', async (req: AuthRequest, res) => {
router.get('/cost/category-benchmark', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_inventory_cost_classified_monthly WHERE month_start = $1 ORDER BY sales_store_code`, [month])
const result = await query(`
WITH store_cat AS (
SELECT sales_store_name AS store_name, finance_category,
round(sum(consumption_amount)::numeric, 2) AS actual_category_cost
FROM analytics.mv_inventory_cost_classified_monthly WHERE month_start = $1
GROUP BY sales_store_name, finance_category
),
store_sales AS (
SELECT store_name, max(sales_received) AS sales_received
FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1
GROUP BY store_name
),
cat_sales AS (
SELECT sc.store_name, sc.finance_category, sc.actual_category_cost,
CASE WHEN ss.sales_received > 0 THEN round(sc.actual_category_cost / ss.sales_received * 10000, 2) ELSE NULL END AS cost_per_10k_sales
FROM store_cat sc JOIN store_sales ss ON sc.store_name = ss.store_name
),
peer AS (
SELECT finance_category, percentile_cont(0.5) WITHIN GROUP (ORDER BY cost_per_10k_sales) AS peer_median
FROM cat_sales WHERE cost_per_10k_sales IS NOT NULL GROUP BY finance_category
)
SELECT cs.store_name, cs.finance_category, cs.actual_category_cost,
cs.cost_per_10k_sales,
round(pm.peer_median::numeric, 2) AS peer_median_cost_per_10k,
CASE WHEN cs.cost_per_10k_sales IS NOT NULL AND pm.peer_median IS NOT NULL
THEN round((cs.cost_per_10k_sales - pm.peer_median)::numeric, 2) ELSE NULL END AS excess_vs_peer_per_10k
FROM cat_sales cs LEFT JOIN peer pm ON cs.finance_category = pm.finance_category
ORDER BY cs.store_name
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -196,7 +254,15 @@ router.get('/cost/category-benchmark', async (req: AuthRequest, res) => {
router.get('/cost/inventory', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_area_efficiency_monthly WHERE month_start = $1 ORDER BY estimated_inventory_days DESC NULLS LAST`, [month])
const result = await query(`
SELECT a.store_code, a.store_name, a.business_type, a.scale_tier, a.estimated_inventory_days,
t.ending_inventory_amount, t.negative_item_lines
FROM analytics.mv_store_area_efficiency_monthly a
LEFT JOIN analytics.mv_store_theoretical_actual_cost_monthly t
ON a.store_code = t.store_code AND a.month_start = t.month_start
WHERE a.month_start = $1
ORDER BY a.estimated_inventory_days DESC NULLS LAST
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -215,7 +281,21 @@ router.get('/platform/economics', async (req: AuthRequest, res) => {
router.get('/member/comparison', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_member_comparison`)
const month = parseMonth(req)
const result = await query(`
SELECT
CASE WHEN member_id IS NULL THEN '非会员' ELSE '会员' END AS customer_type,
count(*) AS bill_count,
round(sum(received_total), 2) AS received,
round(avg(received_total), 2) AS avg_bill_value,
round(avg(discount_total), 2) AS avg_discount,
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
FROM analytics.fact_bill
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY CASE WHEN member_id IS NULL THEN '非会员' ELSE '会员' END
ORDER BY customer_type
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -225,7 +305,7 @@ router.get('/member/comparison', async (req: AuthRequest, res) => {
router.get('/member/repeat', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_repeat_summary_monthly WHERE month_start = $1::date ORDER BY repeat_rate_pct DESC NULLS LAST`, [month])
const result = await query(`SELECT * FROM analytics.v_store_repeat_summary_monthly WHERE month_start = $1::date ORDER BY repeat_rate_pct DESC NULLS LAST`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -261,12 +341,57 @@ router.get('/sku/attach', async (req: AuthRequest, res) => {
}
})
const RECEIVED_COLS = `COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0)`
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 })
const month = parseMonth(req)
const storeName = req.query.store as string
const reason = req.query.reason as string
const cashier = req.query.cashier as string
const conditions: string[] = [
`c175 IS NOT NULL AND c175 != ''`,
`c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month')`,
`((c009::numeric > 0 AND (${RECEIVED_COLS}) = 0) OR (c068::numeric > c009::numeric) OR (abs(c009::numeric - COALESCE(c068::numeric,0) - (${RECEIVED_COLS})) > 0.05))`,
]
const params: any[] = [month]
let paramIdx = 2
if (storeName) {
conditions.push(`c003 = $${paramIdx}`)
params.push(storeName)
paramIdx++
}
if (reason) {
if (reason === '有消费无实收') conditions.push(`c009::numeric > 0 AND (${RECEIVED_COLS}) = 0`)
else if (reason === '优惠大于消费') conditions.push(`c068::numeric > c009::numeric`)
else if (reason === '消费-优惠与实收不平') conditions.push(`abs(c009::numeric - COALESCE(c068::numeric,0) - (${RECEIVED_COLS})) > 0.05 AND NOT (c009::numeric > 0 AND (${RECEIVED_COLS}) = 0) AND NOT (c068::numeric > c009::numeric)`)
}
if (cashier) {
conditions.push(`c191 ILIKE $${paramIdx}`)
params.push(`%${cashier}%`)
paramIdx++
}
const whereClause = conditions.join(' AND ')
const countResult = await query(`SELECT count(*) AS total, round(sum(c009::numeric),2) AS sum_consumption, round(sum(COALESCE(c068::numeric,0)),2) AS sum_discount, round(sum(${RECEIVED_COLS}),2) AS sum_received FROM bill_records WHERE ${whereClause}`, params)
const result = await query(`
SELECT c003 AS store_name, c005 AS bill_no, c004 AS meal_period,
c009::numeric AS consumption, COALESCE(c068::numeric,0) AS discount_total,
(${RECEIVED_COLS}) AS received_total,
c191 AS cashier, c176 AS closed_at,
CASE
WHEN c009::numeric > 0 AND (${RECEIVED_COLS}) = 0 THEN '有消费无实收'
WHEN c068::numeric > c009::numeric THEN '优惠大于消费'
WHEN abs(c009::numeric - COALESCE(c068::numeric,0) - (${RECEIVED_COLS})) > 0.05 THEN '消费-优惠与实收不平'
END AS anomaly_reason
FROM bill_records
WHERE ${whereClause}
ORDER BY c176 DESC
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`, [...params, pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize, sum_consumption: countResult.rows[0].sum_consumption, sum_discount: countResult.rows[0].sum_discount, sum_received: countResult.rows[0].sum_received })
} catch (err: any) {
sendError(res, err.message)
}
@@ -274,14 +399,29 @@ router.get('/risk/anomaly', async (req: AuthRequest, res) => {
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)
const month = parseMonth(req)
const storeName = req.query.store as string
let sql = `
SELECT c003 AS store_name, c005 AS bill_no, c004 AS meal_period,
c009::numeric AS consumption, COALESCE(c068::numeric,0) AS discount_total,
0 AS received_total,
c191 AS cashier, c176 AS closed_at,
CASE
WHEN c068::numeric >= c009::numeric AND c009::numeric > 0 THEN '全额优惠'
WHEN c009::numeric = 0 OR c009 IS NULL OR c009 = '' THEN '零消费零实收'
ELSE '有消费无实收'
END AS zero_received_type
FROM bill_records
WHERE c175 IS NOT NULL AND c175 != ''
AND c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month')
AND c009::numeric > 0 AND (${RECEIVED_COLS}) = 0
`
const params: any[] = [month]
if (storeName) {
sql += ` AND c003 = $2`
params.push(storeName)
}
sql += ` ORDER BY closed_at DESC LIMIT 200`
sql += ` ORDER BY c009::numeric DESC LIMIT 200`
const result = await query(sql, params)
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -291,7 +431,21 @@ router.get('/risk/zero-received', async (req: AuthRequest, res) => {
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`)
const month = parseMonth(req)
const result = await query(`
SELECT c003 AS store_name, c191 AS cashier,
count(*) AS bill_count,
round(sum(${RECEIVED_COLS})::numeric, 2) AS received,
count(*) FILTER (WHERE c009::numeric > 0 AND (${RECEIVED_COLS}) = 0) AS anomaly_bills,
round(count(*) FILTER (WHERE c009::numeric > 0 AND (${RECEIVED_COLS}) = 0)::numeric / count(*) * 100, 2) AS anomaly_rate_pct,
round(sum(c009::numeric) FILTER (WHERE c009::numeric > 0 AND (${RECEIVED_COLS}) = 0)::numeric, 2) AS anomaly_consumption
FROM bill_records
WHERE c175 IS NOT NULL AND c175 != ''
AND c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month')
AND c191 IS NOT NULL AND c191 != ''
GROUP BY c003, c191
ORDER BY anomaly_rate_pct DESC NULLS LAST
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -300,7 +454,21 @@ router.get('/risk/cashier', async (req: AuthRequest, res) => {
router.get('/marketing/plans', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_marketing_plan_summary ORDER BY received DESC`)
const month = parseMonth(req)
const result = await query(`
SELECT store_code, store_name,
count(*) AS bill_count,
sum(received_total) AS received,
sum(discount_total) AS discount_amount,
round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(third_party_discount) / NULLIF(sum(received_total), 0) * 100, 2) AS third_party_rate_pct,
count(*) FILTER (WHERE marketing_plan IS NOT NULL AND marketing_plan != '') AS plan_bills,
round(count(*) FILTER (WHERE marketing_plan IS NOT NULL AND marketing_plan != '')::numeric / count(*) * 100, 2) AS plan_coverage_pct
FROM analytics.fact_bill
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY store_code, store_name
ORDER BY received DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -319,7 +487,21 @@ router.get('/benchmark/composite', async (req: AuthRequest, res) => {
router.get('/time/weekday', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_weekday_summary ORDER BY weekday_no`)
const month = parseMonth(req)
const result = await query(`
SELECT EXTRACT(isodow FROM closed_at)::int AS weekday_no,
CASE EXTRACT(isodow FROM closed_at)::int
WHEN 1 THEN '周一' WHEN 2 THEN '周二' WHEN 3 THEN '周三'
WHEN 4 THEN '周四' WHEN 5 THEN '周五' WHEN 6 THEN '周六' ELSE '周日'
END AS weekday,
count(*) AS bill_count,
sum(received_total) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(avg(duration_minutes), 2) AS avg_duration_minutes
FROM analytics.fact_bill
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY 1, 2 ORDER BY 1
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -328,7 +510,18 @@ router.get('/time/weekday', async (req: AuthRequest, res) => {
router.get('/time/hourly', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_hourly_summary ORDER BY closing_hour`)
const month = parseMonth(req)
const result = await query(`
SELECT EXTRACT(hour FROM closed_at)::int AS closing_hour,
count(*) AS bill_count,
sum(received_total) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(avg(duration_minutes), 2) AS avg_duration_minutes
FROM analytics.fact_bill
WHERE closed_at IS NOT NULL
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY 1 ORDER BY 1
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -337,7 +530,23 @@ router.get('/time/hourly', async (req: AuthRequest, res) => {
router.get('/channel', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_channel_daily ORDER BY business_date`)
const month = parseMonth(req)
const result = await query(`
SELECT closed_at::date AS business_date,
sum(cash_received) AS cash,
sum(alipay_received) AS alipay,
sum(wechat_received) AS wechat,
sum(meituan_received) AS meituan,
sum(unionpay_received) AS unionpay,
sum(douyin_received) AS douyin,
sum(credit_received) AS credit,
sum(jd_delivery_received) AS jd_delivery,
sum(meituan_delivery_received) AS meituan_delivery,
sum(taobao_delivery_received) AS taobao_delivery
FROM analytics.fact_bill
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY 1 ORDER BY 1
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -358,7 +567,7 @@ router.get('/data-quality', async (req: AuthRequest, res) => {
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
FROM analytics.fact_bill
`)
const dishStats = await query(`
SELECT
@@ -388,7 +597,39 @@ router.get('/data-quality', async (req: AuthRequest, res) => {
router.get('/stores/:code/meal-period', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_meal_opportunity WHERE store_code = $1 ORDER BY bill_count DESC`, [req.params.code])
const month = parseMonth(req)
const code = req.params.code
const result = await query(`
WITH network AS (
SELECT meal_period,
count(*) AS network_bills,
(sum(received_total) / NULLIF(count(*), 0)) AS network_avg_bill
FROM analytics.fact_bill
WHERE meal_period IS NOT NULL
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY meal_period
), store_meal AS (
SELECT store_code, store_name, meal_period,
count(*) AS bill_count,
sum(received_total) AS received,
(sum(received_total) / NULLIF(count(*), 0)) AS avg_bill
FROM analytics.fact_bill
WHERE meal_period IS NOT NULL
AND store_code = $2
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY store_code, store_name, meal_period
)
SELECT s.store_code, s.store_name, s.meal_period,
s.bill_count,
round(s.received, 2) AS received,
round(s.avg_bill, 2) AS avg_bill,
round(n.network_avg_bill, 2) AS network_avg_bill,
round(GREATEST(n.network_avg_bill - s.avg_bill, 0) * s.bill_count, 2) AS avg_bill_uplift_scenario,
round((s.avg_bill / n.network_avg_bill - 1) * 100, 2) AS vs_network_pct
FROM store_meal s
JOIN network n ON s.meal_period = n.meal_period
ORDER BY s.bill_count DESC
`, [month, code])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
@@ -469,16 +710,38 @@ router.get('/stores/:code/member', async (req: AuthRequest, res) => {
router.get('/stores/:code/anomalies', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const countResult = await query(`SELECT count(*) AS total FROM analytics.v_anomaly_bills WHERE store_code = $1`, [req.params.code])
const result = await query(`SELECT * FROM analytics.v_anomaly_bills WHERE store_code = $1 ORDER BY closed_at DESC LIMIT $2 OFFSET $3`, [req.params.code, pageSize, offset])
const month = parseMonth(req)
const code = req.params.code
const countResult = await query(`SELECT count(*) AS total FROM analytics.fact_bill WHERE store_code = $1 AND is_anomaly = true AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month')`, [code, month])
const result = await query(`SELECT * FROM analytics.fact_bill WHERE store_code = $1 AND is_anomaly = true AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month') ORDER BY closed_at DESC LIMIT $3 OFFSET $4`, [code, month, 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('/region/summary', async (req, res) => {
router.get('/region/summary', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.mv_region_summary ORDER BY total_received DESC`)
const month = parseMonth(req)
const result = await query(`
SELECT d.region,
count(DISTINCT b.store_code) AS store_count,
COALESCE(sum(b.received_total), 0) AS total_received,
count(*) AS total_bill_count,
round(avg(b.received_total), 2) AS avg_bill_value,
round(sum(b.discount_total) / NULLIF(sum(b.consumption), 0) * 100, 2) AS avg_discount_rate,
round(sum(b.theoretical_profit) / NULLIF(sum(b.received_total), 0) * 100, 2) AS avg_margin_rate,
0 AS avg_anomaly_rate,
count(DISTINCT CASE WHEN r.risk_level = '红色' THEN b.store_code END) AS red_count,
count(DISTINCT CASE WHEN r.risk_level = '黄色' THEN b.store_code END) AS yellow_count,
count(DISTINCT CASE WHEN r.risk_level = '绿色' THEN b.store_code END) AS green_count
FROM analytics.fact_bill b
JOIN analytics.dim_store d ON d.store_code = b.store_code
LEFT JOIN analytics.mv_store_risk_rating_monthly r ON r.store_code = b.store_code AND r.month_start = $1::date
WHERE d.region IS NOT NULL AND d.region <> ''
AND b.closed_at >= $1::date AND b.closed_at < ($1::date + interval '1 month')
GROUP BY d.region
ORDER BY total_received DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
@@ -1112,10 +1375,12 @@ router.get('/bank/report', async (req: AuthRequest, res) => {
const stdDev = Math.sqrt(variance)
const cv = meanRev > 0 ? stdDev / meanRev : 0
const f = (v: any) => v === null || v === undefined ? 0 : parseFloat(v)
const i = (v: any) => v === null || v === undefined ? 0 : parseInt(v)
sendSuccess(res, {
overview: { ...ov, received: parseFloat(ov?.received), bill_count: parseInt(ov?.bill_count), avg_bill_value: parseFloat(ov?.avg_bill_value), discount_rate_pct: parseFloat(ov?.discount_rate_pct), theoretical_margin_pct: parseFloat(ov?.theoretical_margin_pct), member_bills: parseInt(ov?.member_bills), member_share_pct: parseFloat(ov?.member_share_pct) },
overview: { ...ov, received: f(ov?.received), bill_count: i(ov?.bill_count), avg_bill_value: f(ov?.avg_bill_value), discount_rate_pct: f(ov?.discount_rate_pct), theoretical_margin_pct: f(ov?.theoretical_margin_pct), member_bills: i(ov?.member_bills), member_share_pct: f(ov?.member_share_pct) },
daily: dailyRows,
waterfall: { ...wf, received: parseFloat(wf?.received), food_cost: parseFloat(wf?.food_cost), wage: parseFloat(wf?.wage), rent: parseFloat(wf?.rent), utility: parseFloat(wf?.utility), dorm: parseFloat(wf?.dorm), commission: parseFloat(wf?.commission), other_expense: parseFloat(wf?.other_expense), total_expense: parseFloat(wf?.total_expense), store_contribution: parseFloat(wf?.store_contribution) },
waterfall: { ...wf, received: f(wf?.received), food_cost: f(wf?.food_cost), wage: f(wf?.wage), rent: f(wf?.rent), utility: f(wf?.utility), dorm: f(wf?.dorm), commission: f(wf?.commission), other_expense: f(wf?.other_expense), total_expense: f(wf?.total_expense), store_contribution: f(wf?.store_contribution) },
risk: riskRows,
channel: channelTotals,
stores: storeRows,
@@ -1739,8 +2004,10 @@ router.get('/central-kitchen/bom-penetration', async (req: AuthRequest, res) =>
JOIN recipe_agg ra ON pc.recipe_name = ra.recipe_name
LEFT JOIN central_kitchen_processing_cost pc2 ON ra.material_code = pc2.product_code AND pc2.report_month = $1::date
WHERE bt.is_finished_product AND bt.level < 5
${productCode ? '' : 'AND bt.level < 1'}
)
SELECT * FROM bom_tree ORDER BY root_product_code, level, theoretical_amt DESC
SELECT * FROM bom_tree
ORDER BY root_product_code, level, theoretical_amt DESC
${productCode ? '' : 'LIMIT 200'}
`, productCode ? [month, productCode] : [month]),
@@ -1846,29 +2113,34 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
const client = await pool.connect()
try {
// 创建临时表存储聚合销量,避免重复全表扫描550万行
// 检查物化视图是否包含当前月份数据,如缺失则刷新
const mvCheck = await client.query(`
SELECT 1 FROM mv_dish_sales_monthly WHERE month = $1::date LIMIT 1
`, [month])
if (mvCheck.rows.length === 0) {
// 物化视图中没有该月数据,刷新物化视图
await client.query(`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dish_sales_monthly`)
}
// 从物化视图创建临时表(索引扫描,毫秒级)
await client.query(`
CREATE TEMP TABLE IF NOT EXISTS tmp_sales_agg AS
SELECT d.store_code, d.store_name, sk.sku_code, d.dish_name,
round(sum(d.sales_quantity)::numeric,4) AS qty,
round(sum(d.gross_amount)::numeric,2) AS amt,
round(avg(d.unit_price)::numeric,2) AS avg_unit_price
FROM dish_sales_details d
JOIN analytics.dim_sku sk ON d.dish_name = sk.standard_name
WHERE d.ordered_at >= $1::date AND d.ordered_at < ($1::date + INTERVAL '1 month')
${storeCode ? 'AND d.store_code = $2' : ''}
GROUP BY d.store_code, d.store_name, sk.sku_code, d.dish_name
SELECT store_code, store_name, sku_code, dish_name, qty, amt, avg_unit_price
FROM mv_dish_sales_monthly
WHERE month = $1::date
${storeCode ? 'AND store_code = $2' : ''}
`, storeCode ? [month, storeCode] : [month])
await client.query(`CREATE INDEX IF NOT EXISTS idx_tmp_sales_sku ON tmp_sales_agg(sku_code)`)
await client.query(`CREATE INDEX IF NOT EXISTS idx_tmp_sales_store ON tmp_sales_agg(store_code)`)
// 总销量(从临时表获取匹配菜品的汇总,从原始表获取全部菜品数
// 总销量(全部从临时表获取,无需再扫描原表
const totalSales = await client.query(`
SELECT
(SELECT count(*) FROM (SELECT DISTINCT dish_name FROM dish_sales_details WHERE ordered_at >= $1::date AND ordered_at < ($1::date + INTERVAL '1 month')) t) AS total_dish_count,
(SELECT round(sum(gross_amount)::numeric,2) FROM dish_sales_details WHERE ordered_at >= $1::date AND ordered_at < ($1::date + INTERVAL '1 month')) AS total_sales_amt
`, [month])
count(DISTINCT dish_name) AS total_dish_count,
round(sum(amt)::numeric,2) AS total_sales_amt
FROM tmp_sales_agg
`)
const [summaryResult, skuSalesResult, materialDemandResult, storeDemandResult, ckProductionPlanResult, productionCoordResult] = await Promise.all([
// 汇总(使用临时表)
@@ -1916,7 +2188,7 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
LIMIT 50
`),
// 原料需求(使用临时表 × BOM展开)
// 原料需求(使用临时表 × BOM展开,价格通过名称匹配配送记录
client.query(`
WITH demand AS (
SELECT s.store_code, b.material_code,
@@ -1928,11 +2200,12 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
JOIN analytics.fact_recipe_bom b ON s.sku_code = b.sku_code
LEFT JOIN analytics.dim_material m ON b.material_code = m.material_code
LEFT JOIN (
SELECT item_code AS material_code, round(avg(unit_price_excl_tax)::numeric,4) AS unit_price
FROM central_kitchen_recipe_consumption
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
GROUP BY item_code
) ic ON b.material_code = ic.material_code
SELECT d.item_name, round(sum(d.outbound_total_amount)::numeric / NULLIF(sum(d.total_quantity), 0), 4) AS unit_price
FROM distribution_detail_records d
WHERE d.business_date >= $1::date AND d.business_date < ($1::date + INTERVAL '1 month')
AND NOT d.is_return AND d.item_name IS NOT NULL
GROUP BY d.item_name
) ic ON m.material_name = ic.item_name
GROUP BY s.store_code, b.material_code, m.material_name, b.unit
)
SELECT
@@ -1981,7 +2254,7 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
ORDER BY sd.total_amt DESC
`, [month]),
// 中央厨房生产计划(使用临时表)
// 中央厨房生产计划(使用临时表,通过名称匹配实际入库数据
client.query(`
WITH ck_demand AS (
SELECT b.material_code AS ck_product_code,
@@ -1992,10 +2265,7 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
FROM tmp_sales_agg s
JOIN analytics.fact_recipe_bom b ON s.sku_code = b.sku_code
JOIN analytics.dim_material m ON b.material_code = m.material_code
WHERE b.material_code IN (
SELECT product_code FROM central_kitchen_processing_cost WHERE report_month = $1::date
)
OR m.material_name IN (
WHERE m.material_name IN (
SELECT product_name FROM central_kitchen_processing_cost WHERE report_month = $1::date
)
GROUP BY b.material_code, m.material_name, b.unit
@@ -2009,23 +2279,22 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
GROUP BY product_code, product_name
),
ck_combined AS (
SELECT COALESCE(d.ck_product_code, a.product_code) AS product_code,
COALESCE(d.ck_product_name, a.product_name) AS product_name,
SELECT d.ck_product_code AS product_code,
d.ck_product_name AS product_name,
COALESCE(d.unit, '') AS unit,
COALESCE(d.demand_qty, 0) AS demand_qty,
COALESCE(d.store_count, 0) AS store_count,
d.demand_qty,
d.store_count,
COALESCE(a.actual_inbound_qty, 0) AS actual_inbound_qty,
COALESCE(a.actual_cost, 0) AS actual_cost
FROM ck_demand d
FULL OUTER JOIN ck_actual a ON d.ck_product_code = a.product_code
LEFT JOIN ck_actual a ON d.ck_product_name = a.product_name
UNION ALL
SELECT a.product_code, a.product_name, '',
0, 0, a.actual_inbound_qty, a.actual_cost
FROM ck_actual a
WHERE a.product_code NOT IN (SELECT ck_product_code FROM ck_demand WHERE ck_product_code IS NOT NULL)
AND a.product_name NOT IN (SELECT ck_product_name FROM ck_demand WHERE ck_product_name IS NOT NULL)
WHERE a.product_name NOT IN (SELECT ck_product_name FROM ck_demand)
)
SELECT product_code, product_name, unit,
demand_qty, store_count, actual_inbound_qty, actual_cost,
@@ -2055,7 +2324,7 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
FROM distribution_detail_records d
WHERE d.business_date >= $1::date AND d.business_date < ($1::date + INTERVAL '1 month')
AND NOT d.is_return AND d.item_code IS NOT NULL
AND d.distribution_center_code = '3'
AND d.distribution_center_code = '2'
GROUP BY d.item_code, d.item_name
),
store_consumption AS (
+2 -2
View File
@@ -113,8 +113,8 @@ router.get('/alerts', async (req: AuthRequest, res) => {
SELECT closed_at::date AS business_date,
count(*) AS bill_count,
sum(received_total) AS received
FROM analytics.bill_fact
WHERE closed_at >= (SELECT max(closed_at)::date - interval '30 days' FROM analytics.bill_fact)
FROM analytics.fact_bill
WHERE closed_at >= (SELECT max(closed_at)::date - interval '30 days' FROM analytics.fact_bill)
AND closed_at IS NOT NULL
GROUP BY closed_at::date
ORDER BY business_date
+19 -6
View File
@@ -152,7 +152,7 @@ router.get('/dow-traffic', async (req: AuthRequest, res) => {
const storeName = req.query.store as string
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'`
let where = `WHERE c175 IS NOT NULL AND c175 != '' AND c175::timestamp >= $1::date AND c175::timestamp < $1::date + interval '1 month'`
if (storeName) {
params.push(storeName)
where += ` AND c003 = $${params.length}`
@@ -455,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 >= $4 AND c175 < $4::date + interval '1 month'
AND c175::timestamp >= $4::date AND c175::timestamp < $4::date + interval '1 month'
GROUP BY hour, day_type, bill_date
),
hourly_stats AS (
@@ -721,12 +721,18 @@ router.get('/employee-analysis', async (req: AuthRequest, res) => {
params.push(storeName)
where += ` AND org_level5 = $${params.length}`
}
params.push(month)
const monthParam = `$${params.length}`
// 将 2026-04 转为 2026年4月 格式匹配 salary_period
const [yr, mo] = month.split('-')
const periodLabel = `${parseInt(yr)}${parseInt(mo)}`
params.push(periodLabel)
where += ` AND salary_period = $${params.length}`
const countResult = await query(`SELECT count(*) FROM salary_detail_records ${where}`, params)
const total = countResult.rows[0].count
params.push(month)
const monthParam = `$${params.length}`
params.push(pageSize, offset)
const result = await query(`
SELECT employee_code,
@@ -767,6 +773,9 @@ router.get('/employee-analysis', async (req: AuthRequest, res) => {
// 岗位薪资对比
router.get('/position-salary-compare', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [yr, mo] = month.split('-')
const periodLabel = `${parseInt(yr)}${parseInt(mo)}`
const result = await query(`
SELECT position,
count(*) AS emp_count,
@@ -781,10 +790,11 @@ router.get('/position-salary-compare', async (req: AuthRequest, res) => {
FROM salary_detail_records
WHERE org_level2 = '西部马华品牌门店'
AND position IS NOT NULL AND position != ''
AND salary_period = $1
GROUP BY position
HAVING count(*) >= 5
ORDER BY avg_gross DESC
`)
`, [periodLabel])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -795,6 +805,8 @@ router.get('/position-salary-compare', async (req: AuthRequest, res) => {
router.get('/turnover-stats', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [yr, mo] = month.split('-')
const periodLabel = `${parseInt(yr)}${parseInt(mo)}`
const result = await query(`
SELECT org_level5 AS store_name,
count(*) AS total_emp,
@@ -804,9 +816,10 @@ router.get('/turnover-stats', async (req: AuthRequest, res) => {
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 != ''
AND salary_period = $2
GROUP BY org_level5
ORDER BY turnover_rate DESC NULLS LAST
`, [month])
`, [month, periodLabel])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
+71 -7
View File
@@ -123,8 +123,15 @@ router.get('/monthly-review', async (req: AuthRequest, res) => {
router.get('/followup', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.mv_store_monthly_followup ORDER BY priority, store_code`)
sendSuccess(res, result.rows)
const month = req.query.month as string | undefined
if (month) {
const monthDate = month + '-01'
const result = await query(`SELECT * FROM analytics.mv_store_monthly_followup WHERE plan_month = $1::date ORDER BY priority, store_code`, [monthDate])
sendSuccess(res, result.rows)
} else {
const result = await query(`SELECT * FROM analytics.mv_store_monthly_followup ORDER BY priority, store_code`)
sendSuccess(res, result.rows)
}
} catch (err: any) {
sendError(res, err.message)
}
@@ -132,7 +139,8 @@ router.get('/followup', async (req: AuthRequest, res) => {
router.get('/grade-change', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.store_grade_change ORDER BY change_month DESC, store_code`)
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.store_grade_change WHERE change_month = $1::date ORDER BY change_month DESC, store_code`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -278,7 +286,49 @@ router.post('/notifications/dispatch', async (req: AuthRequest, res) => {
router.get('/stores/:code/daily-card', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const result = await query(`SELECT * FROM analytics.v_store_daily_card WHERE store_code = $1 ORDER BY module`, [code])
const month = req.query.month as string | undefined
let dateFilter: string
let params: any[] = [code]
if (month) {
const monthDate = month + '-01'
dateFilter = `closed_at::date = (SELECT max(closed_at)::date FROM analytics.fact_bill WHERE closed_at >= $2::date AND closed_at < ($2::date + interval '1 month') AND closed_at IS NOT NULL)`
params.push(monthDate)
} else {
dateFilter = `closed_at::date = (SELECT max(closed_at)::date FROM analytics.fact_bill WHERE closed_at IS NOT NULL)`
}
const result = await query(`
WITH target_day AS (
SELECT max(closed_at)::date AS business_date
FROM analytics.fact_bill
WHERE ${dateFilter.replace('closed_at', 'fact_bill.closed_at')}
),
today_stats AS (
SELECT bf.store_code, bf.store_name,
round(sum(bf.received_total), 2) AS received,
count(*) AS bill_count,
round(sum(bf.received_total) / count(*), 2) AS avg_bill
FROM analytics.fact_bill bf, target_day td
WHERE bf.store_code = $1 AND bf.closed_at::date = td.business_date
GROUP BY bf.store_code, bf.store_name
),
week_ago_stats AS (
SELECT bf.store_code,
round(sum(bf.received_total), 2) AS received,
count(*) AS bill_count,
round(sum(bf.received_total) / count(*), 2) AS avg_bill
FROM analytics.fact_bill bf, target_day td
WHERE bf.store_code = $1 AND bf.closed_at::date = td.business_date - interval '7 days'
GROUP BY bf.store_code
)
SELECT t.store_code, t.store_name, '收入' AS module,
jsonb_build_array(
jsonb_build_object('metric', '实收', 'value', t.received, 'baseline', COALESCE(w.received, 0), 'is_anomaly', t.received < (COALESCE(w.received, 0) * 0.8)),
jsonb_build_object('metric', '账单数', 'value', t.bill_count, 'baseline', COALESCE(w.bill_count, 0), 'is_anomaly', t.bill_count::numeric < (COALESCE(w.bill_count, 0) * 0.8)),
jsonb_build_object('metric', '客单价', 'value', t.avg_bill, 'baseline', COALESCE(w.avg_bill, 0), 'is_anomaly', t.avg_bill < (COALESCE(w.avg_bill, 0) * 0.9))
) AS anomalies
FROM today_stats t
LEFT JOIN week_ago_stats w ON t.store_code = w.store_code
`, params)
const tasks = await query(`
SELECT t.* FROM analytics.store_task t
WHERE t.store_code = $1 AND t.status IN ('待启动', '进行中')
@@ -601,14 +651,28 @@ router.get('/monthly-review/completion', async (req: AuthRequest, res) => {
router.get('/monthly-review/activity-list', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT marketing_plan, bill_count, consumption, discounts, received,
SELECT marketing_plan, bill_count, consumption, discounts, received,
avg_bill_value, discount_rate_pct, theoretical_margin_pct,
ROUND(discounts * 100.0 / NULLIF(consumption, 0), 2) as discount_cost_rate,
ROUND(received - discounts, 2) as net_contribution
FROM analytics.v_marketing_plan_summary
FROM (
SELECT marketing_plan,
count(*) AS bill_count,
sum(consumption) AS consumption,
sum(discount_total) AS discounts,
sum(received_total) AS received,
round(avg(received_total), 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
FROM analytics.fact_bill
WHERE marketing_plan IS NOT NULL
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY marketing_plan
) t
ORDER BY received DESC
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})