diff --git a/client/src/components/store-expense/ExpenseOverviewTab.tsx b/client/src/components/store-expense/ExpenseOverviewTab.tsx index 4985feb..0271f4d 100644 --- a/client/src/components/store-expense/ExpenseOverviewTab.tsx +++ b/client/src/components/store-expense/ExpenseOverviewTab.tsx @@ -21,10 +21,12 @@ export function ExpenseOverviewTab({ month }: { month: string }) { return (
-
- +
+ + +
diff --git a/client/src/pages/BankPage.tsx b/client/src/pages/BankPage.tsx index c8a4ceb..869d471 100644 --- a/client/src/pages/BankPage.tsx +++ b/client/src/pages/BankPage.tsx @@ -137,11 +137,13 @@ export function BankPage() { const waterfallChart = useMemo(() => { if (!wf) return [] return [ - { name: '实收', value: wf.received, base: 0, fill: '#3b82f6' }, - { name: '食材', value: -wf.food_cost, base: wf.received, fill: '#f97316' }, - { name: '人工', value: -wf.wage, base: wf.received - wf.food_cost, fill: '#3b82f6' }, - { name: '房租', value: -wf.rent, base: wf.received - wf.food_cost - wf.wage, fill: '#8b5cf6' }, - { name: '水电', value: -wf.utility, base: wf.received - wf.food_cost - wf.wage - wf.rent, fill: '#06b6d4' }, + { name: '营业收入', value: wf.consumption, base: 0, fill: '#3b82f6' }, + { name: '优惠', value: -wf.discount, base: wf.consumption - wf.discount, fill: '#f97316' }, + { name: '实收', value: wf.received, base: 0, fill: '#6366f1' }, + { name: '食材', value: -wf.food_cost, base: wf.received - wf.food_cost, fill: '#f97316' }, + { name: '人工', value: -wf.wage, base: wf.received - wf.food_cost - wf.wage, fill: '#3b82f6' }, + { name: '房租', value: -wf.rent, base: wf.received - wf.food_cost - wf.wage - wf.rent, fill: '#8b5cf6' }, + { name: '水电', value: -wf.utility, base: wf.received - wf.food_cost - wf.wage - wf.rent - wf.utility, fill: '#06b6d4' }, { name: '其他', value: -(wf.dorm + wf.commission + wf.other_expense), base: wf.received - wf.food_cost - wf.wage - wf.rent - wf.utility, fill: '#64748b' }, { name: '门店贡献利润', value: wf.store_contribution, base: 0, fill: wf.store_contribution >= 0 ? '#22c55e' : '#ef4444' }, ] @@ -312,11 +314,13 @@ export function BankPage() { {/* ② 企业经营概况 */} -
+
+ + - +
diff --git a/client/src/pages/BossPage.tsx b/client/src/pages/BossPage.tsx index baa930c..7eb1303 100644 --- a/client/src/pages/BossPage.tsx +++ b/client/src/pages/BossPage.tsx @@ -154,7 +154,9 @@ export function BossPage() { // 利润瀑布数据 const waterfallSteps = wf ? [ - { name: '实收', value: Number(wf.received), type: 'total' }, + { name: '营业收入', value: Number(wf.consumption), type: 'total' }, + { name: '优惠', value: -Number(wf.discount), type: 'cost' }, + { name: '实收', value: Number(wf.received), type: 'subtotal' }, { name: '食材成本', value: -Number(wf.food_cost), type: 'cost' }, { name: '人工', value: -Number(wf.wage), type: 'cost' }, { name: '房租', value: -Number(wf.rent), type: 'cost' }, @@ -171,6 +173,9 @@ export function BossPage() { if (step.type === 'total') { cumulative = step.value return { name: step.name, base: 0, value: step.value, fill: '#3b82f6' } + } else if (step.type === 'subtotal') { + cumulative = step.value + return { name: step.name, base: 0, value: step.value, fill: '#6366f1' } } else if (step.type === 'profit') { return { name: step.name, base: 0, value: step.value, fill: '#22c55e' } } else { @@ -218,11 +223,13 @@ export function BossPage() {
{/* ① 核心经营指标 */} -
- 0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`} /> - - - 0 ? sumKey(last7, 'received') / sumKey(last7, 'bill_count') : 0), (prev7.length > 0 ? sumKey(prev7, 'received') / sumKey(prev7, 'bill_count') : 0))} description={`账单 ${formatNumber(ex?.total_bills)} 笔 · 公式:实收 ÷ 账单数`} /> +
+ + + 0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`} /> + + + 0 ? sumKey(last7, 'received') / sumKey(last7, 'bill_count') : 0), (prev7.length > 0 ? sumKey(prev7, 'received') / sumKey(prev7, 'bill_count') : 0))} description={`账单 ${formatNumber(ex?.total_bills)} 笔`} />
{/* ①b KPI达成率 */} diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index a682964..a4392a4 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -212,12 +212,14 @@ export function DashboardPage() {
{/* 经营指标 */} - -
- - - - + +
+ + + + + +
diff --git a/client/src/pages/RegionComparisonPage.tsx b/client/src/pages/RegionComparisonPage.tsx index 96e3efa..1bf1709 100644 --- a/client/src/pages/RegionComparisonPage.tsx +++ b/client/src/pages/RegionComparisonPage.tsx @@ -30,6 +30,8 @@ export function RegionComparisonPage() { const rows = (data as any)?.data || [] const forecastRows = (forecastData as any)?.data || [] + const totalConsumption = rows.reduce((s: number, r: any) => s + Number(r.total_consumption || 0), 0) + const totalDiscount = rows.reduce((s: number, r: any) => s + Number(r.total_discount || 0), 0) const totalRevenue = rows.reduce((s: number, r: any) => s + Number(r.total_revenue || 0), 0) const totalStores = rows.reduce((s: number, r: any) => s + Number(r.store_count || 0), 0) const avgMargin = rows.length > 0 ? rows.reduce((s: number, r: any) => s + Number(r.avg_margin_pct || 0), 0) / rows.length : 0 @@ -53,9 +55,11 @@ export function RegionComparisonPage() { {/* 概览指标 */} -
+
+ + + -
@@ -89,7 +93,9 @@ export function RegionComparisonPage() { filterKey="region" filterLabel="全部区域" sortOptions={[ - { key: 'total_revenue', label: '总收入' }, + { key: 'total_consumption', label: '营业收入' }, + { key: 'total_discount', label: '优惠' }, + { key: 'total_revenue', label: '实收' }, { key: 'revenue_per_store', label: '店均收入' }, { key: 'avg_bill_value', label: '客单价' }, { key: 'avg_margin_pct', label: '毛利率' }, @@ -101,7 +107,9 @@ export function RegionComparisonPage() { columns={[ { key: 'region', label: '区域' }, { key: 'store_count', label: '门店数', align: 'right', render: (r) => formatNumber(r.store_count) }, - { key: 'total_revenue', label: '总收入', align: 'right', render: (r) => formatCurrency(r.total_revenue) }, + { key: 'total_consumption', label: '营业收入', align: 'right', render: (r) => formatCurrency(r.total_consumption) }, + { key: 'total_discount', label: '优惠', align: 'right', render: (r) => formatCurrency(r.total_discount) }, + { key: 'total_revenue', label: '实收', align: 'right', render: (r) => formatCurrency(r.total_revenue) }, { key: 'revenue_per_store', label: '店均收入', align: 'right', render: (r) => formatCurrency(r.revenue_per_store) }, { key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) }, { key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) }, diff --git a/client/src/pages/RegionalPage.tsx b/client/src/pages/RegionalPage.tsx index 56de3f8..a2be755 100644 --- a/client/src/pages/RegionalPage.tsx +++ b/client/src/pages/RegionalPage.tsx @@ -75,7 +75,10 @@ export function RegionalPage() {
{/* 汇总卡片 */} -
+
+ s + Number(r.total_consumption || 0), 0)} format="currency" description="各区域消费金额合计" /> + s + Number(r.total_discount || 0), 0)} format="currency" description="各类优惠扣减合计" /> + s + Number(r.total_received || 0), 0)} format="currency" description="扣除优惠后净收入" /> r.risk_level !== '绿色').length} /> @@ -90,15 +93,19 @@ export function RegionalPage() { data={regionRows} sortOptions={[ { key: 'region', label: '区域' }, + { key: 'total_consumption', label: '营业收入' }, + { key: 'total_discount', label: '优惠' }, { key: 'total_received', label: '实收' }, { key: 'avg_bill_value', label: '客单价' }, { key: 'store_count', label: '门店数' }, ]} - defaultSort="total_received" + defaultSort="total_consumption" defaultOrder="desc" columns={[ { key: 'region', label: '区域' }, { key: 'store_count', label: '门店数', align: 'right' }, + { key: 'total_consumption', label: '营业收入', align: 'right', render: (r) => formatCurrency(r.total_consumption) }, + { key: 'total_discount', label: '优惠', align: 'right', render: (r) => formatCurrency(r.total_discount) }, { key: 'total_received', label: '实收合计', align: 'right', render: (r) => formatCurrency(r.total_received) }, { key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) }, { key: 'avg_discount_rate', label: '优惠率', align: 'right', render: (r) => formatPercent(r.avg_discount_rate) }, diff --git a/client/src/pages/RevenuePage.tsx b/client/src/pages/RevenuePage.tsx index fa494a2..917412d 100644 --- a/client/src/pages/RevenuePage.tsx +++ b/client/src/pages/RevenuePage.tsx @@ -53,6 +53,7 @@ export function RevenuePage() { const rankRows = (storeRankingData as any)?.data || [] // 核心指标 + const totalConsumption = dailyRows.reduce((s: number, r: any) => s + Number(r.consumption || 0), 0) const totalReceived = dailyRows.reduce((s: number, r: any) => s + Number(r.received || 0), 0) const totalBills = dailyRows.reduce((s: number, r: any) => s + Number(r.bill_count || 0), 0) const totalDiscounts = dailyRows.reduce((s: number, r: any) => s + Number(r.discounts || 0), 0) @@ -154,11 +155,13 @@ export function RevenuePage() {
{/* 核心指标 */} -
- 0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`} /> - 0 ? '↑' : '↓'} ${Math.abs(trendBills)}%`} /> - - 25 ? 'bad' : avgDiscountRate > 20 ? 'warn' : 'good'} description={`优惠率 ${formatPercent(avgDiscountRate)} · 公式:优惠率 = 优惠额 ÷ (实收 + 优惠额) × 100%。建议值:≤ 18% 为健康,18-22% 需关注,> 25% 需整改`} /> +
+ + 25 ? 'bad' : avgDiscountRate > 20 ? 'warn' : 'good'} description={`优惠率 ${formatPercent(avgDiscountRate)}`} /> + 0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`} /> + 0 ? '↑' : '↓'} ${Math.abs(trendBills)}%`} /> + +
{/* 日度营收趋势 */} diff --git a/client/src/pages/StorePage.tsx b/client/src/pages/StorePage.tsx index ec01ec1..bdb4859 100644 --- a/client/src/pages/StorePage.tsx +++ b/client/src/pages/StorePage.tsx @@ -266,32 +266,41 @@ export function StorePage() { {/* KPI达成率 */} - {/* 最新经营概览 + 本周指标进度 一行 */} -
- {/* 经营概览 */} -
-
-

日均经营概览({month})

- {anomalyCount > 0 && ( - {anomalyCount} 项异常 - )} + {/* 最新经营概览 */} +
+
+
+

最新经营日概览

+

+ {card?.target_date ? `${String(card.target_date).substring(0, 10)} 当日数据 vs ${String(card.baseline_date).substring(0, 10)} 一周前同日对比` : `${month} 月末单日 vs 一周前同日对比`} +

- {allAnomalyItems.length === 0 ? ( -

暂无数据

- ) : ( -
- {allAnomalyItems.map((item, idx) => ( -
-

{item.metric}

-

- {item.metric === '实收' ? formatCurrency(item.value) : formatNumber(item.value)} -

-

日基准: {item.metric === '实收' ? formatCurrency(item.baseline) : formatNumber(item.baseline)}{item.is_anomaly && }

-
- ))} -
+ {anomalyCount > 0 && ( + {anomalyCount} 项异常 )}
+ {allAnomalyItems.length === 0 ? ( +

暂无数据

+ ) : ( +
+ {allAnomalyItems.map((item, idx) => { + const isCurrency = item.metric === '实收' || item.metric === '营业收入' || item.metric === '优惠' + return ( +
+

{item.metric}

+

+ {isCurrency ? formatCurrency(item.value) : formatNumber(item.value)} +

+

日基准: {isCurrency ? formatCurrency(item.baseline) : formatNumber(item.baseline)}{item.is_anomaly && }

+
+ ) + })} +
+ )} +
+ + {/* 本周指标进度 */} +
{/* 本周指标进度条 */} {followup && ( @@ -515,7 +524,9 @@ export function StorePage() { {sc && Number(sc.received) > 0 && ( <> {/* 核心指标 - 月度汇总 */} -
+
+ + diff --git a/server/src/routes/analytics-enhanced.ts b/server/src/routes/analytics-enhanced.ts index 0e0c24b..7d7c2c8 100644 --- a/server/src/routes/analytics-enhanced.ts +++ b/server/src/routes/analytics-enhanced.ts @@ -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]) diff --git a/server/src/routes/data.ts b/server/src/routes/data.ts index 8a74eb9..ffc9d23 100644 --- a/server/src/routes/data.ts +++ b/server/src/routes/data.ts @@ -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, diff --git a/server/src/routes/situational-awareness.ts b/server/src/routes/situational-awareness.ts index 992ec18..bfcf23c 100644 --- a/server/src/routes/situational-awareness.ts +++ b/server/src/routes/situational-awareness.ts @@ -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 ( diff --git a/server/src/routes/store-expense.ts b/server/src/routes/store-expense.ts index 2607ec0..04e75fc 100644 --- a/server/src/routes/store-expense.ts +++ b/server/src/routes/store-expense.ts @@ -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, diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index bca8c87..f844f98 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -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) }