726 lines
28 KiB
Python
726 lines
28 KiB
Python
"""Finance service — daily revenue vs cost reconciliation.
|
||
|
||
Architecture:
|
||
- Website revenue: Sub2ApiWebsiteClient._request("/usage/stats", params=...)
|
||
Reuses all existing error handling, logging, auth (api_key / bearer).
|
||
- Upstream cost (Sub2API): UpstreamClient._send_request(url, params=...)
|
||
within the client context — retains cookies, 401 refresh, external API log.
|
||
- Upstream cost (New-API/Nox-API): same UpstreamClient pattern.
|
||
|
||
New service functions (get_or_create_daily_summary, compute_daily_summary,
|
||
overwrite_daily_summary) wrap get_daily_summary with DB-backed snapshot storage.
|
||
Each item fails independently; failures do NOT contribute to totals, and the UI
|
||
must treat any failed item as incomplete accounting data.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
from datetime import date, datetime, timedelta, timezone
|
||
from typing import Any
|
||
|
||
import pytz
|
||
|
||
from sqlalchemy import func
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models.upstream import Upstream
|
||
from app.models.website import Website
|
||
from app.models.snapshot import UpstreamBalanceSnapshot
|
||
from app.models.upstream_recharge_event import UpstreamRechargeEvent
|
||
from app.services.upstream_client import UpstreamClient
|
||
from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
SHANGHAI_TZ = pytz.timezone("Asia/Shanghai")
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Upstream type classification
|
||
# ─────────────────────────────────────────────
|
||
|
||
def classify_upstream(upstream: Upstream) -> str:
|
||
"""Return 'sub2api' | 'new_api' | 'unknown'."""
|
||
prefix = (upstream.api_prefix or "").strip("/")
|
||
if prefix == "api/v1":
|
||
return "sub2api"
|
||
auth = upstream.auth_type or ""
|
||
if auth in ("new_api_token", "nox_token"):
|
||
return "new_api"
|
||
if auth in ("cookie", "login_password"):
|
||
try:
|
||
cfg = json.loads(upstream.auth_config_json or "{}")
|
||
except Exception:
|
||
cfg = {}
|
||
if (
|
||
prefix == ""
|
||
and (
|
||
auth == "cookie"
|
||
or "/api/user/login" in (cfg.get("login_path") or "")
|
||
or cfg.get("provider") in {"new-api", "nox-api"}
|
||
)
|
||
):
|
||
return "new_api"
|
||
return "unknown"
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Date helpers
|
||
# ─────────────────────────────────────────────
|
||
|
||
def yesterday_shanghai() -> date:
|
||
return datetime.now(SHANGHAI_TZ).date() - timedelta(days=1)
|
||
|
||
|
||
def today_shanghai() -> date:
|
||
return datetime.now(SHANGHAI_TZ).date()
|
||
|
||
|
||
def date_to_shanghai_timestamps(d: date) -> tuple[int, int]:
|
||
"""Return (start_unix, end_unix) for 00:00:00..23:59:59 Asia/Shanghai on date d."""
|
||
start = SHANGHAI_TZ.localize(datetime(d.year, d.month, d.day, 0, 0, 0))
|
||
end = SHANGHAI_TZ.localize(datetime(d.year, d.month, d.day, 23, 59, 59))
|
||
return int(start.timestamp()), int(end.timestamp())
|
||
|
||
|
||
def date_to_shanghai_utc_range(d: date) -> tuple[datetime, datetime]:
|
||
"""Return UTC datetimes for [start, next_day_start) of a Shanghai calendar day."""
|
||
start = SHANGHAI_TZ.localize(datetime(d.year, d.month, d.day, 0, 0, 0))
|
||
end = start + timedelta(days=1)
|
||
return start.astimezone(timezone.utc), end.astimezone(timezone.utc)
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Website revenue
|
||
# ─────────────────────────────────────────────
|
||
|
||
def fetch_website_revenue(website: Website, target_date: date) -> tuple[float, str | None]:
|
||
"""Fetch website revenue for target_date.
|
||
|
||
Uses Sub2ApiWebsiteClient (existing auth, error handling, external API log).
|
||
GET /usage/stats?start_date=...&end_date=...&timezone=Asia/Shanghai&nocache=true
|
||
"""
|
||
date_str = target_date.isoformat()
|
||
try:
|
||
with Sub2ApiWebsiteClient(
|
||
base_url=website.base_url,
|
||
api_prefix=website.api_prefix,
|
||
auth_type=website.auth_type,
|
||
auth_config=json.loads(website.auth_config_json or "{}"),
|
||
timeout=float(website.timeout_seconds),
|
||
target_id=website.id,
|
||
target_name=website.name,
|
||
) as c:
|
||
data = c._request(
|
||
"GET",
|
||
"/usage/stats",
|
||
params={
|
||
"start_date": date_str,
|
||
"end_date": date_str,
|
||
"timezone": "Asia/Shanghai",
|
||
"nocache": "true",
|
||
},
|
||
)
|
||
# Sub2API wraps payload in `data` key: {data: {total_actual_cost: ...}}
|
||
payload = data.get("data", data) if isinstance(data, dict) else {}
|
||
if isinstance(payload, dict):
|
||
cost = payload.get("total_actual_cost")
|
||
if cost is not None:
|
||
return float(cost), None
|
||
return 0.0, "响应中找不到 total_actual_cost 字段"
|
||
except WebsiteError as e:
|
||
return 0.0, str(e)
|
||
except Exception as e:
|
||
return 0.0, str(e)
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Upstream cost — Sub2API
|
||
# ─────────────────────────────────────────────
|
||
|
||
def fetch_upstream_cost_sub2api(upstream: Upstream, target_date: date) -> tuple[float, str | None]:
|
||
"""Fetch cost for Sub2API-type upstream via /usage/stats (user-facing endpoint).
|
||
|
||
Calls UpstreamClient._send_request inside the context so that cookies,
|
||
401 refresh, and external API logging are all preserved.
|
||
|
||
Sub2API's end_date is exclusive (+1 day internally), so passing the same
|
||
date for start and end yields the full day's data.
|
||
"""
|
||
date_str = target_date.isoformat()
|
||
auth_config = json.loads(upstream.auth_config_json or "{}")
|
||
try:
|
||
with UpstreamClient(
|
||
base_url=upstream.base_url,
|
||
api_prefix=upstream.api_prefix,
|
||
auth_type=upstream.auth_type,
|
||
auth_config=auth_config,
|
||
timeout=float(upstream.timeout_seconds),
|
||
on_auth_config_update=lambda _: None, # read-only, no DB write
|
||
target_id=upstream.id,
|
||
target_name=upstream.name,
|
||
) as client:
|
||
client.ensure_authenticated()
|
||
params = {
|
||
"start_date": date_str,
|
||
"end_date": date_str,
|
||
"timezone": "Asia/Shanghai",
|
||
}
|
||
resp = client._send_request("GET", client._url("/usage/stats"), params=params)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
payload = data.get("data", data) if isinstance(data, dict) else {}
|
||
if isinstance(payload, dict):
|
||
cost = payload.get("total_actual_cost")
|
||
if cost is not None:
|
||
return float(cost), None
|
||
return 0.0, "响应中找不到 total_actual_cost 字段"
|
||
except Exception as e:
|
||
return 0.0, str(e)
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Upstream cost — New-API / Nox-API
|
||
# ─────────────────────────────────────────────
|
||
|
||
def _new_api_failure_message(data: Any) -> str | None:
|
||
if not isinstance(data, dict):
|
||
return None
|
||
message = data.get("message") or data.get("detail") or data.get("error")
|
||
if data.get("success") is False:
|
||
return str(message or "上游返回 success=false")
|
||
if message and data.get("error"):
|
||
return str(message)
|
||
return None
|
||
|
||
|
||
def fetch_upstream_cost_new_api(upstream: Upstream, target_date: date) -> tuple[float, str | None]:
|
||
"""Fetch cost for New-API/Nox-API upstream via /api/log/self/stat.
|
||
|
||
Keeps UpstreamClient open for the full request so that:
|
||
- login_password token refresh is available if the stat call gets 401
|
||
- cookies are maintained throughout
|
||
- external API call is logged via _send_request → _do_request
|
||
|
||
Timestamp range: 00:00:00 to 23:59:59 Asia/Shanghai (closed interval per
|
||
New-API source: `created_at <= endTimestamp`).
|
||
"""
|
||
start_ts, end_ts = date_to_shanghai_timestamps(target_date)
|
||
auth_config = json.loads(upstream.auth_config_json or "{}")
|
||
try:
|
||
with UpstreamClient(
|
||
base_url=upstream.base_url,
|
||
api_prefix=upstream.api_prefix,
|
||
auth_type=upstream.auth_type,
|
||
auth_config=auth_config,
|
||
timeout=float(upstream.timeout_seconds),
|
||
on_auth_config_update=lambda _: None, # read-only, no DB write
|
||
target_id=upstream.id,
|
||
target_name=upstream.name,
|
||
) as client:
|
||
client.ensure_authenticated()
|
||
quota_per_unit = client._new_api_quota_per_unit()
|
||
params = {"type": 2, "start_timestamp": start_ts, "end_timestamp": end_ts}
|
||
resp = client._send_request("GET", client._url("/api/log/self/stat"), params=params)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
failure_message = _new_api_failure_message(data)
|
||
if failure_message:
|
||
return 0.0, failure_message
|
||
payload = data.get("data", data) if isinstance(data, dict) else {}
|
||
if isinstance(payload, dict):
|
||
quota = payload.get("quota")
|
||
if quota is not None:
|
||
cost = float(quota) / max(quota_per_unit, 1)
|
||
return round(cost, 6), None
|
||
return 0.0, "响应中找不到 quota 字段"
|
||
except Exception as e:
|
||
return 0.0, str(e)
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Balance-delta cost (local balance snapshots)
|
||
# ─────────────────────────────────────────────
|
||
|
||
def fetch_upstream_cost_balance_delta(
|
||
upstream: Upstream,
|
||
target_date: date,
|
||
db: Session,
|
||
) -> tuple[float, str | None]:
|
||
"""Compute daily cost from local balance snapshots for balance_delta upstreams.
|
||
|
||
Algorithm:
|
||
1. Find the most recent snapshot BEFORE the start of target_date (baseline).
|
||
2. Find snapshots DURING target_date and use the last one as ending balance.
|
||
3. Sum manual recharge events for target_date.
|
||
4. cost = baseline + recharge_total - ending. Negative values fail.
|
||
"""
|
||
start_dt, end_dt = date_to_shanghai_utc_range(target_date)
|
||
|
||
# Baseline: latest snapshot before start of target date
|
||
baseline = (
|
||
db.query(UpstreamBalanceSnapshot)
|
||
.filter(
|
||
UpstreamBalanceSnapshot.upstream_id == upstream.id,
|
||
UpstreamBalanceSnapshot.captured_at < start_dt,
|
||
)
|
||
.order_by(UpstreamBalanceSnapshot.captured_at.desc())
|
||
.first()
|
||
)
|
||
|
||
# Intra-day snapshots during target date
|
||
intra_day = (
|
||
db.query(UpstreamBalanceSnapshot)
|
||
.filter(
|
||
UpstreamBalanceSnapshot.upstream_id == upstream.id,
|
||
UpstreamBalanceSnapshot.captured_at >= start_dt,
|
||
UpstreamBalanceSnapshot.captured_at < end_dt,
|
||
)
|
||
.order_by(UpstreamBalanceSnapshot.captured_at.asc())
|
||
.all()
|
||
)
|
||
|
||
if baseline is None:
|
||
return 0.0, "缺少目标日期前的余额基线样本,无法进行差分统计"
|
||
|
||
if not intra_day:
|
||
return 0.0, "目标日期内无余额样本,无法进行差分统计"
|
||
|
||
recharge_total = (
|
||
db.query(func.coalesce(func.sum(UpstreamRechargeEvent.amount), 0.0))
|
||
.filter(
|
||
UpstreamRechargeEvent.upstream_id == upstream.id,
|
||
UpstreamRechargeEvent.recharge_date == target_date,
|
||
)
|
||
.scalar()
|
||
)
|
||
recharge_total = float(recharge_total or 0.0)
|
||
|
||
max_balance_rise = 0.0
|
||
prev = float(baseline.balance)
|
||
for snap in intra_day:
|
||
current = float(snap.balance)
|
||
if current > prev:
|
||
max_balance_rise = max(max_balance_rise, current - prev)
|
||
prev = current
|
||
|
||
if max_balance_rise > 0 and recharge_total <= 0:
|
||
return 0.0, "检测到余额上涨,但当天无充值记录;请补录充值后重新对账"
|
||
|
||
if max_balance_rise > 0 and recharge_total + 1e-9 < max_balance_rise:
|
||
return 0.0, "当天充值总额小于观察到的最大余额上涨,充值金额可能漏填"
|
||
|
||
ending = float(intra_day[-1].balance)
|
||
total = float(baseline.balance) + recharge_total - ending
|
||
if total < -1e-9:
|
||
return 0.0, "余额差分计算为负数,请检查余额样本或补录充值记录"
|
||
|
||
return round(max(total, 0.0), 6), None
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# Orchestration
|
||
# ─────────────────────────────────────────────
|
||
|
||
def get_daily_summary(
|
||
websites: list[Website],
|
||
upstreams: list[Upstream],
|
||
target_date: date,
|
||
db: Session | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Compute daily revenue vs cost summary.
|
||
|
||
Never raises — each website/upstream failure is recorded in the item's
|
||
status/error fields. Failures do NOT contribute to totals (safe default:
|
||
under-count rather than over-count profit).
|
||
"""
|
||
website_items: list[dict[str, Any]] = []
|
||
upstream_items: list[dict[str, Any]] = []
|
||
total_revenue = 0.0
|
||
total_cost = 0.0
|
||
|
||
for w in websites:
|
||
amount, err = fetch_website_revenue(w, target_date)
|
||
item: dict[str, Any] = {
|
||
"id": w.id,
|
||
"name": w.name,
|
||
"upstream_type": "sub2api",
|
||
"amount": round(amount, 6),
|
||
"status": "failed" if err else "success",
|
||
"error": err,
|
||
}
|
||
website_items.append(item)
|
||
if not err:
|
||
total_revenue += amount
|
||
|
||
for u in upstreams:
|
||
cost_mode = getattr(u, "finance_cost_mode", "usage_stats")
|
||
if cost_mode == "balance_delta":
|
||
if db is None:
|
||
amount, err = 0.0, "余额差分统计需要数据库连接"
|
||
else:
|
||
amount, err = fetch_upstream_cost_balance_delta(u, target_date, db)
|
||
utype = classify_upstream(u)
|
||
else:
|
||
utype = classify_upstream(u)
|
||
if utype == "sub2api":
|
||
amount, err = fetch_upstream_cost_sub2api(u, target_date)
|
||
elif utype == "new_api":
|
||
amount, err = fetch_upstream_cost_new_api(u, target_date)
|
||
else:
|
||
amount, err = 0.0, (
|
||
f"未知上游类型(auth_type={u.auth_type}, api_prefix={u.api_prefix}),"
|
||
"无法统计消费。请检查上游配置。"
|
||
)
|
||
|
||
item = {
|
||
"id": u.id,
|
||
"name": u.name,
|
||
"upstream_type": utype,
|
||
"amount": round(amount, 6),
|
||
"status": "failed" if err else "success",
|
||
"error": err,
|
||
}
|
||
upstream_items.append(item)
|
||
if not err:
|
||
total_cost += amount
|
||
|
||
net = total_revenue - total_cost
|
||
margin = (net / total_revenue * 100) if total_revenue > 0 else 0.0
|
||
failed_count = sum(1 for i in website_items + upstream_items if i["status"] == "failed")
|
||
|
||
return {
|
||
"date": target_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,
|
||
}
|
||
|
||
|
||
# ─────────────────────────────────────────────
|
||
# DB-backed snapshot storage
|
||
# ─────────────────────────────────────────────
|
||
|
||
def _truncate_errors(summary: dict[str, Any], max_len: int = 500) -> None:
|
||
"""Truncate error messages in place to keep summary_json from growing unbounded."""
|
||
for item in summary.get("website_items", []) + summary.get("upstream_items", []):
|
||
err = item.get("error")
|
||
if err and len(err) > max_len:
|
||
item["error"] = err[:max_len] + "..."
|
||
|
||
|
||
def _save_summary(db: Session, date_key: date, summary: dict[str, Any]) -> datetime:
|
||
"""Save a computed summary to the database. Returns computed_at."""
|
||
_truncate_errors(summary)
|
||
from app.models.finance_daily_summary import FinanceDailySummary
|
||
now = datetime.now(timezone.utc)
|
||
row = FinanceDailySummary(
|
||
stat_date=date_key,
|
||
summary_json=json.dumps(summary, ensure_ascii=False, default=str),
|
||
success=summary.get("success", False),
|
||
computed_at=now,
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
return now
|
||
|
||
|
||
def compute_daily_summary(
|
||
db: Session,
|
||
target_date: date,
|
||
) -> dict[str, Any]:
|
||
"""Compute a fresh daily summary (no DB write). Used for compare."""
|
||
websites = db.query(Website).filter(Website.enabled == True).all()
|
||
upstreams = db.query(Upstream).filter(Upstream.enabled == True).all()
|
||
return get_daily_summary(websites, upstreams, target_date, db=db)
|
||
|
||
|
||
def _load_summary(row: Any) -> dict[str, Any]:
|
||
"""Load summary dict from a FinanceDailySummary row, adding metadata."""
|
||
data = json.loads(row.summary_json)
|
||
data["computed_at"] = row.computed_at.isoformat()
|
||
data["from_snapshot"] = True
|
||
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,
|
||
) -> dict[str, Any]:
|
||
"""Return stored summary for target_date, or compute + save if missing.
|
||
|
||
If a concurrent request already saved a row between our query and commit,
|
||
we rollback and return the existing row. This prevents duplicate-key errors.
|
||
"""
|
||
from app.models.finance_daily_summary import FinanceDailySummary
|
||
|
||
existing = db.query(FinanceDailySummary).filter(
|
||
FinanceDailySummary.stat_date == target_date
|
||
).first()
|
||
if existing:
|
||
return _load_summary(existing)
|
||
|
||
# Compute and save
|
||
summary = compute_daily_summary(db, target_date)
|
||
try:
|
||
now = _save_summary(db, target_date, summary)
|
||
except Exception:
|
||
db.rollback()
|
||
# Race: another request may have inserted this date. Read again.
|
||
existing = db.query(FinanceDailySummary).filter(
|
||
FinanceDailySummary.stat_date == target_date
|
||
).first()
|
||
if existing:
|
||
return _load_summary(existing)
|
||
# Not a race — real error. Re-raise.
|
||
raise
|
||
|
||
summary["computed_at"] = now.isoformat()
|
||
summary["from_snapshot"] = False
|
||
return summary
|
||
|
||
|
||
def save_summary_if_absent(
|
||
db: Session,
|
||
target_date: date,
|
||
) -> bool:
|
||
"""Compute and save summary for target_date if no snapshot exists yet.
|
||
|
||
Returns True if a new snapshot was saved, False if one already existed.
|
||
Safe against concurrent callers: on IntegrityError rolls back and
|
||
re-checks. Used by the scheduler cron job.
|
||
"""
|
||
from app.models.finance_daily_summary import FinanceDailySummary
|
||
|
||
existing = db.query(FinanceDailySummary).filter(
|
||
FinanceDailySummary.stat_date == target_date
|
||
).first()
|
||
if existing:
|
||
return False
|
||
|
||
summary = compute_daily_summary(db, target_date)
|
||
try:
|
||
_save_summary(db, target_date, summary)
|
||
return True
|
||
except Exception:
|
||
db.rollback()
|
||
# Race: another caller may have inserted this date.
|
||
existing = db.query(FinanceDailySummary).filter(
|
||
FinanceDailySummary.stat_date == target_date
|
||
).first()
|
||
if existing:
|
||
return False
|
||
raise
|
||
|
||
|
||
def overwrite_daily_summary(
|
||
db: Session,
|
||
target_date: date,
|
||
) -> dict[str, Any]:
|
||
"""Recompute and overwrite the stored snapshot for target_date.
|
||
|
||
Used when the user explicitly requests a refresh.
|
||
Deletes any existing row for this date first, then inserts the new one.
|
||
"""
|
||
from app.models.finance_daily_summary import FinanceDailySummary
|
||
|
||
db.query(FinanceDailySummary).filter(
|
||
FinanceDailySummary.stat_date == target_date
|
||
).delete()
|
||
db.flush()
|
||
|
||
summary = compute_daily_summary(db, target_date)
|
||
now = _save_summary(db, target_date, summary)
|
||
summary["computed_at"] = now.isoformat()
|
||
summary["from_snapshot"] = False
|
||
return summary
|