Reapply "Redesign finance dashboard with period summaries"
This reverts commit da14ec5740.
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user