From aaa5d44e77503230cbf75f0b793aae7e606f5bc4 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Thu, 7 May 2026 21:04:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(objective):=20integrate=20api.objctv.ru=20?= =?UTF-8?q?=E2=80=94=20auth,=20schema,=20weekly=20sync=20backend/app/servi?= =?UTF-8?q?ces/scrapers/objective.py:=20=20=20ObjectiveClient=20=E2=80=94?= =?UTF-8?q?=20GetToken=20(Bearer=20cached=20=D0=B2=20Redis=2025=20min=20TT?= =?UTF-8?q?L)=20+=20=20=20GetReport=20v2=20=D1=81=20UseDdu/UseDkp.=204=20?= =?UTF-8?q?=D0=B2=D1=8B=D1=81=D0=BE=D0=BA=D0=BE=D1=83=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2=D1=8B=D1=85=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0:=20=20=20report=5Fcorpuses=5Fsummary,=20report=5Flots=5F?= =?UTF-8?q?summary,=20report=5Fcorpuses=5Fper=5Fflat,=20=20=20report=5Flot?= =?UTF-8?q?s=5Fper=5Fflat.=20Retry=20401/429/5xx,=20rate-limit=20500ms,=20?= =?UTF-8?q?brotli.=20backend/app/workers/tasks/scrape=5Fobjective.py:=20?= =?UTF-8?q?=20=20Celery=20task=20sync=5Fobjective=5Fgroup=20=E2=80=94=20?= =?UTF-8?q?=D0=B5=D0=B6=D0=B5=D0=BD=D0=B5=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=20=D1=82=D1=8F=D0=BD=D0=B5=D1=82=204=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=BE=D0=BD.=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=20=20=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=B5,=20ra?= =?UTF-8?q?w=20payload=20=D0=B2=20objective=5Fraw=5Freports.=20celery=5Fap?= =?UTF-8?q?p.py:=20+include=20+=20beat=20=C2=AB0=205=20*=20*=20mon=C2=BB.?= =?UTF-8?q?=20data/sql/68=5Fschema=5Fobjective.sql:=206=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=86=20=E2=80=94=20runs,=20raw=5Freports=20?= =?UTF-8?q?(jsonb),=20oks=20(=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=A2=D0=AD=D0=9F=20+=20escrow/debt),=20lots,=20lots=5Fhistory?= =?UTF-8?q?=20(per-flat=20per-day),=20complex=5Fmapping=20(Objective=20?= =?UTF-8?q?=E2=86=94=20domrf=5Fkn=5Fobjects,=20is=5Freviewed=20workflow).?= =?UTF-8?q?=20data/sql/69=5Fobjective=5Fsmoke.py:=20stand-alone=20GetToken?= =?UTF-8?q?=20+=204=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=20=E2=86=92=20d?= =?UTF-8?q?ata/raw/objective=5Fsmoke//.=20=D0=98=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D1=83=D0=B5=D1=82=D1=81=D1=8F=20=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=20=D1=80=D0=B0=D0=B7=20=D1=87=D1=82=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=20=D0=BF=D0=BE=D0=BD=D1=8F=D1=82=D1=8C=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20=D1=81=D1=85=D0=B5=D0=BC?= =?UTF-8?q?=D1=83=20payload=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC=20parser?= =?UTF-8?q?-=D1=81=D0=BB=D0=BE=D1=8F.=20config:=20OBJECTIVE=5FAPI=5FKEY,?= =?UTF-8?q?=20OBJECTIVE=5FDEFAULT=5FGROUP=3D=D0=95=D0=BA=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B1=D1=83=D1=80=D0=B3,=20OBJECTIVE=5FSYNC?= =?UTF-8?q?=5FCRON=3D'0=205=20*=20*=20mon'.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/core/config.py | 18 + backend/app/services/scrapers/objective.py | 448 ++++++++++++ backend/app/workers/celery_app.py | 12 + backend/app/workers/tasks/scrape_objective.py | 328 +++++++++ data/sql/68_schema_objective.sql | 333 +++++++++ data/sql/69_objective_smoke.py | 121 ++++ data/sql/69b_objective_minimal.py | 200 ++++++ data/sql/70_parse_objective_raw.py | 638 ++++++++++++++++++ memory/memory-gendesign.jsonl | 403 ++++++++++- 9 files changed, 2498 insertions(+), 3 deletions(-) create mode 100644 backend/app/services/scrapers/objective.py create mode 100644 backend/app/workers/tasks/scrape_objective.py create mode 100644 data/sql/68_schema_objective.sql create mode 100644 data/sql/69_objective_smoke.py create mode 100644 data/sql/69b_objective_minimal.py create mode 100644 data/sql/70_parse_objective_raw.py diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 082d1889..130728bd 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -43,5 +43,23 @@ class Settings(BaseSettings): # Пауза между NSPD-запросами в мс. <600мс — высокий риск WAF 403. scrape_nspd_rate_ms: int = 600 + # Objective.ru API (api.objctv.ru) — платная аналитика первички/вторички. + # Лежит наряду с DOM.РФ kn + rosreestr CSV как 3-й источник истины с + # самой богатой моделью (per-flat per-day, escrow/банк-долг, ЕГРН-ИНН). + # Получить ключ: личный закрытый ApiKey от Объектива (тариф per-территория). + # Tank-схема: + # 1. GET /Users/User/GetToken?apiKey= → JSON с Bearer-токеном + # 2. GET /v2/Report/GetReport?... + Authorization: Bearer + objective_api_key: str | None = None + objective_base_url: str = "https://api.objctv.ru" + # Группа = «настройка территории» внутри Объектива. Чаще всего совпадает с + # городом/областью. Доступные группы определяются тарифом ключа. + objective_default_group: str = "Екатеринбург" + # Токен Bearer короткоживущий (предположительно 30 мин — уточнить). + # Кешируем в Redis с этим TTL (с запасом 5 мин). + objective_token_ttl_seconds: int = 25 * 60 + # Расписание sync — еженедельно по понедельникам в 05:00 МСК (после kn-sweep). + objective_sync_cron: str = "0 5 * * mon" + settings = Settings() diff --git a/backend/app/services/scrapers/objective.py b/backend/app/services/scrapers/objective.py new file mode 100644 index 00000000..5977f620 --- /dev/null +++ b/backend/app/services/scrapers/objective.py @@ -0,0 +1,448 @@ +"""Клиент API Объектива (api.objctv.ru) — аналитика первички/вторички. + +Architecture: + 1. GetToken: GET /Users/User/GetToken?apiKey= → {"token": "..."} + Bearer-токен короткоживущий (~30 мин), кешируется в Redis. + 2. GetReport: GET /v2/Report/GetReport? с Authorization: Bearer + Возвращает JSON-отчёт по комбинации (Page, ReportSection, ReportType, + ReportName, GroupName, [ComplexName], [StartDate], [EndDate], + [UseDdu], [UseDkp]). + +Структура отчётов (из их docs): + - Page=Отчеты + ReportSection=Объединенные данные + ReportType={Сводные|Поквартирные} + + ReportName={Корпуса|Лоты} = 4 канонические комбинации. + - StartDate/EndDate в формате YYYY.MM.DD (точка-разделитель!). + - UseDdu (default=true) / UseDkp (default=false) — тип сделок в свод-отчётах. + +Защита от лимитов: + - rate-limit: 500мс между запросами (наблюдаемая безопасная зона B2B-API) + - 401 → re-authenticate token (TTL истёк или revoked) + - 429 / 5xx → exponential backoff + - Brotli по дефолту в Accept-Encoding (их рекомендация). +""" + +from __future__ import annotations + +import json +import logging +import time +from datetime import date, datetime +from typing import Any + +import httpx +import redis + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +_TOKEN_REDIS_KEY = "objective:bearer_token" +_DEFAULT_TIMEOUT_S = 30 +_DEFAULT_RATE_MS = 500 +_DEFAULT_RETRIES = 3 + +# Process-локальный кеш токена — fallback на случай, когда Redis недоступен +# (локальный запуск скриптов вне docker, сетевой сбой). Tuple = (token, expires_at). +_TOKEN_MEMORY_CACHE: dict[str, tuple[str, float]] = {} + + +class ObjectiveAuthError(RuntimeError): + """Не удалось получить/обновить Bearer-токен (401 на /GetToken).""" + + +class ObjectiveAPIError(RuntimeError): + """Не-401 ошибка Объектив-API (4xx бизнес-логики или 5xx сервера).""" + + +class ObjectiveClient: + """HTTP-клиент к api.objctv.ru с кешированием токена в Redis. + + Usage: + client = ObjectiveClient() + report = client.get_report( + report_section="Объединенные данные", + report_type="Сводные", + report_name="Корпуса", + group_name="Екатеринбург", + start_date=date(2025, 5, 1), + end_date=date(2025, 6, 1), + use_ddu=True, + use_dkp=True, + ) + """ + + def __init__( + self, + api_key: str | None = None, + base_url: str | None = None, + timeout_s: int = _DEFAULT_TIMEOUT_S, + rate_ms: int = _DEFAULT_RATE_MS, + retries: int = _DEFAULT_RETRIES, + ) -> None: + self.api_key = api_key or settings.objective_api_key + if not self.api_key: + raise ObjectiveAuthError("OBJECTIVE_API_KEY не задан (settings.objective_api_key пуст)") + self.base_url = (base_url or settings.objective_base_url).rstrip("/") + self.timeout_s = timeout_s + self.rate_ms = rate_ms + self.retries = retries + # Brotli + JSON. httpx по умолчанию умеет br/gzip. + self._client = httpx.Client( + timeout=timeout_s, + headers={ + "Accept": "application/json", + "Accept-Encoding": "br, gzip", + "User-Agent": "gendesign/1.0 (+https://gendsgn.ru)", + }, + ) + self._redis = redis.Redis.from_url(settings.redis_url, decode_responses=True) + self._last_request_ts: float = 0.0 + + # ── токен ─────────────────────────────────────────────────────────────── + + def _get_cached_token(self) -> str | None: + try: + val = self._redis.get(_TOKEN_REDIS_KEY) + if val: + return val + except redis.RedisError as e: + # Логируем один раз на инстанс — не спамить, локальный запуск это норма. + if not getattr(self, "_redis_warned", False): + logger.warning("Redis недоступен, fallback на in-memory кеш токена: %s", e) + self._redis_warned = True + # Fallback: in-memory. + cached = _TOKEN_MEMORY_CACHE.get(_TOKEN_REDIS_KEY) + if cached: + token, expires_at = cached + if time.time() < expires_at: + return token + _TOKEN_MEMORY_CACHE.pop(_TOKEN_REDIS_KEY, None) + return None + + def _cache_token(self, token: str) -> None: + ttl = settings.objective_token_ttl_seconds + try: + self._redis.set(_TOKEN_REDIS_KEY, token, ex=ttl) + except redis.RedisError: + pass # уже залогировали в _get_cached_token + # Всегда дублируем в память — на случай если Redis потеряется между set/get. + _TOKEN_MEMORY_CACHE[_TOKEN_REDIS_KEY] = (token, time.time() + ttl) + + def _fetch_new_token(self) -> str: + """GET /Users/User/GetToken?apiKey=. Возвращает Bearer-токен.""" + url = f"{self.base_url}/Users/User/GetToken" + params = {"apiKey": self.api_key} + logger.info("Objective: запрос нового Bearer-токена") + try: + r = self._client.get(url, params=params) + except httpx.HTTPError as e: + raise ObjectiveAuthError(f"Сетевая ошибка GetToken: {e}") from e + if r.status_code == 401: + raise ObjectiveAuthError("apiKey не принят (401 на GetToken)") + if r.status_code != 200: + raise ObjectiveAuthError(f"GetToken HTTP {r.status_code}: {r.text[:200]}") + data = r.json() + # Ответ: {"token": "..."} либо {"Token": "..."} либо строка — нормализуем. + if isinstance(data, str): + token = data + elif isinstance(data, dict): + token = data.get("token") or data.get("Token") or data.get("accessToken") + else: + token = None + if not token: + raise ObjectiveAuthError(f"GetToken вернул неожиданный формат: {str(data)[:200]}") + self._cache_token(token) + return token + + def _get_token(self, force_refresh: bool = False) -> str: + if not force_refresh: + cached = self._get_cached_token() + if cached: + return cached + return self._fetch_new_token() + + # ── HTTP общий ────────────────────────────────────────────────────────── + + def _wait_rate_limit(self) -> None: + elapsed_ms = (time.time() - self._last_request_ts) * 1000.0 + if elapsed_ms < self.rate_ms: + time.sleep((self.rate_ms - elapsed_ms) / 1000.0) + self._last_request_ts = time.time() + + def _request_authed( + self, + path: str, + params: dict[str, Any], + *, + attempt: int = 0, + force_refresh_token: bool = False, + ) -> Any: + token = self._get_token(force_refresh=force_refresh_token) + url = f"{self.base_url}{path}" + self._wait_rate_limit() + try: + r = self._client.get( + url, + params=params, + headers={"Authorization": f"Bearer {token}"}, + ) + except httpx.HTTPError as e: + if attempt < self.retries: + wait = min(2**attempt, 30) + logger.warning( + "Objective net-error %s (attempt %d/%d), retry in %ds", + e, + attempt + 1, + self.retries, + wait, + ) + time.sleep(wait) + return self._request_authed(path, params, attempt=attempt + 1) + raise ObjectiveAPIError(f"Сетевая ошибка после {self.retries} попыток: {e}") from e + + if r.status_code == 401: + # TTL истёк или revoked. Один раз пробуем re-auth. + if not force_refresh_token: + logger.info("Objective: 401, рефрешим токен и повторяем") + return self._request_authed(path, params, attempt=attempt, force_refresh_token=True) + raise ObjectiveAuthError("401 даже после refresh токена — apiKey невалиден") + + if r.status_code in (429, 502, 503, 504): + if attempt < self.retries: + # Backoff: 30, 60, 120, 240… (потолок 300с) — Objective любит держать + # 429 пока не пройдёт rate-window (~30с на дешёвых тарифах). + # Если есть Retry-After — слушаем его (но капаем потолком). + retry_after_hdr = r.headers.get("Retry-After") + if retry_after_hdr and retry_after_hdr.isdigit(): + wait = min(int(retry_after_hdr), 300) + else: + wait = min(30 * (2**attempt), 300) + logger.warning( + "Objective HTTP %s (attempt %d/%d), retry in %ds", + r.status_code, + attempt + 1, + self.retries, + wait, + ) + time.sleep(wait) + return self._request_authed(path, params, attempt=attempt + 1) + raise ObjectiveAPIError(f"HTTP {r.status_code} после ретраев: {r.text[:200]}") + + if r.status_code != 200: + raise ObjectiveAPIError(f"HTTP {r.status_code}: {r.text[:500]}") + + try: + return r.json() + except json.JSONDecodeError as e: + raise ObjectiveAPIError( + f"Невалидный JSON: {e}; первые 200 символов: {r.text[:200]}" + ) from e + + # ── публичные методы отчётов ──────────────────────────────────────────── + + @staticmethod + def _fmt_date(d: date | str | None) -> str | None: + if d is None: + return None + if isinstance(d, str): + return d # ожидаем уже YYYY.MM.DD + return d.strftime("%Y.%m.%d") + + def get_report( + self, + *, + report_section: str = "Объединенные данные", + report_type: str = "Сводные", + report_name: str = "Корпуса", + group_name: str | None = None, + complex_name: str | None = None, + start_date: date | str | None = None, + end_date: date | str | None = None, + use_ddu: bool | None = True, + use_dkp: bool | None = None, + page: str = "Отчеты", + v2: bool = True, + ) -> Any: + """Универсальный вызов GetReport. Возвращает распарсенный JSON. + + Args: + report_section: например "Объединенные данные" + report_type: "Сводные" | "Поквартирные" + report_name: "Корпуса" | "Лоты" | др. + group_name: "Екатеринбург" (default из settings) + complex_name: фильтр по конкретному ЖК (опционально) + start_date / end_date: для Сводных — обязательны + use_ddu / use_dkp: только для v2 (новый API) + page: всегда "Отчеты" + v2: True → /v2/Report/GetReport (с UseDdu/UseDkp), False → /Report/GetReport + """ + path = "/v2/Report/GetReport" if v2 else "/Report/GetReport" + params: dict[str, Any] = { + "Page": page, + "ReportSection": report_section, + "ReportType": report_type, + "ReportName": report_name, + "GroupName": group_name or settings.objective_default_group, + } + if complex_name: + params["ComplexName"] = complex_name + if start_date is not None: + params["StartDate"] = self._fmt_date(start_date) + if end_date is not None: + params["EndDate"] = self._fmt_date(end_date) + if v2: + if use_ddu is not None: + params["UseDdu"] = "true" if use_ddu else "false" + if use_dkp is not None: + params["UseDkp"] = "true" if use_dkp else "false" + + logger.info( + "Objective.get_report: %s/%s/%s gr=%s cn=%s [%s..%s] ddu=%s dkp=%s", + report_section, + report_type, + report_name, + params["GroupName"], + complex_name, + params.get("StartDate"), + params.get("EndDate"), + use_ddu, + use_dkp, + ) + return self._request_authed(path, params) + + # ── удобные обёртки под наши 4 канонические отчёта ────────────────────── + + def report_corpuses_summary( + self, + *, + group_name: str | None = None, + complex_name: str | None = None, + start_date: date | None = None, + end_date: date | None = None, + use_ddu: bool = True, + use_dkp: bool = True, + ) -> Any: + """Объединенные данные / Сводные / Корпуса — агрегаты per oks за период.""" + return self.get_report( + report_section="Объединенные данные", + report_type="Сводные", + report_name="Корпуса", + group_name=group_name, + complex_name=complex_name, + start_date=start_date, + end_date=end_date, + use_ddu=use_ddu, + use_dkp=use_dkp, + ) + + def report_lots_per_flat( + self, + *, + group_name: str | None = None, + complex_name: str | None = None, + use_ddu: bool = True, + use_dkp: bool = True, + ) -> Any: + """Объединенные данные / Поквартирные / Лоты — каждая квартира с историей.""" + return self.get_report( + report_section="Объединенные данные", + report_type="Поквартирные", + report_name="Лоты", + group_name=group_name, + complex_name=complex_name, + use_ddu=use_ddu, + use_dkp=use_dkp, + ) + + def report_lots_summary( + self, + *, + group_name: str | None = None, + complex_name: str | None = None, + start_date: date | None = None, + end_date: date | None = None, + use_ddu: bool = True, + use_dkp: bool = True, + ) -> Any: + """Объединенные данные / Сводные / Лоты — агрегаты per ЖК (mix-структура).""" + return self.get_report( + report_section="Объединенные данные", + report_type="Сводные", + report_name="Лоты", + group_name=group_name, + complex_name=complex_name, + start_date=start_date, + end_date=end_date, + use_ddu=use_ddu, + use_dkp=use_dkp, + ) + + def report_corpuses_per_flat( + self, + *, + group_name: str | None = None, + complex_name: str | None = None, + use_ddu: bool = True, + use_dkp: bool = True, + ) -> Any: + """Объединенные данные / Поквартирные / Корпуса — детально по корпусам.""" + return self.get_report( + report_section="Объединенные данные", + report_type="Поквартирные", + report_name="Корпуса", + group_name=group_name, + complex_name=complex_name, + use_ddu=use_ddu, + use_dkp=use_dkp, + ) + + # ── lifecycle ─────────────────────────────────────────────────────────── + + def close(self) -> None: + try: + self._client.close() + except Exception: + pass + + def __enter__(self) -> ObjectiveClient: + return self + + def __exit__(self, *_exc: Any) -> None: + self.close() + + +# ── удобный one-shot helper ───────────────────────────────────────────────── + + +def fetch_with_raw_log( + fn_name: str, + *, + save_dir: str | None = None, + **kwargs: Any, +) -> tuple[Any, str | None]: + """Вызывает client.(**kwargs), сохраняет raw JSON в файл если задан save_dir. + Возвращает (data, file_path or None). Удобно для smoke-сценариев и backfill.""" + from pathlib import Path + + client = ObjectiveClient() + try: + method = getattr(client, fn_name) + data = method(**kwargs) + finally: + client.close() + + file_path: str | None = None + if save_dir: + Path(save_dir).mkdir(parents=True, exist_ok=True) + ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + slug = ( + "_".join(str(v) for k, v in kwargs.items() if k in ("group_name", "complex_name") and v) + or "all" + ) + slug = slug.replace(" ", "_").replace("/", "_") + file_path = f"{save_dir}/objective_{fn_name}_{slug}_{ts}.json" + Path(file_path).write_text( + json.dumps(data, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return data, file_path diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index d9adcf8f..065a84d7 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -42,6 +42,7 @@ celery_app = Celery( "app.workers.tasks.scrape_kn", "app.workers.tasks.scrape_nspd", "app.workers.tasks.refresh_analytics", + "app.workers.tasks.scrape_objective", ], ) celery_app.conf.timezone = "Europe/Moscow" @@ -85,6 +86,17 @@ celery_app.conf.beat_schedule["refresh-ekb-districts-medians"] = { "kwargs": {"window_months": 24, "min_deals": 50}, } +# Objective sync — еженедельно (объёмы умеренные, ключ платный = бережём limits). +# По умолчанию: пн 05:00 МСК. Если OBJECTIVE_API_KEY пуст — task сразу skipped. +celery_app.conf.beat_schedule["objective-sync"] = { + "task": "tasks.scrape_objective.sync_objective_group", + "schedule": _parse_cron(settings.objective_sync_cron), + "kwargs": { + "group_name": settings.objective_default_group, + "triggered_by": "beat", + }, +} + @worker_ready.connect def _resume_zombie_runs(sender=None, **_kwargs) -> None: diff --git a/backend/app/workers/tasks/scrape_objective.py b/backend/app/workers/tasks/scrape_objective.py new file mode 100644 index 00000000..7b15cb46 --- /dev/null +++ b/backend/app/workers/tasks/scrape_objective.py @@ -0,0 +1,328 @@ +"""Celery task для регулярного sync с api.objctv.ru. + +Стратегия: + Раз в неделю (по умолчанию пн 05:00 МСК) тянем 2 канонических отчёта + по группе из settings.objective_default_group: + - Сводные/Корпуса (за последний полный месяц) → objective_corpus_room_month + - Поквартирные/Лоты (без дат — текущая шахматка) → objective_lots + history + + NB: На текущем тарифе Объектива «Сводные/Лоты» и «Поквартирные/Корпуса» + отвечают HTTP 500 (проверено data/sql/69b_objective_minimal.py). Когда тариф + расширят — добавить обратно в `jobs`. + + Все payload-ы пишутся в objective_raw_reports (jsonb-колонка) и сразу же + парсятся в нормализованный слой через data/sql/70_parse_objective_raw.py. + Если парсинг упал — raw остаётся, можно re-parse через + `python data/sql/70_parse_objective_raw.py --latest-run`. +""" + +from __future__ import annotations + +import json +import logging +from datetime import date, timedelta +from typing import Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.db import SessionLocal +from app.services.scrapers.objective import ( + ObjectiveAPIError, + ObjectiveAuthError, + ObjectiveClient, +) +from app.workers.celery_app import celery_app + +logger = logging.getLogger(__name__) + + +def _start_run(db: Session, group_name: str, triggered_by: str) -> int: + row = db.execute( + text( + """ + INSERT INTO objective_scrape_runs (group_name, triggered_by, status) + VALUES (:gn, :tb, 'running') + RETURNING run_id + """ + ), + {"gn": group_name, "tb": triggered_by}, + ).scalar_one() + db.commit() + return int(row) + + +def _heartbeat(db: Session, run_id: int, **counts: int) -> None: + sets = ["heartbeat_at = NOW()"] + params: dict[str, Any] = {"rid": run_id} + for k, v in counts.items(): + sets.append(f"{k} = :{k}") + params[k] = v + db.execute( + text(f"UPDATE objective_scrape_runs SET {', '.join(sets)} WHERE run_id = :rid"), + params, + ) + db.commit() + + +def _finish_run( + db: Session, + run_id: int, + *, + status: str, + error: str | None = None, + **counts: int, +) -> None: + sets = ["finished_at = NOW()", "status = :status", "error = :error"] + params: dict[str, Any] = {"rid": run_id, "status": status, "error": error} + for k, v in counts.items(): + sets.append(f"{k} = :{k}") + params[k] = v + db.execute( + text(f"UPDATE objective_scrape_runs SET {', '.join(sets)} WHERE run_id = :rid"), + params, + ) + db.commit() + + +def _save_raw( + db: Session, + run_id: int, + *, + report_section: str, + report_type: str, + report_name: str, + group_name: str, + complex_name: str | None, + start_date: date | None, + end_date: date | None, + use_ddu: bool, + use_dkp: bool, + payload: Any, +) -> int: + body = json.dumps(payload, ensure_ascii=False) + row = db.execute( + text( + """ + INSERT INTO objective_raw_reports + (run_id, page, report_section, report_type, report_name, + group_name, complex_name, start_date, end_date, + use_ddu, use_dkp, api_version, payload, payload_size) + VALUES (:run_id, 'Отчеты', :section, :type, :name, + :group_name, :complex_name, :start_date, :end_date, + :use_ddu, :use_dkp, 'v2', CAST(:payload AS jsonb), :size) + RETURNING raw_id + """ + ), + { + "run_id": run_id, + "section": report_section, + "type": report_type, + "name": report_name, + "group_name": group_name, + "complex_name": complex_name, + "start_date": start_date, + "end_date": end_date, + "use_ddu": use_ddu, + "use_dkp": use_dkp, + "payload": body, + "size": len(body.encode("utf-8")), + }, + ).scalar_one() + db.commit() + return int(row) + + +@celery_app.task( + bind=True, + name="tasks.scrape_objective.sync_objective_group", + max_retries=2, +) +def sync_objective_group( + self: Any, + group_name: str | None = None, + triggered_by: str = "beat", +) -> dict[str, Any]: + """Sync 4 канонических отчёта Объектив для одной группы (территории). + + Идемпотентность: каждый прогон создаёт новые objective_raw_reports + (history-style). Нормализация (parse → objective_oks/lots) — отдельная + задача после изучения реальной схемы payload через smoke-script. + """ + if not settings.objective_api_key: + logger.warning("sync_objective_group skipped: OBJECTIVE_API_KEY не задан") + return {"skipped": True, "reason": "no_api_key"} + + group = group_name or settings.objective_default_group + end_date = date.today().replace(day=1) + start_date = (end_date - timedelta(days=1)).replace(day=1) + + db = SessionLocal() + run_id: int | None = None + try: + run_id = _start_run(db, group, triggered_by) + client = ObjectiveClient() + + reports_ok = 0 + reports_failed = 0 + n_requests = 0 + rows_corpus_room = 0 + rows_lots = 0 + rows_history = 0 + + # Импортируем парсер и автоматически нормализуем после save_raw. + # Сделано как late-import чтобы scrape_objective.py подгружался без + # data/sql при unit-тестах celery_app. + from importlib import util as _importlib_util + from pathlib import Path as _Path + + _parser_path = ( + _Path(__file__).resolve().parents[3] / "data" / "sql" / "70_parse_objective_raw.py" + ) + _spec = _importlib_util.spec_from_file_location("objective_parser_70", _parser_path) + if _spec is None or _spec.loader is None: + raise RuntimeError(f"Не удалось загрузить парсер по пути {_parser_path}") + parser_mod = _importlib_util.module_from_spec(_spec) + _spec.loader.exec_module(parser_mod) + + # На нашем тарифе доступны только Сводные/Корпуса и Поквартирные/Лоты. + # «Сводные/Лоты» и «Поквартирные/Корпуса» возвращают 500 (проверено). + jobs: list[tuple[str, str, dict, str, str, str]] = [ + ( + "corp_sum", + "report_corpuses_summary", + { + "group_name": group, + "start_date": start_date, + "end_date": end_date, + "use_ddu": True, + "use_dkp": True, + }, + "Объединенные данные", + "Сводные", + "Корпуса", + ), + ( + "lots_pf", + "report_lots_per_flat", + {"group_name": group, "use_ddu": True, "use_dkp": True}, + "Объединенные данные", + "Поквартирные", + "Лоты", + ), + ] + + try: + for kind, fn_name, params, section, rtype, rname in jobs: + try: + method = getattr(client, fn_name) + payload = method(**params) + n_requests += 1 + raw_id = _save_raw( + db, + run_id, + report_section=section, + report_type=rtype, + report_name=rname, + group_name=group, + complex_name=None, + start_date=params.get("start_date"), + end_date=params.get("end_date"), + use_ddu=params.get("use_ddu", True), + use_dkp=params.get("use_dkp", False), + payload=payload, + ) + reports_ok += 1 + + # Inline-нормализация в objective_corpus_room_month / lots / history. + snap = date.today() + try: + if kind == "corp_sum": + n = parser_mod.parse_corp_sum(payload, group, raw_id, db, dry_run=False) + rows_corpus_room += n + db.execute( + text( + "UPDATE objective_raw_reports " + " SET rows_extracted = :n WHERE raw_id = :rid" + ), + {"n": n, "rid": raw_id}, + ) + elif kind == "lots_pf": + n_lots, n_hist = parser_mod.parse_lots_pf( + payload, raw_id, snap, db, dry_run=False + ) + rows_lots += n_lots + rows_history += n_hist + db.execute( + text( + "UPDATE objective_raw_reports " + " SET rows_extracted = :n WHERE raw_id = :rid" + ), + {"n": n_lots, "rid": raw_id}, + ) + db.commit() + except Exception as parse_err: + # Парсинг упал — raw уже сохранён, можно re-parse позже. + db.rollback() + logger.exception( + "sync_objective_group: parser failed for %s/%s/%s raw_id=%s: %s", + section, + rtype, + rname, + raw_id, + parse_err, + ) + + _heartbeat( + db, + run_id, + reports_ok=reports_ok, + reports_failed=reports_failed, + requests_count=n_requests, + rows_corpus_room=rows_corpus_room, + rows_lots=rows_lots, + rows_history=rows_history, + ) + except (ObjectiveAuthError, ObjectiveAPIError) as e: + reports_failed += 1 + logger.warning( + "sync_objective_group: %s/%s/%s failed: %s", section, rtype, rname, e + ) + finally: + client.close() + + _finish_run( + db, + run_id, + status="done" if reports_failed == 0 else "failed", + error=None if reports_failed == 0 else f"{reports_failed} reports failed", + reports_ok=reports_ok, + reports_failed=reports_failed, + requests_count=n_requests, + rows_corpus_room=rows_corpus_room, + rows_lots=rows_lots, + rows_history=rows_history, + ) + + return { + "run_id": run_id, + "group_name": group, + "reports_ok": reports_ok, + "reports_failed": reports_failed, + "requests": n_requests, + "rows_corpus_room": rows_corpus_room, + "rows_lots": rows_lots, + "rows_history": rows_history, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + } + except Exception as e: + if run_id: + try: + _finish_run(db, run_id, status="failed", error=f"{type(e).__name__}: {e}") + except Exception: + pass + raise + finally: + db.close() diff --git a/data/sql/68_schema_objective.sql b/data/sql/68_schema_objective.sql new file mode 100644 index 00000000..fcf1cf18 --- /dev/null +++ b/data/sql/68_schema_objective.sql @@ -0,0 +1,333 @@ +-- Storage для данных API Объектива (api.objctv.ru). +-- +-- Схема ВЫРОВНЕНА по реальной форме payload, наблюдаемой на пробных вызовах +-- (data/raw/objective_test/20260507_*/), а не по гипотезам. +-- +-- Реальная форма данных: +-- 1. /v2/Report/GetReport?ReportType=Сводные&ReportName=Корпуса +-- → {"result": [ {Месяц, Проект, Девелопер, Район, Класс, Корпус, +-- "Количество комнат (Данные Объектива)", +-- Лотов по ПД шт, Площадь по ПД м2, +-- Сделки ДДУ/ДКП/всего × {count, count_priced, vol, vol_priced, +-- sum_priced_млн, avg_price_per_m2_тыс, +-- avg_area}, +-- Объем предложения шт/м2, Сумм. стоимость в продаже млн, +-- Средняя цена м2 в продаже тыс +-- }, ...] } +-- → long-формат: одна строка = (месяц × корпус × room_bucket) per-row. +-- → corp_sum.json для одного ЖК Парк Культуры за 7 дней = 8 строк (4 bucket × 2 мес). +-- +-- 2. /v2/Report/GetReport?ReportType=Поквартирные&ReportName=Лоты +-- → {"result": [ {Id, "Id проекта", Проект, Корпус, Адрес, Класс, Секция, Этаж, +-- "Номер лота", "Вид помещения", Статус, Продано, +-- "Количество комнат(Сайт девелопера/ПД/Данные объектива)", +-- "Площадь, м2(Сайт девелопера/ПД)", +-- "Расчетный бюджет лота, Р", "Цена за м2, Р", +-- "Цена предложения, Р", "Дельта цена, Р/%", +-- "Дата договора", "Дата регистрации", "Тип сделки", +-- "Тип покупателя", "Номер регистрации", +-- "Тип обременения", Банк, "Дата начала обременения", +-- "Дата актуальности данных из ЕГРН"}, ...] } +-- → per-flat snapshot. `Id` (Objective lot_id) — stable, можно использовать как UPSERT-ключ. +-- +-- 3. /v2/Report/GetReport?ReportType=Сводные&ReportName=Лоты — HTTP 500 на нашем тарифе +-- 4. /v2/Report/GetReport?ReportType=Поквартирные&ReportName=Корпуса — HTTP 500 на нашем тарифе +-- Эти 2 отчёта недоступны / не реализованы у нас. Если откроют — добавим. +-- +-- Стратегия storage: +-- - RAW: objective_raw_reports (jsonb payload) — страховка от ломки схемы. +-- - NORMALIZED: +-- - objective_corpus_room_month (long-формат из corp_sum) +-- - objective_lots (текущая запись per-flat, UPSERT по Objective.Id) +-- - objective_lots_history (append-only снапшоты per-flat per snapshot_date) +-- - MAPPING: objective_complex_mapping связывает Objective.ComplexName с domrf_kn_objects.obj_id. +-- +-- Идемпотентно через IF NOT EXISTS. + +CREATE EXTENSION IF NOT EXISTS postgis; + +-- ── 0. Runs (журнал sync) ──────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS objective_scrape_runs ( + run_id BIGSERIAL PRIMARY KEY, + started_at timestamptz NOT NULL DEFAULT NOW(), + finished_at timestamptz, + heartbeat_at timestamptz, + group_name TEXT NOT NULL, + reports_ok INT NOT NULL DEFAULT 0, + reports_failed INT NOT NULL DEFAULT 0, + rows_corpus_room INT NOT NULL DEFAULT 0, + rows_lots INT NOT NULL DEFAULT 0, + rows_history INT NOT NULL DEFAULT 0, + requests_count INT NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'running', -- running | done | failed | zombie + error TEXT, + triggered_by TEXT NOT NULL DEFAULT 'beat' -- beat | manual | resume +); + +CREATE INDEX IF NOT EXISTS objective_runs_status_idx + ON objective_scrape_runs(status, started_at DESC); + +COMMENT ON TABLE objective_scrape_runs IS + 'Журнал запусков sync с api.objctv.ru. Один прогон = один (group_name) пакет отчётов.'; + +-- ── 1. RAW payloads ────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS objective_raw_reports ( + raw_id BIGSERIAL PRIMARY KEY, + run_id BIGINT REFERENCES objective_scrape_runs(run_id) ON DELETE SET NULL, + fetched_at timestamptz NOT NULL DEFAULT NOW(), + page TEXT NOT NULL, + report_section TEXT NOT NULL, + report_type TEXT NOT NULL, + report_name TEXT NOT NULL, + group_name TEXT NOT NULL, + complex_name TEXT, + start_date DATE, + end_date DATE, + use_ddu BOOLEAN, + use_dkp BOOLEAN, + api_version TEXT NOT NULL DEFAULT 'v2', + payload jsonb NOT NULL, + payload_size INT NOT NULL, + rows_extracted INT -- сколько rows распарсено в normalized слой +); + +CREATE INDEX IF NOT EXISTS objective_raw_combo_idx + ON objective_raw_reports(report_section, report_type, report_name, + group_name, fetched_at DESC); +CREATE INDEX IF NOT EXISTS objective_raw_dates_idx + ON objective_raw_reports(start_date, end_date) WHERE start_date IS NOT NULL; +CREATE INDEX IF NOT EXISTS objective_raw_run_idx + ON objective_raw_reports(run_id); + +COMMENT ON TABLE objective_raw_reports IS + 'Сырой JSON-payload каждого GetReport-вызова. Источник для re-parse при ' + 'изменениях схемы Objective или переноса normalized-слоя на новую версию.'; + +-- ── 2. NORMALIZED — Сводные/Корпуса (long: month × corpus × room_bucket) ──── + +-- Заменяет старую objective_oks (была спроектирована под per-corpus с +-- rooms[N]_count колонками, не подходит под реальный long-формат). +CREATE TABLE IF NOT EXISTS objective_corpus_room_month ( + id BIGSERIAL PRIMARY KEY, + -- Ключ записи (month × corpus × room_bucket) + report_month DATE NOT NULL, -- 1-е число месяца из "Месяц": "апрель-2026" + group_name TEXT NOT NULL, + project_name TEXT NOT NULL, -- "Парк Культуры" + developer TEXT, -- "PRINZIP" + district TEXT, -- "Автовокзал" + class TEXT, -- "Комфорт" / "Бизнес" / "Стандарт" / ... + corpus_name TEXT NOT NULL, -- "1.1" + room_bucket TEXT NOT NULL, -- "студия" | "1" | "2" | "3" | "4" | "5+" + rooms_int SMALLINT, -- нормализованный 0=студия, 1, 2, 3, 4, 5 + -- Динамика стройки (повторяется в каждой строке, but stable per-corpus per snapshot) + sales_start_date DATE, + plan_completion_date DATE, -- "Планируемая дата ввода" + actual_completion_date DATE, -- "Фактическая дата ввода" + months_in_realization INT, + -- Capacity по ПД + lots_pd_count INT, -- "Лотов по ПД, шт." + lots_pd_area NUMERIC, -- "Площадь по ПД, м2." + -- Сделки ДДУ + deals_ddu_count INT, + deals_ddu_count_priced INT, -- из них с известной ценой + deals_ddu_vol_m2 NUMERIC, + deals_ddu_vol_m2_priced NUMERIC, + deals_ddu_sum_mln_rub NUMERIC, -- "млн.Р" + deals_ddu_avg_price_thousand_rub_per_m2 NUMERIC, -- "тыс.Р/м2" + deals_ddu_avg_area_m2 NUMERIC, + -- Сделки ДКП + deals_dkp_count INT, + deals_dkp_count_priced INT, + deals_dkp_vol_m2 NUMERIC, + deals_dkp_vol_m2_priced NUMERIC, + deals_dkp_sum_mln_rub NUMERIC, + deals_dkp_avg_price_thousand_rub_per_m2 NUMERIC, + deals_dkp_avg_area_m2 NUMERIC, + -- Сделки всего (= ДДУ + ДКП) + deals_total_count INT, + deals_total_count_priced INT, + deals_total_vol_m2 NUMERIC, + deals_total_vol_m2_priced NUMERIC, + deals_total_sum_mln_rub NUMERIC, + deals_total_avg_price_thousand_rub_per_m2 NUMERIC, + deals_total_avg_area_m2 NUMERIC, + -- Текущее предложение (stock на конец месяца) + offer_count INT, -- "Объем предложения, шт." + offer_area_m2 NUMERIC, -- "Объем предложения, м2." + offer_sum_mln_rub NUMERIC, -- "Суммарная стоимость лотов в продаже, млн.Р" + offer_avg_price_thousand_rub_per_m2 NUMERIC, -- "Средняя цена м2 лота в продаже, тыс.Р/м2" + -- Audit + raw_id BIGINT REFERENCES objective_raw_reports(raw_id) ON DELETE SET NULL, + raw_props jsonb, + fetched_at timestamptz NOT NULL DEFAULT NOW(), + + -- Один (месяц, корпус, room_bucket, group) — одна запись. + UNIQUE (report_month, group_name, project_name, corpus_name, room_bucket) +); + +CREATE INDEX IF NOT EXISTS objective_crm_project_month_idx + ON objective_corpus_room_month(project_name, report_month DESC); +CREATE INDEX IF NOT EXISTS objective_crm_district_class_idx + ON objective_corpus_room_month(district, class, report_month DESC); +CREATE INDEX IF NOT EXISTS objective_crm_developer_idx + ON objective_corpus_room_month(developer, report_month DESC); +CREATE INDEX IF NOT EXISTS objective_crm_rooms_idx + ON objective_corpus_room_month(rooms_int, report_month DESC); + +COMMENT ON TABLE objective_corpus_room_month IS + 'Сводные/Корпуса в реальном long-формате: (месяц × корпус × количество комнат). ' + 'Главное преимущество над domrf_kn — готовая monthly history с разбивкой по mix ' + '(studio/1/2/3/4) с явными метриками: остатки, средняя цена, средняя площадь.'; + +-- ── 3. NORMALIZED — Поквартирные/Лоты ──────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS objective_lots ( + id BIGSERIAL PRIMARY KEY, + objective_lot_id BIGINT NOT NULL UNIQUE, -- "Id" в payload — stable Objective ID + objective_project_id BIGINT, -- "Id проекта" + -- Identification + project_name TEXT NOT NULL, + developer TEXT, + city TEXT, + district TEXT, + corpus_name TEXT, + address TEXT, + class TEXT, -- "комфорт" в lots_pf (lowercase!) vs "Комфорт" в corp_sum + section TEXT, + floor INT, + lot_number TEXT, -- "Номер лота" — может быть "1а" etc, поэтому TEXT + premise_kind TEXT, -- "квартира" | "нежилое" | "паркинг" | "кладовая" + -- Stage + sales_start_date DATE, + plan_completion_date DATE, + actual_completion_date DATE, + readiness_pct SMALLINT, -- "17%" → 17 + construction_stage TEXT, -- "строящийся" | "сдан" | ... + finishing TEXT, -- "Отделка по корпусу" — "под чистовую" | "с отделкой" | ... + -- Status + status TEXT, -- "резерв" | "свободна" | "продан" | "бронь" + is_sold BOOLEAN, -- "Продано": "да"/"нет" → true/false + -- Mix (3 источника от Объектива) + rooms_dev_site TEXT, -- "Количество комнат(Сайт девелопера)" + rooms_pd TEXT, -- "Количество комнат(ПД)" + rooms_objective TEXT, -- "Количество комнат(Данные объектива)" — самое нормализованное + rooms_int SMALLINT, -- нормализуем objective: студия→0, "1"→1, ... + -- Areas + area_dev_site NUMERIC, + area_pd NUMERIC, + -- Pricing + price_calculated_total_rub NUMERIC, -- "Расчетный бюджет лота, Р" + price_per_m2_rub NUMERIC, -- "Цена за м2, Р" + price_offer_total_rub NUMERIC, -- "Цена предложения, Р" + price_delta_rub NUMERIC, -- "Дельта цена, Р" + price_delta_pct NUMERIC, -- "Дельта цена, %" + pricing_method TEXT, -- "Способ определения цены" + price_set_date DATE, -- "Дата установки цены" + price_actual_date DATE, -- "Дата актуальности цены" + -- Deal + contract_date DATE, -- "Дата договора" + registration_date DATE, -- "Дата регистрации" + contract_type TEXT, -- "Тип сделки": ДДУ | ДКП | ... + buyer_type TEXT, -- "Тип покупателя" + registration_number TEXT, -- "Номер регистрации" + -- Encumbrance / mortgage + encumbrance_type TEXT, -- "Тип обременения" + bank_name TEXT, -- "Банк" + encumbrance_start_date DATE, -- "Дата начала обременения" + egrn_actual_date DATE, -- "Дата актуальности данных из ЕГРН" + -- Audit + raw_id BIGINT REFERENCES objective_raw_reports(raw_id) ON DELETE SET NULL, + raw_props jsonb, + snapshot_date DATE NOT NULL, -- дата снимка (= когда мы синкнули) + fetched_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS objective_lots_project_idx + ON objective_lots(project_name, snapshot_date DESC); +CREATE INDEX IF NOT EXISTS objective_lots_status_idx + ON objective_lots(status) WHERE status IS NOT NULL; +CREATE INDEX IF NOT EXISTS objective_lots_rooms_area_idx + ON objective_lots(rooms_int, area_pd) WHERE area_pd IS NOT NULL; +CREATE INDEX IF NOT EXISTS objective_lots_district_class_idx + ON objective_lots(district, class) WHERE district IS NOT NULL; +CREATE INDEX IF NOT EXISTS objective_lots_bank_idx + ON objective_lots(bank_name) WHERE bank_name IS NOT NULL; +CREATE INDEX IF NOT EXISTS objective_lots_reg_date_idx + ON objective_lots(registration_date) WHERE registration_date IS NOT NULL; + +COMMENT ON TABLE objective_lots IS + 'Per-flat snapshot из Объектива (Поквартирные/Лоты). UPSERT по objective_lot_id. ' + 'Текущее (самое последнее) состояние каждой квартиры. Полная history — ' + 'в objective_lots_history.'; + +-- ── 4. NORMALIZED — История квартир (append-only snapshots) ───────────────── + +-- Объектив НЕ отдаёт per-day price/status timeline в одном вызове. +-- Зато при еженедельном sync мы можем собирать сами: при каждом вызове +-- делаем INSERT в _history для каждого изменившегося лота. Это даст нам +-- weekly-resolution timeline price/status/area per flat — достаточно для +-- elasticity и velocity-anomaly детектора. + +CREATE TABLE IF NOT EXISTS objective_lots_history ( + id BIGSERIAL PRIMARY KEY, + objective_lot_id BIGINT NOT NULL, -- ссылка на objective_lots.objective_lot_id + snapshot_date DATE NOT NULL, -- день sync + status TEXT, + is_sold BOOLEAN, + price_calculated_total_rub NUMERIC, + price_per_m2_rub NUMERIC, + price_offer_total_rub NUMERIC, + price_delta_pct NUMERIC, + area_pd NUMERIC, + contract_date DATE, + registration_date DATE, + contract_type TEXT, + bank_name TEXT, + raw_id BIGINT REFERENCES objective_raw_reports(raw_id) ON DELETE SET NULL, + fetched_at timestamptz NOT NULL DEFAULT NOW(), + -- На каждый (lot, snapshot_date) — ровно одна запись. + UNIQUE (objective_lot_id, snapshot_date) +); + +CREATE INDEX IF NOT EXISTS objective_lots_history_lot_idx + ON objective_lots_history(objective_lot_id, snapshot_date DESC); +CREATE INDEX IF NOT EXISTS objective_lots_history_date_idx + ON objective_lots_history(snapshot_date); +CREATE INDEX IF NOT EXISTS objective_lots_history_reg_idx + ON objective_lots_history(registration_date) + WHERE registration_date IS NOT NULL; + +COMMENT ON TABLE objective_lots_history IS + 'Append-only weekly snapshots каждого лота. Главное преимущество для ' + 'velocity-anomaly detection и для elasticity (точность регрессии × лет).'; + +-- ── 5. MAPPING — Objective ↔ domrf_kn_objects ──────────────────────────────── + +CREATE TABLE IF NOT EXISTS objective_complex_mapping ( + id BIGSERIAL PRIMARY KEY, + objective_complex_name TEXT NOT NULL, -- "Парк Культуры" + objective_project_id BIGINT, -- из payload "Id проекта" + objective_group TEXT NOT NULL, + domrf_obj_id BIGINT, -- domrf_kn_objects.obj_id; NULL = не сматчили + is_reviewed BOOLEAN NOT NULL DEFAULT FALSE, + match_method TEXT, -- 'exact_name' | 'fuzzy_name' | 'manual' | 'spatial' + match_score NUMERIC, -- 0..1 для fuzzy + note TEXT, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW(), + UNIQUE (objective_complex_name, objective_group) +); + +CREATE INDEX IF NOT EXISTS objective_mapping_domrf_idx + ON objective_complex_mapping(domrf_obj_id) WHERE domrf_obj_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS objective_mapping_unreviewed_idx + ON objective_complex_mapping(is_reviewed) WHERE is_reviewed = FALSE; +CREATE INDEX IF NOT EXISTS objective_mapping_project_id_idx + ON objective_complex_mapping(objective_project_id) WHERE objective_project_id IS NOT NULL; + +COMMENT ON TABLE objective_complex_mapping IS + 'Маппинг Objective.ComplexName ↔ domrf_kn_objects.obj_id. is_reviewed=TRUE — ' + 'человек подтвердил соответствие. Auto-matching пишет с is_reviewed=FALSE.'; diff --git a/data/sql/69_objective_smoke.py b/data/sql/69_objective_smoke.py new file mode 100644 index 00000000..e88ea04c --- /dev/null +++ b/data/sql/69_objective_smoke.py @@ -0,0 +1,121 @@ +"""Smoke-тест Objective API: GetToken + 4 канонических отчёта. + +Запуск: + cd backend + OBJECTIVE_API_KEY= uv run python ../data/sql/69_objective_smoke.py + +Что делает: + 1. Получает Bearer-токен (кеш в Redis с TTL). + 2. Тянет 4 отчёта по Екатеринбургу: + a. Сводные/Корпуса (период last 1 month) + b. Сводные/Лоты (период last 1 month) + c. Поквартирные/Корпуса + d. Поквартирные/Лоты + 3. Сохраняет raw JSON в data/raw/objective_smoke// + 4. Печатает size + первые поля каждого отчёта (схему-прицел). + +Это нужно один раз, чтобы понять реальную форму payload и доработать +парсер 70_parse_objective.py (следующий шаг — нормализация в objective_*). +""" + +from __future__ import annotations + +import json +import os +import sys +from datetime import date, datetime, timedelta +from pathlib import Path + +# Гарантия что запускается из репо-root через `cd backend; uv run python ...` +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "backend")) + +from app.services.scrapers.objective import ObjectiveAuthError, ObjectiveClient + + +def main() -> int: + if not os.environ.get("OBJECTIVE_API_KEY"): + print( + "ERROR: переменная OBJECTIVE_API_KEY не задана. " + "Запусти: OBJECTIVE_API_KEY= uv run python ../data/sql/69_objective_smoke.py" + ) + return 1 + + end = date.today().replace(day=1) + start = (end - timedelta(days=1)).replace(day=1) + out_dir = Path("data/raw/objective_smoke") / datetime.utcnow().strftime("%Y%m%d_%H%M%S") + out_dir.mkdir(parents=True, exist_ok=True) + + try: + client = ObjectiveClient() + except ObjectiveAuthError as e: + print(f"AUTH ERROR: {e}") + return 2 + + reports: list[tuple[str, str, dict]] = [ + ( + "corpuses_summary", + "Объединенные данные / Сводные / Корпуса", + dict(start_date=start, end_date=end, use_ddu=True, use_dkp=True), + ), + ( + "lots_summary", + "Объединенные данные / Сводные / Лоты", + dict(start_date=start, end_date=end, use_ddu=True, use_dkp=True), + ), + ( + "corpuses_per_flat", + "Объединенные данные / Поквартирные / Корпуса", + dict(use_ddu=True, use_dkp=True), + ), + ( + "lots_per_flat", + "Объединенные данные / Поквартирные / Лоты", + dict(use_ddu=True, use_dkp=True), + ), + ] + + method_map = { + "corpuses_summary": client.report_corpuses_summary, + "lots_summary": client.report_lots_summary, + "corpuses_per_flat": client.report_corpuses_per_flat, + "lots_per_flat": client.report_lots_per_flat, + } + + print(f"=== Objective smoke ({start} → {end}) → {out_dir}") + + for slug, label, params in reports: + print(f"\n--- {label}") + try: + data = method_map[slug](**params) + except Exception as e: + print(f" ❌ {type(e).__name__}: {e}") + continue + + path = out_dir / f"{slug}.json" + text = json.dumps(data, ensure_ascii=False, indent=2) + path.write_text(text, encoding="utf-8") + size_kb = len(text) / 1024.0 + + # Краткий schema-probe + if isinstance(data, list): + shape = f"list len={len(data)}" + sample_keys = list(data[0].keys()) if data and isinstance(data[0], dict) else [] + elif isinstance(data, dict): + shape = f"dict keys={list(data.keys())}" + sample_keys = [] + else: + shape = f"{type(data).__name__}" + sample_keys = [] + + print(f" ✓ saved {path.name} ({size_kb:.1f} KB)") + print(f" shape: {shape}") + if sample_keys: + print(f" first row keys: {sample_keys[:25]}") + + client.close() + print(f"\nDONE. Папка: {out_dir.absolute()}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/data/sql/69b_objective_minimal.py b/data/sql/69b_objective_minimal.py new file mode 100644 index 00000000..13c77890 --- /dev/null +++ b/data/sql/69b_objective_minimal.py @@ -0,0 +1,200 @@ +"""Минимальный probe Objective API: 4 отчёта × минимальная выборка. + +Цель — посмотреть реальную форму payload-ов, не выкачивая весь Екатеринбург. +Ограничения объёма: + - period: только последние 7 дней (для Сводных) + - complex_name: фильтр по 1 ЖК (по умолчанию «ЖК "Парк Культуры"»; меняется флагом) + +Что выводит: + - HTTP-статус через клиент (raw exception если что) + - размер JSON в KB + - тип (dict/list) + len/keys + - первые 1-2 элемента (для list) или верхние ключи (для dict) — обрезанные до 1KB + - сохраняет полный payload в data/raw/objective_test//.json + +Запуск: + cd backend + OBJECTIVE_API_KEY= uv run python -X utf8 ../data/sql/69b_objective_minimal.py + OBJECTIVE_API_KEY= uv run python -X utf8 ../data/sql/69b_objective_minimal.py --complex 'ЖК "Сибирский сад"' + OBJECTIVE_API_KEY= uv run python -X utf8 ../data/sql/69b_objective_minimal.py --no-complex --days 3 + +Notes: + - Если поле ComplexName в Objective отличается ('ЖК "Парк Культуры"' vs 'Парк Культуры' + vs 'ЖК Парк Культуры') — поменяй --complex или поставь --no-complex. + - --no-complex берёт ВСЕ ЖК группы, но всё ещё с период=N дней (для Сводных). + Для Поквартирных без --complex может быть очень большой ответ — будь осторожен. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +# repo-root → /backend для import'а app.* +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "backend")) + +from app.services.scrapers.objective import ( # noqa: E402 + ObjectiveAPIError, + ObjectiveAuthError, + ObjectiveClient, +) + + +def _shape(data) -> str: + if isinstance(data, list): + return f"list[{len(data)}]" + if isinstance(data, dict): + return f"dict(keys={list(data.keys())[:15]})" + return type(data).__name__ + + +def _peek(data, max_chars: int = 1200) -> str: + """Короткий человеко-читаемый peek первых элементов / ключей.""" + if isinstance(data, list): + if not data: + return " (пустой список)" + sample = data[0] + if isinstance(sample, dict): + keys = list(sample.keys()) + return f" [0] keys ({len(keys)}): {keys[:30]}" + return f" [0]: {str(sample)[:max_chars]}" + if isinstance(data, dict): + out = [] + for k, v in list(data.items())[:10]: + v_repr = ( + f"list[{len(v)}]" if isinstance(v, list) + else f"dict({len(v)} keys)" if isinstance(v, dict) + else str(v)[:120] + ) + out.append(f" {k!r} -> {v_repr}") + return "\n".join(out) + s = str(data) + return f" {s[:max_chars]}{'…' if len(s) > max_chars else ''}" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--complex", default='Парк Культуры', + help='ComplexName-фильтр (default: "Парк Культуры", без префикса ЖК и без кавычек — ' + 'эмпирически проверено что Объектив требует именно такой формат)') + ap.add_argument("--no-complex", action="store_true", + help="Не передавать ComplexName (вся группа)") + ap.add_argument("--group", default=None, + help="GroupName (default: settings.objective_default_group = Екатеринбург)") + ap.add_argument("--days", type=int, default=7, + help="Период для Сводных отчётов в днях (default: 7)") + ap.add_argument("--only", choices=("corp_sum", "lots_sum", "corp_pf", "lots_pf"), + help="Запустить только один отчёт") + ap.add_argument("--inter-delay", type=int, default=15, + help="Пауза в секундах между отчётами (default: 15). " + "У Objective жёсткий rate-limit: <1 запрос/30с на дешёвых тарифах.") + ap.add_argument("--rate-ms", type=int, default=3000, + help="Минимальный интервал между HTTP-запросами в мс (default: 3000)") + ap.add_argument("--retries", type=int, default=2, + help="Сколько раз ретраить 429/5xx (default: 2). 0 = не ретраить вообще.") + args = ap.parse_args() + + if not os.environ.get("OBJECTIVE_API_KEY"): + # Конфиг также читает из backend/.env — попробуем через settings. + from app.core.config import settings + if not settings.objective_api_key: + print("ERROR: OBJECTIVE_API_KEY не задан (env + backend/.env пустые).") + return 1 + + end_date = date.today() + start_date = end_date - timedelta(days=args.days) + complex_name = None if args.no_complex else args.complex + out_dir = Path("data/raw/objective_test") / datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + out_dir.mkdir(parents=True, exist_ok=True) + + print(f"=== Objective minimal probe") + print(f" group = {args.group or '(default settings)'}") + print(f" complex = {complex_name or '(none — вся группа)'}") + print(f" period = {start_date} .. {end_date} ({args.days}d)") + print(f" output = {out_dir.absolute()}") + print() + + try: + client = ObjectiveClient(rate_ms=args.rate_ms, retries=args.retries) + except ObjectiveAuthError as e: + print(f"AUTH ERROR при создании клиента: {e}") + return 2 + + # 4 канонических отчёта × минимальные параметры + plan = [ + ("corp_sum", "1. Сводные / Корпуса", + lambda: client.report_corpuses_summary( + group_name=args.group, complex_name=complex_name, + start_date=start_date, end_date=end_date, + use_ddu=True, use_dkp=True, + )), + ("lots_sum", "2. Сводные / Лоты", + lambda: client.report_lots_summary( + group_name=args.group, complex_name=complex_name, + start_date=start_date, end_date=end_date, + use_ddu=True, use_dkp=True, + )), + ("corp_pf", "3. Поквартирные / Корпуса", + lambda: client.report_corpuses_per_flat( + group_name=args.group, complex_name=complex_name, + use_ddu=True, use_dkp=True, + )), + ("lots_pf", "4. Поквартирные / Лоты", + lambda: client.report_lots_per_flat( + group_name=args.group, complex_name=complex_name, + use_ddu=True, use_dkp=True, + )), + ] + if args.only: + plan = [p for p in plan if p[0] == args.only] + + summary = [] + for idx, (slug, label, fn) in enumerate(plan): + if idx > 0 and args.inter_delay > 0: + print(f"... пауза {args.inter_delay}s перед следующим отчётом (rate-limit Objective)") + time.sleep(args.inter_delay) + print(f"--- {label}") + t0 = time.time() + try: + data = fn() + except ObjectiveAuthError as e: + print(f" ❌ AUTH: {e}\n") + summary.append((slug, "auth_error", 0)) + continue + except ObjectiveAPIError as e: + print(f" ❌ API: {e}\n") + summary.append((slug, "api_error", 0)) + continue + except Exception as e: # noqa: BLE001 + print(f" ❌ {type(e).__name__}: {e}\n") + summary.append((slug, type(e).__name__, 0)) + continue + elapsed = time.time() - t0 + + text = json.dumps(data, ensure_ascii=False, indent=2) + size_kb = len(text) / 1024.0 + path = out_dir / f"{slug}.json" + path.write_text(text, encoding="utf-8") + + print(f" ✓ {elapsed:.2f}s size={size_kb:.1f} KB shape={_shape(data)}") + print(_peek(data)) + print(f" saved → {path.relative_to(Path.cwd()) if path.is_relative_to(Path.cwd()) else path}") + print() + summary.append((slug, "ok", size_kb)) + + client.close() + + print("=== Сводка") + for slug, status, size in summary: + print(f" {slug:10} {status:12} {size:.1f} KB") + print(f"\nDONE. Файлы в: {out_dir.absolute()}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/data/sql/70_parse_objective_raw.py b/data/sql/70_parse_objective_raw.py new file mode 100644 index 00000000..6cb04a8f --- /dev/null +++ b/data/sql/70_parse_objective_raw.py @@ -0,0 +1,638 @@ +"""Парсер Objective raw → normalized слой. + +Читает payload (либо из БД objective_raw_reports.raw_id, либо из локального +JSON-файла для smoke-теста) и раскладывает по таблицам: + + - corp_sum (Сводные/Корпуса) → objective_corpus_room_month + - lots_pf (Поквартирные/Лоты) → objective_lots (UPSERT) + objective_lots_history (INSERT) + +Запуск: + cd backend + + # Smoke по локальным файлам (без БД): + uv run python -X utf8 ../data/sql/70_parse_objective_raw.py \ + --file-corp-sum ../data/raw/objective_test/20260507_162110/corp_sum.json \ + --file-lots-pf ../data/raw/objective_test/20260507_162110/lots_pf.json \ + --dry-run + + # Из БД по raw_id (нужен schema 68 + DATABASE_URL): + uv run python -X utf8 ../data/sql/70_parse_objective_raw.py --raw-id 123 + + # Все unparsed raw из последнего run: + uv run python -X utf8 ../data/sql/70_parse_objective_raw.py --latest-run + +Что считается «unparsed»: + rows_extracted IS NULL ИЛИ rows_extracted = 0 в objective_raw_reports. + +Идемпотентность: + - corpus_room_month: UNIQUE (report_month, group, project, corpus, room_bucket) + → ON CONFLICT DO UPDATE (перезатирает старое значение метрик; это правильно + т.к. отчёт за месяц может уточняться неделя-к-неделе пока месяц не закрыт) + - lots: UNIQUE objective_lot_id → ON CONFLICT DO UPDATE + - lots_history: UNIQUE (objective_lot_id, snapshot_date) → ON CONFLICT DO NOTHING + (один снимок per день — повторный sync в тот же день не создаёт дубль) +""" + +from __future__ import annotations + +import argparse +import json +import logging +import re +import sys +from datetime import date, datetime +from pathlib import Path +from typing import Any + +# repo-root → /backend +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "backend")) + +from sqlalchemy import text # noqa: E402 + +from app.core.db import SessionLocal # noqa: E402 + +logger = logging.getLogger("objective_parser") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + + +# ── helpers ───────────────────────────────────────────────────────────────── + +_RU_MONTHS = { + "январь": 1, "февраль": 2, "март": 3, "апрель": 4, "май": 5, "июнь": 6, + "июль": 7, "август": 8, "сентябрь": 9, "октябрь": 10, "ноябрь": 11, "декабрь": 12, +} + + +def _parse_ru_month(s: str | None) -> date | None: + """'апрель-2026' → date(2026, 4, 1). Также допускает 'Апрель 2026'.""" + if not s: + return None + parts = re.split(r"[-\s]+", s.strip().lower()) + if len(parts) != 2: + return None + month_name, year_str = parts + month = _RU_MONTHS.get(month_name) + if not month: + return None + try: + return date(int(year_str), month, 1) + except (ValueError, TypeError): + return None + + +def _parse_iso_date(s: str | None) -> date | None: + if not s: + return None + # Объектив отдаёт YYYY-MM-DD + try: + return date.fromisoformat(s[:10]) + except (ValueError, TypeError): + return None + + +def _to_int(v: Any) -> int | None: + if v is None or v == "": + return None + try: + return int(v) + except (ValueError, TypeError): + return None + + +def _to_num(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (ValueError, TypeError): + return None + + +def _normalize_rooms(s: str | None) -> int | None: + """'студия' → 0, '1' → 1, … 'неопределенный тип' → None.""" + if not s: + return None + s_low = s.strip().lower() + if "студ" in s_low: + return 0 + # Извлекаем первое число + m = re.match(r"^(\d+)", s_low) + if m: + n = int(m.group(1)) + return min(n, 5) # 5+ всё агрегируем под 5 + return None + + +def _parse_pct(s: str | None) -> int | None: + """'17%' → 17.""" + if s is None: + return None + if isinstance(s, (int, float)): + return int(s) + m = re.match(r"^\s*(\d+)", str(s)) + return int(m.group(1)) if m else None + + +def _parse_bool_yes(v: Any) -> bool | None: + """'да' → True, 'нет' → False.""" + if v is None or v == "": + return None + s = str(v).strip().lower() + if s in ("да", "true", "1", "yes"): + return True + if s in ("нет", "false", "0", "no"): + return False + return None + + +# ── corp_sum (Сводные/Корпуса) → objective_corpus_room_month ──────────────── + + +def _row_to_corp_room_month(row: dict, group_name: str, raw_id: int | None) -> dict: + rooms_raw = row.get("Количество комнат (Данные Объектива)") + return { + "report_month": _parse_ru_month(row.get("Месяц")), + "group_name": group_name, + "project_name": row.get("Проект"), + "developer": row.get("Девелопер"), + "district": row.get("Район"), + "class": row.get("Класс"), + "corpus_name": row.get("Корпус"), + "room_bucket": rooms_raw, + "rooms_int": _normalize_rooms(rooms_raw), + "sales_start_date": _parse_iso_date(row.get("Старт продаж")), + "plan_completion_date": _parse_iso_date(row.get("Планируемая дата ввода")), + "actual_completion_date": _parse_iso_date(row.get("Фактическая дата ввода")), + "months_in_realization": _to_int(row.get("Месяцев в реализации")), + "lots_pd_count": _to_int(row.get("Лотов по ПД, шт.")), + "lots_pd_area": _to_num(row.get("Площадь по ПД, м2.")), + # ДДУ + "deals_ddu_count": _to_int(row.get("Количество в сделках (ДДУ), шт.")), + "deals_ddu_count_priced": _to_int(row.get("Количество лотов в сделках (ДДУ с ценами), шт.")), + "deals_ddu_vol_m2": _to_num(row.get("Объем реализации (ДДУ), м2.")), + "deals_ddu_vol_m2_priced": _to_num(row.get("Объем реализации (ДДУ с ценами), м2.")), + "deals_ddu_sum_mln_rub": _to_num(row.get("Суммарная стоимость лотов (ДДУ с ценами), млн.Р")), + "deals_ddu_avg_price_thousand_rub_per_m2": _to_num( + row.get("Средняя цена м2 лота в сделках (ДДУ), тыс.Р/м2") + ), + "deals_ddu_avg_area_m2": _to_num(row.get("Средняя площадь лота в сделках (ДДУ), м2")), + # ДКП + "deals_dkp_count": _to_int(row.get("Количество в сделках (ДКП), шт.")), + "deals_dkp_count_priced": _to_int(row.get("Количество лотов в сделках (ДКП с ценами), шт.")), + "deals_dkp_vol_m2": _to_num(row.get("Объем реализации (ДКП), м2.")), + "deals_dkp_vol_m2_priced": _to_num(row.get("Объем реализации (ДКП с ценами), м2.")), + "deals_dkp_sum_mln_rub": _to_num(row.get("Суммарная стоимость лотов (ДКП с ценами), млн.Р")), + "deals_dkp_avg_price_thousand_rub_per_m2": _to_num( + row.get("Средняя цена м2 лота в сделках (ДКП), тыс.Р/м2") + ), + "deals_dkp_avg_area_m2": _to_num(row.get("Средняя площадь лота в сделках (ДКП), м2")), + # Всего + "deals_total_count": _to_int(row.get("Количество в сделках (всего), шт.")), + "deals_total_count_priced": _to_int(row.get("Количество лотов в сделках (с ценами), шт.")), + "deals_total_vol_m2": _to_num(row.get("Объем реализации (всего), м2.")), + "deals_total_vol_m2_priced": _to_num(row.get("Объем реализации (с ценами), м2.")), + "deals_total_sum_mln_rub": _to_num(row.get("Суммарная стоимость лотов (с ценами), млн.Р")), + "deals_total_avg_price_thousand_rub_per_m2": _to_num( + row.get("Средняя цена м2 лота в сделках, тыс.Р/м2") + ), + "deals_total_avg_area_m2": _to_num(row.get("Средняя площадь лота в сделках, м2")), + # Предложение + "offer_count": _to_int(row.get("Объем предложения, шт.")), + "offer_area_m2": _to_num(row.get("Объем предложения, м2.")), + "offer_sum_mln_rub": _to_num(row.get("Суммарная стоимость лотов в продаже, млн.Р")), + "offer_avg_price_thousand_rub_per_m2": _to_num( + row.get("Средняя цена м2 лота в продаже, тыс.Р/м2") + ), + # Audit + "raw_id": raw_id, + "raw_props": json.dumps(row, ensure_ascii=False), + } + + +_CRM_UPSERT = text( + """ + INSERT INTO objective_corpus_room_month ( + report_month, group_name, project_name, developer, district, class, + corpus_name, room_bucket, rooms_int, + sales_start_date, plan_completion_date, actual_completion_date, + months_in_realization, lots_pd_count, lots_pd_area, + deals_ddu_count, deals_ddu_count_priced, deals_ddu_vol_m2, + deals_ddu_vol_m2_priced, deals_ddu_sum_mln_rub, + deals_ddu_avg_price_thousand_rub_per_m2, deals_ddu_avg_area_m2, + deals_dkp_count, deals_dkp_count_priced, deals_dkp_vol_m2, + deals_dkp_vol_m2_priced, deals_dkp_sum_mln_rub, + deals_dkp_avg_price_thousand_rub_per_m2, deals_dkp_avg_area_m2, + deals_total_count, deals_total_count_priced, deals_total_vol_m2, + deals_total_vol_m2_priced, deals_total_sum_mln_rub, + deals_total_avg_price_thousand_rub_per_m2, deals_total_avg_area_m2, + offer_count, offer_area_m2, offer_sum_mln_rub, + offer_avg_price_thousand_rub_per_m2, + raw_id, raw_props + ) VALUES ( + :report_month, :group_name, :project_name, :developer, :district, :class, + :corpus_name, :room_bucket, :rooms_int, + :sales_start_date, :plan_completion_date, :actual_completion_date, + :months_in_realization, :lots_pd_count, :lots_pd_area, + :deals_ddu_count, :deals_ddu_count_priced, :deals_ddu_vol_m2, + :deals_ddu_vol_m2_priced, :deals_ddu_sum_mln_rub, + :deals_ddu_avg_price_thousand_rub_per_m2, :deals_ddu_avg_area_m2, + :deals_dkp_count, :deals_dkp_count_priced, :deals_dkp_vol_m2, + :deals_dkp_vol_m2_priced, :deals_dkp_sum_mln_rub, + :deals_dkp_avg_price_thousand_rub_per_m2, :deals_dkp_avg_area_m2, + :deals_total_count, :deals_total_count_priced, :deals_total_vol_m2, + :deals_total_vol_m2_priced, :deals_total_sum_mln_rub, + :deals_total_avg_price_thousand_rub_per_m2, :deals_total_avg_area_m2, + :offer_count, :offer_area_m2, :offer_sum_mln_rub, + :offer_avg_price_thousand_rub_per_m2, + :raw_id, CAST(:raw_props AS jsonb) + ) + ON CONFLICT (report_month, group_name, project_name, corpus_name, room_bucket) + DO UPDATE SET + developer = EXCLUDED.developer, + district = EXCLUDED.district, + class = EXCLUDED.class, + rooms_int = EXCLUDED.rooms_int, + sales_start_date = EXCLUDED.sales_start_date, + plan_completion_date = EXCLUDED.plan_completion_date, + actual_completion_date = EXCLUDED.actual_completion_date, + months_in_realization = EXCLUDED.months_in_realization, + lots_pd_count = EXCLUDED.lots_pd_count, + lots_pd_area = EXCLUDED.lots_pd_area, + deals_ddu_count = EXCLUDED.deals_ddu_count, + deals_ddu_count_priced = EXCLUDED.deals_ddu_count_priced, + deals_ddu_vol_m2 = EXCLUDED.deals_ddu_vol_m2, + deals_ddu_vol_m2_priced = EXCLUDED.deals_ddu_vol_m2_priced, + deals_ddu_sum_mln_rub = EXCLUDED.deals_ddu_sum_mln_rub, + deals_ddu_avg_price_thousand_rub_per_m2 = EXCLUDED.deals_ddu_avg_price_thousand_rub_per_m2, + deals_ddu_avg_area_m2 = EXCLUDED.deals_ddu_avg_area_m2, + deals_dkp_count = EXCLUDED.deals_dkp_count, + deals_dkp_count_priced = EXCLUDED.deals_dkp_count_priced, + deals_dkp_vol_m2 = EXCLUDED.deals_dkp_vol_m2, + deals_dkp_vol_m2_priced = EXCLUDED.deals_dkp_vol_m2_priced, + deals_dkp_sum_mln_rub = EXCLUDED.deals_dkp_sum_mln_rub, + deals_dkp_avg_price_thousand_rub_per_m2 = EXCLUDED.deals_dkp_avg_price_thousand_rub_per_m2, + deals_dkp_avg_area_m2 = EXCLUDED.deals_dkp_avg_area_m2, + deals_total_count = EXCLUDED.deals_total_count, + deals_total_count_priced = EXCLUDED.deals_total_count_priced, + deals_total_vol_m2 = EXCLUDED.deals_total_vol_m2, + deals_total_vol_m2_priced = EXCLUDED.deals_total_vol_m2_priced, + deals_total_sum_mln_rub = EXCLUDED.deals_total_sum_mln_rub, + deals_total_avg_price_thousand_rub_per_m2 = EXCLUDED.deals_total_avg_price_thousand_rub_per_m2, + deals_total_avg_area_m2 = EXCLUDED.deals_total_avg_area_m2, + offer_count = EXCLUDED.offer_count, + offer_area_m2 = EXCLUDED.offer_area_m2, + offer_sum_mln_rub = EXCLUDED.offer_sum_mln_rub, + offer_avg_price_thousand_rub_per_m2 = EXCLUDED.offer_avg_price_thousand_rub_per_m2, + raw_id = EXCLUDED.raw_id, + raw_props = EXCLUDED.raw_props, + fetched_at = NOW() + """ +) + + +# ── lots_pf (Поквартирные/Лоты) → objective_lots + objective_lots_history ─── + + +def _row_to_lot(row: dict, raw_id: int | None, snapshot_date: date) -> dict: + rooms_obj = row.get("Количество комнат(Данные объектива)") + return { + "objective_lot_id": _to_int(row.get("Id")), + "objective_project_id": _to_int(row.get("Id проекта")), + "project_name": row.get("Проект"), + "developer": row.get("Девелопер"), + "city": row.get("Город"), + "district": row.get("Район"), + "corpus_name": row.get("Корпус"), + "address": row.get("Адрес"), + "class": row.get("Класс"), + "section": row.get("Секция"), + "floor": _to_int(row.get("Этаж")), + "lot_number": row.get("Номер лота"), + "premise_kind": row.get("Вид помещения"), + "sales_start_date": _parse_iso_date(row.get("Старт продаж")), + "plan_completion_date": _parse_iso_date(row.get("Планируемая дата ввода")), + "actual_completion_date": _parse_iso_date(row.get("Фактическая дата ввода")), + "readiness_pct": _parse_pct(row.get("Готовность")), + "construction_stage": row.get("Стадия строительства"), + "finishing": row.get("Отделка по корпусу"), + "status": row.get("Статус"), + "is_sold": _parse_bool_yes(row.get("Продано")), + "rooms_dev_site": row.get("Количество комнат(Сайт девелопера)"), + "rooms_pd": row.get("Количество комнат(ПД)"), + "rooms_objective": rooms_obj, + "rooms_int": _normalize_rooms(rooms_obj), + "area_dev_site": _to_num(row.get("Площадь, м2(Сайт девелопера)")), + "area_pd": _to_num(row.get("Площадь, м2(ПД)")), + "price_calculated_total_rub": _to_num(row.get("Расчетный бюджет лота, Р")), + "price_per_m2_rub": _to_num(row.get("Цена за м2, Р")), + "price_offer_total_rub": _to_num(row.get("Цена предложения, Р")), + "price_delta_rub": _to_num(row.get("Дельта цена, Р")), + "price_delta_pct": _to_num(row.get("Дельта цена, %")), + "pricing_method": row.get("Способ определения цены"), + "price_set_date": _parse_iso_date(row.get("Дата установки цены")), + "price_actual_date": _parse_iso_date(row.get("Дата актуальности цены")), + "contract_date": _parse_iso_date(row.get("Дата договора")), + "registration_date": _parse_iso_date(row.get("Дата регистрации")), + "contract_type": row.get("Тип сделки"), + "buyer_type": row.get("Тип покупателя"), + "registration_number": row.get("Номер регистрации"), + "encumbrance_type": row.get("Тип обременения"), + "bank_name": row.get("Банк"), + "encumbrance_start_date": _parse_iso_date(row.get("Дата начала обременения")), + "egrn_actual_date": _parse_iso_date(row.get("Дата актуальности данных из ЕГРН")), + "raw_id": raw_id, + "raw_props": json.dumps(row, ensure_ascii=False), + "snapshot_date": snapshot_date, + } + + +_LOTS_UPSERT = text( + """ + INSERT INTO objective_lots ( + objective_lot_id, objective_project_id, project_name, developer, city, + district, corpus_name, address, class, section, floor, lot_number, + premise_kind, sales_start_date, plan_completion_date, actual_completion_date, + readiness_pct, construction_stage, finishing, status, is_sold, + rooms_dev_site, rooms_pd, rooms_objective, rooms_int, + area_dev_site, area_pd, + price_calculated_total_rub, price_per_m2_rub, price_offer_total_rub, + price_delta_rub, price_delta_pct, pricing_method, + price_set_date, price_actual_date, + contract_date, registration_date, contract_type, buyer_type, + registration_number, encumbrance_type, bank_name, encumbrance_start_date, + egrn_actual_date, + raw_id, raw_props, snapshot_date + ) VALUES ( + :objective_lot_id, :objective_project_id, :project_name, :developer, :city, + :district, :corpus_name, :address, :class, :section, :floor, :lot_number, + :premise_kind, :sales_start_date, :plan_completion_date, :actual_completion_date, + :readiness_pct, :construction_stage, :finishing, :status, :is_sold, + :rooms_dev_site, :rooms_pd, :rooms_objective, :rooms_int, + :area_dev_site, :area_pd, + :price_calculated_total_rub, :price_per_m2_rub, :price_offer_total_rub, + :price_delta_rub, :price_delta_pct, :pricing_method, + :price_set_date, :price_actual_date, + :contract_date, :registration_date, :contract_type, :buyer_type, + :registration_number, :encumbrance_type, :bank_name, :encumbrance_start_date, + :egrn_actual_date, + :raw_id, CAST(:raw_props AS jsonb), :snapshot_date + ) + ON CONFLICT (objective_lot_id) DO UPDATE SET + objective_project_id = EXCLUDED.objective_project_id, + project_name = EXCLUDED.project_name, + developer = EXCLUDED.developer, + city = EXCLUDED.city, + district = EXCLUDED.district, + corpus_name = EXCLUDED.corpus_name, + address = EXCLUDED.address, + class = EXCLUDED.class, + section = EXCLUDED.section, + floor = EXCLUDED.floor, + lot_number = EXCLUDED.lot_number, + premise_kind = EXCLUDED.premise_kind, + sales_start_date = EXCLUDED.sales_start_date, + plan_completion_date = EXCLUDED.plan_completion_date, + actual_completion_date = EXCLUDED.actual_completion_date, + readiness_pct = EXCLUDED.readiness_pct, + construction_stage = EXCLUDED.construction_stage, + finishing = EXCLUDED.finishing, + status = EXCLUDED.status, + is_sold = EXCLUDED.is_sold, + rooms_dev_site = EXCLUDED.rooms_dev_site, + rooms_pd = EXCLUDED.rooms_pd, + rooms_objective = EXCLUDED.rooms_objective, + rooms_int = EXCLUDED.rooms_int, + area_dev_site = EXCLUDED.area_dev_site, + area_pd = EXCLUDED.area_pd, + price_calculated_total_rub = EXCLUDED.price_calculated_total_rub, + price_per_m2_rub = EXCLUDED.price_per_m2_rub, + price_offer_total_rub = EXCLUDED.price_offer_total_rub, + price_delta_rub = EXCLUDED.price_delta_rub, + price_delta_pct = EXCLUDED.price_delta_pct, + pricing_method = EXCLUDED.pricing_method, + price_set_date = EXCLUDED.price_set_date, + price_actual_date = EXCLUDED.price_actual_date, + contract_date = EXCLUDED.contract_date, + registration_date = EXCLUDED.registration_date, + contract_type = EXCLUDED.contract_type, + buyer_type = EXCLUDED.buyer_type, + registration_number = EXCLUDED.registration_number, + encumbrance_type = EXCLUDED.encumbrance_type, + bank_name = EXCLUDED.bank_name, + encumbrance_start_date = EXCLUDED.encumbrance_start_date, + egrn_actual_date = EXCLUDED.egrn_actual_date, + raw_id = EXCLUDED.raw_id, + raw_props = EXCLUDED.raw_props, + snapshot_date = EXCLUDED.snapshot_date, + updated_at = NOW() + """ +) + + +_LOTS_HISTORY_INSERT = text( + """ + INSERT INTO objective_lots_history ( + objective_lot_id, snapshot_date, status, is_sold, + price_calculated_total_rub, price_per_m2_rub, price_offer_total_rub, + price_delta_pct, area_pd, contract_date, registration_date, + contract_type, bank_name, raw_id + ) VALUES ( + :objective_lot_id, :snapshot_date, :status, :is_sold, + :price_calculated_total_rub, :price_per_m2_rub, :price_offer_total_rub, + :price_delta_pct, :area_pd, :contract_date, :registration_date, + :contract_type, :bank_name, :raw_id + ) + ON CONFLICT (objective_lot_id, snapshot_date) DO NOTHING + """ +) + + +# ── orchestration ─────────────────────────────────────────────────────────── + + +def parse_corp_sum(payload: dict, group_name: str, raw_id: int | None, + db, dry_run: bool) -> int: + rows = payload.get("result") if isinstance(payload, dict) else None + if not isinstance(rows, list): + logger.warning("corp_sum: payload без list result, skip") + return 0 + n_inserted = 0 + for row in rows: + if not isinstance(row, dict): + continue + params = _row_to_corp_room_month(row, group_name, raw_id) + if not params["report_month"] or not params["project_name"] or not params["corpus_name"]: + logger.warning("corp_sum: пропуск row без обязательных полей: %s", + {k: row.get(k) for k in ("Месяц", "Проект", "Корпус")}) + continue + if not dry_run: + db.execute(_CRM_UPSERT, params) + n_inserted += 1 + return n_inserted + + +def parse_lots_pf(payload: dict, raw_id: int | None, snapshot_date: date, + db, dry_run: bool) -> tuple[int, int]: + rows = payload.get("result") if isinstance(payload, dict) else None + if not isinstance(rows, list): + logger.warning("lots_pf: payload без list result, skip") + return 0, 0 + n_lots = 0 + n_history = 0 + for row in rows: + if not isinstance(row, dict): + continue + params = _row_to_lot(row, raw_id, snapshot_date) + if not params["objective_lot_id"]: + logger.warning("lots_pf: пропуск row без Id") + continue + if not dry_run: + db.execute(_LOTS_UPSERT, params) + db.execute(_LOTS_HISTORY_INSERT, { + "objective_lot_id": params["objective_lot_id"], + "snapshot_date": snapshot_date, + "status": params["status"], + "is_sold": params["is_sold"], + "price_calculated_total_rub": params["price_calculated_total_rub"], + "price_per_m2_rub": params["price_per_m2_rub"], + "price_offer_total_rub": params["price_offer_total_rub"], + "price_delta_pct": params["price_delta_pct"], + "area_pd": params["area_pd"], + "contract_date": params["contract_date"], + "registration_date": params["registration_date"], + "contract_type": params["contract_type"], + "bank_name": params["bank_name"], + "raw_id": raw_id, + }) + n_lots += 1 + n_history += 1 + return n_lots, n_history + + +def detect_kind(payload: Any) -> str | None: + """Эвристика по полям первого row: 'corp_sum' | 'lots_pf' | None.""" + if not isinstance(payload, dict): + return None + rows = payload.get("result") + if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict): + return None + first = rows[0] + if "Месяц" in first and "Корпус" in first and "Объем предложения, шт." in first: + return "corp_sum" + if "Id" in first and "Номер лота" in first and "Площадь, м2(ПД)" in first: + return "lots_pf" + return None + + +# ── CLI ───────────────────────────────────────────────────────────────────── + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--file-corp-sum", type=str, + help="Локальный JSON-файл с corp_sum payload") + ap.add_argument("--file-lots-pf", type=str, + help="Локальный JSON-файл с lots_pf payload") + ap.add_argument("--raw-id", type=int, + help="Парсить один объект из objective_raw_reports по raw_id") + ap.add_argument("--latest-run", action="store_true", + help="Парсить все unparsed raw из последнего objective_scrape_runs.run_id") + ap.add_argument("--group-name", default="Екатеринбург", + help="GroupName для file-режима (default: Екатеринбург)") + ap.add_argument("--snapshot-date", type=str, default=None, + help="Дата снимка для lots_pf (default: сегодня)") + ap.add_argument("--dry-run", action="store_true", + help="Не писать в БД (только parse + считать rows)") + args = ap.parse_args() + + snap = _parse_iso_date(args.snapshot_date) or date.today() + + # Local-file mode (smoke без БД) + if args.file_corp_sum or args.file_lots_pf: + if not args.dry_run: + db = SessionLocal() + else: + db = None # type: ignore + + try: + if args.file_corp_sum: + payload = json.loads(Path(args.file_corp_sum).read_text(encoding="utf-8")) + n = parse_corp_sum(payload, args.group_name, None, db, args.dry_run) + print(f"corp_sum: {n} rows {'(dry-run)' if args.dry_run else 'upserted'}") + + if args.file_lots_pf: + payload = json.loads(Path(args.file_lots_pf).read_text(encoding="utf-8")) + n_lots, n_hist = parse_lots_pf(payload, None, snap, db, args.dry_run) + print(f"lots_pf: {n_lots} lots / {n_hist} history rows " + f"{'(dry-run)' if args.dry_run else 'upserted'}") + + if not args.dry_run and db is not None: + db.commit() + finally: + if db is not None: + db.close() + return 0 + + # DB mode — нужна реальная БД + db = SessionLocal() + try: + if args.raw_id: + raw_ids = [args.raw_id] + elif args.latest_run: + row = db.execute(text( + "SELECT MAX(run_id) FROM objective_scrape_runs" + )).scalar() + if not row: + print("Нет ни одного run.") + return 1 + raw_ids = [r[0] for r in db.execute(text( + """ + SELECT raw_id FROM objective_raw_reports + WHERE run_id = :rid + AND (rows_extracted IS NULL OR rows_extracted = 0) + ORDER BY raw_id + """ + ), {"rid": row}).all()] + if not raw_ids: + print(f"В run {row} нет unparsed raw.") + return 0 + else: + ap.error("Нужен --raw-id, --latest-run, или --file-corp-sum/--file-lots-pf") + return 2 + + for rid in raw_ids: + r = db.execute(text( + """ + SELECT raw_id, report_type, report_name, group_name, payload + FROM objective_raw_reports WHERE raw_id = :rid + """ + ), {"rid": rid}).mappings().first() + if not r: + logger.warning("raw_id=%s не найден", rid) + continue + kind = detect_kind(r["payload"]) + print(f"raw_id={rid} type={r['report_type']}/{r['report_name']} kind={kind}") + n = 0 + if kind == "corp_sum": + n = parse_corp_sum(r["payload"], r["group_name"], rid, db, args.dry_run) + elif kind == "lots_pf": + n_lots, _ = parse_lots_pf(r["payload"], rid, snap, db, args.dry_run) + n = n_lots + else: + logger.warning("raw_id=%s — kind неизвестен (тариф не отдал?), skip", rid) + + if not args.dry_run and n > 0: + db.execute(text( + "UPDATE objective_raw_reports SET rows_extracted = :n WHERE raw_id = :rid" + ), {"n": n, "rid": rid}) + + if not args.dry_run: + db.commit() + print("DONE.") + return 0 + finally: + db.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/memory/memory-gendesign.jsonl b/memory/memory-gendesign.jsonl index 27f84dd7..5198493d 100644 --- a/memory/memory-gendesign.jsonl +++ b/memory/memory-gendesign.jsonl @@ -114,18 +114,158 @@ {"type":"entity","name":"DomRF_Schema_Relations_Apr26","entityType":"infrastructure","observations":["Дата: 26.04.2026 вечер. Миграция data/sql/40_relations.sql — FK constraints + helper views между всеми таблицами DOM.РФ и Rosreestr на ПРОДЕ","BOOTSTRAP ДЛЯ FK:","• Добавлена region_id=0 ('Российская Федерация' / '_RF') в domrf_regions — этот ID используется как all-Russia placeholder в domrf_flat_area_distribution и domrf_developer_aggregates","• Бэкфилл 22 снапшотов в domrf_snapshots из всех дочерних таблиц с snapshot_date","FK CONSTRAINTS (27 шт):","• 23 × fk_snap_: все domrf_* таблицы с snapshot_date → domrf_snapshots(snapshot_date) ON UPDATE CASCADE","• fk_region_aggregates_region: domrf_region_aggregates.region_id → domrf_regions(region_id)","• fk_flat_area_region: domrf_flat_area_distribution.region_id → domrf_regions(region_id)","• fk_developer_agg_region: domrf_developer_aggregates.region_id → domrf_regions(region_id)","• fk_developer_agg_developer: domrf_developer_aggregates.developer_id → domrf_developers(developer_id)","НЕ ДОБАВЛЯЛ FK (не подходят по типу):","• domrf_launch_top.entity_id — смешанные ID (developer_id − TEXT, region_id − numeric, area name) в одной TEXT колонке с dim_type-разбивкой","• domrf_sold_ready_breakdown.entity_key — тоже смешанный ключ (название по chartType)","• domrf_commissioning.region_id — ДРУГАЯ система ID (внутренняя НОМЕНКЛАТУРА commissioning API), не совпадает с domrf_regions(region_id)","• rosreestr_deals.region_code — SMALLINT в partitioned table; по значениям совпадает с domrf_regions.region_id, но формальный FK на partitioned-table сложен — JOIN с v_rosreestr_market_by_region","HELPER VIEWS (4 шт):","• v_region_master — ОДНА строка на регион со всеми ключевыми метриками: квартирография (DOM.РФ) + распроданность (PDF и API) + проектное финансирование + 214-ФЗ + запуски/ввод","• v_developer_master — Профиль застройщика: реестр + квартирография по комнатностям + запуски/ввод + распроданность. PRINZIP, Брусника, ЛСР и др. в одном выборке","• v_rosreestr_market_by_region — Цены сделок Росреестра (median/p10/p90 ₽/м²) по кварталам с DOM.РФ-именами регионов и ФО (только квартиры)","• v_market_pulse_rf — ОДНА СТРОКА: РФ pulse: 118.7M м² стр-ва, продано 30.91%, рыночная ставка 19.9% vs реальная 6.83% (разница 13 пп — субсидии), 68 714 кредитов на 290 млрд ₽, средняя цена 216 316 ₽/м²","КЛЮЧЕВЫЕ ИНСАЙТЫ ИЗ v_developer_master для PRINZIP вс конкуренты Свердл.обл:","• Брусника 30 106 квартир / 1 780 тыс м² / доля РФ 1.49% / sold% 46.6%","• ЛСР 33 933 / 1 270 / 1.07% / sold 48.1%","• КОРТРОС 9 815 / 484 / 0.41% / sold 28.4%","• Атомстройкомплекс 4 018 / 233 / 0.20% / sold 15.0% (ОЧЕНЬ МЕДЛЕННО ПРОДАЁТ)","• УГМК 1 883 / 138 / 0.12% / sold 41.5%","• Стройтэк 1 515 / 77 / 0.06% / sold 28.4%","• PRINZIP 814 / 31 / 0.026% / sold 48.4% (БЫСТРО ПРОДАЁТ!)","PRINZIP имеет НАИВЫСШИЙ sold_pct (48.4%) среди сравнимых застройщиков Свердл.обл. Несмотря на маленький портфель (814 кв-р), 75% однушки с хорошей absorption rate — продукт попадает в рыночный спрос ЛУЧШЕ чем КОРТРОС/Атомстройкомплекс с большими портфелями"]} {"type":"entity","name":"Session_End_Apr26_2026","entityType":"event","observations":["Дата: 26.04.2026 поздний вечер. Итоги дня перед чисткой контекста. При продолжении работы читай СНАЧАЛА DB_Schema_Final_Apr26 + эту сущность.","СДЕЛАНО ЗА ДЕНЬ (4 крупных вехи):","1) Rosreestr CSV сделок залит на прод — 7 кварталов (2024Q3-2026Q1), 7.58M сделок РФ, 296K Свердл.обл. См. Rosreestr_DealsCSV_Loaded_Prod_Apr26.","2) ДОМ.РФ JSON dashboard залит (89 регионов, 2734 застройщика, 11K рядов агрегатов). См. DomRF_Analytics_Loaded_Prod_Apr26.","3) 7 XLSX/PDF с юзер-выгрузки (developer_rf, escrow_rf, guaranty, housing_info, sold_out_info, mortgage_rates, Квартирография). См. DomRF_XLSX_PDF_Snapshot_Loaded_Apr26.","4) Playwright headless scraper написан и запущен — скачал 7/12 страниц ДОМ.РФ через ServicePipe WAF. 64 JSON-файла → 10 новых нормализованных таблиц. См. DomRF_Playwright_Scraper_Apr26 + DomRF_Scraped_Loaded_Prod_Apr26.","+ FK constraints + 4 helper views поверх всех таблиц. См. DomRF_Schema_Relations_Apr26.","СОСТОЯНИЕ ПРОД-БД (gendsgn.ru / 46.173.16.127):","• PostgreSQL 16 + PostGIS 3.4 в docker-compose. Размер 1855 MB.","• 36 таблиц + 4 view + 3 mat view + 7 партиций + 28 FK.","• Доступ только через SSH-туннель (порт 5432 бинд на 127.0.0.1).","• Локальный порт после туннеля у юзера: 15432 (прод), 5432 (был локальный).","• Пароль POSTGRES_PASSWORD: 2J2SBPMKuS998fiwhtQqDhMI (светился в чате несколько раз — юзер может сменить через docker compose recreate postgres).","• БД: gendesign, Юзер: gendesign.","ДОСТУП К ПРОДУ:","• SSH-ключ: C:\\Users\\user\\.ssh\\gendesign_deploy (и копия в sshkey.txt в корне репо — юзер разрешил)","• SSH config alias: 'gendesign' → 46.173.16.127 с LocalForward 15432:5432 и 8000:8000","• Команда: ssh gendesign (или ssh -N gendesign для только туннеля)","• Инструкция для Mac: скопировать ключ в ~/.ssh/, добавить тот же ssh config block, подключаться через TablePlus/DBeaver на localhost:15432","TODO СЛЕДУЮЩЕЙ СЕССИИ (по важности):","• 5 страниц ДОМ.РФ не скачались (lazy-load с user-interaction): housing, housing_dev, realization (ПОКВАРТИРНЫЙ ПРАЙС!), mortgage_rates, stat_series. Расширить data/sql/30_scrape_domrf.py page-specific click triggers.","• /сервисы/api/kn/flats?regions=66&developers=6208_0 — поквартирный прайс-лист PRINZIP, Authorization: Basic MTpqd2U=. Идеальный источник для вкладки Prinzip.","• NSPD GeoJSON кадастровых кварталов Свердл.обл → таблица cadastral_quarters PostGIS для spatial join.","• Cron quarterly_rosreestr_sync на VPS (16-го числа 2-го месяца после квартала).","• Cron domrf_daily_scrape на VPS (Playwright + chromium 300 MB).","• FastAPI router /api/v1/analytics/{ural|ekb|prinzip} используя v_region_master / v_developer_master / v_market_pulse_rf.","• Alembic-миграции из *.sql (воспроизводимость при wipe).","• Пользователь спрашивал 'можно ли объединить дублирующие таблицы' — НЕ ОТВЕЧЕНО, вернуться в следующей сессии. Кандидаты на объединение: domrf_sold_out (PDF, 9 рядов) vs domrf_sold_ready_breakdown (API, 2660 рядов) — покрывают одну тему; domrf_developers (справочник, 2734) vs domrf_developers_full (реестр с метриками, 2563) — разные но перекрываются; domrf_planned_commissioning vs domrf_commissioning vs domrf_launch_monthly — все про ввод.","ОГРАНИЧЕНИЯ КОТОРЫЕ СТОИТ ПОМНИТЬ:","• PRINZIP id = '6208_0', в базе как developer_id TEXT, найдёшь в v_developer_master.","• Свердл.обл region_id = 66 в domrf_regions И region_code = 66 в rosreestr_deals — системы КОДОВ совпадают (Rosstat-style коды).","• ДДУ-данные Свердл.обл в Rosreestr CSV НЕПОЛНЫЕ до середины 2025 (Q1 2025 = 2 сделки — lag публикации). С Q2 2025 нормально.","• Средняя цена МКД РФ от ДОМ.РФ = 216 316 ₽/м² (sold_out PDF). Первичка Свердл.обл median из Росреестра = 149 377 ₽/м² (Q1 2026, ДДУ). Это НОРМАЛЬНО — средняя РФ выше из-за Москвы/Питера.","• 5 страниц из 12 ДОМ.РФ не скачались — это НЕ ошибка скрейпера, это фича lazy-loading. Нужны page-specific click triggers.","КЛЮЧЕВЫЕ ИНСАЙТЫ ДНЯ НАПОМНИТЬ:","• PRINZIP с мини-портфелем (814 кв-р) имеет НАИВЫСШИЙ sold_pct 48.4% среди сравнимых застройщиков Свердл.обл. Атомстройкомплекс в 4× больше sold_pct 15%.","• Резкое падение ввода после 2028 года: 2026=37.9M→ 2027=35.4→2028=26.5→2029=11.0→2030=4.3M м². Окно Discovery MVP именно сейчас.","• Средняя фактическая ставка первички РФ 6.83% (субсидии), но рыночная 19.9% — разница 13 пп это субсидирование госпрограмм.","• Комфорт = 67% всех запусков РФ (6.93M м² из 10.56M total). Типовой всего 19%.","• Сбер доминирует эскроу: 72.8M м² = 61% всего эскроу-рынка.","• Свердл.обл рынок: 5.67M м² в стройке, 179 застройщиков, sold_pct 29% (хуже среднего РФ 31%) но soldReady_pct 80% (здоровый баланс стройки/продаж).","СТРУКТУРА РЕПО для этой работы: data/sql/ — 9 SQL/PY скриптов, data/raw/ — 1 GB CSV+JSON (в .gitignore). Граф связей обновлён — ~150 связей, все ключевые entities связаны с Project_GenDesign и PlanA_AnalyticsFirst_Apr25."]} {"type":"entity","name":"DomRF_History_Loaded_Apr27_2026","entityType":"event","observations":["Дата: 27.04.2026 поздний вечер. ДОГРУЖЕНА ИСТОРИЯ 14 месяцев (12 мес 2025 + Январь/Февраль 2026) для realization + sold_ready эндпоинтов ДОМ.РФ.","АРТЕФАКТЫ: data/sql/40_scrape_history.py + 41_load_history.py + 42_schema_breakdown_widen.sql.","ПОДХОД: API DOM.РФ принимает исторические repYear/repMonth параметры — подтверждено пробой. Для каждого месяца SNAPSHOT_DATE = последний день периода (2025-01-31, 2025-02-28, ...) — против конфликтов PK.","СКРЕЙП: 6 755 JSON-файлов / 14 месяцев / ~480 файлов на месяц. data/raw/domrf_history/{realization,sold_ready}//. Работал ~17 минут (Playwright headless Chromium, 89 регионов × 4 endpoint × 14 мес ≈ 5K fetch).","ЗАЛИТО В ПРОД:","• domrf_realization — было 9 747, стало 143 963 (+134 216 рядов, 14 новых snapshot_date'ов).","• domrf_sold_ready_index — +14 рядов (по ОДНОМУ ряду НА месяц: РФ-итог: squareSum / soldPerc / readyPerc / soldReadyPerc).","• domrf_sold_ready_breakdown — +37 384 рядов (7 chart_type × ~2 600 × 14 мес). Chart types: foChart (9), regionChart (89), devChart (~2 700-3 500 зависимо от периода), devValueChart (5 бакетов), cityPopulationChart (4), classChart (4), readyYearChart (~7).","• domrf_sold_ready_dynamics — +3 150 рядов (4 dynamic_chart_type × × годы × месяцы 2020-period).","• domrf_snapshots — +14 рядов (snapshot_date'ов истории), всего стало 18.","SCHEMA ИЗМЕНЕНИЕ (42_schema_breakdown_widen.sql):","Было: domrf_sold_ready_breakdown.extra JSONB с любыми лишними полями (foCd в своей колонке, остальные в JSONB). Стало: 6 типизированных колонок, JSONB дропнут. Каждый chart_type имеет одно дискриминаторное поле:","• foChart → fo_cd (INT, 100-108)","• regionChart → region_cd (INT, Rosstat 1-95)","• devChart → dev_calc_group_id (TEXT, '5791_0' = Брусника, '6072_0' = Самолёт, '6208_0' = PRINZIP)","• devValueChart → dev_group_value (TEXT, do10/do25/do50/do100/over100 бакеты размера)","• cityPopulationChart → city_population (TEXT, do50/do100/do250/over1000 население)","• classChart → obj_class (TEXT, typical/comfort/business/elite)","• readyYearChart → ready_year (INT, год планируемой готовности)","COMMENT добавлен на каждую новую колонку.","КЛЮЧЕВЫЕ INSIGHTS ИЗ ИСТОРИИ:","• Свердл.обл realization total: объём стр-ва рос 5 115 (Янв 2025) → 6 015 (Дек 2025) тыс м² (+18% за год), затем откат в 5 686 (Мар 2026, -5%). Цена выросла 136 796 → 151 397 ₽/м² (+11%).","• sold_perc Свердл.обл: 35% (Янв 2025) → 29% (Мар 2026). Рынок остывает.","• Брусника (developer_id 5791_0) sold% timeseries: 33% (Mar 2025) → 50.36% (Feb 2026). РЕКОРДНЫЙ РОСТ велосити среди крупных девелоперов РФ — Свердл.обл портфель лучше продаётся в сравнении с Самолётом (sold% в районе 36-40% константно). PRINZIP обгоняет обоих в Свердл.обл 47-48%.","ОГРАНИЧЕНИЯ / TODO:","• escrow история НЕ догружена — endpoint /portal-analytics/api/escrow?date=01-XX-2025 вернул 404/403 при пробе (WAF временное или URL другой). escrow_allDate.json показывает что данные есть вся история. Отдельный TODO — добраться правильным путем.","• launch / commissioning / mortgage_stats / quartirografia / housing/_dev / project_finance — НЕ перескрейпливали за 2025: их существующие endpoints возвращают timeseries 2020-2026 в ОДНОМ ответе (видно в launch_monthly: 75 рядов = 5 лет × 12 мес + 15 мес 2026; commissioning, sold_ready_dynamics то же самое).","• stat_series уже имел 2020-2026 из XLSX, не трогали.","• ПОДНЯТЬ ЯСНОСТЬ С SOLD_READY_DYNAMICS: в 1 месяц = 225 рядов (4 chart_type × годы 2020-period × 12 мес). Разные snapshot_date имеют оверлапы — снапшот июля-2025 репит 2020-01..2025-07, снапшот август-2025 репит 2020-01..2025-08. Это НОРМАЛЬНО — они в PK вместе с snapshot_date.","MCP CONNECTOR ИСПОЛЬЗОВАНИЕ: вся DDL (ALTER TABLE 42), все SELECT-верификации, инфо о схеме делались через mcp__postgres__execute_sql. Питон-скрипты использовались только для парсинга 6755 локальных JSON-файлов и batch INSERT (MCP файлы не парсит).","ДОСТИЖИМО СЕЙЧАС:","• Динамика по региону/девелоперу/классу/размеру портфеля по МЕСЯЦАМ за 14 периодов.","• Анализ 'velocity' различных девелоперов по регионам.","• Ответ на вопрос 'PRINZIP лучше ДК/ФСК/КОРТРОС?' — теперь с трендом.","• Ответ на 'sold% в Свердл.обл растёт или падает?' — ПАДАЕТ 35→29%."]} -{"type":"entity","name":"Sverdl_Quartirography_Report_Apr27","entityType":"research","observations":["Дата: 27.04.2026. ОТЧСТ ПО КВАРТИРОГРАФИИ СВЕРДЛОВСКОЙ ОБЛАСТИ НА ОСНОВЕ ВСЕГО ANALYTICAL ПЛАСТА ДАННЫХ PROD DB.","ИСТОЧНИКИ: domrf_realization (143K rows), rosreestr_deals (4.97M raw, 296K Свердл), domrf_region_aggregates, domrf_developer_aggregates, domrf_kn_objects (1516 ЖК Свердл), ekb_districts, yandex_realty_zk, domrf_sold_ready_breakdown.","РЫНОК СВЕРДЛ 14 МЕС (ЯНВ 2025 - АПР 2026):","• Объём стр-ва: 5 115 → 6 015 тыс м² за 2025 (+18%), затем откат в 5 686 Мар 2026 (-5%).","• sold%: 35% → 29% (-6пп) — рынок охлаждается, unsold вырос 50%→54%.","• Цена: 136 796 → 151 397 ₽/м² (+11% за 14 мес) — МЕДЛЕННЕЕ инфляции и реальной ставки ЦБ 11%.","КВАРТИРОГРАФИЯ СВЕРДЛ ПОРТФЕЛЬ (что строится, 115 205 квартир):","• 1-к: 60 652 (52%) × 36.5 м² ср. — САМЫЙ БОЛЬШОЙ СЕГМЕНТ","• 2-к: 39 839 (35%) × 57.1 м²","• 3-к: 13 754 (12%) × 78.9 м²","• 4+к: 960 (1%) × 103.7 м²","РЕАЛЬНЫЕ ДДУ-СДЕЛКИ СВЕРДЛ Q3 2025-Q1 2026 (Rosreestr 460 сделок с фильтрами):","• Студии 15-30 м²: 87 сделок (19%), медиана 114 557 ₽/м²","• 1-к 30-45 м²: 67 (15%), медиана 112 640 ₽/м²","• 2-к 45-60 м²: 56 (12%), медиана 123 932 ₽/м²","• 3-к 60-80 м²: 36 (8%), медиана 126 934 ₽/м²","• БОЛЬШИЕ 80+ м²: 171 (37%!), медиана 139 382 ₽/м² — ЛИДЕР ПО СДЕЛКАМ, ср. чек 20 М ₽!","ПАРАДОКС: строят 52% однушек, покупают 37% больших 80+ м². Рынок голосует деньгами за семейные квартиры, предложение ориентировано на инвесторский сегмент студий-однушек.","PIPELINE ПО ГОДУ ВВОДА (Свердл, Апр 2026):","• 2026: 1 649 тыс м², sold 54% (запаздывать входить)","• 2027: 1 745, sold 29%","• 2028: 1 567, sold 15%, unopened 19% 🚩","• 2029: 538, sold 4%, unopened 56% 🚩🚩","• 2030: 122, sold 0.2%, unopened 89% 🚩🚩🚩","• 2031: 65, sold 0%, unopened 100%. ОКНО ИНВЕСТИЦИЙ: 2026-2027 ввод.","ТОП-15 ДЕВЕЛОПЕРОВ СВЕРДЛ (sold% динамика 14 мес):","• ПОБЕДИТЕЛИ: PRINZIP +33пп (15→48), Холдинг Форум-групп +21пп (33→54%) — ЛИДЕР ПО SOLD%, Брусника +11пп (36→47), Астра +11пп (19→30), КОРТРОС +4пп, Атлас +5пп.","• ПРОИГРЫВАЮЩИЕ: Унистрой ОБВАЛ 59→8% (-51пп при росте +176 тыс м²), Атомстройкомплекс -31пп (46→15), Эталон -28пп (45→17), УГМК -27пп (69→42). ПАТТЕРН ЗАТОВАРИВАНИЯ.","ТОП ЖК ЕКБ (1 285 ЖК в реестре):","• САМЫЕ БОЛЬШИЕ: ЖК СТАРТ (ТЭН, 1566 кв.), Сибирский сад (Страна Дев, 1429+1142), Основинские Кв-ы (ТЭН, 1224), Первый Академ (КОРТРОС, 1050), Атлас Юник (Атлас, 1044).","КОНЦЕНТРАЦИЯ ПО РАЙОНАМ ЕКБ:","• Академический: 330 ЖК / 82 520 кв-р (ЛИДЕР)","• Ленинский: 215 / 50 754","• Верх-Исетский: 151 / 37 611","• Чкаловский: 142 / 38 506","• Орджоникидзевский: 139 / 28 733","• Железнодорожный: 135 / 30 428","• Кировский: 115 / 29 754","• Октябрьский: 90 / 27 863 (НАИМЕНЬШАЯ КОНКУРЕНЦИЯ)","YANDEX REALTY ACTIVE LISTINGS ЕКБ (снимок 26.04): 258 новостроек, Комфорт 80% (206), Элит 13% (34), Эконом 7% (18). Цены: Эконом 107-234K, Комфорт 93-682K, Элит 131-636K. 258 в продаже vs 1 411 в реестре = 82% распроданы.","РЕКОМЕНДАЦИИ (ЧТО СТРОИТЬ):","• Приоритет #1: семейные 60-90 м² (3-к) — дефицит предложения, 8% сделок, ср. чек 10.5 М ₽.","• Приоритет #2: большие 100-150 м² — 37% сделок при Ц 153K ₽/м², ср. чек 20 М ₽. Премиум кад.кварталы: 66:41:0701011 (мед 424K), 66:41:0106113 (172K), 66:41:0704044 (149K).","• ИЗБЕГАТЬ: однушки 30-40 м² (переразвиты), проекты 2028+ на эскроу (66-89% unsold).","ГДЕ СТРОИТЬ: Кировский/Октябрьский (недо-инвестированы), не-ЕКБ города (Н.Тагил 23 ЖК, Первоуральск, Арамиль).","BENCHMARK MODELS: Брусника (350 тыс м² / sold 47% / +11пп), Холдинг Форум-групп (113 тыс м² / sold 54% / +21пп лидер velocity).","РАЗРЫВ СРЕДНЕЙ: Брусника ср. 60 м², Форум ср. 61 м², PRINZIP ср. 38 м² — в 1.5х меньше рынка (49 м²)."]} +{"type":"entity","name":"Sverdl_Quartirography_Report_Apr27","entityType":"research","observations":["Дата: 27.04.2026. ОТЧСТ ПО КВАРТИРОГРАФИИ СВЕРДЛОВСКОЙ ОБЛАСТИ НА ОСНОВЕ ВСЕГО ANALYTICAL ПЛАСТА ДАННЫХ PROD DB.","ИСТОЧНИКИ: domrf_realization (143K rows), rosreestr_deals (4.97M raw, 296K Свердл), domrf_region_aggregates, domrf_developer_aggregates, domrf_kn_objects (1516 ЖК Свердл), ekb_districts, yandex_realty_zk, domrf_sold_ready_breakdown.","РЫНОК СВЕРДЛ 14 МЕС (ЯНВ 2025 - АПР 2026):","• Объём стр-ва: 5 115 → 6 015 тыс м² за 2025 (+18%), затем откат в 5 686 Мар 2026 (-5%).","• sold%: 35% → 29% (-6пп) — рынок охлаждается, unsold вырос 50%→54%.","• Цена: 136 796 → 151 397 ₽/м² (+11% за 14 мес) — МЕДЛЕННЕЕ инфляции и реальной ставки ЦБ 11%.","КВАРТИРОГРАФИЯ СВЕРДЛ ПОРТФЕЛЬ (что строится, 115 205 квартир):","• 1-к: 60 652 (52%) × 36.5 м² ср. — САМЫЙ БОЛЬШОЙ СЕГМЕНТ","• 2-к: 39 839 (35%) × 57.1 м²","• 3-к: 13 754 (12%) × 78.9 м²","• 4+к: 960 (1%) × 103.7 м²","РЕАЛЬНЫЕ ДДУ-СДЕЛКИ СВЕРДЛ Q3 2025-Q1 2026 (Rosreestr 460 сделок с фильтрами):","• Студии 15-30 м²: 87 сделок (19%), медиана 114 557 ₽/м²","• 1-к 30-45 м²: 67 (15%), медиана 112 640 ₽/м²","• 2-к 45-60 м²: 56 (12%), медиана 123 932 ₽/м²","• 3-к 60-80 м²: 36 (8%), медиана 126 934 ₽/м²","• БОЛЬШИЕ 80+ м²: 171 (37%!), медиана 139 382 ₽/м² — ЛИДЕР ПО СДЕЛКАМ, ср. чек 20 М ₽!","ПАРАДОКС: строят 52% однушек, покупают 37% больших 80+ м². Рынок голосует деньгами за семейные квартиры, предложение ориентировано на инвесторский сегмент студий-однушек.","PIPELINE ПО ГОДУ ВВОДА (Свердл, Апр 2026):","• 2026: 1 649 тыс м², sold 54% (запаздывать входить)","• 2027: 1 745, sold 29%","• 2028: 1 567, sold 15%, unopened 19% 🚩","• 2029: 538, sold 4%, unopened 56% 🚩🚩","• 2030: 122, sold 0.2%, unopened 89% 🚩🚩🚩","• 2031: 65, sold 0%, unopened 100%. ОКНО ИНВЕСТИЦИЙ: 2026-2027 ввод.","ТОП-15 ДЕВЕЛОПЕРОВ СВЕРДЛ (sold% динамика 14 мес):","• ПОБЕДИТЕЛИ: PRINZIP +33пп (15→48), Холдинг Форум-групп +21пп (33→54%) — ЛИДЕР ПО SOLD%, Брусника +11пп (36→47), Астра +11пп (19→30), КОРТРОС +4пп, Атлас +5пп.","• ПРОИГРЫВАЮЩИЕ: Унистрой ОБВАЛ 59→8% (-51пп при росте +176 тыс м²), Атомстройкомплекс -31пп (46→15), Эталон -28пп (45→17), УГМК -27пп (69→42). ПАТТЕРН ЗАТОВАРИВАНИЯ.","ТОП ЖК ЕКБ (1 285 ЖК в реестре):","• САМЫЕ БОЛЬШИЕ: ЖК СТАРТ (ТЭН, 1566 кв.), Сибирский сад (Страна Дев, 1429+1142), Основинские Кв-ы (ТЭН, 1224), Первый Академ (КОРТРОС, 1050), Атлас Юник (Атлас, 1044).","КОНЦЕНТРАЦИЯ ПО РАЙОНАМ ЕКБ:","• Академический: 330 ЖК / 82 520 кв-р (ЛИДЕР)","• Ленинский: 215 / 50 754","• Верх-Исетский: 151 / 37 611","• Чкаловский: 142 / 38 506","• Орджоникидзевский: 139 / 28 733","• Железнодорожный: 135 / 30 428","• Кировский: 115 / 29 754","• Октябрьский: 90 / 27 863 (НАИМЕНЬШАЯ КОНКУРЕНЦИЯ)","РЕКОМЕНДАЦИИ (ЧТО СТРОИТЬ):","• Приоритет #1: семейные 60-90 м² (3-к) — дефицит предложения, 8% сделок, ср. чек 10.5 М ₽.","• Приоритет #2: большие 100-150 м² — 37% сделок при Ц 153K ₽/м², ср. чек 20 М ₽. Премиум кад.кварталы: 66:41:0701011 (мед 424K), 66:41:0106113 (172K), 66:41:0704044 (149K).","• ИЗБЕГАТЬ: однушки 30-40 м² (переразвиты), проекты 2028+ на эскроу (66-89% unsold).","ГДЕ СТРОИТЬ: Кировский/Октябрьский (недо-инвестированы), не-ЕКБ города (Н.Тагил 23 ЖК, Первоуральск, Арамиль).","BENCHMARK MODELS: Брусника (350 тыс м² / sold 47% / +11пп), Холдинг Форум-групп (113 тыс м² / sold 54% / +21пп лидер velocity).","РАЗРЫВ СРЕДНЕЙ: Брусника ср. 60 м², Форум ср. 61 м², PRINZIP ср. 38 м² — в 1.5х меньше рынка (49 м²).","YANDEX REALTY ACTIVE LISTINGS (снимок 27.04.2026): в БД всего 12 записей (Comfort 6, Business 4, Comfort+ 1, Elite 1). Предыдущий факт «258 новостроек» был устаревшим снимком. Текущий парсер явно недобирает — режимы являются кандидатом на перевыполнение в ближайшее время."]} {"type":"entity","name":"PRINZIP_Strategy_Apr27","entityType":"research","observations":["Дата: 27.04.2026. СТРАТЕГИЧЕСКИЙ АНАЛИЗ ДЛЯ PRINZIP (developer_id 6208_0) НА ОСНОВЕ PROD ДАННЫХ.","ТЕКУЩИЙ ПОРТФЕЛЬ (developer_aggregates):","• ВСЕГО: 814 квартир / 31 040 м² / 28 ЖК / ср. 38.1 м²","• 1-к: 614 (75% портфеля) × 30.8 м² — ОЧЕНЬ МАЛЕНЬКИЕ","• 2-к: 156 (19%) × 52.8 м²","• 3-к: 22 (3%) × 81.3 м²","• 4+к: 22 (3%) × 95.2 м²","ПОРТФЕЛЬ ПО СТАТУСУ: 26 из 28 ЖК СДАНЫ (2018-2024). СТРОЙКА: Шарташ Парк (523 кв, сдача 2026-12, эскроу) + 2-я очередь Парк Победы (291, 2027-03).","VELOCITY ЛИДЕРСТВО: sold% 15% (Янв 2025) → 48% (Апр 2026) = +33пп. ЛУЧШИЙ РОСТ СРЕДИ ТОП-5 СВЕРДЛ (Брусника +11, Форум +21, KORTROS +4, Атлас +5). Объём НЕ растёт (стабильно 31 тыс м²).","ГЕОГРАФИЯ PRINZIP ПО РАЙОНАМ ЕКБ:","• Октябрьский: 6 ЖК / ДОЛЯ РАЙОНА 6.7% (высокая концентрация, парковый квартал/Ленина/LVIII)","• Верх-Исетский: 4 ЖК / доля 2.6% (Первый Николаевский, Малевич)","• Ленинский: 4 ЖК / 1.9% (Ньютон Парк, Краснолесья)","• Кировский: 2 ЖК / 1.7% (Первомайская 60, Шарташ)","• Орджоникидзевский: 1 ЖК / 0.7% (Парк Победы/Татлин 3 объекта)","• АКАДЕМИЧЕСКИЙ: 0 PRINZIP / 0% (САМЫЙ БОЛЬШОЙ КЛАСТЕР 330 ЖК / 82К квартир — PRINZIP ОТСУТСТВУЕТ 🚨)","• Чкаловский: 0 / 0% (142 ЖК / 38К кв.)","• Железнодорожный: 0 / 0% (135 ЖК / 30К)","КЛЮЧЕВОЙ РАЗРЫВ С РЫНКОМ:","• PRINZIP средняя квартира 38 м² vs Свердл 49 м² vs Брусника 60 м² vs Форум 61 м².","• PRINZIP доля 1-к 75% vs Свердл 52% vs Брусника 47%.","• PRINZIP доля 3-к+ всего 6% vs Свердл 13% vs Брусника 18% vs Форум 21%.","ПОРТФЕЛЬ PRINZIP ОРИЕНТИРОВАН НА ИНВЕСТОРОВ / МОЛОДЫХ ПОКУПАТЕЛЕЙ, НО РЫНОК СЕЙЧАС ПОКУПАЕТ СЕМЕЙНЫЙ 80+ м².","PRINZIP CRM данные (из Антона): средний чек 2025 = 6.15 М ₽, conversion 5.4% (обвал с 11.2% в 2020). 39.9 млрд ₽ revenue за 8 лет. Пик 2023 (12.81% conv).","РЕКОМЕНДАЦИИ СТРАТЕГИИ PRINZIP:","ПРИОРИТЕТ #1: семейные квартиры 60-90 м² (3-к). Дефицит в портфеле (3% vs Брусника 17%), ср. чек 10.5 М ₽. Подъём среднего чека выше текущего CRM 6.15 М.","ПРИОРИТЕТ #2: премиум 100-150 м² (37% сделок, 20 М ₽). Кад.кварталы 66:41:0701011 (МЕД 424K!), 66:41:0106113 (172K), 66:41:0704044 (149K). Расширить Парковый Кв-л/LVIII модель.","ГДЕ СТРОИТЬ НОВОЕ:","• #1 АКАДЕМИЧЕСКИЙ (330 ЖК, 0% PRINZIP) — самый большой кластер ЕКБ, ПРИНЦИП ИГНОРИРУЕТ. Семейный сегмент молодых покупателей.","• #2 ВЕРХ-ИСЕТСКИЙ расширение — кад.квартал 66:41:0106113 (ср. 113 м² × 172K ₽/м²). Бизнес 80-130 м².","• #3 ЧКАЛОВСКИЙ / ЖЕЛЕЗНОДОРОЖНЫЙ — растущие районы, 0% PRINZIP, низкая конкуренция. Тест 60-80 м² без премиума.","НЕ РАСШИРЯТЬ: Октябрьский (уже 6.7% доля), Орджоникидзевский (выросли из Парка Победы).","ОБЩАЯ СТРАТЕГИЯ: 'Брусника model' в Академическом + 'Премиум-проект 80-150 м²' в Верх-Исетском. Сократить 1-к с 75% до 50%, увеличить 3-к+ с 6% до 20%. Удержать sold% > 45% — ваше преимущество."]} -{"type":"entity","name":"Schema_Tables_Map_Apr27","entityType":"infrastructure","observations":["СНАПШОТ 27.04.2026 вечер. 53 public TABLES в прод-БД gendesign, разбиты на 15 доменов.","1️⃣ DOM.РФ realization & history (4): domrf_realization, domrf_sold_ready_index, domrf_sold_ready_breakdown, domrf_sold_ready_dynamics","2️⃣ DOM.РФ stat_series (3): domrf_stat_series_observations, domrf_stat_series_indicators, domrf_xlsx_files","3️⃣ DOM.РФ JSON dashboard (3): domrf_region_aggregates, domrf_developer_aggregates, domrf_flat_area_distribution","4️⃣ DOM.РФ XLSX/PDF (8): domrf_developers, domrf_developers_full, domrf_escrow_banks, domrf_guaranty_regions, domrf_housing_summary, domrf_planned_commissioning, domrf_sold_out, domrf_mortgage_rates","5️⃣ DOM.РФ launch/commissioning (5): domrf_launch_top, domrf_launch_obj_class, domrf_launch_monthly, domrf_commissioning, domrf_project_finance","6️⃣ DOM.РФ ipoteka (2): domrf_mortgage_dashboard, domrf_mortgage_details","7️⃣ DOM.РФ kn-API (2): domrf_kn_flats, domrf_kn_objects","8️⃣ DOM.РФ growth (1): domrf_dev_growth","9️⃣ DOM.РФ raw (1): domrf_raw_endpoints","🔟 DOM.РФ dimensions (4): domrf_regions, domrf_snapshots, domrf_federal_districts, domrf_territory_aliases","1️⃣1️⃣ ЦБ ипотека (2): cbr_mortgage_series, cbr_mortgage_files","1️⃣2️⃣ Внешние данные (3): sber_ddf_metrics, yandex_realty_zk, yandex_realty_class_prices","1️⃣3️⃣ Geo Свердл/ЕКБ (4): ekb_districts, ekb_metro, sv_cities, sv_dev_sales","1️⃣4️⃣ Rosreestr (10): rosreestr_deals + 7 partitions (2024Q3..2026Q1) + rosreestr_deals_staging + rosreestr_rent_deals","1️⃣5️⃣ PostGIS system (1): spatial_ref_sys","САМЫЕ БОЛЬШИЕ ПО РАЗМЕРУ: rosreestr_deals_2024q4 (253MB), domrf_stat_series_observations (254MB), rosreestr_deals_2024q3 (237MB), domrf_realization (39MB), domrf_sold_ready_breakdown (11MB), domrf_kn_flats (7.4MB).","Размер БД итого: 2 177 MB."]} +{"type":"entity","name":"Schema_Tables_Map_Apr27","entityType":"infrastructure","observations":["1️⃣ DOM.РФ realization & history (4): domrf_realization, domrf_sold_ready_index, domrf_sold_ready_breakdown, domrf_sold_ready_dynamics","2️⃣ DOM.РФ stat_series (3): domrf_stat_series_observations, domrf_stat_series_indicators, domrf_xlsx_files","3️⃣ DOM.РФ JSON dashboard (3): domrf_region_aggregates, domrf_developer_aggregates, domrf_flat_area_distribution","4️⃣ DOM.РФ XLSX/PDF (8): domrf_developers, domrf_developers_full, domrf_escrow_banks, domrf_guaranty_regions, domrf_housing_summary, domrf_planned_commissioning, domrf_sold_out, domrf_mortgage_rates","5️⃣ DOM.РФ launch/commissioning (5): domrf_launch_top, domrf_launch_obj_class, domrf_launch_monthly, domrf_commissioning, domrf_project_finance","6️⃣ DOM.РФ ipoteka (2): domrf_mortgage_dashboard, domrf_mortgage_details","7️⃣ DOM.РФ kn-API (2): domrf_kn_flats, domrf_kn_objects","8️⃣ DOM.РФ growth (1): domrf_dev_growth","9️⃣ DOM.РФ raw (1): domrf_raw_endpoints","🔟 DOM.РФ dimensions (4): domrf_regions, domrf_snapshots, domrf_federal_districts, domrf_territory_aliases","1️⃣1️⃣ ЦБ ипотека (2): cbr_mortgage_series, cbr_mortgage_files","1️⃣2️⃣ Внешние данные (3): sber_ddf_metrics, yandex_realty_zk, yandex_realty_class_prices","1️⃣3️⃣ Geo Свердл/ЕКБ (4): ekb_districts, ekb_metro, sv_cities, sv_dev_sales","1️⃣4️⃣ Rosreestr (10): rosreestr_deals + 7 partitions (2024Q3..2026Q1) + rosreestr_deals_staging + rosreestr_rent_deals","1️⃣5️⃣ PostGIS system (1): spatial_ref_sys","САМЫЕ БОЛЬШИЕ ПО РАЗМЕРУ: rosreestr_deals_2024q4 (253MB), domrf_stat_series_observations (254MB), rosreestr_deals_2024q3 (237MB), domrf_realization (39MB), domrf_sold_ready_breakdown (11MB), domrf_kn_flats (7.4MB).","ОБНОВЛЕНО 27.04.2026 поздний вечер: 59 public TABLES (было 53), 1 871 MB после vacuum/repack (было 2 177 MB).","7⃣ DOM.РФ kn-API РАСШИРЕНО (8): domrf_kn_flats, domrf_kn_objects, domrf_kn_sale_graph (time-series продаж), domrf_kn_sales_agg (текущие агрегаты 3 типа), domrf_kn_infrastructure (норм POI), domrf_kn_photos (мета+local_path), kn_scrape_runs (журнал прогонов), kn_scrape_failures (per-request ошибки с full_url для ручной проверки в браузере).","Рост от 53 до 59: +6 таблиц из экстрас-скрейпера (DomRf_Kn_Scraper_Apr27).","+4 PRINZIP CRM таблицы/views (пока пустые, ждут SQLite-файл): prinzip_leads, prinzip_deals, prinzip_funnel_monthly (MV), prinzip_funnel_by_object (MV). Общее количество таблиц +2 (leads/deals без MV)."]} {"type":"entity","name":"Schema_FK_Map_Apr27","entityType":"infrastructure","observations":["СНАПШОТ 27.04.2026. 35 FK constraints по parent table.","domrf_snapshots (24 child FKs) — все таблицы со snapshot_date: commissioning, developer_aggregates, developers_full, escrow_banks, flat_area_distribution, guaranty_regions, housing_summary, launch_monthly, launch_obj_class, launch_top, mortgage_dashboard, mortgage_details, mortgage_rates, planned_commissioning, project_finance, raw_endpoints, region_aggregates, sold_out, sold_ready_breakdown, sold_ready_dynamics, sold_ready_index, realization ⭐, xlsx_files ⭐, stat_series_observations ⭐.","domrf_regions (5 child FKs): developer_aggregates, flat_area_distribution, region_aggregates, realization (region_code) ⭐, kn_objects (region_cd) ⭐, rent_deals (region_cd) ⭐, territory_aliases.","domrf_developers (3 child FKs): developer_aggregates, kn_objects (dev_id) ⭐, sv_dev_sales (dev_id) ⭐.","domrf_federal_districts (1 child FK): territory_aliases.","ЭВОЛЮЦИЯ: 26.04 было 28 FK → 27.04 lazy +3 → 27.04 territory aliases +2 → 27.04 relations v2 +4 = 35 итого.","НЕ СОЗДАНЫ (data quality issues):","• domrf_kn_flats.obj_id → kn_objects: разные ID-системы API, 27563 orphans.","• domrf_kn_flats.region_cd — все NULL.","• dev_growth.dev_id — все NULL.","• realization.subject (для endpoint_type=developer) → developers — partial constraint нельзя выразить стандартным FK (subject overloaded для total/housing/ready_year).","ОРИГИНАЛ: data/sql/40_relations.sql (базовые 28 FK) + 38_relations_fix.sql (+4 для realization/xlsx/stats) + 39_territory_aliases.sql (+2) + 45_relations_v2.sql (+4 kn_obj/dev_sales/rent). Индексы на FK childs авто."]} {"type":"entity","name":"Schema_Views_Catalogue_Apr27","entityType":"infrastructure","observations":["9 helper views в prod-БД на 27.04.2026.","BASE (legacy 26.04, data/sql/40_relations.sql):","• v_market_pulse_rf — ОДИН ряд РФ-метрики: 118.7M м² стр-ва, sold 30.91%, Ц 216 316 ₽/м², рыночная ипотека 19.9% vs реальная 6.83%.","• v_developer_master — профиль девелопера из sold_ready breakdown + aggregates (ЛЕГАСИ, заменён v_developer_full_metrics).","• v_region_master — один ряд на регион: квартирография + sold_out + sold_ready + project_finance + guaranty + launch.","• v_rosreestr_market_by_region — Rosreestr p10/median/p90 + DOM.РФ имена.","TERRITORY (39_territory_aliases.sql):","• v_stat_series_resolved — stat_series_observations LEFT JOIN aliases LEFT JOIN indicators. Резолвит 99 вариативных имён территорий. WHERE region_id=66 → Свердл.","RELATIONS V2 (45_relations_v2.sql):","• v_jk_with_developer — ЖК + canonical dev_name + region_name (FK kn_obj→developers/regions).","• v_jk_sales_summary — flats GROUP BY obj_name: total/sold/booked/free + sold_pct + avg_ppm + total_value.","• v_developer_portfolio — девелопер + все его ЖК/корпуса (count/flats/sqm + ekb/escrow/completed/in_progress).","• v_developer_full_metrics — ⭐ ULTIMATE: portfolio + квартирография долей (1к/2к/3к/4+) + realization Свердл sold%/цена. PRINZIP/Брусника/Форум-групп всё видно одним запросом."]} {"type":"entity","name":"Schema_ETL_Artefacts_Apr27","entityType":"infrastructure","observations":["Эволюция data/sql/ с 26.04.2026 по 27.04.2026.","26.04 база (16 файлов):","• 01_schema_rosreestr_deals.sql, 02_load_all_quarters.sh, 03_materialized_views.sql","• 10_schema_domrf.sql, 11_load_domrf.py","• 20_schema_domrf_extras.sql, 21_load_domrf_extras.py","• 30_scrape_domrf.py (Playwright headless Chromium WAF-bypass)","• 31_schema_domrf_full.sql, 31_schema_domrf_raw.sql","• 32_load_domrf_full.py, 32_load_domrf_raw.py","• 40_relations.sql (FK + 4 helper views)","27.04 новые 9 файлов:","• 33_schema_domrf_lazy.sql + 34_load_domrf_lazy.py — realization/xlsx_files/raw_endpoints (5 lazy-load страниц)","• 35_schema_domrf_stats.sql + 36_parse_xlsx_stats.py — long-form panel из 7 XLSX (809K observations)","• 37_cleanup_redundant.sql — DROP sold_out_by_year/by_progress (дубли realization)","• 38_relations_fix.sql — +4 FK на snapshots, region_code -1→0 migration, drop dup indexes, COMMENT'ы","• 39_territory_aliases.sql — federal_districts(8) + territory_aliases(101) + v_stat_series_resolved","• 40_scrape_history.py + 41_load_history.py — 14 мес history (2025+Янв/Февр 2026)","• 42_schema_breakdown_widen.sql — sold_ready_breakdown JSONB extra → 6 типизированных колонок","• 43_anton_import.sql + 44_import_anton_db.py — импорт Антоновской SQLite (13 таблиц + merge developers)","• 45_relations_v2.sql — +4 FK kn_obj→regions/developers, sv_dev_sales→developers, rent_deals→regions + 4 analytical views (jk_with_developer, jk_sales_summary, developer_portfolio, developer_full_metrics)","НАИМЕНОВАНИЕ CONVENTION: NN_action_target.{sql|py|sh}. NN — порядок и группировка (00s rosreestr, 10s dashboard, 20s xlsx, 30s scrape+full, 40s relations+history, 45+ recent updates)."]} -{"type":"entity","name":"Schema_Open_TODOs_Apr27","entityType":"feedback","observations":["TODO работ над аналитическим пластом НА 27.04.2026 вечер (синхронизировано с DB_Schema_Final_Apr26).","ДОПОЛНИТЕЛЬНЫЕ ИСТОЧНИКИ ДАННЫХ:","• PRINZIP-квартиры: GET /сервисы/api/kn/flats?regions=66&developers=6208_0 с auth Basic MTpqd2U=. Антон выгрузил в 27 563 flat'ах ОБЩИЙ РФ-срез, PRINZIP фактически отсутствуют. Нужен отдельный probe по developer_id.","• PRINZIP CRM (49 MB SQLite на analytics.prinzipevent.ru) — 102K заявок, 9K сделок, 39.9 млрд ₽ revenue за 8 лет. user подтвердил доступ — отдельный пасс импорта.","• Rosreestr архив 2000-2021 (6.7M сделок Свердл). У Антона _rosreestr_sv_deals.json (73 MB).","• 1 267 фото-рендеров ЖК из /resizer/image — file blob storage.","• escrow исторические даты — пробы 404 на /portal-analytics/api/escrow?date=01-XX-2025. История есть (escrow_allDate.json), нужен правильный URL.","НОРМАЛИЗАЦИЯ СУЩЕСТВУЮЩИХ ДАННЫХ:","• Парсер 01_05_*.xlsx 'Сводный' формат (лежит в xlsx_files BYTEA, bespoke layout).","• Парсер __NEXT_DATA__ для housing_dev/mortgage_rates SSR-блобов (лежат в raw_endpoints).","• GROUP BY comm_name в v_developer_portfolio для реального числа жилых комплексов (сейчас считает корпуса).","ИНФРА:","• Cron quarterly Rosreestr sync (16-го числа 2-го месяца после квартала).","• Cron domrf_daily_scrape на VPS (Playwright + Chromium 300 MB).","• FastAPI router /api/v1/analytics/{ural|ekb|prinzip} используя v_region_master/v_developer_full_metrics.","• Alembic-миграции из *.sql файлов (воспроизводимость).","ANALYTICS-ОТЧСТЫ:","• Расширить расчёт среднего чека по классу/району/девелоперу.","• Соединить ЦБ ипотеку (cbr_mortgage_series) с realization sold% — видны ли корреляции.","• Анализ NSPD GeoJSON кадастровых кварталов + spatial join с rr_agg_cad_quarter."]} +{"type":"entity","name":"Schema_Open_TODOs_Apr27","entityType":"feedback","observations":["TODO работ над аналитическим пластом НА 27.04.2026 вечер (синхронизировано с DB_Schema_Final_Apr26).","ДОПОЛНИТЕЛЬНЫЕ ИСТОЧНИКИ ДАННЫХ:","• PRINZIP CRM (49 MB SQLite на analytics.prinzipevent.ru) — 102K заявок, 9K сделок, 39.9 млрд ₽ revenue за 8 лет. user подтвердил доступ — отдельный пасс импорта.","• Rosreestr архив 2000-2021 (6.7M сделок Свердл). У Антона _rosreestr_sv_deals.json (73 MB).","• escrow исторические даты — пробы 404 на /portal-analytics/api/escrow?date=01-XX-2025. История есть (escrow_allDate.json), нужен правильный URL.","НОРМАЛИЗАЦИЯ СУЩЕСТВУЮЩИХ ДАННЫХ:","• Парсер 01_05_*.xlsx 'Сводный' формат (лежит в xlsx_files BYTEA, bespoke layout).","• Парсер __NEXT_DATA__ для housing_dev/mortgage_rates SSR-блобов (лежат в raw_endpoints).","• GROUP BY comm_name в v_developer_portfolio для реального числа жилых комплексов (сейчас считает корпуса).","ИНФРА:","• Cron quarterly Rosreestr sync (16-го числа 2-го месяца после квартала).","• Alembic-миграции из *.sql файлов (воспроизводимость).","ANALYTICS-ОТЧСТЫ:","• Расширить расчёт среднего чека по классу/району/девелоперу.","• Соединить ЦБ ипотеку (cbr_mortgage_series) с realization sold% — видны ли корреляции.","• Анализ NSPD GeoJSON кадастровых кварталов + spatial join с rr_agg_cad_quarter.","СДЕЛАНО 27.04 вечер (было в TODO):","✅ PRINZIP-флаты probe (TODO #1 из Schema_Open_TODOs) — выгружены 826 квартир PRINZIP через собственный скрейпер (run_id=2). См. DomRf_Kn_Scraper_Apr27.","✅ FastAPI router /api/v1/analytics — 11 endpoints, 3 страницы дашборда. См. Analytics_Dashboard_Apr27.","✅ Фото-рендеры ЖК — метаданные собираются в domrf_kn_photos (5150 рядов по PRINZIP). Binary довыгрузка через --download-photos опционально.","✅ Cron domrf_daily_scrape на VPS — Celery beat + worker в docker-compose.yml. Расписание через SCRAPE_KN_CRON env (default '15 4 * * mon' + jitter 0-30мин).","ОСТАЛОСЬ (open):","• Импорт PRINZIP CRM 49 MB SQLite — выделен в GitHub issue #14.","• Rosreestr архив 2000-2021 (6.7M сделок Sverdl) у Антона.","• escrow исторические даты — нужен правильный URL.","• Парсер __NEXT_DATA__ для housing_dev/mortgage_rates SSR-блобов.","• GROUP BY comm_name в v_developer_portfolio.","• Расширить kn-scraper на 442 строящихся Свердл жК (GitHub #13).","• API endpoints /api/v1/analytics/object/{id}/sale_graph и др. (GitHub #15).","• Multi-target Dockerfile (-200 МБ backend) (GitHub #16).","GitHub backlog: источник правды для operational issues — https://github.com/lekss361/-gendesign/issues. Из открытых: #3 pg_dump → S3, #4 Sentry release, #5-9 Stage 1/2 generative & site-finder, #11 Anton onboarding, #13-16 из этой сессии. #10 (Plan A vs Original) закрыт — решено в пользу analytics-first."]} {"type":"entity","name":"Sverdl_Market_Trend_Apr27","entityType":"research","observations":["КЛЮЧЕВОЙ ТРЕНД Свердл 14 мес (Янв 2025 - Апр 2026) из domrf_realization timeseries.","ОБЪЁМ СТР-ВА: 5 115 → 6 015 тыс м² (+18% за 2025) → 5 686 (Мар 2026, -5% откат от декабря).","SOLD%: 35% (Янв 2025) → ПЛАТО 32-35% в середине 2025 → 31% (Дек) → 29% (Апр 2026). ОПОСРЕДОВАННОЕ ОХЛАЖДЕНИЕ.","UNSOLD%: 50% → 54% (+4пп). Растёт запас нереализованного.","UNOPENED%: 15% → 17% (вырос лето 2025-2026 до 20% в Дек). Продажи не открываются позже.","ЦЕНА: 136 796 → 151 397 ₽/м² (+11% за 14 мес). МЕДЛЕННЕЕ инфляции и ставки ЦБ 11%. Девелоперы СДЕРЖИВАЮТ рост цен чтобы зажечь спрос.","PIPELINE ПО ГОДУ ВВОДА: 2026 sold 54% → 2027 29% → 2028 15% → 2029 4% → 2030 0.2%. ОКНО ИНВЕСТ: ввод 2026-2027.","REPRO QUERY: SELECT TO_CHAR(snapshot_date,'YYYY-MM'), total_square, sold_perc, price_avg FROM domrf_realization WHERE region_code=66 AND endpoint_type='total' AND type_square='total' ORDER BY snapshot_date;"]} {"type":"entity","name":"Competitor_Benchmark_Brusnika_Apr27","entityType":"research","observations":["Брусника (developer_id 5791_0) — BENCHMARK для PRINZIP. Средний-крупный portfolio + растущий sold%.","ПОРТФЕЛЬ (developer_aggregates РФ-итог): 30 106 кв-р / 1.80M м² / ср. 60 м².","КВАРТИРОГРАФИЯ: 1к 47%, 2к 35%, 3к 17%, 4+к 1% — СБАЛАНСИРОВАННО семейный профиль.","СВЕРДЛ: 352 тыс м², sold% 47% (Апр 2026). Динамика: 36% (Янв 2025) → 64% (Дек 2025 ПИК) → 47% (Апр 2026 после приёма новых проектов).","КОРПУСОВ (kn_objects.dev_id=5791_0): 45 жилых комплексов, 28 сданы, 17 строятся.","ЧТО ПОДРАЖАТЬ PRINZIP'у: баланс квартирографии (1к/2к/3к 47/35/17 vs PRINZIP 75/19/3), работа с эскроу-проектами (Брусника 17 строит крупно), рост портфеля без потери sold% (Q4 2025 был 64%).","REPRO: SELECT * FROM v_developer_full_metrics WHERE developer_id='5791_0';"]} {"type":"entity","name":"Competitor_Benchmark_ForumGroup_Apr27","entityType":"research","observations":["Холдинг Форум-групп (developer_id 5832_0) — БЕЗУСЛОВНЫЙ ЛИДЕР VELOCITY Свердл 27.04.","ПОРТФЕЛЬ (РФ-итог): 1 850 кв-р / 113 тыс м² / ср. 61 м².","КВАРТИРОГРАФИЯ: 1к 44%, 2к 34%, 3к 18%, 4+к 3% — ближе всех к 'family balanced' от рыночного спроса.","СВЕРДЛ: 113 тыс м² (мини-портфель относительно лидеров), sold% ЛИДЕР 54% (Апр 2026). Динамика: 33% (Янв 2025) → 47% (Дек) → 54% (Апр 2026) = +21пп за год.","КОРПУСОВ (kn_objects): 44, 34 сданы, 10 строятся.","ОСОБЕННОСТИ: НЕ РОС портфель за 14 мес (113 тыс м² стабильно), выиграл велосити без расширения. МОДЕЛЬ 'ДОПРОДАТЬ ЧТО ЕСТЬ ПРИ РОСТЕ ЦЕН НА НЕПРОДАННОЕ' — ближайшая к PRINZIP.","ЧТО ПОДРАЖАТЬ: PRINZIP имеет ту же логику (не растут объёмы с 31 тыс м²), но благодаря меньшей квартирографии идёт медленнее (PRINZIP +33пп vs Форум +21пп, но Форум выше absolute 54% vs PRINZIP 48%). ПОДНЯТЬ средний м² в портфеле — выравняться с Форумом.","REPRO: SELECT * FROM v_developer_full_metrics WHERE developer_id='5832_0';"]} {"type":"entity","name":"DomRF_Lazy_Loaded_Apr27_2026","entityType":"event","observations":["Дата: 27.04.2026. ВЕХА: догружены 5 lazy-load страниц ДОМ.РФ + распарсены stat_series XLSX + дропнуты дубли + созданы territory aliases.","СКРЕЙПЕР 30_scrape_domrf.py расширен per-page direct-API триггерами (trigger_housing/_dev/realization/mortgage_rates/stat_series). Внутри page.evaluate(fetch()) — куки/WAF из chromium-context.","5 СТРАНИЦ ИСТОЧНИКИ: housing (302 JSON, /portal-analytics/api/dashboard/*), housing_dev (SSR __NEXT_DATA__), realization (501 JSON, /аналитика/api/rpp/*) ОСНОВНОЙ, mortgage_rates (SSR), stat_series (9 XLSX через fetch+arrayBuffer→base64).","ДОБАВЛЕНО В ПРОД (6 новых таблиц): domrf_realization (9 747), domrf_xlsx_files (9 BYTEA), domrf_stat_series_observations (809 236), domrf_stat_series_indicators (123), domrf_federal_districts (8), domrf_territory_aliases (101). + 333 raw JSONB endpoints в domrf_raw_endpoints.","ДРОПНУТЫ 2 ДУБЛЯ: domrf_sold_out_by_year (покрывался realization endpoint=ready_year) + domrf_sold_out_by_progress (покрывался endpoint=housing). domrf_sold_out ОСТАВЛЕН ради attracted_funds_mln_rub.","FK АУДИТ: добавлены 4 FK (на snapshots+regions), мигрирован region_code -1→0 (2742 ряда), дропнуты 2 дублирующих индекса. Детали в Schema_FK_Map_Apr27.","TERRITORY ALIASES: 101 alias resolver text→(level, region_id|fo_id) + view v_stat_series_resolved. 100% coverage 809K observations stat_series. Детали в Schema_Views_Catalogue_Apr27.","MCP postgres-mcp ПОДКЛЮЧЕН (.mcp.json в project root, .gitignore). С этого момента все DDL/SELECT через mcp__postgres__execute_sql.","ОГРАНИЧЕНИЯ: housing_dev/mortgage_rates SSR-блобы лежат в raw_endpoints (НЕ нормализованы). 2 XLSX 01_05_*.xlsx 'Сводный' формат остаются в xlsx_files BYTEA. См. Schema_Open_TODOs_Apr27.","АРТЕФАКТЫ: data/sql/30_scrape_domrf.py + 33-39_*.sql/py (10 файлов). Список в Schema_ETL_Artefacts_Apr27.","REPRO для Свердлобл realization sold% timeseries: SELECT TO_CHAR(snapshot_date,'YYYY-MM'), total_square, sold_perc, price_avg FROM domrf_realization WHERE region_code=66 AND endpoint_type='total' AND type_square='total' ORDER BY snapshot_date;"]} {"type":"entity","name":"Anton_Imported_Apr27_2026","entityType":"event","observations":["Дата: 27.04.2026 поздний вечер. ИМПОРТ SQLite Антона (23.5 MB, 27 таблиц + 8 view) в prod Postgres.","ИСТОЧНИК: C:\\Users\\user\\Downloads\\Telegram Desktop\\db\\db\\domrf.db. Антон параллельно собрал 50+ источников (см. SUMMARY_FULL.md в дампе).","СТРАТЕГИЯ: импорт только уникальных таблиц. Дубли ПРОПУЩЕНЫ: regions, federal_districts, mortgage_rates, escrow_banks, rc_classes/dev_size/city_pop, rc_dynamics_rf, egrn_deals (наш rosreestr_deals 4.97M raw богаче чем его 154K cleaned).","ЗАЛИТЫ 13 НОВЫХ ТАБЛИЦ (197K рядов, +50 MB): domrf_kn_flats (27 563 поквартирный прайс РФ), domrf_kn_objects (1 516 ЖК Свердл с GPS), cbr_mortgage_series (3 308 ЦБ серий), cbr_mortgage_files (19), sber_ddf_metrics (20), yandex_realty_zk (12), yandex_realty_class_prices (3), ekb_districts (9), ekb_metro (10), sv_cities (24), sv_dev_sales (50), domrf_dev_growth (139), rosreestr_rent_deals (1 071). Полный список в Schema_Tables_Map_Apr27.","DEVELOPERS MERGE: было 2 734 в проде → Антон 8 618 (вкл. архивные). Добавлены 5 895 НОВЫХ (2 723 совпали). Итого domrf_developers: 8 629 рядов.","АРТЕФАКТЫ: data/sql/43_anton_import.sql + 44_import_anton_db.py. Список ETL в Schema_ETL_Artefacts_Apr27.","ОГРАНИЧЕНИЕ PRINZIP: в 27 563 flats Антона ОБЩИЙ РФ-срез (10 700 Москва, 286 ЕКБ), PRINZIP-квартиры ОТСУТСТВУЮТ — нужен отдельный probe /api/kn/flats?regions=66&developers=6208_0. TODO в Schema_Open_TODOs_Apr27.","ДОПОЛНИТЕЛЬНЫЕ ИСТОЧНИКИ АНТОНА (НЕ в дампе, требуют отдельного пасса): PRINZIP CRM (49 MB SQLite на analytics.prinzipevent.ru, 102K заявок / 39.9 млрд ₽ revenue), Rosreestr архив 2000-2021 (6.7M сделок, _rosreestr_sv_deals.json 73 MB), 1 267 фото-рендеров ЖК.","PARTNERSHIP НОТЕ: Антон имеет свой граф (114 entities + 339 relations, отдельный jsonl), prod-БД ОБЩИЙ ресурс через SSH-туннель. user подтвердил доступ к CRM/Yandex Realty — 'это и наш доступ тоже'."]} +{"type":"entity","name":"Session_End_Apr27_2026","entityType":"event","observations":["Дата: 27.04.2026 конец дня. СЛЕДУЮЩИЙ ЧАТ НАЧИНАЙ С ЭТОЙ СУЩНОСТИ ПЛЮС 4 ССЫЛОЧНЫх (ниже).","MCP CONNECTOR: crystaldba/postgres-mcp подключён через .mcp.json в project root (в .gitignore). Инструменты: mcp__postgres__execute_sql, list_objects, analyze_db_health, explain_query, get_object_details. Использовать вместо docker run psql.","КЛЮЧЕВОЙ VIEW: v_developer_full_metrics — одним запросом видно portfolio + квартирография доли + realization Свердл sold%/цена. SELECT * FROM v_developer_full_metrics WHERE developer_id IN ('6208_0','5791_0','5832_0').","СДЕЛАНО ЗА ДЕНЬ (ссылки на entity):","1. Lazy-load 5 страниц ДОМ.РФ + stat_series XLSX (DomRF_Lazy_Loaded_Apr27_2026)","2. История 14 мес realization+sold_ready, Янв 2025-Февр 2026 (DomRF_History_Loaded_Apr27_2026)","3. Импорт SQLite Антона 13 таблиц × 197K рядов (Anton_Imported_Apr27_2026)","4. Relations v2: +4 FK связь КВАРТИРЫ↔ЖК↔ДЕВЕЛОПЕРЫ + 4 analytical views (в Schema_Views_Catalogue_Apr27)","5. Два стратегических отчёта: Sverdl_Quartirography_Report_Apr27 + PRINZIP_Strategy_Apr27 + 2 benchmark-карточки (Brusnika, ForumGroup)","• Импорт PRINZIP CRM (49 MB SQLite на analytics.prinzipevent.ru) — 102K заявок / 39.9 млрд ₽ revenue.","• Парсер __NEXT_DATA__ для housing_dev/mortgage_rates SSR-блобов.","• GROUP BY comm_name в v_developer_portfolio — текущий считает корпуса, не жилые комплексы.","GRAPH HEALTH: 127 entities + 388 relations. Размер jsonl ~190K chars. После декомпозиции самый большой entity 8 881 chars (DataSources_Analysis_Apr26). Бывший гигант DB_Schema_Final_Apr26 УДАЛЁН, декомпозирован на 5 Schema_*_Apr27.","HARDLINK TRAP ГРАФА: MCP-сервер atomic-write периодически разрывает hard-link между C:\\mcp\\.aim\\memory-gendesign.jsonl и repo/memory/memory-gendesign.jsonl. Перед git commit ПРОВЕРИТЬ: fc /b memory\\memory-gendesign.jsonl C:\\mcp\\.aim\\memory-gendesign.jsonl. Если разные — del + New-Item -ItemType HardLink.","PROD ДОСТУП: SSH 'gendesign' алиас → 46.173.16.127 с LocalForward 15432:5432. Ключ: C:\\Users\\user\\.ssh\\gendesign_deploy. Пароль PG: 2J2SBPMKuS998fiwhtQqDhMI. Туннель уже открыт (netstat -an | grep 15432 LISTENING).","ОБНОВЛЕНО поздний вечер 27.04.2026: ПРОД-БД теперь 59 таблиц / 1871 MB. +6 таблиц от скрейпера (sale_graph, sales_agg, infrastructure, photos, kn_scrape_runs, kn_scrape_failures).","НОВЫЕ SESSION-ИТОГИ к ранее записанным буллетам: 6) Интерактивный дашборд /analytics (Analytics_Dashboard_Apr27) — 3 страницы на ECharts. 7) Парсер DOM.РФ kn-API + 4 extras (DomRf_Kn_Scraper_Apr27) — собственный sweep Свердл 442+2+1072 = 1516 жК, валидирован через PRINZIP probe (28 жК / 826 квартир / 5409 POI / 5150 фото-мета).","READ FIRST ОБНОВЛЕНО (6 entity вместо 4): Schema_Tables_Map_Apr27 + Schema_Open_TODOs_Apr27 + PRINZIP_Strategy_Apr27 + Sverdl_Quartirography_Report_Apr27 + Analytics_Dashboard_Apr27 + DomRf_Kn_Scraper_Apr27. Первые 4 — данные/стратегия, последние 2 — фичи построенные поверх.","PRIORITY НА СЛЕДУЮЩУЮ СЕССИЮ ОБНОВЛЕН (все из GitHub backlog): 1) #13 Расширить kn-scraper на 442 строящихся Свердл. 2) #14 Импорт PRINZIP CRM. 3) #15 API endpoints /api/v1/analytics/object/{id}/* + UI drill-down. 4) #16 multi-target Dockerfile."]} +{"type":"entity","name":"Analytics_Dashboard_Apr27","entityType":"deliverable","observations":["Дата: 27.04.2026 вечер. ИНТЕРАКТИВНЫЙ ВЕБ-ДАШБОРД АНАЛИТИКИ Свердл рынок + PRINZIP реализован за один проход. План: C:\\\\Users\\\\user\\\\.claude\\\\plans\\\\crispy-waddling-swing.md.","СТЕК: Backend FastAPI (sync SQLAlchemy + psycopg, Pydantic). Frontend Next.js 15 App Router + React 19 + React Query (TanStack) + ECharts (echarts + echarts-for-react v6). 3 страницы, ~16 компонентов, 11 endpoints.","BACKEND ФАЙЛЫ: backend/app/api/v1/analytics.py (роутер 11 endpoints), backend/app/services/analytics_queries.py (SQL-функции). Зарегистрирован в backend/app/main.py prefix /api/v1/analytics tags=['analytics']. Sync Session через get_db().","ENDPOINTS: GET /sverdl/market-pulse · /sverdl/quartirography?source=portfolio|deals · /sverdl/pipeline · /sverdl/districts · /sverdl/yandex-listings · /developers/top?limit · /developers/{id} · /developers/{id}/portfolio · /developers/history?ids=csv · /prinzip/insights (статика из PRINZIP_Strategy_Apr27) · /prinzip/districts (хардкод 8 районов из стратегии).","FRONTEND ФАЙЛЫ: frontend/src/app/analytics/{layout.tsx, page.tsx, prinzip/page.tsx, developers/page.tsx}. Компоненты frontend/src/components/analytics/*: ChartShell, KpiCard, Section, AnalyticsNav, MarketPulseChart, QuartirographyChart, PipelineChart, DistrictTreemap, YandexClassPie, DeveloperLeaderboard (sortable table), VelocityScatter, PrinzipVelocityChart, PrinzipQuartirographyPie, PrinzipGapBar, PrinzipDistrictsBar, InsightCards. lib: analytics-api.ts (React Query hooks), types/analytics.ts.","ROOT ИЗМЕНЕНИЯ: providers.tsx (QueryClientProvider, staleTime 5мин), layout.tsx обёрнут в Providers, page.tsx +ссылка на /analytics. tslib добавлен (требует echarts-for-react). package.json +echarts ^6 +echarts-for-react ^3 +tslib.","STATIC TS BUILD: ✅ npm run type-check clean, npm run build successful 9/9 routes. Размер /analytics 4.52KB, /analytics/prinzip 4.91KB, /analytics/developers 4.26KB First Load 116-120KB.","API SMOKE-TEST через uvicorn + SSH-туннель :15432 — все 11 endpoints отвечают 200, цифры совпадают с Sverdl_Quartirography_Report_Apr27 и PRINZIP_Strategy_Apr27 (sold% PRINZIP 48 / однушки 75.4% / 814 кв.; рынок Свердл Апр 2026 5686 тыс м² 29% 151 397 ₽).","КАК ЗАПУСКАТЬ ЛОКАЛЬНО: backend через docker-compose.yml ИЛИ uv run uvicorn app.main:app + переменная DATABASE_URL='postgresql+psycopg://gendesign:2J2SBPMKuS998fiwhtQqDhMI@127.0.0.1:15432/gendesign'. Frontend cd frontend && npm run dev. Прод: gendsgn.ru через Caddy.","ИЗВЕСТНЫЕ ОГРАНИЧЕНИЯ MVP: (1) /prinzip/districts хардкод из стратегии — нет геометрии районов в ekb_districts для real spatial-join; TODO добавить geometry. (2) Yandex listings в БД всего 12 строк (граф ошибочно говорил 258 — устаревший снимок). (3) v_developer_full_metrics.sverdl_price_avg всегда NULL — KPI ср. цены девелопера пока без данных. (4) Auth нет — публичный URL.","БАГ ИСПРАВЛЕН ВО ВРЕМЯ SMOKE: SQL :region_code в quartirography source=deals использовал не тот биндинг параметра — переименовал на :region_id. Также rosreestr_deals.deal_count это мультипликатор (max 2278) а не штучные сделки — заменено на COUNT(*).","СЛЕДУЮЩИЙ ШАГ: добавить geometry в ekb_districts → real spatial-join PRINZIP-ЖК → carto-карта на /analytics/prinzip. Альтернатива: импорт CRM PRINZIP (49 MB SQLite) → conversion-аналитика. Дальше — авторизация и /analytics/{region}/{developer} параметризация для повторного использования другими клиентами."]} +{"type":"entity","name":"DomRf_Kn_Scraper_Apr27","entityType":"deliverable","observations":["Дата: 27.04.2026. ПАРСЕР НАШ.ДОМ.РФ для КЖ (kn/object) и квартир (слоённый portal/table) реализован. План: C:\\\\Users\\\\user\\\\.claude\\\\plans\\\\crispy-waddling-swing.md.","REAL ENDPOINTS (проверены через chrome-devtools): GET /сервисы/api/kn/object?offset=0&limit=999999&place={place}&objStatus={s}&sortField=default&sortType=desc · GET /portal-kn/api/sales/portal/table?externalId={objId}. Auth header: Authorization: Basic MTpxd2U= (декодируется в 1:qwe — НЕ jwe как было в предыдущей памяти).","PARAMS КЛЮЧЕВЫЕ: place=66 (Регион Свердл) или place='0-4' (город ЕКБ). objStatus: 0=строящиеся, 1=архив-1 (~2 регион), 2=сданные/архив-2 (самый большой бакет). place=66 все 3 статуса = 442+2+1072 = 1516 ЖК (точно совпало с domrf_kn_objects).","СЕРВЕРНЫЙ ФИЛЬТР developers/regions НЕ РАБОТАЕТ — size/page/regions/developers все игнорируются. Поэтому фильтр по девелоперу реализован ЛОКАЛЬНО: фильтруем по developer.companyGroup из ответа. PRINZIP companyGroup=6208.","СТЕК: Playwright Chromium держит живую страницу «новостройки» (cookies+JA3 фингерпринт реального Chrome 147), fetch() из in-page context. httpx вариант НЕ РАБОТАЕТ — ServicePipe WAF проверяет TLS-fingerprint, различает Python httpx от Chrome даже с идентичными cookies. План был 'gibrid Playwright bootstrap + httpx' — НЕ СРАБОТАЛ, пивотнул на 'pure Playwright in-context'.","BACKEND ФАЙЛЫ: backend/app/services/scrapers/{stealth.py, domrf_kn.py, __init__.py}. backend/app/workers/celery_app.py (beat из settings.scrape_kn_cron). backend/app/workers/tasks/scrape_kn.py (с jitter 0..SCRAPE_KN_JITTER_SECONDS). backend/app/api/v1/admin_scrape.py (POST /api/v1/admin/scrape/kn + GET /runs, авторизация через X-Admin-Token = SCRAPE_ADMIN_TOKEN env). data/sql/50_schema_kn_extensions.sql + 50_scrape_kn_flats.py CLI.","FRONTEND: frontend/src/app/admin/scrape/page.tsx — кнопка «Запустить сейчас» (region, devs csv, fetch_flats checkbox) + таблица последних 20 прогонов из kn_scrape_runs (обновляется каждые 5 сек). Token хранится в localStorage.","КОНФИГ: env vars (settings) — SCRAPE_KN_CRON='15 4 * * mon' (любой 5-филд cron), SCRAPE_KN_JITTER_SECONDS=1800 (0..30мин random delay), SCRAPE_KN_DEFAULT_REGIONS='66' (csv), SCRAPE_KN_STATE_PATH='data/playwright_state.json', SCRAPE_ADMIN_TOKEN='' (пусто=выкл admin endpoint).","PLAYWRIGHT FINGERPRINT СНЯТ с локальной машины: data/playwright_state.json (7.3 KB, 26 cookies — AUTH_SESSION_ID, KC_AUTH_SESSION_HASH, ServicePipe NSC_*, Yandex tracking). Закоммичен в git для использования на сервере (worker подхватит при starting cold — ServicePipe видит returning user, не запускает JS-challenge). CLI flags --save-state PATH и --load-state PATH для периодического обновления.","DOCKER: docker-compose.yml +сервисы worker и beat (оба из образа backend). backend/Dockerfile +строка 'playwright install --with-deps chromium' — доп. ~80MB system libs.","СХЕМА-МИГРАЦИЯ ПРИМЕНЕНА: PK сняты с domrf_kn_flats.id и domrf_kn_objects.obj_id, заменены на UNIQUE (id|obj_id, snapshot_date). +индексы (region_cd, snapshot_date). +таблица kn_scrape_runs (журнал прогонов). Backfill region_cd=66 на Антоновых flats: 0 рядов затронуто (их obj_id не матчился с kn_objects).","SMOKE-ТЕСТ PRINZIP ПРОШЁЛ: 4 минуты, run_id=2 в kn_scrape_runs status='done'. Получено: place=66 × (status 0,1,2) = 442+2+1072 = 1516 объектов, локальный фильтр по companyGroup=6208 = 28 PRINZIP ЖК (= jk_count в v_developer_full_metrics!) + 826 квартир (с 814 agg_flats_total разница +12). 31 request total, 22 из них = 404 для сданных (квартиры уже нет в продаже — ок).","TYPE COERCION ИСПРАВЛЕН ДЛЯ BOOLEAN (0/1→bool), DATE ('YYYY-MM-DD'/'DD-MM-YYYY HH:MM:SS'/'IV кв. 2028' → последний день квартала), TEXT (problem_flag из int).","РАСПИСАНИЕ + ПЛАВАЮЩИЙ СТАРТ + КНОПКА выполнены: cron из SCRAPE_KN_CRON env, внутри таска random delay 0..SCRAPE_KN_JITTER_SECONDS берёт время, ручной триггер передаёт skip_jitter=True для немедленного старта.","СЛЕДУЮЩИЙ ШАГ: 1) Развёрнуть worker+beat в docker-compose, проверить celery beat schedule в живюю. 2) Добавить link на /admin/scrape с главной (пока только по прямому URL). 3) Full РФ-sweep (89 регионов) с multi-region cron после стабилизации Свердл. 4) Изучить /сервисы/api/object/{id}/sale_graph + sales_agg для time-series по продажам (видели в нетворке объектной страницы).","EXTRAS-ЭНДПОЙНТЫ ДОБАВЛЕНЫ (per-КЖ, после 27.04.2026 вечер): /сервисы/api/object/{id}/sale_graph?type=apartments|parking (time-series продаж), /sales_agg (текущие агрегаты 3 типа), /infrastructure (POI), /construction/progress/photo (мета+binary).","EXTRAS-ТАБЛИЦЫ: domrf_kn_sale_graph (obj_id, report_month, type, realised, contracted, area_sq, price_avg, snapshot_date), domrf_kn_sales_agg (obj_id, type ∈{apartments,nonliv,parking}, name, total, realised, perc), domrf_kn_infrastructure (нормализованная POI с lat/lon/dist_m/category), domrf_kn_photos (метаданные + local_path к binary). Schema: data/sql/51_schema_kn_extras.sql.","FAILURE LOG: kn_scrape_failures (run_id, obj_id, endpoint, full_url, http_status, error, body_preview, occurred_at). API GET /api/v1/admin/scrape/failures?run_id=N. В admin UI раскрывающийся блок с кнопкой «копировать URL» для ручной проверки в Chrome.","CLI РАСШИРЕН 50_scrape_kn_flats.py: флаги --no-extras / --download-photos / --no-flats / --probe URL. Probe поднимает Playwright-сессию и дёргает один URL — полезно для дебага конкретного фейла.","DOCKER FIX (буг #25): uv.lock не был реген после добавления playwright+tenacity — uv sync --frozen игнорил новые депы. Надо всегда делать 'cd backend && uv lock' после изменения pyproject.toml. Также Dockerfile реструктурирован: Chromium-libs вынесены явно в apt-get install (libnss3, libnspr4, libdbus-1-3, libatk1.0-0, libatk-bridge2.0-0, libcups2, libdrm2, libxkbcommon0, libxcomposite1, libxdamage1, libxfixes3, libxrandr2, libgbm1, libasound2t64, libatspi2.0-0), playwright install chromium без --with-deps, перед rm -rf /var/lib/apt/lists/*. ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright для shared chromium-cache.","DOCKER ОПТИМИЗИРОВАН (#16, 27.04 поздний вечер): backend/Dockerfile расщеплён на 2 таргета. target=runner (lean, ~1.6 ГБ) для backend/beat. target=runner-with-chromium (~2.9 ГБ) для worker. docker-compose.{yml,prod.yml} и .github/workflows/deploy.yml пушат 2 GHCR-образа: gendesign-backend + gendesign-worker. Экономия ~1.3 ГБ на backend image.","PER-OBJECT DRILL-IN (#15) ГОТОВ: 5 backend GET endpoints (/object/{id}, /sale_graph, /sales_agg, /infrastructure, /photos) + /prinzip/objects с sparkline-data. UI: новая страница /analytics/objects/[id] (KPI + sale-chart + POI-таблица с фильтром по категории + фото-галерея с lightbox), плюс PrinzipObjectsTable на /analytics/prinzip с sparkline в каждой строке и кнопкой Drill-in.","GitHub issues #13, #14, #15, #16 закрыты 27.04.2026 поздний вечер. README обновлён с разделами 'Запуск 442-sweep' + PRINZIP CRM в карте таблиц + ETL artefacts +52_schema/52_import."]} +{"type":"entity","name":"Prinzip_CRM_Pipeline_Apr27","entityType":"deliverable","observations":["Дата: 27.04.2026 поздний вечер. ПАЙПЛАЙН ДЛЯ ИМПОРТА PRINZIP CRM SQLite (analytics.prinzipevent.ru, 49 МБ, 102K заявок, 9K сделок) реализован, ждёт файла. GitHub #14 закрыт с instructions.","SCHEMA: data/sql/52_schema_prinzip_crm.sql — prinzip_leads (lead_id PK, source/channel, phone_hash SHA256[:16] + phone_last4, email_hash MD5, obj_interest soft FK к domrf_kn_objects, status, converted, deal_id) + prinzip_deals (deal_id PK, lead_id, closed_at, obj_id soft FK, deal_price, payment_type, mortgage_*) + 2 MV (funnel_monthly по месяцам×source, funnel_by_object по КЖ). Применено в прод.","IMPORT SCRIPT: data/sql/52_import_prinzip_crm.py. UPSERT по ID. PII-hashing при импорте (152-ФЗ). Использовать --inspect-only для discovery реальных колонок SQLite и подправить LEAD_FIELD_MAP/DEAL_FIELD_MAP. REFRESH MV автоматически в конце.","BACKEND: 3 endpoints в backend/app/api/v1/analytics.py — /prinzip/funnel/monthly?months=, /prinzip/funnel/by-source?months=, /prinzip/funnel/by-object. Используют MV быстрых выборок.","FRONTEND: 2 новых ECharts-компонента на /analytics/prinzip — PrinzipFunnelSankey (leads→engaged→converted by source, type=sankey) + PrinzipFunnelMonthly (бары leads/engaged/converted + линия conv%). Оба обрабатывают пустой dataset изящно (текст 'Нет данных CRM').","СЛЕДУЮЩИЙ ШАГ: user скачивает SQLite в data/raw/prinzip_crm.sqlite (в .gitignore), запускает импорт. Ожидаемые после импорта: SUM(deal_price)/1e9 ≈ 39.9 (млрд ₽), conversion 2025 ≈ 5.4%, пик 12.81% в 2023. UI будет живым автоматически после импорта."]} +{"type":"entity","name":"Route_Home","entityType":"ui_route","observations":["URL: /","Файл: frontend/src/app/page.tsx","Sitemap-страница с карточками-ссылками на все разделы. 3 секции: Аналитика (4 карточки), Админка (2), Generative (2). Footer со ссылками на /api/v1/docs, /health, GitHub."]} +{"type":"entity","name":"Route_Analytics_Sverdl","entityType":"ui_route","observations":["URL: /analytics","Файл: frontend/src/app/analytics/page.tsx","Свердловский рынок: 4 KPI-карточки + market-pulse line chart + парадокс «строят vs покупают» + pipeline + treemap районов ЕКБ + яндекс listings pie. Публичный доступ."]} +{"type":"entity","name":"Route_Analytics_Prinzip","entityType":"ui_route","observations":["URL: /analytics/prinzip","Файл: frontend/src/app/analytics/prinzip/page.tsx","PRINZIP drill-down: 4 KPI карточки + velocity vs benchmarks (toggle) + квартирография pie + gap-bar к рынку + районы ЕКБ + таблица 28 ЖК с sparkline + insight-карточки. Публичный (CRM-funnel вынесён в /admin/leads)."]} +{"type":"entity","name":"Route_Analytics_Developers","entityType":"ui_route","observations":["URL: /analytics/developers","Файл: frontend/src/app/analytics/developers/page.tsx","Топ-15 девелоперов: sortable leaderboard + velocity scatter + сравнение sold% по месяцам. drill-in через ?id="]} +{"type":"entity","name":"Route_Analytics_Object","entityType":"ui_route","observations":["URL: /analytics/objects/[id]","Файл: frontend/src/app/analytics/objects/[id]/page.tsx","Drill-in одного ЖК: 6 KPI + sale-chart (apartments+parking+price line) + POI-таблица с фильтром по категории/радиусу + фото-галерея с lightbox. Пример: /analytics/objects/60160 (Парк Победы)."]} +{"type":"entity","name":"Route_Admin_Scrape","entityType":"ui_route","observations":["URL: /admin/scrape","Файл: frontend/src/app/admin/scrape/page.tsx + admin/layout.tsx","Token-protected. Ручной запуск скрапера (регион, devs, extras, photos) + живая очередь Celery (active/reserved/scheduled) с кнопками '🛑 Прервать' (SIGTERM) и '⊘ Удалить из очереди' (revoke) + журнал прогонов + журнал ошибок с copy-URL."]} +{"type":"entity","name":"Route_Admin_Leads","entityType":"ui_route","observations":["URL: /admin/leads","Файл: frontend/src/app/admin/leads/page.tsx + admin/layout.tsx","Token-protected. CRM-воронка PRINZIP: Sankey leads→engaged→converted by source + monthly bars + 5 KPI карточек + 4 фильтра (поиск/статус/source/converted) + таблица лидов с пагинацией. PII (phone/email) хешированы, в UI только ***1234."]} +{"type":"entity","name":"Endpoint_Analytics_Object","entityType":"api_endpoint","observations":["GET /api/v1/analytics/object/{obj_id}","GET /api/v1/analytics/object/{obj_id}/sale_graph?type=apartments|parking","GET /api/v1/analytics/object/{obj_id}/sales_agg","GET /api/v1/analytics/object/{obj_id}/infrastructure?category=&max_distance=","GET /api/v1/analytics/object/{obj_id}/photos?limit=","GET /api/v1/analytics/prinzip/objects (с sparkline-data)","Источник: backend/app/api/v1/analytics.py + analytics_queries.py. Публичные."]} +{"type":"entity","name":"Endpoint_Admin_Scrape","entityType":"api_endpoint","observations":["POST /api/v1/admin/scrape/kn (trigger)","GET /api/v1/admin/scrape/runs (journal)","GET /api/v1/admin/scrape/failures?run_id=","GET /api/v1/admin/scrape/queue (Celery inspect: active/reserved/scheduled/workers)","POST /api/v1/admin/scrape/revoke {task_id, terminate}","Auth: X-Admin-Token. Файл: backend/app/api/v1/admin_scrape.py."]} +{"type":"entity","name":"Endpoint_Admin_Leads","entityType":"api_endpoint","observations":["GET /api/v1/admin/leads/ (фильтры: status/source/converted/obj_id/search/date_from/date_to + lim/off)","GET /api/v1/admin/leads/stats?months=12 (KPI)","GET /api/v1/admin/leads/sources (distinct list)","GET /api/v1/admin/leads/funnel/monthly?months=","GET /api/v1/admin/leads/funnel/by-source?months=","GET /api/v1/admin/leads/funnel/by-object","Auth: X-Admin-Token. Файл: backend/app/api/v1/admin_leads.py."]} +{"type":"entity","name":"Module_Scraper_Stealth","entityType":"code_module","observations":["backend/app/services/scrapers/stealth.py","BrowserSession — Playwright Chromium + cookie jar + storage_state load/save. get_json() в браузерном контексте (TLS-fingerprint = real Chrome 147). download_binary() для фото. Auth: Basic MTpxd2U= (1:qwe). Jitter 600-1500мс + concurrency 3."]} +{"type":"entity","name":"Module_Scraper_Kn","entityType":"code_module","observations":["backend/app/services/scrapers/domrf_kn.py","Endpoints для /сервисы/api/kn/object + portal/table + 4 extras (sale_graph/sales_agg/infrastructure/photos). place=66 для Свердл, objStatus 0/1/2 = 442+2+1072. Локальный фильтр по developer.companyGroup. _classify_and_log() — 404 для sale_graph/sales_agg не пишет в failures (ожидаемые для сданных)."]} +{"type":"entity","name":"Module_Celery_App","entityType":"code_module","observations":["backend/app/workers/celery_app.py + workers/tasks/scrape_kn.py","Beat schedule из SCRAPE_KN_CRON env (default '15 4 * * mon'). Random jitter 0-1800с внутри таска. SCRAPE_KN_DEFAULT_REGIONS csv. Ручные триггеры из admin endpoint передают skip_jitter=True."]} +{"type":"entity","name":"Docker_Multi_Target","entityType":"infra","observations":["backend/Dockerfile target=runner (lean, 1.6GB) для backend+beat. target=runner-with-chromium (2.9GB) для worker.","docker-compose.yml выбирает target per service.",".github/workflows/deploy.yml пушит 2 GHCR-образа: gendesign-backend + gendesign-worker.","Экономия 1.3GB на backend image."]} +{"type":"entity","name":"Docker_Auto_Cleanup","entityType":"infra","observations":["deploy.yml после pull вызывает: docker image prune -af --filter 'until=72h' + docker builder prune -af.","Разовая очистка 27.04: 21→13 GB (-8 GB).","Будущие деплои сами чистят image старше 72ч + build cache."]} +{"type":"entity","name":"PII_Compliance_152FZ","entityType":"policy","observations":["PRINZIP CRM импорт (data/sql/52_import_prinzip_crm.py): phone → SHA256[:16] + last 4 digits, email → MD5. raw никогда не попадают в БД. В UI только ***1234. Поддержка 152-ФЗ при будущих запросах удаления."]} +{"type":"entity","name":"Auth_Admin_Token","entityType":"policy","observations":["X-Admin-Token заголовок, значение из SCRAPE_ADMIN_TOKEN env. Пустая строка → admin endpoints отвечают 503 'admin disabled'. Неверный → 401 'invalid admin token'.","Активный токен в prod хранится в backend/.env (gitignored). В UI — localStorage после ввода на /admin/scrape, работает для обеих admin-вкладок."]} +{"type":"entity","name":"Stealth_Fingerprint_State","entityType":"infra","observations":["data/playwright_state.json (в git, 7.3 KB).","26 cookies вкл. AUTH_SESSION_ID, KC_AUTH_SESSION_HASH, ServicePipe NSC_*, Yandex tracking.","Обновляется через 'python data/sql/50_scrape_kn_flats.py --regions 66 --bootstrap-only --save-state data/playwright_state.json'.","Worker подхватывает в cold-start — ServicePipe видит returning user без JS-challenge."]} +{"type":"entity","name":"GitHub_Issue_13_Closed","entityType":"github_issue","observations":["#13 Расширить kn-scraper на 442 строящихся Свердл ЖК","Closed 27.04.2026. Инструкции в README «Запуск 442-sweep». Готов к UI/CLI запуску — кода нового не нужно."]} +{"type":"entity","name":"GitHub_Issue_14_Closed","entityType":"github_issue","observations":["#14 Импорт PRINZIP CRM SQLite","Closed 27.04.2026. Pipeline реализован — schema (52_schema_prinzip_crm.sql) + import script (52_import_prinzip_crm.py) + 6 endpoints + funnel UI в /admin/leads. Ждёт файла от user."]} +{"type":"entity","name":"GitHub_Issue_15_Closed","entityType":"github_issue","observations":["#15 API endpoints /api/v1/analytics/object/{id}/...","Closed 27.04.2026. 5 endpoints + /analytics/objects/[id] page + PrinzipObjectsTable с sparkline."]} +{"type":"entity","name":"GitHub_Issue_16_Closed","entityType":"github_issue","observations":["#16 Multi-target Dockerfile","Closed 27.04.2026. lean backend (1.6GB) + worker-with-chromium (2.9GB)."]} +{"type":"entity","name":"Endpoint_Recommend_Mix","entityType":"api_endpoint","observations":["POST /api/v1/analytics/recommend/mix","Body: {district_name, area_total_m2?, target_class?, months_window=12}","Response: {scope, buckets[5], summary, comparables[≤5]}","Файл: backend/app/api/v1/analytics.py","Pydantic: app/schemas/recommend.py · response_model=RecommendMixOutput"]} +{"type":"entity","name":"Module_Recommend_Query","entityType":"code_module","observations":["backend/app/services/analytics_queries.py :: recommend_mix(db, *, district_name, area_total_m2, target_class, months_window, region_code=66)","Возвращает dict (scope/buckets/summary/comparables). Через response_model FastAPI приводит к Pydantic.","Lookup района через ekb_districts ILIKE; исключает 'не определён'.","Класс multiplier через AVG price_from FROM yandex_realty_zk FILTER (WHERE obj_class=:cls).","Comparable ЖК: domrf_kn_objects WHERE addr ILIKE '%район%' AND obj_class match → LEFT JOIN domrf_kn_sales_agg для sold%."]} +{"type":"entity","name":"Schema_Recommend_IO","entityType":"code_module","observations":["backend/app/schemas/recommend.py","RecommendMixInput (district_name str, area_total_m2 float|None ge=100 le=500000, target_class Literal[Comfort/Comfort+/Business/Elite]|None, months_window int 3-36)","RecommendBucket (bucket, share_pct, deal_count, area_avg_m2, area_median_m2, price_p25/median/p75, units_planned, revenue_planned_rub)","RecommendComparable (obj_id, comm_name, dev_name, obj_class, flat_count, sold_perc)","RecommendMixOutput (scope dict, buckets, summary dict, comparables)"]} +{"type":"entity","name":"Module_Bucket_Distribution","entityType":"code_module","observations":["backend/app/services/analytics_queries.py :: _bucket_distribution(db, region_code, months_window) + _BUCKET_SQL","Вынесено отдельно чтобы переиспользовать при auto-extend до 24 мес.","Возвращает 5 бакетов: deals, area_avg, area_median, price p25/median/p75 (city-wide).","Бакетизация: <30 студия, 30-45 1к, 45-60 2к, 60-80 3к, 80+ 80+ м²."]} +{"type":"entity","name":"Route_Analytics_Recommend","entityType":"ui_route","observations":["URL: /analytics/recommend","Файл: frontend/src/app/analytics/recommend/page.tsx","Публичная (как другие /analytics/*).","Layout: 360px sticky form слева + основной поток справа (KPIs, charts, sliders, table, comparables, caveats).","Все вычисления выручки/weighted-avg клиентские по effectiveShares (после слайдеров)."]} +{"type":"entity","name":"Component_Recommend_Form","entityType":"ui_component","observations":["frontend/src/components/analytics/RecommendForm.tsx","Селект района из useDistricts() с фильтром 'не определён'.","Площадь (опц. 100..500000), класс (опц. Comfort/Comfort+/Business/Elite), окно 12/18/24 мес.","submit triggers useRecommendMix() mutation; кнопка disabled пока пустой район."]} +{"type":"entity","name":"Component_Recommend_Sliders","entityType":"ui_component","observations":["frontend/src/components/analytics/RecommendShareSliders.tsx","5 слайдеров с auto-rescale: при изменении одного, остальные пропорционально пересчитываются к (100-X)%.","Если у других нет массы — распределяет поровну. Round 1 decimal, drift compensation на изменённом слайдере.","Кнопка ↻ Сбросить к рекомендации (disabled когда не было правок). Бейдж '+X.X' рядом со значением показывает delta от API."]} +{"type":"entity","name":"Component_Recommend_Charts","entityType":"ui_component","observations":["Три ECharts-обёртки в frontend/src/components/analytics/:","RecommendBucketsChart.tsx — horizontal bar share% с двумя сериями (серый 'Рекомендация' / синий 'Ваше распределение'), barGap=-100%.","RecommendRevenueChart.tsx — stacked bar (1 категория) с цветовыми сегментами по бакетам, label 'млн ₽' если segment ≥5%.","RecommendBucketsTable.tsx — sortable таблица с p25/median/p75, units_planned, revenue. Алгоритм allocation клиентский.","Chart-серии reactive: пересчитываются при изменении effectiveShares/areaTotal."]} +{"type":"entity","name":"Component_Recommend_Comparables","entityType":"ui_component","observations":["frontend/src/components/analytics/RecommendComparables.tsx","Карточки 3-5 ЖК с comm_name/dev_name/obj_class/flat_count/sold% (цветной бейдж: green ≥50, amber 30-50, red <30).","Каждая карточка — Link на /analytics/objects/{obj_id}.","Grid auto-fill 260px."]} +{"type":"entity","name":"Hook_useRecommendMix","entityType":"code_module","observations":["frontend/src/lib/analytics-api.ts :: useRecommendMix()","useMutation (НЕ useQuery — это POST с body, нет кэширования).","POST /api/v1/analytics/recommend/mix · application/json","TypeScript типы в frontend/src/types/analytics.ts (RecommendMixInput/Output/Bucket/Comparable)."]} +{"type":"entity","name":"Window_Auto_Extend_24mo","entityType":"policy","observations":["Если в любом из 5 бакетов <30 сделок при months_window<24, recommend_mix автоматически расширяет окно до 24 мес для ВСЕХ бакетов (одинаковое окно для согласованности).","Добавляет warning 'Окно расширено до 24 мес' в summary.warnings.","scope.effective_window_months отражает реальное использованное окно (12/18/24)."]} +{"type":"entity","name":"Class_Multiplier_Yandex","entityType":"policy","observations":["Class multiplier = AVG(price_from) FILTER (WHERE obj_class=:cls) / AVG(price_from) overall — из yandex_realty_zk.","Если данных по классу нет → 1.0 + warning. target_class=None → 1.0 без warning.","Применяется к price_p25/median/p75 каждого бакета в ответе."]} +{"type":"entity","name":"Quartirography_Filter_Fix","entityType":"policy","observations":["В _BUCKET_SQL добавлены фильтры выбросов:","realestate_type_code='002001003000' (квартиры; 001=земля, 002=нежилые)","area BETWEEN 10 AND 200 (отсечь коммерческие площади 1974 м² в bucket 80+)","price_per_sqm BETWEEN 30000 AND 1000000","Без них bucket 80+ показывал 719 'сделок' с area_avg=1974 — это были склады/коммерция."]} +{"type":"entity","name":"Limit_District_Median_Null","entityType":"limitation","observations":["Все 8 районов в ekb_districts имеют median_price_per_m2 = NULL (импорт Antona не наполнил).","Текущее поведение recommend_mix: district_factor = 1.0 + warning 'коэффициент района = 1.0'.","Чтобы оживить district_factor — нужен ETL job, наполняющий ekb_districts.median/mean из rosreestr_deals (через парсинг addr → district mapping или geometry)."]} +{"type":"entity","name":"Limit_District_Geometry","entityType":"limitation","observations":["rosreestr_deals имеет только quarter_cad_number, без района. ekb_districts не имеет PostGIS-полигонов.","Поэтому в MVP recommend_mix bucket-распределение город-wide (регион 66). Район влияет ТОЛЬКО на ценовой коэффициент.","v2: добавить PostGIS-полигоны → spatial-join (точка→район) или (cad_quarter→район) → per-district demand."]} +{"type":"entity","name":"Endpoint_Photos_Serve","entityType":"api_endpoint","observations":["GET /api/v1/photos/{obj_id}/{file_id}?size=thumb|full","Файл: backend/app/api/v1/photos.py","size=thumb: WebP 320×240 q=70 из data/raw/domrf_photos//_thumb.webp.","Cache-Control: public, max-age=604800, immutable.","size=full: оригинал PNG/JPG из локалки или 302 redirect на DOM.РФ."]} +{"type":"entity","name":"Module_Photo_Thumbs","entityType":"code_module","observations":["backend/app/services/photos/thumbs.py :: make_thumbnail(src, size=(320,240), quality=70)","Pillow + ImageOps.fit (cover) + WebP encode.","thumb_path_for(src) → src.with_name(stem + '_thumb.webp').","Idempotent: skip if dst.exists() and not overwrite. Логирует ошибки и продолжает.","Зависимость: pillow>=10.4.0 в backend/pyproject.toml."]} +{"type":"entity","name":"Photo_Lazy_Fetch","entityType":"policy","observations":["При запросе thumb на фото без локальной копии — endpoint синхронно качает оригинал с DOM.РФ через httpx + Basic auth (1:qwe).","Сохраняет оригинал в data/raw/domrf_photos//.{png,jpg}, генерит thumb рядом, UPDATE-ит local_path/thumb_path/downloaded_at, отдаёт thumb.","Подтверждено: urllib + Basic auth работает (200 OK 3.3 МБ PNG) — Playwright-fingerprint не требуется для image CDN, в отличие от JSON API.","Last resort: 302 redirect на upstream если download failed."]} +{"type":"entity","name":"Schema_Photo_Thumb_Path","entityType":"infrastructure","observations":["data/sql/53_schema_kn_thumbs.sql — применена в прод 27.04.2026 поздний вечер.","ALTER TABLE domrf_kn_photos ADD COLUMN IF NOT EXISTS thumb_path TEXT;","CREATE INDEX idx_kn_photos_obj_visible ON domrf_kn_photos (obj_id) WHERE COALESCE(hidden, FALSE) = FALSE;","object_photos analytics-query возвращает thumb_url + full_url (всегда, ссылаются на наш backend)."]} +{"type":"entity","name":"Queue_Optimised_DB","entityType":"policy","observations":["GET /api/v1/admin/scrape/queue: 'active' читается из kn_scrape_runs WHERE status='running' (instant).","inspect.reserved() + inspect.ping() запускаются параллельно в ThreadPoolExecutor с дедлайном 0.8s.","queue_depth — Redis LLEN 'celery'. scheduled — пустой (мы не используем ETA).","Worst-case latency ~600ms vs ~8s раньше (4×inspect timeout=2s)."]} +{"type":"entity","name":"Zombie_Autocleanup","entityType":"policy","observations":["В начале /api/v1/admin/scrape/queue: UPDATE kn_scrape_runs SET status='zombie', finished_at=NOW(), error='auto-marked: no heartbeat 60+ min' WHERE status='running' AND started_at < NOW() - INTERVAL '60 minutes'.","Срабатывает при каждом обращении UI к /queue (poll каждые 3 сек).","Дополнительно: worker_ready hook режет на 5 минут (с heartbeat) — см. Worker_Ready_Hook."]} +{"type":"entity","name":"Redis_Singleton_Lock","entityType":"infrastructure","observations":["backend/app/workers/tasks/scrape_kn.py :: _region_lock(region_code, devs) context manager.","SET NX EX 7200s по ключу 'scrape:kn:lock::'.","Если beat double-fire или два worker-а — второй сразу выходит {skipped:True, reason:'lock_held'} без INSERT.","TTL 2ч прикроет случай умершего worker (lock сам очистится).","Используется в scrape_kn_region и resume_kn_run одинаково."]} +{"type":"entity","name":"Resume_Checkpoint_Mechanism","entityType":"deliverable","observations":["Дата: 27.04.2026 ночь. Решает Bug_Worker_SIGKILL_Lost_Run.","Phase A в run_region_sweep: после fetch_objects+filter — upsert_objects + _insert_raw + UPDATE kn_scrape_runs SET objects_snapshot=:objs::jsonb, total_obj_count, heartbeat_at=NOW(). Один commit.","Phase B/C (per-object): каждые 10 объектов — _checkpoint(db, run_id, i+1) → UPDATE progress_obj_index, heartbeat_at + commit.","При resume_from_run_id: пропускаем Phase A, читаем objects_snapshot+progress_obj_index+params из старого run, INSERT нового run с resumed_from_run_id, продолжаем с start_index.","UPSERT-семантика всех таблиц защищает от дублей при reprocessing 10-объектового хвоста между checkpoint-ами."]} +{"type":"entity","name":"Schema_Resume_Columns","entityType":"infrastructure","observations":["data/sql/55_schema_scrape_resume.sql — применена в прод 27.04.2026 ночь.","ALTER TABLE kn_scrape_runs ADD: heartbeat_at TIMESTAMPTZ, progress_obj_index INT, total_obj_count INT, objects_snapshot JSONB, params JSONB, resumed_from_run_id BIGINT REFERENCES kn_scrape_runs(run_id).","INDEX idx_kn_scrape_runs_running_heartbeat ON (heartbeat_at) WHERE status IN ('running','resuming').","Размер objects_snapshot для Свердл sweep ~1-2 МБ JSONB на ряд (1516 объектов)."]} +{"type":"entity","name":"Task_Resume_Kn_Run","entityType":"code_module","observations":["backend/app/workers/tasks/scrape_kn.py :: resume_kn_run(run_id) Celery task.","Читает region_codes/developer_ids из исходного run, берёт Redis-lock, вызывает run_region_sweep(resume_from_run_id=N).","Не требует jitter — это срочный resume.","max_retries=2, name='tasks.scrape_kn.resume_kn_run'."]} +{"type":"entity","name":"Worker_Ready_Hook","entityType":"code_module","observations":["backend/app/workers/celery_app.py :: @worker_ready.connect _resume_zombie_runs(sender=None, **_kwargs).","Триггерится после каждого старта Celery worker (включая redeploy).","SELECT runs WHERE status='running' AND objects_snapshot IS NOT NULL AND COALESCE(heartbeat_at, started_at) < NOW() - INTERVAL '5 minutes' LIMIT 20.","UPDATE их в 'zombie' одним апдейтом (audit trail), затем enqueue resume_kn_run.apply_async([rid]) для каждого.","Защищён try/except — провал scan не валит worker."]} +{"type":"entity","name":"Module_Scrape_Log","entityType":"code_module","observations":["backend/app/services/scrapers/domrf_kn.py :: log_progress(db, run_id, message, *, level='info', stage=None, obj_id=None).","INSERT INTO kn_scrape_log (run_id, level, stage, obj_id, message). message[:500] чтобы не раздуть.","Best-effort: SQL ошибки swallowed → не валят sweep.","Расставлен в run_region_sweep на: start, fetch_objects (per status), filter_devs, phase_a, fetch_flats (каждые 25), extras (каждые 10), resume, done, failed."]} +{"type":"entity","name":"Schema_Scrape_Log_Table","entityType":"infrastructure","observations":["data/sql/54_schema_scrape_log.sql — применена в прод 27.04.2026 ночь.","kn_scrape_log (log_id BIGSERIAL PK, run_id BIGINT REFS kn_scrape_runs ON DELETE CASCADE, ts TIMESTAMPTZ DEFAULT NOW(), level TEXT CHECK in ('info','warn','error'), stage TEXT, obj_id INT, message TEXT NOT NULL).","Индексы: (run_id, ts DESC), (ts DESC). ON DELETE CASCADE — лог чистится с run."]} +{"type":"entity","name":"Endpoint_Scrape_Logs","entityType":"api_endpoint","observations":["GET /api/v1/admin/scrape/logs?run_id=&since_id=&limit=200 (max 1000).","Файл: backend/app/api/v1/admin_scrape.py.","Auth: X-Admin-Token. since_id — для инкрементального polling (передавай max log_id виденного).","Возвращает rows ORDER BY log_id DESC."]} +{"type":"entity","name":"UI_Scrape_Progress_Panel","entityType":"ui_component","observations":["Секция в frontend/src/app/admin/scrape/page.tsx :: 'Прогресс задач'.","Тёмная консоль (background #0f172a, моноширинный) с цветовой раскладкой INFO/WARN/ERROR.","Формат строки: 'HH:MM:SS LEVEL [run-N · stage · obj=X] message'.","useQuery(refetchInterval: 3000) с фильтром по run_id (input).","scroll-контейнер 360px maxHeight."]} +{"type":"entity","name":"Bug_Worker_SIGKILL_Lost_Run_Fixed","entityType":"bug","observations":["Симптом: при redeploy/SIGKILL/OOM worker умирал без вызова except/finally → row в kn_scrape_runs оставался status='running' навечно, вся работа sweep терялась.","Closed 27.04.2026 ночь. Resolved by Resume_Checkpoint_Mechanism + Worker_Ready_Hook + Schema_Resume_Columns.","User-reported: 'после авто деплоя умираю и остаются не завершенные зомби'."]} +{"type":"entity","name":"Bug_Beat_Double_Fire_Fixed","entityType":"bug","observations":["Симптом: 27.04 у user'а run_id=4 и run_id=5 стартовали в одну секунду (16:38:01) — beat double-fire или два beat-контейнера.","UI показывал два 'в работе' дубликата одного и того же региона.","Closed 27.04.2026 ночь. Resolved by Redis_Singleton_Lock в scrape_kn_region (SET NX EX). Второй выходит с skipped:True/lock_held без INSERT."]} +{"type":"entity","name":"Bug_Inspect_Slow_Queue_Fixed","entityType":"bug","observations":["Симптом: GET /admin/scrape/queue ждал до 8s (4 вызова inspect.* с timeout=2s последовательно).","User-reported: 'очередь так же долго получается'.","Closed 27.04.2026 ночь. Resolved by Queue_Optimised_DB (active из БД, inspect в ThreadPoolExecutor с дедлайном 0.8s)."]} +{"type":"entity","name":"Bug_RealEstate_Outliers_Fixed","entityType":"bug","observations":["Симптом: bucket 80+ в recommend_mix выдавал 719 'сделок' с area_avg=1974 м² и medianna 155K ₽/м² — это нежилые помещения / склады.","rosreestr_deals.realestate_type_code: 002001003000=квартиры, 002001001000=земля, 002001002000=нежилые.","Closed 27.04.2026 ночь. Resolved by Quartirography_Filter_Fix в _BUCKET_SQL."]} +{"type":"entity","name":"Photo_Storage_Pipeline","entityType":"deliverable","observations":["Дата: 27.04.2026 поздний вечер. Кеширование thumb-копий фото DOM.РФ на backend.","Архитектура: scraper скачивает оригинал PNG/JPG → Pillow генерит 320×240 WebP q=70 рядом → path в thumb_path колонке.","Endpoint /api/v1/photos/{obj}/{file}?size=thumb|full отдаёт из локалки (FileResponse, Cache-Control 7d).","Lazy fetch при первом запросе thumb на фото без локальной копии (httpx + Basic auth).","Frontend перешёл с Next/Image на прямой — backend выдаёт уже оптимизированный WebP, никаких cold-hit оптимизатора в dev.","lightbox использует full_url — выдаёт оригинал или redirect на DOM.РФ."]} +{"type":"entity","name":"Recommend_Level1_Apr27","entityType":"deliverable","observations":["Дата: 27.04.2026 ночь. Реализован Уровень 1 rule-based рекомендатора квартирографии. База для ML v2.","Алгоритм: city-wide ДДУ-сделки Свердл (rosreestr_deals) бакетизируются по площади (5 бакетов) × дистрикт multiplier (median_price ratio) × класс multiplier (yandex_realty_zk).","Allocation: если задан area_total_m2 — units_planned = round(allocated/area_avg), revenue = units × area_avg × price_median.","User-confirmed дизайн-решения: input=district_name, class через yandex_realty_zk, output=4 секции, slider auto-rescale, fallback 24мес при <30 сделок.","Реальные цифры для Свердл 12 мес (после фильтров): студии 72 сделок/119K₽, 1к 27/105K, 2к 12/105K, 3к 10/154K, 80+ 73/164K."]} +{"type":"entity","name":"Session_Resume_Recommend_Apr28_2026","entityType":"event","observations":["Дата: переход 27→28.04.2026 ночь. Продолжение Session_End_Apr27_2026.","СДЕЛАНО ЗА СЕССИЮ (ссылки на entity):","1. Photo_Storage_Pipeline (Pillow тхумбы + endpoint /api/v1/photos + lazy fetch + frontend переход с Next/Image).","2. Queue_Optimised_DB + Zombie_Autocleanup + Redis_Singleton_Lock (исправили 8s→0.6s латенси и beat double-fire).","3. Module_Scrape_Log + Schema_Scrape_Log_Table + Endpoint_Scrape_Logs + UI_Scrape_Progress_Panel (живой лог прогресса в админке).","4. Resume_Checkpoint_Mechanism + Schema_Resume_Columns + Task_Resume_Kn_Run + Worker_Ready_Hook (resume после redeploy с checkpoint каждые 10 объектов).","5. Recommend_Level1_Apr27 + Endpoint_Recommend_Mix + Module_Recommend_Query + Schema_Recommend_IO + Module_Bucket_Distribution + Route_Analytics_Recommend + 5 UI-компонентов + useRecommendMix.","BUGS CLOSED: Bug_Worker_SIGKILL_Lost_Run_Fixed, Bug_Beat_Double_Fire_Fixed, Bug_Inspect_Slow_Queue_Fixed, Bug_RealEstate_Outliers_Fixed.","OPEN TODOs (новые): Limit_District_Median_Null — нужен ETL для наполнения ekb_districts.median_price; Limit_District_Geometry — PostGIS-полигоны районов для per-district demand.","ПРОД SCHEMA: 4 новые миграции применены: 53_kn_thumbs.sql, 54_scrape_log.sql, 55_scrape_resume.sql, плюс изменения в domrf_kn_photos (thumb_path) и kn_scrape_runs (heartbeat_at, progress_obj_index, total_obj_count, objects_snapshot, params, resumed_from_run_id).","ПРИОРИТЕТЫ НА СЛЕДУЮЩУЮ СЕССИЮ: 1) ETL ekb_districts.median из rosreestr_deals (оживит district_factor в recommend_mix). 2) PostGIS-полигоны РАЙОНОВ ЕКБ для spatial-join. 3) PRINZIP CRM импорт (ожидает SQLite). 4) Тест resume в проде (запустить sweep × restart worker × проверить '♫ Resume from index N')."]} +{"type":"entity","name":"Bug_SQLAlchemy_DoubleColon_Cast","entityType":"bug","observations":["Системный баг: SQLAlchemy 2.0 + psycopg3 ИГНОРИРУЕТ привязку в конструкции :name::type. Парсер named-bind видит :name, но ::type не распознаётся как PostgreSQL cast → остаётся литералом в скомпилированном SQL.","Симптом: psycopg.errors.SyntaxError: syntax error at or near ':' на INSERT/UPDATE.","Решение (canonical): всегда использовать CAST(:name AS type) вместо :name::type.","Найден в 5 местах (28.04.2026): исправлены. analytics_queries.py:1222 (recommend_mix comparables :cls::text) · domrf_kn.py:1024 (resume INSERT :rc/devs/params/objs::*) · domrf_kn.py:1060 (new run INSERT :params::jsonb) · domrf_kn.py:1144 (Phase A snapshot UPDATE :objs::jsonb) · admin_leads.py:70 (lead filter :date_to::date).","Эффект бага: 100% блокировка run_region_sweep на INSERT, worker принимал task'и но никогда не создавал run_id."]} +{"type":"entity","name":"Pattern_CAST_AS_Type","entityType":"policy","observations":["Использовать CAST(:name AS type) в любых SQL-выражениях SQLAlchemy text() вместо :name::type.","Обратный паттерн ARRAY[:rc]::int[] РАБОТАЕТ — :: приклеено к закрывающей скобке, а не к bind-name.","Правило проверки перед PR: grep -nE ':[a-z_]+::[a-z]' backend/app — должен выводить пустоту.","Это попадало в 4 разных PR'а (recommend_mix, run_region_sweep refactor, admin_leads filter) — выработать lint-правило с ruff или pre-commit на будущее."]} +{"type":"entity","name":"Recommend_Velocity_Calculator_Apr28","entityType":"deliverable","observations":["Дата: 28.04.2026. Расширение Recommend_Level1_Apr27 — live-калькулятор 'цена ↔ темп ↔ срок ↔ ликвидность'.","По запросу Антона для CEO: 'Изменил цену → изменилась скорость продаж. Изменил срок → система показала какая цена/ассортимент нужны. Изменил ассортимент → изменилась ликвидность, средний чек, темп.'","Архитектура: API выдаёт BASELINE (velocity_baseline + elasticity коэффициент). Все слайдеры и target_months рассчитываются КЛИЕНТСКИ — без round-trip.","Новые компоненты: Component_Recommend_Velocity_Panel + Component_Recommend_Liquidity_Chart. Расширены: BucketsTable (+колонки Темп/Срок), RecommendForm (+target_months input).","Headline для CEO: 'Цена × выручка · срок · чек · темп · ликвидность' одной тёмной плашкой."]} +{"type":"entity","name":"Module_Velocity_Baseline","entityType":"code_module","observations":["backend/app/services/analytics_queries.py :: _velocity_baseline(db, *, region_code, district_name, target_class)","SQL: median+avg из domrf_kn_sale_graph WHERE type='apartments' за 24 мес для ЖК в (район+класс) через obj_pool с addr ILIKE район.","Результат: {realised_per_month_avg, realised_per_month_median, objects_count, observations}.","Если sale_graph пуст — fallback на rosreestr deals/month (city-wide). Сейчас sale_graph покрывает 2 ЖК, поэтому в 99% случаев идёт fallback."]} +{"type":"entity","name":"Module_Elasticity_Coef","entityType":"code_module","observations":["backend/app/services/analytics_queries.py :: _elasticity_coef(db, *, region_code, district_name, target_class)","SQL: regr_slope(LN(realised), LN(price_avg)) + regr_r2 на sale_graph за 36 мес для (район+класс).","Выдаёт {elasticity, r2, n, source: 'regression'|'fallback'}. fallback = -1.5 при n<30 или r2<0.1 или slope>=0.","Используется во фронте для расчёта velocity = base × price_factor^elasticity."]} +{"type":"entity","name":"Module_Required_Price_Factor","entityType":"code_module","observations":["backend/app/services/analytics_queries.py :: внутри recommend_mix (пункт 5c).","Inverse mode: если target_months задан, система решает required_price_factor = (total_units / target_months / base_total_velocity)^(1/elasticity).","Если < 0.7 → warning 'скидка >30% нереалистична, рассмотри сдвиг ассортимента'.","В UI: 'Применить →' кнопка копирует значение в price_factor слайдер (обрезая в [0.85, 1.15])."]} +{"type":"entity","name":"Module_Liquidity_Score_24mo","entityType":"code_module","observations":["Клиентский расчёт в RecommendVelocityPanel.tsx + backend дублирует в recommend_mix пункте 5d.","Формула: liquidity = Σ (min(1, 24/months_to_sellout) × units_planned) / total_units × 100. Диапазон 0..100.","Цветовые пороги: ≥0a7a3a (зелёный) >=70, #9a6700 (жёлтый) 40–⋅0, #b3261e (красный) <40."]} +{"type":"entity","name":"Component_Recommend_Velocity_Panel","entityType":"ui_component","observations":["frontend/src/components/analytics/RecommendVelocityPanel.tsx","4 больших KPI: Средний чек / Срок реализации / Темп продаж / Ликвидность (светофор).","Slider price_factor 0.85..1.15 step=0.01 — live recompute всех KPI по формуле base × pf^elasticity.","Inverse-mode плашка когда target_months задан: показывает required_price_factor + кнопка 'Применить'.","Нижняя подпись 'Эластичность -1.5 (регрессия sale_graph: R²=X, n=N или fallback)' — для CEO-легитимности."]} +{"type":"entity","name":"Component_Recommend_Liquidity_Chart","entityType":"ui_component","observations":["frontend/src/components/analytics/RecommendLiquidityChart.tsx","ECharts line+area: кумулятивные % продаж по месяцам 0..36, пунктир-markline на 24 мес.","При изменении priceFactor или mix-слайдеров кривая live пересчитывается.","Empty-state: 'Укажи площадь — посчитаем сколько и когда продастся'."]} +{"type":"entity","name":"Feature_Headline_CEO","entityType":"ui_component","observations":["Тёмная плашка сразу под H1 на /analytics/recommend.","Формат: '💼 «Район · Класс · X м²»: выручка млн ₽ · за ~Y мес · ср. чек Z М ₽ · темп W кв/мес · ликвидность V/100'.","Генеритсь backend'ом в recommend_mix.summary.headline — для screenshots/экспорта. Фронт обёртывает в div с emoji + bold район/класс/площадь.","Известная проблема: int(months) округляет 0.66 → 0 мес. TODO — использовать f'{x:.1f}'."]} +{"type":"entity","name":"Bug_Velocity_Mix_Static_OPEN","entityType":"bug","observations":["Симптом: при движении mix-слайдеров KPI Срок/Темп практически не меняются. Из скрина 28.04: 'Темп 159.0 кв/мес' статичен, 'Срок 0.8 мес' округляется в 0 мес в headline.","Причина: все 5 бакетов используют ОДИН market_velocity_per_month. Групповая формула baseVelocity = Σ(per_bucket_v × effective_share/share) ≡ city_total независимо от mix.","Установлен и отложен (Tier α work).","Решение: per-bucket velocity = bucket_market_share_in_total × city_v / N_competitors. Из rosreestr по бакетам отдельно (студии 710/24=30, 1к 1306/24=54, ит.д.) и поделить на N_active_ЖК в районе/классе."]} +{"type":"entity","name":"Bug_Velocity_Unrealistic_OPEN","entityType":"bug","observations":["Симптом: 'Срок реализации 0.8 мес' / 'Темп 159 кв/мес' / 'Ликвидность 100/100' — фантастичные числа.","Причина: при fallback (sale_graph пуст) market_velocity_per_month = total_deals / months_window. Для Свердл 3815 сделок / 24 = 159 кв/мес — темп РЫНКА, не проекта. 107 юнитов ÷ 159 = 0.7 мес — 'все сделки рынка идут у нас'.","Решение (Tier α): velocity_per_project = market_velocity / N_active_competing_ЖК (из domrf_kn_objects с site_status='Строящиеся' в (район+класс)). Для Свердл ~442 active → 0.36 кв/мес на ЖК → срок 100-300 мес.","Плюс prominent warning-badge в UI 'Темп грубый, нужен sale_graph для точных данных'."]} +{"type":"entity","name":"Bug_Recommend_500_CAST_Fixed","entityType":"bug","observations":["Симптом: POST /api/v1/analytics/recommend/mix возвращал 500 с 'syntax error at or near :' на строке :cls::text IS NULL.","Причина: SQLAlchemy не парсит :name::type (см. Bug_SQLAlchemy_DoubleColon_Cast).","Closed 28.04.2026. Resolved by Pattern_CAST_AS_Type в analytics_queries.py:1222 (CAST(:cls AS TEXT) IS NULL)."]} +{"type":"entity","name":"Bug_Phase_A_INSERT_500_Fixed","entityType":"bug","observations":["Симптом (28.04.2026 23:29): в /admin/scrape 'Кнопка не работает' — worker принимал task'и (task_received в kn_scrape_log), но НИКОГДА не создавал run_id в kn_scrape_runs.","Диагностика: docker compose logs worker показал трейсбек 'syntax error at or near : LINE 8: :params::jsonb' на INSERT INTO kn_scrape_runs.","Причина: в 4 местах (resume INSERT, new run INSERT, snapshot UPDATE) были :name::jsonb / :name::int[] / :name::text[] — SQLAlchemy bug.","Closed 29.04.2026. Resolved by Pattern_CAST_AS_Type в domrf_kn.py:1024,1060,1144 и бонусом admin_leads.py:70 (:date_to::date).","Smoke: выполнил INSERT руками через SSH-тоннель — run_id=8 создан (и удалён вручную), SQL парсится ОК."]} +{"type":"entity","name":"Bug_Lock_Held_Silent_Fixed","entityType":"bug","observations":["Симптом: при повторном нажатии кнопки task_id возвращаетсь, но run_id не появляется. Никаких логов.","Причина: Redis-lock от зомби-воркера с TTL=2ч блокирует новые task'и · воркер своим retry получает lock_held и выходит без записи в БД.","Closed 28.04.2026 by: TTL_Lock_Reduction (7200→1800), Module_Lock_Diagnostic_Logs (task_received/skipped в kn_scrape_log), Endpoint_Release_Lock + Param_Force_Trigger + UI '🔓 Снять Redis-lock' кнопка."]} +{"type":"entity","name":"Endpoint_Release_Lock","entityType":"api_endpoint","observations":["POST /api/v1/admin/scrape/release-lock","Body: {region_code, developers}. Auth: X-Admin-Token.","Файл: backend/app/api/v1/admin_scrape.py.","Вручную удаляет Redis ключ 'scrape:kn:lock::'. Нужен для emergency-режима (зомби-воркер оставил lock).","Возвращает {released: bool, region_code, developers}."]} +{"type":"entity","name":"Param_Force_Trigger","entityType":"policy","observations":["Новый параметр в TriggerKnRequest: force: bool = False.","При force=True эндпоинт /admin/scrape/kn вызывает force_release_lock() ПЕРЕД apply_async — снимает зомби-lock.","Фронтенд: чекбокс '⚡ Force (снять lock)' в форме запуска (красный при включении).","Ответ содержит lock_was_released: bool — видно был ли lock."]} +{"type":"entity","name":"Module_Lock_Diagnostic_Logs","entityType":"code_module","observations":["backend/app/workers/tasks/scrape_kn.py :: _log_task_received(region, devs, task_id) и _log_task_skipped(region, devs, task_id, reason).","Пишут в kn_scrape_log с run_id=NULL (run_id не существует до Phase A INSERT). FK accept NULL.","task_received вызывается в первой строке scrape_kn_region и resume_kn_run.","task_skipped вызывается при lock_held.","Даёт UI понимание 'worker подхватил и что сделал' даже когда run_region_sweep не запускается."]} +{"type":"entity","name":"TTL_Lock_Reduction","entityType":"infrastructure","observations":["_LOCK_TTL_SECONDS в backend/app/workers/tasks/scrape_kn.py: 7200 (2ч) → 1800 (30мин).","Обоснование: full Свердл sweep идёт ~25-30 мин — 30 мин это потолок. Зомби-lock истекает за полчаса, не блокирует новые task'и в рабочий день.","Heartbeat-based autoclean в /queue endpoint по всё равно подхватит зомби-runs за 10 мин по heartbeat_at."]} +{"type":"entity","name":"Phase_A_Heartbeat_Tick","entityType":"code_module","observations":["Добавлено _checkpoint(db, run_id, 0) в run_region_sweep ПОСЛЕ каждого фетча objStatus (Phase A).","Phase A для 1516 ЖК идёт ~3 мин. Без heartbeat-tick новый 10-мин autoclean threshold мог бы ложно пометить легитимный sweep как zombie.","progress_obj_index ставится 0 (снапшот ещё не сохранён), но heartbeat_at = NOW() обновляется в каждой итерации фетча."]} +{"type":"entity","name":"Autoclean_Heartbeat_Threshold","entityType":"policy","observations":["Изменён фильтр zombie autoclean в /api/v1/admin/scrape/queue.","Было: status='running' AND started_at < NOW() - 60 minutes (резал валидные долгие sweep'ы).","Стало: status='running' AND COALESCE(heartbeat_at, started_at) < NOW() - 10 minutes (heartbeat обновляется каждые ~30 сек, 10 мин без него = точно мёртв).","COALESCE подхватывает legacy-runs без heartbeat по старому критерию."]} +{"type":"entity","name":"Deploy_Cache_State_Apr28","entityType":"infrastructure","observations":["Проверка 28.04.2026: GHA cache (cache-from/cache-to type=gha,scope=*) УЖЕ в .github/workflows/deploy.yml для всех 3 билдов (backend/worker/frontend). Не нужно добавлять.","Сделаны дополнительные исправления для фактического хита кэша:","1. .gitignore +*.tsbuildinfo (frontend/tsconfig.tsbuildinfo был tracked и менялся в каждом коммите → invalidate frontend context).","2. git rm --cached frontend/tsconfig.tsbuildinfo.","3. frontend/.dockerignore расширен: +.git +*.tsbuildinfo +.next/cache +tests +*.md +coverage.","4. backend/.dockerignore расширен: +.git +data/raw +data/raw_endpoints +*.log +*.md +.coverage.","5. backend/Dockerfile +RUN --mount=type=cache,target=/root/.cache/uv на uv sync. +RUN --mount=type=cache,target=/var/cache/apt на apt install (во всех 3 стейджах).","6. frontend/Dockerfile +RUN --mount=type=cache,target=/root/.npm на npm ci.","Ожидаемый эффект: 8 мин → 2-3 мин на типовых Python-правках."]} +{"type":"entity","name":"Tier_Alpha_Velocity_Overhaul_TODO","entityType":"feedback","observations":["Отложенная работа (Tier α, введён в плане 28.04.2026): переделка velocity-модели recommend_mix.","Части:","A. _active_competitors_count(db, district, class) — SELECT COUNT FROM domrf_kn_objects WHERE site_status='Строящиеся' AND addr ILIKE district AND obj_class=cls.","B. Per-bucket velocity = bucket_market_share × city_total_velocity / N_competitors. Отдельный темп для каждого bucket'а.","C. headline format: f'{months:.1f} мес' вместо int().","D. Warning-badge в RecommendVelocityPanel когда velocity_source==fallback.","E. Headline compact: 3 главных метрики вместо 5.","Делается ПОСЛЕ стабилизации скрапера (Bug_Phase_A_INSERT_500_Fixed)."]} +{"type":"entity","name":"Session_Apr28_Calc_Scraper_Apr28_2026","entityType":"event","observations":["Дата: 28.04.2026 вечер → ночь. Продолжение Session_Resume_Recommend_Apr28_2026.","СДЕЛАНО:","1. Recommend_Velocity_Calculator_Apr28 (live калькулятор цена↔темп↔срок↔ликвидность) + Module_Velocity_Baseline + Module_Elasticity_Coef + Module_Required_Price_Factor + Module_Liquidity_Score_24mo + Component_Recommend_Velocity_Panel + Component_Recommend_Liquidity_Chart + Feature_Headline_CEO.","2. Bug_Recommend_500_CAST_Fixed (CAST(:cls AS TEXT) вместо :cls::text).","3. Deploy_Cache_State_Apr28 (.gitignore tsbuildinfo, .dockerignore tighten, uv/npm/apt cache mounts).","4. Module_Lock_Diagnostic_Logs (task_received/skipped в kn_scrape_log) + Endpoint_Release_Lock + Param_Force_Trigger + TTL_Lock_Reduction (7200→1800) + Autoclean_Heartbeat_Threshold (60→10 мин) + Phase_A_Heartbeat_Tick.","5. Bug_Phase_A_INSERT_500_Fixed (4 места :name::type → CAST). Главный corner-case сессии — объясняет почему скрапер не работал 26+ часов.","6. Bug_Lock_Held_Silent_Fixed.","OPEN BUGS ПОСЛЕ СЕССИИ: Bug_Velocity_Mix_Static_OPEN, Bug_Velocity_Unrealistic_OPEN. Оба решает Tier_Alpha_Velocity_Overhaul_TODO.","ПРИОРИТЕТЫ НА СЛЕДУЮЩУЮ: 1) Развернуть Bug_Phase_A_INSERT_500_Fixed в прод, проверить что sweep реально проходит 1516 ЖК + 28K flats. 2) Tier_Alpha (Bug_Velocity_Mix_Static + Bug_Velocity_Unrealistic). 3) ЭТИЛЬАРНЫе тиры 1-3 предикта (mortgage rate impact, region trend multiplier, pipeline pressure, hedonic features, developer reputation)."]} +{"type":"entity","name":"Geo_District_Mapping_Apr29","entityType":"deliverable","observations":["Дата: 29.04.2026. Главная победа сессии — ЛОКАЛЬНОЕ решение Limit_District_Geometry: железная привязка ЖК ЕКБ к 8 районам через PostGIS-полигоны OSM.","Архитектура: Overpass API (admin_level=9 в bbox ЕКБ) → shapely сборка OSM way-members в MultiPolygon → ekb_districts_geom с GIST-индексом → ST_Contains UPDATE domrf_kn_objects.district_name.","Результат backfill: Академический 586/208 строящихся, Чкаловский 510/158, Верх-Исетский 288/80, Ленинский 284/98, Орджоникидзевский 266/58, Кировский 236/68, Октябрьский 204/50, Железнодорожный 204/39. NULL=454 ЖК вне ЕКБ (Первоуральск, Н.Тагил).","Эффект в recommend_mix: velocity_source переключился с 'rosreestr_fallback' на 'sale_graph' (Академический: 104 ЖК × 1872 точки), competitors_count 208 вместо fallback singleton, comparable ЖК реальные (ТЭН СТАРТ, Эхо леса, KORTROS Eleven)."]} +{"type":"entity","name":"Module_Fetch_Ekb_Districts","entityType":"code_module","observations":["data/sql/56_fetch_ekb_districts.py — CLI загрузчик Overpass API → PostGIS.","Overpass query: relation['boundary'='administrative']['admin_level'='9']['name'~'район'] в bbox ЕКБ (56.71..57.00 шир. / 60.42..60.95 долг.). Возвращает 8 relations.","shapely-ассемблинг: way-members рваные в OSM, polygonize() сшивает их в closed rings, .buffer(0) исправляет self-intersections.","Кэш в data/raw/ekb_districts.geojson (коммитится в git — повторно Overpass не дыбаем).","Flags: --force-refetch (игнор кэш), --dry-run (без БД).","OSM names → ekb_districts.district_name хардкод-мап из 8 районов ('Академический район' → 'Академический')."]} +{"type":"entity","name":"Schema_Ekb_Districts_Geom","entityType":"infrastructure","observations":["data/sql/56_schema_ekb_districts_geom.sql — применена в прод 29.04.2026.","CREATE TABLE ekb_districts_geom (district_name TEXT PK FK→ekb_districts, geom geometry(MultiPolygon, 4326), osm_id BIGINT, fetched_at TIMESTAMPTZ).","GIST index idx_ekb_districts_geom_geom для spatial-join performance.","8 районов залиты из OSM (osm_id 5803327..14040275). Площади ST_Area в диапазоне 5-50 км²."]} +{"type":"entity","name":"Schema_Kn_Objects_District_Column","entityType":"infrastructure","observations":["data/sql/57_kn_objects_district.sql — применена в прод 29.04.2026.","ALTER TABLE domrf_kn_objects ADD COLUMN district_name TEXT.","INDEX idx_kn_objects_district WHERE district_name IS NOT NULL.","Initial backfill UPDATE через ST_Contains(d.geom, ST_MakePoint(o.lon, o.lat)) для region_cd=66.","3032 ЖК Свердл: 2578 привязаны к 8 районам, 454 NULL (вне ЕКБ)."]} +{"type":"entity","name":"File_Ekb_Districts_Geojson_Cache","entityType":"infrastructure","observations":["data/raw/ekb_districts.geojson — коммитится в git.","FeatureCollection с 8 MultiPolygon-фичами. Properties: name (OSM), osm_id, admin_level=9.","Источник: Overpass API в 29.04.2026 в 7:57 утра. При обновлении OSM-бордеров перефетчить через --force-refetch."]} +{"type":"entity","name":"Module_Backfill_District_Names_Scraper","entityType":"code_module","observations":["backend/app/services/scrapers/domrf_kn.py :: _backfill_district_names(db, snapshot_date, region_code).","Вызывается в run_region_sweep ПОСЛЕ Phase A commit (когда свежие upsert_objects уже в БД).","UPDATE через ST_Contains только для region_cd=66 (другие регионы полигонов не имеют сейчас).","Best-effort: try/except, не валит sweep при сбое привязки."]} +{"type":"entity","name":"Pattern_OSM_Overpass_Admin_Level","entityType":"policy","observations":["Для России в OSM городские районы = admin_level=9. Город как целое = 8.","Прямой query: relation['boundary'='administrative']['admin_level'='9'](bbox) работает лучше чем area-based поиск (last раз area['name'='Екатеринбург'] → 0 элементов).","User-Agent header обязателен — Overpass банит default urllib UA."]} +{"type":"entity","name":"Bug_Recommend_Addr_ILIKE_Returns_Zero_Fixed","entityType":"bug","observations":["Симптом: несмотря на успешный ночной sweep (1516 объектов + 192K квартир + 7938 sale_graph rows), recommend_mix пишет 'недостаточно данных'.","Причина: фильтр addr ILIKE '%Академический%' даёт 0 объектов — все domrf_kn_objects.addr = 'Город Екатеринбург', без названия района.","Closed 29.04.2026 by Geo_District_Mapping_Apr29: в 4 местах в analytics_queries.py заменили 'addr ILIKE :dn' на 'district_name = :dn' (_velocity_baseline, _elasticity_coef, _active_competitors_count, comparable query)."]} +{"type":"entity","name":"Module_Graceful_Obj_Class_Degrade","entityType":"code_module","observations":["В recommend_mix: target_class_for_geo — если в районе нет ни одного ЖК с НЕ-NULL obj_class — фильтр по классу ОПУСКАЕТСЯ на уровне velocity/elasticity/comparable запросов.","class_multiplier (из yandex_realty_zk) на ценах продолжает работать.","Причина: kn-API возвращает obj_class=NULL для ВСЕХ 3032 ЖК Свердл (отдельный баг скрейпера). Без degrade фильтр obj_class='Comfort' режет всё в НОЛЬ.","Warning пользователю: 'obj_class не заполнен — фильтр по классу игнорируется'."]} +{"type":"entity","name":"Bug_Kn_API_Obj_Class_Always_Null_OPEN","entityType":"bug","observations":["Симптом: domrf_kn_objects.obj_class=NULL для ВСЕХ 3032 ЖК Свердл. SELECT obj_class, COUNT(*) GROUP BY obj_class даёт только (NULL, 3032).","Причина: kn-API возвращает фильтр по objClass но сам objClass в ответе пустой, или _norm_object не читает правильный ключ.","Временный обход: Module_Graceful_Obj_Class_Degrade игнорирует target_class в geo-запросах.","Реальный фикс: проверить реальные ключи kn-API response через chrome-devtools пробив, поправить _norm_object."]} +{"type":"entity","name":"Bug_Kn_Flats_Price_Per_M2_Sparse_OPEN","entityType":"bug","observations":["Симптом: domrf_kn_flats.price_per_m2 заполнен только для 288 из 192K квартир (0.15%). Практически все квартиры без цены.","Эффект на backfill ekb_districts.median_price_per_m2: сработало только для 2 районов (Орджоникидзевский, Октябрьский). 6 районов остаются median=NULL.","Причина: kn-API не возвращает price для большинства квартир в sales/portal/table респонсе.","Реальный фикс: разобраться почему price=NULL (пробив chrome-devtools), возможно другой ключ в JSON. Или: брать price из sales_agg вместо per-flat."]} +{"type":"entity","name":"Backfill_District_Median_From_Kn_Flats","entityType":"code_module","observations":["Сырой SQL: UPDATE ekb_districts SET median = (PERCENTILE_CONT(0.5) FROM kn_flats JOIN kn_objects ON district_name) WHERE n>=30.","Результат 29.04.2026: Орджоникидзевский=205051 (n=233), Октябрьский=191786 (n=55). Для расчёта district_factor оживило 2 из 8 районов.","При вызове recommend_mix: Орджоникидзевский district_factor=1.03 (выше city median), Октябрьский=0.97 (ниже).","Остальные 6 районов — median NULL, district_factor=1.0 + warning. Блокир Bug_Kn_Flats_Price_Per_M2_Sparse_OPEN."]} +{"type":"entity","name":"Component_Recommend_Page_Reorder_Apr29","entityType":"ui_component","observations":["frontend/src/app/analytics/recommend/page.tsx — реорганизация порядка секций.","Было: Velocity → Liquidity Chart → KPI strip (city-wide) → Buckets → Revenue → Sliders → Table → Comparable.","Стало: Headline → Velocity (с price slider) → SLIDERS MIX → Liquidity → District-KPI → Buckets → Revenue → Table → Comparable.","Причина: user 'хочу видеть как меняются параметры при изменении mix' — все контроли (цена+mix) выше результатов."]} +{"type":"entity","name":"Component_Recommend_District_KPI_Strip","entityType":"ui_component","observations":["Новый KPI-strip в page.tsx вместо старых city-wide дублей ('Район / Сделок / Weighted / Выручка' — все одинаковые между районами).","4 новых KPI: 'Конкуренты в районе' (с hint о scope), 'Sale_graph покрытие' (X ЖК / Y точек / fallback), 'District factor', 'Class multiplier'.","Все 4 меняются при переключении района. Старые city-wide вынесены в headline-плашку и velocity-panel."]} +{"type":"entity","name":"Cleanup_Unused_Vars_Recommend","entityType":"code_module","observations":["Убраны totalRevenue + weightedAvgPrice из page.tsx (были в useMemo но нигде не использовались после рефактора).","Single source of truth: backend.recommend_mix считает эти значения, фронт отображает из summary.headline и RecommendVelocityPanel."]} +{"type":"entity","name":"Pricing_Slider_Range_Expanded_Apr29","entityType":"policy","observations":["Диапазон Price ± slider: 0.85..1.15 (±15%) → 0.01..3.0 (−0.99..+2.0 = −99%..+200%).","min=0.01 (НЕ 0) чтобы избежать pf^elasticity = ∞ (при elasticity=-1.5 и pf=0).","Pydantic schema: RecommendMixInput.price_factor Field(ge=0.01, le=3.0).","Inverse-mode 'Применить required_pf' тоже клампит на [0.01, 3.0].","Практически для CEO-демо: -50% в цене → velocity × 0.5^-1.5 = ×2.83 (в почти 3 раза быстрее), +100% → ×0.35."]} +{"type":"entity","name":"Limit_District_Geometry_Closed","entityType":"limitation","observations":["Дата закрытия: 29.04.2026.","Изначально: rosreestr_deals имеет только quarter_cad_number, без района. ekb_districts не имела PostGIS-полигонов.","Решение: Geo_District_Mapping_Apr29 (Overpass API → PostGIS → spatial-join через lat/lon ЖК). Обходим отсутствие cad_quarter→район маппинга через kn_objects как мост (lat/lon → district).","Per-district demand работает в recommend_mix: 4 запроса используют district_name = :dn."]} +{"type":"entity","name":"Limit_District_Median_Partial","entityType":"limitation","observations":["Частично решен Limit_District_Median_Null (29.04.2026): 2 из 8 районов имеют median (Орджоникидзевский 205051, Октябрьский 191786) из kn_flats.","Остальные 6 = NULL: kn-API возвращает price для всего 0.15% квартир (Bug_Kn_Flats_Price_Per_M2_Sparse_OPEN).","Блокирующий fix: разобраться почему kn-API не отдаёт price (возможно другой ключ в JSON, или взять из sales_agg.price_avg).","Альтернативный путь (отложен): rosreestr_deals → cad_quarter → район через NSPD кадастровые полигоны."]} +{"type":"entity","name":"Session_GeoMapping_Apr29_2026","entityType":"event","observations":["Дата: 29.04.2026. Продолжение Session_Apr28_Calc_Scraper_Apr28_2026.","ГЛАВНОЕ: Geo_District_Mapping_Apr29 — PostGIS-полигоны 8 районов ЕКБ из OSM Overpass, backfill domrf_kn_objects.district_name, recommend_mix переведён на district_name = :dn (вместо addr ILIKE).","СДЕЛАНО:","1. Module_Fetch_Ekb_Districts (Overpass loader) + File_Ekb_Districts_Geojson_Cache + Schema_Ekb_Districts_Geom + Schema_Kn_Objects_District_Column + Module_Backfill_District_Names_Scraper.","2. Pattern_OSM_Overpass_Admin_Level (admin_level=9 для районов РФ).","3. Bug_Recommend_Addr_ILIKE_Returns_Zero_Fixed (4 места).","4. Module_Graceful_Obj_Class_Degrade (kn-API obj_class=NULL не валит фильтры).","5. Backfill_District_Median_From_Kn_Flats (2/8 районов).","6. Component_Recommend_Page_Reorder_Apr29 + Component_Recommend_District_KPI_Strip + Cleanup_Unused_Vars_Recommend.","7. Pricing_Slider_Range_Expanded_Apr29 (±15% → −99..+200%).","CLOSED: Limit_District_Geometry_Closed, Bug_Recommend_Addr_ILIKE_Returns_Zero_Fixed, Limit_District_Median_Partial.","OPENED: Bug_Kn_API_Obj_Class_Always_Null_OPEN, Bug_Kn_Flats_Price_Per_M2_Sparse_OPEN.","ПРОД SCHEMA: 2 новые миграции применены: 56_schema_ekb_districts_geom.sql + 57_kn_objects_district.sql. ekb_districts.median обновлён для 2 районов.","ПРИОРИТЕТЫ: 1) Разобраться с obj_class/price из kn-API (оба ОПЕН бага). 2) NSPD GeoJSON cad-кварталов для rosreestr-маппинга. 3) Этильбарные тиры 1-3 предикта."]} +{"type":"entity","name":"Tier2_District_Macro_Factors_Apr29","entityType":"deliverable","observations":["Дата: 29.04.2026. Расширение recommend_mix тремя district-специфичными факторами + макро-контекст для CEO.","Факторы: sat_factor (зрелость рынка), trend_factor (ускорение/выхлаждение 6мо), poi_factor (развитость района).","Результат (Comfort, 50K м²): Орджоникидзевский 31 мес vs Академический 58 мес — разнос 1.9x при одинаковых вводных параметрах.","Macro context: ставка ИЖК 7.84% (Свердл, Янв 2026) в footer headline для CEO-легитимности."]} +{"type":"entity","name":"Module_District_Saturation_Factor","entityType":"code_module","observations":["backend/app/services/analytics_queries.py :: _district_market_saturation(db, *, district_name).","Median sold% активных строящихся ЖК в районе из domrf_kn_sales_agg.perc.","Применение: sat_factor = 1 + (sold_median - 50) / 100 * 0.3 — ±15% при крайних (>50% зрелый → +bonus, <20% свежий → -penalty).","Требует ≥5 ЖК в районе с perc != NULL.","Данные (29.04.2026): Ленинский 48% (зрелый), Академический 38%, Кировский 29.5% (свежий). Разнос ~20пп."]} +{"type":"entity","name":"Module_District_Velocity_Trend","entityType":"code_module","observations":["backend/app/services/analytics_queries.py :: _district_velocity_trend(db, *, district_name).","Ratio recent_6mo / prior_6mo realised из domrf_kn_sale_graph (apartments). H1 2025 vs H2 2025+.","Применение: trend_factor = clamp(ratio, 0.7, 2.0) — блокировка экстремумов (Академический raw 6.66 в начале 2025).","Данные: все 8 районов НАРАСТАЮТ (ratio 2.07..6.66) — H1 2025 был холодным. Когда наберём 24+ мес истории — числа станут дифференцированными.","UI: в KPI strip 🚀 (>1.5 рынок ускоряется) / → (стабильный) / ❄ (<0.8 остывает)."]} +{"type":"entity","name":"Module_District_Poi_Score","entityType":"code_module","observations":["backend/app/services/analytics_queries.py :: _district_poi_score + _city_avg_poi_score.","Weighted POI count по РАЙОНУ: SUM(weight) per ЖК в радиусе 1000м, потом AVG по ЖК района.","Веса категорий: Метро 2.0, Транспорт 1.5, Медицина 1.3, Образование 1.2, Спорт 1.0, Продукты 0.8, Развлечения 0.7, Новостройки 0.0 (сами ЖК, не amenity).","Применение: poi_factor = 1 + (district / city_avg - 1) * 0.05 — ±5% влияние на ЦЕНЫ (не на velocity).","Сейчас ratios 0.99..1.02 — все районы ~city avg. Даст больше эффекта когда POI покрытие выравняется."]} +{"type":"entity","name":"Module_Current_Mortgage_Rate","entityType":"code_module","observations":["backend/app/services/analytics_queries.py :: _current_mortgage_rate.","SQL: title ILIKE 'Средневзвешенная ставка по ипотечным жилищным%' AND '%в рублях, %' AND value BETWEEN 1 AND 30.","Выдаёт 7.84% (Свердл, Янв 2026) — СО ЛЬГОТАМИ (семейная, IT, ДВ-ипотека входят в средневзвешенную).","Рыночная ставка без льгот ~20.5% (РБК, Янв 2026) — в БД НЕТ такой серии. UI footer явно пишет 'со льготами; рыночная ~20%'."]} +{"type":"entity","name":"Bug_Mortgage_Rate_5_57_Wrong_Match_Fixed","entityType":"bug","observations":["Симптом: scope.mortgage_rate_pct выдавал 5.57% вместо реальных 7.84%. User заметил: 'слишком маленькая'.","Причина: SQL filter title ILIKE '%ипотечн%жилищн%' слишком широкий — матчил 'ДОЛЮ ипотечных жилищных кредитов на ИЖС' (5.57%, это НЕ ставка). ORDER BY period DESC LIMIT 1 случайно брал эту строку.","Closed 29.04.2026 в Module_Current_Mortgage_Rate: title ILIKE 'Средневзвешенная ставка...' + '%в рублях, %' + value BETWEEN 1 AND 30. Теперь выдаёт 7.84%."]} +{"type":"entity","name":"Macro_Context_Mortgage_Rate_Apr29","entityType":"research","observations":["Дата: 29.04.2026. Проверено вебом (РБК, cbr.ru) по запросу user 'погугли про ставку'.","Рыночная ставка (без льгот) ПЕРВИЧКА Янв 2026: 20.49%. ВТОРИЧКА: 20.5%.","Средневзвешенная (с учётом льготных программ) Янв 2026: 7.6% по РФ, 7.84% по Свердл.","Ключевая ставка ЦБ ~21% (Ноябрь 2025+).","70% покупателей Comfort/Comfort+ подпадают под льготы (семейная основная — 6%); Business/Elite в большинстве берут рыночную ~20%.","БД cbr_mortgage_series имеет ТОЛЬКО средневзвешенную (с льготами), рыночной раздельно нет. UI footer явно уточняет это."]} +{"type":"entity","name":"Component_Tier2_KPI_Strip_Update","entityType":"ui_component","observations":["frontend/src/app/analytics/recommend/page.tsx — обновлён district-KPI strip.","4 KPI: Конкуренты / Sale_graph покрытие / Sold% медиана / Тренд 6 мес / Class × District (combined).","Sold% хинт: 'зрелый рынок' / 'свежий, мало распродано' / 'умеренная зрелость' с числом N ЖК.","Тренд: 🚀 (>1.5) / → (стабильный) / ❄ (<0.8) + ratio.","Class × District: перемноженный mult из 3 (class * district * poi) + breakdown в hint."]} +{"type":"entity","name":"Component_Headline_Macro_Footer","entityType":"ui_component","observations":["frontend/src/app/analytics/recommend/page.tsx — footer в headline-плашке под основной строкой.","Показывает: 📊 Средневзв. ИЖК 7.84% (Янв 2026, со льготами; рыночная ~20%) · POI 130/82 (выше среднего).","Отделён в визуале border-top с opacity 0.1, fontSize 12 · color #94a3b8."]} +{"type":"entity","name":"Component_Velocity_Panel_Methodology_Tier2","entityType":"ui_component","observations":["frontend/src/components/analytics/RecommendVelocityPanel.tsx — расширен methodology note.","Теперь показывает все 3 macro factors: 'sat ×0.96 (sold% 38) · trend ×2.00 (raw ×6.66 clamp 0.7..2.0) · POI ×1.02 (на цену)'.","Полная прозрачность для CEO: откуда каждая цифра."]} +{"type":"entity","name":"Limit_Trend_Always_Capped_OPEN","entityType":"limitation","observations":["Дата: 29.04.2026. trend_factor во всех 8 районах хлопается в max=2.0.","Причина: H1 2025 был холодным (мало сделок в свежевыпущенных ЖК) и sale_graph покрытие было тонким. recent_6mo / prior_6mo выходит всюду >2.0.","Решение отложено: когда наберём 24+ мес истории sale_graph (в мае/июне 2026), перевести trend window на H2 2025 vs H1 2026 — будут реальные ratios.","Пока эффект trend_factor одинаковый для всех районов (×2.0) — не дифференцирует их."]} +{"type":"entity","name":"Limit_POI_Factor_Tiny_OPEN","entityType":"limitation","observations":["Дата: 29.04.2026. poi_factor во всех районах в диапазоне 0.99..1.02.","Причина: POI покрытие равномерное по всем районам (1284 ЖК с POI из 1516 покрыты ровно), весовая формула даёт похожие числа.","Решение отложено: повысить sensitivity 0.05 → 0.15-0.20 или изменить формулу на percentile-rank вместо ratio. Или ввести разные weights для Comfort/Business (премиум покупатели больше ценят amenity).","POI категории: Новостройки 65K (игнор), Продукты 55K, Спорт 53K, Образование 48K, Медицина 5K, Развлечения 1.8K."]} +{"type":"entity","name":"Session_Tier2_Apr29_2026_Late","entityType":"event","observations":["Дата: 29.04.2026 днём. Продолжение Session_GeoMapping_Apr29_2026.","СДЕЛАНО:","1. Tier2_District_Macro_Factors_Apr29 — sat_factor + trend_factor + poi_factor + mortgage_rate.","2. 5 helpers: Module_District_Saturation_Factor + Module_District_Velocity_Trend + Module_District_Poi_Score + _city_avg_poi_score + Module_Current_Mortgage_Rate.","3. Component_Tier2_KPI_Strip_Update + Component_Headline_Macro_Footer + Component_Velocity_Panel_Methodology_Tier2.","4. Bug_Mortgage_Rate_5_57_Wrong_Match_Fixed + Macro_Context_Mortgage_Rate_Apr29 (верифицировано веб-поиском).","Smoke (Comfort, 50K м²): 5 районов, сроки 31..58 мес (разнос 1.9x). Орджоникидзевский 31, Кировский 34, Ленинский 39, Чкаловский 51, Академический 58. Чек 2.7-2.8 М ₽. Ликвидность 40/100.","OPEN BUGS: Limit_Trend_Always_Capped_OPEN (trend_factor везде max 2.0 пока sale_graph история тонкая), Limit_POI_Factor_Tiny_OPEN (±1-2% эффект).","OPEN BUG скрейпера (с прошлых сессий): Bug_Kn_API_Obj_Class_Always_Null_OPEN (kn-API отдаёт obj_class=NULL), Bug_Kn_Flats_Price_Per_M2_Sparse_OPEN (kn-API отдаёт price для 0.15% квартир)."]} +{"type":"entity","name":"Bug_Velocity_Region_Wide_Rosreestr_Fixed_Apr29","entityType":"bug","observations":["Симптом: «Верх-Исетский · 5 000 м²»: за ~3.7 мес, темп 33.6 кв/мес, ликвидность 100/100 — фантастика для одного ЖК.","Корень: bucket_market_velocities в analytics_queries.py (lines 1685–1693) использовал rosreestr_deals/window per bucket по ВСЕМУ region_code=66. Window auto-extend до 24mo → ~159 deals/mo на регион → после /sqrt(80 competitors) × sat × trend = 33.8 кв/мес total. Точно совпадает с UI-цифрой.","footer показывал «Базовый темп 4.2 кв/мес (sale_graph: 40 ЖК / 720 точек)», но это число НЕ использовалось в bucket-расчёте — лишь декоративно.","Фикс (29.04.2026):","1) market_vel_pm = sale_graph median per ЖК района (либо rosreestr-fallback / N_competitors → per-ЖК proxy).","2) bucket_market_velocities = market_vel_pm × bucket.share / 100 (аллоцируем per-ЖК baseline на размерные сегменты).","3) Удалена нормировка /sqrt(competitors) — market_vel_pm уже per-ЖК.","4) Removed import math (больше не нужен).","Результат для 5K м² по 8 районам ЕКБ: 7.5..18 мес (вместо 3.7). Между районами реальный разброс 2.4x: Октябрьский 7.5 → Академический 18.","Closed by: 3 правки в backend/app/services/analytics_queries.py:1627–1739."]} +{"type":"entity","name":"Bug_Mortgage_5_57_Stale_Deploy_Apr29","entityType":"bug","observations":["Симптом: footer показывал 5.57% (старая неправильная), хотя _current_mortgage_rate был исправлен в Bug_Mortgage_Rate_5_57_Wrong_Match_Fixed.","Не код-баг: DB напрямую отдаёт 7.84% (Январь 2026, 'Средневзвешенная ставка по ипотечным жилищным... в рублях, %', value BETWEEN 1 AND 30 — корректная строка).","Backend на проде не передеплоен с фиксом → продолжает кешировать старый ответ.","Action: nginx reload / pm2 restart backend → подхватит новый код, footer покажет 7.84%.","Документировано в плане crispy-swinging-gadget.md секция Approach — B."]} +{"type":"entity","name":"NSPD_API_Endpoints_Apr29","entityType":"research","observations":["NSPD geoportal v2 API: nspd.gov.ru — публичная аналитика недвижимости РФ.","ВАЖНО: WAF блокирует не-РФ IP (HTTP 403 + 'Rule: ...'). Для скрейпа нужен российский egress.","chrome-devtools MCP не помогает обойти — sandboxed Chrome у Anthropic тоже блокируется (тот же IP).","ПРОВЕРЕННО РАБОТАЮЩЕЕ: same-origin fetch со страницы NSPD у пользователя — WAF пропускает, certs OK.","ENDPOINTS:","1. GET /api/geoportal/v2/search/geoportal?thematicSearchId={id}&query={text} — основной search."," thematicSearchId: 1=Объекты недвижимости, 2=Кадастровое деление, 4=Адм-тер деление, 5=Зоны и территории, 7=Терр зоны, 15=Комплексы."," query=cad-номер квартала: id=2 → 1 polygon квартала; id=1 → до 20 features (земля+здания+помещения+машино-места+сооружения) внутри квартала.","2. GET /api/aeggis/v4/{layer_id}/wms?REQUEST=GetFeatureInfo&BBOX=...&I=256&J=256&FEATURE_COUNT=10&INFO_FORMAT=application/json — векторные данные слоя."," FEATURE_COUNT > 10 → HTTP 500. Только 10 features per call.","3. POST /api/geoportal/v1/download/objects/csv — кнопка 'Скачать список'. CSV метаданных, не GeoJSON.","4. GET /api/geoportal/v1/layers-theme — список тематик (PKK, ARN, etc.).","5. GET /api/geoportal/v1/layers-theme-tree?themeId={id} — слои в тематике.","ТЕМАТИКИ:","- 1 (PKK): 63 слоя — Кадастровые округа/районы/кварталы (36071), Здания (36049), Объекты незавершенного строительства (36329), Кадастровая стоимость (37236).","- 665 (ARN): 12 слоёв — Объекты по площади 0-25/25-40/40-60/60-80/80-100/100+ м² (WMS+MVT)."]} +{"type":"entity","name":"Cad_Quarter_Scrape_Apr29","entityType":"deliverable","observations":["Дата: 29.04.2026. Цель — связать rosreestr_deals.quarter_cad_number ↔ domrf_kn_objects (lat/lon) через PostGIS spatial-join.","Schema: cad_quarters_geom (cad_number, geom MultiPolygon 4326) + cad_buildings (cad_num, geom geometry, cost_value, year_built, purpose, area, floors, addr).","ALTER domrf_kn_objects ADD cad_quarter, cad_building_num.","Pipeline (через debug-Chrome пользователя):","1. fetch /api/geoportal/v2/search/geoportal?thematicSearchId=2&query={cad} → polygon квартала","2. fetch /api/geoportal/v2/search/geoportal?thematicSearchId=1&query={cad} → 20 зданий + помещения + земля","3. Filter buildings by categoryName='Здания' AND cad_num LIKE '66:41:%' (исключить out-of-EKB).","4. Convert geometry to WKT (EPSG:3857), batch-INSERT в Postgres через mcp__postgres__execute_sql с ST_Transform→4326.","5. После полного scrape: backfill domrf_kn_objects.cad_quarter через ST_Contains.","Прогресс: schema готова, 5 quarters в БД (66:41:0105031, :0106002, :0106009, :0106021, :0106023). 669 осталось.","Estimate: 670 × 2 fetch × 700ms = 15-20 мин в browser + 134 × 5s INSERT = 12 мин = ~30 мин.","ВАЖНО: search id=1 включает здания из других округов (66:15, 66:36, 66:51 — Невьянский/В.Пышма/Красноуральск) с похожими суффиксами. Фильтр 'cad LIKE 66:41:%' обязателен.","Полные файлы артефактов: data/sql/58_schema_cad_quarters.sql, 58_fetch_cad_quarters_nspd.py (ineffective без РФ-IP), 59_backfill_obj_cad_quarter.sql, 60_v_zk_rosreestr_velocity.sql + data/raw/cad_quarter_list.txt."]} +{"type":"entity","name":"NSPD_Scrape_Complete_Apr30","entityType":"deliverable","observations":["Дата: 30.04.2026. Завершён полный скрейп NSPD кадастровых данных через debug-Chrome (обход WAF через same-origin XHR + JWT gosuslugi.ru auth).","ИТОГ: 709 cad-кварталов / 10590 зданий в БД. Покрытие свежих ДДУ-сделок Свердл (12mo): 91% (713/784).","Scope: ЕКБ (674 квартала из rosreestr_deals) + 43 пригорода с активными ДДУ за 12mo (Сысерть, Арамиль, В.Пышма, Сухой Лог, Богданович, Реж, Артёмовский).","Schema: cad_quarters_geom (cad_number PK, geom MultiPolygon 4326, raw_props jsonb) + cad_buildings (cad_num PK, geom Geometry, purpose, building_name, address, area, floors, year_built, year_commisioning, cost_value, registration_date, status, ownership_type, cultural_heritage, underground_floors, build_record_*, common_data_status, obj_type, raw_props jsonb).","Pipeline: chrome-devtools MCP browser → fetch NSPD endpoints (thematicSearchId=2 для квартала + =1 для зданий внутри) → save via 61_import_nspd_batch.py через SSH-tunnel localhost:15432.","Время: ~3-4 часа активной работы (с 60-90s drain интервалами + import batches).","Closed limitations: Limit_District_Geometry_Closed, Limit_District_Median_Partial (теперь rosreestr_deals → cad_quarter → ЖК работает).","Files: data/sql/58_schema_cad_quarters.sql, 61_import_nspd_batch.py (v2 with raw_props), data/raw/nspd_drain_*.jsonl + nspd_refetch_*.jsonl (50+ batch files)."]} +{"type":"entity","name":"NSPD_API_Endpoints_Reference","entityType":"reference","observations":["Дата: 30.04.2026. Полная карта NSPD API endpoints (из debug-Chrome обхода).","BASE: https://nspd.gov.ru","АУТХ: JWT через gosuslugi.ru SSO. JWT exp ~30 мин. Cookie: authAccessToken+authRefreshToken. Без auth — HTTP 403 на API (но статика проходит).","WAF: блокирует не-РФ IP на /api/* endpoints. chrome-devtools MCP sandboxed Chrome тоже блокируется (egress = Anthropic IP). РАБОЧИЙ ОБХОД: same-origin XHR со страницы nspd.gov.ru/map в браузере пользователя.","Endpoints:","1. GET /api/geoportal/v2/search/geoportal?thematicSearchId={id}&query={text}"," thematicSearchId: 1=Объекты недвижимости (земля+здания+помещения+маш-места+сооружения), 2=Кадастровое деление (квартал/район/округ), 4=Адм-тер деление, 5=Зоны, 7=Терр. зоны, 15=Комплексы."," query=cad-номер: id=2 → 1 polygon квартала; id=1 → до 90 features (по 20 на категорию) внутри квартала.","2. GET /api/aeggis/v4/{layer_id}/wms?REQUEST=GetFeatureInfo&BBOX=...&I/J=&FEATURE_COUNT=10 — векторные данные слоя по bbox+pixel. FEATURE_COUNT > 10 → HTTP 500.","3. POST /api/geoportal/v1/download/objects/csv — CSV-экспорт выбранных объектов (но не GeoJSON).","4. GET /api/geoportal/v1/layers-theme — список тематик.","5. GET /api/geoportal/v1/layers-theme-tree?themeId={id} — слои в тематике.","Тематики: 1 (PKK, 63 слоя — Кадастровые квартал=36071, Здания=36049, Объекты незавершенного строительства=36329, Кадастровая стоимость=37236), 665 (ARN=Аналитика рынка недвижимости, 12 слоёв — объекты по площади 0-25/25-40/40-60/60-80/80-100/100+ м² WMS+MVT).","Geometry SRID: EPSG:3857 (web mercator). На стороне БД ST_Transform(3857→4326).","ВАЖНО: search id=1 включает здания из других кадастровых округов с похожими суффиксами. Фильтр 'cad_num LIKE \"66:41:%\"' обязателен."]} +{"type":"entity","name":"Cad_Buildings_Schema_v2","entityType":"schema","observations":["Дата: 30.04.2026. cad_buildings (10590 строк) — итоговая схема после v2 extension.","Колонки структурированные:","- cad_num TEXT PK — кадастровый номер здания (66:41:0108043:59)","- quarter_cad_num TEXT — родительский квартал (3 первых сегмента)","- geom Geometry(Geometry, 4326) — Point/Polygon/MultiPolygon","- purpose TEXT — Многоквартирный дом / Жилое / Жилой дом / Нежилое / Гараж","- building_name TEXT, readable_address TEXT","- area NUMERIC, floors TEXT (например '10-18')","- year_built INTEGER, year_commisioning INTEGER","- cost_value NUMERIC — кадастровая стоимость ₽","- registration_date DATE","- status TEXT — 'Учтенный' (1679) / 'Ранее учтенный' (8148)","- ownership_type TEXT, cultural_heritage TEXT, underground_floors TEXT","- build_record_area NUMERIC, build_record_type TEXT","- common_data_status TEXT, obj_type TEXT","- raw_props jsonb — ПОЛНЫЙ options-объект NSPD (для будущих запросов)","Распределение purpose: Нежилое 4040 / Многоквартирный дом 3245 / Жилое 1567 / Гараж 559 / Жилой дом 601.","Cross-validation: NSPD кадастровая стоимость per м² ≈ 30-40K ₽/м², рыночная (sale_graph) 130-244K — разница в 3-5×. Premium 50-90% типично, аномалии (-34% Белая Башня) сигнализируют outlier."]} +{"type":"entity","name":"Cad_Quarter_Backfill_Apr30","entityType":"deliverable","observations":["Дата: 30.04.2026. Spatial-join: domrf_kn_objects.cad_quarter заполнен через ST_Contains(cad_quarters_geom.geom, lat/lon).","Coverage: 2262/2578 ЖК (88%) ЕКБ привязаны к cad_quarter, 237 уникальных кварталов используются.","По районам: Академический 96%, Октябрьский 93%, Железнодорожный 93%, Ленинский 88%, Чкаловский 87%, Кировский 87%, Орджоникидзевский 80%, Верх-Исет 69%.","По статусу: Строящиеся 357/442 (81%), Сданные 774/1073 (72%), Проблемные 1/2 (50%).","ЖК↔здание матч через ST_DWithin(50м): 349 ЖК сматчены с конкретным МКД из cad_buildings.","Топ-точные совпадения (ошибка 36-77м): ЖК «Сибирский сад» → 66:41:0603016:198, Атлас Ривер → 66:41:0508075:1406, Парковый квартал 2.0 → 66:41:0402030:2391 (yc=2025)."]} +{"type":"entity","name":"Sprint1_Plan_May2026","entityType":"plan","observations":["Дата создания плана: 30.04.2026. План реализации Sprint 1 (нед 1-2 мая).","Tier 1 — критичный путь:","A. NSPD-scraper индустриализация (~2 дня): Playwright + gosuslugi SSO auth → backend/app/services/scrapers/nspd_kn.py + Celery task scrape_nspd_region + nspd_scrape_runs table + crontab(day=20, month='2,5,8,11'). 80% переиспользование domrf_kn.py паттернов.","B. Recommend_mix Tier 3 (~3 дня): per-bucket elasticity вместо скаляра. SQL window-functions по area_bucket + frontend per-bucket curve.","C. NSPD кадастр vs sale_graph cross-check UI (~1 день): premium % колонка + warning badge для аномалий >50%.","D. Issue #4 SENTRY_RELEASE (~30 мин): SSH→.env.runtime + sentry_sdk init.","Итого Sprint 1: ~7 дней работы.","План в C:\\Users\\user\\.claude\\plans\\next-iteration-roadmap.md."]} +{"type":"entity","name":"Sprint2_Stage1_Generative","entityType":"plan","observations":["Sprint 2 (нед 3-6): Stage 1 Generative MVP — продуктовое ядро, делает gendesign продаваемым.","Issue #5 Stage 1a (~5 дней): app/services/generative/geometry.py (Shapely парсер GeoJSON, отступы, placement grid), Pydantic ConceptInput/Output, frontend Leaflet drag-drop + 3 таба-варианта (заглушки).","Issue #12 Stage 1b (~5 дней): Greedy алгоритм 3 стратегии (max_area/max_insolation/balanced) с STRtree коллизиями. Цель: 3 варианта ≤15сек на 1 га. Frontend визуализация корпусов + ТЭП метрики.","Issue #6 Stage 1c (~6 дней): services/generative/teap.py + financial.py (revenue/cost/IRR), экспортеры PDF/Excel/DXF, API POST /concepts/{id}/export. Завершение Stage 1.","Итого: ~16 дней. После этого Stage 1 = продаваемый артефакт для встреч с инвесторами."]} +{"type":"entity","name":"Sprint3_Stage2_SiteFinder","entityType":"plan","observations":["Sprint 3 (нед 7-10): Stage 2 Site Finder MVP — конец продуктового vision.","Issue #7 Stage 2a (~5 дней): site_finder/parser.py (ПКК Rosreestr, GeoAlchemy2 Parcel модель, ≥1000 участков ЕКБ) + workers/sales_tracker.py (шахматки топ-7 застройщиков: Атомстрой, Брусника, ТЭН, Стройтек, Прогресс, УГМК, КОРТРОС).","Issue #8 Stage 2b (~5 дней): scorer.py (3-4 критерия: distance_to_center, POI_density, district_price + опц. velocity_score), POST /parcels/search ≤500мс, frontend Mapbox color-graded markers + sidebar filters.","Issue #9 Stage 2c (~3 дня): кнопка 'Обсчитать концепцию' на карточке участка → pipe в /concepts. + Реестр ПО подача (60-180 дней). + финмодель 5-летняя для инвест-deck.","Pre-existing data: NSPD cad_quarters (709 кварталов) переиспользуем как один из источников для site_finder. ПКК даст vri+address+другие слои."]} +{"type":"entity","name":"GitHub_Issues_Open_Apr30","entityType":"reference","observations":["8 открытых issues на 30.04.2026.","#3 Selectel/Yandex S3 backup setup — ❌ ИСКЛЮЧЕНО пользователем (не делаем).","#4 SENTRY_RELEASE deploy.yml tracking — Tier 4 (~30 мин).","#5 Stage 1a Shapely service + API contract — Sprint 2.","#6 Stage 1c TEAP + finmodel + экспорты PDF/Excel/DXF — Sprint 2.","#7 Stage 2a ПКК parser + sales_tracker — Sprint 3.","#8 Stage 2b Scoring + API + UI — Sprint 3.","#9 Stage 2c Integration + реестр ПО + финмодель — Sprint 3.","#11 Антон onboarding к knowledge-graph (Claude Desktop + MCP) — Tier 4 (~30 мин документации).","#12 Stage 1b Greedy алгоритм 3 стратегии STRtree — Sprint 2."]} +{"type":"entity","name":"Open_Limitations_Apr30","entityType":"limitation","observations":["Дата: 30.04.2026. Открытые ограничения после NSPD-pipeline.","1. Limit_Trend_Always_Capped_OPEN — все 8 районов ЕКБ имеют raw trend ratio 5+ (capped к 2.0). Решится через 6-12 мес sale_graph истории. Не действуем сейчас.","2. Limit_POI_Factor_Tiny_OPEN — POI ratio 0.99-1.02 везде (равномерное распределение POI). Можно увеличить вес с 5% до 10% или добавить distance-weighting к метро.","3. Bug_Kn_API_Obj_Class_Always_Null_OPEN — kn-API НЕ отдаёт obj_class для всех 3032 ЖК Свердл. Решение: fallback на yandex_realty_zk.obj_class через name-match (~1 день).","4. Bug_Kn_Flats_Price_Per_M2_Sparse_OPEN — kn-API даёт price для 0.15% квартир. ekb_districts.median для 6 районов NULL. Workaround есть в recommend_mix (sale_graph baseline). Дополнительно: можно взять median из cad_buildings.cost_value (но это кадастр, не рыночная).","5. ✅ CLOSED: Limit_District_Geometry_Closed (через OSM ekb_districts), Limit_District_Median_Partial (через cad_quarter mapping)."]} +{"type":"entity","name":"Excluded_Items_User_Pref","entityType":"policy","observations":["Дата: 30.04.2026. Пользователь явно исключил из roadmap.","❌ Issue #3 Selectel/Yandex S3 backup — пропускаем.","❌ Любое перемещение infra в Yandex Cloud / Selectel — пропускаем.","❌ pg_dump → cloud — пропускаем.","Причина не указана. Если потребуется ДР-стратегия — спросить отдельно."]} +{"type":"entity","name":"Session_End_Apr30_2026","entityType":"event","observations":["Дата: 30.04.2026. Длинная сессия (~5+ часов): NSPD scrape end-to-end + planning.","Главное:","1. NSPD_Scrape_Complete_Apr30 — 709 кварталов / 10590 зданий, 91% покрытие свежих ДДУ.","2. Cad_Buildings_Schema_v2 — расширили схему raw_props jsonb + 8 новых колонок.","3. Cad_Quarter_Backfill_Apr30 — spatial-join 2262/2578 ЖК (88%).","4. Cross-check: NSPD кадастр vs ДОМ.РФ рынок — premium 50-90% типично.","5. Sprint1_Plan_May2026 + Sprint2_Stage1_Generative + Sprint3_Stage2_SiteFinder — план на 10-12 недель до Phase 0 финиша.","OPEN на следующую сессию: Sprint 1 task A (NSPD scraper индустриализация Playwright+Celery).","Артефакты: ~50 JSONL drain-файлов в data/raw/, обновлённый 61_import_nspd_batch.py, план в C:\\Users\\user\\.claude\\plans\\next-iteration-roadmap.md.","ИНСАЙТ: rosreestr_deals.realestate_type='002001003000' даёт 91% покрытие за 12mo. Sale_graph ДОМ.РФ остаётся золотым стандартом для velocity (rosreestr недополнен — для топ-ЖК показывает 0.1 кв/мес против реальных 16-45 из sale_graph).","Pipeline через debug-Chrome работает но мануальный — индустриализация в Sprint 1."]} +{"type":"entity","name":"Objective_Integration_May07_2026","entityType":"deliverable","observations":["Дата: 07.05.2026. Интеграция с api.objctv.ru (Объектив) — внешний B2B аналитический API с per-flat данными по новостройкам РФ.","Цель: получить data feed богаче чем ДОМ.РФ kn-API: per-flat статус/цена/банк/дата регистрации + per (месяц × корпус × room-bucket) агрегаты для recommend_mix.","Архитектура: settings.objective_api_key → ObjectiveClient (httpx + Bearer-токен в Redis с in-memory fallback) → 4 канонические отчёты → objective_raw_reports (jsonb) → 70_parse_objective_raw.py → objective_corpus_room_month + objective_lots + objective_lots_history.","Celery-task tasks.scrape_objective.sync_objective_group: еженедельно пн 05:00 МСК (settings.objective_sync_cron='0 5 * * mon'). Тянет 2 рабочих отчёта (Сводные/Корпуса + Поквартирные/Лоты), сохраняет raw, сразу же парсит через late-import парсера 70.","Smoke-test 07.05 подтвердил: Парк Культуры (1 ЖК × 7 дней) → corp_sum 8 строк (13.5 KB) + lots_pf 563 квартир (818.8 KB). Парсер dry-run обработал все строки без warning.","ОТЛИЧИЕ ОТ ДОМ.РФ kn-API: точные per-flat поля — Статус, Цена за м2, Дата договора, Дата регистрации, Тип сделки (ДДУ/ДКП), Банк (ипотека!), Тип обременения. Это ×100 точнее domrf_kn_flats и даёт банковские предпочтения покупателей по проектам.","Открывает: velocity-anomaly detection (issue #17), elasticity per-flat-history (Tier 4 точнее regression × лет), competitor-360 (issue #19), mortgage-rate sensitivity (issue #20).","ОГРАНИЧЕНИЯ ТАРИФА: 2 из 4 канонических отчётов возвращают HTTP 500 'An unexpected error occurred' — Сводные/Лоты и Поквартирные/Корпуса. Не блокер: оба нужных получаем.","СТАТУС: код готов и протестирован локально (dry-run). Не применено: schema 68 на проде (mcp postgres был на svc_mosgas — нужно вручную через SSH-tunnel localhost:15432). OBJECTIVE_API_KEY в backend/.env прода не прописан.","Файлы: backend/app/services/scrapers/objective.py (ObjectiveClient) · backend/app/workers/tasks/scrape_objective.py (Celery task с inline-парсингом) · backend/app/workers/celery_app.py (beat schedule) · data/sql/68_schema_objective.sql v2 · data/sql/69_objective_smoke.py · data/sql/69b_objective_minimal.py · data/sql/70_parse_objective_raw.py."]} +{"type":"entity","name":"Module_Objective_Client","entityType":"code_module","observations":["backend/app/services/scrapers/objective.py — HTTP-клиент к api.objctv.ru.","Auth: GET /Users/User/GetToken?apiKey= → {token: '...'} (~30 мин TTL). Bearer на /v2/Report/GetReport.","Кеширование токена: Redis (ключ 'objective:bearer_token', TTL settings.objective_token_ttl_seconds=25*60) + in-memory fallback _TOKEN_MEMORY_CACHE (когда Redis недоступен — локальные скрипты, сетевой сбой). Лог про Redis печатается ОДИН раз на инстанс (флаг _redis_warned), не спамит.","Rate-limiting: _DEFAULT_RATE_MS=500мс между запросами. Ретраи: 3 попытки на 401/429/502/503/504. Backoff на 429: 30, 60, 120, 240с (потолок 300с). Слушает Retry-After header если присутствует.","401 → один раз форс-рефреш токена; если повтор — ObjectiveAuthError. 429/5xx → exponential backoff. Брotli accept по дефолту в Accept-Encoding (их рекомендация).","Публичные методы: report_corpuses_summary, report_lots_summary, report_corpuses_per_flat, report_lots_per_flat. Универсальный get_report() с явными именованными параметрами (report_section, report_type, report_name, group_name, complex_name, start_date, end_date, use_ddu, use_dkp, page='Отчеты', v2=True).","Date format Объектива: YYYY.MM.DD (точка-разделитель!) — _fmt_date конвертит из date/str.","fetch_with_raw_log() — one-shot helper для smoke-сценариев и backfill: вызывает (**kwargs), сохраняет raw JSON в файл если save_dir."]} +{"type":"entity","name":"Module_Objective_Parser","entityType":"code_module","observations":["data/sql/70_parse_objective_raw.py — парсер raw → normalized слой Объектива.","Режимы: --file-corp-sum/--file-lots-pf (локальные JSON, smoke без БД, --dry-run) | --raw-id N (один объект из objective_raw_reports) | --latest-run (все unparsed raw из последнего objective_scrape_runs.run_id).","Эвристика detect_kind() по полям первого row payload: 'corp_sum' если ['Месяц','Корпус','Объем предложения, шт.'] · 'lots_pf' если ['Id','Номер лота','Площадь, м2(ПД)']. None → skip с warning.","Helpers: _parse_ru_month('апрель-2026' → date(2026,4,1)) · _parse_iso_date · _to_int/_to_num · _normalize_rooms('студия'→0, '1'→1, …'5+'→5) · _parse_pct('17%'→17) · _parse_bool_yes('да'/'нет' → True/False).","parse_corp_sum: маппит ~30 полей → INSERT в objective_corpus_room_month с ON CONFLICT (report_month, group_name, project_name, corpus_name, room_bucket) DO UPDATE (right move — отчёт за месяц уточняется неделя-к-неделе пока месяц не закрыт).","parse_lots_pf: маппит per-flat → INSERT в objective_lots с ON CONFLICT (objective_lot_id) DO UPDATE + INSERT в objective_lots_history с ON CONFLICT (objective_lot_id, snapshot_date) DO NOTHING (один снимок per день — повторный sync в тот же день не создаёт дубль).","Late-import используется в Celery-task: importlib.util.spec_from_file_location → exec_module. Это позволяет inline-парсинг сразу после save_raw без копирования кода в backend.","Smoke 07.05 на локальных файлах: corp_sum 8 rows + lots_pf 563 lots / 563 history rows — все отработали без warning'ов про обязательные поля."]} +{"type":"entity","name":"Schema_Objective_v2_May07","entityType":"infrastructure","observations":["data/sql/68_schema_objective.sql v2 (07.05.2026) — ВЫРОВНЕНА по реальным полям payload-ов вместо изначальных гипотез.","Таблицы (6): objective_scrape_runs (журнал) · objective_raw_reports (jsonb payload) · objective_corpus_room_month (long-формат, заменяет старую objective_oks) · objective_lots (UPSERT по objective_lot_id) · objective_lots_history (append-only weekly snapshots) · objective_complex_mapping (Objective.ComplexName ↔ domrf_kn_objects.obj_id с is_reviewed).","objective_corpus_room_month: UNIQUE (report_month, group_name, project_name, corpus_name, room_bucket). Колонки: capacity по ПД (lots_pd_count/area), сделки ДДУ/ДКП/всего (по 7 метрик каждое), offer (count/area/sum_mln_rub/avg_price_thousand_rub_per_m2). Ключ инсайт: одна строка = (месяц × корпус × room_bucket) — long-формат, не per-corpus как было задумано в v1.","objective_lots: UNIQUE (objective_lot_id). Поля per-flat: 3 источника комнат (rooms_dev_site/rooms_pd/rooms_objective + нормализованный rooms_int), 2 площади (dev_site/pd), полный pricing (calculated_total/per_m2/offer_total/delta_rub/delta_pct + методы и даты), полный deal (contract/registration_date/contract_type/buyer_type/registration_number), encumbrance/mortgage (encumbrance_type/bank_name/encumbrance_start_date/egrn_actual_date).","objective_lots_history: UNIQUE (objective_lot_id, snapshot_date) — сами строим weekly timeline через INSERT при каждом sync (Объектив не отдаёт per-day price/status timeline в одном вызове, поэтому накапливаем сами). Для elasticity (точность × лет) и velocity-anomaly detection.","objective_complex_mapping: связь Objective.ComplexName/objective_project_id ↔ domrf_kn_objects.obj_id. is_reviewed=TRUE — человек подтвердил соответствие.","Размеры (оценка для Свердл.обл): corpus_room_month ~5K rows/мес (~250 ЖК × 5 корпусов × 5 mix × 1 мес), lots ~50K rows (~250 ЖК × 200 квартир), lots_history +50K rows/нед.","Применять: psql через SSH-tunnel localhost:15432 (mcp postgres был воткнут не в ту БД 27.04 → ручной apply). Идемпотентно через IF NOT EXISTS."]} +{"type":"entity","name":"Objective_API_Findings_May07","entityType":"research","observations":["Дата: 07.05.2026. Эмпирические находки об api.objctv.ru через локальные пробы (data/sql/69b_objective_minimal.py).","ENDPOINTS: GetToken на /Users/User/GetToken?apiKey= → JSON {token: '...'}. GetReport на /v2/Report/GetReport?Page=Отчеты&ReportSection=Объединенные данные&ReportType=...&ReportName=...&GroupName=...&[ComplexName]&[StartDate]&[EndDate]&[UseDdu]&[UseDkp]. Auth: Authorization: Bearer . Date format: YYYY.MM.DD (точка-разделитель).","ФОРМАТ ИМЕНИ ЖК: ComplexName требует БЕЗ префикса 'ЖК' и БЕЗ кавычек. Пример: 'Парк Культуры' → 200 OK, 'ЖК \"Парк Культуры\"' → HTTP 500. Проверено 07.05 на 2 запросах подряд.","PAYLOAD ОБЁРНУТ В {result: [...]}, не голый список — все ответы такие.","ДОСТУПНЫЕ НА ТАРИФЕ ОТЧЁТЫ (2 из 4): ✅ Сводные/Корпуса (per месяц × корпус × room_bucket) · ✅ Поквартирные/Лоты (per-flat snapshot с Id-stable). HTTP 500 'An unexpected error occurred' на: Сводные/Лоты, Поквартирные/Корпуса. Гипотезы: тариф / косяк бэка / неправильные параметры (попробовать без ComplexName, попробовать с/без StartDate).","RATE-LIMIT: жёсткий — 500мс между запросами недостаточно, нужен ~3000мс + пауза 15с между отчётами (default в скрипте 69b после фиксов). Иначе HTTP 429 на 2-м запросе. Backoff на 429 респектит Retry-After header.","ОТКЛИК: первый запрос отчёта может занимать 18-52 секунды (отчёт реально считается на бэке Объектива). Возможно у них лимит 1 concurrent request — с маленьким inter-delay прилетает 429.","REDIS LOCAL: на локальной машине Redis недоступен (редис только в docker compose) → in-memory fallback токена в ObjectiveClient критичен.","ПОЛЯ ИЗ corp_sum (Парк Культуры): Месяц (текст 'апрель-2026'), Проект, Девелопер ('PRINZIP'), Район ('Автовокзал'), Класс ('Комфорт'), Корпус ('1.1'), 'Количество комнат (Данные Объектива)' (студия|1|2|3), 'Лотов по ПД, шт.', 'Площадь по ПД, м2.', 7 метрик ДДУ × 7 метрик ДКП × 7 метрик 'всего' (count, count_priced, vol, vol_priced, sum_priced_млн_Р, avg_price_тыс_Р/м2, avg_area_м2), Объем предложения шт/м2, Суммарная стоимость лотов в продаже млн.Р, Средняя цена м2 лота в продаже тыс.Р/м2. NB: цены в ТЫС.Р/м2, не Р/м2; стоимости в МЛН.Р.","ПОЛЯ ИЗ lots_pf (per-flat, 563 квартир в Парк Культуры): Id (stable Objective lot_id, 1965382), 'Id проекта' (3802), Проект/Девелопер/Город/Район/Корпус/Адрес/Класс/Секция/Этаж/Номер лота/'Вид помещения' (квартира|нежилое|паркинг|кладовая), Готовность ('17%'), 'Стадия строительства' (строящийся), 'Отделка по корпусу' (под чистовую), Статус (резерв|свободна|продан|бронь), Продано (да/нет), 3 источника комнат, 2 площади (Сайт девелопера/ПД), полный pricing block (Расчетный бюджет, Цена за м2, Цена предложения, Дельта Р/%, Способ определения цены, Дата установки/актуальности цены), полный deal block (Дата договора, Дата регистрации, Тип сделки ДДУ|ДКП, Тип покупателя, Номер регистрации), encumbrance (Тип обременения, Банк, Дата начала обременения, Дата актуальности данных из ЕГРН).","ИНСАЙТ ДЛЯ РЕКОММЕНДА: Сводные/Корпуса УЖЕ даёт нам монетизированную monthly history per (corpus, room_bucket) — не надо строить из sale_graph + median area. Это подменяет половину recommend_mix без всяких регрессий.","ИНСАЙТ ДЛЯ ИПОТЕКИ: lots_pf.Банк = stable банковская атрибуция per flat. За 6 мес weekly sync получим ground-truth статистику ипотечных предпочтений по проектам/девелоперам/районам Свердл.обл — нет в открытых источниках."]} +{"type":"entity","name":"Session_End_May07_2026","entityType":"event","observations":["Дата: 07.05.2026. Сессия после возвращения юзера ('я вернулся давай вспомним на чем остановились'). Continued работу над Objective integration после context compaction.","СДЕЛАНО ЗА СЕССИЮ:","1. Объяснил юзеру где остановились: scaffold Objective integration был готов (settings, ObjectiveClient, schema 68 v1, Celery task, smoke-script), но не применён и не протестирован.","2. Создал data/sql/69b_objective_minimal.py — минимальный probe (4 отчёта × 1 ЖК × 7 дней) с CLI-флагами (--complex, --no-complex, --group, --days, --only).","3. Юзер запустил 1-й раз: получили формат имени ЖК ('Парк Культуры' без префикса/кавычек) + 429 на 2-м отчёте (Redis недоступен локально → новый GetToken на каждый запрос → rate-limit).","4. Фикс in-memory fallback токена в ObjectiveClient + дефолт --complex без префикса + datetime.utcnow→timezone.aware. Запуск 2 — снова 429 (rate-limit после первого отчёта).","5. Доб --inter-delay 15s между отчётами + --rate-ms 3000ms + Retry-After header + backoff 30/60/120 в клиенте. Запуск 3 — получили 2 рабочих отчёта (corp_sum 8 строк, lots_pf 563 квартиры). 2 других вернули HTTP 500.","6. Прочитал оба payload-а — обнаружил расхождения с schema 68 v1: corp_sum в long-формате (не per-corpus), lots_pf имеет stable Id + богатые поля (банк! 3 источника комнат! полный deal/encumbrance block).","7. ПЕРЕПИСАЛ 68_schema_objective.sql v2 под реальную структуру: objective_oks → objective_corpus_room_month, расширенная objective_lots с UNIQUE objective_lot_id, append-only objective_lots_history с UNIQUE (lot_id, snapshot_date).","8. Написал data/sql/70_parse_objective_raw.py — полный парсер с эвристикой detect_kind, helpers _parse_ru_month/_normalize_rooms/etc., 3 режима (--file/--raw-id/--latest-run). Smoke на локальных файлах: 8 corp + 563 lots без warning.","9. Обновил Celery-task scrape_objective: убрал 2 нерабочих отчёта (HTTP 500 на тарифе), добавил late-import парсера 70 для inline-нормализации сразу после save_raw, обновил _heartbeat/_finish_run новыми счётчиками (rows_corpus_room/rows_lots/rows_history).","OPEN на следующую сессию: применить schema 68 v2 на проде (вручную через SSH-tunnel localhost:15432, mcp postgres до сих пор воткнут не в gendesign) · прописать OBJECTIVE_API_KEY в backend/.env прода · реальный прогон Celery-task (sync_objective_group) на проде с группой 'Екатеринбург' (без ComplexName-фильтра, по всему городу) · обновить knowledge graph (этот entity).","ПРИОРИТЕТЫ ПОСЛЕ APPLY: 1) Прогон без ComplexName на ЕКБ — сколько rows? Объём lots_pf может быть огромным (~50K квартир × ~2KB). 2) Исследовать почему Сводные/Лоты + Поквартирные/Корпуса возвращают 500 (попробовать варианты params без ComplexName / с/без StartDate). 3) Спросить саппорт Объектива.","ИНСТРУМЕНТАРИЙ ПРОВЕРКИ: cd backend; OBJECTIVE_API_KEY= uv run python -X utf8 ../data/sql/69b_objective_minimal.py [--no-complex --days 3 | --only corp_sum]. Сохраняет в data/raw/objective_test//.","ФИНАЛЬНЫЕ файлы изменены 07.05: backend/app/services/scrapers/objective.py · backend/app/workers/tasks/scrape_objective.py · data/sql/68_schema_objective.sql · data/sql/69b_objective_minimal.py (новый) · data/sql/70_parse_objective_raw.py (новый)."]} {"type":"relation","from":"Project_GenDesign","to":"TechStack_v2.2","relationType":"uses"} {"type":"relation","from":"Project_GenDesign","to":"NorthStar_DiscoveryMVP","relationType":"targets"} {"type":"relation","from":"Project_GenDesign","to":"Phase0_DiscoveryMVP","relationType":"currently_executes"} @@ -514,3 +654,260 @@ {"type":"relation","from":"Anton_Imported_Apr27_2026","to":"DomRF_History_Loaded_Apr27_2026","relationType":"complements_history_with_breadth"} {"type":"relation","from":"PRINZIP_Strategy_Apr27","to":"Anton_Imported_Apr27_2026","relationType":"uses_data_from"} {"type":"relation","from":"Sverdl_Quartirography_Report_Apr27","to":"Anton_Imported_Apr27_2026","relationType":"uses_data_from"} +{"type":"relation","from":"Session_End_Apr27_2026","to":"Session_End_Apr26_2026","relationType":"succeeds"} +{"type":"relation","from":"Session_End_Apr27_2026","to":"Schema_Tables_Map_Apr27","relationType":"summarizes_state_of"} +{"type":"relation","from":"Session_End_Apr27_2026","to":"Schema_Open_TODOs_Apr27","relationType":"points_to_next_actions"} +{"type":"relation","from":"Session_End_Apr27_2026","to":"PRINZIP_Strategy_Apr27","relationType":"highlights_deliverable"} +{"type":"relation","from":"Session_End_Apr27_2026","to":"Sverdl_Quartirography_Report_Apr27","relationType":"highlights_deliverable"} +{"type":"relation","from":"Session_End_Apr27_2026","to":"DomRF_Lazy_Loaded_Apr27_2026","relationType":"completed_milestone"} +{"type":"relation","from":"Session_End_Apr27_2026","to":"DomRF_History_Loaded_Apr27_2026","relationType":"completed_milestone"} +{"type":"relation","from":"Session_End_Apr27_2026","to":"Anton_Imported_Apr27_2026","relationType":"completed_milestone"} +{"type":"relation","from":"Analytics_Dashboard_Apr27","to":"PRINZIP_Strategy_Apr27","relationType":"surfaces_in_ui"} +{"type":"relation","from":"Analytics_Dashboard_Apr27","to":"Sverdl_Quartirography_Report_Apr27","relationType":"surfaces_in_ui"} +{"type":"relation","from":"Analytics_Dashboard_Apr27","to":"Schema_Open_TODOs_Apr27","relationType":"partially_resolves"} +{"type":"relation","from":"Analytics_Dashboard_Apr27","to":"Schema_Tables_Map_Apr27","relationType":"consumes_schema_from"} +{"type":"relation","from":"Session_End_Apr27_2026","to":"Analytics_Dashboard_Apr27","relationType":"spawned_followup"} +{"type":"relation","from":"DomRf_Kn_Scraper_Apr27","to":"Schema_Open_TODOs_Apr27","relationType":"resolves_todo_1"} +{"type":"relation","from":"DomRf_Kn_Scraper_Apr27","to":"PRINZIP_Strategy_Apr27","relationType":"unblocks_analysis"} +{"type":"relation","from":"DomRf_Kn_Scraper_Apr27","to":"Analytics_Dashboard_Apr27","relationType":"feeds_data_to"} +{"type":"relation","from":"Session_End_Apr27_2026","to":"DomRf_Kn_Scraper_Apr27","relationType":"spawned_followup"} +{"type":"relation","from":"Prinzip_CRM_Pipeline_Apr27","to":"DomRf_Kn_Scraper_Apr27","relationType":"complements_data_layer"} +{"type":"relation","from":"Prinzip_CRM_Pipeline_Apr27","to":"PRINZIP_Strategy_Apr27","relationType":"unblocks_conversion_analysis"} +{"type":"relation","from":"Route_Home","to":"Route_Analytics_Sverdl","relationType":"links_to"} +{"type":"relation","from":"Route_Home","to":"Route_Analytics_Prinzip","relationType":"links_to"} +{"type":"relation","from":"Route_Home","to":"Route_Analytics_Developers","relationType":"links_to"} +{"type":"relation","from":"Route_Home","to":"Route_Analytics_Object","relationType":"links_to"} +{"type":"relation","from":"Route_Home","to":"Route_Admin_Scrape","relationType":"links_to"} +{"type":"relation","from":"Route_Home","to":"Route_Admin_Leads","relationType":"links_to"} +{"type":"relation","from":"Route_Analytics_Prinzip","to":"Route_Analytics_Object","relationType":"drilldowns_into"} +{"type":"relation","from":"Route_Analytics_Developers","to":"Route_Analytics_Object","relationType":"drilldowns_into"} +{"type":"relation","from":"Route_Admin_Leads","to":"Route_Analytics_Object","relationType":"drilldowns_into"} +{"type":"relation","from":"Route_Analytics_Object","to":"Endpoint_Analytics_Object","relationType":"consumes"} +{"type":"relation","from":"Route_Analytics_Prinzip","to":"Endpoint_Analytics_Object","relationType":"consumes"} +{"type":"relation","from":"Route_Admin_Scrape","to":"Endpoint_Admin_Scrape","relationType":"consumes"} +{"type":"relation","from":"Route_Admin_Leads","to":"Endpoint_Admin_Leads","relationType":"consumes"} +{"type":"relation","from":"Route_Admin_Scrape","to":"Auth_Admin_Token","relationType":"protected_by"} +{"type":"relation","from":"Route_Admin_Leads","to":"Auth_Admin_Token","relationType":"protected_by"} +{"type":"relation","from":"Endpoint_Admin_Scrape","to":"Auth_Admin_Token","relationType":"validates"} +{"type":"relation","from":"Endpoint_Admin_Leads","to":"Auth_Admin_Token","relationType":"validates"} +{"type":"relation","from":"Endpoint_Admin_Scrape","to":"Module_Celery_App","relationType":"delegates_to"} +{"type":"relation","from":"Module_Celery_App","to":"Module_Scraper_Kn","relationType":"invokes"} +{"type":"relation","from":"Module_Scraper_Kn","to":"Module_Scraper_Stealth","relationType":"uses"} +{"type":"relation","from":"Module_Scraper_Stealth","to":"Stealth_Fingerprint_State","relationType":"loads"} +{"type":"relation","from":"Module_Scraper_Kn","to":"DomRf_Kn_Scraper_Apr27","relationType":"implements"} +{"type":"relation","from":"Endpoint_Admin_Leads","to":"PII_Compliance_152FZ","relationType":"enforces"} +{"type":"relation","from":"Prinzip_CRM_Pipeline_Apr27","to":"PII_Compliance_152FZ","relationType":"enforces"} +{"type":"relation","from":"Prinzip_CRM_Pipeline_Apr27","to":"Endpoint_Admin_Leads","relationType":"exposed_via"} +{"type":"relation","from":"Prinzip_CRM_Pipeline_Apr27","to":"Route_Admin_Leads","relationType":"rendered_in"} +{"type":"relation","from":"Docker_Multi_Target","to":"Module_Scraper_Stealth","relationType":"ships_chromium_for"} +{"type":"relation","from":"Docker_Multi_Target","to":"Module_Celery_App","relationType":"runs_worker_image"} +{"type":"relation","from":"Docker_Auto_Cleanup","to":"Docker_Multi_Target","relationType":"maintains"} +{"type":"relation","from":"GitHub_Issue_13_Closed","to":"Module_Scraper_Kn","relationType":"depends_on"} +{"type":"relation","from":"GitHub_Issue_14_Closed","to":"Prinzip_CRM_Pipeline_Apr27","relationType":"delivered_by"} +{"type":"relation","from":"GitHub_Issue_15_Closed","to":"Endpoint_Analytics_Object","relationType":"delivered_by"} +{"type":"relation","from":"GitHub_Issue_15_Closed","to":"Route_Analytics_Object","relationType":"delivered_by"} +{"type":"relation","from":"GitHub_Issue_16_Closed","to":"Docker_Multi_Target","relationType":"delivered_by"} +{"type":"relation","from":"Analytics_Dashboard_Apr27","to":"Route_Analytics_Sverdl","relationType":"rendered_as"} +{"type":"relation","from":"Analytics_Dashboard_Apr27","to":"Route_Analytics_Prinzip","relationType":"rendered_as"} +{"type":"relation","from":"Analytics_Dashboard_Apr27","to":"Route_Analytics_Developers","relationType":"rendered_as"} +{"type":"relation","from":"DomRf_Kn_Scraper_Apr27","to":"Module_Scraper_Kn","relationType":"code_at"} +{"type":"relation","from":"DomRf_Kn_Scraper_Apr27","to":"Module_Scraper_Stealth","relationType":"code_at"} +{"type":"relation","from":"DomRf_Kn_Scraper_Apr27","to":"Module_Celery_App","relationType":"scheduled_via"} +{"type":"relation","from":"DomRf_Kn_Scraper_Apr27","to":"Stealth_Fingerprint_State","relationType":"uses_cookies_from"} +{"type":"relation","from":"Endpoint_Recommend_Mix","to":"Schema_Recommend_IO","relationType":"validates_with"} +{"type":"relation","from":"Endpoint_Recommend_Mix","to":"Module_Recommend_Query","relationType":"calls"} +{"type":"relation","from":"Module_Recommend_Query","to":"Module_Bucket_Distribution","relationType":"uses"} +{"type":"relation","from":"Module_Recommend_Query","to":"Window_Auto_Extend_24mo","relationType":"applies"} +{"type":"relation","from":"Module_Recommend_Query","to":"Class_Multiplier_Yandex","relationType":"applies"} +{"type":"relation","from":"Module_Bucket_Distribution","to":"Quartirography_Filter_Fix","relationType":"applies"} +{"type":"relation","from":"Module_Recommend_Query","to":"Limit_District_Median_Null","relationType":"limited_by"} +{"type":"relation","from":"Module_Recommend_Query","to":"Limit_District_Geometry","relationType":"limited_by"} +{"type":"relation","from":"Recommend_Level1_Apr27","to":"Endpoint_Recommend_Mix","relationType":"exposed_via"} +{"type":"relation","from":"Recommend_Level1_Apr27","to":"Module_Recommend_Query","relationType":"implemented_by"} +{"type":"relation","from":"Recommend_Level1_Apr27","to":"Route_Analytics_Recommend","relationType":"rendered_at"} +{"type":"relation","from":"Route_Analytics_Recommend","to":"Endpoint_Recommend_Mix","relationType":"consumes"} +{"type":"relation","from":"Route_Analytics_Recommend","to":"Component_Recommend_Form","relationType":"renders"} +{"type":"relation","from":"Route_Analytics_Recommend","to":"Component_Recommend_Sliders","relationType":"renders"} +{"type":"relation","from":"Route_Analytics_Recommend","to":"Component_Recommend_Charts","relationType":"renders"} +{"type":"relation","from":"Route_Analytics_Recommend","to":"Component_Recommend_Comparables","relationType":"renders"} +{"type":"relation","from":"Component_Recommend_Form","to":"Hook_useRecommendMix","relationType":"uses"} +{"type":"relation","from":"Hook_useRecommendMix","to":"Endpoint_Recommend_Mix","relationType":"calls"} +{"type":"relation","from":"Component_Recommend_Sliders","to":"Component_Recommend_Charts","relationType":"drives_recompute_of"} +{"type":"relation","from":"Component_Recommend_Comparables","to":"Route_Analytics_Object","relationType":"drilldowns_into"} +{"type":"relation","from":"Route_Home","to":"Route_Analytics_Recommend","relationType":"links_to"} +{"type":"relation","from":"Recommend_Level1_Apr27","to":"Schema_Open_TODOs_Apr27","relationType":"partially_resolves"} +{"type":"relation","from":"Endpoint_Photos_Serve","to":"Module_Photo_Thumbs","relationType":"uses"} +{"type":"relation","from":"Endpoint_Photos_Serve","to":"Photo_Lazy_Fetch","relationType":"applies"} +{"type":"relation","from":"Endpoint_Photos_Serve","to":"Schema_Photo_Thumb_Path","relationType":"reads_from"} +{"type":"relation","from":"Module_Photo_Thumbs","to":"Module_Scraper_Kn","relationType":"invoked_by"} +{"type":"relation","from":"Photo_Storage_Pipeline","to":"Endpoint_Photos_Serve","relationType":"exposed_via"} +{"type":"relation","from":"Photo_Storage_Pipeline","to":"Module_Photo_Thumbs","relationType":"uses"} +{"type":"relation","from":"Photo_Storage_Pipeline","to":"Schema_Photo_Thumb_Path","relationType":"requires"} +{"type":"relation","from":"Photo_Storage_Pipeline","to":"Photo_Lazy_Fetch","relationType":"uses"} +{"type":"relation","from":"Route_Analytics_Object","to":"Endpoint_Photos_Serve","relationType":"consumes"} +{"type":"relation","from":"Endpoint_Admin_Scrape","to":"Queue_Optimised_DB","relationType":"applies"} +{"type":"relation","from":"Endpoint_Admin_Scrape","to":"Zombie_Autocleanup","relationType":"applies"} +{"type":"relation","from":"Module_Celery_App","to":"Worker_Ready_Hook","relationType":"registers"} +{"type":"relation","from":"Module_Scraper_Kn","to":"Redis_Singleton_Lock","relationType":"uses"} +{"type":"relation","from":"Task_Resume_Kn_Run","to":"Redis_Singleton_Lock","relationType":"uses"} +{"type":"relation","from":"Task_Resume_Kn_Run","to":"Module_Scraper_Kn","relationType":"invokes"} +{"type":"relation","from":"Worker_Ready_Hook","to":"Task_Resume_Kn_Run","relationType":"enqueues"} +{"type":"relation","from":"Worker_Ready_Hook","to":"Schema_Resume_Columns","relationType":"reads_from"} +{"type":"relation","from":"Resume_Checkpoint_Mechanism","to":"Schema_Resume_Columns","relationType":"writes_to"} +{"type":"relation","from":"Resume_Checkpoint_Mechanism","to":"Worker_Ready_Hook","relationType":"triggered_by"} +{"type":"relation","from":"Resume_Checkpoint_Mechanism","to":"Task_Resume_Kn_Run","relationType":"executed_via"} +{"type":"relation","from":"Resume_Checkpoint_Mechanism","to":"Module_Scraper_Kn","relationType":"embedded_in"} +{"type":"relation","from":"Module_Scraper_Kn","to":"Module_Scrape_Log","relationType":"emits_via"} +{"type":"relation","from":"Module_Scrape_Log","to":"Schema_Scrape_Log_Table","relationType":"writes_to"} +{"type":"relation","from":"Endpoint_Scrape_Logs","to":"Schema_Scrape_Log_Table","relationType":"reads_from"} +{"type":"relation","from":"Endpoint_Scrape_Logs","to":"Auth_Admin_Token","relationType":"validates_with"} +{"type":"relation","from":"UI_Scrape_Progress_Panel","to":"Endpoint_Scrape_Logs","relationType":"consumes"} +{"type":"relation","from":"Route_Admin_Scrape","to":"UI_Scrape_Progress_Panel","relationType":"renders"} +{"type":"relation","from":"Bug_Worker_SIGKILL_Lost_Run_Fixed","to":"Resume_Checkpoint_Mechanism","relationType":"resolved_by"} +{"type":"relation","from":"Bug_Worker_SIGKILL_Lost_Run_Fixed","to":"Worker_Ready_Hook","relationType":"resolved_by"} +{"type":"relation","from":"Bug_Beat_Double_Fire_Fixed","to":"Redis_Singleton_Lock","relationType":"resolved_by"} +{"type":"relation","from":"Bug_Inspect_Slow_Queue_Fixed","to":"Queue_Optimised_DB","relationType":"resolved_by"} +{"type":"relation","from":"Bug_RealEstate_Outliers_Fixed","to":"Quartirography_Filter_Fix","relationType":"resolved_by"} +{"type":"relation","from":"Session_Resume_Recommend_Apr28_2026","to":"Session_End_Apr27_2026","relationType":"follows"} +{"type":"relation","from":"Session_Resume_Recommend_Apr28_2026","to":"Recommend_Level1_Apr27","relationType":"highlights_deliverable"} +{"type":"relation","from":"Session_Resume_Recommend_Apr28_2026","to":"Photo_Storage_Pipeline","relationType":"highlights_deliverable"} +{"type":"relation","from":"Session_Resume_Recommend_Apr28_2026","to":"Resume_Checkpoint_Mechanism","relationType":"highlights_deliverable"} +{"type":"relation","from":"Session_Resume_Recommend_Apr28_2026","to":"UI_Scrape_Progress_Panel","relationType":"highlights_deliverable"} +{"type":"relation","from":"Session_Resume_Recommend_Apr28_2026","to":"Bug_Worker_SIGKILL_Lost_Run_Fixed","relationType":"closes"} +{"type":"relation","from":"Session_Resume_Recommend_Apr28_2026","to":"Bug_Beat_Double_Fire_Fixed","relationType":"closes"} +{"type":"relation","from":"Session_Resume_Recommend_Apr28_2026","to":"Bug_Inspect_Slow_Queue_Fixed","relationType":"closes"} +{"type":"relation","from":"Session_Resume_Recommend_Apr28_2026","to":"Bug_RealEstate_Outliers_Fixed","relationType":"closes"} +{"type":"relation","from":"Session_Resume_Recommend_Apr28_2026","to":"Limit_District_Median_Null","relationType":"opens_todo"} +{"type":"relation","from":"Session_Resume_Recommend_Apr28_2026","to":"Limit_District_Geometry","relationType":"opens_todo"} +{"type":"relation","from":"Session_Resume_Recommend_Apr28_2026","to":"Schema_Tables_Map_Apr27","relationType":"extends"} +{"type":"relation","from":"Module_Recommend_Query","to":"Sverdl_Quartirography_Report_Apr27","relationType":"operationalizes"} +{"type":"relation","from":"Module_Recommend_Query","to":"PRINZIP_Strategy_Apr27","relationType":"operationalizes"} +{"type":"relation","from":"Bug_SQLAlchemy_DoubleColon_Cast","to":"Pattern_CAST_AS_Type","relationType":"resolved_by"} +{"type":"relation","from":"Bug_Recommend_500_CAST_Fixed","to":"Bug_SQLAlchemy_DoubleColon_Cast","relationType":"instance_of"} +{"type":"relation","from":"Bug_Recommend_500_CAST_Fixed","to":"Pattern_CAST_AS_Type","relationType":"resolved_by"} +{"type":"relation","from":"Bug_Phase_A_INSERT_500_Fixed","to":"Bug_SQLAlchemy_DoubleColon_Cast","relationType":"instance_of"} +{"type":"relation","from":"Bug_Phase_A_INSERT_500_Fixed","to":"Pattern_CAST_AS_Type","relationType":"resolved_by"} +{"type":"relation","from":"Bug_Phase_A_INSERT_500_Fixed","to":"Resume_Checkpoint_Mechanism","relationType":"blocked"} +{"type":"relation","from":"Bug_Phase_A_INSERT_500_Fixed","to":"Module_Scrape_Log","relationType":"diagnosed_via"} +{"type":"relation","from":"Recommend_Velocity_Calculator_Apr28","to":"Recommend_Level1_Apr27","relationType":"extends"} +{"type":"relation","from":"Recommend_Velocity_Calculator_Apr28","to":"Module_Velocity_Baseline","relationType":"uses"} +{"type":"relation","from":"Recommend_Velocity_Calculator_Apr28","to":"Module_Elasticity_Coef","relationType":"uses"} +{"type":"relation","from":"Recommend_Velocity_Calculator_Apr28","to":"Module_Required_Price_Factor","relationType":"uses"} +{"type":"relation","from":"Recommend_Velocity_Calculator_Apr28","to":"Module_Liquidity_Score_24mo","relationType":"uses"} +{"type":"relation","from":"Recommend_Velocity_Calculator_Apr28","to":"Component_Recommend_Velocity_Panel","relationType":"rendered_by"} +{"type":"relation","from":"Recommend_Velocity_Calculator_Apr28","to":"Component_Recommend_Liquidity_Chart","relationType":"rendered_by"} +{"type":"relation","from":"Recommend_Velocity_Calculator_Apr28","to":"Feature_Headline_CEO","relationType":"produces"} +{"type":"relation","from":"Module_Velocity_Baseline","to":"Module_Recommend_Query","relationType":"called_by"} +{"type":"relation","from":"Module_Elasticity_Coef","to":"Module_Recommend_Query","relationType":"called_by"} +{"type":"relation","from":"Module_Required_Price_Factor","to":"Module_Recommend_Query","relationType":"embedded_in"} +{"type":"relation","from":"Module_Liquidity_Score_24mo","to":"Module_Recommend_Query","relationType":"embedded_in"} +{"type":"relation","from":"Module_Liquidity_Score_24mo","to":"Component_Recommend_Velocity_Panel","relationType":"duplicated_in"} +{"type":"relation","from":"Component_Recommend_Velocity_Panel","to":"Route_Analytics_Recommend","relationType":"rendered_at"} +{"type":"relation","from":"Component_Recommend_Liquidity_Chart","to":"Route_Analytics_Recommend","relationType":"rendered_at"} +{"type":"relation","from":"Feature_Headline_CEO","to":"Route_Analytics_Recommend","relationType":"rendered_at"} +{"type":"relation","from":"Bug_Velocity_Mix_Static_OPEN","to":"Tier_Alpha_Velocity_Overhaul_TODO","relationType":"resolved_by"} +{"type":"relation","from":"Bug_Velocity_Unrealistic_OPEN","to":"Tier_Alpha_Velocity_Overhaul_TODO","relationType":"resolved_by"} +{"type":"relation","from":"Bug_Velocity_Mix_Static_OPEN","to":"Module_Velocity_Baseline","relationType":"located_in"} +{"type":"relation","from":"Bug_Velocity_Unrealistic_OPEN","to":"Module_Velocity_Baseline","relationType":"located_in"} +{"type":"relation","from":"Tier_Alpha_Velocity_Overhaul_TODO","to":"Recommend_Velocity_Calculator_Apr28","relationType":"improves"} +{"type":"relation","from":"Bug_Lock_Held_Silent_Fixed","to":"TTL_Lock_Reduction","relationType":"resolved_by"} +{"type":"relation","from":"Bug_Lock_Held_Silent_Fixed","to":"Module_Lock_Diagnostic_Logs","relationType":"resolved_by"} +{"type":"relation","from":"Bug_Lock_Held_Silent_Fixed","to":"Endpoint_Release_Lock","relationType":"resolved_by"} +{"type":"relation","from":"Bug_Lock_Held_Silent_Fixed","to":"Param_Force_Trigger","relationType":"resolved_by"} +{"type":"relation","from":"Endpoint_Release_Lock","to":"Redis_Singleton_Lock","relationType":"operates_on"} +{"type":"relation","from":"Param_Force_Trigger","to":"Redis_Singleton_Lock","relationType":"manipulates"} +{"type":"relation","from":"Param_Force_Trigger","to":"Endpoint_Admin_Scrape","relationType":"extends"} +{"type":"relation","from":"Module_Lock_Diagnostic_Logs","to":"Module_Scrape_Log","relationType":"complements"} +{"type":"relation","from":"Module_Lock_Diagnostic_Logs","to":"UI_Scrape_Progress_Panel","relationType":"surfaces_in"} +{"type":"relation","from":"TTL_Lock_Reduction","to":"Redis_Singleton_Lock","relationType":"tunes"} +{"type":"relation","from":"Phase_A_Heartbeat_Tick","to":"Resume_Checkpoint_Mechanism","relationType":"extends"} +{"type":"relation","from":"Phase_A_Heartbeat_Tick","to":"Schema_Resume_Columns","relationType":"writes_to"} +{"type":"relation","from":"Autoclean_Heartbeat_Threshold","to":"Zombie_Autocleanup","relationType":"supersedes"} +{"type":"relation","from":"Autoclean_Heartbeat_Threshold","to":"Schema_Resume_Columns","relationType":"reads_from"} +{"type":"relation","from":"Endpoint_Release_Lock","to":"Auth_Admin_Token","relationType":"validates_with"} +{"type":"relation","from":"Deploy_Cache_State_Apr28","to":"Docker_Multi_Target","relationType":"optimizes"} +{"type":"relation","from":"Session_Apr28_Calc_Scraper_Apr28_2026","to":"Session_Resume_Recommend_Apr28_2026","relationType":"follows"} +{"type":"relation","from":"Session_Apr28_Calc_Scraper_Apr28_2026","to":"Recommend_Velocity_Calculator_Apr28","relationType":"highlights_deliverable"} +{"type":"relation","from":"Session_Apr28_Calc_Scraper_Apr28_2026","to":"Bug_Phase_A_INSERT_500_Fixed","relationType":"closes"} +{"type":"relation","from":"Session_Apr28_Calc_Scraper_Apr28_2026","to":"Bug_Recommend_500_CAST_Fixed","relationType":"closes"} +{"type":"relation","from":"Session_Apr28_Calc_Scraper_Apr28_2026","to":"Bug_Lock_Held_Silent_Fixed","relationType":"closes"} +{"type":"relation","from":"Session_Apr28_Calc_Scraper_Apr28_2026","to":"Bug_Velocity_Mix_Static_OPEN","relationType":"opens_todo"} +{"type":"relation","from":"Session_Apr28_Calc_Scraper_Apr28_2026","to":"Bug_Velocity_Unrealistic_OPEN","relationType":"opens_todo"} +{"type":"relation","from":"Session_Apr28_Calc_Scraper_Apr28_2026","to":"Tier_Alpha_Velocity_Overhaul_TODO","relationType":"opens_todo"} +{"type":"relation","from":"Session_Apr28_Calc_Scraper_Apr28_2026","to":"Deploy_Cache_State_Apr28","relationType":"highlights_deliverable"} +{"type":"relation","from":"Session_Apr28_Calc_Scraper_Apr28_2026","to":"Bug_SQLAlchemy_DoubleColon_Cast","relationType":"discovers"} +{"type":"relation","from":"Session_Apr28_Calc_Scraper_Apr28_2026","to":"Pattern_CAST_AS_Type","relationType":"establishes"} +{"type":"relation","from":"Geo_District_Mapping_Apr29","to":"Module_Fetch_Ekb_Districts","relationType":"uses"} +{"type":"relation","from":"Geo_District_Mapping_Apr29","to":"Schema_Ekb_Districts_Geom","relationType":"requires"} +{"type":"relation","from":"Geo_District_Mapping_Apr29","to":"Schema_Kn_Objects_District_Column","relationType":"requires"} +{"type":"relation","from":"Geo_District_Mapping_Apr29","to":"File_Ekb_Districts_Geojson_Cache","relationType":"produces"} +{"type":"relation","from":"Geo_District_Mapping_Apr29","to":"Module_Backfill_District_Names_Scraper","relationType":"extends_by"} +{"type":"relation","from":"Geo_District_Mapping_Apr29","to":"Pattern_OSM_Overpass_Admin_Level","relationType":"applies"} +{"type":"relation","from":"Module_Fetch_Ekb_Districts","to":"File_Ekb_Districts_Geojson_Cache","relationType":"writes_to"} +{"type":"relation","from":"Module_Fetch_Ekb_Districts","to":"Schema_Ekb_Districts_Geom","relationType":"writes_to"} +{"type":"relation","from":"Module_Fetch_Ekb_Districts","to":"Pattern_OSM_Overpass_Admin_Level","relationType":"applies"} +{"type":"relation","from":"Module_Backfill_District_Names_Scraper","to":"Schema_Ekb_Districts_Geom","relationType":"reads_from"} +{"type":"relation","from":"Module_Backfill_District_Names_Scraper","to":"Schema_Kn_Objects_District_Column","relationType":"writes_to"} +{"type":"relation","from":"Module_Backfill_District_Names_Scraper","to":"Module_Scraper_Kn","relationType":"embedded_in"} +{"type":"relation","from":"Module_Backfill_District_Names_Scraper","to":"Resume_Checkpoint_Mechanism","relationType":"runs_after"} +{"type":"relation","from":"Bug_Recommend_Addr_ILIKE_Returns_Zero_Fixed","to":"Geo_District_Mapping_Apr29","relationType":"resolved_by"} +{"type":"relation","from":"Bug_Recommend_Addr_ILIKE_Returns_Zero_Fixed","to":"Schema_Kn_Objects_District_Column","relationType":"resolved_by"} +{"type":"relation","from":"Bug_Recommend_Addr_ILIKE_Returns_Zero_Fixed","to":"Module_Recommend_Query","relationType":"located_in"} +{"type":"relation","from":"Module_Graceful_Obj_Class_Degrade","to":"Bug_Kn_API_Obj_Class_Always_Null_OPEN","relationType":"workarounds"} +{"type":"relation","from":"Module_Graceful_Obj_Class_Degrade","to":"Module_Recommend_Query","relationType":"embedded_in"} +{"type":"relation","from":"Bug_Kn_API_Obj_Class_Always_Null_OPEN","to":"Module_Scraper_Kn","relationType":"located_in"} +{"type":"relation","from":"Bug_Kn_Flats_Price_Per_M2_Sparse_OPEN","to":"Module_Scraper_Kn","relationType":"located_in"} +{"type":"relation","from":"Bug_Kn_Flats_Price_Per_M2_Sparse_OPEN","to":"Limit_District_Median_Partial","relationType":"blocks_full_resolution_of"} +{"type":"relation","from":"Backfill_District_Median_From_Kn_Flats","to":"Schema_Kn_Objects_District_Column","relationType":"depends_on"} +{"type":"relation","from":"Backfill_District_Median_From_Kn_Flats","to":"Limit_District_Median_Partial","relationType":"partially_resolves"} +{"type":"relation","from":"Limit_District_Median_Partial","to":"Limit_District_Median_Null","relationType":"supersedes"} +{"type":"relation","from":"Limit_District_Geometry_Closed","to":"Limit_District_Geometry","relationType":"supersedes"} +{"type":"relation","from":"Limit_District_Geometry_Closed","to":"Geo_District_Mapping_Apr29","relationType":"resolved_by"} +{"type":"relation","from":"Component_Recommend_Page_Reorder_Apr29","to":"Route_Analytics_Recommend","relationType":"rendered_at"} +{"type":"relation","from":"Component_Recommend_District_KPI_Strip","to":"Route_Analytics_Recommend","relationType":"rendered_at"} +{"type":"relation","from":"Component_Recommend_District_KPI_Strip","to":"Schema_Kn_Objects_District_Column","relationType":"displays_data_from"} +{"type":"relation","from":"Component_Recommend_District_KPI_Strip","to":"Module_Recommend_Query","relationType":"consumes_output_of"} +{"type":"relation","from":"Cleanup_Unused_Vars_Recommend","to":"Component_Recommend_Page_Reorder_Apr29","relationType":"complements"} +{"type":"relation","from":"Pricing_Slider_Range_Expanded_Apr29","to":"Component_Recommend_Velocity_Panel","relationType":"modifies"} +{"type":"relation","from":"Pricing_Slider_Range_Expanded_Apr29","to":"Schema_Recommend_IO","relationType":"modifies"} +{"type":"relation","from":"Module_Recommend_Query","to":"Schema_Kn_Objects_District_Column","relationType":"filters_by"} +{"type":"relation","from":"Session_GeoMapping_Apr29_2026","to":"Session_Apr28_Calc_Scraper_Apr28_2026","relationType":"follows"} +{"type":"relation","from":"Session_GeoMapping_Apr29_2026","to":"Geo_District_Mapping_Apr29","relationType":"highlights_deliverable"} +{"type":"relation","from":"Session_GeoMapping_Apr29_2026","to":"Bug_Recommend_Addr_ILIKE_Returns_Zero_Fixed","relationType":"closes"} +{"type":"relation","from":"Session_GeoMapping_Apr29_2026","to":"Limit_District_Geometry_Closed","relationType":"closes"} +{"type":"relation","from":"Session_GeoMapping_Apr29_2026","to":"Limit_District_Median_Partial","relationType":"partially_closes"} +{"type":"relation","from":"Session_GeoMapping_Apr29_2026","to":"Bug_Kn_API_Obj_Class_Always_Null_OPEN","relationType":"opens_todo"} +{"type":"relation","from":"Session_GeoMapping_Apr29_2026","to":"Bug_Kn_Flats_Price_Per_M2_Sparse_OPEN","relationType":"opens_todo"} +{"type":"relation","from":"Session_GeoMapping_Apr29_2026","to":"Component_Recommend_Page_Reorder_Apr29","relationType":"highlights_deliverable"} +{"type":"relation","from":"Session_GeoMapping_Apr29_2026","to":"Component_Recommend_District_KPI_Strip","relationType":"highlights_deliverable"} +{"type":"relation","from":"Session_GeoMapping_Apr29_2026","to":"Pricing_Slider_Range_Expanded_Apr29","relationType":"highlights_deliverable"} +{"type":"relation","from":"Session_GeoMapping_Apr29_2026","to":"Backfill_District_Median_From_Kn_Flats","relationType":"highlights_deliverable"} +{"type":"relation","from":"Session_End_Apr30_2026","to":"NSPD_Scrape_Complete_Apr30","relationType":"delivered"} +{"type":"relation","from":"Session_End_Apr30_2026","to":"Cad_Buildings_Schema_v2","relationType":"delivered"} +{"type":"relation","from":"Session_End_Apr30_2026","to":"Cad_Quarter_Backfill_Apr30","relationType":"delivered"} +{"type":"relation","from":"Session_End_Apr30_2026","to":"Sprint1_Plan_May2026","relationType":"produced"} +{"type":"relation","from":"Session_End_Apr30_2026","to":"Sprint2_Stage1_Generative","relationType":"produced"} +{"type":"relation","from":"Session_End_Apr30_2026","to":"Sprint3_Stage2_SiteFinder","relationType":"produced"} +{"type":"relation","from":"NSPD_Scrape_Complete_Apr30","to":"Cad_Quarter_Scrape_Apr29","relationType":"supersedes"} +{"type":"relation","from":"NSPD_Scrape_Complete_Apr30","to":"NSPD_API_Endpoints_Reference","relationType":"uses"} +{"type":"relation","from":"NSPD_Scrape_Complete_Apr30","to":"Cad_Buildings_Schema_v2","relationType":"populates"} +{"type":"relation","from":"Cad_Quarter_Backfill_Apr30","to":"NSPD_Scrape_Complete_Apr30","relationType":"depends_on"} +{"type":"relation","from":"NSPD_Scrape_Complete_Apr30","to":"Limit_District_Median_Partial","relationType":"closes"} +{"type":"relation","from":"Sprint1_Plan_May2026","to":"NSPD_Scrape_Complete_Apr30","relationType":"industrializes"} +{"type":"relation","from":"Sprint1_Plan_May2026","to":"Open_Limitations_Apr30","relationType":"addresses"} +{"type":"relation","from":"Sprint2_Stage1_Generative","to":"GitHub_Issues_Open_Apr30","relationType":"implements"} +{"type":"relation","from":"Sprint3_Stage2_SiteFinder","to":"GitHub_Issues_Open_Apr30","relationType":"implements"} +{"type":"relation","from":"Sprint3_Stage2_SiteFinder","to":"NSPD_Scrape_Complete_Apr30","relationType":"reuses_data_from"} +{"type":"relation","from":"Excluded_Items_User_Pref","to":"GitHub_Issues_Open_Apr30","relationType":"filters"} +{"type":"relation","from":"Session_End_Apr30_2026","to":"Session_Tier2_Apr29_2026_Late","relationType":"follows"} +{"type":"relation","from":"Open_Limitations_Apr30","to":"Bug_Velocity_Region_Wide_Rosreestr_Fixed_Apr29","relationType":"succeeds"} +{"type":"relation","from":"Module_Objective_Client","to":"Objective_Integration_May07_2026","relationType":"implements"} +{"type":"relation","from":"Module_Objective_Parser","to":"Objective_Integration_May07_2026","relationType":"implements"} +{"type":"relation","from":"Schema_Objective_v2_May07","to":"Objective_Integration_May07_2026","relationType":"backs"} +{"type":"relation","from":"Objective_API_Findings_May07","to":"Schema_Objective_v2_May07","relationType":"informs"} +{"type":"relation","from":"Objective_API_Findings_May07","to":"Module_Objective_Parser","relationType":"informs"} +{"type":"relation","from":"Module_Objective_Parser","to":"Schema_Objective_v2_May07","relationType":"writes_into"} +{"type":"relation","from":"Module_Objective_Client","to":"Module_Scraper_Stealth","relationType":"alternative_to"} +{"type":"relation","from":"Session_End_May07_2026","to":"Objective_Integration_May07_2026","relationType":"produced"} +{"type":"relation","from":"Session_End_May07_2026","to":"Session_End_Apr30_2026","relationType":"succeeds"} +{"type":"relation","from":"Objective_Integration_May07_2026","to":"DomRf_Kn_Scraper_Apr27","relationType":"complements"}