feat: 全页面增加营业收入/优惠指标 + 修复趋势预测空数据 + 区域对比口径统一 + StorePage最新经营日概览
- 后端: 所有API的discount改为从bill_fact累计discount_total - 后端: revenue/daily-summary增加consumption字段 - 后端: situational-awareness/forecast修复c175字符串比较为timestamp - 后端: region/comparison去掉未知区域排除,统一口径 - 后端: daily-card增加营业收入/优惠指标,返回target_date/baseline_date - 前端: BossPage/DashboardPage/BankPage/StorePage/ExpenseOverviewTab/RegionalPage/RegionComparisonPage/RevenuePage 均增加营业收入和优惠指标卡 - 前端: StorePage日均经营概览改为最新经营日概览,显示具体对比日期 - 前端: 日期格式统一截取YYYY-MM-DD显示
This commit is contained in:
@@ -168,9 +168,19 @@ router.get('/region/comparison', async (req: AuthRequest, res) => {
|
||||
const month = parseMonth(req)
|
||||
|
||||
const result = await query(`
|
||||
WITH store_metrics AS (
|
||||
WITH bill_region AS (
|
||||
SELECT COALESCE(ds.region, '未知区域') AS region,
|
||||
round(sum(b.consumption)::numeric, 2) AS total_consumption,
|
||||
round(sum(b.discount_total)::numeric, 2) AS total_discount
|
||||
FROM analytics.bill_fact b
|
||||
JOIN analytics.dim_store ds ON b.store_code = ds.store_code
|
||||
WHERE b.closed_at >= $1::date AND b.closed_at < ($1::date + interval '1 month')
|
||||
AND ds.region IS NOT NULL
|
||||
GROUP BY COALESCE(ds.region, '未知区域')
|
||||
),
|
||||
store_metrics AS (
|
||||
SELECT
|
||||
ds.region,
|
||||
COALESCE(ds.region, '未知区域') AS region,
|
||||
count(DISTINCT r.store_code) as store_count,
|
||||
sum(r.received) as total_revenue,
|
||||
round(avg(r.avg_bill_value)::numeric, 2) as avg_bill_value,
|
||||
@@ -180,24 +190,26 @@ router.get('/region/comparison', async (req: AuthRequest, res) => {
|
||||
FROM analytics.mv_store_risk_rating_monthly r
|
||||
JOIN analytics.dim_store ds ON r.store_code = ds.store_code
|
||||
WHERE r.month_start = $1
|
||||
AND ds.region IS NOT NULL AND ds.region != '未知区域'
|
||||
GROUP BY ds.region
|
||||
AND ds.region IS NOT NULL
|
||||
GROUP BY COALESCE(ds.region, '未知区域')
|
||||
),
|
||||
expense_metrics AS (
|
||||
SELECT
|
||||
ds.region,
|
||||
COALESCE(ds.region, '未知区域') AS region,
|
||||
avg(oe.operating_expense_rate_pct) as avg_expense_rate,
|
||||
avg(oe.rent_rate_pct) as avg_rent_rate,
|
||||
avg(oe.wage_rate_pct) as avg_wage_rate
|
||||
FROM analytics.mv_store_operating_expense_monthly oe
|
||||
JOIN analytics.dim_store ds ON oe.sales_store_code = ds.store_code
|
||||
WHERE oe.report_month = $1
|
||||
AND ds.region IS NOT NULL AND ds.region != '未知区域'
|
||||
GROUP BY ds.region
|
||||
AND ds.region IS NOT NULL
|
||||
GROUP BY COALESCE(ds.region, '未知区域')
|
||||
)
|
||||
SELECT
|
||||
sm.region,
|
||||
sm.store_count,
|
||||
br.total_consumption,
|
||||
br.total_discount,
|
||||
round(sm.total_revenue::numeric, 2) as total_revenue,
|
||||
round((sm.total_revenue / sm.store_count)::numeric, 2) as revenue_per_store,
|
||||
round(sm.avg_bill_value::numeric, 2) as avg_bill_value,
|
||||
@@ -209,6 +221,7 @@ router.get('/region/comparison', async (req: AuthRequest, res) => {
|
||||
round(em.avg_rent_rate::numeric, 2) as avg_rent_rate,
|
||||
round(em.avg_wage_rate::numeric, 2) as avg_wage_rate
|
||||
FROM store_metrics sm
|
||||
LEFT JOIN bill_region br ON sm.region = br.region
|
||||
LEFT JOIN expense_metrics em ON sm.region = em.region
|
||||
ORDER BY sm.total_revenue DESC
|
||||
`, [month])
|
||||
|
||||
@@ -9,7 +9,19 @@ 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])
|
||||
const result = await query(`
|
||||
SELECT o.bill_count, o.received, o.avg_bill_value, o.discount_rate_pct, o.theoretical_margin_pct, o.member_bills, o.member_share_pct,
|
||||
round(b.consumption::numeric, 2) AS consumption,
|
||||
round(b.discount::numeric, 2) AS discount
|
||||
FROM analytics.mv_overview_monthly o
|
||||
LEFT JOIN (
|
||||
SELECT round(sum(consumption)::numeric, 2) AS consumption,
|
||||
round(sum(discount_total)::numeric, 2) AS discount
|
||||
FROM analytics.bill_fact
|
||||
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
|
||||
) b ON true
|
||||
WHERE o.month = $1::date
|
||||
`, [month])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
@@ -120,6 +132,8 @@ router.get('/stores/:code', async (req: AuthRequest, res) => {
|
||||
SELECT c002 AS store_code, c003 AS store_name,
|
||||
count(*) AS bill_count,
|
||||
count(DISTINCT c176::date) AS active_days,
|
||||
round(sum(COALESCE(NULLIF(c009,'')::numeric,0)), 2) AS consumption,
|
||||
round(sum(COALESCE(NULLIF(c068,'')::numeric,0)), 2) AS discount,
|
||||
round(sum(COALESCE(NULLIF(c114,'')::numeric,0)), 2) AS received,
|
||||
round(sum(COALESCE(NULLIF(c114,'')::numeric,0)) / NULLIF(count(DISTINCT c176::date), 0), 2) AS avg_daily_received,
|
||||
round(sum(COALESCE(NULLIF(c114,'')::numeric,0)) / count(*), 2) AS avg_bill_value,
|
||||
@@ -147,7 +161,7 @@ router.get('/stores/:code', async (req: AuthRequest, res) => {
|
||||
}
|
||||
const si = storeInfo.rows[0] as any
|
||||
return sendSuccess(res, {
|
||||
scorecard: { store_code: si.store_code, store_name: si.store_name, bill_count: 0, active_days: 0, received: 0, avg_daily_received: 0, avg_bill_value: 0, avg_guest_value: 0, discount_rate_pct: 0, theoretical_margin_pct: 0, member_bill_share_pct: 0 },
|
||||
scorecard: { store_code: si.store_code, store_name: si.store_name, bill_count: 0, active_days: 0, consumption: 0, discount: 0, received: 0, avg_daily_received: 0, avg_bill_value: 0, avg_guest_value: 0, discount_rate_pct: 0, theoretical_margin_pct: 0, member_bill_share_pct: 0 },
|
||||
risk: null,
|
||||
platform: null,
|
||||
benchmark: null,
|
||||
@@ -683,7 +697,9 @@ router.get('/region/summary', async (req: AuthRequest, res) => {
|
||||
const result = await query(`
|
||||
SELECT d.region,
|
||||
count(DISTINCT b.store_code) AS store_count,
|
||||
COALESCE(sum(b.received_total), 0) AS total_received,
|
||||
round(COALESCE(sum(b.consumption), 0)::numeric, 2) AS total_consumption,
|
||||
round(COALESCE(sum(b.discount_total), 0)::numeric, 2) AS total_discount,
|
||||
round(COALESCE(sum(b.received_total), 0)::numeric, 2) AS total_received,
|
||||
count(*) AS total_bill_count,
|
||||
round(avg(b.received_total), 2) AS avg_bill_value,
|
||||
round(sum(b.discount_total) / NULLIF(sum(b.consumption), 0) * 100, 2) AS avg_discount_rate,
|
||||
@@ -754,9 +770,18 @@ router.get('/overview/profit-waterfall', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const result = await query(`
|
||||
WITH full_scope AS (
|
||||
WITH bill_discount AS (
|
||||
SELECT store_code,
|
||||
round(sum(discount_total)::numeric, 2) AS discount
|
||||
FROM analytics.bill_fact
|
||||
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
|
||||
GROUP BY store_code
|
||||
),
|
||||
full_scope AS (
|
||||
SELECT
|
||||
r.received,
|
||||
e.consumption,
|
||||
COALESCE(bd.discount, e.consumption - e.received) AS discount,
|
||||
e.actual_food_cost,
|
||||
e.wage_expense,
|
||||
e.rent_expense,
|
||||
@@ -770,9 +795,12 @@ router.get('/overview/profit-waterfall', async (req: AuthRequest, res) => {
|
||||
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
|
||||
LEFT JOIN bill_discount bd ON bd.store_code = r.store_code
|
||||
WHERE r.month_start = $1 AND r.received IS NOT NULL AND e.operating_expense IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
round(sum(consumption)::numeric, 2) AS consumption,
|
||||
round(sum(discount)::numeric, 2) AS discount,
|
||||
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,
|
||||
@@ -1122,8 +1150,17 @@ router.get('/overview/profit-trend', async (req: AuthRequest, res) => {
|
||||
const startMonth = startDate.toISOString().slice(0, 10)
|
||||
|
||||
const result = await query(`
|
||||
WITH monthly AS (
|
||||
WITH bill_monthly AS (
|
||||
SELECT date_trunc('month', closed_at)::date AS report_month,
|
||||
round(sum(discount_total)::numeric, 2) AS discount
|
||||
FROM analytics.bill_fact
|
||||
WHERE closed_at >= $1::date AND closed_at < ($2::date + interval '1 month')
|
||||
GROUP BY date_trunc('month', closed_at)
|
||||
),
|
||||
monthly AS (
|
||||
SELECT e.report_month,
|
||||
round(sum(e.consumption)::numeric, 2) AS consumption,
|
||||
round(max(bm.discount)::numeric, 2) AS discount,
|
||||
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,
|
||||
@@ -1134,6 +1171,7 @@ router.get('/overview/profit-trend', async (req: AuthRequest, res) => {
|
||||
FROM analytics.v_store_scorecard r
|
||||
JOIN analytics.mv_store_operating_expense_monthly e
|
||||
ON r.store_code = e.sales_store_code
|
||||
LEFT JOIN bill_monthly bm ON bm.report_month = e.report_month
|
||||
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
|
||||
@@ -1248,6 +1286,7 @@ router.get('/revenue/daily-summary', async (req: AuthRequest, res) => {
|
||||
SELECT
|
||||
business_date,
|
||||
sum(bill_count)::bigint AS bill_count,
|
||||
round(sum(received + discounts)::numeric, 2) AS consumption,
|
||||
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,
|
||||
@@ -1275,15 +1314,25 @@ router.get('/bank/report', async (req: AuthRequest, res) => {
|
||||
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,
|
||||
WITH bill_discount AS (
|
||||
SELECT store_code,
|
||||
round(sum(discount_total)::numeric, 2) AS discount
|
||||
FROM analytics.bill_fact
|
||||
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
|
||||
GROUP BY store_code
|
||||
),
|
||||
full_scope AS (
|
||||
SELECT r.received, e.consumption, COALESCE(bd.discount, e.consumption - e.received) AS discount,
|
||||
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.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
|
||||
LEFT JOIN bill_discount bd ON bd.store_code = r.store_code
|
||||
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,
|
||||
SELECT round(sum(consumption)::numeric,2) AS consumption, round(sum(discount)::numeric,2) AS discount,
|
||||
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,
|
||||
@@ -1338,7 +1387,7 @@ router.get('/bank/report', async (req: AuthRequest, res) => {
|
||||
sendSuccess(res, {
|
||||
overview: { ...ov, received: f(ov?.received), bill_count: i(ov?.bill_count), avg_bill_value: f(ov?.avg_bill_value), discount_rate_pct: f(ov?.discount_rate_pct), theoretical_margin_pct: f(ov?.theoretical_margin_pct), member_bills: i(ov?.member_bills), member_share_pct: f(ov?.member_share_pct) },
|
||||
daily: dailyRows,
|
||||
waterfall: { ...wf, received: f(wf?.received), food_cost: f(wf?.food_cost), wage: f(wf?.wage), rent: f(wf?.rent), utility: f(wf?.utility), dorm: f(wf?.dorm), commission: f(wf?.commission), other_expense: f(wf?.other_expense), total_expense: f(wf?.total_expense), store_contribution: f(wf?.store_contribution) },
|
||||
waterfall: { ...wf, consumption: f(wf?.consumption), discount: f(wf?.discount), received: f(wf?.received), food_cost: f(wf?.food_cost), wage: f(wf?.wage), rent: f(wf?.rent), utility: f(wf?.utility), dorm: f(wf?.dorm), commission: f(wf?.commission), other_expense: f(wf?.other_expense), total_expense: f(wf?.total_expense), store_contribution: f(wf?.store_contribution) },
|
||||
risk: riskRows,
|
||||
channel: channelTotals,
|
||||
stores: storeRows,
|
||||
|
||||
@@ -418,7 +418,7 @@ router.get('/forecast', async (req: AuthRequest, res) => {
|
||||
count(*) AS bills
|
||||
FROM bill_records
|
||||
WHERE c175 IS NOT NULL AND c175 != ''
|
||||
AND c175 >= $1 AND c175 < $1::date + interval '1 month'
|
||||
AND c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month')
|
||||
GROUP BY hour, day_type, bill_date
|
||||
),
|
||||
hourly_stats AS (
|
||||
|
||||
@@ -12,11 +12,20 @@ router.get('/overview', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const result = await query(`
|
||||
WITH full_scope AS (
|
||||
WITH bill_discount AS (
|
||||
SELECT store_code,
|
||||
round(sum(discount_total)::numeric, 2) AS discount
|
||||
FROM analytics.bill_fact
|
||||
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
|
||||
GROUP BY store_code
|
||||
),
|
||||
full_scope AS (
|
||||
SELECT
|
||||
r.store_code,
|
||||
r.store_name,
|
||||
r.received,
|
||||
e.consumption,
|
||||
COALESCE(bd.discount, e.consumption - e.received) AS discount,
|
||||
r.bill_count,
|
||||
r.theoretical_margin_pct,
|
||||
e.operating_expense,
|
||||
@@ -35,6 +44,7 @@ router.get('/overview', async (req: AuthRequest, res) => {
|
||||
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
|
||||
LEFT JOIN bill_discount bd ON bd.store_code = r.store_code
|
||||
WHERE r.month_start = $1 AND r.received IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
@@ -43,6 +53,8 @@ router.get('/overview', async (req: AuthRequest, res) => {
|
||||
count(*) FILTER (WHERE actual_store_contribution <= 0 AND received > 0 AND actual_store_contribution IS NOT NULL) AS loss_stores,
|
||||
count(*) FILTER (WHERE operating_expense IS NULL) AS no_expense_stores,
|
||||
sum(bill_count)::bigint AS total_bills,
|
||||
round(sum(consumption)::numeric, 2) AS total_consumption,
|
||||
round(sum(discount)::numeric, 2) AS total_discount,
|
||||
round(sum(received)::numeric, 2) AS total_received,
|
||||
round(sum(received) / nullif(sum(bill_count), 0), 2) AS avg_bill_value,
|
||||
round(sum(operating_expense)::numeric, 2) AS total_expense,
|
||||
|
||||
@@ -313,6 +313,8 @@ router.get('/stores/:code/daily-card', async (req: AuthRequest, res) => {
|
||||
),
|
||||
today_stats AS (
|
||||
SELECT c003 AS store_name,
|
||||
round(sum(COALESCE(NULLIF(c009,'')::numeric,0)), 2) AS consumption,
|
||||
round(sum(COALESCE(NULLIF(c068,'')::numeric,0)), 2) AS discount,
|
||||
round(sum(${RECEIVED_COLS}), 2) AS received,
|
||||
count(*) AS bill_count,
|
||||
round(sum(${RECEIVED_COLS}) / count(*), 2) AS avg_bill
|
||||
@@ -322,6 +324,8 @@ router.get('/stores/:code/daily-card', async (req: AuthRequest, res) => {
|
||||
),
|
||||
week_ago_stats AS (
|
||||
SELECT
|
||||
round(sum(COALESCE(NULLIF(c009,'')::numeric,0)), 2) AS consumption,
|
||||
round(sum(COALESCE(NULLIF(c068,'')::numeric,0)), 2) AS discount,
|
||||
round(sum(${RECEIVED_COLS}), 2) AS received,
|
||||
count(*) AS bill_count,
|
||||
round(sum(${RECEIVED_COLS}) / count(*), 2) AS avg_bill
|
||||
@@ -330,12 +334,16 @@ router.get('/stores/:code/daily-card', async (req: AuthRequest, res) => {
|
||||
GROUP BY c003
|
||||
)
|
||||
SELECT t.store_name, '收入' AS module,
|
||||
td.business_date AS target_date,
|
||||
(td.business_date - interval '7 days')::date AS baseline_date,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('metric', '营业收入', 'value', t.consumption, 'baseline', COALESCE(w.consumption, 0), 'is_anomaly', t.consumption < (COALESCE(w.consumption, 0) * 0.8)),
|
||||
jsonb_build_object('metric', '优惠', 'value', t.discount, 'baseline', COALESCE(w.discount, 0), 'is_anomaly', t.discount > (COALESCE(w.discount, 0) * 1.2)),
|
||||
jsonb_build_object('metric', '实收', 'value', t.received, 'baseline', COALESCE(w.received, 0), 'is_anomaly', t.received < (COALESCE(w.received, 0) * 0.8)),
|
||||
jsonb_build_object('metric', '账单数', 'value', t.bill_count, 'baseline', COALESCE(w.bill_count, 0), 'is_anomaly', t.bill_count::numeric < (COALESCE(w.bill_count, 0) * 0.8)),
|
||||
jsonb_build_object('metric', '客单价', 'value', t.avg_bill, 'baseline', COALESCE(w.avg_bill, 0), 'is_anomaly', t.avg_bill < (COALESCE(w.avg_bill, 0) * 0.9))
|
||||
) AS anomalies
|
||||
FROM today_stats t
|
||||
FROM today_stats t, target_day td
|
||||
LEFT JOIN week_ago_stats w ON true
|
||||
`, params)
|
||||
const tasks = await query(`
|
||||
@@ -343,7 +351,9 @@ router.get('/stores/:code/daily-card', async (req: AuthRequest, res) => {
|
||||
WHERE t.store_code = $1 AND t.status IN ('待启动', '进行中')
|
||||
ORDER BY t.priority LIMIT 3
|
||||
`, [code])
|
||||
sendSuccess(res, { anomalies: result.rows, todos: tasks.rows })
|
||||
const targetDate = result.rows[0]?.target_date
|
||||
const baselineDate = result.rows[0]?.baseline_date
|
||||
sendSuccess(res, { anomalies: result.rows, todos: tasks.rows, target_date: targetDate, baseline_date: baselineDate })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user