feat: persist finance daily summaries

This commit is contained in:
SmartUp Developer
2026-07-03 17:38:54 +08:00
parent 1a1a663b6a
commit eba209395d
7 changed files with 1115 additions and 47 deletions
+381
View File
@@ -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