fix: P2/P3审计问题修复 + 更新审计文档移除已修复问题

- 修复schema前缀:确认central_kitchen/mv_distribution/mv_dish_sales等表在public schema,撤销错误的analytics.前缀
- 移除API内部物化视图刷新:mv_distribution_monthly/mv_dish_sales_monthly改为console.warn
- 修复BOM树节点key:使用path字段替代material_code+level确保唯一性
- 修复HR人才矩阵potential_score:基于v3_learning_record动态计算替代硬编码60
- 修复StoreDetailPage公司均值:优先使用API返回的companyAvg
- 修复前端字段名匹配:discount_rate_pct/check_comment等
- 添加explicit column list和LIMIT到大型查询
- 更新数据溯源审计.md:移除全部52个已修复/确认无需修复的问题条目
This commit is contained in:
freedakgmail
2026-08-11 22:00:58 +08:00
parent 915345bfd6
commit 8f5271fcc6
15 changed files with 3399 additions and 150 deletions
+62 -32
View File
@@ -129,7 +129,7 @@ router.get('/stores', async (req: AuthRequest, res) => {
router.get('/stores/risk', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 ORDER BY risk_level, received DESC`, [month])
const result = await query(`SELECT store_code, store_name, risk_level, risk_score, received, bill_count, avg_daily_received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bill_share_pct, anomaly_rate_pct, repeat_rate_pct, delivery_bill_share_pct, combo_rate_pct, avg_items_per_bill, primary_issue, month_start FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 ORDER BY risk_level, received DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -173,7 +173,7 @@ 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([
const [scorecard, risk, platform, benchmark, action, companyAvg] = await Promise.all([
query(`
SELECT c002 AS store_code, c003 AS store_name,
count(*) AS bill_count,
@@ -198,6 +198,20 @@ router.get('/stores/:code', async (req: AuthRequest, res) => {
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]),
query(`
SELECT
round(avg(avg_daily_received)::numeric, 0) AS daily_rev,
round(avg(avg_bill_value)::numeric, 1) AS bill,
round(avg(discount_rate_pct)::numeric, 1) AS discount,
round(avg(theoretical_margin_pct)::numeric, 1) AS margin,
round(avg(member_bill_share_pct)::numeric, 1) AS member,
round(avg(COALESCE(repeat_rate_pct, 0))::numeric, 1) AS repeat,
round(avg(COALESCE(delivery_bill_share_pct, 0))::numeric, 1) AS delivery,
round(avg(COALESCE(combo_rate_pct, 0))::numeric, 1) AS combo,
round(avg(COALESCE(avg_items_per_bill, 0))::numeric, 2) AS items
FROM analytics.mv_store_risk_rating_monthly
WHERE month_start = $1
`, [month]),
])
if (scorecard.rows.length === 0) {
@@ -212,6 +226,7 @@ router.get('/stores/:code', async (req: AuthRequest, res) => {
platform: null,
benchmark: null,
action: { management_quadrant: '问题门店', risk_level: null },
companyAvg: companyAvg.rows[0] || null,
})
}
@@ -235,6 +250,7 @@ router.get('/stores/:code', async (req: AuthRequest, res) => {
platform: platform.rows[0],
benchmark: benchmark.rows[0],
action: { ...act, management_quadrant: quadrant, risk_level: rk?.risk_level },
companyAvg: companyAvg.rows[0] || null,
})
} catch (err: any) {
sendError(res, err.message)
@@ -337,7 +353,7 @@ router.get('/cost/inventory', async (req: AuthRequest, res) => {
router.get('/platform/economics', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $1 ORDER BY meituan_received DESC NULLS LAST`, [month])
const result = await query(`SELECT store_code, store_name, month_start, platform_share, meituan_received, meituan_cost_rate_pct, eleme_received, eleme_cost_rate_pct, douyin_received, douyin_cost_rate_pct, self_delivery_received, dine_in_received, dine_in_share_pct, delivery_bill_share_pct FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $1 ORDER BY meituan_received DESC NULLS LAST LIMIT 200`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -484,12 +500,16 @@ router.get('/risk/zero-received', async (req: AuthRequest, res) => {
const baseWhere = baseConditions.join(' AND ')
const countResult = await query(`SELECT count(*) FILTER (WHERE zero_received_type = '全额优惠') AS full_discount, count(*) FILTER (WHERE zero_received_type = '无消费无优惠') AS empty_bill FROM mv_risk_zero WHERE ${baseWhere}`, baseParams)
const totalResult = await query(`SELECT count(*) AS total FROM mv_risk_zero WHERE ${whereClause}`, params)
const sort = (req.query.sort as string) || 'consumption'
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
const validSorts = ['consumption', 'discount_total', 'store_name']
const sortCol = validSorts.includes(sort) ? sort : 'consumption'
const result = await query(`
SELECT store_name, bill_no, meal_period, consumption, discount_total,
0 AS received_total, cashier, closed_at, zero_received_type
FROM mv_risk_zero
WHERE ${whereClause}
ORDER BY consumption DESC
ORDER BY ${sortCol} ${order}
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`, [...params, pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(totalResult.rows[0].total), full_discount: parseInt(countResult.rows[0].full_discount), empty_bill: parseInt(countResult.rows[0].empty_bill), page, page_size: pageSize })
@@ -501,15 +521,20 @@ router.get('/risk/zero-received', async (req: AuthRequest, res) => {
router.get('/risk/cashier', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const sort = (req.query.sort as string) || 'anomaly_bills'
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
const validSorts = ['anomaly_rate_pct', 'bill_count', 'received', 'anomaly_bills', 'consumption_total']
const sortCol = validSorts.includes(sort) ? sort : 'anomaly_bills'
const result = await query(`
SELECT store_name, cashier, bill_count,
received,
(anomaly_no_received + anomaly_discount_over + anomaly_unbalanced) AS anomaly_bills,
round((anomaly_no_received + anomaly_discount_over + anomaly_unbalanced)::numeric / bill_count * 100, 2) AS anomaly_rate_pct,
consumption_total
consumption_total,
consumption_total AS anomaly_consumption
FROM mv_risk_cashier
WHERE month = to_char($1::date, 'YYYY-MM') AND cashier IS NOT NULL AND cashier != ''
ORDER BY (anomaly_no_received + anomaly_discount_over + anomaly_unbalanced) DESC NULLS LAST
ORDER BY ${sortCol} ${order} NULLS LAST
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -586,7 +611,7 @@ router.get('/channel', async (req: AuthRequest, res) => {
const month = parseMonth(req)
const result = await query(`
SELECT business_date, cash, alipay, wechat, meituan, unionpay, douyin, credit, jd_delivery, meituan_delivery, taobao_delivery
FROM mv_channel_daily
FROM analytics.mv_channel_daily
WHERE month = to_char($1::date, 'YYYY-MM')
ORDER BY business_date
`, [month])
@@ -598,6 +623,7 @@ router.get('/channel', async (req: AuthRequest, res) => {
router.get('/data-quality', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const billStats = await query(`
SELECT
count(*) AS total_bills,
@@ -611,15 +637,16 @@ router.get('/data-quality', async (req: AuthRequest, res) => {
min(closed_at)::text AS min_date,
max(closed_at)::text AS max_date
FROM analytics.bill_fact
`)
WHERE closed_at >= $1::date AND closed_at < ($1::date + INTERVAL '1 month')
`, [month])
const dishStats = await query(`
SELECT
count(*) AS total_dish_records,
count(*) FILTER (WHERE store_code IS NULL OR store_code = '') AS dish_missing_store,
count(*) FILTER (WHERE dish_name IS NULL OR dish_name = '') AS dish_missing_dish
FROM public.dish_sales_details
`)
const month = parseMonth(req)
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
`, [month])
const inventoryStats = await query(`
SELECT
count(*) FILTER (WHERE consumption_amount < 0) AS negative_consumption_count,
@@ -972,7 +999,7 @@ router.get('/region/summary', async (req: AuthRequest, res) => {
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,
round(avg(COALESCE(r.anomaly_rate_pct, 0)), 2) 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
@@ -992,7 +1019,7 @@ router.get('/region/summary', async (req: AuthRequest, res) => {
router.get('/site-selection/profile', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_site_profile_monthly WHERE month_start = $1 AND business_type='标准门店' AND received>0 ORDER BY received DESC`, [month])
const result = await query(`SELECT store_code, store_name, received, avg_daily_received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bill_share_pct, delivery_bill_share_pct, combo_bill_share_pct, business_type, scale_tier, area_sqm, business_address, city, district, latitude_gcj02, longitude_gcj02, open_date, lease_expiry_date, store_age_years, monthly_received_per_sqm, daily_received_per_sqm, estimated_inventory_days, site_scene, floor_type, area_band, age_band FROM analytics.mv_store_site_profile_monthly WHERE month_start = $1 AND business_type='标准门店' AND received>0 ORDER BY received DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
@@ -1001,7 +1028,7 @@ router.get('/site-selection/profile', async (req: AuthRequest, res) => {
router.get('/site-selection/segment-benchmark', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_site_segment_benchmark_monthly WHERE month_start = $1 ORDER BY avg_received_per_sqm DESC`, [month])
const result = await query(`SELECT site_scene, area_band, store_count, avg_area_sqm, avg_received, median_received, avg_received_per_sqm, median_received_per_sqm, avg_bill_value, avg_discount_rate_pct, avg_repeat_rate_pct, avg_actual_cost_rate_pct, avg_delivery_share_pct, avg_drink_attach_pct FROM analytics.mv_site_segment_benchmark_monthly WHERE month_start = $1 ORDER BY avg_received_per_sqm DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
@@ -1010,7 +1037,7 @@ router.get('/site-selection/segment-benchmark', async (req: AuthRequest, res) =>
router.get('/site-selection/replication', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_site_replication_monthly WHERE month_start = $1 ORDER BY site_replication_score DESC`, [month])
const result = await query(`SELECT store_code, store_name, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bill_share_pct, repeat_rate_pct, benchmark_score, area_sqm, business_address, city, district, latitude_gcj02, longitude_gcj02, site_scene, area_band, nearest_store_code, nearest_store_name, nearest_distance_km, site_replication_score, replication_recommendation, spatial_recommendation FROM analytics.mv_store_site_replication_monthly WHERE month_start = $1 ORDER BY site_replication_score DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
@@ -1019,7 +1046,7 @@ router.get('/site-selection/replication', async (req: AuthRequest, res) => {
router.get('/site-selection/overlap-risk', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_store_overlap_risk_monthly WHERE month_start = $1 ORDER BY distance_km`, [month])
const result = await query(`SELECT store_code_a, store_name_a, store_code_b, store_name_b, district_a, district_b, scene_a, scene_b, priority_a, priority_b, received_a, received_b, sqm_efficiency_a, sqm_efficiency_b, distance_km, proximity_level, problem_count_a, problem_count_b, overlap_risk FROM analytics.mv_store_overlap_risk_monthly WHERE month_start = $1 ORDER BY distance_km LIMIT 200`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
@@ -1028,7 +1055,7 @@ router.get('/site-selection/overlap-risk', async (req: AuthRequest, res) => {
router.get('/site-selection/district-benchmark', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.mv_district_site_benchmark_monthly WHERE month_start = $1 ORDER BY total_received DESC`, [month])
const result = await query(`SELECT city, district, store_count, avg_area_sqm, total_received, avg_received, median_received, avg_received_per_sqm, avg_bill_value, avg_discount_rate_pct, avg_repeat_rate_pct, avg_actual_cost_rate_pct, avg_platform_cost_rate_pct, p0_count, p1_count FROM analytics.mv_district_site_benchmark_monthly WHERE month_start = $1 ORDER BY total_received DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
@@ -1543,6 +1570,7 @@ router.get('/revenue/store-ranking', async (req: AuthRequest, res) => {
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
GROUP BY store_code, store_name
ORDER BY sum(received) DESC
LIMIT 200
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -1710,7 +1738,7 @@ router.get('/central-kitchen/dashboard', async (req: AuthRequest, res) => {
round(sum(c.inbound_quantity * c.inbound_avg_unit_price)::numeric,2) AS calculated_inbound_value,
round((sum(c.inbound_quantity * c.inbound_avg_unit_price) - sum(p.full_manufacturing_cost))::numeric,2) AS manufacturing_margin
FROM analytics.v_central_kitchen_product_full_cost p
JOIN central_kitchen_processing_cost c ON p.report_month = c.report_month AND p.product_code = c.product_code
LEFT JOIN central_kitchen_processing_cost c ON p.report_month = c.report_month AND p.product_code = c.product_code
WHERE p.report_month = $1::date
`, [month]),
// 产品明细列表
@@ -1734,7 +1762,7 @@ router.get('/central-kitchen/dashboard', async (req: AuthRequest, res) => {
round((c.inbound_quantity * c.inbound_avg_unit_price)::numeric,2) AS inbound_value,
round((c.inbound_quantity * c.inbound_avg_unit_price - p.full_manufacturing_cost)::numeric,2) AS product_margin
FROM analytics.v_central_kitchen_product_full_cost p
JOIN central_kitchen_processing_cost c ON p.report_month = c.report_month AND p.product_code = c.product_code
LEFT JOIN central_kitchen_processing_cost c ON p.report_month = c.report_month AND p.product_code = c.product_code
WHERE p.report_month = $1::date
ORDER BY p.full_manufacturing_cost DESC
`, [month]),
@@ -1925,7 +1953,7 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
// 检查配送物化视图是否包含当前月份数据
const distMvCheck = await query(`SELECT 1 FROM mv_distribution_monthly WHERE report_month = $1::date LIMIT 1`, [month])
if (distMvCheck.rows.length === 0) {
await query(`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_distribution_monthly`)
console.warn(`[distribution/reconciliation] mv_distribution_monthly 缺少 ${month} 数据,请通过ETL流程刷新物化视图`)
}
const [summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation] = await Promise.all([
@@ -2089,9 +2117,10 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
GROUP BY COALESCE(NULLIF(d.minor_category,''),'未分类')
),
item_cat AS (
SELECT DISTINCT item_code, COALESCE(NULLIF(minor_category,''),'未分类') AS minor_category
FROM mv_distribution_monthly
WHERE report_month = $1::date
SELECT DISTINCT ic.item_code, COALESCE(NULLIF(dm.minor_category,''), NULLIF(ic.minor_category,''),'未分类') AS minor_category
FROM mv_distribution_monthly ic
LEFT JOIN analytics.dim_material dm ON ic.item_code = dm.material_code
WHERE ic.report_month = $1::date
),
inv AS (
SELECT ic.minor_category,
@@ -2396,13 +2425,12 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
const client = await pool.connect()
try {
// 检查物化视图是否包含当前月份数据,如缺失则刷新
// 检查物化视图是否包含当前月份数据
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`)
console.warn(`[production-plan] mv_dish_sales_monthly 缺少 ${month} 数据,请通过ETL流程刷新物化视图`)
}
// 检查配送物化视图是否包含当前月份数据
@@ -2410,7 +2438,7 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
SELECT 1 FROM mv_distribution_monthly WHERE report_month = $1::date LIMIT 1
`, [month])
if (distMvCheck.rows.length === 0) {
await client.query(`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_distribution_monthly`)
console.warn(`[production-plan] mv_distribution_monthly 缺少 ${month} 数据,请通过ETL流程刷新物化视图`)
}
// 从物化视图创建临时表(索引扫描,毫秒级)
@@ -2519,7 +2547,8 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
count(DISTINCT s.sku_code) AS sku_count,
round(sum(s.qty)::numeric,2) AS total_qty,
round(sum(s.amt)::numeric,2) AS total_amt,
round(sum(s.qty * b.standard_gross_quantity)::numeric,4) AS total_material_demand_qty
round(sum(s.qty * b.standard_gross_quantity)::numeric,4) AS total_material_demand_qty,
count(DISTINCT b.unit) AS material_unit_count
FROM tmp_sales_agg s
LEFT JOIN analytics.fact_recipe_bom b ON s.sku_code = b.sku_code
GROUP BY s.store_code, s.store_name
@@ -2531,7 +2560,8 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
round(sd.total_material_demand_qty::numeric,4) AS total_material_demand_qty,
COALESCE(inv.ending_qty, 0) AS ending_inventory_qty,
COALESCE(inv.ending_amt, 0) AS ending_inventory_amt,
round((sd.total_material_demand_qty - COALESCE(inv.ending_qty, 0))::numeric,4) AS suggested_order_qty
round((sd.total_material_demand_qty - COALESCE(inv.ending_qty, 0))::numeric,4) AS suggested_order_qty,
sd.material_unit_count
FROM store_demand sd
LEFT JOIN (
SELECT store_code,
@@ -2555,8 +2585,8 @@ 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 m.material_name IN (
SELECT product_name FROM central_kitchen_processing_cost WHERE report_month = $1::date
WHERE b.material_code IN (
SELECT product_code FROM central_kitchen_processing_cost WHERE report_month = $1::date
)
GROUP BY b.material_code, m.material_name, b.unit
),
@@ -2577,14 +2607,14 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
COALESCE(a.actual_inbound_qty, 0) AS actual_inbound_qty,
COALESCE(a.actual_cost, 0) AS actual_cost
FROM ck_demand d
LEFT JOIN ck_actual a ON d.ck_product_name = a.product_name
LEFT JOIN ck_actual a ON d.ck_product_code = a.product_code
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_name NOT IN (SELECT ck_product_name FROM ck_demand)
WHERE a.product_code NOT IN (SELECT ck_product_code FROM ck_demand)
)
SELECT product_code, product_name, unit,
demand_qty, store_count, actual_inbound_qty, actual_cost,
+15 -3
View File
@@ -216,13 +216,25 @@ router.get('/hr/talent-matrix', async (req: AuthRequest, res) => {
END AS quadrant
FROM (
SELECT e.employee_name, e.store_code,
COALESCE(ep.performance_score, 70) AS performance_score,
COALESCE(60, 60) AS potential_score
COALESCE(ps.performance_score, 70) AS performance_score,
COALESCE(ls.potential_score, 60) AS potential_score
FROM analytics.dim_employee e
LEFT JOIN LATERAL (
SELECT AVG(CASE WHEN t.status = '已完成' THEN 85 ELSE 60 END) AS performance_score
FROM analytics.store_task t WHERE t.owner = e.employee_name
) ep ON true
) ps ON true
LEFT JOIN LATERAL (
SELECT CASE
WHEN COUNT(*) > 0 THEN
LEAST(100, ROUND(
(COUNT(*) FILTER (WHERE completion_status = '已完成')::numeric / COUNT(*) * 50 +
COALESCE(AVG(exam_score), 50) / 100 * 50
)::numeric, 1
))
ELSE NULL
END AS potential_score
FROM analytics.v3_learning_record lr WHERE lr.employee_name = e.employee_name
) ls ON true
WHERE e.status = '在职'
) t
ORDER BY performance_score DESC, potential_score DESC
+14 -9
View File
@@ -12,12 +12,14 @@ router.get('/health-score', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const scope = getDataScope(req)
const storeCode = (req.query.store_code as string) || ''
const scopeCodes = scope?.role === 'store' || scope?.role === 'regional'
? (scope.storeCode || scope.region || '').split(',').map(s => s.trim()).filter(Boolean)
: []
const hasScope = scopeCodes.length > 0
const scopeParams = hasScope ? [month, scopeCodes] : [month]
const scopeFilter = hasScope ? ` AND store_code = ANY($2)` : ''
const hasStoreCode = !!storeCode
const scopeParams = hasStoreCode ? [month, storeCode] : hasScope ? [month, scopeCodes] : [month]
const scopeFilter = hasStoreCode ? ` AND store_code = $2` : hasScope ? ` AND store_code = ANY($2)` : ''
const result = await query(`
WITH base AS (
SELECT store_code, store_name, received, bill_count,
@@ -122,7 +124,7 @@ router.get('/alerts', async (req: AuthRequest, res) => {
WITH daily_stats AS (
SELECT business_date, bill_count, received
FROM analytics.mv_daily_revenue
WHERE business_date >= (SELECT max(business_date) - interval '30 days' FROM analytics.mv_daily_revenue)
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
ORDER BY business_date
),
monthly_avg AS (
@@ -135,7 +137,7 @@ router.get('/alerts', async (req: AuthRequest, res) => {
WHERE d.received < m.avg_received * 0.7
ORDER BY d.business_date DESC
LIMIT 10
`, [], { skipScope: true }),
`, [month], { skipScope: true }),
// 2. 成本异动预警:成本差异>30%的菜品
query(`
SELECT dish_name, dish_code, category_level1,
@@ -143,12 +145,13 @@ router.get('/alerts', async (req: AuthRequest, res) => {
round((cost_variance_amount / nullif(theoretical_cost, 0) * 100)::numeric, 2) AS variance_pct,
round(sales_amount::numeric, 2) AS sales_amount
FROM public.dish_cost_analysis_summary
WHERE cost_variance_amount > 0
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)
AND cost_variance_amount > 0
AND theoretical_cost > 0
AND (cost_variance_amount / theoretical_cost) > 0.3
ORDER BY variance_pct DESC
LIMIT 10
`, [], { skipScope: true }),
`, [month], { skipScope: true }),
// 3. 人力异动预警:考勤异常员工
query(`
SELECT s.org_level5 AS store_name, count(*) AS alert_count,
@@ -368,6 +371,7 @@ router.get('/correlation', async (req: AuthRequest, res) => {
round(count(*) FILTER (WHERE s.actual_attend / nullif(s.expected_attend, 0) < 0.8)::numeric / nullif(count(*), 0) * 100, 1) AS low_attend_rate
FROM salary_detail_records s
WHERE s.org_level2 = '西部马华品牌门店' AND s.org_level5 IS NOT NULL AND s.org_level5 != ''
AND s.salary_month = to_char($1::date, 'YYYY-MM')
GROUP BY s.org_level5
),
rev AS (
@@ -399,7 +403,7 @@ router.get('/correlation', async (req: AuthRequest, res) => {
ORDER BY
CASE WHEN COALESCE(h.low_attend_rate, 0) > 30 THEN 0 ELSE 1 END,
COALESCE(h.total_absent, 0) DESC
`)
`, [month])
sendSuccess(res, {
staffing_efficiency: staffingRows,
@@ -462,10 +466,11 @@ router.get('/forecast', async (req: AuthRequest, res) => {
ELSE '正常'
END AS trend_status
FROM public.dish_cost_analysis_summary
WHERE actual_margin_rate_pct IS NOT NULL
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)
AND actual_margin_rate_pct IS NOT NULL
ORDER BY variance DESC
LIMIT 20
`)
`, [month])
// 人员流失预警
const turnoverAlert = await query(`
+13 -2
View File
@@ -376,6 +376,7 @@ router.get('/efficiency', async (req: AuthRequest, res) => {
round(bill_count::numeric, 0) AS bill_count,
round(received / nullif(bill_count, 0)::numeric, 2) AS avg_ticket_size,
CASE
WHEN received_per_sqm < 500 THEN '坪效极低'
WHEN received_per_sqm < 1000 THEN '坪效偏低'
WHEN received_per_sqm < 2000 THEN '坪效一般'
WHEN received_per_sqm < 4000 THEN '坪效良好'
@@ -438,7 +439,7 @@ router.get('/fixed-variable', async (req: AuthRequest, res) => {
round((rent_expense + dorm_expense + repair_clean_expense * 0.5) / nullif(received, 0) * 100::numeric, 2) AS fixed_rate_pct,
round((wage_expense + utility_expense + delivery_commission_expense + card_fee_expense + repair_clean_expense * 0.5) / nullif(received, 0) * 100::numeric, 2) AS variable_rate_pct,
round((received - actual_food_cost - wage_expense - utility_expense - delivery_commission_expense - card_fee_expense - repair_clean_expense * 0.5)::numeric, 2) AS contribution_after_variable,
round((rent_expense + dorm_expense + repair_clean_expense * 0.5)::numeric, 2) AS break_even_sales
round((rent_expense + dorm_expense + repair_clean_expense * 0.5) / nullif(1 - actual_food_cost / nullif(received, 0), 0)::numeric, 2) AS break_even_sales
FROM analytics.mv_store_operating_expense_monthly
${where}
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
@@ -539,6 +540,16 @@ router.get('/loss-diagnosis', async (req: AuthRequest, res) => {
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
const total = countResult.rows[0].count
// 全量统计(不受分页影响)
const evalStats = await query(`
SELECT
count(*) FILTER (WHERE actual_store_contribution_rate_pct < -20) AS p0_count,
count(*) FILTER (WHERE actual_store_contribution_rate_pct >= -20 AND actual_store_contribution_rate_pct < -5) AS p1_count,
round(sum(actual_store_contribution)::numeric, 2) AS total_loss
FROM analytics.mv_store_operating_expense_monthly
WHERE report_month = $1::date AND actual_store_contribution <= 0 AND received > 0
`, [month])
const validSorts = ['actual_store_contribution', 'actual_store_contribution_rate_pct', 'received', 'wage_rate_pct', 'rent_rate_pct', 'utility_rate_pct', 'received_per_sqm', 'operating_expense_rate_pct']
const sortCol = validSorts.includes(sort) ? sort : 'actual_store_contribution'
@@ -737,7 +748,7 @@ router.get('/loss-diagnosis', async (req: AuthRequest, res) => {
}
})
sendSuccess(res, rows, { page, pageSize, total })
sendSuccess(res, rows, { page, pageSize, total, evalStats: evalStats.rows[0] })
} catch (err: any) {
sendError(res, err.message)
}
+6 -6
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'
import { query, withTransaction } from '../config/database.js'
import { sendSuccess, sendError } from '../middleware/error.js'
import { sendSuccess, sendError, parseMonth } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
@@ -8,7 +8,7 @@ const router = Router()
// T-160: 门店自动分级算法
router.post('/auto-grade', async (req: AuthRequest, res) => {
try {
const gradeMonth = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
const gradeMonth = parseMonth(req)
const storesResult = await query(`
SELECT
@@ -20,7 +20,7 @@ router.post('/auto-grade', async (req: AuthRequest, res) => {
COALESCE(s.risk_level, '绿色') AS risk_level
FROM analytics.dim_store ds
LEFT JOIN analytics.mv_store_risk_rating s
ON s.store_code = ds.store_code
ON s.store_code = ds.store_code AND s.month_start = date_trunc('month', $1::date)
LEFT JOIN analytics.v3_store_monthly_target t
ON t.store_code = ds.store_code AND date_trunc('month', t.month) = date_trunc('month', $1::date)
WHERE ds.close_date IS NULL
@@ -73,7 +73,7 @@ router.post('/auto-grade', async (req: AuthRequest, res) => {
// 获取门店分级列表(含风险等级详情)
router.get('/grades', async (req: AuthRequest, res) => {
try {
const gradeMonth = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
const gradeMonth = parseMonth(req)
const result = await query(`
SELECT
g.*,
@@ -103,7 +103,7 @@ router.get('/grades', async (req: AuthRequest, res) => {
// 红黄绿灯状态 — 基于实时经营健康度(达成率+异常率+风险等级),与分级维度不同
router.get('/traffic-light', async (req: AuthRequest, res) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
const month = parseMonth(req)
const result = await query(`
SELECT * FROM (
SELECT
@@ -147,7 +147,7 @@ router.get('/traffic-light', async (req: AuthRequest, res) => {
// ============================================================
router.get('/closure-analysis', async (req: AuthRequest, res) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
const month = parseMonth(req)
const result = await query(`
WITH store_financials AS (