Redesign finance dashboard with period summaries

This commit is contained in:
SmartUp Developer
2026-07-03 22:16:05 +08:00
parent 1670503720
commit 8d41956d88
6 changed files with 1207 additions and 801 deletions
+21
View File
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.services.finance_service import ( from app.services.finance_service import (
compute_daily_summary, compute_daily_summary,
get_finance_summary,
get_or_create_daily_summary, get_or_create_daily_summary,
overwrite_daily_summary, overwrite_daily_summary,
today_shanghai, today_shanghai,
@@ -53,6 +54,26 @@ def daily_summary(
return get_or_create_daily_summary(db, target) 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") @router.post("/daily-summary/compare")
def compare_daily_summary( def compare_daily_summary(
date: str | None = Query(None, description="YYYY-MM-DD, defaults to yesterday Asia/Shanghai"), date: str | None = Query(None, description="YYYY-MM-DD, defaults to yesterday Asia/Shanghai"),
+183
View File
@@ -449,6 +449,189 @@ def _load_summary(row: Any) -> dict[str, Any]:
return data 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( def get_or_create_daily_summary(
db: Session, db: Session,
target_date: date, target_date: date,
+150
View File
@@ -861,6 +861,156 @@ def test_save_summary_if_absent_skips_existing(monkeypatch):
db.close() 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): def _make_test_summary(website_items, upstream_items=None):
"""Return a summary dict with deep-copied items to prevent cross-contamination.""" """Return a summary dict with deep-copied items to prevent cross-contamination."""
return { return {
+36
View File
@@ -12,7 +12,9 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"axios-retry": "^4.5.0", "axios-retry": "^4.5.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"echarts": "^6.1.0",
"element-plus": "^2.8.8", "element-plus": "^2.8.8",
"lucide-vue-next": "^0.577.0",
"pinia": "^2.2.6", "pinia": "^2.2.6",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.4.5" "vue-router": "^4.4.5"
@@ -1380,6 +1382,16 @@
"node": ">= 0.4" "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": { "node_modules/element-plus": {
"version": "2.14.0", "version": "2.14.0",
"resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.0.tgz", "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.0.tgz",
@@ -1767,6 +1779,15 @@
"lodash-es": "*" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@@ -2128,6 +2149,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
@@ -2472,6 +2499,15 @@
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true, "dev": true,
"license": "MIT" "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"
}
} }
} }
} }
+2
View File
@@ -12,7 +12,9 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"axios-retry": "^4.5.0", "axios-retry": "^4.5.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"echarts": "^6.1.0",
"element-plus": "^2.8.8", "element-plus": "^2.8.8",
"lucide-vue-next": "^0.577.0",
"pinia": "^2.2.6", "pinia": "^2.2.6",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.4.5" "vue-router": "^4.4.5"
File diff suppressed because it is too large Load Diff