-
时间维度分析
-
星期规律 · 小时分布 · 渠道趋势 · 2026年4月
+
+
+
时间维度分析
+
星期规律 · 小时分布 · 渠道趋势
+
+
{/* 概览指标 */}
-
-
-
+
+
+
@@ -117,7 +124,7 @@ export function TimePage() {
-
+
diff --git a/server/src/routes/cost-analysis.ts b/server/src/routes/cost-analysis.ts
index efd2bb7..dd6610f 100644
--- a/server/src/routes/cost-analysis.ts
+++ b/server/src/routes/cost-analysis.ts
@@ -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)
diff --git a/server/src/routes/data.ts b/server/src/routes/data.ts
index d0ad454..32ea2c2 100644
--- a/server/src/routes/data.ts
+++ b/server/src/routes/data.ts
@@ -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 (
diff --git a/server/src/routes/situational-awareness.ts b/server/src/routes/situational-awareness.ts
index 992ec18..1d4a262 100644
--- a/server/src/routes/situational-awareness.ts
+++ b/server/src/routes/situational-awareness.ts
@@ -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
diff --git a/server/src/routes/smart-scheduling.ts b/server/src/routes/smart-scheduling.ts
index e3e9ef4..2f2b022 100644
--- a/server/src/routes/smart-scheduling.ts
+++ b/server/src/routes/smart-scheduling.ts
@@ -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)
diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts
index 104fbb8..7cef8e5 100644
--- a/server/src/routes/tasks.ts
+++ b/server/src/routes/tasks.ts
@@ -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) }
})