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,71 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_category, make_txn, month_back, refresh
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import MetricSpendingByCategory
|
||||
|
||||
|
||||
async def rows() -> list[MetricSpendingByCategory]:
|
||||
async with get_sessionmaker()() as session:
|
||||
return list((await session.execute(select(MetricSpendingByCategory))).scalars().all())
|
||||
|
||||
|
||||
async def test_root_category_rollup_and_uncategorised_row(app):
|
||||
card = await make_account(balance="0")
|
||||
food = await make_category("Еда")
|
||||
groceries = await make_category("Продукты", parent_id=food)
|
||||
cafe = await make_category("Кафе", parent_id=food)
|
||||
m = month_back(1)
|
||||
await make_txn(
|
||||
m + timedelta(days=1), outcome="700", outcome_account_id=card, primary_category_id=groceries
|
||||
)
|
||||
await make_txn(
|
||||
m + timedelta(days=2), outcome="300", outcome_account_id=card, primary_category_id=cafe
|
||||
)
|
||||
await make_txn(m + timedelta(days=3), outcome="150", outcome_account_id=card)
|
||||
await refresh()
|
||||
|
||||
by_category = {r.category_id: r for r in await rows()}
|
||||
assert by_category[groceries].amount_rub == Decimal("700")
|
||||
assert by_category[groceries].root_category_id == food
|
||||
assert by_category[cafe].root_category_id == food
|
||||
assert by_category[None].amount_rub == Decimal("150")
|
||||
assert by_category[None].root_category_id is None
|
||||
assert sum(r.amount_rub for r in await rows()) == Decimal("1150")
|
||||
assert {r.month for r in await rows()} == {m}
|
||||
|
||||
|
||||
async def test_only_expenses_are_counted(app):
|
||||
card = await make_account(balance="0")
|
||||
savings = await make_account(name="Вклад", balance="0")
|
||||
m = month_back(1)
|
||||
await make_txn(m + timedelta(days=1), income="1000", income_account_id=card)
|
||||
await make_txn(
|
||||
m + timedelta(days=2),
|
||||
income="500",
|
||||
income_account_id=savings,
|
||||
outcome="500",
|
||||
outcome_account_id=card,
|
||||
)
|
||||
await refresh()
|
||||
|
||||
assert await rows() == []
|
||||
|
||||
|
||||
async def test_top_level_category_is_its_own_root(app):
|
||||
card = await make_account(balance="0")
|
||||
transport = await make_category("Транспорт")
|
||||
m = month_back(1)
|
||||
await make_txn(
|
||||
m + timedelta(days=4),
|
||||
outcome="90",
|
||||
outcome_account_id=card,
|
||||
primary_category_id=transport,
|
||||
)
|
||||
await refresh()
|
||||
|
||||
(row,) = await rows()
|
||||
assert (row.category_id, row.root_category_id) == (transport, transport)
|
||||
Reference in New Issue
Block a user