风险与内控管理优化: 异常账单服务端分页/筛选/收银员搜索/合计, 收银员TOP15按异常账单数排序, 双击跳转明细, MonthPicker靠右, 侧边栏底部间距
This commit is contained in:
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user