Files
Deal/archive/leadradar-legacy/backend/app/services/rates.py
T
Rustam Khalimov 713d554dc2 Нормализовать переводы строк в LF
Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF,
CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были
LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения),
.editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize.
Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
2026-09-11 19:01:42 +03:00

131 lines
5.0 KiB
Python

"""Курсы валют: источник ЦБ РФ, 4 запроса в сутки (каждые 6 часов).
По ТЗ до подключения сервиса используются мок-курсы; источник выбирается
в настройках (cbr | mock). Значения хранятся в БД вместе с временем
обновления и выдаются наружу для вкладки «Валюта и курсы».
"""
from __future__ import annotations
import json
import logging
import time
import httpx
from .. import constants as C
from ..db import store
log = logging.getLogger("leadradar.rates")
_FETCH_INTERVAL_MS = 6 * C.HOUR_MS # 4 раза в сутки
def get_rates() -> dict:
row = store.query_one("SELECT rates, source, updated_at FROM rates WHERE id = 1")
rates = json.loads(row["rates"]) if row else dict(C.MOCK_RATES)
return {
"base": "RUB",
"rates": rates,
"source": row["source"] if row else "mock",
"updatedAt": row["updated_at"] if row else None,
}
def save_rates(rates: dict, source: str) -> None:
store.execute(
"INSERT INTO rates(id, rates, source, updated_at) VALUES (1, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET rates = excluded.rates, source = excluded.source, "
"updated_at = excluded.updated_at",
[json.dumps(rates), source, time.time_ns() // 1_000_000],
)
async def fetch_cbr() -> dict | None:
"""Запрос к ЦБ РФ (JSON-зеркало daily_json.js). Возвращает rates к RUB."""
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(C.CBR_URL)
resp.raise_for_status()
payload = resp.json()
rates: dict = {"RUB": 1.0}
for code, item in payload.get("Valute", {}).items():
# 1 единица валюты в рублях: Nominal может быть > 1
nominal = int(item.get("Nominal", 1)) or 1
value = float(item.get("Value", 0))
rates[code] = round(value / nominal, 6)
return rates
except Exception as exc: # noqa: BLE001
log.warning("CBR fetch failed: %s", exc)
return None
async def refresh_rates(force_source: str | None = None) -> bool:
"""Ручное/фоновое обновление. Возвращает True при успехе (или при мок-режиме)."""
source = force_source or store.get_setting("rateSource") or "cbr"
if source == "mock":
save_rates(dict(C.MOCK_RATES), "mock")
recompute_conversions()
return True
rates = await fetch_cbr()
if rates is None:
return False
save_rates(rates, "cbr")
recompute_conversions()
return True
def should_fetch() -> bool:
row = store.query_one("SELECT updated_at, source FROM rates WHERE id = 1")
if not row:
return True
if row["source"] == "mock" and store.get_setting("rateSource") == "cbr":
return True
return time.time_ns() // 1_000_000 - row["updated_at"] >= _FETCH_INTERVAL_MS
def _resolve_rate(rates: dict, code: str) -> float | None:
"""USDT приравниваем к USD (у ЦБ нет тикера USDT)."""
if code == "USDT" and "USD" in rates:
return float(rates["USD"])
val = rates.get(code)
return float(val) if val is not None else None
def convert_amount(amount: float | int | None, from_cur: str, to_cur: str) -> float | None:
"""amount в from_cur -> to_cur по актуальным курсам (к рублю)."""
if amount is None:
return None
rates = json.loads(store.query_one("SELECT rates FROM rates WHERE id = 1")["rates"])
rf = _resolve_rate(rates, from_cur)
rt = _resolve_rate(rates, to_cur)
if rf is None or rt is None:
return None
return round(float(amount) * rf / rt, 2)
def recompute_conversions() -> int:
"""Пересчёт старых карточек по актуальному курсу и целевой валюте.
Пересчитываются карточки на канбане и в «Неразобранном»; архивные,
корзинные и ушедшие в «Выбранные» (taken) не трогаем.
"""
if not store.get_setting("conversionOn"):
return 0
target = store.get_setting("targetCurrency") or "RUB"
rows = store.query(
"SELECT id, budget_from, budget_to, budget_cur FROM leads "
"WHERE budget_cur <> '' AND col NOT IN ('archive','trash','taken')",
)
updated = 0
for r in rows:
cf = convert_amount(r["budget_from"], r["budget_cur"], target)
ct = convert_amount(r["budget_to"] if r["budget_to"] is not None else r["budget_from"], r["budget_cur"], target)
if cf is None:
continue
store.execute(
"UPDATE leads SET conv_from = ?, conv_to = ?, conv_cur = ? WHERE id = ?",
[cf, ct, target, r["id"]],
)
updated += 1
return updated