perf: 全量物化视图优化 — 按月预计算所有慢函数,API响应从106秒降至0.09秒

- 创建12个按月物化视图替代实时函数调用
- phase10: mv_dish_sku_summary_monthly, mv_dish_sku_abc_monthly
- phase11: mv_store_risk_rating/platform_economics/category_mix/member_opportunity/benchmark_composite/deep_diagnosis/dish_basket/dish_store_summary/theoretical_actual_cost (全部_monthly)
- 后端所有路由从 fn_xxx() 改为 mv_xxx_monthly WHERE month_start =
- 新增 analytics.refresh_all_monthly_views() 刷新函数
- 优化 fn_dish_sku_summary: CTE物化避免重复调用
- 优化 fn_dish_sku_abc: CTE物化 fn_dish_sku_summary 只调用一次
- 优化 fn_dish_sales: 添加 dish_name IS NOT NULL 提前过滤
This commit is contained in:
freedakgmail
2026-08-01 14:05:12 +08:00
parent dab06ba109
commit 25db0f3854
22 changed files with 2761 additions and 71 deletions
+7 -5
View File
@@ -734,7 +734,8 @@ router.get('/store-overview', async (req: AuthRequest, res) => {
count(*) FILTER (WHERE variance_level = '绿色-基本正常') AS green_count,
count(*) FILTER (WHERE variance_level = '灰色-口径异常') AS gray_count,
round(sum(food_cost_variance)::numeric, 2) AS total_variance
FROM analytics.fn_store_theoretical_actual_cost($1)
FROM analytics.mv_store_theoretical_actual_cost_monthly
WHERE month_start = $1
`, [month])
sendSuccess(res, result.rows[0])
} catch (err: any) {
@@ -752,9 +753,9 @@ router.get('/store-map', async (req: AuthRequest, res) => {
round(c.theoretical_cost_rate_pct::numeric, 2) AS theo_cost_rate,
round(c.actual_food_cost_rate_pct::numeric, 2) AS actual_cost_rate,
l.latitude_gcj02, l.longitude_gcj02
FROM analytics.fn_store_theoretical_actual_cost($1) c
FROM analytics.mv_store_theoretical_actual_cost_monthly c
LEFT JOIN analytics.v_store_location_operating l ON l.store_code = c.store_code
WHERE l.latitude_gcj02 IS NOT NULL
WHERE c.month_start = $1 AND l.latitude_gcj02 IS NOT NULL
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -780,7 +781,7 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
const sortCol = validSorts.includes(sort) ? sort : 'food_cost_variance'
const month = parseMonth(req)
const countResult = await query(`SELECT count(*) FROM analytics.fn_store_theoretical_actual_cost($1) ${where}`, [month])
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1 ${where}`, [month])
const total = countResult.rows[0].count
const result = await query(`
@@ -791,7 +792,8 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
round(food_cost_variance::numeric, 2) AS variance_amount,
negative_item_lines,
variance_level
FROM analytics.fn_store_theoretical_actual_cost($1)
FROM analytics.mv_store_theoretical_actual_cost_monthly
WHERE month_start = $1
${where}
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $2 OFFSET $3
`, [month, pageSize, offset])
+33 -27
View File
@@ -39,8 +39,8 @@ router.get('/stores', async (req: AuthRequest, res) => {
if (riskLevel) {
const m = parseMonth(req)
sql = `SELECT s.* FROM analytics.v_store_scorecard s
JOIN analytics.fn_store_risk_rating($${params.length + 1}) r ON s.store_code = r.store_code
WHERE r.risk_level = $${params.length + 2}`
JOIN analytics.mv_store_risk_rating_monthly r ON s.store_code = r.store_code
WHERE r.month_start = $${params.length + 1} AND r.risk_level = $${params.length + 2}`
params.push(m, riskLevel)
}
@@ -71,7 +71,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.fn_store_risk_rating($1) ORDER BY risk_level, received DESC`, [month])
const result = await query(`SELECT * 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)
@@ -113,9 +113,9 @@ router.get('/stores/:code', async (req: AuthRequest, res) => {
const code = req.params.code
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.fn_store_risk_rating($1) WHERE store_code = $2`, [parseMonth(req), code]),
query(`SELECT * FROM analytics.fn_store_platform_economics($2) WHERE store_code = $1`, [code, parseMonth(req)]),
query(`SELECT * FROM analytics.fn_store_benchmark_composite($2) WHERE store_code = $1`, [code, parseMonth(req)]),
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.fn_store_action_priority_deep($1) WHERE store_code = $2`, [parseMonth(req), code]),
])
@@ -176,7 +176,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.fn_store_theoretical_actual_cost($1) ORDER BY variance_to_theoretical_pct DESC NULLS LAST`, [month])
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])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -206,7 +206,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.fn_store_platform_economics($1) ORDER BY meituan_received DESC NULLS LAST`, [month])
const result = await query(`SELECT * FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $1 ORDER BY meituan_received DESC NULLS LAST`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -235,7 +235,7 @@ router.get('/member/repeat', async (req: AuthRequest, res) => {
router.get('/sku/abc', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.fn_dish_sku_abc($1) ORDER BY cumulative_revenue_share`, [month])
const result = await query(`SELECT * FROM analytics.mv_dish_sku_abc_monthly WHERE month_start = $1 ORDER BY cumulative_revenue_share`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -310,7 +310,7 @@ router.get('/marketing/plans', async (req: AuthRequest, res) => {
router.get('/benchmark/composite', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.fn_store_benchmark_composite($1) ORDER BY benchmark_score DESC`, [month])
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)
@@ -396,7 +396,7 @@ router.get('/stores/:code/category-mix', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [result, companyResult] = await Promise.all([
query(`SELECT * FROM analytics.fn_store_category_mix($1) WHERE store_code = $2`, [month, req.params.code]),
query(`SELECT * FROM analytics.mv_store_category_mix_monthly WHERE month_start = $1 AND store_code = $2`, [month, req.params.code]),
query(`
SELECT
SUM(consumption) as total_consumption,
@@ -405,7 +405,7 @@ router.get('/stores/:code/category-mix', async (req: AuthRequest, res) => {
SUM(delivery_package) as total_delivery,
SUM(cold_dishes) as total_cold,
SUM(silk_road_food) as total_silk
FROM analytics.fn_store_category_mix($1)
FROM analytics.mv_store_category_mix_monthly WHERE month_start = $1
`, [month]),
])
sendSuccess(res, { store: result.rows[0], company: companyResult.rows[0] })
@@ -416,7 +416,7 @@ router.get('/stores/:code/cost', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [costResult, catResult] = await Promise.all([
query(`SELECT * FROM analytics.fn_store_theoretical_actual_cost($1) WHERE store_code = $2`, [month, req.params.code]),
query(`SELECT * FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1 AND store_code = $2`, [month, req.params.code]),
query(`
WITH store_cat AS (
SELECT finance_category AS category_name,
@@ -450,7 +450,7 @@ router.get('/stores/:code/member', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [oppResult, repeatResult, monthlyResult] = await Promise.all([
query(`SELECT * FROM analytics.fn_store_member_opportunity($2) WHERE store_code = $1`, [req.params.code, month]),
query(`SELECT * FROM analytics.mv_store_member_opportunity_monthly WHERE month_start = $2 AND store_code = $1`, [req.params.code, month]),
query(`SELECT * FROM analytics.mv_store_repeat_summary_monthly WHERE store_code = $1 AND month_start = $2::date`, [req.params.code, month]),
query(`
SELECT store_code, store_name, count(*) as member_count,
@@ -545,10 +545,10 @@ router.get('/overview/profit-waterfall', async (req: AuthRequest, res) => {
e.repair_clean_expense,
e.operating_expense,
CASE WHEN e.operating_expense IS NOT NULL THEN true ELSE false END AS has_expense
FROM analytics.fn_store_risk_rating($1) r
FROM analytics.mv_store_risk_rating_monthly r
LEFT JOIN analytics.mv_store_operating_expense_monthly e
ON r.store_code = e.sales_store_code AND e.report_month = $1::date
WHERE r.received IS NOT NULL AND e.operating_expense IS NOT NULL
WHERE r.month_start = $1 AND r.received IS NOT NULL AND e.operating_expense IS NOT NULL
)
SELECT
round(sum(received)::numeric, 2) AS received,
@@ -584,10 +584,10 @@ router.get('/overview/store-profit-ranking', async (req: AuthRequest, res) => {
round((r.received - e.actual_food_cost - e.operating_expense)::numeric, 2) AS store_contribution,
round((r.received - e.actual_food_cost - e.operating_expense) / nullif(r.received, 0) * 100, 2) AS contribution_margin_pct,
r.risk_level
FROM analytics.fn_store_risk_rating($1) r
FROM analytics.mv_store_risk_rating_monthly r
LEFT JOIN analytics.mv_store_operating_expense_monthly e
ON r.store_code = e.sales_store_code AND e.report_month = $1::date
WHERE r.received IS NOT NULL AND r.received > 0 AND e.operating_expense IS NOT NULL
WHERE r.month_start = $1 AND r.received IS NOT NULL AND r.received > 0 AND e.operating_expense IS NOT NULL
ORDER BY store_contribution DESC
`, [month])
sendSuccess(res, result.rows)
@@ -601,12 +601,18 @@ router.get('/overview/profit-opportunity', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH standard_stores AS (
WITH risk AS (
SELECT * FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1
),
sku_abc AS (
SELECT * FROM analytics.mv_dish_sku_abc_monthly WHERE month_start = $1
),
standard_stores AS (
SELECT r.store_code, r.store_name, r.received,
e.actual_food_cost, e.operating_expense,
e.wage_expense, e.utility_expense,
r.theoretical_margin_pct, r.discount_rate_pct
FROM analytics.fn_store_risk_rating($1) r
FROM risk r
LEFT JOIN analytics.mv_store_operating_expense_monthly e
ON r.store_code = e.sales_store_code AND e.report_month = $1::date
WHERE r.received IS NOT NULL AND r.received > 0 AND e.operating_expense IS NOT NULL
@@ -663,14 +669,14 @@ router.get('/overview/profit-opportunity', async (req: AuthRequest, res) => {
SELECT
count(*) FILTER (WHERE discount_rate_pct > 25) AS high_discount_stores,
round(sum(CASE WHEN discount_rate_pct > 25 THEN received * discount_rate_pct / nullif(100 - discount_rate_pct, 0) * (discount_rate_pct - 25) / nullif(discount_rate_pct, 0) ELSE 0 END)::numeric, 2) AS theoretical_saving
FROM analytics.fn_store_risk_rating($1)
FROM risk
WHERE received IS NOT NULL AND received > 0
),
discount_stores AS (
SELECT store_name, received,
round(discount_rate_pct::numeric, 2) AS discount_rate,
round((received * discount_rate_pct / nullif(100 - discount_rate_pct, 0) * (discount_rate_pct - 25) / nullif(discount_rate_pct, 0))::numeric, 2) AS potential_saving
FROM analytics.fn_store_risk_rating($1)
FROM risk
WHERE received IS NOT NULL AND received > 0 AND discount_rate_pct > 25
ORDER BY discount_rate_pct DESC
LIMIT 5
@@ -695,11 +701,11 @@ router.get('/overview/profit-opportunity', async (req: AuthRequest, res) => {
SELECT
count(*) FILTER (WHERE received_amount < 1000 AND bill_count < 30) AS low_sku_count,
round(sum(received_amount) FILTER (WHERE received_amount < 1000 AND bill_count < 30)::numeric, 2) AS low_sku_revenue
FROM analytics.fn_dish_sku_abc($1)
FROM sku_abc
),
sku_samples AS (
SELECT dish_name, category_level1, received_amount, bill_count, abc_class
FROM analytics.fn_dish_sku_abc($1)
FROM sku_abc
WHERE received_amount < 1000 AND bill_count < 30
ORDER BY received_amount ASC
LIMIT 10
@@ -1051,7 +1057,7 @@ router.get('/bank/report', async (req: AuthRequest, res) => {
SELECT r.received, e.actual_food_cost AS food_cost, e.wage_expense AS wage, e.rent_expense AS rent,
e.utility_expense AS utility, e.dorm_expense AS dorm, e.delivery_commission_expense AS commission,
e.card_fee_expense AS card_fee, e.repair_clean_expense AS repair, e.operating_expense AS operating_expense
FROM analytics.fn_store_risk_rating($1) r
FROM analytics.mv_store_risk_rating_monthly r
LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1::date
WHERE r.received IS NOT NULL AND e.operating_expense IS NOT NULL
)
@@ -1062,9 +1068,9 @@ router.get('/bank/report', async (req: AuthRequest, res) => {
round(sum(received)-sum(food_cost)-sum(operating_expense),2) AS store_contribution
FROM full_scope
`, [month]),
query(`SELECT risk_level, count(*) AS store_count, round(sum(received)::numeric,2) AS total_received, round(avg(theoretical_margin_pct)::numeric,2) AS avg_margin_pct, round(avg(discount_rate_pct)::numeric,2) AS avg_discount_pct FROM analytics.fn_store_risk_rating($1) WHERE received IS NOT NULL GROUP BY risk_level ORDER BY risk_level`, [month]),
query(`SELECT risk_level, count(*) AS store_count, round(sum(received)::numeric,2) AS total_received, round(avg(theoretical_margin_pct)::numeric,2) AS avg_margin_pct, round(avg(discount_rate_pct)::numeric,2) AS avg_discount_pct FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 AND received IS NOT NULL GROUP BY risk_level ORDER BY risk_level`, [month]),
query(`SELECT round(sum(cash)::numeric,2) AS cash, round(sum(alipay)::numeric,2) AS alipay, round(sum(wechat)::numeric,2) AS wechat, round(sum(meituan)::numeric,2) AS meituan, round(sum(unionpay)::numeric,2) AS unionpay, round(sum(douyin)::numeric,2) AS douyin, round(sum(credit)::numeric,2) AS credit, round(sum(jd_delivery)::numeric,2) AS jd_delivery, round(sum(meituan_delivery)::numeric,2) AS meituan_delivery, round(sum(taobao_delivery)::numeric,2) AS taobao_delivery FROM analytics.v_channel_daily WHERE business_date >= $1::date AND business_date < $2::date`, [month, nextMonth]),
query(`SELECT store_code, store_name, round(received::numeric,2) AS received, bill_count, round(avg_bill_value::numeric,2) AS avg_bill_value, round(discount_rate_pct::numeric,2) AS discount_rate_pct, round(theoretical_margin_pct::numeric,2) AS theoretical_margin_pct, risk_level FROM analytics.fn_store_risk_rating($1) WHERE received IS NOT NULL ORDER BY received DESC`, [month]),
query(`SELECT store_code, store_name, round(received::numeric,2) AS received, bill_count, round(avg_bill_value::numeric,2) AS avg_bill_value, round(discount_rate_pct::numeric,2) AS discount_rate_pct, round(theoretical_margin_pct::numeric,2) AS theoretical_margin_pct, risk_level FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 AND received IS NOT NULL ORDER BY received DESC`, [month]),
])
const ov = overview.rows[0] as any
+13 -12
View File
@@ -15,14 +15,14 @@ router.get('/health-score', async (req: AuthRequest, res) => {
SELECT store_code, store_name, received, bill_count,
avg_bill_value, avg_daily_received, theoretical_margin_pct,
member_bill_share_pct, risk_level
FROM analytics.fn_store_risk_rating($1)
WHERE received IS NOT NULL
FROM analytics.mv_store_risk_rating_monthly
WHERE month_start = $1 AND received IS NOT NULL
),
cost AS (
SELECT store_code,
round(avg(LEAST(variance_to_theoretical_pct, 50))::numeric, 1) AS avg_cost_variance_pct
FROM analytics.fn_store_theoretical_actual_cost($1)
WHERE variance_to_theoretical_pct IS NOT NULL
FROM analytics.mv_store_theoretical_actual_cost_monthly
WHERE month_start = $1 AND variance_to_theoretical_pct IS NOT NULL
GROUP BY store_code
),
member AS (
@@ -184,9 +184,9 @@ router.get('/alerts', async (req: AuthRequest, res) => {
round(p.taobao_cost_rate_pct::numeric, 1) AS taobao_rate,
round(p.jd_cost_rate_pct::numeric, 1) AS jd_rate,
round((COALESCE(p.meituan_received, 0) + COALESCE(p.taobao_received, 0) + COALESCE(p.jd_received, 0)) / nullif(sc.received, 0) * 100, 1) AS platform_share
FROM analytics.fn_store_platform_economics($1) p
FROM analytics.mv_store_platform_economics_monthly p
JOIN analytics.v_store_scorecard sc ON p.store_code = sc.store_code
WHERE (COALESCE(p.meituan_received, 0) + COALESCE(p.taobao_received, 0) + COALESCE(p.jd_received, 0)) / nullif(sc.received, 0) > 0.4
WHERE p.month_start = $1 AND (COALESCE(p.meituan_received, 0) + COALESCE(p.taobao_received, 0) + COALESCE(p.jd_received, 0)) / nullif(sc.received, 0) > 0.4
ORDER BY platform_share DESC
LIMIT 10
`, [month])
@@ -293,12 +293,12 @@ router.get('/correlation', async (req: AuthRequest, res) => {
WHEN COALESCE(c.avg_cost_variance_pct, 20) > 30 THEN '成本失控'
ELSE '正常'
END AS correlation_status
FROM analytics.fn_store_risk_rating($1) r
FROM analytics.mv_store_risk_rating_monthly r
LEFT JOIN (
SELECT store_code,
round(avg(LEAST(variance_to_theoretical_pct, 50))::numeric, 1) AS avg_cost_variance_pct
FROM analytics.fn_store_theoretical_actual_cost($1)
WHERE variance_to_theoretical_pct IS NOT NULL
FROM analytics.mv_store_theoretical_actual_cost_monthly
WHERE month_start = $1 AND variance_to_theoretical_pct IS NOT NULL
GROUP BY store_code
) c ON r.store_code = c.store_code
WHERE r.received IS NOT NULL
@@ -323,12 +323,13 @@ router.get('/correlation', async (req: AuthRequest, res) => {
WHEN COALESCE(r.member_bill_share_pct, 0) > 50 THEN '会员驱动型'
ELSE '均衡型'
END AS channel_status
FROM analytics.fn_store_risk_rating($1) r
FROM analytics.mv_store_risk_rating_monthly r
LEFT JOIN (
SELECT pe.store_code,
round((COALESCE(pe.meituan_received, 0) + COALESCE(pe.taobao_received, 0) + COALESCE(pe.jd_received, 0)) / nullif(r2.received, 0) * 100, 1) AS platform_share
FROM analytics.fn_store_platform_economics($1) pe
JOIN analytics.fn_store_risk_rating($1) r2 ON pe.store_code = r2.store_code
FROM analytics.mv_store_platform_economics_monthly pe
JOIN analytics.mv_store_risk_rating_monthly r2 ON pe.store_code = r2.store_code AND r2.month_start = pe.month_start
WHERE pe.month_start = $1
) p ON r.store_code = p.store_code
LEFT JOIN analytics.mv_store_repeat_summary_monthly m ON r.store_code = m.store_code AND m.month_start = $1::date
WHERE r.received IS NOT NULL
+2 -2
View File
@@ -32,10 +32,10 @@ router.get('/overview', async (req: AuthRequest, res) => {
CASE WHEN e.operating_expense IS NOT NULL THEN (r.received - e.actual_food_cost - e.operating_expense) ELSE NULL END AS actual_store_contribution,
CASE WHEN e.operating_expense IS NOT NULL THEN (r.received - r.received * (1 - COALESCE(r.theoretical_margin_pct, 0) / 100) - e.operating_expense) ELSE NULL END AS theoretical_store_contribution,
e.area_sqm
FROM analytics.fn_store_risk_rating($1) r
FROM analytics.mv_store_risk_rating_monthly r
LEFT JOIN analytics.mv_store_operating_expense_monthly e
ON r.store_code = e.sales_store_code AND e.report_month = $1::date
WHERE r.received IS NOT NULL
WHERE r.month_start = $1 AND r.received IS NOT NULL
)
SELECT
count(*) AS total_stores,
+4 -3
View File
@@ -620,15 +620,16 @@ router.get('/monthly-review/sku-governance', async (req: AuthRequest, res) => {
SELECT abc_class, count(*) as sku_count,
ROUND(sum(revenue_share_pct), 1) as total_revenue_share,
ROUND(avg(revenue_share_pct), 2) as avg_revenue_share
FROM analytics.fn_dish_sku_abc($1)
FROM analytics.mv_dish_sku_abc_monthly
WHERE month_start = $1
GROUP BY abc_class
ORDER BY abc_class
`, [month])
const longtailResult = await query(`
SELECT dish_name, category_level1, bill_count, sales_quantity,
received_amount, revenue_share_pct, cumulative_revenue_share
FROM analytics.fn_dish_sku_abc($1)
WHERE abc_class = 'C-长尾'
FROM analytics.mv_dish_sku_abc_monthly
WHERE month_start = $1 AND abc_class = 'C-长尾'
ORDER BY received_amount ASC
LIMIT 50
`, [month])