feat: persist finance daily summaries
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Tests for finance daily summary service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
from datetime import date
|
||||
from unittest.mock import MagicMock
|
||||
@@ -404,3 +405,383 @@ def test_get_daily_summary_all_success(monkeypatch):
|
||||
assert abs(result["total_cost"] - 40.0) < 1e-6
|
||||
assert abs(result["net_income"] - 60.0) < 1e-6
|
||||
assert abs(result["margin_percent"] - 60.0) < 1e-6
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# DB-backed snapshot tests (FinanceDailySummary)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _make_inmemory_db():
|
||||
"""Create an isolated in-memory SQLite session with all tables."""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.database import Base
|
||||
|
||||
# Import models so they register with Base.metadata
|
||||
from app.models import finance_daily_summary # noqa: F401
|
||||
from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog # noqa: F401
|
||||
from app.models.upstream import Upstream # noqa: F401
|
||||
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(bind=engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
return Session()
|
||||
|
||||
|
||||
def _add_minimal_test_data(db):
|
||||
"""Add one Website and one Upstream so compute_daily_summary has data to iterate."""
|
||||
from app.models.website import Website
|
||||
from app.models.upstream import Upstream
|
||||
|
||||
w = Website(
|
||||
id=1, name="TestSite", base_url="http://t1", api_prefix="/api/v1/admin",
|
||||
auth_type="api_key", auth_config_json="{}", enabled=True,
|
||||
)
|
||||
u = Upstream(
|
||||
id=1, name="TestUp", base_url="http://u1", api_prefix="api/v1",
|
||||
auth_type="bearer", auth_config_json='{"token":"t"}', enabled=True,
|
||||
)
|
||||
db.add_all([w, u])
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_get_or_create_no_existing_computes_and_saves(monkeypatch):
|
||||
"""Missing date triggers compute and save, returning from_snapshot=False."""
|
||||
db = _make_inmemory_db()
|
||||
_add_minimal_test_data(db)
|
||||
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))
|
||||
|
||||
from app.services.finance_service import get_or_create_daily_summary
|
||||
|
||||
result = get_or_create_daily_summary(db, date(2026, 7, 2))
|
||||
|
||||
assert result["from_snapshot"] is False
|
||||
assert abs(result["total_revenue"] - 100.0) < 1e-6
|
||||
assert abs(result["total_cost"] - 40.0) < 1e-6
|
||||
|
||||
# Verify it was saved
|
||||
from app.models.finance_daily_summary import FinanceDailySummary
|
||||
row = db.query(FinanceDailySummary).filter(
|
||||
FinanceDailySummary.stat_date == date(2026, 7, 2)
|
||||
).first()
|
||||
assert row is not None
|
||||
assert row.success is True
|
||||
db.close()
|
||||
|
||||
|
||||
def test_get_or_create_existing_returns_stored(monkeypatch):
|
||||
"""Existing date returns stored data without calling upstream fetchers."""
|
||||
from app.models.finance_daily_summary import FinanceDailySummary
|
||||
from app.services.finance_service import get_or_create_daily_summary
|
||||
|
||||
db = _make_inmemory_db()
|
||||
|
||||
# Pre-save a row
|
||||
stored_data = {
|
||||
"date": "2026-07-02", "total_revenue": 200.0, "total_cost": 80.0,
|
||||
"net_income": 120.0, "margin_percent": 60.0,
|
||||
"website_items": [], "upstream_items": [],
|
||||
"failed_count": 0, "success": True,
|
||||
}
|
||||
row = FinanceDailySummary(
|
||||
stat_date=date(2026, 7, 2),
|
||||
summary_json=json.dumps(stored_data),
|
||||
success=True,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
|
||||
# If it calls fetch_*, the monkeypatched function will raise
|
||||
called = []
|
||||
def _fail(*a, **kw):
|
||||
called.append(True)
|
||||
raise RuntimeError("should not be called")
|
||||
monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", _fail)
|
||||
monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", _fail)
|
||||
|
||||
result = get_or_create_daily_summary(db, date(2026, 7, 2))
|
||||
|
||||
assert result["from_snapshot"] is True
|
||||
assert abs(result["total_revenue"] - 200.0) < 1e-6
|
||||
assert len(called) == 0
|
||||
db.close()
|
||||
|
||||
|
||||
def test_compare_does_not_write(monkeypatch):
|
||||
"""POST /daily-summary/compare must NOT write to the database."""
|
||||
from app.routers.finance import compare_daily_summary
|
||||
|
||||
db = _make_inmemory_db()
|
||||
_add_minimal_test_data(db)
|
||||
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 = compare_daily_summary(date="2026-07-02", db=db, _=None)
|
||||
|
||||
assert result["has_difference"] is True # no stored data
|
||||
assert result["stored"] is None
|
||||
assert abs(result["current"]["total_revenue"] - 100.0) < 1e-6
|
||||
|
||||
# Verify NOT saved
|
||||
from app.models.finance_daily_summary import FinanceDailySummary
|
||||
row = db.query(FinanceDailySummary).filter(
|
||||
FinanceDailySummary.stat_date == date(2026, 7, 2)
|
||||
).first()
|
||||
assert row is None
|
||||
db.close()
|
||||
|
||||
|
||||
def test_overwrite_replaces_existing(monkeypatch):
|
||||
"""Overwrite updates the same date's record."""
|
||||
from app.models.finance_daily_summary import FinanceDailySummary
|
||||
from app.services.finance_service import overwrite_daily_summary
|
||||
|
||||
db = _make_inmemory_db()
|
||||
_add_minimal_test_data(db)
|
||||
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))
|
||||
|
||||
# Save once
|
||||
overwrite_daily_summary(db, date(2026, 7, 2))
|
||||
|
||||
# Change revenue for second save
|
||||
monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", lambda *_: (200.0, None))
|
||||
result2 = overwrite_daily_summary(db, date(2026, 7, 2))
|
||||
|
||||
assert abs(result2["total_revenue"] - 200.0) < 1e-6
|
||||
|
||||
# Only one row
|
||||
rows = db.query(FinanceDailySummary).filter(
|
||||
FinanceDailySummary.stat_date == date(2026, 7, 2)
|
||||
).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].success is True
|
||||
db.close()
|
||||
|
||||
|
||||
def test_error_truncation(monkeypatch):
|
||||
"""Error messages over 500 chars are truncated."""
|
||||
from app.services.finance_service import get_or_create_daily_summary
|
||||
|
||||
db = _make_inmemory_db()
|
||||
_add_minimal_test_data(db)
|
||||
long_err = "x" * 1000
|
||||
monkeypatch.setattr(
|
||||
"app.services.finance_service.fetch_website_revenue",
|
||||
lambda *_: (0.0, long_err),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.finance_service.fetch_upstream_cost_sub2api",
|
||||
lambda *_: (40.0, None),
|
||||
)
|
||||
|
||||
result = get_or_create_daily_summary(db, date(2026, 7, 2))
|
||||
|
||||
err = result["website_items"][0]["error"]
|
||||
assert len(err) == 503 # 500 + "..."
|
||||
assert err.endswith("...")
|
||||
|
||||
from app.models.finance_daily_summary import FinanceDailySummary
|
||||
row = db.query(FinanceDailySummary).filter(
|
||||
FinanceDailySummary.stat_date == date(2026, 7, 2)
|
||||
).first()
|
||||
stored = json.loads(row.summary_json)
|
||||
assert len(stored["website_items"][0]["error"]) == 503
|
||||
db.close()
|
||||
|
||||
|
||||
def test_future_date_rejected():
|
||||
"""Future dates must return 400."""
|
||||
from app.routers.finance import _validate_not_future
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
|
||||
from app.services.finance_service import today_shanghai
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_validate_not_future(today_shanghai() + _dt.timedelta(days=1))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_invalid_date_format_rejected():
|
||||
"""Invalid date format must return 400."""
|
||||
from app.routers.finance import _resolve_date
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_resolve_date("not-a-date")
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_partial_failure_still_saved(monkeypatch):
|
||||
"""Partially failed results are saved, with success=False and failed_count."""
|
||||
from app.services.finance_service import get_or_create_daily_summary
|
||||
|
||||
db = _make_inmemory_db()
|
||||
_add_minimal_test_data(db)
|
||||
|
||||
# Override the first website to fail
|
||||
original_revenue_calls = []
|
||||
def mock_website_revenue(website, _date):
|
||||
original_revenue_calls.append(website.id)
|
||||
if website.id == 1:
|
||||
return (0.0, "timeout")
|
||||
return (50.0, None)
|
||||
|
||||
monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", mock_website_revenue)
|
||||
monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None))
|
||||
|
||||
result = get_or_create_daily_summary(db, date(2026, 7, 2))
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["failed_count"] >= 1
|
||||
|
||||
from app.models.finance_daily_summary import FinanceDailySummary
|
||||
row = db.query(FinanceDailySummary).filter(
|
||||
FinanceDailySummary.stat_date == date(2026, 7, 2)
|
||||
).first()
|
||||
assert row is not None
|
||||
assert row.success is False
|
||||
db.close()
|
||||
|
||||
|
||||
def test_save_summary_if_absent_saves_new(monkeypatch):
|
||||
"""save_summary_if_absent computes and saves when no snapshot exists."""
|
||||
from app.services.finance_service import save_summary_if_absent
|
||||
|
||||
db = _make_inmemory_db()
|
||||
_add_minimal_test_data(db)
|
||||
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))
|
||||
|
||||
saved = save_summary_if_absent(db, date(2026, 7, 2))
|
||||
|
||||
assert saved is True
|
||||
from app.models.finance_daily_summary import FinanceDailySummary
|
||||
row = db.query(FinanceDailySummary).filter(
|
||||
FinanceDailySummary.stat_date == date(2026, 7, 2)
|
||||
).first()
|
||||
assert row is not None
|
||||
db.close()
|
||||
|
||||
|
||||
def test_save_summary_if_absent_skips_existing(monkeypatch):
|
||||
"""save_summary_if_absent returns False and does nothing when snapshot exists."""
|
||||
from app.models.finance_daily_summary import FinanceDailySummary
|
||||
from app.services.finance_service import save_summary_if_absent
|
||||
|
||||
db = _make_inmemory_db()
|
||||
|
||||
# Pre-save a row
|
||||
row = FinanceDailySummary(
|
||||
stat_date=date(2026, 7, 2),
|
||||
summary_json='{"date":"2026-07-02"}',
|
||||
success=True,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
|
||||
# If it tries to compute, the monkeypatched function would raise
|
||||
def _fail(*a, **kw):
|
||||
raise RuntimeError("should not be called")
|
||||
monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", _fail)
|
||||
|
||||
saved = save_summary_if_absent(db, date(2026, 7, 2))
|
||||
|
||||
assert saved is False
|
||||
db.close()
|
||||
|
||||
|
||||
def _make_test_summary(website_items, upstream_items=None):
|
||||
"""Return a summary dict with deep-copied items to prevent cross-contamination."""
|
||||
return {
|
||||
"total_revenue": 100.0, "total_cost": 40.0,
|
||||
"net_income": 60.0, "success": True,
|
||||
"website_items": [dict(it) for it in website_items],
|
||||
"upstream_items": [dict(it) for it in (upstream_items or [
|
||||
{"id": 1, "name": "U1", "upstream_type": "sub2api", "amount": 40.0, "status": "success", "error": None},
|
||||
])],
|
||||
}
|
||||
|
||||
|
||||
def test_has_difference_by_identity():
|
||||
"""_has_difference compares items by (id, upstream_type), not by position."""
|
||||
from app.routers.finance import _has_difference
|
||||
|
||||
items = [
|
||||
{"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 60.0, "status": "success", "error": None},
|
||||
{"id": 2, "name": "B", "upstream_type": "sub2api", "amount": 40.0, "status": "success", "error": None},
|
||||
]
|
||||
stored = _make_test_summary(items)
|
||||
current = _make_test_summary(list(reversed(items)))
|
||||
|
||||
assert _has_difference(stored, current) is False
|
||||
|
||||
# Different amount on same identity — should be different
|
||||
current["website_items"][1]["amount"] = 50.0 # item id=1 amount changed
|
||||
assert _has_difference(stored, current) is True
|
||||
|
||||
# Different item count — should be different
|
||||
current["website_items"].pop()
|
||||
assert _has_difference(stored, current) is True
|
||||
|
||||
|
||||
def test_compute_diff_items_unchanged():
|
||||
"""_compute_diff_items returns empty diff_items when data matches."""
|
||||
from app.routers.finance import _compute_diff_items
|
||||
|
||||
items = [
|
||||
{"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 60.0, "status": "success", "error": None},
|
||||
{"id": 2, "name": "B", "upstream_type": "sub2api", "amount": 40.0, "status": "success", "error": None},
|
||||
]
|
||||
stored = _make_test_summary(items)
|
||||
current = _make_test_summary(list(reversed(items)))
|
||||
|
||||
diff_items, has_diff = _compute_diff_items(stored, current)
|
||||
assert has_diff is False
|
||||
assert diff_items == []
|
||||
|
||||
|
||||
def test_compute_diff_items_amount_change():
|
||||
"""_compute_diff_items returns the changed item when amount differs."""
|
||||
from app.routers.finance import _compute_diff_items
|
||||
|
||||
stored = _make_test_summary([
|
||||
{"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 60.0, "status": "success", "error": None},
|
||||
])
|
||||
current = _make_test_summary([
|
||||
{"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 55.0, "status": "success", "error": None},
|
||||
])
|
||||
|
||||
diff_items, has_diff = _compute_diff_items(stored, current)
|
||||
assert has_diff is True
|
||||
assert len(diff_items) == 1
|
||||
d = diff_items[0]
|
||||
assert d["side"] == "website_items"
|
||||
assert d["id"] == 1
|
||||
assert d["name"] == "A"
|
||||
assert d["old_amount"] == 60.0
|
||||
assert d["new_amount"] == 55.0
|
||||
|
||||
|
||||
def test_compute_diff_items_added_and_removed():
|
||||
"""_compute_diff_items handles items added or removed between snapshots."""
|
||||
from app.routers.finance import _compute_diff_items
|
||||
|
||||
stored = _make_test_summary([
|
||||
{"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 60.0, "status": "success", "error": None},
|
||||
])
|
||||
current = _make_test_summary([
|
||||
{"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 60.0, "status": "success", "error": None},
|
||||
{"id": 2, "name": "B", "upstream_type": "sub2api", "amount": 40.0, "status": "success", "error": None},
|
||||
])
|
||||
|
||||
diff_items, has_diff = _compute_diff_items(stored, current)
|
||||
assert has_diff is True
|
||||
# B is new — find it
|
||||
added = [d for d in diff_items if d["name"] == "B"]
|
||||
assert len(added) == 1
|
||||
assert added[0]["old_amount"] is None
|
||||
assert added[0]["new_amount"] == 40.0
|
||||
|
||||
Reference in New Issue
Block a user