Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
"""Курсы валют: источник ЦБ РФ, 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
|
||||
Reference in New Issue
Block a user