245 lines
9.1 KiB
Python
245 lines
9.1 KiB
Python
"""Finance router — daily revenue vs cost reconciliation with snapshot storage."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as _dt
|
|
import json
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
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,
|
|
yesterday_shanghai,
|
|
)
|
|
from app.utils.auth import get_current_user
|
|
|
|
router = APIRouter(prefix="/api/finance", tags=["finance"])
|
|
|
|
|
|
def _resolve_date(date_str: str | None) -> _dt.date:
|
|
"""Parse optional date string; return yesterday Asia/Shanghai if omitted."""
|
|
if date_str:
|
|
try:
|
|
return _dt.date.fromisoformat(date_str)
|
|
except ValueError:
|
|
raise HTTPException(400, f"Invalid date format: {date_str!r}. Expected YYYY-MM-DD.")
|
|
return yesterday_shanghai()
|
|
|
|
|
|
def _validate_not_future(target: _dt.date) -> None:
|
|
"""Reject future dates — we can't save snapshots for days that haven't happened."""
|
|
if target > today_shanghai():
|
|
raise HTTPException(400, f"Cannot query future date: {target.isoformat()}")
|
|
|
|
|
|
@router.get("/daily-summary")
|
|
def daily_summary(
|
|
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 stored daily summary snapshot.
|
|
|
|
If no snapshot exists for the requested date, computes one on the fly,
|
|
saves it, and returns it. Never raises for individual item failures.
|
|
"""
|
|
target = _resolve_date(date)
|
|
_validate_not_future(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")
|
|
def compare_daily_summary(
|
|
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:
|
|
"""Compare stored snapshot against a fresh real-time computation.
|
|
|
|
Returns {stored, current, has_difference, diff_items}. Does NOT write to DB.
|
|
"""
|
|
target = _resolve_date(date)
|
|
_validate_not_future(target)
|
|
|
|
from app.models.finance_daily_summary import FinanceDailySummary
|
|
|
|
stored_row = db.query(FinanceDailySummary).filter(
|
|
FinanceDailySummary.stat_date == target
|
|
).first()
|
|
|
|
current = compute_daily_summary(db, target)
|
|
|
|
if stored_row:
|
|
stored_data = json.loads(stored_row.summary_json)
|
|
stored_data["computed_at"] = stored_row.computed_at.isoformat()
|
|
stored_data["from_snapshot"] = True
|
|
diff_items, has_diff = _compute_diff_items(stored_data, current)
|
|
else:
|
|
stored_data = None
|
|
diff_items = []
|
|
has_diff = True # no stored data = definitely different
|
|
|
|
return {
|
|
"stored": stored_data,
|
|
"current": current,
|
|
"has_difference": has_diff,
|
|
"diff_items": diff_items,
|
|
}
|
|
|
|
|
|
@router.post("/daily-summary/overwrite")
|
|
def overwrite_daily_summary_endpoint(
|
|
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:
|
|
"""Recompute and overwrite the stored snapshot for the requested date."""
|
|
target = _resolve_date(date)
|
|
_validate_not_future(target)
|
|
return overwrite_daily_summary(db, target)
|
|
|
|
|
|
def _has_difference(stored: dict, current: dict) -> bool:
|
|
"""Compare stored vs current summary at the total and item level.
|
|
|
|
Items are compared by (id, upstream_type) identity rather than by array
|
|
position, so that a replaced website/upstream doesn't falsely appear
|
|
identical, and reordered items don't falsely appear different.
|
|
"""
|
|
for key in ("total_revenue", "total_cost", "net_income", "success"):
|
|
if stored.get(key) != current.get(key):
|
|
return True
|
|
for side in ("website_items", "upstream_items"):
|
|
s_list = stored.get(side, [])
|
|
c_list = current.get(side, [])
|
|
if len(s_list) != len(c_list):
|
|
return True
|
|
# Build lookup map by (id, upstream_type)
|
|
s_map = {(_item_id(s), s.get("upstream_type")): s for s in s_list}
|
|
c_map = {(_item_id(c), c.get("upstream_type")): c for c in c_list}
|
|
# Check that keys match exactly
|
|
if s_map.keys() != c_map.keys():
|
|
return True
|
|
# Compare each item by its stable identity
|
|
for key_ident, s_item in s_map.items():
|
|
c_item = c_map[key_ident]
|
|
for attr in ("status", "amount", "error"):
|
|
if s_item.get(attr) != c_item.get(attr):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _item_id(item: dict) -> int | str:
|
|
"""Return item id, defaulting to a sentinel for items that lack one."""
|
|
v = item.get("id")
|
|
return v if v is not None else -1
|
|
|
|
|
|
def _compute_diff_items(stored: dict, current: dict) -> tuple[list[dict], bool]:
|
|
"""Compare stored vs current and return (diff_items, has_difference).
|
|
|
|
diff_items is a list of changes at the item level, each with:
|
|
side, name, id, upstream_type, old_{status,amount,error}, new_{status,amount,error}.
|
|
|
|
has_difference also covers total-level changes and count mismatches.
|
|
"""
|
|
diff_items: list[dict] = []
|
|
has_diff = False
|
|
|
|
# Total-level checks
|
|
for key in ("total_revenue", "total_cost", "net_income", "success"):
|
|
if stored.get(key) != current.get(key):
|
|
has_diff = True
|
|
|
|
# Item-level checks
|
|
for side in ("website_items", "upstream_items"):
|
|
s_list = stored.get(side, [])
|
|
c_list = current.get(side, [])
|
|
if len(s_list) != len(c_list):
|
|
has_diff = True
|
|
s_map = {(_item_id(s), s.get("upstream_type")): s for s in s_list}
|
|
c_map = {(_item_id(c), c.get("upstream_type")): c for c in c_list}
|
|
if s_map.keys() != c_map.keys():
|
|
has_diff = True
|
|
# Items in both sides — compare attributes
|
|
for key_ident, s_item in s_map.items():
|
|
c_item = c_map.get(key_ident)
|
|
if c_item is None:
|
|
continue # already counted as key mismatch
|
|
for attr in ("status", "amount", "error"):
|
|
if s_item.get(attr) != c_item.get(attr):
|
|
has_diff = True
|
|
diff_items.append({
|
|
"side": side,
|
|
"id": _item_id(s_item),
|
|
"name": s_item.get("name", ""),
|
|
"upstream_type": s_item.get("upstream_type", ""),
|
|
"old_status": s_item.get("status"),
|
|
"new_status": c_item.get("status"),
|
|
"old_amount": s_item.get("amount"),
|
|
"new_amount": c_item.get("amount"),
|
|
"old_error": s_item.get("error"),
|
|
"new_error": c_item.get("error"),
|
|
})
|
|
break # one diff entry per item
|
|
# Items only in stored (removed)
|
|
for key_ident in s_map.keys() - c_map.keys():
|
|
has_diff = True
|
|
s_item = s_map[key_ident]
|
|
diff_items.append({
|
|
"side": side,
|
|
"id": _item_id(s_item),
|
|
"name": s_item.get("name", ""),
|
|
"upstream_type": s_item.get("upstream_type", ""),
|
|
"old_status": s_item.get("status"),
|
|
"new_status": None,
|
|
"old_amount": s_item.get("amount"),
|
|
"new_amount": None,
|
|
"old_error": s_item.get("error"),
|
|
"new_error": None,
|
|
})
|
|
# Items only in current (added)
|
|
for key_ident in c_map.keys() - s_map.keys():
|
|
has_diff = True
|
|
c_item = c_map[key_ident]
|
|
diff_items.append({
|
|
"side": side,
|
|
"id": _item_id(c_item),
|
|
"name": c_item.get("name", ""),
|
|
"upstream_type": c_item.get("upstream_type", ""),
|
|
"old_status": None,
|
|
"new_status": c_item.get("status"),
|
|
"old_amount": None,
|
|
"new_amount": c_item.get("amount"),
|
|
"old_error": None,
|
|
"new_error": c_item.get("error"),
|
|
})
|
|
|
|
return diff_items, has_diff
|