Files
SBrainCO/server/src/routes/data.ts
T

2173 lines
105 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router } from 'express'
import { query } from '../config/database.js'
import pool from '../config/database.js'
import { sendSuccess, sendError, parseMonth, parsePagination, parseDateRange, prevYearMonth, prevMonth } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
router.get('/overview', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT bill_count, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bills, member_share_pct FROM analytics.mv_overview_monthly WHERE month = $1::date`, [month])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/overview/daily', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT business_date, bill_count, received, avg_bill_value, discount_rate_pct FROM analytics.mv_overview_daily WHERE month = $1::date ORDER BY business_date`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const riskLevel = req.query.risk_level as string
const quadrant = req.query.quadrant as string
let sql = `SELECT * FROM analytics.v_store_scorecard`
const params: any[] = []
const conditions: string[] = []
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}`
params.push(m, riskLevel)
}
if (quadrant) {
if (params.length > 0) {
conditions.push(`b.management_quadrant = $${params.length + 1}`)
sql = sql.replace('FROM analytics.v_store_scorecard s', 'FROM analytics.v_store_scorecard s JOIN analytics.v_store_benchmark b ON s.store_code = b.store_code')
} else {
sql = `SELECT s.* FROM analytics.v_store_scorecard s
JOIN analytics.v_store_benchmark b ON s.store_code = b.store_code
WHERE b.management_quadrant = $1`
params.push(quadrant)
}
}
sql += ` ORDER BY received DESC NULLS LAST`
const countResult = await query(`SELECT count(*) AS total FROM (${sql}) t`, params)
sql += ` LIMIT $${params.length + 1} OFFSET $${params.length + 2}`
params.push(pageSize, offset)
const result = await query(sql, params)
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
} catch (err: any) {
sendError(res, err.message)
}
})
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])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/priority', async (req: AuthRequest, res) => {
try {
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
ORDER BY CASE action_priority
WHEN 'P0-修复数据口径' THEN 1
WHEN 'P0-综合专项整改' THEN 2
WHEN 'P1-重点整改' THEN 3
WHEN 'P2-单项改善' THEN 4
WHEN '标杆候选' THEN 5
ELSE 6
END, received DESC
`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
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`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/:code', async (req: AuthRequest, res) => {
try {
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.fn_store_action_priority_deep($1) WHERE store_code = $2`, [parseMonth(req), code]),
])
if (scorecard.rows.length === 0) {
return sendError(res, 'Store not found', 404)
}
const sc = scorecard.rows[0] as any
const rk = risk.rows[0] as any
const act = action.rows[0] as any
// 计算经营象限
const medianRev = 18642
const medianMargin = 70.0
const dailyRev = parseFloat(rk?.avg_daily_received) || 0
const margin = parseFloat(rk?.theoretical_margin_pct) || 0
const quadrant = dailyRev >= medianRev && margin >= medianMargin ? '明星门店'
: dailyRev >= medianRev && margin < medianMargin ? '现金牛门店'
: dailyRev < medianRev && margin >= medianMargin ? '潜力门店'
: '问题门店'
sendSuccess(res, {
scorecard: sc,
risk: rk,
platform: platform.rows[0],
benchmark: benchmark.rows[0],
action: { ...act, management_quadrant: quadrant, risk_level: rk?.risk_level },
})
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/:code/daily', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const month = parseMonth(req)
const result = await query(`
SELECT closed_at::date AS business_date,
count(*) AS bill_count,
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
WHERE store_code = $1
AND closed_at >= $2::date
AND closed_at < ($2::date + interval '1 month')
AND closed_at IS NOT NULL
GROUP BY closed_at::date
ORDER BY business_date
`, [code, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
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])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/cost/category-benchmark', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.fn_inventory_finance_category($1) ORDER BY store_code`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/cost/inventory', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.fn_store_area_efficiency($1) ORDER BY estimated_inventory_days DESC NULLS LAST`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
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])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/member/comparison', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_member_comparison`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
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])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
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])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/sku/category', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.category_summary ORDER BY amount DESC`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/sku/attach', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.fn_dish_pair_summary($1) ORDER BY pair_count DESC LIMIT 50`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
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 })
} catch (err: any) {
sendError(res, err.message)
}
})
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)
}
sql += ` ORDER BY closed_at DESC LIMIT 200`
const result = await query(sql, params)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
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`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/marketing/plans', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_marketing_plan_summary ORDER BY received DESC`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
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])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/time/weekday', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_weekday_summary ORDER BY weekday_no`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/time/hourly', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_hourly_summary ORDER BY closing_hour`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/channel', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_channel_daily ORDER BY business_date`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/data-quality', async (req: AuthRequest, res) => {
try {
const billStats = await query(`
SELECT
count(*) AS total_bills,
count(*) FILTER (WHERE bill_no IS NULL OR bill_no = '') AS missing_bill_no,
count(*) FILTER (WHERE store_code IS NULL OR store_code = '') AS missing_store_code,
count(*) FILTER (WHERE consumption = 0 OR consumption IS NULL) AS zero_consumption,
count(*) FILTER (WHERE received_total < 0) AS negative_received,
count(*) FILTER (WHERE received_total = 0 OR received_total IS NULL) AS zero_received,
count(*) FILTER (WHERE discount_total > consumption AND consumption > 0) AS discount_gt_consumption,
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
`)
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)
const inventoryStats = await query(`
SELECT
count(*) FILTER (WHERE consumption_amount < 0) AS negative_consumption_count,
round(sum(consumption_amount) FILTER (WHERE consumption_amount < 0)::numeric, 2) AS negative_consumption_amount
FROM analytics.fn_inventory_cost_classified($1)
`, [month])
const result = { ...billStats.rows[0], ...dishStats.rows[0], ...inventoryStats.rows[0] }
sendSuccess(res, result)
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 门店详情页扩展 API (Phase 9)
// ============================================================
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])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
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
SUM(consumption) as total_consumption,
SUM(lanzhou_noodle) as total_noodle,
SUM(western_staple) as total_western,
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)
`, [month]),
])
sendSuccess(res, { store: result.rows[0], company: companyResult.rows[0] })
} catch (err: any) { sendError(res, err.message) }
})
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(`
WITH store_cat AS (
SELECT finance_category AS category_name,
round(sum(consumption_amount)::numeric, 2) AS consumption_amount,
round(sum(consumption_amount)::numeric / nullif(sum(sum(consumption_amount)) OVER (), 0) * 100, 1) AS actual_cost_rate_pct
FROM analytics.fn_inventory_cost_classified($1)
WHERE sales_store_code = $2 AND finance_category IS NOT NULL
GROUP BY finance_category
ORDER BY sum(consumption_amount) DESC
),
company_cat AS (
SELECT finance_category,
round(sum(consumption_amount)::numeric / nullif(sum(sum(consumption_amount)) OVER (), 0) * 100, 1) AS benchmark_rate_pct
FROM analytics.fn_inventory_cost_classified($1)
WHERE finance_category IS NOT NULL
GROUP BY finance_category
)
SELECT s.category_name, s.consumption_amount, s.actual_cost_rate_pct,
COALESCE(c.benchmark_rate_pct, 0) AS benchmark_rate_pct,
round(s.actual_cost_rate_pct - COALESCE(c.benchmark_rate_pct, 0), 1) AS variance_pct
FROM store_cat s
LEFT JOIN company_cat c ON s.category_name = c.finance_category
ORDER BY s.consumption_amount DESC
`, [month, req.params.code]),
])
sendSuccess(res, { cost: costResult.rows[0], categories: catResult.rows })
} catch (err: any) { sendError(res, err.message) }
})
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_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,
sum(orders) as total_orders, sum(received) as total_received,
avg(orders) as avg_orders, avg(received) as avg_received
FROM analytics.v_store_member_monthly_activity
WHERE store_code = $1 AND month_start = $2::date
GROUP BY store_code, store_name
`, [req.params.code, month]),
])
sendSuccess(res, { opportunity: oppResult.rows[0], repeat: repeatResult.rows[0], monthly: monthlyResult.rows[0] })
} catch (err: any) { sendError(res, err.message) }
})
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])
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) => {
try {
const result = await query(`SELECT * FROM analytics.mv_region_summary ORDER BY total_received DESC`)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 门店选址分析 — 门店选址画像
router.get('/site-selection/profile', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.fn_store_site_profile($1) WHERE business_type='标准门店' AND received>0 ORDER BY received DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 门店选址分析 — 分段基准(场景×面积)
router.get('/site-selection/segment-benchmark', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.fn_site_segment_benchmark($1) ORDER BY avg_received_per_sqm DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 门店选址分析 — 复制评分
router.get('/site-selection/replication', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.fn_store_site_replication($1) ORDER BY site_replication_score DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 门店选址分析 — 重叠风险
router.get('/site-selection/overlap-risk', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.fn_store_overlap_risk($1) ORDER BY distance_km`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 门店选址分析 — 区域基准
router.get('/site-selection/district-benchmark', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.fn_district_site_benchmark($1) ORDER BY total_received DESC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 利润瀑布数据
router.get('/overview/profit-waterfall', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH full_scope AS (
SELECT
r.received,
e.actual_food_cost,
e.wage_expense,
e.rent_expense,
e.utility_expense,
e.dorm_expense,
e.delivery_commission_expense,
e.card_fee_expense,
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
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
)
SELECT
round(sum(received)::numeric, 2) AS received,
round(sum(actual_food_cost)::numeric, 2) AS food_cost,
round(sum(wage_expense)::numeric, 2) AS wage,
round(sum(rent_expense)::numeric, 2) AS rent,
round(sum(utility_expense)::numeric, 2) AS utility,
round(sum(dorm_expense)::numeric, 2) AS dorm,
round(sum(delivery_commission_expense)::numeric, 2) AS commission,
round(sum(card_fee_expense + repair_clean_expense)::numeric, 2) AS other_expense,
round(sum(operating_expense)::numeric, 2) AS total_expense,
round(sum(received) - sum(actual_food_cost) - sum(operating_expense), 2) AS store_contribution,
count(*) AS covered_stores
FROM full_scope
`, [month])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
// 门店利润排名
router.get('/overview/store-profit-ranking', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
r.store_code,
r.store_name,
round(r.received::numeric, 2) AS received,
round(e.actual_food_cost::numeric, 2) AS food_cost,
round(e.operating_expense::numeric, 2) AS expense,
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
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
ORDER BY store_contribution DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// 利润机会池
router.get('/overview/profit-opportunity', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
WITH 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
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
),
cost_diff AS (
SELECT
round(sum(actual_food_cost)::numeric, 2) AS actual_cost,
round(sum(received * (1 - COALESCE(theoretical_margin_pct, 0) / 100))::numeric, 2) AS theoretical_cost
FROM standard_stores
),
cost_diff_stores AS (
SELECT store_name, received,
round(actual_food_cost::numeric, 2) AS actual_food_cost,
round((received * (1 - COALESCE(theoretical_margin_pct, 0) / 100))::numeric, 2) AS theoretical_cost,
round((actual_food_cost - received * (1 - COALESCE(theoretical_margin_pct, 0) / 100))::numeric, 2) AS diff_amount,
round((actual_food_cost / nullif(received, 0) * 100)::numeric, 2) AS actual_cost_rate,
round((received * (1 - COALESCE(theoretical_margin_pct, 0) / 100) / nullif(received, 0) * 100)::numeric, 2) AS theoretical_cost_rate
FROM standard_stores
WHERE actual_food_cost IS NOT NULL
ORDER BY (actual_food_cost - received * (1 - COALESCE(theoretical_margin_pct, 0) / 100)) DESC
LIMIT 5
),
labor AS (
SELECT
round(sum(wage_expense)::numeric, 2) AS total_wage,
round(sum(received)::numeric, 2) AS total_received
FROM standard_stores
),
labor_stores AS (
SELECT store_name, received,
round(wage_expense::numeric, 2) AS wage,
round((wage_expense / nullif(received, 0) * 100)::numeric, 2) AS wage_rate
FROM standard_stores
WHERE wage_expense IS NOT NULL
ORDER BY wage_expense / nullif(received, 0) DESC
LIMIT 5
),
energy AS (
SELECT
round(sum(utility_expense)::numeric, 2) AS total_utility,
round(sum(received)::numeric, 2) AS total_received
FROM standard_stores
),
energy_stores AS (
SELECT store_name, received,
round(utility_expense::numeric, 2) AS utility,
round((utility_expense / nullif(received, 0) * 100)::numeric, 2) AS utility_rate
FROM standard_stores
WHERE utility_expense IS NOT NULL
ORDER BY utility_expense / nullif(received, 0) DESC
LIMIT 5
),
discount AS (
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)
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)
WHERE received IS NOT NULL AND received > 0 AND discount_rate_pct > 25
ORDER BY discount_rate_pct DESC
LIMIT 5
),
platform AS (
SELECT
round(sum(delivery_commission_expense)::numeric, 2) AS total_commission,
round(sum(received)::numeric, 2) AS platform_received
FROM analytics.mv_store_operating_expense_monthly
WHERE report_month = $1::date AND delivery_commission_expense IS NOT NULL
),
platform_stores AS (
SELECT e.sales_store_code AS store_code, e.received,
round(e.delivery_commission_expense::numeric, 2) AS commission,
round((e.delivery_commission_expense / nullif(e.received, 0) * 100)::numeric, 2) AS commission_rate
FROM analytics.mv_store_operating_expense_monthly e
WHERE e.report_month = $1::date AND e.delivery_commission_expense IS NOT NULL
ORDER BY e.delivery_commission_expense / nullif(e.received, 0) DESC
LIMIT 5
),
sku AS (
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)
),
sku_samples AS (
SELECT dish_name, category_level1, received_amount, bill_count, abc_class
FROM analytics.fn_dish_sku_abc($1)
WHERE received_amount < 1000 AND bill_count < 30
ORDER BY received_amount ASC
LIMIT 10
)
SELECT
json_build_object(
'items', json_build_array(
json_build_object(
'category', '食材成本差异回收',
'baseline', (SELECT round((actual_cost - theoretical_cost)::numeric, 2) FROM cost_diff),
'target_pct', 30,
'opportunity', round((SELECT (actual_cost - theoretical_cost) FROM cost_diff) * 0.30, 2),
'confidence', '中高',
'owner', '商品/供应链/门店',
'evidence', '采购价差、用量差、盘点差、报损差',
'detail', (SELECT
'实际食材成本' || (SELECT actual_cost FROM cost_diff) || '元 vs 理论成本' || (SELECT theoretical_cost FROM cost_diff) || '元,差异' || round((SELECT actual_cost - theoretical_cost FROM cost_diff)::numeric, 2) || '元(成本率' || round((SELECT actual_cost FROM cost_diff) / nullif((SELECT sum(received) FROM standard_stores), 0) * 100, 2) || '% vs 理论' || round((SELECT theoretical_cost FROM cost_diff) / nullif((SELECT sum(received) FROM standard_stores), 0) * 100, 2) || '%)。\n\n' ||
'差异TOP5门店(需优先排查):\n' ||
string_agg(store_name || ':差异' || diff_amount || '元(实际成本率' || actual_cost_rate || '% vs 理论' || theoretical_cost_rate || '%,实收' || received || '元)', '\n') ||
'\n\n行动指向:\n1)上述5家门店实际成本率均远超理论值,需逐店排查采购单价与BOM标准价差异;\n2)盘点差——核查月末盘点与系统库存一致性,差异>3%需复盘;\n3)报损差——对比报损记录与行业基准,报损率>2%的门店需检查存储和加工流程;\n4)30天目标:将TOP5门店成本率降低5个百分点,预计回收' || round((SELECT (actual_cost - theoretical_cost) FROM cost_diff) * 0.30, 2) || '元。'
FROM cost_diff_stores)
),
json_build_object(
'category', '标准店人工优化',
'baseline', (SELECT round(total_wage / total_received * 100, 2) FROM labor),
'target_pct', 100,
'opportunity', round((SELECT total_wage * (1 - 24.0 / nullif(total_wage / total_received * 100, 0)) FROM labor), 2),
'confidence', '中',
'owner', '运营/人力',
'evidence', '工时、工资、餐段销售与服务质量',
'detail', (SELECT
'标准店人工合计' || (SELECT total_wage FROM labor) || '元,费率' || round((SELECT total_wage FROM labor) / nullif((SELECT total_received FROM labor), 0) * 100, 2) || '%,目标降至24%。\n\n' ||
'人工费率TOP5门店(需重点督导):\n' ||
string_agg(store_name || ':人工' || wage || '元(费率' || wage_rate || '%,实收' || received || '元)', '\n') ||
'\n\n行动指向:\n1)上述门店人工费率远超24%目标,需核查排班与实际打卡工时,识别冗余工时;\n2)低峰时段用小时工替代月薪员工,预计可降费率2-3个百分点;\n3)对费率>30%的门店启动人效专项督导,要求店长提交排班优化方案;\n4)30天目标:TOP5门店人工费率平均降低2个百分点。'
FROM labor_stores)
),
json_build_object(
'category', '标准店能源优化',
'baseline', (SELECT round(total_utility / total_received * 100, 2) FROM energy),
'target_pct', 100,
'opportunity', round((SELECT total_utility * (1 - 5.0 / nullif(total_utility / total_received * 100, 0)) FROM energy), 2),
'confidence', '中',
'owner', '工程/门店',
'evidence', '账单/抄表、面积、营业时长',
'detail', (SELECT
'标准店水电合计' || (SELECT total_utility FROM energy) || '元,费率' || round((SELECT total_utility FROM energy) / nullif((SELECT total_received FROM energy), 0) * 100, 2) || '%,目标降至5%。\n\n' ||
'水电费率TOP5门店(需排查设备):\n' ||
string_agg(store_name || ':水电' || utility || '元(费率' || utility_rate || '%,实收' || received || '元)', '\n') ||
'\n\n行动指向:\n1)上述门店水电费率远超5%目标,需排查是否存在设备老化、管道泄漏或空调空转;\n2)对比近3个月水电账单,波动>20%的门店需现场检查;\n3)缩短非营业时段的照明和空调,预计可降费率0.3-0.5个百分点;\n4)30天目标:TOP5门店水电费率平均降低0.5个百分点。'
FROM energy_stores)
),
json_build_object(
'category', '高优惠门店治理',
'baseline', (SELECT high_discount_stores FROM discount),
'target_pct', 100,
'opportunity', (SELECT theoretical_saving FROM discount),
'confidence', '中低',
'owner', '营销/运营',
'evidence', '活动贡献、账单和复购不恶化',
'detail', (SELECT
'优惠率>25%的门店共' || (SELECT high_discount_stores FROM discount) || '家,理论可节约' || (SELECT theoretical_saving FROM discount) || '元/月。\n\n' ||
'高优惠TOP5门店(需活动ROI评估):\n' ||
COALESCE(string_agg(store_name || ':优惠率' || discount_rate || '%(实收' || received || '元,理论可节约' || potential_saving || '元)', '\n'), '无符合条件的门店') ||
'\n\n行动指向:\n1)上述门店优惠率远超25%红线,需逐个活动对比优惠前后实收与客流变化;\n2)将优惠率从>25%降至20%以内,用会员积分/储值优惠替代直接打折;\n3)优惠率>20%的活动需总部审批,未审批的活动立即停用;\n4)30天目标:TOP5门店优惠率平均降低5个百分点。'
FROM discount_stores)
),
json_build_object(
'category', '平台佣金优化',
'baseline', (SELECT round(total_commission / nullif(platform_received, 0) * 100, 2) FROM platform),
'target_pct', 100,
'opportunity', GREATEST(round((SELECT total_commission * (1 - 20.0 / nullif(total_commission / nullif(platform_received, 0) * 100, 0)) FROM platform), 2), 0),
'confidence', '中低',
'owner', '外卖/采购',
'evidence', '平台结算单与订单对账',
'detail', (SELECT
'平台佣金合计' || (SELECT total_commission FROM platform) || '元,佣金率' || round((SELECT total_commission FROM platform) / nullif((SELECT platform_received FROM platform), 0) * 100, 2) || '%。\n\n' ||
'佣金费率TOP5门店(需对账核查):\n' ||
COALESCE(string_agg(store_code || ':佣金' || commission || '元(费率' || commission_rate || '%,实收' || received || '元)', '\n'), '无数据') ||
'\n\n行动指向:\n1)当前整体佣金率6.08%已低于20%目标,暂无大幅优化空间;\n2)上述门店佣金费率偏高,需逐月核对平台结算单与订单明细,识别多扣佣金;\n3)平台活动费与佣金应分离核算,避免活动费被计入佣金;\n4)提升自配送比例,降低对平台配送依赖;\n5)30天目标:完成TOP5门店平台结算单对账。'
FROM platform_stores)
),
json_build_object(
'category', 'SKU复杂度压缩',
'baseline', (SELECT low_sku_count FROM sku),
'target_pct', 100,
'opportunity', round((SELECT low_sku_revenue FROM sku) * 0.15, 2),
'confidence', '低',
'owner', '商品/运营',
'evidence', '停用低效SKU减少独有原料库存、报损和采购复杂度',
'detail', (SELECT
'月收入<1000元且账单<30笔的低效SKU共' || (SELECT low_sku_count FROM sku) || '个,合计收入' || (SELECT low_sku_revenue FROM sku) || '元。\n\n' ||
'低效SKU示例(候选停用):\n' ||
COALESCE(string_agg(dish_name || '' || category_level1 || '):收入' || received_amount || '元/' || bill_count || '笔,ABC类' || abc_class, '\n'), '无数据') ||
'\n\n行动指向:\n1)上述SKU月收入极低,停用后可减少独有原料库存和报损;\n2)逐个排查停用SKU的独有原料,计算可释放库存金额;\n3)每店SKU数减少15-20%,新品准入需通过收入预测和原料复用率审核;\n430天目标:完成' || (SELECT low_sku_count FROM sku) || '个低效SKU的停用评估。'
FROM sku_samples)
)
)
) AS data
`, [month])
const rows = result.rows[0] as any
sendSuccess(res, rows?.data)
} catch (err: any) {
sendError(res, err.message)
}
})
// 同比:当前月 vs 去年同月
router.get('/overview/yoy', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const prevYear = prevYearMonth(month)
const [current, previous] = await Promise.all([
query(`SELECT bill_count, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bills, member_share_pct FROM analytics.mv_overview_monthly WHERE month = $1::date`, [month]),
query(`SELECT bill_count, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bills, member_share_pct FROM analytics.mv_overview_monthly WHERE month = $1::date`, [prevYear])
])
const c = current.rows[0] as any
const p = previous.rows[0] as any
if (!c) { sendSuccess(res, null); return }
const calcChange = (cur: number, prev: number | null) => {
if (prev === null || prev === 0) return null
return Math.round(((cur - prev) / Math.abs(prev)) * 100 * 100) / 100
}
sendSuccess(res, {
current: c,
previous: p,
yoy: p ? {
received_change_pct: calcChange(parseFloat(c.received), parseFloat(p.received)),
bill_count_change_pct: calcChange(parseInt(c.bill_count), parseInt(p.bill_count)),
avg_bill_value_change_pct: calcChange(parseFloat(c.avg_bill_value), parseFloat(p.avg_bill_value)),
discount_rate_change: Math.round((parseFloat(c.discount_rate_pct) - parseFloat(p.discount_rate_pct)) * 100) / 100,
} : null
})
} catch (err: any) { sendError(res, err.message) }
})
// 环比:当前月 vs 上月
router.get('/overview/mom', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const prevMo = prevMonth(month)
const [current, previous] = await Promise.all([
query(`SELECT bill_count, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bills, member_share_pct FROM analytics.mv_overview_monthly WHERE month = $1::date`, [month]),
query(`SELECT bill_count, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bills, member_share_pct FROM analytics.mv_overview_monthly WHERE month = $1::date`, [prevMo])
])
const c = current.rows[0] as any
const p = previous.rows[0] as any
if (!c) { sendSuccess(res, null); return }
const calcChange = (cur: number, prev: number | null) => {
if (prev === null || prev === 0) return null
return Math.round(((cur - prev) / Math.abs(prev)) * 100 * 100) / 100
}
sendSuccess(res, {
current: c,
previous: p,
mom: p ? {
received_change_pct: calcChange(parseFloat(c.received), parseFloat(p.received)),
bill_count_change_pct: calcChange(parseInt(c.bill_count), parseInt(p.bill_count)),
avg_bill_value_change_pct: calcChange(parseFloat(c.avg_bill_value), parseFloat(p.avg_bill_value)),
discount_rate_change: Math.round((parseFloat(c.discount_rate_pct) - parseFloat(p.discount_rate_pct)) * 100) / 100,
} : null
})
} catch (err: any) { sendError(res, err.message) }
})
// 月度趋势
router.get('/overview/trend', async (req: AuthRequest, res) => {
try {
const endMonth = parseMonth(req)
const months = parseInt(req.query.months as string) || 12
const startDate = new Date(endMonth)
startDate.setMonth(startDate.getMonth() - months + 1)
const startMonth = startDate.toISOString().slice(0, 10)
const result = await query(`
SELECT month, bill_count, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bills, member_share_pct
FROM analytics.mv_overview_monthly
WHERE month >= $1::date AND month <= $2::date
ORDER BY month
`, [startMonth, endMonth])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 利润趋势(多月瀑布汇总)
router.get('/overview/profit-trend', async (req: AuthRequest, res) => {
try {
const endMonth = parseMonth(req)
const months = parseInt(req.query.months as string) || 6
const startDate = new Date(endMonth)
startDate.setMonth(startDate.getMonth() - months + 1)
const startMonth = startDate.toISOString().slice(0, 10)
const result = await query(`
WITH monthly AS (
SELECT e.report_month,
round(sum(r.received)::numeric, 2) AS received,
round(sum(e.actual_food_cost)::numeric, 2) AS food_cost,
round(sum(e.wage_expense)::numeric, 2) AS wage,
round(sum(e.rent_expense)::numeric, 2) AS rent,
round(sum(e.utility_expense)::numeric, 2) AS utility,
round(sum(e.operating_expense)::numeric, 2) AS total_expense,
round(sum(r.received) - sum(e.actual_food_cost) - sum(e.operating_expense), 2) AS store_contribution
FROM analytics.v_store_scorecard r
JOIN analytics.mv_store_operating_expense_monthly e
ON r.store_code = e.sales_store_code
WHERE e.report_month >= $1::date AND e.report_month <= $2::date
AND e.operating_expense IS NOT NULL AND r.received > 0
GROUP BY e.report_month
ORDER BY e.report_month
)
SELECT *,
round(store_contribution / nullif(received, 0) * 100, 2) AS contribution_margin_pct
FROM monthly
`, [startMonth, endMonth])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// 时间段汇总
router.get('/overview/period', async (req: AuthRequest, res) => {
try {
const { start, end, mode, label } = parseDateRange(req)
const result = await query(`
SELECT
sum(bill_count)::bigint AS bill_count,
round(sum(received)::numeric, 2) AS received,
round(sum(discounts)::numeric, 2) AS discounts,
round(sum(discounts) / nullif(sum(received) + sum(discounts), 0) * 100, 2) AS discount_rate_pct,
round(sum(received) / nullif(sum(bill_count), 0), 2) AS avg_bill_value,
round(sum(guests)::numeric, 0) AS guests
FROM analytics.v_store_daily
WHERE business_date >= $1::date AND business_date < $2::date
`, [start, end])
sendSuccess(res, { ...result.rows[0], range_mode: mode, range_label: label, start_date: start, end_date: end })
} catch (err: any) { sendError(res, err.message) }
})
// 渠道收入结构
router.get('/revenue/channel', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
business_date,
round(cash::numeric, 2) AS cash,
round(alipay::numeric, 2) AS alipay,
round(wechat::numeric, 2) AS wechat,
round(meituan::numeric, 2) AS meituan,
round(unionpay::numeric, 2) AS unionpay,
round(douyin::numeric, 2) AS douyin,
round(credit::numeric, 2) AS credit,
round(jd_delivery::numeric, 2) AS jd_delivery,
round(meituan_delivery::numeric, 2) AS meituan_delivery,
round(taobao_delivery::numeric, 2) AS taobao_delivery
FROM analytics.v_channel_daily
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
ORDER BY business_date
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// 时段收入分布
router.get('/revenue/meal-period', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
meal_period,
sum(bill_count)::bigint AS bill_count,
round(sum(received)::numeric, 2) AS received,
round(sum(received) / nullif(sum(bill_count), 0), 2) AS avg_bill_value
FROM analytics.v_meal_period_daily
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
GROUP BY meal_period
ORDER BY sum(received) DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// 门店营收排名
router.get('/revenue/store-ranking', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
store_code,
store_name,
sum(bill_count)::bigint AS bill_count,
round(sum(received)::numeric, 2) AS received,
round(sum(discounts)::numeric, 2) AS discounts,
round(sum(discounts) / nullif(sum(received) + sum(discounts), 0) * 100, 2) AS discount_rate_pct,
round(sum(received) / nullif(sum(bill_count), 0), 2) AS avg_bill_value,
round(sum(guests)::numeric, 0) AS guests,
round(avg(theoretical_margin_pct)::numeric, 2) AS avg_theoretical_margin_pct
FROM analytics.v_store_daily
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
GROUP BY store_code, store_name
ORDER BY sum(received) DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// 日度营收汇总
router.get('/revenue/daily-summary', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
business_date,
sum(bill_count)::bigint AS bill_count,
round(sum(received)::numeric, 2) AS received,
round(sum(discounts)::numeric, 2) AS discounts,
round(sum(discounts) / nullif(sum(received) + sum(discounts), 0) * 100, 2) AS discount_rate_pct,
round(sum(received) / nullif(sum(bill_count), 0), 2) AS avg_bill_value,
round(sum(guests)::numeric, 0) AS guests
FROM analytics.v_store_daily
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
GROUP BY business_date
ORDER BY business_date
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// 银行授信报告
router.get('/bank/report', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const nextMonthDate = new Date(month)
nextMonthDate.setMonth(nextMonthDate.getMonth() + 1)
const nextMonth = nextMonthDate.toISOString().slice(0, 10)
const [overview, daily, waterfall, risk, channel, storeRanking] = await Promise.all([
query(`SELECT bill_count, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, member_bills, member_share_pct FROM analytics.mv_overview_monthly WHERE month = $1::date`, [month]),
query(`SELECT business_date, bill_count, received, avg_bill_value, discount_rate_pct FROM analytics.mv_overview_daily WHERE month = $1::date ORDER BY business_date`, [month]),
query(`
WITH full_scope AS (
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
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
)
SELECT round(sum(received)::numeric,2) AS received, round(sum(food_cost)::numeric,2) AS food_cost, round(sum(wage)::numeric,2) AS wage,
round(sum(rent)::numeric,2) AS rent, round(sum(utility)::numeric,2) AS utility, round(sum(dorm)::numeric,2) AS dorm,
round(sum(commission)::numeric,2) AS commission, round(sum(card_fee+repair)::numeric,2) AS other_expense,
round(sum(operating_expense)::numeric,2) AS total_expense,
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 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]),
])
const ov = overview.rows[0] as any
const dailyRows = daily.rows.map((r: any) => ({
...r,
received: parseFloat(r.received),
bill_count: parseInt(r.bill_count),
avg_bill_value: parseFloat(r.avg_bill_value),
discount_rate_pct: parseFloat(r.discount_rate_pct),
}))
const wf = waterfall.rows[0] as any
const riskRows = risk.rows.map((r: any) => ({
...r,
store_count: parseInt(r.store_count),
total_received: parseFloat(r.total_received),
}))
const ch = channel.rows[0] as any
const channelTotals: Record<string, number> = {}
if (ch) {
for (const [k, v] of Object.entries(ch)) {
const val = parseFloat(v as string)
if (val > 0) channelTotals[k] = val
}
}
const storeRows = storeRanking.rows.map((r: any) => ({
...r,
received: parseFloat(r.received),
bill_count: parseInt(r.bill_count),
avg_bill_value: parseFloat(r.avg_bill_value),
discount_rate_pct: parseFloat(r.discount_rate_pct),
theoretical_margin_pct: parseFloat(r.theoretical_margin_pct),
}))
// 计算日度营收波动率
const receivedArr = dailyRows.map((d: any) => d.received)
const meanRev = receivedArr.reduce((a: number, b: number) => a + b, 0) / (receivedArr.length || 1)
const variance = receivedArr.reduce((s: number, v: number) => s + Math.pow(v - meanRev, 2), 0) / (receivedArr.length || 1)
const stdDev = Math.sqrt(variance)
const cv = meanRev > 0 ? stdDev / meanRev : 0
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) },
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) },
risk: riskRows,
channel: channelTotals,
stores: storeRows,
stability: { mean_daily_revenue: meanRev, std_dev: stdDev, cv: cv, days: receivedArr.length },
})
} catch (err: any) {
sendError(res, err.message)
}
})
// 中央厨房成本驾驶舱
router.get('/central-kitchen/dashboard', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [summary, reconciliation, products, categoryCost, recipeEfficiency, mfgPool, yieldAnalysis] = await Promise.all([
// 汇总指标
query(`
SELECT
count(*) AS product_count,
round(sum(inbound_quantity)::numeric,2) AS total_inbound_qty,
round(sum(theoretical_cost)::numeric,2) AS theoretical_cost,
round(sum(standard_cost)::numeric,2) AS standard_cost,
round(sum(material_actual_cost)::numeric,2) AS material_actual_cost,
round(max(manufacturing_cost_pool)::numeric,2) AS manufacturing_cost_pool,
round(sum(allocated_manufacturing_cost)::numeric,2) AS allocated_manufacturing_cost,
round(sum(full_manufacturing_cost)::numeric,2) AS full_manufacturing_cost,
round(avg(NULLIF(full_unit_cost,0))::numeric,4) AS avg_unit_cost,
round((sum(material_actual_cost) - sum(theoretical_cost))::numeric,2) AS efficiency_variance,
round((sum(material_actual_cost) - sum(theoretical_cost)) / NULLIF(sum(theoretical_cost),0) * 100::numeric,2) AS efficiency_variance_pct,
round((sum(material_actual_cost) - sum(standard_cost)) / NULLIF(sum(standard_cost),0) * 100::numeric,2) AS standard_variance_pct,
round(max(manufacturing_cost_pool) / NULLIF(sum(material_actual_cost),0) * 100::numeric,2) AS mfg_cost_rate
FROM analytics.v_central_kitchen_product_full_cost
WHERE report_month = $1::date
`, [month]),
// 成本对账瀑布
query(`
SELECT
count(*) AS product_lines,
round(sum(p.theoretical_cost)::numeric,2) AS theoretical_cost,
round(sum(p.standard_cost)::numeric,2) AS standard_cost,
round(sum(p.material_actual_cost)::numeric,2) AS material_actual_cost,
round(max(p.manufacturing_cost_pool)::numeric,2) AS manufacturing_cost_pool,
round(sum(p.allocated_manufacturing_cost)::numeric,2) AS allocated_manufacturing_cost,
round(sum(p.full_manufacturing_cost)::numeric,2) AS full_manufacturing_cost,
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
WHERE p.report_month = $1::date
`, [month]),
// 产品明细列表
query(`
SELECT
p.product_code,
p.product_name,
p.recipe_name,
p.category_minor,
p.unit,
round(p.inbound_quantity::numeric,2) AS inbound_quantity,
round(p.theoretical_cost::numeric,2) AS theoretical_cost,
round(p.standard_cost::numeric,2) AS standard_cost,
round(p.material_actual_cost::numeric,2) AS material_actual_cost,
round(p.allocated_manufacturing_cost::numeric,2) AS allocated_manufacturing_cost,
round(p.full_manufacturing_cost::numeric,2) AS full_manufacturing_cost,
round(p.full_unit_cost::numeric,4) AS full_unit_cost,
round(((p.material_actual_cost - p.theoretical_cost) / NULLIF(p.theoretical_cost,0) * 100)::numeric,2) AS efficiency_variance_pct,
round(((p.material_actual_cost - p.standard_cost) / NULLIF(p.standard_cost,0) * 100)::numeric,2) AS standard_variance_pct,
round((p.allocated_manufacturing_cost / NULLIF(p.material_actual_cost,0) * 100)::numeric,2) AS mfg_allocation_pct,
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
WHERE p.report_month = $1::date
ORDER BY p.full_manufacturing_cost DESC
`, [month]),
// 品类成本结构
query(`
SELECT
COALESCE(NULLIF(category_minor,''),'未分类') AS category_minor,
count(*) AS product_count,
round(sum(inbound_quantity)::numeric,2) AS total_qty,
round(sum(theoretical_cost)::numeric,2) AS theoretical_cost,
round(sum(material_actual_cost)::numeric,2) AS material_actual_cost,
round(sum(allocated_manufacturing_cost)::numeric,2) AS allocated_mfg_cost,
round(sum(full_manufacturing_cost)::numeric,2) AS full_cost,
round(avg(NULLIF(full_unit_cost,0))::numeric,4) AS avg_unit_cost,
round(((sum(material_actual_cost) - sum(theoretical_cost)) / NULLIF(sum(theoretical_cost),0) * 100)::numeric,2) AS efficiency_var_pct
FROM analytics.v_central_kitchen_product_full_cost
WHERE report_month = $1::date
GROUP BY category_minor
ORDER BY full_cost DESC
`, [month]),
// 配方效率分析(Top超耗)
query(`
SELECT
recipe_name,
item_name,
specification,
unit,
round(sum(theoretical_quantity)::numeric,2) AS theoretical_qty,
round(sum(net_quantity)::numeric,2) AS actual_qty,
round(sum(issue_quantity)::numeric,2) AS issue_qty,
round(sum(theoretical_amount)::numeric,2) AS theoretical_amt,
round(sum(issue_amount)::numeric,2) AS actual_amt,
round(((sum(issue_amount) - sum(theoretical_amount)) / NULLIF(sum(theoretical_amount),0) * 100)::numeric,2) AS variance_pct,
round(avg(actual_yield)::numeric,4) AS avg_actual_yield,
round(avg(recipe_yield)::numeric,4) AS avg_recipe_yield,
round((avg(actual_yield) - avg(recipe_yield))::numeric,4) AS yield_diff
FROM central_kitchen_recipe_consumption
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
GROUP BY recipe_name, item_name, specification, unit
HAVING sum(theoretical_amount) > 0
ORDER BY abs((sum(issue_amount) - sum(theoretical_amount)) / NULLIF(sum(theoretical_amount),0)) DESC
LIMIT 20
`, [month]),
// 制造费用池明细
query(`
SELECT
cost_type,
cost_subtype,
source_type,
source_reference,
round(source_amount::numeric,2) AS source_amount,
round(central_kitchen_share_pct::numeric,4) AS share_pct,
round(allocated_amount::numeric,2) AS allocated_amount,
allocation_method,
is_provisional,
include_in_rebuilt_cost,
note
FROM central_kitchen_manufacturing_cost_pool
WHERE report_month = $1::date
ORDER BY include_in_rebuilt_cost DESC, allocated_amount DESC
`, [month]),
// 出成率分析
query(`
SELECT
recipe_name,
product_name,
specification,
unit,
round(sum(theoretical_inbound_quantity)::numeric,2) AS theoretical_inbound_qty,
round(sum(actual_inbound_quantity)::numeric,2) AS actual_inbound_qty,
round(avg(achievement_rate)::numeric,4) AS avg_achievement_rate,
round(avg(expected_quantity_variance_rate)::numeric,4) AS avg_expected_var_rate,
round(sum(inbound_quantity)::numeric,2) AS total_inbound_qty,
round(sum(return_quantity)::numeric,2) AS total_return_qty,
round(sum(inbound_amount)::numeric,2) AS total_inbound_amt,
round(sum(return_amount)::numeric,2) AS total_return_amt,
round((sum(return_quantity) / NULLIF(sum(inbound_quantity),0) * 100)::numeric,2) AS return_rate
FROM central_kitchen_finished_receipt
WHERE receipt_date >= $1::date AND receipt_date < ($1::date + INTERVAL '1 month')
GROUP BY recipe_name, product_name, specification, unit
ORDER BY avg_expected_var_rate ASC
LIMIT 20
`, [month]),
])
const s = summary.rows[0] as any
const r = reconciliation.rows[0] as any
const parseNum = (v: any) => v ? parseFloat(v) : 0
const parseIntSafe = (v: any) => v ? parseInt(v) : 0
sendSuccess(res, {
summary: {
product_count: parseIntSafe(s?.product_count),
total_inbound_qty: parseNum(s?.total_inbound_qty),
theoretical_cost: parseNum(s?.theoretical_cost),
standard_cost: parseNum(s?.standard_cost),
material_actual_cost: parseNum(s?.material_actual_cost),
manufacturing_cost_pool: parseNum(s?.manufacturing_cost_pool),
allocated_manufacturing_cost: parseNum(s?.allocated_manufacturing_cost),
full_manufacturing_cost: parseNum(s?.full_manufacturing_cost),
avg_unit_cost: parseNum(s?.avg_unit_cost),
efficiency_variance: parseNum(s?.efficiency_variance),
efficiency_variance_pct: parseNum(s?.efficiency_variance_pct),
standard_variance_pct: parseNum(s?.standard_variance_pct),
mfg_cost_rate: parseNum(s?.mfg_cost_rate),
},
reconciliation: {
product_lines: parseIntSafe(r?.product_lines),
theoretical_cost: parseNum(r?.theoretical_cost),
standard_cost: parseNum(r?.standard_cost),
material_actual_cost: parseNum(r?.material_actual_cost),
manufacturing_cost_pool: parseNum(r?.manufacturing_cost_pool),
allocated_manufacturing_cost: parseNum(r?.allocated_manufacturing_cost),
full_manufacturing_cost: parseNum(r?.full_manufacturing_cost),
calculated_inbound_value: parseNum(r?.calculated_inbound_value),
manufacturing_margin: parseNum(r?.manufacturing_margin),
},
products: products.rows.map((row: any) => ({
...row,
inbound_quantity: parseNum(row.inbound_quantity),
theoretical_cost: parseNum(row.theoretical_cost),
standard_cost: parseNum(row.standard_cost),
material_actual_cost: parseNum(row.material_actual_cost),
allocated_manufacturing_cost: parseNum(row.allocated_manufacturing_cost),
full_manufacturing_cost: parseNum(row.full_manufacturing_cost),
full_unit_cost: parseNum(row.full_unit_cost),
efficiency_variance_pct: parseNum(row.efficiency_variance_pct),
standard_variance_pct: parseNum(row.standard_variance_pct),
mfg_allocation_pct: parseNum(row.mfg_allocation_pct),
inbound_value: parseNum(row.inbound_value),
product_margin: parseNum(row.product_margin),
})),
categoryCost: categoryCost.rows.map((row: any) => ({
...row,
total_qty: parseNum(row.total_qty),
theoretical_cost: parseNum(row.theoretical_cost),
material_actual_cost: parseNum(row.material_actual_cost),
allocated_mfg_cost: parseNum(row.allocated_mfg_cost),
full_cost: parseNum(row.full_cost),
avg_unit_cost: parseNum(row.avg_unit_cost),
efficiency_var_pct: parseNum(row.efficiency_var_pct),
product_count: parseIntSafe(row.product_count),
})),
recipeEfficiency: recipeEfficiency.rows.map((row: any) => ({
...row,
theoretical_qty: parseNum(row.theoretical_qty),
actual_qty: parseNum(row.actual_qty),
issue_qty: parseNum(row.issue_qty),
theoretical_amt: parseNum(row.theoretical_amt),
actual_amt: parseNum(row.actual_amt),
variance_pct: parseNum(row.variance_pct),
avg_actual_yield: parseNum(row.avg_actual_yield),
avg_recipe_yield: parseNum(row.avg_recipe_yield),
yield_diff: parseNum(row.yield_diff),
})),
mfgPool: mfgPool.rows.map((row: any) => ({
...row,
source_amount: parseNum(row.source_amount),
share_pct: parseNum(row.share_pct),
allocated_amount: parseNum(row.allocated_amount),
is_provisional: row.is_provisional,
include_in_rebuilt_cost: row.include_in_rebuilt_cost,
})),
yieldAnalysis: yieldAnalysis.rows.map((row: any) => ({
...row,
theoretical_inbound_qty: parseNum(row.theoretical_inbound_qty),
actual_inbound_qty: parseNum(row.actual_inbound_qty),
avg_achievement_rate: parseNum(row.avg_achievement_rate),
avg_expected_var_rate: parseNum(row.avg_expected_var_rate),
total_inbound_qty: parseNum(row.total_inbound_qty),
total_return_qty: parseNum(row.total_return_qty),
total_inbound_amt: parseNum(row.total_inbound_amt),
total_return_amt: parseNum(row.total_return_amt),
return_rate: parseNum(row.return_rate),
})),
})
} catch (err: any) {
sendError(res, err.message)
}
})
// 配送—倒挤成本对账
router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation] = await Promise.all([
// 汇总
query(`
WITH dist AS (
SELECT d.store_code, d.item_code,
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax
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
GROUP BY d.store_code, d.item_code
),
inv AS (
SELECT f.store_code, f.material_code,
round(sum(f.opening_quantity)::numeric,2) AS opening_qty,
round(sum(f.opening_amount)::numeric,2) AS opening_amt,
round(sum(f.consumption_quantity)::numeric,2) AS consumption_qty,
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
round(sum(f.ending_quantity)::numeric,2) AS ending_qty,
round(sum(f.ending_amount)::numeric,2) AS ending_amt
FROM analytics.fact_inventory_snapshot f
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
GROUP BY f.store_code, f.material_code
),
recon AS (
SELECT COALESCE(d.store_code, i.store_code) AS store_code,
COALESCE(d.item_code, i.material_code) AS item_code,
COALESCE(d.dist_qty,0) AS dist_qty,
COALESCE(d.dist_amt,0) AS dist_amt,
COALESCE(d.dist_cost_excl_tax,0) AS dist_cost_excl_tax,
COALESCE(i.opening_qty,0) AS opening_qty,
COALESCE(i.opening_amt,0) AS opening_amt,
COALESCE(i.consumption_qty,0) AS consumption_qty,
COALESCE(i.consumption_amt,0) AS consumption_amt,
COALESCE(i.ending_qty,0) AS ending_qty,
COALESCE(i.ending_amt,0) AS ending_amt
FROM dist d FULL OUTER JOIN inv i ON d.store_code = i.store_code AND d.item_code = i.material_code
)
SELECT
count(*) AS total_lines,
count(*) FILTER(WHERE dist_qty > 0 AND consumption_qty > 0) AS matched_lines,
count(*) FILTER(WHERE dist_qty > 0 AND consumption_qty = 0) AS dist_only_lines,
count(*) FILTER(WHERE dist_qty = 0 AND consumption_qty > 0) AS inv_only_lines,
round(sum(dist_amt)::numeric,2) AS total_dist_amt,
round(sum(dist_cost_excl_tax)::numeric,2) AS total_dist_cost_excl_tax,
round(sum(opening_amt)::numeric,2) AS total_opening_amt,
round(sum(consumption_amt)::numeric,2) AS total_consumption_amt,
round(sum(ending_amt)::numeric,2) AS total_ending_amt,
round((sum(opening_amt) + sum(dist_amt) - sum(ending_amt))::numeric,2) AS reverse_consumption_amt,
round((sum(consumption_amt) - (sum(opening_amt) + sum(dist_amt) - sum(ending_amt)))::numeric,2) AS variance_amt,
round((sum(consumption_amt) - (sum(opening_amt) + sum(dist_amt) - sum(ending_amt))) / NULLIF(sum(consumption_amt),0) * 100::numeric,2) AS variance_pct
FROM recon
`, [month]),
// 门店维度对账
query(`
WITH dist AS (
SELECT d.store_code,
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax
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
GROUP BY d.store_code
),
inv AS (
SELECT f.store_code,
round(sum(f.opening_amount)::numeric,2) AS opening_amt,
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
round(sum(f.ending_amount)::numeric,2) AS ending_amt,
count(*) FILTER(WHERE f.is_negative) AS neg_inventory_count
FROM analytics.fact_inventory_snapshot f
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
GROUP BY f.store_code
),
store_names AS (
SELECT DISTINCT store_code, store_name FROM distribution_detail_records
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
)
SELECT COALESCE(d.store_code, i.store_code) AS store_code,
sn.store_name,
COALESCE(d.dist_qty,0) AS dist_qty,
COALESCE(d.dist_amt,0) AS dist_amt,
COALESCE(d.dist_cost_excl_tax,0) AS dist_cost_excl_tax,
COALESCE(i.opening_amt,0) AS opening_amt,
COALESCE(i.consumption_amt,0) AS consumption_amt,
COALESCE(i.ending_amt,0) AS ending_amt,
round((COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0))::numeric,2) AS reverse_consumption_amt,
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0)))::numeric,2) AS variance_amt,
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0))) / NULLIF(COALESCE(i.consumption_amt,0),0) * 100::numeric,2) AS variance_pct,
COALESCE(i.neg_inventory_count,0) AS neg_inventory_count
FROM dist d FULL OUTER JOIN inv i ON d.store_code = i.store_code
LEFT JOIN store_names sn ON COALESCE(d.store_code, i.store_code) = sn.store_code
ORDER BY abs(COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0))) DESC
`, [month]),
// Top差异品项
query(`
WITH dist AS (
SELECT d.store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt
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
GROUP BY d.store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category
),
inv AS (
SELECT f.store_code, f.material_code,
round(sum(f.opening_amount)::numeric,2) AS opening_amt,
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
round(sum(f.ending_amount)::numeric,2) AS ending_amt
FROM analytics.fact_inventory_snapshot f
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
GROUP BY f.store_code, f.material_code
)
SELECT d.store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
d.dist_qty, d.dist_amt,
COALESCE(i.opening_amt,0) AS opening_amt,
COALESCE(i.consumption_amt,0) AS consumption_amt,
COALESCE(i.ending_amt,0) AS ending_amt,
round((COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0))::numeric,2) AS reverse_consumption_amt,
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0)))::numeric,2) AS variance_amt,
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0))) / NULLIF(COALESCE(i.consumption_amt,0),0) * 100::numeric,2) AS variance_pct
FROM dist d LEFT JOIN inv i ON d.store_code = i.store_code AND d.item_code = i.material_code
WHERE COALESCE(i.consumption_amt,0) > 0
ORDER BY abs(COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0))) DESC
LIMIT 30
`, [month]),
// 未匹配品项(有配送无库存耗用)
query(`
WITH dist_items AS (
SELECT DISTINCT d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
count(DISTINCT d.store_code) AS store_count
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
GROUP BY d.item_code, d.item_name, d.unit, d.major_category, d.minor_category
),
inv_items AS (
SELECT DISTINCT material_code FROM analytics.fact_inventory_snapshot
WHERE snapshot_date >= $1::date AND snapshot_date < ($1::date + INTERVAL '1 month')
)
SELECT d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
d.dist_qty, d.dist_amt, d.store_count
FROM dist_items d
WHERE d.item_code NOT IN (SELECT material_code FROM inv_items)
ORDER BY d.dist_amt DESC
LIMIT 20
`, [month]),
// 品类维度对账
query(`
WITH dist AS (
SELECT COALESCE(NULLIF(d.minor_category,''),'未分类') AS minor_category,
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax,
count(DISTINCT d.item_code) AS item_count
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
GROUP BY COALESCE(NULLIF(d.minor_category,''),'未分类')
),
item_cat AS (
SELECT DISTINCT item_code, COALESCE(NULLIF(minor_category,''),'未分类') AS minor_category
FROM distribution_detail_records
WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month')
AND item_code IS NOT NULL
),
inv AS (
SELECT ic.minor_category,
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
round(sum(f.ending_amount)::numeric,2) AS ending_amt
FROM analytics.fact_inventory_snapshot f
JOIN item_cat ic ON f.material_code = ic.item_code
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
GROUP BY ic.minor_category
)
SELECT COALESCE(d.minor_category, i.minor_category) AS minor_category,
COALESCE(d.item_count,0) AS item_count,
COALESCE(d.dist_qty,0) AS dist_qty,
COALESCE(d.dist_amt,0) AS dist_amt,
COALESCE(d.dist_cost_excl_tax,0) AS dist_cost_excl_tax,
COALESCE(i.consumption_amt,0) AS consumption_amt,
COALESCE(i.ending_amt,0) AS ending_amt,
round((COALESCE(i.consumption_amt,0) - COALESCE(d.dist_amt,0) + COALESCE(i.ending_amt,0))::numeric,2) AS reverse_opening_amt,
round((COALESCE(i.consumption_amt,0) - COALESCE(d.dist_amt,0))::numeric,2) AS variance_amt,
round((COALESCE(i.consumption_amt,0) - COALESCE(d.dist_amt,0)) / NULLIF(COALESCE(d.dist_amt,0),0) * 100::numeric,2) AS variance_pct
FROM dist d FULL OUTER JOIN inv i ON d.minor_category = i.minor_category
ORDER BY COALESCE(d.dist_amt,0) DESC
`, [month]),
])
const s = summary.rows[0] as any
const parseNum = (v: any) => v ? parseFloat(v) : 0
const parseIntSafe = (v: any) => v ? parseInt(v) : 0
sendSuccess(res, {
summary: {
total_lines: parseIntSafe(s?.total_lines),
matched_lines: parseIntSafe(s?.matched_lines),
dist_only_lines: parseIntSafe(s?.dist_only_lines),
inv_only_lines: parseIntSafe(s?.inv_only_lines),
total_dist_amt: parseNum(s?.total_dist_amt),
total_dist_cost_excl_tax: parseNum(s?.total_dist_cost_excl_tax),
total_opening_amt: parseNum(s?.total_opening_amt),
total_consumption_amt: parseNum(s?.total_consumption_amt),
total_ending_amt: parseNum(s?.total_ending_amt),
reverse_consumption_amt: parseNum(s?.reverse_consumption_amt),
variance_amt: parseNum(s?.variance_amt),
variance_pct: parseNum(s?.variance_pct),
},
storeReconciliation: storeReconciliation.rows.map((row: any) => ({
...row,
dist_qty: parseNum(row.dist_qty),
dist_amt: parseNum(row.dist_amt),
dist_cost_excl_tax: parseNum(row.dist_cost_excl_tax),
opening_amt: parseNum(row.opening_amt),
consumption_amt: parseNum(row.consumption_amt),
ending_amt: parseNum(row.ending_amt),
reverse_consumption_amt: parseNum(row.reverse_consumption_amt),
variance_amt: parseNum(row.variance_amt),
variance_pct: parseNum(row.variance_pct),
neg_inventory_count: parseIntSafe(row.neg_inventory_count),
})),
topVariances: topVariances.rows.map((row: any) => ({
...row,
dist_qty: parseNum(row.dist_qty),
dist_amt: parseNum(row.dist_amt),
opening_amt: parseNum(row.opening_amt),
consumption_amt: parseNum(row.consumption_amt),
ending_amt: parseNum(row.ending_amt),
reverse_consumption_amt: parseNum(row.reverse_consumption_amt),
variance_amt: parseNum(row.variance_amt),
variance_pct: parseNum(row.variance_pct),
})),
unmatchedItems: unmatchedItems.rows.map((row: any) => ({
...row,
dist_qty: parseNum(row.dist_qty),
dist_amt: parseNum(row.dist_amt),
store_count: parseIntSafe(row.store_count),
})),
categoryReconciliation: categoryReconciliation.rows.map((row: any) => ({
...row,
item_count: parseIntSafe(row.item_count),
dist_qty: parseNum(row.dist_qty),
dist_amt: parseNum(row.dist_amt),
dist_cost_excl_tax: parseNum(row.dist_cost_excl_tax),
consumption_amt: parseNum(row.consumption_amt),
ending_amt: parseNum(row.ending_amt),
reverse_opening_amt: parseNum(row.reverse_opening_amt),
variance_amt: parseNum(row.variance_amt),
variance_pct: parseNum(row.variance_pct),
})),
})
} catch (err: any) {
sendError(res, err.message)
}
})
// 多级BOM成本穿透
router.get('/central-kitchen/bom-penetration', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const productCode = (req.query.productCode as string) || ''
const [productList, bomTree, bomSummary, multiLevelChains] = await Promise.all([
// 产品列表(含BOM层级数)
query(`
WITH bom_materials AS (
SELECT DISTINCT rc.recipe_name, rc.item_code AS material_code
FROM central_kitchen_recipe_consumption rc
WHERE rc.business_date >= $1::date AND rc.business_date < ($1::date + INTERVAL '1 month')
),
finished_products AS (
SELECT product_code, product_name FROM central_kitchen_processing_cost
WHERE report_month = $1::date
),
multi_level AS (
SELECT bm.material_code
FROM bom_materials bm
JOIN finished_products fp ON bm.material_code = fp.product_code
)
SELECT pc.product_code,
pc.product_name,
pc.category_minor,
pc.unit,
round(pc.inbound_quantity::numeric,2) AS inbound_quantity,
round(pc.theoretical_cost::numeric,2) AS theoretical_cost,
round(pc.actual_cost::numeric,2) AS actual_cost,
round(pc.standard_cost::numeric,2) AS standard_cost,
CASE WHEN pc.product_code IN (SELECT material_code FROM multi_level) THEN true ELSE false END AS has_multi_level_bom,
(SELECT count(DISTINCT rc.item_code) FROM central_kitchen_recipe_consumption rc
WHERE rc.business_date >= $1::date AND rc.business_date < ($1::date + INTERVAL '1 month')
AND rc.recipe_name = pc.recipe_name) AS material_count
FROM central_kitchen_processing_cost pc
WHERE pc.report_month = $1::date
ORDER BY pc.actual_cost DESC
`, [month]),
// BOM树:展开选中产品的配方
query(`
WITH RECURSIVE
recipe_agg AS (
SELECT rc.recipe_name, rc.item_code AS material_code, rc.item_name AS material_name,
rc.unit,
round(sum(rc.theoretical_quantity)::numeric,4) AS theoretical_qty,
round(sum(rc.issue_quantity)::numeric,4) AS issue_qty,
round(sum(rc.theoretical_amount)::numeric,2) AS theoretical_amt,
round(sum(rc.issue_amount)::numeric,2) AS issue_amt,
round(avg(rc.unit_price_excl_tax)::numeric,4) AS avg_unit_price
FROM central_kitchen_recipe_consumption rc
WHERE rc.business_date >= $1::date AND rc.business_date < ($1::date + INTERVAL '1 month')
GROUP BY rc.recipe_name, rc.item_code, rc.item_name, rc.unit
),
bom_tree AS (
-- Level 1: 直接原材料
SELECT
pc.product_code AS root_product_code,
pc.product_name AS root_product_name,
pc.recipe_name AS root_recipe,
1 AS level,
ra.material_code,
ra.material_name,
ra.unit,
ra.theoretical_qty,
ra.issue_qty,
ra.theoretical_amt,
ra.issue_amt,
ra.avg_unit_price,
CASE WHEN pc2.product_code IS NOT NULL THEN true ELSE false END AS is_finished_product,
ra.material_code AS path,
'' AS parent_material_code
FROM central_kitchen_processing_cost pc
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 pc.report_month = $1::date
${productCode ? 'AND pc.product_code = $2' : ''}
UNION ALL
-- Level 2+: 递归展开半成品
SELECT
bt.root_product_code,
bt.root_product_name,
bt.root_recipe,
bt.level + 1,
ra.material_code,
ra.material_name,
ra.unit,
ra.theoretical_qty,
ra.issue_qty,
ra.theoretical_amt,
ra.issue_amt,
ra.avg_unit_price,
CASE WHEN pc2.product_code IS NOT NULL THEN true ELSE false END AS is_finished_product,
bt.path || ' -> ' || ra.material_code,
bt.material_code AS parent_material_code
FROM bom_tree bt
JOIN central_kitchen_processing_cost pc ON bt.material_code = pc.product_code AND pc.report_month = $1::date
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
)
SELECT * FROM bom_tree ORDER BY root_product_code, level, theoretical_amt DESC
${productCode ? '' : 'LIMIT 200'}
`, productCode ? [month, productCode] : [month]),
// BOM汇总:每个产品的BOM成本结构
query(`
WITH bom AS (
SELECT pc.product_code, pc.product_name,
round(sum(rc.theoretical_amount)::numeric,2) AS bom_theoretical_amt,
round(sum(rc.issue_amount)::numeric,2) AS bom_issue_amt,
count(DISTINCT rc.item_code) AS material_count,
count(DISTINCT rc.item_code) FILTER(WHERE pc2.product_code IS NOT NULL) AS sub_product_count,
round(sum(rc.theoretical_amount) / NULLIF(pc.inbound_quantity, 0)::numeric,4) AS bom_unit_theoretical_cost,
round(sum(rc.issue_amount) / NULLIF(pc.inbound_quantity, 0)::numeric,4) AS bom_unit_issue_cost
FROM central_kitchen_processing_cost pc
JOIN central_kitchen_recipe_consumption rc ON pc.recipe_name = rc.recipe_name
AND rc.business_date >= $1::date AND rc.business_date < ($1::date + INTERVAL '1 month')
LEFT JOIN central_kitchen_processing_cost pc2 ON rc.item_code = pc2.product_code AND pc2.report_month = $1::date
WHERE pc.report_month = $1::date
GROUP BY pc.product_code, pc.product_name, pc.inbound_quantity
)
SELECT b.*,
round((b.bom_issue_amt - b.bom_theoretical_amt)::numeric,2) AS variance_amt,
round((b.bom_issue_amt - b.bom_theoretical_amt) / NULLIF(b.bom_theoretical_amt, 0) * 100::numeric,2) AS variance_pct
FROM bom b
ORDER BY b.bom_theoretical_amt DESC
`, [month]),
// 多级BOM链:识别半成品依赖链
query(`
WITH bom_materials AS (
SELECT DISTINCT rc.recipe_name, rc.item_code AS material_code, rc.item_name AS material_name
FROM central_kitchen_recipe_consumption rc
WHERE rc.business_date >= $1::date AND rc.business_date < ($1::date + INTERVAL '1 month')
),
finished_products AS (
SELECT product_code, product_name, recipe_name FROM central_kitchen_processing_cost
WHERE report_month = $1::date
)
SELECT fp.product_code AS finished_code,
fp.product_name AS finished_name,
fp.recipe_name AS finished_recipe,
bm.material_code AS sub_product_code,
bm.material_name AS sub_product_name,
(SELECT fp2.product_name FROM finished_products fp2 WHERE fp2.product_code = bm.material_code) AS sub_product_finished_name,
(SELECT fp2.recipe_name FROM finished_products fp2 WHERE fp2.product_code = bm.material_code) AS sub_product_recipe
FROM finished_products fp
JOIN bom_materials bm ON fp.recipe_name = bm.recipe_name
WHERE bm.material_code IN (SELECT product_code FROM finished_products)
ORDER BY fp.product_name
`, [month]),
])
const parseNum = (v: any) => v ? parseFloat(v) : 0
const parseIntSafe = (v: any) => v ? parseInt(v) : 0
sendSuccess(res, {
productList: productList.rows.map((row: any) => ({
...row,
inbound_quantity: parseNum(row.inbound_quantity),
theoretical_cost: parseNum(row.theoretical_cost),
actual_cost: parseNum(row.actual_cost),
standard_cost: parseNum(row.standard_cost),
material_count: parseIntSafe(row.material_count),
has_multi_level_bom: row.has_multi_level_bom,
})),
bomTree: bomTree.rows.map((row: any) => ({
...row,
level: parseIntSafe(row.level),
theoretical_qty: parseNum(row.theoretical_qty),
issue_qty: parseNum(row.issue_qty),
theoretical_amt: parseNum(row.theoretical_amt),
issue_amt: parseNum(row.issue_amt),
avg_unit_price: parseNum(row.avg_unit_price),
is_finished_product: row.is_finished_product,
})),
bomSummary: bomSummary.rows.map((row: any) => ({
...row,
bom_theoretical_amt: parseNum(row.bom_theoretical_amt),
bom_issue_amt: parseNum(row.bom_issue_amt),
material_count: parseIntSafe(row.material_count),
sub_product_count: parseIntSafe(row.sub_product_count),
bom_unit_theoretical_cost: parseNum(row.bom_unit_theoretical_cost),
bom_unit_issue_cost: parseNum(row.bom_unit_issue_cost),
variance_amt: parseNum(row.variance_amt),
variance_pct: parseNum(row.variance_pct),
})),
multiLevelChains: multiLevelChains.rows.map((row: any) => ({
...row,
})),
})
} catch (err: any) {
sendError(res, err.message)
}
})
// 销量驱动生产与要货计划
router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const storeCode = (req.query.storeCode as string) || ''
// 使用单一连接确保临时表在所有查询中可见
const client = await pool.connect()
try {
// 创建临时表存储聚合销量,避免重复全表扫描550万行
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
`, 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])
const [summaryResult, skuSalesResult, materialDemandResult, storeDemandResult, ckProductionPlanResult, productionCoordResult] = await Promise.all([
// 汇总(使用临时表)
client.query(`
WITH bom_skus AS (
SELECT DISTINCT sku_code FROM analytics.fact_recipe_bom
)
SELECT
count(*) AS total_sales_lines,
count(DISTINCT s.dish_name) AS matched_sku_count,
round(sum(s.qty)::numeric,2) AS matched_qty,
round(sum(s.amt)::numeric,2) AS matched_amt,
(SELECT count(DISTINCT material_code) FROM analytics.fact_recipe_bom) AS total_bom_materials
FROM tmp_sales_agg s
JOIN bom_skus bs ON s.sku_code = bs.sku_code
`),
// SKU销量明细(使用临时表)
client.query(`
WITH sales AS (
SELECT dish_name,
round(sum(qty)::numeric,2) AS qty,
round(sum(amt)::numeric,2) AS amt,
count(DISTINCT store_code) AS store_count,
round(avg(avg_unit_price)::numeric,2) AS avg_unit_price
FROM tmp_sales_agg
GROUP BY dish_name
)
SELECT s.dish_name,
sk.sku_code,
s.qty,
s.amt,
s.store_count,
s.avg_unit_price,
CASE WHEN b.sku_code IS NOT NULL THEN true ELSE false END AS has_bom,
COALESCE(b.material_count, 0) AS material_count
FROM sales s
LEFT JOIN analytics.dim_sku sk ON s.dish_name = sk.standard_name
LEFT JOIN (
SELECT sku_code, count(DISTINCT material_code) AS material_count
FROM analytics.fact_recipe_bom
GROUP BY sku_code
) b ON sk.sku_code = b.sku_code
ORDER BY s.amt DESC
LIMIT 50
`),
// 原料需求(使用临时表 × BOM展开)
client.query(`
WITH demand AS (
SELECT s.store_code, b.material_code,
m.material_name,
b.unit,
round(sum(s.qty * b.standard_gross_quantity)::numeric,4) AS demand_qty,
round(sum(s.qty * b.standard_gross_quantity * COALESCE(ic.unit_price, 0))::numeric,2) AS demand_amt
FROM tmp_sales_agg s
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
GROUP BY s.store_code, b.material_code, m.material_name, b.unit
)
SELECT
material_code,
material_name,
unit,
round(sum(demand_qty)::numeric,4) AS total_demand_qty,
round(sum(demand_amt)::numeric,2) AS total_demand_amt,
count(DISTINCT store_code) AS store_count,
round(avg(demand_qty)::numeric,4) AS avg_store_demand
FROM demand
GROUP BY material_code, material_name, unit
ORDER BY total_demand_amt DESC NULLS LAST
LIMIT 100
`, [month]),
// 门店维度需求(使用临时表)
client.query(`
WITH store_demand AS (
SELECT s.store_code, s.store_name,
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
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
)
SELECT sd.store_code, sd.store_name,
sd.sku_count,
sd.total_qty,
sd.total_amt,
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
FROM store_demand sd
LEFT JOIN (
SELECT store_code,
round(sum(ending_quantity)::numeric,4) AS ending_qty,
round(sum(ending_amount)::numeric,2) AS ending_amt
FROM analytics.fact_inventory_snapshot
WHERE snapshot_date >= $1::date AND snapshot_date < ($1::date + INTERVAL '1 month')
GROUP BY store_code
) inv ON sd.store_code = inv.store_code
ORDER BY sd.total_amt DESC
`, [month]),
// 中央厨房生产计划(使用临时表)
client.query(`
WITH ck_demand AS (
SELECT b.material_code AS ck_product_code,
m.material_name AS ck_product_name,
b.unit,
round(sum(s.qty * b.standard_gross_quantity)::numeric,4) AS demand_qty,
count(DISTINCT s.store_code) AS store_count
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 (
SELECT product_name FROM central_kitchen_processing_cost WHERE report_month = $1::date
)
GROUP BY b.material_code, m.material_name, b.unit
),
ck_actual AS (
SELECT product_code, product_name,
round(sum(inbound_quantity)::numeric,4) AS actual_inbound_qty,
round(sum(actual_cost)::numeric,2) AS actual_cost
FROM central_kitchen_processing_cost
WHERE report_month = $1::date
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,
COALESCE(d.unit, '') AS unit,
COALESCE(d.demand_qty, 0) AS demand_qty,
COALESCE(d.store_count, 0) AS 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
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)
)
SELECT product_code, product_name, unit,
demand_qty, store_count, actual_inbound_qty, actual_cost,
round((demand_qty - actual_inbound_qty)::numeric,4) AS variance_qty,
CASE WHEN actual_inbound_qty > 0
THEN round((demand_qty - actual_inbound_qty) / actual_inbound_qty * 100::numeric, 2)
ELSE NULL END AS variance_pct
FROM ck_combined
ORDER BY demand_qty DESC
`, [month]),
// 生产与配送协同
client.query(`
WITH ck_production AS (
SELECT pc.product_code, pc.product_name,
round(sum(pc.inbound_quantity)::numeric,4) AS inbound_qty,
round(sum(pc.actual_cost)::numeric,2) AS actual_cost,
round(sum(pc.theoretical_cost)::numeric,2) AS theoretical_cost
FROM central_kitchen_processing_cost pc
WHERE pc.report_month = $1::date
GROUP BY pc.product_code, pc.product_name
),
ck_distribution AS (
SELECT d.item_code, d.item_name,
round(sum(d.total_quantity)::numeric,4) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt
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'
GROUP BY d.item_code, d.item_name
),
store_consumption AS (
SELECT f.material_code,
round(sum(f.consumption_quantity)::numeric,4) AS consumption_qty,
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
round(sum(f.ending_quantity)::numeric,4) AS ending_qty,
round(sum(f.ending_amount)::numeric,2) AS ending_amt
FROM analytics.fact_inventory_snapshot f
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
GROUP BY f.material_code
)
SELECT COALESCE(p.product_code, dist.item_code) AS product_code,
COALESCE(p.product_name, dist.item_name) AS product_name,
COALESCE(p.inbound_qty, 0) AS inbound_qty,
COALESCE(p.actual_cost, 0) AS actual_cost,
COALESCE(p.theoretical_cost, 0) AS theoretical_cost,
COALESCE(dist.dist_qty, 0) AS dist_qty,
COALESCE(dist.dist_amt, 0) AS dist_amt,
COALESCE(sc.consumption_qty, 0) AS consumption_qty,
COALESCE(sc.consumption_amt, 0) AS consumption_amt,
COALESCE(sc.ending_qty, 0) AS ending_qty,
COALESCE(sc.ending_amt, 0) AS ending_amt,
round((COALESCE(dist.dist_qty, 0) - COALESCE(p.inbound_qty, 0))::numeric,4) AS production_distribution_gap,
round((COALESCE(sc.consumption_qty, 0) - COALESCE(dist.dist_qty, 0))::numeric,4) AS distribution_consumption_gap,
CASE WHEN COALESCE(p.inbound_qty, 0) > 0
THEN round(COALESCE(dist.dist_qty, 0) / COALESCE(p.inbound_qty, 0) * 100::numeric, 2)
ELSE NULL END AS completion_distribution_rate,
CASE WHEN COALESCE(dist.dist_qty, 0) > 0
THEN round(COALESCE(sc.consumption_qty, 0) / COALESCE(dist.dist_qty, 0) * 100::numeric, 2)
ELSE NULL END AS distribution_consumption_rate,
CASE WHEN COALESCE(sc.consumption_qty, 0) > 0
THEN round(COALESCE(sc.ending_qty, 0) / COALESCE(sc.consumption_qty, 0) * 100::numeric, 2)
ELSE NULL END AS inventory_accumulation_rate
FROM ck_production p
FULL OUTER JOIN ck_distribution dist ON p.product_code = dist.item_code
FULL OUTER JOIN store_consumption sc ON COALESCE(p.product_code, dist.item_code) = sc.material_code
ORDER BY COALESCE(p.actual_cost, 0) DESC
LIMIT 50
`, [month]),
])
const parseNum = (v: any) => v ? parseFloat(v) : 0
const parseIntSafe = (v: any) => v ? parseInt(v) : 0
const s = summaryResult.rows[0] as any
const ts = totalSales.rows[0] as any
sendSuccess(res, {
summary: {
total_sales_lines: parseIntSafe(s?.total_sales_lines),
matched_sku_count: parseIntSafe(s?.matched_sku_count),
total_dish_count: parseIntSafe(ts?.total_dish_count),
matched_qty: parseNum(s?.matched_qty),
matched_amt: parseNum(s?.matched_amt),
total_sales_amt: parseNum(ts?.total_sales_amt),
total_bom_materials: parseIntSafe(s?.total_bom_materials),
bom_coverage_pct: ts?.total_dish_count > 0 ? parseNum((s?.matched_sku_count / ts?.total_dish_count * 100).toFixed(2)) : 0,
},
skuSales: skuSalesResult.rows.map((row: any) => ({
...row,
qty: parseNum(row.qty),
amt: parseNum(row.amt),
store_count: parseIntSafe(row.store_count),
avg_unit_price: parseNum(row.avg_unit_price),
material_count: parseIntSafe(row.material_count),
has_bom: row.has_bom,
})),
materialDemand: materialDemandResult.rows.map((row: any) => ({
...row,
total_demand_qty: parseNum(row.total_demand_qty),
total_demand_amt: parseNum(row.total_demand_amt),
store_count: parseIntSafe(row.store_count),
avg_store_demand: parseNum(row.avg_store_demand),
})),
storeDemand: storeDemandResult.rows.map((row: any) => ({
...row,
sku_count: parseIntSafe(row.sku_count),
total_qty: parseNum(row.total_qty),
total_amt: parseNum(row.total_amt),
total_material_demand_qty: parseNum(row.total_material_demand_qty),
ending_inventory_qty: parseNum(row.ending_inventory_qty),
ending_inventory_amt: parseNum(row.ending_inventory_amt),
suggested_order_qty: parseNum(row.suggested_order_qty),
})),
ckProductionPlan: ckProductionPlanResult.rows.map((row: any) => ({
...row,
demand_qty: parseNum(row.demand_qty),
actual_inbound_qty: parseNum(row.actual_inbound_qty),
actual_cost: parseNum(row.actual_cost),
variance_qty: parseNum(row.variance_qty),
variance_pct: row.variance_pct ? parseNum(row.variance_pct) : null,
store_count: parseIntSafe(row.store_count),
})),
productionCoord: productionCoordResult.rows.map((row: any) => ({
...row,
inbound_qty: parseNum(row.inbound_qty),
actual_cost: parseNum(row.actual_cost),
theoretical_cost: parseNum(row.theoretical_cost),
dist_qty: parseNum(row.dist_qty),
dist_amt: parseNum(row.dist_amt),
consumption_qty: parseNum(row.consumption_qty),
consumption_amt: parseNum(row.consumption_amt),
ending_qty: parseNum(row.ending_qty),
ending_amt: parseNum(row.ending_amt),
production_distribution_gap: parseNum(row.production_distribution_gap),
distribution_consumption_gap: parseNum(row.distribution_consumption_gap),
completion_distribution_rate: row.completion_distribution_rate ? parseNum(row.completion_distribution_rate) : null,
distribution_consumption_rate: row.distribution_consumption_rate ? parseNum(row.distribution_consumption_rate) : null,
inventory_accumulation_rate: row.inventory_accumulation_rate ? parseNum(row.inventory_accumulation_rate) : null,
})),
})
} finally {
client.release()
}
} catch (err: any) {
sendError(res, err.message)
}
})
export default router