fix finance balance delta recharges
This commit is contained in:
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import datetime as _dt
|
||||
import json
|
||||
from datetime import date
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
@@ -13,6 +14,7 @@ from app.services.finance_service import (
|
||||
date_to_shanghai_timestamps,
|
||||
fetch_upstream_cost_sub2api,
|
||||
fetch_upstream_cost_new_api,
|
||||
fetch_upstream_cost_balance_delta,
|
||||
fetch_website_revenue,
|
||||
get_daily_summary,
|
||||
)
|
||||
@@ -31,6 +33,7 @@ class FakeUpstream:
|
||||
self.auth_type = kw.get("auth_type", "bearer")
|
||||
self.auth_config_json = kw.get("auth_config_json", json.dumps({"token": "tok"}))
|
||||
self.timeout_seconds = kw.get("timeout_seconds", 30)
|
||||
self.finance_cost_mode = kw.get("finance_cost_mode", "usage_stats")
|
||||
self.enabled = True
|
||||
|
||||
|
||||
@@ -407,6 +410,163 @@ def test_get_daily_summary_all_success(monkeypatch):
|
||||
assert abs(result["margin_percent"] - 60.0) < 1e-6
|
||||
|
||||
|
||||
def _add_balance_snapshot(db, upstream_id: int, balance: float, captured_at: datetime):
|
||||
from app.models.snapshot import UpstreamBalanceSnapshot
|
||||
|
||||
row = UpstreamBalanceSnapshot(
|
||||
upstream_id=upstream_id,
|
||||
balance=balance,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return row
|
||||
|
||||
|
||||
def _add_recharge_event(db, upstream_id: int, recharge_date: date, amount: float, note: str = ""):
|
||||
from app.models.upstream_recharge_event import UpstreamRechargeEvent
|
||||
|
||||
row = UpstreamRechargeEvent(
|
||||
upstream_id=upstream_id,
|
||||
recharge_date=recharge_date,
|
||||
amount=amount,
|
||||
note=note,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return row
|
||||
|
||||
|
||||
def test_balance_delta_cost_uses_manual_recharge_and_ending_balance():
|
||||
"""Balance-delta cost is baseline + recharge_total - ending."""
|
||||
db = _make_inmemory_db()
|
||||
u = FakeUpstream(id=1, finance_cost_mode="balance_delta")
|
||||
|
||||
_add_balance_snapshot(db, 1, 100.0, datetime(2026, 7, 1, 15, 55, tzinfo=timezone.utc))
|
||||
_add_balance_snapshot(db, 1, 90.0, datetime(2026, 7, 1, 16, 30, tzinfo=timezone.utc))
|
||||
_add_balance_snapshot(db, 1, 110.0, datetime(2026, 7, 1, 18, 0, tzinfo=timezone.utc))
|
||||
_add_balance_snapshot(db, 1, 80.0, datetime(2026, 7, 2, 15, 59, tzinfo=timezone.utc))
|
||||
_add_recharge_event(db, 1, date(2026, 7, 2), 20.0)
|
||||
|
||||
amount, err = fetch_upstream_cost_balance_delta(u, date(2026, 7, 2), db)
|
||||
|
||||
assert err is None
|
||||
assert abs(amount - 40.0) < 1e-6
|
||||
db.close()
|
||||
|
||||
|
||||
def test_balance_delta_balance_rise_without_recharge_fails():
|
||||
db = _make_inmemory_db()
|
||||
u = FakeUpstream(id=1, finance_cost_mode="balance_delta")
|
||||
_add_balance_snapshot(db, 1, 100.0, datetime(2026, 7, 1, 15, 55, tzinfo=timezone.utc))
|
||||
_add_balance_snapshot(db, 1, 120.0, datetime(2026, 7, 1, 16, 30, tzinfo=timezone.utc))
|
||||
|
||||
amount, err = fetch_upstream_cost_balance_delta(u, date(2026, 7, 2), db)
|
||||
|
||||
assert amount == 0.0
|
||||
assert "无充值记录" in err
|
||||
db.close()
|
||||
|
||||
|
||||
def test_balance_delta_recharge_less_than_observed_rise_fails():
|
||||
db = _make_inmemory_db()
|
||||
u = FakeUpstream(id=1, finance_cost_mode="balance_delta")
|
||||
_add_balance_snapshot(db, 1, 100.0, datetime(2026, 7, 1, 15, 55, tzinfo=timezone.utc))
|
||||
_add_balance_snapshot(db, 1, 130.0, datetime(2026, 7, 1, 16, 30, tzinfo=timezone.utc))
|
||||
_add_recharge_event(db, 1, date(2026, 7, 2), 20.0)
|
||||
|
||||
amount, err = fetch_upstream_cost_balance_delta(u, date(2026, 7, 2), db)
|
||||
|
||||
assert amount == 0.0
|
||||
assert "充值金额可能漏填" in err
|
||||
db.close()
|
||||
|
||||
|
||||
def test_balance_delta_missing_baseline_fails():
|
||||
"""Balance-delta requires a pre-day baseline sample."""
|
||||
db = _make_inmemory_db()
|
||||
u = FakeUpstream(id=1, finance_cost_mode="balance_delta")
|
||||
_add_balance_snapshot(db, 1, 90.0, datetime(2026, 7, 1, 16, 30, tzinfo=timezone.utc))
|
||||
|
||||
amount, err = fetch_upstream_cost_balance_delta(u, date(2026, 7, 2), db)
|
||||
|
||||
assert amount == 0.0
|
||||
assert "基线样本" in err
|
||||
db.close()
|
||||
|
||||
|
||||
def test_get_daily_summary_balance_delta_failure_excluded_from_total():
|
||||
"""Incomplete balance-delta data must not be counted as zero-cost success."""
|
||||
db = _make_inmemory_db()
|
||||
u = FakeUpstream(id=1, api_prefix="api/v1", auth_type="bearer", finance_cost_mode="balance_delta")
|
||||
|
||||
result = get_daily_summary([], [u], date(2026, 7, 2), db=db)
|
||||
|
||||
assert result["failed_count"] == 1
|
||||
assert result["total_cost"] == 0.0
|
||||
assert result["upstream_items"][0]["status"] == "failed"
|
||||
assert "基线样本" in result["upstream_items"][0]["error"]
|
||||
db.close()
|
||||
|
||||
|
||||
def test_upstream_recharge_crud_api_validates_and_scopes_to_upstream():
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.database import get_db
|
||||
from app.main import app
|
||||
from app.models.upstream import Upstream
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
db = _make_inmemory_db()
|
||||
db.add_all([
|
||||
Upstream(id=1, name="U1", base_url="http://u1", api_prefix="api/v1", auth_type="bearer", auth_config_json="{}"),
|
||||
Upstream(id=2, name="U2", base_url="http://u2", api_prefix="api/v1", auth_type="bearer", auth_config_json="{}"),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[get_current_user] = lambda: None
|
||||
try:
|
||||
client = TestClient(app)
|
||||
|
||||
bad_amount = client.post("/api/upstreams/1/recharges", json={"date": "2026-07-02", "amount": 0})
|
||||
assert bad_amount.status_code == 422
|
||||
|
||||
future = client.post("/api/upstreams/1/recharges", json={"date": "2999-01-01", "amount": 10})
|
||||
assert future.status_code == 422
|
||||
|
||||
created = client.post(
|
||||
"/api/upstreams/1/recharges",
|
||||
json={"date": "2026-07-02", "amount": 50.5, "note": "manual"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
body = created.json()
|
||||
rid = body["id"]
|
||||
assert body["date"] == "2026-07-02"
|
||||
assert body["amount"] == 50.5
|
||||
|
||||
listed = client.get("/api/upstreams/1/recharges", params={"date": "2026-07-02"})
|
||||
assert listed.status_code == 200
|
||||
assert [row["id"] for row in listed.json()] == [rid]
|
||||
|
||||
wrong_upstream = client.put(f"/api/upstreams/2/recharges/{rid}", json={"amount": 60})
|
||||
assert wrong_upstream.status_code == 404
|
||||
|
||||
updated = client.put(f"/api/upstreams/1/recharges/{rid}", json={"amount": 60, "note": "fixed"})
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["amount"] == 60
|
||||
assert updated.json()["note"] == "fixed"
|
||||
|
||||
deleted = client.delete(f"/api/upstreams/1/recharges/{rid}")
|
||||
assert deleted.status_code == 204
|
||||
|
||||
empty = client.get("/api/upstreams/1/recharges", params={"date": "2026-07-02"})
|
||||
assert empty.json() == []
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
db.close()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# DB-backed snapshot tests (FinanceDailySummary)
|
||||
# ─────────────────────────────────────────────
|
||||
@@ -415,14 +575,21 @@ 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 sqlalchemy.pool import StaticPool
|
||||
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 import snapshot # noqa: F401
|
||||
from app.models import upstream_recharge_event # 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})
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
return Session()
|
||||
|
||||
Reference in New Issue
Block a user