From 8d41956d88309f9328c7b3248cbdfa1a2e5f9a8c Mon Sep 17 00:00:00 2001 From: SmartUp Developer Date: Fri, 3 Jul 2026 22:16:05 +0800 Subject: [PATCH] Redesign finance dashboard with period summaries --- backend/app/routers/finance.py | 21 + backend/app/services/finance_service.py | 183 +++ backend/test_finance_daily_summary.py | 150 +++ frontend/package-lock.json | 36 + frontend/package.json | 2 + frontend/src/views/Finance.vue | 1616 ++++++++++++----------- 6 files changed, 1207 insertions(+), 801 deletions(-) diff --git a/backend/app/routers/finance.py b/backend/app/routers/finance.py index 515c046..d69fa4b 100644 --- a/backend/app/routers/finance.py +++ b/backend/app/routers/finance.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import Session from app.database import get_db from app.services.finance_service import ( compute_daily_summary, + get_finance_summary, get_or_create_daily_summary, overwrite_daily_summary, today_shanghai, @@ -53,6 +54,26 @@ def daily_summary( return get_or_create_daily_summary(db, target) +@router.get("/summary") +def finance_summary( + period: str = Query("day", description="day | week | month"), + date: str | None = Query(None, description="YYYY-MM-DD, defaults to yesterday Asia/Shanghai"), + db: Session = Depends(get_db), + _: Any = Depends(get_current_user), +) -> dict: + """Return finance summary for day/week/month. + + day reuses the existing daily snapshot behavior. week/month aggregate only + stored finance_daily_summaries rows and never call upstream services. + """ + period = period.lower() + if period not in {"day", "week", "month"}: + raise HTTPException(400, f"Invalid period: {period!r}. Expected day, week, or month.") + target = _resolve_date(date) + _validate_not_future(target) + return get_finance_summary(db, period, target) + + @router.post("/daily-summary/compare") def compare_daily_summary( date: str | None = Query(None, description="YYYY-MM-DD, defaults to yesterday Asia/Shanghai"), diff --git a/backend/app/services/finance_service.py b/backend/app/services/finance_service.py index 72c7f5a..b304a6a 100644 --- a/backend/app/services/finance_service.py +++ b/backend/app/services/finance_service.py @@ -449,6 +449,189 @@ def _load_summary(row: Any) -> dict[str, Any]: return data +def _date_range(start: date, end: date) -> list[date]: + """Return every date in [start, end].""" + days = (end - start).days + return [start + timedelta(days=i) for i in range(days + 1)] + + +def _month_end(target_date: date) -> date: + """Return the last day of target_date's natural month.""" + if target_date.month == 12: + next_month = date(target_date.year + 1, 1, 1) + else: + next_month = date(target_date.year, target_date.month + 1, 1) + return next_month - timedelta(days=1) + + +def finance_period_bounds(period: str, target_date: date) -> tuple[date, date]: + """Resolve finance summary period bounds.""" + if period == "day": + return target_date, target_date + if period == "week": + return target_date - timedelta(days=6), target_date + if period == "month": + return date(target_date.year, target_date.month, 1), _month_end(target_date) + raise ValueError(f"unsupported finance period: {period}") + + +def _chart_from_summaries( + dates: list[date], + summaries_by_date: dict[date, dict[str, Any]], +) -> dict[str, list[Any]]: + """Build date-aligned chart series, using zero for dates without snapshots.""" + return { + "labels": [d.isoformat() for d in dates], + "revenue": [ + round(float(summaries_by_date.get(d, {}).get("total_revenue", 0.0) or 0.0), 6) + for d in dates + ], + "cost": [ + round(float(summaries_by_date.get(d, {}).get("total_cost", 0.0) or 0.0), 6) + for d in dates + ], + } + + +def _aggregate_items(rows: list[tuple[date, dict[str, Any]]], side: str) -> list[dict[str, Any]]: + """Aggregate item amounts by (id, upstream_type); any daily failure fails the item.""" + grouped: dict[tuple[int | str, str], dict[str, Any]] = {} + + for row_date, summary in rows: + for item in summary.get(side, []) or []: + ident = item.get("id") + if ident is None: + ident = -1 + upstream_type = item.get("upstream_type") or "" + key = (ident, upstream_type) + aggregate = grouped.setdefault( + key, + { + "id": ident, + "name": item.get("name", ""), + "upstream_type": upstream_type, + "amount": 0.0, + "status": "success", + "error": None, + "_errors": [], + }, + ) + if item.get("name"): + aggregate["name"] = item["name"] + aggregate["amount"] += float(item.get("amount") or 0.0) + if item.get("status") == "failed": + aggregate["status"] = "failed" + error = item.get("error") or "统计失败" + aggregate["_errors"].append(f"{row_date.isoformat()}: {error}") + + result = [] + for item in grouped.values(): + errors = item.pop("_errors") + item["amount"] = round(item["amount"], 6) + item["error"] = ";".join(errors) if errors else None + result.append(item) + + return sorted(result, key=lambda i: (i["status"] != "failed", str(i.get("name") or ""), str(i.get("id")))) + + +def get_stored_period_summary( + db: Session, + period: str, + target_date: date, +) -> dict[str, Any]: + """Aggregate already-stored finance daily snapshots for week/month periods. + + This intentionally does not compute, backfill, or overwrite daily summaries. + Missing dates are reported through partial metadata and charted as zero. + """ + from app.models.finance_daily_summary import FinanceDailySummary + + start_date, end_date = finance_period_bounds(period, target_date) + all_dates = _date_range(start_date, end_date) + rows = ( + db.query(FinanceDailySummary) + .filter( + FinanceDailySummary.stat_date >= start_date, + FinanceDailySummary.stat_date <= end_date, + ) + .order_by(FinanceDailySummary.stat_date.asc()) + .all() + ) + + loaded_rows: list[tuple[date, dict[str, Any]]] = [] + summaries_by_date: dict[date, dict[str, Any]] = {} + for row in rows: + summary = _load_summary(row) + loaded_rows.append((row.stat_date, summary)) + summaries_by_date[row.stat_date] = summary + + missing_dates = [d.isoformat() for d in all_dates if d not in summaries_by_date] + total_revenue = sum(float(s.get("total_revenue", 0.0) or 0.0) for s in summaries_by_date.values()) + total_cost = sum(float(s.get("total_cost", 0.0) or 0.0) for s in summaries_by_date.values()) + net = total_revenue - total_cost + margin = (net / total_revenue * 100) if total_revenue > 0 else 0.0 + website_items = _aggregate_items(loaded_rows, "website_items") + upstream_items = _aggregate_items(loaded_rows, "upstream_items") + failed_count = sum(1 for i in website_items + upstream_items if i["status"] == "failed") + + return { + "period": period, + "date": target_date.isoformat(), + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "total_revenue": round(total_revenue, 6), + "total_cost": round(total_cost, 6), + "net_income": round(net, 6), + "margin_percent": round(margin, 2), + "website_items": website_items, + "upstream_items": upstream_items, + "failed_count": failed_count, + "success": failed_count == 0, + "chart": _chart_from_summaries(all_dates, summaries_by_date), + "missing_dates": missing_dates, + "included_days": len(summaries_by_date), + "partial": len(missing_dates) > 0, + "from_snapshot": True, + } + + +def get_finance_summary( + db: Session, + period: str, + target_date: date, +) -> dict[str, Any]: + """Return day/week/month finance summary with date-aligned chart metadata.""" + if period == "day": + summary = get_or_create_daily_summary(db, target_date) + chart_start = target_date - timedelta(days=6) + chart_dates = _date_range(chart_start, target_date) + + from app.models.finance_daily_summary import FinanceDailySummary + rows = ( + db.query(FinanceDailySummary) + .filter( + FinanceDailySummary.stat_date >= chart_start, + FinanceDailySummary.stat_date <= target_date, + ) + .order_by(FinanceDailySummary.stat_date.asc()) + .all() + ) + summaries_by_date = {row.stat_date: _load_summary(row) for row in rows} + result = dict(summary) + result.update({ + "period": period, + "start_date": target_date.isoformat(), + "end_date": target_date.isoformat(), + "chart": _chart_from_summaries(chart_dates, summaries_by_date), + "missing_dates": [], + "included_days": 1, + "partial": False, + }) + return result + + return get_stored_period_summary(db, period, target_date) + + def get_or_create_daily_summary( db: Session, target_date: date, diff --git a/backend/test_finance_daily_summary.py b/backend/test_finance_daily_summary.py index c9c9e84..006ca71 100644 --- a/backend/test_finance_daily_summary.py +++ b/backend/test_finance_daily_summary.py @@ -861,6 +861,156 @@ def test_save_summary_if_absent_skips_existing(monkeypatch): db.close() +def _insert_finance_summary_row(db, stat_date, summary): + from app.models.finance_daily_summary import FinanceDailySummary + + row = FinanceDailySummary( + stat_date=stat_date, + summary_json=json.dumps(summary, ensure_ascii=False), + success=summary.get("success", False), + ) + db.add(row) + db.commit() + return row + + +def test_week_summary_aggregates_stored_snapshots_only(monkeypatch): + """Week aggregation reads existing DB snapshots without computing missing days.""" + from app.services.finance_service import get_finance_summary + + db = _make_inmemory_db() + _insert_finance_summary_row(db, date(2026, 6, 30), { + "date": "2026-06-30", + "total_revenue": 100.0, + "total_cost": 30.0, + "net_income": 70.0, + "margin_percent": 70.0, + "website_items": [ + {"id": 1, "name": "Site A", "upstream_type": "sub2api", "amount": 100.0, "status": "success", "error": None}, + ], + "upstream_items": [ + {"id": 10, "name": "Up A", "upstream_type": "new_api", "amount": 30.0, "status": "success", "error": None}, + ], + "failed_count": 0, + "success": True, + }) + _insert_finance_summary_row(db, date(2026, 7, 2), { + "date": "2026-07-02", + "total_revenue": 50.0, + "total_cost": 0.0, + "net_income": 50.0, + "margin_percent": 100.0, + "website_items": [ + {"id": 1, "name": "Site A", "upstream_type": "sub2api", "amount": 50.0, "status": "success", "error": None}, + ], + "upstream_items": [ + {"id": 10, "name": "Up A", "upstream_type": "new_api", "amount": 0.0, "status": "failed", "error": "timeout"}, + ], + "failed_count": 1, + "success": False, + }) + + def _should_not_compute(*_args, **_kwargs): + raise AssertionError("week/month summary must not compute daily summaries") + + monkeypatch.setattr("app.services.finance_service.compute_daily_summary", _should_not_compute) + result = get_finance_summary(db, "week", date(2026, 7, 2)) + + assert result["start_date"] == "2026-06-26" + assert result["end_date"] == "2026-07-02" + assert result["included_days"] == 2 + assert result["partial"] is True + assert len(result["missing_dates"]) == 5 + assert result["total_revenue"] == 150.0 + assert result["total_cost"] == 30.0 + assert result["net_income"] == 120.0 + assert result["chart"]["labels"] == [ + "2026-06-26", "2026-06-27", "2026-06-28", "2026-06-29", + "2026-06-30", "2026-07-01", "2026-07-02", + ] + assert result["chart"]["revenue"] == [0.0, 0.0, 0.0, 0.0, 100.0, 0.0, 50.0] + assert result["website_items"][0]["amount"] == 150.0 + assert result["upstream_items"][0]["status"] == "failed" + assert result["upstream_items"][0]["amount"] == 30.0 + assert result["upstream_items"][0]["error"] == "2026-07-02: timeout" + db.close() + + +def test_month_summary_uses_natural_month_and_reports_missing_dates(): + """Month aggregation spans the selected date's calendar month.""" + from app.services.finance_service import get_finance_summary + + db = _make_inmemory_db() + _insert_finance_summary_row(db, date(2026, 6, 1), { + "date": "2026-06-01", + "total_revenue": 10.0, + "total_cost": 4.0, + "net_income": 6.0, + "margin_percent": 60.0, + "website_items": [], + "upstream_items": [], + "failed_count": 0, + "success": True, + }) + _insert_finance_summary_row(db, date(2026, 6, 15), { + "date": "2026-06-15", + "total_revenue": 20.0, + "total_cost": 7.0, + "net_income": 13.0, + "margin_percent": 65.0, + "website_items": [], + "upstream_items": [], + "failed_count": 0, + "success": True, + }) + + result = get_finance_summary(db, "month", date(2026, 6, 15)) + + assert result["start_date"] == "2026-06-01" + assert result["end_date"] == "2026-06-30" + assert result["included_days"] == 2 + assert result["partial"] is True + assert len(result["missing_dates"]) == 28 + assert result["total_revenue"] == 30.0 + assert result["total_cost"] == 11.0 + assert result["net_income"] == 19.0 + assert len(result["chart"]["labels"]) == 30 + db.close() + + +def test_day_summary_adds_seven_day_snapshot_chart(monkeypatch): + """Day summary keeps existing compute-on-miss behavior and adds daily trend data.""" + from app.services.finance_service import get_finance_summary + + db = _make_inmemory_db() + _add_minimal_test_data(db) + _insert_finance_summary_row(db, date(2026, 6, 30), { + "date": "2026-06-30", + "total_revenue": 80.0, + "total_cost": 20.0, + "net_income": 60.0, + "margin_percent": 75.0, + "website_items": [], + "upstream_items": [], + "failed_count": 0, + "success": True, + }) + monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", lambda *_: (100.0, None)) + monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None)) + + result = get_finance_summary(db, "day", date(2026, 7, 2)) + + assert result["total_revenue"] == 100.0 + assert result["included_days"] == 1 + assert result["partial"] is False + assert result["chart"]["labels"] == [ + "2026-06-26", "2026-06-27", "2026-06-28", "2026-06-29", + "2026-06-30", "2026-07-01", "2026-07-02", + ] + assert result["chart"]["revenue"] == [0.0, 0.0, 0.0, 0.0, 80.0, 0.0, 100.0] + db.close() + + def _make_test_summary(website_items, upstream_items=None): """Return a summary dict with deep-copied items to prevent cross-contamination.""" return { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 14514ba..3205ebf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,7 +12,9 @@ "axios": "^1.7.9", "axios-retry": "^4.5.0", "dayjs": "^1.11.13", + "echarts": "^6.1.0", "element-plus": "^2.8.8", + "lucide-vue-next": "^0.577.0", "pinia": "^2.2.6", "vue": "^3.5.13", "vue-router": "^4.4.5" @@ -1380,6 +1382,16 @@ "node": ">= 0.4" } }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, "node_modules/element-plus": { "version": "2.14.0", "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.0.tgz", @@ -1767,6 +1779,15 @@ "lodash-es": "*" } }, + "node_modules/lucide-vue-next": { + "version": "0.577.0", + "resolved": "https://registry.npmmirror.com/lucide-vue-next/-/lucide-vue-next-0.577.0.tgz", + "integrity": "sha512-py05bAfv9SHVJqscbiOnjcnLlEmOffA58a+7XhZuFxrs6txe1E8VoR1ngWGTYO+9aVKABAz8l3ee3PqiQN9QPA==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", @@ -2128,6 +2149,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", @@ -2472,6 +2499,15 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "dev": true, "license": "MIT" + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 39ae5e0..60bb22c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,9 @@ "axios": "^1.7.9", "axios-retry": "^4.5.0", "dayjs": "^1.11.13", + "echarts": "^6.1.0", "element-plus": "^2.8.8", + "lucide-vue-next": "^0.577.0", "pinia": "^2.2.6", "vue": "^3.5.13", "vue-router": "^4.4.5" diff --git a/frontend/src/views/Finance.vue b/frontend/src/views/Finance.vue index 6fa3cd0..c2c1153 100644 --- a/frontend/src/views/Finance.vue +++ b/frontend/src/views/Finance.vue @@ -1,181 +1,228 @@