"""Курсы валют: источник ЦБ РФ, 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