Redesign finance dashboard with period summaries
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+36
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
+811
-797
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user