diff --git a/backend/app/routers/finance.py b/backend/app/routers/finance.py index d69fa4b..515c046 100644 --- a/backend/app/routers/finance.py +++ b/backend/app/routers/finance.py @@ -11,7 +11,6 @@ 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, @@ -54,26 +53,6 @@ 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 b304a6a..72c7f5a 100644 --- a/backend/app/services/finance_service.py +++ b/backend/app/services/finance_service.py @@ -449,189 +449,6 @@ 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 006ca71..c9c9e84 100644 --- a/backend/test_finance_daily_summary.py +++ b/backend/test_finance_daily_summary.py @@ -861,156 +861,6 @@ 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 3205ebf..14514ba 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,9 +12,7 @@ "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" @@ -1382,16 +1380,6 @@ "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", @@ -1779,15 +1767,6 @@ "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", @@ -2149,12 +2128,6 @@ "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", @@ -2499,15 +2472,6 @@ "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 60bb22c..39ae5e0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,9 +12,7 @@ "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 c2c1153..6fa3cd0 100644 --- a/frontend/src/views/Finance.vue +++ b/frontend/src/views/Finance.vue @@ -1,228 +1,181 @@