Merge pull request 'feat(mera): макро-ряды ЦБ РФ — ипотека по субъектам и ключевая ставка' (#3427) from feat/mera-cbr-macro into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m13s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 1m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m13s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 1m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
This commit is contained in:
commit
3bd30aab87
8 changed files with 1129 additions and 0 deletions
464
tradein-mvp/backend/app/services/cbr_macro.py
Normal file
464
tradein-mvp/backend/app/services/cbr_macro.py
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
"""Макро-ряды ЦБ РФ: ипотека по субъектам (XLSX) + ключевая ставка (SOAP).
|
||||||
|
|
||||||
|
CONTEXT: продукту нужен региональный макро-контекст ипотечного рынка (динамика ставок,
|
||||||
|
объёмов выдач и задолженности по субъектам РФ) и дневная ключевая ставка. Этот модуль
|
||||||
|
только собирает данные в cbr_mortgage_series / cbr_key_rate (292_cbr_macro_series.sql) —
|
||||||
|
подключение к estimator вынесено в отдельное продуктовое решение (out of scope).
|
||||||
|
|
||||||
|
ИСТОЧНИК (ипотека): три XLSX-дашборда cbr.ru, wide-формат (территория × месяц),
|
||||||
|
строка 1 пустая, строка 2 — заголовок-описание, строка 3 — шапка периодов
|
||||||
|
(«Январь 2019», «Февраль 2019», …), строки 4+ — территории в колонке A
|
||||||
|
(РФ целиком → федеральные округа → субъекты). Unpivot в длинный ряд
|
||||||
|
(region, period_month, series, value).
|
||||||
|
|
||||||
|
ИСТОЧНИК (ключевая ставка): POST https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx,
|
||||||
|
SOAP 1.1 (Content-Type: text/xml; charset=utf-8, SOAPAction: http://web.cbr.ru/KeyRate),
|
||||||
|
метод KeyRate(fromDate, ToDate). Ответ — DataSet-XML со строками
|
||||||
|
<KR><DT>дата</DT><Rate>значение</Rate></KR> (namespace-агностичный парсинг: берём по
|
||||||
|
локальному имени тега, т.к. .NET DataSet оборачивает их в diffgram/reset-default-ns).
|
||||||
|
|
||||||
|
TLS: cbr.ru отдаёт RU-сертификат → verify=False (open data, без auth/PII).
|
||||||
|
psycopg v3: CAST(:x AS type), НИКОГДА :x::type.
|
||||||
|
|
||||||
|
Дизайн-инвариант (как domrf_kapremont_loader): сервис-функции НЕ коммитят — коммитит
|
||||||
|
caller (app/tasks/cbr_macro_pull.py). Изоляция сбоев — try/except на серию,
|
||||||
|
db.rollback() в except (иначе аборченная транзакция каскадит на следующие серии).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import openpyxl
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DOWNLOAD_TIMEOUT_SEC = 60.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ипотека по субъектам — конфиг серий
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MORTGAGE_BASE_URL = "https://www.cbr.ru/vfs/statistics/BankSector/Mortgage"
|
||||||
|
NEW_LOANS_URL = f"{_MORTGAGE_BASE_URL}/02_11_New_loans_mortgage.xlsx"
|
||||||
|
RATES_URL = f"{_MORTGAGE_BASE_URL}/02_13_Rates_mortgage.xlsx"
|
||||||
|
DEBT_URL = f"{_MORTGAGE_BASE_URL}/02_14_Debt_mortgage.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CbrMortgageSeries:
|
||||||
|
slug: str
|
||||||
|
url: str
|
||||||
|
sheet: str
|
||||||
|
|
||||||
|
|
||||||
|
# По одной серии на файл (лист «в рублях» — основной ряд каждого дашборда).
|
||||||
|
CBR_MORTGAGE_SERIES: list[CbrMortgageSeries] = [
|
||||||
|
CbrMortgageSeries("new_loans_rub", NEW_LOANS_URL, "в рублях"),
|
||||||
|
CbrMortgageSeries("rate_rub", RATES_URL, "ставка в рублях"),
|
||||||
|
CbrMortgageSeries("debt_rub", DEBT_URL, "в рублях"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class CbrMortgageRow:
|
||||||
|
region: str
|
||||||
|
period_month: date
|
||||||
|
series: str
|
||||||
|
value: float
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Период: русские имена месяцев → date(y, m, 1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
RU_MONTHS: dict[str, int] = {
|
||||||
|
"Январь": 1,
|
||||||
|
"Февраль": 2,
|
||||||
|
"Март": 3,
|
||||||
|
"Апрель": 4,
|
||||||
|
"Май": 5,
|
||||||
|
"Июнь": 6,
|
||||||
|
"Июль": 7,
|
||||||
|
"Август": 8,
|
||||||
|
"Сентябрь": 9,
|
||||||
|
"Октябрь": 10,
|
||||||
|
"Ноябрь": 11,
|
||||||
|
"Декабрь": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ru_period(period_str: str) -> date:
|
||||||
|
"""Разобрать шапку периода «Январь 2019» → date(2019, 1, 1)."""
|
||||||
|
parts = str(period_str).strip().split()
|
||||||
|
if len(parts) != 2:
|
||||||
|
raise ValueError(f"cbr_macro: не удалось разобрать период {period_str!r}")
|
||||||
|
month_name, year_str = parts
|
||||||
|
month = RU_MONTHS.get(month_name)
|
||||||
|
if month is None:
|
||||||
|
raise ValueError(f"cbr_macro: неизвестное имя месяца {month_name!r} в {period_str!r}")
|
||||||
|
try:
|
||||||
|
year = int(year_str)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"cbr_macro: неверный год в периоде {period_str!r}") from exc
|
||||||
|
return date(year, month, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_period_cell(cell: object) -> date:
|
||||||
|
"""Разобрать ячейку шапки периода в первое число месяца.
|
||||||
|
|
||||||
|
ЦБ отдаёт шапку в двух формах, зависящих от файла:
|
||||||
|
* текст «Январь 2019» — 02_11_New_loans_mortgage, 02_13_Rates_mortgage;
|
||||||
|
* настоящий datetime/date — 02_14_Debt_mortgage.
|
||||||
|
Обе приводятся к date(y, m, 1).
|
||||||
|
"""
|
||||||
|
if isinstance(cell, datetime):
|
||||||
|
return date(cell.year, cell.month, 1)
|
||||||
|
if isinstance(cell, date):
|
||||||
|
return date(cell.year, cell.month, 1)
|
||||||
|
return parse_ru_period(str(cell))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unpivot листа (чистая функция — тестируется без сети/файла)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
HEADER_SCAN_ROWS = 6
|
||||||
|
"""Сколько первых строк просматривать в поисках шапки периодов."""
|
||||||
|
|
||||||
|
MIN_HEADER_PERIODS = 6
|
||||||
|
"""Минимум распознанных периодов, чтобы считать строку шапкой (а не данными)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _locate_header_row(rows: list[tuple[object, ...]]) -> tuple[int, dict[int, date]]:
|
||||||
|
"""Найти строку шапки периодов и колонки-периоды в ней.
|
||||||
|
|
||||||
|
Раскладка у файлов ЦБ РАЗНАЯ и жёстко фиксировать индекс строки нельзя:
|
||||||
|
* 02_11 / 02_13 — строка 1 пустая, строка 2 — описание, шапка в строке 3 (idx 2);
|
||||||
|
* 02_14 (задолженность) — строка 1 описание, шапка уже в строке 2 (idx 1),
|
||||||
|
и периоды там datetime, а не «Январь 2019».
|
||||||
|
Ранее индекс был захардкожен на idx 2, из-за чего серия задолженности молча
|
||||||
|
давала ноль строк. Поэтому шапку ищем: берём строку с наибольшим числом
|
||||||
|
распознанных периодов среди первых HEADER_SCAN_ROWS.
|
||||||
|
"""
|
||||||
|
best_idx = -1
|
||||||
|
best_periods: dict[int, date] = {}
|
||||||
|
for row_idx, row in enumerate(rows[:HEADER_SCAN_ROWS]):
|
||||||
|
periods: dict[int, date] = {}
|
||||||
|
for col_idx, cell in enumerate(row):
|
||||||
|
if col_idx == 0 or cell is None or str(cell).strip() == "":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
periods[col_idx] = parse_period_cell(cell)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if len(periods) > len(best_periods):
|
||||||
|
best_idx, best_periods = row_idx, periods
|
||||||
|
if len(best_periods) < MIN_HEADER_PERIODS:
|
||||||
|
return -1, {}
|
||||||
|
return best_idx, best_periods
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mortgage_sheet(
|
||||||
|
rows: list[tuple[object, ...]], *, series_slug: str
|
||||||
|
) -> list[CbrMortgageRow]:
|
||||||
|
"""Unpivot wide-листа ЦБ (территория × месяц) в длинный ряд.
|
||||||
|
|
||||||
|
rows — результат ws.iter_rows(values_only=True). Строка шапки периодов ищется
|
||||||
|
динамически (`_locate_header_row`), территории идут сразу после неё.
|
||||||
|
"""
|
||||||
|
if len(rows) < 3:
|
||||||
|
return []
|
||||||
|
|
||||||
|
header_idx, periods = _locate_header_row(rows)
|
||||||
|
if header_idx < 0:
|
||||||
|
logger.warning(
|
||||||
|
"cbr_macro: шапка периодов не найдена для series=%s — лист пропущен", series_slug
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
out: list[CbrMortgageRow] = []
|
||||||
|
for data_row in rows[header_idx + 1 :]:
|
||||||
|
if not data_row or data_row[0] is None:
|
||||||
|
continue
|
||||||
|
region = str(data_row[0]).strip()
|
||||||
|
if not region:
|
||||||
|
continue
|
||||||
|
for col_idx, period_month in periods.items():
|
||||||
|
if col_idx >= len(data_row):
|
||||||
|
continue
|
||||||
|
raw_value = data_row[col_idx]
|
||||||
|
if raw_value is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
value = float(raw_value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning(
|
||||||
|
"cbr_macro: пропуск ячейки region=%r period=%s series=%s — не число: %r",
|
||||||
|
region,
|
||||||
|
period_month,
|
||||||
|
series_slug,
|
||||||
|
raw_value,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
CbrMortgageRow(
|
||||||
|
region=region, period_month=period_month, series=series_slug, value=value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Загрузка XLSX + upsert
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_UPSERT_MORTGAGE_SQL = text("""
|
||||||
|
INSERT INTO cbr_mortgage_series (region, period_month, series, value, source, fetched_at)
|
||||||
|
VALUES (
|
||||||
|
CAST(:region AS text),
|
||||||
|
CAST(:period_month AS date),
|
||||||
|
CAST(:series AS text),
|
||||||
|
CAST(:value AS double precision),
|
||||||
|
'cbr',
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (region, period_month, series)
|
||||||
|
DO UPDATE SET
|
||||||
|
value = EXCLUDED.value
|
||||||
|
-- fetched_at НЕ трогаем (#2846 у sber_price_index): означает
|
||||||
|
-- «когда мы ВПЕРВЫЕ увидели этот период», а не время последней загрузки.
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_mortgage_rows(db: Session, rows: list[CbrMortgageRow]) -> int:
|
||||||
|
for row in rows:
|
||||||
|
db.execute(
|
||||||
|
_UPSERT_MORTGAGE_SQL,
|
||||||
|
{
|
||||||
|
"region": row.region,
|
||||||
|
"period_month": row.period_month.isoformat(),
|
||||||
|
"series": row.series,
|
||||||
|
"value": row.value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _download(url: str, *, client: httpx.Client) -> bytes:
|
||||||
|
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
def load_cbr_mortgage_series(
|
||||||
|
db: Session,
|
||||||
|
series: CbrMortgageSeries,
|
||||||
|
*,
|
||||||
|
client: httpx.Client | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Скачать один XLSX-дашборд, разобрать один лист, upsert-нуть ряд.
|
||||||
|
|
||||||
|
Не коммитит — коммитит caller.
|
||||||
|
"""
|
||||||
|
own_client = client is None
|
||||||
|
if own_client:
|
||||||
|
# cbr.ru отдаёт RU-сертификат НУЦ Минцифры → verify=False. Открытые данные,
|
||||||
|
# без auth/PII (прецедент: sber_index.py, domrf_kapremont_loader.py).
|
||||||
|
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
|
||||||
|
try:
|
||||||
|
data = _download(series.url, client=client)
|
||||||
|
finally:
|
||||||
|
if own_client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
||||||
|
try:
|
||||||
|
if series.sheet not in wb.sheetnames:
|
||||||
|
raise ValueError(
|
||||||
|
f"cbr_macro: лист {series.sheet!r} отсутствует в {series.url} "
|
||||||
|
f"(есть: {wb.sheetnames!r})"
|
||||||
|
)
|
||||||
|
ws = wb[series.sheet]
|
||||||
|
rows_raw = list(ws.iter_rows(values_only=True))
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
parsed = parse_mortgage_sheet(rows_raw, series_slug=series.slug)
|
||||||
|
upserted = 0 if dry_run else _upsert_mortgage_rows(db, parsed)
|
||||||
|
return {"rows": len(parsed), "upserted": upserted}
|
||||||
|
|
||||||
|
|
||||||
|
def pull_cbr_mortgage(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
series_list: list[CbrMortgageSeries] | None = None,
|
||||||
|
client: httpx.Client | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Забрать все (или выбранные) серии ипотечной статистики.
|
||||||
|
|
||||||
|
Per-series try/except: одна сбойнувшая серия логируется и не роняет остальные
|
||||||
|
(урок #1345 у sber_index: без rollback аборченная транзакция каскадит дальше).
|
||||||
|
"""
|
||||||
|
if series_list is None:
|
||||||
|
series_list = CBR_MORTGAGE_SERIES
|
||||||
|
|
||||||
|
counters = {"upserted": 0, "skipped": 0, "errors": 0}
|
||||||
|
own_client = client is None
|
||||||
|
if own_client:
|
||||||
|
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
|
||||||
|
try:
|
||||||
|
for series in series_list:
|
||||||
|
try:
|
||||||
|
result = load_cbr_mortgage_series(db, series, client=client, dry_run=dry_run)
|
||||||
|
if result["rows"] == 0:
|
||||||
|
logger.info("cbr_macro: пустой результат для series=%s", series.slug)
|
||||||
|
counters["skipped"] += 1
|
||||||
|
else:
|
||||||
|
counters["upserted"] += result["upserted"]
|
||||||
|
logger.info(
|
||||||
|
"cbr_macro: upserted %d rows for series=%s", result["upserted"], series.slug
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("cbr_macro: series=%s сбойнула — пропуск", series.slug)
|
||||||
|
counters["errors"] += 1
|
||||||
|
finally:
|
||||||
|
if own_client:
|
||||||
|
client.close()
|
||||||
|
return counters
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ключевая ставка — SOAP
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CBR_SOAP_URL = "https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx"
|
||||||
|
KEY_RATE_START = date(2013, 9, 13) # инструмент введён Советом директоров ЦБ РФ
|
||||||
|
|
||||||
|
_KEY_RATE_ENVELOPE = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" \
|
||||||
|
xmlns:xsd="http://www.w3.org/2001/XMLSchema" \
|
||||||
|
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<soap:Body>
|
||||||
|
<KeyRate xmlns="http://web.cbr.ru/">
|
||||||
|
<fromDate>{from_date}</fromDate>
|
||||||
|
<ToDate>{to_date}</ToDate>
|
||||||
|
</KeyRate>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_key_rate_envelope(from_date: date, to_date: date) -> str:
|
||||||
|
return _KEY_RATE_ENVELOPE.format(from_date=from_date.isoformat(), to_date=to_date.isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
def _local_tag(tag: str) -> str:
|
||||||
|
"""Локальное имя тега без namespace-префикса ('{ns}KR' → 'KR')."""
|
||||||
|
return tag.rsplit("}", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_key_rate_response(xml_text: str) -> list[tuple[date, float]]:
|
||||||
|
"""Разобрать SOAP-ответ KeyRate: строки <KR><DT>…</DT><Rate>…</Rate></KR>.
|
||||||
|
|
||||||
|
Namespace-агностично (ищем по локальному имени тега) — .NET DataSet
|
||||||
|
оборачивает строки в diffgram/reset-default-ns, точный путь не гарантирован.
|
||||||
|
"""
|
||||||
|
root = ET.fromstring(xml_text)
|
||||||
|
out: list[tuple[date, float]] = []
|
||||||
|
for el in root.iter():
|
||||||
|
if _local_tag(el.tag) != "KR":
|
||||||
|
continue
|
||||||
|
dt_text: str | None = None
|
||||||
|
rate_text: str | None = None
|
||||||
|
for child in el:
|
||||||
|
name = _local_tag(child.tag)
|
||||||
|
if name == "DT":
|
||||||
|
dt_text = child.text
|
||||||
|
elif name == "Rate":
|
||||||
|
rate_text = child.text
|
||||||
|
if not dt_text or rate_text is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rate_date = datetime.fromisoformat(dt_text).date()
|
||||||
|
rate = float(rate_text)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning(
|
||||||
|
"cbr_macro: пропуск строки KeyRate — не распознана: DT=%r Rate=%r",
|
||||||
|
dt_text,
|
||||||
|
rate_text,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
out.append((rate_date, rate))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_key_rate(
|
||||||
|
client: httpx.Client, *, from_date: date, to_date: date
|
||||||
|
) -> list[tuple[date, float]]:
|
||||||
|
body = build_key_rate_envelope(from_date, to_date)
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "text/xml; charset=utf-8",
|
||||||
|
"SOAPAction": "http://web.cbr.ru/KeyRate",
|
||||||
|
}
|
||||||
|
resp = client.post(CBR_SOAP_URL, content=body.encode("utf-8"), headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return parse_key_rate_response(resp.text)
|
||||||
|
|
||||||
|
|
||||||
|
_UPSERT_KEY_RATE_SQL = text("""
|
||||||
|
INSERT INTO cbr_key_rate (rate_date, rate, fetched_at)
|
||||||
|
VALUES (CAST(:rate_date AS date), CAST(:rate AS double precision), now())
|
||||||
|
ON CONFLICT (rate_date)
|
||||||
|
DO UPDATE SET
|
||||||
|
rate = EXCLUDED.rate
|
||||||
|
-- fetched_at НЕ трогаем — та же логика, что и у cbr_mortgage_series.
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_key_rate_rows(db: Session, rows: list[tuple[date, float]]) -> int:
|
||||||
|
for rate_date, rate in rows:
|
||||||
|
db.execute(_UPSERT_KEY_RATE_SQL, {"rate_date": rate_date.isoformat(), "rate": rate})
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def load_key_rate(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
from_date: date | None = None,
|
||||||
|
to_date: date | None = None,
|
||||||
|
client: httpx.Client | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Скачать ключевую ставку за диапазон дат, upsert-нуть в cbr_key_rate.
|
||||||
|
|
||||||
|
Не коммитит — коммитит caller. from_date/to_date по умолчанию — вся история
|
||||||
|
инструмента (KEY_RATE_START) до сегодня.
|
||||||
|
"""
|
||||||
|
if from_date is None:
|
||||||
|
from_date = KEY_RATE_START
|
||||||
|
if to_date is None:
|
||||||
|
to_date = date.today()
|
||||||
|
|
||||||
|
own_client = client is None
|
||||||
|
if own_client:
|
||||||
|
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
|
||||||
|
try:
|
||||||
|
rows = fetch_key_rate(client, from_date=from_date, to_date=to_date)
|
||||||
|
finally:
|
||||||
|
if own_client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
upserted = 0 if dry_run else _upsert_key_rate_rows(db, rows)
|
||||||
|
result = {"rows": len(rows), "upserted": upserted}
|
||||||
|
logger.info("cbr_macro: key_rate load DONE (dry_run=%s): %s", dry_run, result)
|
||||||
|
return result
|
||||||
|
|
@ -704,6 +704,43 @@ async def _job_frt_mkd_load(
|
||||||
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
|
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
|
||||||
|
|
||||||
|
|
||||||
|
# ── cbr_macro_pull — макро-ряды ЦБ РФ (ипотека по субъектам + ключевая ставка) ─
|
||||||
|
async def _job_cbr_macro_pull(
|
||||||
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
) -> None:
|
||||||
|
"""Ипотечные ряды (XLSX) + ключевая ставка (SOAP) → cbr_mortgage_series/cbr_key_rate.
|
||||||
|
|
||||||
|
Тело переиспользует те же функции, что и CLI (app/tasks/cbr_macro_pull.py) —
|
||||||
|
дизайн-инвариант модуля: не дублируем логику. Lifecycle не свой —
|
||||||
|
mark_done/mark_failed здесь, как у _job_domrf_kapremont_load.
|
||||||
|
"""
|
||||||
|
from app.services.cbr_macro import load_key_rate, pull_cbr_mortgage
|
||||||
|
|
||||||
|
def _run() -> dict[str, int]:
|
||||||
|
mortgage_counts = pull_cbr_mortgage(db)
|
||||||
|
db.commit()
|
||||||
|
key_rate_counts = load_key_rate(db)
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"mortgage_upserted": mortgage_counts["upserted"],
|
||||||
|
"mortgage_skipped": mortgage_counts["skipped"],
|
||||||
|
"mortgage_errors": mortgage_counts["errors"],
|
||||||
|
"key_rate_upserted": key_rate_counts["upserted"],
|
||||||
|
# выделенные колонки прогона + гейт «три подряд нулевых прогона» (#2625).
|
||||||
|
"total_seen": mortgage_counts["upserted"] + key_rate_counts["rows"],
|
||||||
|
"new_count": mortgage_counts["upserted"] + key_rate_counts["upserted"],
|
||||||
|
}
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
counters = await loop.run_in_executor(None, _run)
|
||||||
|
ctx.runs.mark_done(db, run_id, counters)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("scheduler: cbr_macro_pull crashed run_id=%d", run_id)
|
||||||
|
db.rollback()
|
||||||
|
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
|
||||||
|
|
||||||
|
|
||||||
# ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ───────────────
|
# ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ───────────────
|
||||||
async def _job_purge_expired_trade_in_data(
|
async def _job_purge_expired_trade_in_data(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
|
@ -877,6 +914,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
||||||
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
|
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
|
||||||
"domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"),
|
"domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"),
|
||||||
"frt_mkd_load": Handler(_job_frt_mkd_load, "frt_mkd_load"),
|
"frt_mkd_load": Handler(_job_frt_mkd_load, "frt_mkd_load"),
|
||||||
|
"cbr_macro_pull": Handler(_job_cbr_macro_pull, "cbr_macro_pull"),
|
||||||
"purge_expired_trade_in_data": Handler(
|
"purge_expired_trade_in_data": Handler(
|
||||||
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
|
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
|
||||||
),
|
),
|
||||||
|
|
|
||||||
95
tradein-mvp/backend/app/tasks/cbr_macro_pull.py
Normal file
95
tradein-mvp/backend/app/tasks/cbr_macro_pull.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
"""CLI: макро-ряды ЦБ РФ — ипотека по субъектам + ключевая ставка (#issue см. vault).
|
||||||
|
|
||||||
|
Запуск из контейнера:
|
||||||
|
python -m app.tasks.cbr_macro_pull # всё: ипотека + ставка
|
||||||
|
python -m app.tasks.cbr_macro_pull --dry-run
|
||||||
|
python -m app.tasks.cbr_macro_pull --series rate_rub debt_rub
|
||||||
|
python -m app.tasks.cbr_macro_pull --skip-mortgage --from-date 2024-01-01 --to-date 2024-12-31
|
||||||
|
python -m app.tasks.cbr_macro_pull --skip-key-rate
|
||||||
|
|
||||||
|
Делегирует всю логику app.services.cbr_macro (download → parse → upsert); этот
|
||||||
|
модуль — только argparse + lifecycle сессии + commit между стадиями.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.services.cbr_macro import (
|
||||||
|
CBR_MORTGAGE_SERIES,
|
||||||
|
load_key_rate,
|
||||||
|
pull_cbr_mortgage,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SERIES_SLUGS = [s.slug for s in CBR_MORTGAGE_SERIES]
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="Загрузка макро-рядов ЦБ РФ")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="только посчитать, без upsert")
|
||||||
|
parser.add_argument(
|
||||||
|
"--series",
|
||||||
|
nargs="*",
|
||||||
|
choices=_SERIES_SLUGS,
|
||||||
|
default=None,
|
||||||
|
help="подмножество серий ипотеки (по умолчанию — все)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--skip-mortgage", action="store_true", help="не тянуть ипотечные серии")
|
||||||
|
parser.add_argument("--skip-key-rate", action="store_true", help="не тянуть ключевую ставку")
|
||||||
|
parser.add_argument(
|
||||||
|
"--from-date", type=date.fromisoformat, default=None, help="ключевая ставка: ISO-дата от"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--to-date", type=date.fromisoformat, default=None, help="ключевая ставка: ISO-дата до"
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
||||||
|
)
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.skip_mortgage and args.skip_key_rate:
|
||||||
|
parser.error("--skip-mortgage и --skip-key-rate вместе не имеют смысла")
|
||||||
|
|
||||||
|
mortgage_counts: dict[str, int] = {}
|
||||||
|
key_rate_counts: dict[str, int] = {}
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if not args.skip_mortgage:
|
||||||
|
series_list = None
|
||||||
|
if args.series:
|
||||||
|
series_list = [s for s in CBR_MORTGAGE_SERIES if s.slug in args.series]
|
||||||
|
mortgage_counts = pull_cbr_mortgage(
|
||||||
|
db, series_list=series_list, dry_run=args.dry_run
|
||||||
|
)
|
||||||
|
if not args.dry_run:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if not args.skip_key_rate:
|
||||||
|
key_rate_counts = load_key_rate(
|
||||||
|
db, from_date=args.from_date, to_date=args.to_date, dry_run=args.dry_run
|
||||||
|
)
|
||||||
|
if not args.dry_run:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"cbr_macro_pull DONE: dry_run=%s mortgage=%s key_rate=%s",
|
||||||
|
args.dry_run,
|
||||||
|
mortgage_counts,
|
||||||
|
key_rate_counts,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
87
tradein-mvp/backend/data/sql/292_cbr_macro_series.sql
Normal file
87
tradein-mvp/backend/data/sql/292_cbr_macro_series.sql
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
-- 292_cbr_macro_series.sql
|
||||||
|
-- Макро-ряды ЦБ РФ: ипотека по субъектам + ключевая ставка.
|
||||||
|
--
|
||||||
|
-- Context:
|
||||||
|
-- ЦБ РФ публикует месячную статистику по ипотечным жилищным кредитам (объём выдач,
|
||||||
|
-- ставки, задолженность) в разрезе субъектов РФ — три XLSX-дашборда на cbr.ru
|
||||||
|
-- (см. app/services/cbr_macro.py::CBR_MORTGAGE_SERIES) — и дневную ключевую ставку
|
||||||
|
-- через SOAP-сервис DailyInfoWebServ. Обе серии нужны как макро-контекст для
|
||||||
|
-- продукта (региональная динамика ипотечного рынка); загрузку в estimator этот
|
||||||
|
-- PR не делает — только данные и их сбор (app/tasks/cbr_macro_pull.py).
|
||||||
|
--
|
||||||
|
-- Schema notes:
|
||||||
|
-- cbr_mortgage_series
|
||||||
|
-- * region — территория как в источнике (РФ целиком / федеральный округ /
|
||||||
|
-- субъект), без нормализации кодов ОКАТО.
|
||||||
|
-- * period_month — первое число отчётного месяца (шапка XLSX «Месяц Год»).
|
||||||
|
-- * series — slug серии (rate_rub / new_loans_rub / debt_rub, конфиг —
|
||||||
|
-- CBR_MORTGAGE_SERIES в коде).
|
||||||
|
-- * PRIMARY KEY (region, period_month, series) — одно значение на территорию×месяц×серию.
|
||||||
|
-- * fetched_at — «когда мы ВПЕРВЫЕ увидели этот период» (не время последней
|
||||||
|
-- загрузки — источник даёт всю серию целиком, отсечки по
|
||||||
|
-- периоду нет). Урок #2846 у sber_price_index: НЕ обновлять
|
||||||
|
-- fetched_at в ON CONFLICT DO UPDATE, иначе один прогон
|
||||||
|
-- перезаписывает метку у всех периодов, включая 2019-01.
|
||||||
|
-- cbr_key_rate
|
||||||
|
-- * rate_date — дата действия ключевой ставки (PRIMARY KEY).
|
||||||
|
-- * rate — значение ставки, % годовых.
|
||||||
|
-- * fetched_at — по той же логике, что и выше: не трогаем в DO UPDATE.
|
||||||
|
--
|
||||||
|
-- Idempotent: CREATE TABLE IF NOT EXISTS — безопасно переприменять. Только ADDITIVE.
|
||||||
|
--
|
||||||
|
-- Dependencies: нет (самостоятельные таблицы).
|
||||||
|
-- Deploy order: применить до app/services/cbr_macro.py + app/tasks/cbr_macro_pull.py
|
||||||
|
-- + миграции 291 (сид scrape_schedules).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cbr_mortgage_series (
|
||||||
|
region text NOT NULL,
|
||||||
|
period_month date NOT NULL,
|
||||||
|
series text NOT NULL,
|
||||||
|
value double precision NOT NULL,
|
||||||
|
source text NOT NULL DEFAULT 'cbr',
|
||||||
|
fetched_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (region, period_month, series)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE cbr_mortgage_series IS
|
||||||
|
'Месячные ряды ипотечной статистики ЦБ РФ по территориям (РФ / ФО / субъекты): '
|
||||||
|
'объём выдач, ставки, задолженность. Источник — XLSX-дашборды cbr.ru/vfs/statistics/'
|
||||||
|
'BankSector/Mortgage/. Загружается app/tasks/cbr_macro_pull.py через in-app '
|
||||||
|
'планировщик (scrape_schedules.source=''cbr_macro_pull'').';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN cbr_mortgage_series.region IS
|
||||||
|
'Территория как в источнике (например «РОССИЙСКАЯ ФЕДЕРАЦИЯ», «ЦЕНТРАЛЬНЫЙ '
|
||||||
|
'ФЕДЕРАЛЬНЫЙ ОКРУГ», «Свердловская область»). Без нормализации ОКАТО/ISO.';
|
||||||
|
COMMENT ON COLUMN cbr_mortgage_series.period_month IS
|
||||||
|
'Первое число отчётного месяца (нормализовано из шапки «Месяц Год» XLSX).';
|
||||||
|
COMMENT ON COLUMN cbr_mortgage_series.series IS
|
||||||
|
'Slug серии, см. CBR_MORTGAGE_SERIES в app/services/cbr_macro.py '
|
||||||
|
'(rate_rub, new_loans_rub, debt_rub).';
|
||||||
|
COMMENT ON COLUMN cbr_mortgage_series.value IS
|
||||||
|
'Значение серии в единицах источника (% годовых для ставки, млн руб для объёмов).';
|
||||||
|
COMMENT ON COLUMN cbr_mortgage_series.fetched_at IS
|
||||||
|
'Когда мы ВПЕРВЫЕ увидели этот период (не время последней загрузки). '
|
||||||
|
'НЕ обновляется в ON CONFLICT DO UPDATE — см. урок #2846 у sber_price_index.';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cbr_mortgage_series_region_series_period
|
||||||
|
ON cbr_mortgage_series (region, series, period_month DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cbr_key_rate (
|
||||||
|
rate_date date PRIMARY KEY,
|
||||||
|
rate double precision NOT NULL,
|
||||||
|
fetched_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE cbr_key_rate IS
|
||||||
|
'Ключевая ставка ЦБ РФ по датам действия. Источник — SOAP-метод KeyRate '
|
||||||
|
'сервиса https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx. Загружается '
|
||||||
|
'app/tasks/cbr_macro_pull.py.';
|
||||||
|
COMMENT ON COLUMN cbr_key_rate.rate_date IS 'Дата, с которой действует значение ставки.';
|
||||||
|
COMMENT ON COLUMN cbr_key_rate.rate IS 'Ключевая ставка, % годовых.';
|
||||||
|
COMMENT ON COLUMN cbr_key_rate.fetched_at IS
|
||||||
|
'Когда мы ВПЕРВЫЕ увидели эту дату. НЕ обновляется в ON CONFLICT DO UPDATE.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
-- 293_scrape_schedules_seed_cbr_macro_pull.sql
|
||||||
|
-- Сид расписания для нового продуктового источника cbr_macro_pull (ипотека по
|
||||||
|
-- субъектам + ключевая ставка ЦБ РФ, см. 292_cbr_macro_series.sql).
|
||||||
|
--
|
||||||
|
-- Такт: interval_days=7 — источник обновляет XLSX-дашборды ипотеки раз в месяц,
|
||||||
|
-- ключевую ставку — по решениям Совета директоров (раз в 6 недель), недельный
|
||||||
|
-- такт достаточен и не создаёт нагрузки (три XLSX ~800 КБ суммарно + один SOAP-запрос).
|
||||||
|
--
|
||||||
|
-- Окно 07:00–08:00 UTC — свободно на момент миграции (проверены соседние сиды:
|
||||||
|
-- 180 sber_freshness_monitor 09-10, 275 landing_stats_refresh 05-06,
|
||||||
|
-- 289 rosreestr_dkp_import_77 04-06); до утра по Екатеринбургу (UTC+5 → 12:00).
|
||||||
|
--
|
||||||
|
-- ENABLED=false — как и у прочих новых источников (домрф/sber): включение —
|
||||||
|
-- отдельный осознанный шаг после деплоя и дымовой пробы.
|
||||||
|
--
|
||||||
|
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 290 (таблицы данных).
|
||||||
|
-- Идемпотентно: ON CONFLICT (source) DO NOTHING.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
INSERT INTO scrape_schedules (
|
||||||
|
source,
|
||||||
|
enabled,
|
||||||
|
window_start_hour,
|
||||||
|
window_end_hour,
|
||||||
|
next_run_at,
|
||||||
|
default_params
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'cbr_macro_pull',
|
||||||
|
false,
|
||||||
|
7,
|
||||||
|
8,
|
||||||
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
|
||||||
|
'{"interval_days": 7}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (source) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -31,6 +31,7 @@ dependencies = [
|
||||||
"bcrypt>=4.2.0", # password hashing для DB-auth (#2550)
|
"bcrypt>=4.2.0", # password hashing для DB-auth (#2550)
|
||||||
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
|
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
|
||||||
"prometheus-client>=0.21.0", # /metrics — экспозиция и process-коллекторы (#3078)
|
"prometheus-client>=0.21.0", # /metrics — экспозиция и process-коллекторы (#3078)
|
||||||
|
"openpyxl>=3.1.0", # ЦБ РФ XLSX-дашборды ипотеки (app/services/cbr_macro.py)
|
||||||
"scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов;
|
"scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов;
|
||||||
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI.
|
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI.
|
||||||
# Docker-context = tradein-mvp root, uv sync ставит editable.
|
# Docker-context = tradein-mvp root, uv sync ставит editable.
|
||||||
|
|
|
||||||
380
tradein-mvp/backend/tests/test_cbr_macro.py
Normal file
380
tradein-mvp/backend/tests/test_cbr_macro.py
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
"""Tests for cbr_macro service — pure unit tests, no network/DB.
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
(a) parse_ru_period: русские имена месяцев → date(y, m, 1), включая мусор.
|
||||||
|
(b) parse_mortgage_sheet: unpivot мини-листа (реальные значения Свердловской обл.,
|
||||||
|
замерены вживую 08.09.2026: 11, 10.64, 10.98, 10.84, 9.2, 11.49).
|
||||||
|
(c) parse_key_rate_response: разбор реального SOAP-ответа KeyRate (namespace-агностично).
|
||||||
|
(d) build_key_rate_envelope: SOAP-конверт содержит нужные теги/значения.
|
||||||
|
(e) upsert SQL — статические asserts: ON CONFLICT, CAST(:x AS type), нет `::`-кастов,
|
||||||
|
fetched_at отсутствует в DO UPDATE SET.
|
||||||
|
(f) load_cbr_mortgage_series / load_key_rate — httpx.MockTransport round-trip.
|
||||||
|
(g) db.commit() отсутствует в исходниках сервисных функций (коммитит caller).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import date, datetime
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# DATABASE_URL required by config before any app import.
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import openpyxl
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import cbr_macro
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_ru_period
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseRuPeriod:
|
||||||
|
def test_january(self) -> None:
|
||||||
|
assert cbr_macro.parse_ru_period("Январь 2019") == date(2019, 1, 1)
|
||||||
|
|
||||||
|
def test_december(self) -> None:
|
||||||
|
assert cbr_macro.parse_ru_period("Декабрь 2026") == date(2026, 12, 1)
|
||||||
|
|
||||||
|
def test_july_2026(self) -> None:
|
||||||
|
assert cbr_macro.parse_ru_period("Июль 2026") == date(2026, 7, 1)
|
||||||
|
|
||||||
|
def test_unknown_month(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="месяца"):
|
||||||
|
cbr_macro.parse_ru_period("Смогтябрь 2019")
|
||||||
|
|
||||||
|
def test_garbage_no_year(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="период"):
|
||||||
|
cbr_macro.parse_ru_period("Январь")
|
||||||
|
|
||||||
|
def test_bad_year(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="период"):
|
||||||
|
cbr_macro.parse_ru_period("Январь двадцать")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_mortgage_sheet — unpivot
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Мини-лист по форме реального 02_13_Rates_mortgage.xlsx («ставка в рублях»):
|
||||||
|
# строка 1 пустая, строка 2 заголовок, строка 3 — шапка периодов, строки 4+ территории.
|
||||||
|
# Последние 6 значений Свердловской обл. — реальные, замерены 08.09.2026.
|
||||||
|
_RATE_SHEET_ROWS: list[tuple] = [
|
||||||
|
(None, None, None, None, None, None, None, None),
|
||||||
|
(
|
||||||
|
"Средневзвешенная ставка по ипотечным жилищным кредитам, %",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(None, "Февраль 2026", "Март 2026", "Апрель 2026", "Май 2026", "Июнь 2026", "Июль 2026", None),
|
||||||
|
("РОССИЙСКАЯ ФЕДЕРАЦИЯ", 21.5, 21.1, 20.8, 19.9, 18.6, 17.9, None),
|
||||||
|
("Свердловская область", 11, 10.64, 10.98, 10.84, 9.2, 11.49, None),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseMortgageSheet:
|
||||||
|
def test_too_short_returns_empty(self) -> None:
|
||||||
|
assert cbr_macro.parse_mortgage_sheet([(1,), (2,)], series_slug="rate_rub") == []
|
||||||
|
|
||||||
|
def test_unpivot_counts(self) -> None:
|
||||||
|
rows = cbr_macro.parse_mortgage_sheet(_RATE_SHEET_ROWS, series_slug="rate_rub")
|
||||||
|
# 2 территории × 6 месяцев (последний столбец None — trailing empty col пропущен)
|
||||||
|
assert len(rows) == 12
|
||||||
|
|
||||||
|
def test_sverdlovsk_values(self) -> None:
|
||||||
|
rows = cbr_macro.parse_mortgage_sheet(_RATE_SHEET_ROWS, series_slug="rate_rub")
|
||||||
|
sverdlovsk = [r for r in rows if r.region == "Свердловская область"]
|
||||||
|
sverdlovsk.sort(key=lambda r: r.period_month)
|
||||||
|
values = [r.value for r in sverdlovsk]
|
||||||
|
assert values == [11.0, 10.64, 10.98, 10.84, 9.2, 11.49]
|
||||||
|
assert sverdlovsk[0].period_month == date(2026, 2, 1)
|
||||||
|
assert sverdlovsk[-1].period_month == date(2026, 7, 1)
|
||||||
|
assert all(r.series == "rate_rub" for r in sverdlovsk)
|
||||||
|
|
||||||
|
def test_skips_empty_region_rows(self) -> None:
|
||||||
|
rows_in = [*_RATE_SHEET_ROWS, (None, 1, 2, 3, 4, 5, 6, None), ("", 1, 2, 3, 4, 5, 6, None)]
|
||||||
|
rows = cbr_macro.parse_mortgage_sheet(rows_in, series_slug="rate_rub")
|
||||||
|
assert len(rows) == 12 # мусорные строки без региона не добавляют записей
|
||||||
|
|
||||||
|
def test_skips_non_numeric_cell(self) -> None:
|
||||||
|
bad_rows = [*_RATE_SHEET_ROWS[:-1], ("Москва", "n/a", 10.0, None, None, None, None, None)]
|
||||||
|
rows = cbr_macro.parse_mortgage_sheet(bad_rows, series_slug="rate_rub")
|
||||||
|
moscow = [r for r in rows if r.region == "Москва"]
|
||||||
|
assert len(moscow) == 1 # только числовая ячейка попала в результат
|
||||||
|
assert moscow[0].value == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SOAP key rate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_KEY_RATE_XML = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<soap:Body>
|
||||||
|
<KeyRateResponse xmlns="http://web.cbr.ru/">
|
||||||
|
<KeyRateResult>
|
||||||
|
<diffgr:diffgram xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"
|
||||||
|
xmlns:diffgr="urn:schemas-microsoft-com:xml-diffgram-v1">
|
||||||
|
<KeyRate xmlns="">
|
||||||
|
<KR>
|
||||||
|
<DT>2024-01-01T00:00:00+03:00</DT>
|
||||||
|
<Rate>16</Rate>
|
||||||
|
</KR>
|
||||||
|
<KR>
|
||||||
|
<DT>2024-07-29T00:00:00+03:00</DT>
|
||||||
|
<Rate>18</Rate>
|
||||||
|
</KR>
|
||||||
|
</KeyRate>
|
||||||
|
</diffgr:diffgram>
|
||||||
|
</KeyRateResult>
|
||||||
|
</KeyRateResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyRateSoap:
|
||||||
|
def test_parse_real_response_shape(self) -> None:
|
||||||
|
rows = cbr_macro.parse_key_rate_response(_KEY_RATE_XML)
|
||||||
|
assert rows == [(date(2024, 1, 1), 16.0), (date(2024, 7, 29), 18.0)]
|
||||||
|
|
||||||
|
def test_parse_empty_response(self) -> None:
|
||||||
|
empty = (
|
||||||
|
"<soap:Envelope xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/'>"
|
||||||
|
"<soap:Body></soap:Body></soap:Envelope>"
|
||||||
|
)
|
||||||
|
assert cbr_macro.parse_key_rate_response(empty) == []
|
||||||
|
|
||||||
|
def test_build_envelope_contains_dates_and_soapaction_target(self) -> None:
|
||||||
|
envelope = cbr_macro.build_key_rate_envelope(date(2024, 1, 1), date(2024, 12, 31))
|
||||||
|
assert "<fromDate>2024-01-01</fromDate>" in envelope
|
||||||
|
assert "<ToDate>2024-12-31</ToDate>" in envelope
|
||||||
|
assert "http://web.cbr.ru/" in envelope
|
||||||
|
assert "<KeyRate " in envelope or "<KeyRate>" in envelope
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Upsert SQL — статические asserts (без БД)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpsertSql:
|
||||||
|
def test_mortgage_sql_no_double_colon_cast(self) -> None:
|
||||||
|
sql = re.sub(r"\s+", " ", str(cbr_macro._UPSERT_MORTGAGE_SQL.text))
|
||||||
|
assert not re.search(r":\w+::", sql)
|
||||||
|
|
||||||
|
def test_mortgage_sql_has_cast_and_on_conflict(self) -> None:
|
||||||
|
sql = re.sub(r"\s+", " ", str(cbr_macro._UPSERT_MORTGAGE_SQL.text))
|
||||||
|
assert "CAST(:region AS text)" in sql
|
||||||
|
assert "CAST(:period_month AS date)" in sql
|
||||||
|
assert "CAST(:value AS double precision)" in sql
|
||||||
|
assert "ON CONFLICT (region, period_month, series)" in sql
|
||||||
|
assert "DO UPDATE SET" in sql
|
||||||
|
|
||||||
|
def test_mortgage_sql_does_not_touch_fetched_at_on_conflict(self) -> None:
|
||||||
|
sql = str(cbr_macro._UPSERT_MORTGAGE_SQL.text)
|
||||||
|
do_update_part = sql.split("DO UPDATE SET", 1)[1]
|
||||||
|
# fetched_at упоминается только в SQL-комментарии (объясняет почему НЕ обновляем),
|
||||||
|
# но не как цель присваивания.
|
||||||
|
assert not re.search(r"fetched_at\s*=", do_update_part)
|
||||||
|
|
||||||
|
def test_key_rate_sql_no_double_colon_cast(self) -> None:
|
||||||
|
sql = re.sub(r"\s+", " ", str(cbr_macro._UPSERT_KEY_RATE_SQL.text))
|
||||||
|
assert not re.search(r":\w+::", sql)
|
||||||
|
|
||||||
|
def test_key_rate_sql_shape(self) -> None:
|
||||||
|
sql = re.sub(r"\s+", " ", str(cbr_macro._UPSERT_KEY_RATE_SQL.text))
|
||||||
|
assert "CAST(:rate_date AS date)" in sql
|
||||||
|
assert "CAST(:rate AS double precision)" in sql
|
||||||
|
assert "ON CONFLICT (rate_date)" in sql
|
||||||
|
|
||||||
|
def test_key_rate_sql_does_not_touch_fetched_at_on_conflict(self) -> None:
|
||||||
|
sql = str(cbr_macro._UPSERT_KEY_RATE_SQL.text)
|
||||||
|
do_update_part = sql.split("DO UPDATE SET", 1)[1]
|
||||||
|
assert not re.search(r"fetched_at\s*=", do_update_part)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Services do not commit — caller commits
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoCommitInService:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"fn",
|
||||||
|
[
|
||||||
|
cbr_macro.load_cbr_mortgage_series,
|
||||||
|
cbr_macro.pull_cbr_mortgage,
|
||||||
|
cbr_macro.load_key_rate,
|
||||||
|
cbr_macro._upsert_mortgage_rows,
|
||||||
|
cbr_macro._upsert_key_rate_rows,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_no_commit_call(self, fn) -> None:
|
||||||
|
src = inspect.getsource(fn)
|
||||||
|
assert "db.commit()" not in src
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Network round-trip via httpx.MockTransport
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _xlsx_bytes_for(rows: list[tuple], *, sheet_name: str) -> bytes:
|
||||||
|
import io
|
||||||
|
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = sheet_name
|
||||||
|
for row in rows:
|
||||||
|
ws.append(row)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMortgageNetworkRoundtrip:
|
||||||
|
def test_load_series_upserts_expected_rows(self) -> None:
|
||||||
|
series = cbr_macro.CbrMortgageSeries(
|
||||||
|
"rate_rub", "https://www.cbr.ru/fake.xlsx", "ставка в рублях"
|
||||||
|
)
|
||||||
|
xlsx_bytes = _xlsx_bytes_for(_RATE_SHEET_ROWS, sheet_name="ставка в рублях")
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert str(request.url) == series.url
|
||||||
|
return httpx.Response(200, content=xlsx_bytes)
|
||||||
|
|
||||||
|
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.rowcount = 1
|
||||||
|
|
||||||
|
result = cbr_macro.load_cbr_mortgage_series(db, series, client=client)
|
||||||
|
|
||||||
|
assert result["rows"] == 12
|
||||||
|
assert result["upserted"] == 12
|
||||||
|
assert db.execute.call_count == 12
|
||||||
|
|
||||||
|
def test_load_series_dry_run_skips_upsert(self) -> None:
|
||||||
|
series = cbr_macro.CbrMortgageSeries(
|
||||||
|
"rate_rub", "https://www.cbr.ru/fake.xlsx", "ставка в рублях"
|
||||||
|
)
|
||||||
|
xlsx_bytes = _xlsx_bytes_for(_RATE_SHEET_ROWS, sheet_name="ставка в рублях")
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, content=xlsx_bytes)
|
||||||
|
|
||||||
|
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||||
|
db = MagicMock()
|
||||||
|
|
||||||
|
result = cbr_macro.load_cbr_mortgage_series(db, series, client=client, dry_run=True)
|
||||||
|
|
||||||
|
assert result["rows"] == 12
|
||||||
|
assert result["upserted"] == 0
|
||||||
|
db.execute.assert_not_called()
|
||||||
|
|
||||||
|
def test_missing_sheet_raises(self) -> None:
|
||||||
|
series = cbr_macro.CbrMortgageSeries(
|
||||||
|
"rate_rub", "https://www.cbr.ru/fake.xlsx", "нет такого листа"
|
||||||
|
)
|
||||||
|
xlsx_bytes = _xlsx_bytes_for(_RATE_SHEET_ROWS, sheet_name="ставка в рублях")
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, content=xlsx_bytes)
|
||||||
|
|
||||||
|
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||||
|
db = MagicMock()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="лист"):
|
||||||
|
cbr_macro.load_cbr_mortgage_series(db, series, client=client)
|
||||||
|
|
||||||
|
def test_pull_isolates_series_failure(self) -> None:
|
||||||
|
good = cbr_macro.CbrMortgageSeries(
|
||||||
|
"rate_rub", "https://www.cbr.ru/good.xlsx", "ставка в рублях"
|
||||||
|
)
|
||||||
|
bad = cbr_macro.CbrMortgageSeries("debt_rub", "https://www.cbr.ru/bad.xlsx", "в рублях")
|
||||||
|
xlsx_bytes = _xlsx_bytes_for(_RATE_SHEET_ROWS, sheet_name="ставка в рублях")
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if str(request.url) == bad.url:
|
||||||
|
return httpx.Response(500)
|
||||||
|
return httpx.Response(200, content=xlsx_bytes)
|
||||||
|
|
||||||
|
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.rowcount = 1
|
||||||
|
|
||||||
|
counters = cbr_macro.pull_cbr_mortgage(db, series_list=[good, bad], client=client)
|
||||||
|
|
||||||
|
assert counters["upserted"] == 12
|
||||||
|
assert counters["errors"] == 1
|
||||||
|
db.rollback.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyRateNetworkRoundtrip:
|
||||||
|
def test_load_key_rate_upserts_parsed_rows(self) -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.method == "POST"
|
||||||
|
assert request.headers["soapaction"] == "http://web.cbr.ru/KeyRate"
|
||||||
|
return httpx.Response(200, content=_KEY_RATE_XML.encode("utf-8"))
|
||||||
|
|
||||||
|
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.rowcount = 1
|
||||||
|
|
||||||
|
result = cbr_macro.load_key_rate(
|
||||||
|
db, from_date=date(2024, 1, 1), to_date=date(2024, 12, 31), client=client
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["rows"] == 2
|
||||||
|
assert result["upserted"] == 2
|
||||||
|
assert db.execute.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_mortgage_sheet_debt_layout_datetime_header() -> None:
|
||||||
|
"""02_14_Debt_mortgage.xlsx: шапка в строке 2 и периоды — datetime, не «Январь 2019».
|
||||||
|
|
||||||
|
Реальная раскладка (проверено на живом файле 08.09.2026): в 02_11/02_13 строка 1
|
||||||
|
пустая, описание в строке 2, шапка в строке 3 текстом; в 02_14 описание в строке 1,
|
||||||
|
а шапка уже в строке 2 и хранит настоящие даты. Захардкоженный индекс строки давал
|
||||||
|
по серии задолженности ноль строк — молча, только с warning'ами.
|
||||||
|
"""
|
||||||
|
months = [
|
||||||
|
datetime(2019, 2, 1),
|
||||||
|
datetime(2019, 3, 1),
|
||||||
|
datetime(2019, 4, 1),
|
||||||
|
datetime(2019, 5, 1),
|
||||||
|
datetime(2019, 6, 1),
|
||||||
|
datetime(2019, 7, 1),
|
||||||
|
]
|
||||||
|
rows: list[tuple[object, ...]] = [
|
||||||
|
("Задолженность по ипотечным жилищным кредитам", None, None, None, None, None, None),
|
||||||
|
(None, *months),
|
||||||
|
("РОССИЙСКАЯ ФЕДЕРАЦИЯ", 6605685, 6719335, 6823490, 6937855, 7018162, 7095485),
|
||||||
|
("Свердловская область", 100, 101, 102, 103, 104, 105),
|
||||||
|
]
|
||||||
|
|
||||||
|
out = cbr_macro.parse_mortgage_sheet(rows, series_slug="debt_rub")
|
||||||
|
|
||||||
|
sverdlovsk = [r for r in out if r.region == "Свердловская область"]
|
||||||
|
assert len(sverdlovsk) == 6
|
||||||
|
assert sverdlovsk[0].period_month == date(2019, 2, 1)
|
||||||
|
assert sverdlovsk[0].value == 100.0
|
||||||
|
assert {r.region for r in out} == {"РОССИЙСКАЯ ФЕДЕРАЦИЯ", "Свердловская область"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_mortgage_sheet_returns_empty_when_no_header_found() -> None:
|
||||||
|
"""Нет распознаваемой шапки — пустой результат и предупреждение, а не мусор."""
|
||||||
|
rows: list[tuple[object, ...]] = [
|
||||||
|
("Какой-то заголовок", None, None),
|
||||||
|
("РОССИЙСКАЯ ФЕДЕРАЦИЯ", 1, 2),
|
||||||
|
]
|
||||||
|
assert cbr_macro.parse_mortgage_sheet(rows, series_slug="debt_rub") == []
|
||||||
23
tradein-mvp/uv.lock
generated
23
tradein-mvp/uv.lock
generated
|
|
@ -376,6 +376,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
|
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "et-xmlfile"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.139.0"
|
version = "0.139.0"
|
||||||
|
|
@ -983,6 +992,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openpyxl"
|
||||||
|
version = "3.1.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "et-xmlfile" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.2"
|
version = "26.2"
|
||||||
|
|
@ -1692,6 +1713,7 @@ dependencies = [
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "lxml" },
|
{ name = "lxml" },
|
||||||
{ name = "matplotlib" },
|
{ name = "matplotlib" },
|
||||||
|
{ name = "openpyxl" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "playwright" },
|
{ name = "playwright" },
|
||||||
{ name = "prometheus-client" },
|
{ name = "prometheus-client" },
|
||||||
|
|
@ -1729,6 +1751,7 @@ requires-dist = [
|
||||||
{ name = "jinja2", specifier = ">=3.1.0" },
|
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||||
{ name = "lxml", specifier = ">=5.0.0" },
|
{ name = "lxml", specifier = ">=5.0.0" },
|
||||||
{ name = "matplotlib", specifier = ">=3.9.0" },
|
{ name = "matplotlib", specifier = ">=3.9.0" },
|
||||||
|
{ name = "openpyxl", specifier = ">=3.1.0" },
|
||||||
{ name = "pillow", specifier = ">=10.3.0" },
|
{ name = "pillow", specifier = ">=10.3.0" },
|
||||||
{ name = "playwright", specifier = ">=1.45" },
|
{ name = "playwright", specifier = ">=1.45" },
|
||||||
{ name = "prometheus-client", specifier = ">=0.21.0" },
|
{ name = "prometheus-client", specifier = ">=0.21.0" },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue