Решение по 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.
131 lines
5.0 KiB
Python
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
|