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

This commit is contained in:
lekss361 2026-09-08 21:48:59 +00:00
commit 3bd30aab87
8 changed files with 1129 additions and 0 deletions

View 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

View file

@ -704,6 +704,43 @@ async def _job_frt_mkd_load(
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-ФЗ) ───────────────
async def _job_purge_expired_trade_in_data(
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"),
"domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_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(
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
),

View 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()

View 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;

View file

@ -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:0008: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;

View file

@ -31,6 +31,7 @@ dependencies = [
"bcrypt>=4.2.0", # password hashing для DB-auth (#2550)
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
"prometheus-client>=0.21.0", # /metrics — экспозиция и process-коллекторы (#3078)
"openpyxl>=3.1.0", # ЦБ РФ XLSX-дашборды ипотеки (app/services/cbr_macro.py)
"scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов;
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI.
# Docker-context = tradein-mvp root, uv sync ставит editable.

View 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
View file

@ -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" },
]
[[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]]
name = "fastapi"
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" },
]
[[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]]
name = "packaging"
version = "26.2"
@ -1692,6 +1713,7 @@ dependencies = [
{ name = "jinja2" },
{ name = "lxml" },
{ name = "matplotlib" },
{ name = "openpyxl" },
{ name = "pillow" },
{ name = "playwright" },
{ name = "prometheus-client" },
@ -1729,6 +1751,7 @@ requires-dist = [
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "lxml", specifier = ">=5.0.0" },
{ name = "matplotlib", specifier = ">=3.9.0" },
{ name = "openpyxl", specifier = ">=3.1.0" },
{ name = "pillow", specifier = ">=10.3.0" },
{ name = "playwright", specifier = ">=1.45" },
{ name = "prometheus-client", specifier = ">=0.21.0" },