feat(analytics): метрики фазы 1 — классификация, net worth, потоки, расходы, runway
fx_rate_daily получает строку на каждый календарный день: котировки ЦБ тянутся вперёд (и назад до первой), is_carried это помечает, RUB = 1.0 всегда. Дальше любая сумма конвертируется по курсу СВОЕЙ даты, а не сегодняшнему. Net worth восстанавливается назад от текущего account.balance по транзакциям — ZenMoney отдаёт остаток, а не историю; поэтому сегодняшняя строка совпадает с тем, что показывает ZenMoney, а каждая прошлая с ней согласована. Нет курса — не подстановка, а NULL и строка в metric_data_quality. Туда же попадает то, что шаги заметили по дороге: правило без совпадений, счёт без баланса, перевод через границу net worth.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
"""`refresh_all` under concurrency: every step replaces its whole table, so two refreshes
|
||||
must not interleave. The advisory lock makes the second one wait instead of racing (and
|
||||
instead of being skipped — `POST /rules/apply` during a sync has to take effect)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from factories import make_account, make_cbr_rate, make_txn
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.metrics.refresh import refresh_all
|
||||
from fintracker.models import MetricNetWorthDaily
|
||||
|
||||
|
||||
async def _refresh(trigger: str):
|
||||
async with get_sessionmaker()() as session:
|
||||
return await refresh_all(session, trigger=trigger)
|
||||
|
||||
|
||||
async def test_two_concurrent_refreshes_both_succeed(app):
|
||||
t = today_local()
|
||||
card = await make_account(name="Карта", balance="10000")
|
||||
await make_account(name="Валютный", currency="USD", balance="100")
|
||||
await make_cbr_rate(t - timedelta(days=1), "USD", "90")
|
||||
for day in range(1, 6):
|
||||
await make_txn(t - timedelta(days=day), outcome="100", outcome_account_id=card)
|
||||
|
||||
first, second = await asyncio.gather(_refresh("sync:a"), _refresh("rules"))
|
||||
|
||||
assert first.error is None, first.error
|
||||
assert second.error is None, second.error
|
||||
|
||||
# serialised, not interleaved: one refresh finished before the other started
|
||||
assert first.finished_at is not None and second.finished_at is not None
|
||||
assert first.finished_at <= second.started_at or second.finished_at <= first.started_at
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
rows = (
|
||||
await session.execute(select(func.count()).select_from(MetricNetWorthDaily))
|
||||
).scalar_one()
|
||||
days = (
|
||||
await session.execute(select(func.count(func.distinct(MetricNetWorthDaily.d))))
|
||||
).scalar_one()
|
||||
# one row per day of the series, written exactly once
|
||||
assert rows == days == 6
|
||||
Reference in New Issue
Block a user