Files

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