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:
freedakgmail
2026-08-02 14:56:00 +08:00
parent a1a8b7ee5e
commit 271ad8041d
13 changed files with 204 additions and 76 deletions
+20 -7
View File
@@ -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])
+58 -9
View File
@@ -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,
+1 -1
View File
@@ -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 (
+13 -1
View File
@@ -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,
+12 -2
View File
@@ -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)
}