ship #13-16: multi-target Dockerfile, per-object drill-in, CRM pipeline,

442-sweep instructions
#16 Dockerfile: runner (lean, 1.6 GB) + runner-with-chromium (2.9 GB).
   Compose + deploy.yml pushes 2 GHCR images. Backend saves 1.3 GB.
#15 5 backend object endpoints + /analytics/objects/[id] page (gallery,
   POI table, sale chart) + sparkline-driven PrinzipObjectsTable.
#14 prinzip_leads/deals + 2 MVs. Import script with PII-hashing &
   --inspect-only mode. 3 funnel endpoints + Sankey/Monthly charts.
#13 README section with UI/CLI/SQL for 442-object sweep.
This commit is contained in:
lekss361 2026-04-27 19:32:37 +03:00
parent 7c4197000f
commit 522ecca5c4
21 changed files with 2053 additions and 28 deletions

View file

@ -11,6 +11,7 @@ concurrency:
env: env:
IMAGE_BACKEND: ghcr.io/lekss361/gendesign-backend IMAGE_BACKEND: ghcr.io/lekss361/gendesign-backend
IMAGE_WORKER: ghcr.io/lekss361/gendesign-worker
IMAGE_FRONTEND: ghcr.io/lekss361/gendesign-frontend IMAGE_FRONTEND: ghcr.io/lekss361/gendesign-frontend
jobs: jobs:
@ -37,10 +38,11 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Build & push backend - name: Build & push backend (lean — без Chromium)
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: ./backend context: ./backend
target: runner
push: true push: true
cache-from: type=gha,scope=backend cache-from: type=gha,scope=backend
cache-to: type=gha,mode=max,scope=backend cache-to: type=gha,mode=max,scope=backend
@ -48,6 +50,18 @@ jobs:
${{ env.IMAGE_BACKEND }}:latest ${{ env.IMAGE_BACKEND }}:latest
${{ env.IMAGE_BACKEND }}:${{ steps.meta.outputs.tag }} ${{ env.IMAGE_BACKEND }}:${{ steps.meta.outputs.tag }}
- name: Build & push worker (с Chromium для Playwright)
uses: docker/build-push-action@v6
with:
context: ./backend
target: runner-with-chromium
push: true
cache-from: type=gha,scope=worker
cache-to: type=gha,mode=max,scope=worker
tags: |
${{ env.IMAGE_WORKER }}:latest
${{ env.IMAGE_WORKER }}:${{ steps.meta.outputs.tag }}
- name: Build & push frontend - name: Build & push frontend
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:

2
.gitignore vendored
View file

@ -30,6 +30,8 @@ out/
# Local data (raw CSVs from Rosreestr/etc — too large for git) # Local data (raw CSVs from Rosreestr/etc — too large for git)
data/raw/ data/raw/
data/raw/prinzip_crm.sqlite
data/raw/domrf_photos/
*.csv *.csv
*.csv.zip *.csv.zip
!backend/db/init/*.sql !backend/db/init/*.sql

View file

@ -60,9 +60,45 @@ Acceptance criteria разбиты на 3 блока (функционально
- **Веб-аналитика** (`Analytics_Dashboard_Apr27`) — 3 страницы на ECharts/React Query: `/analytics` (рынок Свердл — KPI + market-pulse + парадокс «строят vs покупают» + pipeline + treemap районов ЕКБ), `/analytics/prinzip` (drill-down PRINZIP с benchmark-velocity, gap-bar к рынку, инсайты из стратегии), `/analytics/developers` (топ-15 + scatter velocity). Backend: `/api/v1/analytics` 11 endpoints поверх `v_developer_full_metrics`. - **Веб-аналитика** (`Analytics_Dashboard_Apr27`) — 3 страницы на ECharts/React Query: `/analytics` (рынок Свердл — KPI + market-pulse + парадокс «строят vs покупают» + pipeline + treemap районов ЕКБ), `/analytics/prinzip` (drill-down PRINZIP с benchmark-velocity, gap-bar к рынку, инсайты из стратегии), `/analytics/developers` (топ-15 + scatter velocity). Backend: `/api/v1/analytics` 11 endpoints поверх `v_developer_full_metrics`.
- **DOM.РФ kn-API парсер** (`DomRf_Kn_Scraper_Apr27`) — Playwright in-context fetch (httpx бьётся об ServicePipe TLS-fingerprint, проверено), правильные endpoints `/сервисы/api/kn/object` (place=66, objStatus 0/1/2 = 442+2+1072 = **1516 ЖК Свердл, точно совпало с БД**) + `/portal-kn/api/sales/portal/table` (квартиры). Auth — `Basic MTpqd2U=` (`1:qwe`, не `jwe`!). Серверная фильтрация по `regions/developers` НЕ работает — фильтруем локально по `developer.companyGroup`. - **DOM.РФ kn-API парсер** (`DomRf_Kn_Scraper_Apr27`) — Playwright in-context fetch (httpx бьётся об ServicePipe TLS-fingerprint, проверено), правильные endpoints `/сервисы/api/kn/object` (place=66, objStatus 0/1/2 = 442+2+1072 = **1516 ЖК Свердл, точно совпало с БД**) + `/portal-kn/api/sales/portal/table` (квартиры). Auth — `Basic MTpqd2U=` (`1:qwe`, не `jwe`!). Серверная фильтрация по `regions/developers` НЕ работает — фильтруем локально по `developer.companyGroup`.
- **Extras-эндпойнты** (per-ЖК, того же sweep): `/sale_graph?type=apartments|parking` (time-series продаж), `/sales_agg` (текущие агрегаты 3 типов), `/infrastructure` (POI), `/construction/progress/photo` (мета+опц. binary). PRINZIP probe: 28 ЖК → 36 sale_graph + 6 sales_agg + 5 409 POI + 5 150 фото-метаданных. - **Extras-эндпойнты** (per-ЖК, того же sweep): `/sale_graph?type=apartments|parking` (time-series продаж), `/sales_agg` (текущие агрегаты 3 типов), `/infrastructure` (POI), `/construction/progress/photo` (мета+опц. binary). PRINZIP probe: 28 ЖК → 36 sale_graph + 6 sales_agg + 5 409 POI + 5 150 фото-метаданных.
- **Расписание + ручной триггер** — Celery beat (cron из `SCRAPE_KN_CRON` env, jitter 0..30мин), `POST /api/v1/admin/scrape/kn` с `X-Admin-Token`, UI `/admin/scrape` (чекбоксы extras/photos + журнал ошибок с copy-URL для ручной проверки в браузере). - **Расписание + ручной триггер** — Celery beat (cron из `SCRAPE_KN_CRON` env, jitter 0..30мин), `POST /api/v1/admin/scrape/kn` с `X-Admin-Token`, UI `/admin/scrape` (чекбоксы extras/photos + журнал ошибок с copy-URL для ручной проверки в браузере + раздел «Очередь и активные» через Celery inspect).
- **Stealth** — Playwright fingerprint снят с локальной машины в `data/playwright_state.json` (26 cookies, ServicePipe NSC + Keycloak), закоммичен в git для server-reuse без cold-bootstrap. - **Stealth** — Playwright fingerprint снят с локальной машины в `data/playwright_state.json` (26 cookies, ServicePipe NSC + Keycloak), закоммичен в git для server-reuse без cold-bootstrap.
### Запуск 442-sweep (все строящиеся ЖК Свердл)
После того как PRINZIP-probe прошёл (28 ЖК), масштабируем на все 442 строящихся:
**Через UI** (рекомендуется):
1. Открыть https://gendsgn.ru/admin/scrape, ввести `SCRAPE_ADMIN_TOKEN`.
2. Регион: 66, Девелоперы: пусто (все), чекбоксы: ✅ Квартиры, ✅ Extras, ❌ Скачать фото.
3. «Запустить сейчас» → таск уйдёт в Redis-очередь, worker подхватит. Прогон ~60 минут.
**Через CLI на VPS** (если worker оффлайн):
```bash
ssh gendesign 'cd /opt/gendesign && \
docker compose -f docker-compose.prod.yml run --rm worker \
python data/sql/50_scrape_kn_flats.py --regions 66 \
--load-state data/playwright_state.json -v'
```
**Sanity-чеки после прогона:**
```sql
SELECT obj_status, COUNT(*) FROM domrf_kn_objects
WHERE region_cd=66 AND snapshot_date=CURRENT_DATE GROUP BY 1;
-- Ожидаем obj_status=0 (строящиеся): ~442
SELECT COUNT(DISTINCT obj_id) FROM domrf_kn_sale_graph
WHERE snapshot_date=CURRENT_DATE;
-- Ожидаем 100-200 ЖК с активными продажами
SELECT poi_category, COUNT(*) FROM domrf_kn_infrastructure
WHERE snapshot_date=CURRENT_DATE GROUP BY 1 ORDER BY 2 DESC;
-- Ожидаем ~85 000 POI (442 ЖК × ~190 POI на ЖК)
```
**Возможные проблемы:**
- WAF cookie expire — обновить `data/playwright_state.json` через `python data/sql/50_scrape_kn_flats.py --regions 66 --bootstrap-only --save-state data/playwright_state.json` локально, закоммитить, redeploy.
- Rate-limit (429) — повысить `SCRAPE_KN_JITTER_SECONDS` в env или поправить `jitter_sleep(min_ms, max_ms)` в `stealth.py`.
--- ---
## Аналитический пласт данных (data warehouse) — 27.04.2026 ## Аналитический пласт данных (data warehouse) — 27.04.2026
@ -166,6 +202,14 @@ Acceptance criteria разбиты на 3 блока (функционально
| `kn_scrape_runs` | 3 | журнал прогонов парсера: regions, developers, objects_count, flats_count, requests_count, status | | `kn_scrape_runs` | 3 | журнал прогонов парсера: regions, developers, objects_count, flats_count, requests_count, status |
| `kn_scrape_failures` | 78 | per-request ошибки с full_url для ручной проверки в браузере (404 для сданных ЖК = ожидаемо) | | `kn_scrape_failures` | 78 | per-request ошибки с full_url для ручной проверки в браузере (404 для сданных ЖК = ожидаемо) |
**PRINZIP CRM (analytics.prinzipevent.ru)**
| Таблица | Рядов | Описание |
|---|---:|---|
| `prinzip_leads` ⭐ | до 102 000 | заявки 8 лет. PII (phone/email) хешированы. Импорт через `data/sql/52_import_prinzip_crm.py --sqlite data/raw/prinzip_crm.sqlite` |
| `prinzip_deals` ⭐ | до 9 000 | сделки 8 лет, sum revenue ≈ 39.9 млрд ₽. FK к `prinzip_leads` и soft-FK к `domrf_kn_objects` |
| `prinzip_funnel_monthly` ⭐ | MV | воронка по месяцам × source (REFRESH после импорта) |
| `prinzip_funnel_by_object` ⭐ | MV | conversion per ЖК: leads → deals → revenue |
**ЦБ РФ ипотека (от Антона)** **ЦБ РФ ипотека (от Антона)**
| Таблица | Рядов | Описание | | Таблица | Рядов | Описание |
|---|---:|---| |---|---:|---|
@ -271,9 +315,11 @@ domrf_kn_flats (поквартирно) ─── obj_name ──→ domrf_kn_ob
50_schema_kn_extensions.sql UNIQUE (id, snapshot_date) для kn_flats/kn_objects + индексы (region_cd, snapshot_date) + kn_scrape_runs (журнал прогонов) 50_schema_kn_extensions.sql UNIQUE (id, snapshot_date) для kn_flats/kn_objects + индексы (region_cd, snapshot_date) + kn_scrape_runs (журнал прогонов)
50_scrape_kn_flats.py CLI scraper для /сервисы/api/kn/object + portal/table; флаги --regions/--developers/--no-extras/--download-photos/--probe URL/--save-state/--load-state 50_scrape_kn_flats.py CLI scraper для /сервисы/api/kn/object + portal/table; флаги --regions/--developers/--no-extras/--download-photos/--probe URL/--save-state/--load-state
51_schema_kn_extras.sql 4 extras-таблицы: sale_graph (time-series продаж), sales_agg (агрегаты), infrastructure (POI норм.), photos (мета+local_path) + kn_scrape_failures 51_schema_kn_extras.sql 4 extras-таблицы: sale_graph (time-series продаж), sales_agg (агрегаты), infrastructure (POI норм.), photos (мета+local_path) + kn_scrape_failures
52_schema_prinzip_crm.sql prinzip_leads (102K) + prinzip_deals (9K) + 2 materialized views (funnel_monthly, funnel_by_object). PII-hashing
52_import_prinzip_crm.py SQLite → Postgres importer. Use --inspect-only для discovery колонок, потом правь LEAD_FIELD_MAP/DEAL_FIELD_MAP
``` ```
Парсер живёт в `backend/app/services/scrapers/{stealth.py, domrf_kn.py}`, Celery-таск в `backend/app/workers/tasks/scrape_kn.py`, admin endpoint в `backend/app/api/v1/admin_scrape.py` (POST /kn + GET /runs + GET /failures с auth `X-Admin-Token`). UI ручного запуска: `frontend/src/app/admin/scrape/page.tsx`. Парсер живёт в `backend/app/services/scrapers/{stealth.py, domrf_kn.py}`, Celery-таск в `backend/app/workers/tasks/scrape_kn.py`, admin endpoint в `backend/app/api/v1/admin_scrape.py` (POST /kn + GET /runs + GET /failures + GET /queue + POST /revoke с auth `X-Admin-Token`). UI ручного запуска: `frontend/src/app/admin/scrape/page.tsx`. Per-объект drill-in: `/analytics/objects/[id]` (галерея фото + POI-таблица + sale-chart) + `PrinzipObjectsTable` с sparkline на `/analytics/prinzip`.
### 🎯 Аналитика — что делать с этими данными ### 🎯 Аналитика — что делать с этими данными

View file

@ -1,4 +1,11 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
#
# Multi-target build:
# target=runner — lean image для backend (uvicorn) и beat. ~1.4 ГБ.
# target=runner-with-chromium — image для worker (Playwright fetch). ~1.6 ГБ.
#
# Local: docker compose сам выбирает target по сервису (см. docker-compose.yml).
# Prod: CI пушит 2 GHCR-образа: gendesign-backend (lean) и gendesign-worker (chromium).
# ---- builder ---- # ---- builder ----
FROM python:3.12-slim AS builder FROM python:3.12-slim AS builder
@ -28,14 +35,13 @@ COPY alembic.ini ./
COPY alembic ./alembic COPY alembic ./alembic
# ---- runner ---- # ---- runner (lean — без Chromium) ----
FROM python:3.12-slim AS runner FROM python:3.12-slim AS runner
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1 \
PATH="/app/.venv/bin:$PATH" PATH="/app/.venv/bin:$PATH"
# Runtime libs only — no -dev / no build-essential.
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \ libpq5 \
libgdal36 \ libgdal36 \
@ -46,8 +52,31 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpangoft2-1.0-0 \ libpangoft2-1.0-0 \
fonts-dejavu-core \ fonts-dejavu-core \
curl \ curl \
# Chromium runtime libs for Playwright (отдельно вместо --with-deps, чтобы apt-lists && rm -rf /var/lib/apt/lists/* \
# были доступны до самой команды playwright install). && useradd --create-home --uid 1000 app
WORKDIR /app
COPY --from=builder --chown=app:app /app/.venv /app/.venv
COPY --from=builder --chown=app:app /app/app /app/app
COPY --from=builder --chown=app:app /app/alembic.ini /app/alembic.ini
COPY --from=builder --chown=app:app /app/alembic /app/alembic
USER app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# ---- runner-with-chromium (для worker) ----
# Наследуется от lean runner, добавляет Chromium-libs + Playwright-binary (~200 MB).
FROM runner AS runner-with-chromium
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
# Chromium runtime libs (раньше заходили через `playwright install --with-deps`)
libnss3 \ libnss3 \
libnspr4 \ libnspr4 \
libdbus-1-3 \ libdbus-1-3 \
@ -62,20 +91,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxrandr2 \ libxrandr2 \
libgbm1 \ libgbm1 \
libasound2t64 \ libasound2t64 \
libatspi2.0-0 \ libatspi2.0-0
&& useradd --create-home --uid 1000 app
WORKDIR /app # Playwright Chromium binary под root → shared dir, чтобы и app-user мог использовать.
COPY --from=builder --chown=app:app /app/.venv /app/.venv
COPY --from=builder --chown=app:app /app/app /app/app
COPY --from=builder --chown=app:app /app/alembic.ini /app/alembic.ini
COPY --from=builder --chown=app:app /app/alembic /app/alembic
# Playwright Chromium binary (~150 MB) — устанавливаем под root, кладём в общую
# директорию, чтобы и app-user мог использовать. Без --with-deps: системные
# библиотеки уже поставлены apt-get выше, а --with-deps требует apt-lists
# которые мы тут же чистим в одной команде ради размера слоя.
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright
RUN mkdir -p $PLAYWRIGHT_BROWSERS_PATH \ RUN mkdir -p $PLAYWRIGHT_BROWSERS_PATH \
&& /app/.venv/bin/playwright install chromium \ && /app/.venv/bin/playwright install chromium \
@ -83,7 +101,3 @@ RUN mkdir -p $PLAYWRIGHT_BROWSERS_PATH \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
USER app USER app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -103,6 +103,61 @@ def developer_portfolio(
return q.developer_portfolio(db, developer_id=dev) return q.developer_portfolio(db, developer_id=dev)
# ---- Per-object drill-in (sale_graph / sales_agg / infra / photos) ----------
@router.get("/object/{obj_id}/sale_graph")
def object_sale_graph(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
type_filter: Annotated[str | None, Query(alias="type")] = None,
) -> list[dict[str, Any]]:
"""Time-series продаж per-ЖК. type=apartments|parking, без фильтра — обе серии."""
return q.object_sale_graph(db, obj_id=obj_id, type_filter=type_filter)
@router.get("/object/{obj_id}/sales_agg")
def object_sales_agg(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
) -> list[dict[str, Any]]:
"""3 строки: apartments + nonliv + parking (total / realised / sold%)."""
return q.object_sales_agg(db, obj_id=obj_id)
@router.get("/object/{obj_id}/infrastructure")
def object_infrastructure(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
category: Annotated[str | None, Query()] = None,
max_distance: Annotated[int, Query(ge=0, le=10000)] = 5000,
) -> list[dict[str, Any]]:
"""POI вокруг ЖК. Фильтр по category (Спорт/Образование/...) и radius."""
return q.object_infrastructure(db, obj_id=obj_id, category=category, max_distance=max_distance)
@router.get("/object/{obj_id}/photos")
def object_photos(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
limit: Annotated[int, Query(ge=1, le=500)] = 100,
) -> list[dict[str, Any]]:
"""Фото-метаданные с photo_url, отсортированные по period_dt DESC."""
return q.object_photos(db, obj_id=obj_id, limit=limit)
@router.get("/object/{obj_id}")
def object_detail(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
) -> dict[str, Any]:
"""Базовая инфа объекта из domrf_kn_objects (имя, адрес, координаты, девелопер)."""
result = q.object_detail(db, obj_id=obj_id)
if result is None:
raise HTTPException(status_code=404, detail="object not found")
return result
# ---- PRINZIP-specific ------------------------------------------------------- # ---- PRINZIP-specific -------------------------------------------------------
@ -115,3 +170,35 @@ def prinzip_insights() -> dict[str, Any]:
@router.get("/prinzip/districts") @router.get("/prinzip/districts")
def prinzip_districts(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]: def prinzip_districts(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
return q.prinzip_district_distribution(db, developer_id="6208_0") return q.prinzip_district_distribution(db, developer_id="6208_0")
@router.get("/prinzip/objects")
def prinzip_objects(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
"""Список 28 ЖК PRINZIP с агрегатами sales_agg + sparkline-данными для UI таблицы."""
return q.prinzip_objects_with_velocity(db)
@router.get("/prinzip/funnel/monthly")
def prinzip_funnel_monthly(
db: Annotated[Session, Depends(get_db)],
months: Annotated[int, Query(ge=1, le=120)] = 24,
) -> list[dict[str, Any]]:
"""Воронка по месяцам: leads → engaged → converted (с разбивкой по source)."""
return q.prinzip_funnel_monthly(db, months=months)
@router.get("/prinzip/funnel/by-source")
def prinzip_funnel_by_source(
db: Annotated[Session, Depends(get_db)],
months: Annotated[int, Query(ge=1, le=120)] = 12,
) -> list[dict[str, Any]]:
"""Splitting по source: кто конвертит лучше за последние N месяцев."""
return q.prinzip_funnel_by_source(db, months=months)
@router.get("/prinzip/funnel/by-object")
def prinzip_funnel_by_object(
db: Annotated[Session, Depends(get_db)],
) -> list[dict[str, Any]]:
"""Воронка для каждого ЖК PRINZIP: leads / deals / revenue."""
return q.prinzip_funnel_by_object(db)

View file

@ -586,3 +586,368 @@ def prinzip_insights() -> dict[str, Any]:
}, },
], ],
} }
# ── Per-object drill-in (поверх extras-таблиц из 51_schema_kn_extras.sql) ────
def object_detail(db: Session, obj_id: int) -> dict[str, Any] | None:
"""Базовая инфа объекта из domrf_kn_objects (последний snapshot)."""
row = (
db.execute(
text(
"""
SELECT obj_id, hobj_id, comm_name, addr, short_addr, region_cd,
dev_id, dev_name, floor_min, floor_max, flat_count, square_living,
ready_dt, site_status, escrow, obj_class, latitude, longitude,
obj_status, snapshot_date
FROM domrf_kn_objects
WHERE obj_id = :obj
ORDER BY snapshot_date DESC
LIMIT 1
"""
),
{"obj": obj_id},
)
.mappings()
.first()
)
if not row:
return None
return {
"obj_id": row["obj_id"],
"hobj_id": row["hobj_id"],
"comm_name": row["comm_name"],
"addr": row["addr"],
"short_addr": row["short_addr"],
"region_cd": row["region_cd"],
"dev_id": row["dev_id"],
"dev_name": row["dev_name"],
"floor_min": row["floor_min"],
"floor_max": row["floor_max"],
"flat_count": row["flat_count"],
"square_living": _f(row["square_living"]),
"ready_dt": row["ready_dt"].isoformat() if row["ready_dt"] else None,
"site_status": row["site_status"],
"escrow": row["escrow"],
"obj_class": row["obj_class"],
"latitude": _f(row["latitude"]),
"longitude": _f(row["longitude"]),
"obj_status": row["obj_status"],
"snapshot_date": row["snapshot_date"].isoformat() if row["snapshot_date"] else None,
}
def object_sale_graph(
db: Session, obj_id: int, type_filter: str | None = None
) -> list[dict[str, Any]]:
"""Time-series продаж per-ЖК. Latest snapshot."""
where_type = ""
params: dict[str, Any] = {"obj": obj_id}
if type_filter:
where_type = "AND type = :type_filter"
params["type_filter"] = type_filter
rows = (
db.execute(
text(
f"""
SELECT obj_id, report_month, type, realised, contracted,
area_sq, price_avg, snapshot_date
FROM domrf_kn_sale_graph
WHERE obj_id = :obj
{where_type}
AND snapshot_date = (
SELECT MAX(snapshot_date) FROM domrf_kn_sale_graph
WHERE obj_id = :obj {where_type}
)
ORDER BY type, report_month
"""
),
params,
)
.mappings()
.all()
)
return [
{
"report_month": r["report_month"].isoformat() if r["report_month"] else None,
"type": r["type"],
"realised": r["realised"],
"contracted": r["contracted"],
"area_sq": _f(r["area_sq"]),
"price_avg": _f(r["price_avg"]),
}
for r in rows
]
def object_sales_agg(db: Session, obj_id: int) -> list[dict[str, Any]]:
"""3 строки текущих агрегатов: apartments / nonliv / parking."""
rows = (
db.execute(
text(
"""
SELECT type, name, total, realised, perc, snapshot_date
FROM domrf_kn_sales_agg
WHERE obj_id = :obj
AND snapshot_date = (
SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg WHERE obj_id = :obj
)
ORDER BY CASE type
WHEN 'apartments' THEN 1
WHEN 'nonliv' THEN 2
ELSE 3
END
"""
),
{"obj": obj_id},
)
.mappings()
.all()
)
return [
{
"type": r["type"],
"name": r["name"],
"total": r["total"],
"realised": r["realised"],
"perc": _f(r["perc"]),
}
for r in rows
]
def object_infrastructure(
db: Session,
obj_id: int,
category: str | None = None,
max_distance: int = 5000,
) -> list[dict[str, Any]]:
"""POI вокруг ЖК с фильтром по категории и радиусу."""
where_cat = "AND poi_category = :cat" if category else ""
params: dict[str, Any] = {"obj": obj_id, "dist": max_distance}
if category:
params["cat"] = category
rows = (
db.execute(
text(
f"""
SELECT poi_name, poi_subtitle, poi_category, poi_address,
poi_lat, poi_lon, distance_m
FROM domrf_kn_infrastructure
WHERE obj_id = :obj
AND distance_m <= :dist
{where_cat}
AND snapshot_date = (
SELECT MAX(snapshot_date) FROM domrf_kn_infrastructure WHERE obj_id = :obj
)
ORDER BY distance_m ASC
"""
),
params,
)
.mappings()
.all()
)
return [
{
"poi_name": r["poi_name"],
"poi_subtitle": r["poi_subtitle"],
"poi_category": r["poi_category"],
"poi_address": r["poi_address"],
"lat": _f(r["poi_lat"]),
"lon": _f(r["poi_lon"]),
"distance_m": _f(r["distance_m"]),
}
for r in rows
]
def object_photos(db: Session, obj_id: int, limit: int = 100) -> list[dict[str, Any]]:
"""Фото-метаданные, последние сверху."""
rows = (
db.execute(
text(
"""
SELECT obj_file_id, ord_num, photo_url, photo_dttm, period_dt,
size_bytes, photo_name, ready_desc, build_type, hidden, local_path
FROM domrf_kn_photos
WHERE obj_id = :obj AND COALESCE(hidden, FALSE) = FALSE
ORDER BY period_dt DESC NULLS LAST, ord_num DESC NULLS LAST
LIMIT :lim
"""
),
{"obj": obj_id, "lim": limit},
)
.mappings()
.all()
)
return [
{
"obj_file_id": r["obj_file_id"],
"ord_num": r["ord_num"],
"photo_url": r["photo_url"],
"photo_dttm": r["photo_dttm"].isoformat() if r["photo_dttm"] else None,
"period_dt": r["period_dt"].isoformat() if r["period_dt"] else None,
"size_bytes": r["size_bytes"],
"photo_name": r["photo_name"],
"ready_desc": r["ready_desc"],
"build_type": r["build_type"],
"local_path": r["local_path"],
}
for r in rows
]
def prinzip_funnel_monthly(db: Session, months: int = 24) -> list[dict[str, Any]]:
"""Воронка по месяцам из materialized view."""
rows = (
db.execute(
text(
"""
SELECT month, source, leads, engaged, converted, conv_pct
FROM prinzip_funnel_monthly
WHERE month >= (CURRENT_DATE - (:months || ' months')::interval)::date
ORDER BY month DESC, leads DESC
"""
),
{"months": months},
)
.mappings()
.all()
)
return [
{
"month": r["month"].isoformat() if r["month"] else None,
"source": r["source"],
"leads": r["leads"],
"engaged": r["engaged"],
"converted": r["converted"],
"conv_pct": _f(r["conv_pct"]),
}
for r in rows
]
def prinzip_funnel_by_source(db: Session, months: int = 12) -> list[dict[str, Any]]:
"""Агрегаты по source за последние N месяцев."""
rows = (
db.execute(
text(
"""
SELECT source,
SUM(leads) AS leads,
SUM(engaged) AS engaged,
SUM(converted) AS converted,
ROUND(100.0 * SUM(converted) / NULLIF(SUM(leads), 0), 2) AS conv_pct
FROM prinzip_funnel_monthly
WHERE month >= (CURRENT_DATE - (:months || ' months')::interval)::date
GROUP BY source
ORDER BY leads DESC
"""
),
{"months": months},
)
.mappings()
.all()
)
return [
{
"source": r["source"],
"leads": int(r["leads"] or 0),
"engaged": int(r["engaged"] or 0),
"converted": int(r["converted"] or 0),
"conv_pct": _f(r["conv_pct"]),
}
for r in rows
]
def prinzip_funnel_by_object(db: Session) -> list[dict[str, Any]]:
"""Conversion per ЖК."""
rows = (
db.execute(
text(
"""
SELECT obj_id, comm_name, leads_count, deals_count, conv_pct,
total_revenue, avg_deal_price
FROM prinzip_funnel_by_object
ORDER BY total_revenue DESC NULLS LAST
"""
),
)
.mappings()
.all()
)
return [
{
"obj_id": r["obj_id"],
"comm_name": r["comm_name"],
"leads_count": r["leads_count"],
"deals_count": r["deals_count"],
"conv_pct": _f(r["conv_pct"]),
"total_revenue": _f(r["total_revenue"]),
"avg_deal_price": _f(r["avg_deal_price"]),
}
for r in rows
]
def prinzip_objects_with_velocity(db: Session) -> list[dict[str, Any]]:
"""Список 28 PRINZIP-ЖК с агрегатами + apartments-velocity sparkline data."""
rows = (
db.execute(
text(
"""
WITH agg AS (
SELECT obj_id, total, realised, perc
FROM domrf_kn_sales_agg
WHERE type = 'apartments'
AND snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg)
),
velocity AS (
SELECT obj_id,
ARRAY_AGG(realised ORDER BY report_month) AS sparkline_realised,
ARRAY_AGG(report_month::text ORDER BY report_month) AS months
FROM domrf_kn_sale_graph
WHERE type = 'apartments'
AND snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_sale_graph)
GROUP BY obj_id
)
SELECT o.obj_id, o.comm_name, o.addr, o.flat_count, o.square_living, o.ready_dt,
o.site_status,
a.total, a.realised, a.perc,
v.sparkline_realised, v.months
FROM domrf_kn_objects o
LEFT JOIN agg a ON a.obj_id = o.obj_id
LEFT JOIN velocity v ON v.obj_id = o.obj_id
WHERE o.dev_id = '6208_0'
AND o.snapshot_date = (
SELECT MAX(snapshot_date) FROM domrf_kn_objects WHERE dev_id = '6208_0'
)
ORDER BY a.total DESC NULLS LAST
"""
),
)
.mappings()
.all()
)
return [
{
"obj_id": r["obj_id"],
"comm_name": r["comm_name"],
"addr": r["addr"],
"flat_count": r["flat_count"],
"square_living": _f(r["square_living"]),
"ready_dt": r["ready_dt"].isoformat() if r["ready_dt"] else None,
"site_status": r["site_status"],
"total": r["total"],
"realised": r["realised"],
"perc": _f(r["perc"]),
"sparkline_realised": (
list(r["sparkline_realised"]) if r["sparkline_realised"] else []
),
"months": list(r["months"]) if r["months"] else [],
}
for r in rows
]

View file

@ -0,0 +1,301 @@
"""Import PRINZIP CRM SQLite dump → Postgres prinzip_leads / prinzip_deals.
Источник: 49 MB SQLite-дамп с analytics.prinzipevent.ru. user скачивает руками
(доступ подтверждён) и кладёт в data/raw/prinzip_crm.sqlite (gitignored).
Запуск:
PYTHONIOENCODING=utf-8 python data/sql/52_import_prinzip_crm.py \\
--sqlite data/raw/prinzip_crm.sqlite
Скрипт идемпотентный UPSERT по lead_id / deal_id. Можно перезапускать
после очередного экспорта SQLite.
Точная схема таблиц в SQLite неизвестна заранее. Скрипт сначала логирует
sqlite_master + table_info по каждой таблице, чтобы можно было поправить
mapping (поле LEAD_FIELD_MAP внизу).
PII compliance (152-ФЗ):
- phone SHA256(phone)[:16] в phone_hash + last 4 digits в phone_last4
- email MD5(email) в email_hash
- name остаётся (legal: client может попросить удалить)
"""
from __future__ import annotations
import argparse
import hashlib
import logging
import os
import sqlite3
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
from sqlalchemy import create_engine, text # noqa: E402
logger = logging.getLogger(__name__)
# ── PII helpers ──────────────────────────────────────────────────────────────
def hash_phone(phone: str | None) -> tuple[str | None, str | None]:
"""Return (sha256_short, last4_digits)."""
if not phone:
return None, None
digits = "".join(c for c in str(phone) if c.isdigit())
if not digits:
return None, None
h = hashlib.sha256(digits.encode("utf-8")).hexdigest()[:16]
last4 = digits[-4:] if len(digits) >= 4 else digits
return h, last4
def hash_email(email: str | None) -> str | None:
if not email:
return None
return hashlib.md5(str(email).strip().lower().encode("utf-8")).hexdigest()
# ── SQLite → Postgres column mapping (правится после первого dry-run) ───────
#
# Скрипт ВПЕРВЫЕ логирует table_info — посмотри логи и поправь LEAD_FIELD_MAP
# и DEAL_FIELD_MAP под реальные имена в SQLite.
LEAD_FIELD_MAP = {
"lead_id": ["id", "lead_id", "request_id"],
"created_at": ["created_at", "createdAt", "datetime", "date"],
"source": ["source", "utm_source", "channel"],
"channel": ["channel", "utm_campaign"],
"name": ["name", "client_name", "fio"],
"phone": ["phone", "tel", "telephone"],
"email": ["email", "mail"],
"obj_interest": ["obj_id", "object_id", "complex_id", "jk_id"],
"rooms": ["rooms", "room_count"],
"budget_min": ["budget_min", "price_min"],
"budget_max": ["budget_max", "price_max"],
"status": ["status", "state"],
}
DEAL_FIELD_MAP = {
"deal_id": ["id", "deal_id"],
"lead_id": ["lead_id", "request_id"],
"closed_at": ["closed_at", "deal_date", "date", "datetime"],
"obj_id": ["obj_id", "object_id", "complex_id"],
"flat_ods_id": ["flat_id", "flat_ods_id", "ods_id"],
"rooms": ["rooms", "room_count"],
"total_area": ["area", "total_area"],
"deal_price": ["price", "deal_price", "amount"],
"payment_type": ["payment_type", "payment_method"],
"mortgage_bank": ["bank", "mortgage_bank"],
"mortgage_rate": ["rate", "mortgage_rate"],
"deal_stage": ["stage", "deal_stage"],
}
def find_field(row: sqlite3.Row, candidates: list[str]):
"""Pick first existing column from candidates in a sqlite Row."""
keys = row.keys() if hasattr(row, "keys") else []
for c in candidates:
if c in keys:
return row[c]
return None
# ── Main import ──────────────────────────────────────────────────────────────
UPSERT_LEAD = text(
"""
INSERT INTO prinzip_leads (
lead_id, created_at, source, channel, name, phone_hash, phone_last4,
email_hash, obj_interest, rooms, budget_min, budget_max, status, converted, deal_id
) VALUES (
:lead_id, :created_at, :source, :channel, :name, :phone_hash, :phone_last4,
:email_hash, :obj_interest, :rooms, :budget_min, :budget_max, :status,
:converted, :deal_id
)
ON CONFLICT (lead_id) DO UPDATE SET
status = EXCLUDED.status,
converted = EXCLUDED.converted,
deal_id = EXCLUDED.deal_id
"""
)
UPSERT_DEAL = text(
"""
INSERT INTO prinzip_deals (
deal_id, lead_id, closed_at, obj_id, flat_ods_id, rooms, total_area,
deal_price, price_per_m2, payment_type, mortgage_bank, mortgage_rate, deal_stage
) VALUES (
:deal_id, :lead_id, :closed_at, :obj_id, :flat_ods_id, :rooms, :total_area,
:deal_price, :price_per_m2, :payment_type, :mortgage_bank, :mortgage_rate, :deal_stage
)
ON CONFLICT (deal_id) DO UPDATE SET
deal_stage = EXCLUDED.deal_stage,
deal_price = EXCLUDED.deal_price,
mortgage_bank = EXCLUDED.mortgage_bank
"""
)
def inspect_sqlite(sqlite_path: Path) -> None:
"""Log all tables + columns for first-time schema discovery."""
conn = sqlite3.connect(sqlite_path)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
tables = [
r[0]
for r in cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).fetchall()
]
logger.info("SQLite contains %d tables: %s", len(tables), tables)
for t in tables:
cols = cur.execute(f"PRAGMA table_info({t})").fetchall()
col_names = [c[1] for c in cols]
cnt = cur.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
logger.info(" %s (%d rows): %s", t, cnt, col_names)
conn.close()
def import_leads(sqlite_path: Path, pg_engine, table_name: str) -> int:
conn = sqlite3.connect(sqlite_path)
conn.row_factory = sqlite3.Row
cur = conn.execute(f"SELECT * FROM {table_name}")
n = 0
with pg_engine.begin() as pg_conn:
for row in cur:
phone_h, phone_last4 = hash_phone(find_field(row, LEAD_FIELD_MAP["phone"]))
try:
pg_conn.execute(
UPSERT_LEAD,
{
"lead_id": find_field(row, LEAD_FIELD_MAP["lead_id"]),
"created_at": find_field(row, LEAD_FIELD_MAP["created_at"]),
"source": find_field(row, LEAD_FIELD_MAP["source"]),
"channel": find_field(row, LEAD_FIELD_MAP["channel"]),
"name": find_field(row, LEAD_FIELD_MAP["name"]),
"phone_hash": phone_h,
"phone_last4": phone_last4,
"email_hash": hash_email(find_field(row, LEAD_FIELD_MAP["email"])),
"obj_interest": find_field(row, LEAD_FIELD_MAP["obj_interest"]),
"rooms": find_field(row, LEAD_FIELD_MAP["rooms"]),
"budget_min": find_field(row, LEAD_FIELD_MAP["budget_min"]),
"budget_max": find_field(row, LEAD_FIELD_MAP["budget_max"]),
"status": find_field(row, LEAD_FIELD_MAP["status"]),
"converted": (find_field(row, ["converted", "is_converted"]) or False),
"deal_id": find_field(row, ["deal_id"]),
},
)
n += 1
except Exception as e:
logger.warning("lead %s skipped: %s", find_field(row, LEAD_FIELD_MAP["lead_id"]), e)
conn.close()
return n
def import_deals(sqlite_path: Path, pg_engine, table_name: str) -> int:
conn = sqlite3.connect(sqlite_path)
conn.row_factory = sqlite3.Row
cur = conn.execute(f"SELECT * FROM {table_name}")
n = 0
with pg_engine.begin() as pg_conn:
for row in cur:
area = find_field(row, DEAL_FIELD_MAP["total_area"])
price = find_field(row, DEAL_FIELD_MAP["deal_price"])
ppm = (price / area) if area and price else None
try:
pg_conn.execute(
UPSERT_DEAL,
{
"deal_id": find_field(row, DEAL_FIELD_MAP["deal_id"]),
"lead_id": find_field(row, DEAL_FIELD_MAP["lead_id"]),
"closed_at": find_field(row, DEAL_FIELD_MAP["closed_at"]),
"obj_id": find_field(row, DEAL_FIELD_MAP["obj_id"]),
"flat_ods_id": find_field(row, DEAL_FIELD_MAP["flat_ods_id"]),
"rooms": find_field(row, DEAL_FIELD_MAP["rooms"]),
"total_area": area,
"deal_price": price,
"price_per_m2": ppm,
"payment_type": find_field(row, DEAL_FIELD_MAP["payment_type"]),
"mortgage_bank": find_field(row, DEAL_FIELD_MAP["mortgage_bank"]),
"mortgage_rate": find_field(row, DEAL_FIELD_MAP["mortgage_rate"]),
"deal_stage": find_field(row, DEAL_FIELD_MAP["deal_stage"]),
},
)
n += 1
except Exception as e:
logger.warning("deal %s skipped: %s", find_field(row, DEAL_FIELD_MAP["deal_id"]), e)
conn.close()
return n
def refresh_views(pg_engine) -> None:
with pg_engine.begin() as pg_conn:
pg_conn.execute(text("REFRESH MATERIALIZED VIEW prinzip_funnel_monthly"))
pg_conn.execute(text("REFRESH MATERIALIZED VIEW prinzip_funnel_by_object"))
def main() -> None:
parser = argparse.ArgumentParser(description="PRINZIP CRM SQLite → Postgres importer")
parser.add_argument(
"--sqlite",
type=Path,
default=Path("data/raw/prinzip_crm.sqlite"),
help="Путь к SQLite-дампу",
)
parser.add_argument(
"--inspect-only",
action="store_true",
help="Только перечислить таблицы и колонки SQLite (для подбора field-mapping)",
)
parser.add_argument(
"--leads-table", default="leads", help="Имя таблицы leads в SQLite"
)
parser.add_argument(
"--deals-table", default="deals", help="Имя таблицы deals в SQLite"
)
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
if not args.sqlite.exists():
logger.error(
"SQLite file not found: %s. Скачай файл с analytics.prinzipevent.ru "
"и положи туда.",
args.sqlite,
)
sys.exit(1)
inspect_sqlite(args.sqlite)
if args.inspect_only:
return
db_url = os.environ.get(
"DATABASE_URL",
"postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign",
)
engine = create_engine(db_url)
logger.info("Importing leads from table %s", args.leads_table)
n_leads = import_leads(args.sqlite, engine, args.leads_table)
logger.info("Imported %d leads.", n_leads)
logger.info("Importing deals from table %s", args.deals_table)
n_deals = import_deals(args.sqlite, engine, args.deals_table)
logger.info("Imported %d deals.", n_deals)
logger.info("Refreshing materialized views…")
refresh_views(engine)
logger.info("Done.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,108 @@
-- PRINZIP CRM — заявки и сделки за 8 лет (102K leads / 9K deals / 39.9 млрд ₽).
-- Источник: SQLite-дамп analytics.prinzipevent.ru (49 МБ).
-- Импорт: data/sql/52_import_prinzip_crm.py --sqlite data/raw/prinzip_crm.sqlite.
-- См. план C:\Users\user\.claude\plans\crispy-waddling-swing.md (#14).
-- ── Заявки (~102K) ─────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS prinzip_leads (
lead_id BIGINT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
source TEXT, -- web/call/cian/avito/...
channel TEXT, -- ad campaign / utm
name TEXT,
phone_hash TEXT, -- SHA256(phone)[:16] для PII compliance
phone_last4 TEXT, -- последние 4 цифры для UI
email_hash TEXT, -- md5(email)
obj_interest BIGINT, -- soft FK → domrf_kn_objects.obj_id
rooms INT,
budget_min NUMERIC,
budget_max NUMERIC,
status TEXT, -- new/contacted/qualified/lost/converted
converted BOOLEAN DEFAULT FALSE,
deal_id BIGINT -- soft FK → prinzip_deals (если converted)
);
CREATE INDEX IF NOT EXISTS idx_leads_created ON prinzip_leads (created_at);
CREATE INDEX IF NOT EXISTS idx_leads_status ON prinzip_leads (status, created_at);
CREATE INDEX IF NOT EXISTS idx_leads_obj ON prinzip_leads (obj_interest);
CREATE INDEX IF NOT EXISTS idx_leads_source ON prinzip_leads (source);
-- ── Сделки (~9K, sum revenue 39.9 млрд ₽) ──────────────────────────────────
CREATE TABLE IF NOT EXISTS prinzip_deals (
deal_id BIGINT PRIMARY KEY,
lead_id BIGINT, -- soft FK → prinzip_leads
closed_at TIMESTAMPTZ NOT NULL,
obj_id BIGINT, -- soft FK → domrf_kn_objects
flat_ods_id TEXT, -- match с domrf_kn_flats.ods_id
rooms INT,
total_area NUMERIC,
deal_price NUMERIC, -- ₽
price_per_m2 NUMERIC,
payment_type TEXT, -- cash/mortgage/escrow/installment
mortgage_bank TEXT,
mortgage_rate NUMERIC,
deal_stage TEXT -- reservation/dku/contract/done
);
CREATE INDEX IF NOT EXISTS idx_deals_closed ON prinzip_deals (closed_at);
CREATE INDEX IF NOT EXISTS idx_deals_obj ON prinzip_deals (obj_id);
CREATE INDEX IF NOT EXISTS idx_deals_lead ON prinzip_deals (lead_id);
-- ── Воронка по месяцам — denormalized aggregate (для UI чартов) ────────────
-- REFRESH MATERIALIZED VIEW prinzip_funnel_monthly после каждого импорта.
DROP MATERIALIZED VIEW IF EXISTS prinzip_funnel_monthly;
CREATE MATERIALIZED VIEW prinzip_funnel_monthly AS
SELECT
DATE_TRUNC('month', created_at)::date AS month,
COALESCE(source, 'unknown') AS source,
COUNT(*) AS leads,
COUNT(*) FILTER (WHERE status IN ('qualified', 'contacted', 'converted')) AS engaged,
COUNT(*) FILTER (WHERE converted) AS converted,
ROUND(100.0 * COUNT(*) FILTER (WHERE converted) / NULLIF(COUNT(*), 0), 2) AS conv_pct
FROM prinzip_leads
GROUP BY 1, 2
ORDER BY 1 DESC, 2;
CREATE UNIQUE INDEX IF NOT EXISTS idx_funnel_monthly_pk
ON prinzip_funnel_monthly (month, source);
-- ── Воронка по ЖК (агрегат) ────────────────────────────────────────────────
DROP MATERIALIZED VIEW IF EXISTS prinzip_funnel_by_object;
CREATE MATERIALIZED VIEW prinzip_funnel_by_object AS
WITH leads_by_obj AS (
SELECT obj_interest AS obj_id,
COUNT(*) AS leads_count
FROM prinzip_leads
WHERE obj_interest IS NOT NULL
GROUP BY obj_interest
),
deals_by_obj AS (
SELECT obj_id,
COUNT(*) AS deals_count,
SUM(deal_price) AS total_revenue,
AVG(deal_price) AS avg_deal_price
FROM prinzip_deals
WHERE obj_id IS NOT NULL
GROUP BY obj_id
)
SELECT
COALESCE(l.obj_id, d.obj_id) AS obj_id,
o.comm_name,
COALESCE(l.leads_count, 0) AS leads_count,
COALESCE(d.deals_count, 0) AS deals_count,
ROUND(100.0 * d.deals_count / NULLIF(l.leads_count, 0), 2) AS conv_pct,
d.total_revenue,
d.avg_deal_price
FROM leads_by_obj l
FULL OUTER JOIN deals_by_obj d ON d.obj_id = l.obj_id
LEFT JOIN domrf_kn_objects o
ON o.obj_id = COALESCE(l.obj_id, d.obj_id)
AND o.snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_objects);
CREATE UNIQUE INDEX IF NOT EXISTS idx_funnel_by_obj_pk
ON prinzip_funnel_by_object (obj_id);
COMMENT ON TABLE prinzip_leads IS
'PRINZIP CRM заявки. PII (phone/email) хешированы при импорте.';
COMMENT ON TABLE prinzip_deals IS
'PRINZIP CRM сделки. Sum deal_price ≈ 39.9 млрд ₽ за 8 лет.';
COMMENT ON MATERIALIZED VIEW prinzip_funnel_monthly IS
'Воронка leads → engaged → converted по месяцам и source. REFRESH после import.';
COMMENT ON MATERIALIZED VIEW prinzip_funnel_by_object IS
'Conversion per ЖК: leads / deals / revenue / avg deal_price.';

View file

@ -71,6 +71,30 @@ services:
depends_on: depends_on:
- backend - backend
worker:
# Отдельный chromium-образ (+200 МБ Playwright). См. backend/Dockerfile target=runner-with-chromium.
image: ghcr.io/lekss361/gendesign-worker:${IMAGE_TAG:-latest}
restart: unless-stopped
env_file: ./backend/.env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
volumes:
- ./data:/app/data # playwright_state.json + photo binaries
command: ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=2"]
beat:
# Lean backend-образ (без Chromium) — beat только триггерит таски в Redis.
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
restart: unless-stopped
env_file: ./backend/.env
depends_on:
redis:
condition: service_started
command: ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info"]
caddy: caddy:
image: caddy:2 image: caddy:2
restart: unless-stopped restart: unless-stopped

View file

@ -29,7 +29,9 @@ services:
retries: 5 retries: 5
backend: backend:
build: ./backend build:
context: ./backend
target: runner
env_file: ./backend/.env env_file: ./backend/.env
depends_on: depends_on:
postgres: postgres:
@ -55,7 +57,9 @@ services:
- backend - backend
worker: worker:
build: ./backend build:
context: ./backend
target: runner-with-chromium # +Playwright Chromium для kn-scraper
env_file: ./backend/.env env_file: ./backend/.env
depends_on: depends_on:
postgres: postgres:
@ -64,10 +68,13 @@ services:
condition: service_started condition: service_started
volumes: volumes:
- ./backend/app:/app/app - ./backend/app:/app/app
- ./data:/app/data # storage_state.json + photo binaries
command: ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=2"] command: ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=2"]
beat: beat:
build: ./backend build:
context: ./backend
target: runner # lean — beat только триггерит, без Chromium
env_file: ./backend/.env env_file: ./backend/.env
depends_on: depends_on:
redis: redis:

View file

@ -0,0 +1,145 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { KpiCard } from "@/components/analytics/KpiCard";
import { ObjectInfraMap } from "@/components/analytics/ObjectInfraMap";
import { ObjectPhotoGallery } from "@/components/analytics/ObjectPhotoGallery";
import { ObjectSaleChart } from "@/components/analytics/ObjectSaleChart";
import { Section } from "@/components/analytics/Section";
import { useObjectDetail, useObjectSalesAgg } from "@/lib/analytics-api";
export default function ObjectDrillInPage() {
const params = useParams<{ id: string }>();
const objId = params.id;
const detail = useObjectDetail(objId);
const agg = useObjectSalesAgg(objId);
const apartments = agg.data?.find((r) => r.type === "apartments");
const parking = agg.data?.find((r) => r.type === "parking");
const nonliv = agg.data?.find((r) => r.type === "nonliv");
const d = detail.data;
return (
<>
<Link
href="/analytics/prinzip"
style={{ fontSize: 13, color: "#5b6066", textDecoration: "none" }}
>
К PRINZIP
</Link>
<h1 style={{ margin: "8px 0", fontSize: 22 }}>
{d?.comm_name ?? "Загрузка…"}{" "}
{d?.site_status ? (
<span
style={{
fontSize: 13,
fontWeight: 400,
padding: "3px 8px",
borderRadius: 4,
marginLeft: 8,
background:
d.site_status === "Строящиеся" ? "#dbeafe" : "#dcfce7",
color: d.site_status === "Строящиеся" ? "#1e40af" : "#065f46",
}}
>
{d.site_status}
</span>
) : null}
</h1>
<p style={{ margin: "0 0 16px", color: "#5b6066", fontSize: 13 }}>
{d?.addr ?? ""} · obj_id={d?.obj_id} · {d?.dev_name ?? ""}
{d?.escrow ? " · ✅ эскроу" : ""}
</p>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<KpiCard
label="Квартир"
value={
apartments?.total?.toString() ?? d?.flat_count?.toString() ?? "—"
}
hint={
apartments?.realised != null
? `${apartments.realised} продано (${apartments.perc}%)`
: undefined
}
/>
<KpiCard
label="Sold % (квартиры)"
value={apartments?.perc != null ? `${apartments.perc}` : "—"}
unit="%"
delta={
apartments?.perc != null
? {
value:
apartments.perc >= 50
? "выше среднего рынка"
: apartments.perc >= 30
? "около среднего"
: "ниже среднего",
positive: apartments.perc >= 30,
}
: undefined
}
/>
<KpiCard
label="Машино-места"
value={parking?.total?.toString() ?? "—"}
hint={
parking?.perc != null
? `${parking.realised} продано (${parking.perc}%)`
: undefined
}
/>
<KpiCard
label="Нежилые"
value={nonliv?.total?.toString() ?? "—"}
hint={
nonliv?.perc != null
? `${nonliv.realised} продано (${nonliv.perc}%)`
: undefined
}
/>
<KpiCard
label="Этажность"
value={d?.floor_max?.toString() ?? "—"}
hint={d?.floor_min ? `min ${d.floor_min}` : undefined}
/>
<KpiCard
label="Сдача"
value={d?.ready_dt?.slice(0, 7) ?? "—"}
hint={d?.obj_class ?? undefined}
/>
</div>
<Section
title="Динамика продаж"
subtitle="DOM.РФ /sale_graph: реализовано (бары) + средняя цена ₽/м² (линия). Apartments + parking."
>
<ObjectSaleChart objId={objId} />
</Section>
<Section
title="Инфраструктура (POI вокруг ЖК)"
subtitle="Из DOM.РФ /infrastructure. Категории кликабельны для фильтра."
>
<ObjectInfraMap
objId={objId}
centerLat={d?.latitude ?? null}
centerLon={d?.longitude ?? null}
/>
</Section>
<Section
title="Фото прогресса строительства"
subtitle="DOM.РФ /construction/progress/photo. Клик по фото — увеличение."
>
<ObjectPhotoGallery objId={objId} />
</Section>
</>
);
}

View file

@ -5,7 +5,10 @@ import { useState } from "react";
import { InsightCards } from "@/components/analytics/InsightCards"; import { InsightCards } from "@/components/analytics/InsightCards";
import { KpiCard } from "@/components/analytics/KpiCard"; import { KpiCard } from "@/components/analytics/KpiCard";
import { PrinzipDistrictsBar } from "@/components/analytics/PrinzipDistrictsBar"; import { PrinzipDistrictsBar } from "@/components/analytics/PrinzipDistrictsBar";
import { PrinzipFunnelMonthly } from "@/components/analytics/PrinzipFunnelMonthly";
import { PrinzipFunnelSankey } from "@/components/analytics/PrinzipFunnelSankey";
import { PrinzipGapBar } from "@/components/analytics/PrinzipGapBar"; import { PrinzipGapBar } from "@/components/analytics/PrinzipGapBar";
import { PrinzipObjectsTable } from "@/components/analytics/PrinzipObjectsTable";
import { PrinzipQuartirographyPie } from "@/components/analytics/PrinzipQuartirographyPie"; import { PrinzipQuartirographyPie } from "@/components/analytics/PrinzipQuartirographyPie";
import { PrinzipVelocityChart } from "@/components/analytics/PrinzipVelocityChart"; import { PrinzipVelocityChart } from "@/components/analytics/PrinzipVelocityChart";
import { Section } from "@/components/analytics/Section"; import { Section } from "@/components/analytics/Section";
@ -133,6 +136,35 @@ export default function PrinzipPage() {
<PrinzipDistrictsBar /> <PrinzipDistrictsBar />
</Section> </Section>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 16,
marginTop: 16,
}}
>
<Section
title="Воронка CRM (по source)"
subtitle="Sankey leads → engaged → converted из prinzip_leads. Импорт SQLite — см. data/sql/52_import_prinzip_crm.py."
>
<PrinzipFunnelSankey months={12} />
</Section>
<Section
title="Конверсия по месяцам"
subtitle="Заявки + Engaged + Сделки (бары) + Conversion % (линия)."
>
<PrinzipFunnelMonthly months={24} />
</Section>
</div>
<Section
title="ЖК PRINZIP — таблица с velocity"
subtitle="28 объектов по объёму квартир. Sparkline — динамика реализованных квартир по месяцам (apartments). Клик по «Drill-in» открывает страницу ЖК с галереей и POI."
>
<PrinzipObjectsTable />
</Section>
<Section <Section
title="Инсайты и рекомендации" title="Инсайты и рекомендации"
subtitle="На основе анализа портфеля + реальных ДДУ-сделок Свердл (PRINZIP_Strategy_Apr27)." subtitle="На основе анализа портфеля + реальных ДДУ-сделок Свердл (PRINZIP_Strategy_Apr27)."

View file

@ -0,0 +1,170 @@
"use client";
import { useMemo, useState } from "react";
import { useObjectInfrastructure } from "@/lib/analytics-api";
const CATEGORY_COLORS: Record<string, string> = {
Спорт: "#0a7a3a",
Продукты: "#1d4ed8",
Образование: "#c2410c",
Медицина: "#b3261e",
Развлечения: "#9333ea",
Новостройки: "#0891b2",
Транспорт: "#475569",
};
interface Props {
objId: number | string;
centerLat: number | null;
centerLon: number | null;
}
export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
const [maxDist, setMaxDist] = useState(2000);
const [activeCat, setActiveCat] = useState<string | null>(null);
const { data, isLoading } = useObjectInfrastructure(objId, {
category: activeCat ?? undefined,
max_distance: maxDist,
});
const stats = useMemo(() => {
const byCat: Record<string, number> = {};
for (const p of data ?? []) {
const c = p.poi_category ?? "—";
byCat[c] = (byCat[c] ?? 0) + 1;
}
return Object.entries(byCat).sort((a, b) => b[1] - a[1]);
}, [data]);
return (
<div>
<div
style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}
>
{stats.map(([cat, n]) => (
<button
key={cat}
onClick={() => setActiveCat(activeCat === cat ? null : cat)}
style={{
padding: "6px 10px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 16,
background:
activeCat === cat ? CATEGORY_COLORS[cat] || "#1d4ed8" : "#fff",
color: activeCat === cat ? "#fff" : "#374151",
cursor: "pointer",
}}
>
{cat} ({n})
</button>
))}
<select
value={maxDist}
onChange={(e) => setMaxDist(Number(e.target.value))}
style={{
padding: "6px 10px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 16,
background: "#fff",
cursor: "pointer",
}}
>
<option value={500}> 500 м</option>
<option value={1000}> 1 км</option>
<option value={2000}> 2 км</option>
<option value={5000}> 5 км</option>
</select>
</div>
{isLoading ? (
<div style={{ padding: 24, color: "#5b6066" }}>Загрузка POI</div>
) : (
<div
style={{
border: "1px solid #e6e8ec",
borderRadius: 8,
overflow: "hidden",
position: "relative",
}}
>
<div
style={{
padding: 12,
fontSize: 13,
color: "#5b6066",
background: "#f9fafb",
}}
>
Найдено {data?.length ?? 0} POI в радиусе {maxDist} м.{" "}
{centerLat && centerLon
? `ЖК: ${centerLat.toFixed(4)}, ${centerLon.toFixed(4)}`
: "координаты ЖК не заданы"}
</div>
<table
style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
{["POI", "Категория", "Адрес", "Расстояние, м"].map((h) => (
<th
key={h}
style={{
padding: "8px 10px",
textAlign: "left",
borderBottom: "1px solid #e6e8ec",
fontWeight: 600,
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{(data ?? []).slice(0, 50).map((p, i) => (
<tr
key={`${p.poi_name}-${p.lat}-${p.lon}`}
style={{
borderBottom: "1px solid #eef0f3",
background: i % 2 ? "#fafbfc" : "#fff",
}}
>
<td style={{ padding: "6px 10px" }}>
<div style={{ fontWeight: 500 }}>{p.poi_name}</div>
{p.poi_subtitle ? (
<div style={{ fontSize: 11, color: "#73767e" }}>
{p.poi_subtitle}
</div>
) : null}
</td>
<td style={{ padding: "6px 10px" }}>
<span
style={{
background:
CATEGORY_COLORS[p.poi_category ?? ""] || "#9ca3af",
color: "#fff",
fontSize: 11,
padding: "2px 6px",
borderRadius: 3,
}}
>
{p.poi_category ?? "—"}
</span>
</td>
<td style={{ padding: "6px 10px", color: "#5b6066" }}>
{p.poi_address ?? "—"}
</td>
<td style={{ padding: "6px 10px", textAlign: "right" }}>
{p.distance_m ? Math.round(p.distance_m) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { useObjectPhotos } from "@/lib/analytics-api";
export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
const { data, isLoading } = useObjectPhotos(objId, 60);
const [active, setActive] = useState<string | null>(null);
if (isLoading) {
return <div style={{ color: "#5b6066" }}>Загрузка фото</div>;
}
const photos = data ?? [];
if (photos.length === 0) {
return <div style={{ color: "#9ca3af" }}>Фото отсутствуют.</div>;
}
const activePhoto = photos.find((p) => p.obj_file_id === active);
return (
<>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
gap: 8,
}}
>
{photos.map((p) => (
<button
key={p.obj_file_id}
onClick={() => setActive(p.obj_file_id)}
style={{
padding: 0,
border: "1px solid #e6e8ec",
borderRadius: 6,
overflow: "hidden",
cursor: "pointer",
background: "#f3f4f6",
aspectRatio: "4/3",
position: "relative",
}}
>
{p.photo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={p.photo_url}
alt={p.photo_name ?? "фото прогресса"}
loading="lazy"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : null}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
background: "rgba(0,0,0,0.55)",
color: "#fff",
fontSize: 11,
padding: "3px 6px",
}}
>
{p.period_dt?.slice(0, 7) ?? "—"}
</div>
</button>
))}
</div>
{activePhoto ? (
<div
onClick={() => setActive(null)}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.85)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
cursor: "zoom-out",
}}
>
{activePhoto.photo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={activePhoto.photo_url}
alt={activePhoto.photo_name ?? ""}
style={{
maxWidth: "92vw",
maxHeight: "92vh",
objectFit: "contain",
}}
/>
) : null}
<div
style={{
position: "absolute",
bottom: 16,
left: 16,
right: 16,
color: "#fff",
textAlign: "center",
fontSize: 13,
}}
>
{activePhoto.period_dt?.slice(0, 10)} · {activePhoto.photo_name}
</div>
</div>
) : null}
</>
);
}

View file

@ -0,0 +1,81 @@
"use client";
import { useMemo } from "react";
import { useObjectSaleGraph } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function ObjectSaleChart({ objId }: { objId: number | string }) {
const { data, isLoading } = useObjectSaleGraph(objId);
const option = useMemo(() => {
const points = data ?? [];
if (points.length === 0) {
return {
title: {
text: "Нет данных по продажам",
left: "center",
top: "middle",
textStyle: { color: "#9ca3af", fontSize: 13, fontWeight: 400 },
},
xAxis: { show: false },
yAxis: { show: false },
};
}
const byType: Record<
string,
{
months: string[];
realised: number[];
contracted: number[];
price: (number | null)[];
}
> = {};
for (const p of points) {
const t = p.type;
if (!byType[t])
byType[t] = { months: [], realised: [], contracted: [], price: [] };
byType[t].months.push((p.report_month ?? "").slice(0, 7));
byType[t].realised.push(p.realised ?? 0);
byType[t].contracted.push(p.contracted ?? 0);
byType[t].price.push(p.price_avg);
}
const months = Object.values(byType)[0]?.months ?? [];
const series = Object.entries(byType).flatMap(([type, d]) => [
{
name: `${type} · реализовано`,
type: "bar",
stack: type,
data: d.realised,
itemStyle: { color: type === "apartments" ? "#1d4ed8" : "#9333ea" },
},
{
name: `${type} · цена ₽/м²`,
type: "line",
yAxisIndex: 1,
smooth: true,
symbol: "circle",
data: d.price.map((v) => (v ? Math.round(v / 1000) : null)),
lineStyle: { color: type === "apartments" ? "#0a7a3a" : "#c2410c" },
itemStyle: { color: type === "apartments" ? "#0a7a3a" : "#c2410c" },
connectNulls: true,
},
]);
return {
tooltip: { trigger: "axis", axisPointer: { type: "cross" } },
legend: {},
grid: { left: 56, right: 64, top: 40, bottom: 36 },
xAxis: { type: "category", data: months },
yAxis: [
{ type: "value", name: "шт", position: "left" },
{ type: "value", name: "тыс ₽/м²", position: "right" },
],
series,
};
}, [data]);
return (
<ChartShell option={option} loading={isLoading} height={300} notMerge />
);
}

View file

@ -0,0 +1,96 @@
"use client";
import { useMemo } from "react";
import { usePrinzipFunnelMonthly } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function PrinzipFunnelMonthly({ months = 24 }: { months?: number }) {
const { data, isLoading } = usePrinzipFunnelMonthly(months);
const option = useMemo(() => {
const rows = data ?? [];
if (rows.length === 0) {
return {
title: {
text: "Нет данных CRM",
left: "center",
top: "middle",
textStyle: { color: "#9ca3af", fontWeight: 400, fontSize: 13 },
},
xAxis: { show: false },
yAxis: { show: false },
};
}
// Aggregate by month (sum across sources).
const byMonth: Record<
string,
{ leads: number; engaged: number; converted: number }
> = {};
for (const r of rows) {
const m = (r.month ?? "").slice(0, 7);
if (!byMonth[m]) byMonth[m] = { leads: 0, engaged: 0, converted: 0 };
byMonth[m].leads += r.leads;
byMonth[m].engaged += r.engaged;
byMonth[m].converted += r.converted;
}
const months = Object.keys(byMonth).sort();
const leads = months.map((m) => byMonth[m].leads);
const engaged = months.map((m) => byMonth[m].engaged);
const converted = months.map((m) => byMonth[m].converted);
const conv = months.map((m) =>
byMonth[m].leads > 0
? Math.round((byMonth[m].converted / byMonth[m].leads) * 1000) / 10
: null,
);
return {
tooltip: { trigger: "axis" },
legend: { data: ["Заявки", "Engaged", "Сделки", "Conversion %"] },
grid: { left: 56, right: 64, top: 40, bottom: 36 },
xAxis: { type: "category", data: months },
yAxis: [
{ type: "value", name: "шт", position: "left" },
{
type: "value",
name: "%",
position: "right",
axisLabel: { formatter: "{value}%" },
},
],
series: [
{
name: "Заявки",
type: "bar",
data: leads,
itemStyle: { color: "#9ca3af" },
},
{
name: "Engaged",
type: "bar",
data: engaged,
itemStyle: { color: "#3b82f6" },
},
{
name: "Сделки",
type: "bar",
data: converted,
itemStyle: { color: "#0a7a3a" },
},
{
name: "Conversion %",
type: "line",
yAxisIndex: 1,
smooth: true,
data: conv,
lineStyle: { color: "#c2410c", width: 2 },
itemStyle: { color: "#c2410c" },
},
],
};
}, [data, months]);
return (
<ChartShell option={option} loading={isLoading} height={320} notMerge />
);
}

View file

@ -0,0 +1,67 @@
"use client";
import { useMemo } from "react";
import { usePrinzipFunnelBySource } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function PrinzipFunnelSankey({ months = 12 }: { months?: number }) {
const { data, isLoading } = usePrinzipFunnelBySource(months);
const option = useMemo(() => {
const rows = data ?? [];
if (rows.length === 0) {
return {
title: {
text: "Нет данных CRM (импортируй prinzip_crm.sqlite)",
left: "center",
top: "middle",
textStyle: { color: "#9ca3af", fontWeight: 400, fontSize: 13 },
},
xAxis: { show: false },
yAxis: { show: false },
};
}
const nodes = [
{ name: "Все заявки" },
{ name: "Engaged" },
{ name: "Converted (deals)" },
...rows.map((r) => ({ name: `📥 ${r.source}` })),
];
const links = [
...rows.map((r) => ({
source: `📥 ${r.source}`,
target: "Все заявки",
value: r.leads,
})),
{
source: "Все заявки",
target: "Engaged",
value: rows.reduce((s, r) => s + r.engaged, 0),
},
{
source: "Engaged",
target: "Converted (deals)",
value: rows.reduce((s, r) => s + r.converted, 0),
},
];
return {
tooltip: { trigger: "item" },
series: [
{
type: "sankey",
data: nodes,
links,
label: { fontSize: 12 },
lineStyle: { color: "gradient", curveness: 0.5 },
emphasis: { focus: "adjacency" },
},
],
};
}, [data]);
return (
<ChartShell option={option} loading={isLoading} height={360} notMerge />
);
}

View file

@ -0,0 +1,134 @@
"use client";
import Link from "next/link";
import { usePrinzipObjects } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
function Sparkline({ data }: { data: number[] }) {
if (!data || data.length === 0) {
return <span style={{ color: "#9ca3af", fontSize: 11 }}>нет данных</span>;
}
const option = {
grid: { left: 0, right: 0, top: 4, bottom: 4 },
xAxis: { type: "category", show: false, data: data.map((_, i) => i) },
yAxis: { type: "value", show: false, min: 0 },
series: [
{
data,
type: "line",
smooth: true,
symbol: "none",
lineStyle: { width: 2, color: "#1d4ed8" },
areaStyle: { color: "rgba(29, 78, 216, 0.15)" },
},
],
tooltip: {
trigger: "axis",
formatter: (params: { value: number; dataIndex: number }[]) =>
`мес ${params[0].dataIndex + 1}: ${params[0].value} шт`,
},
};
return <ChartShell option={option} height={36} notMerge />;
}
export function PrinzipObjectsTable() {
const { data, isLoading } = usePrinzipObjects();
if (isLoading) return <div style={{ color: "#5b6066" }}>Загрузка</div>;
const rows = data ?? [];
return (
<div style={{ overflowX: "auto" }}>
<table
style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
{[
"#",
"ЖК",
"Total",
"Sold",
"Sold %",
"Velocity (12 мес)",
"Сдача",
"",
].map((h) => (
<th
key={h}
style={{
padding: "10px 12px",
textAlign: "left",
fontWeight: 600,
borderBottom: "1px solid #e6e8ec",
color: "#374151",
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr
key={r.obj_id}
style={{
borderBottom: "1px solid #eef0f3",
background: i % 2 ? "#fafbfc" : "#fff",
}}
>
<td style={td}>{i + 1}</td>
<td style={td}>
<div style={{ fontWeight: 500 }}>{r.comm_name ?? "—"}</div>
<div style={{ fontSize: 11, color: "#73767e" }}>
{r.addr ?? ""}
</div>
</td>
<td style={td}>{r.total ?? "—"}</td>
<td style={td}>{r.realised ?? "—"}</td>
<td
style={{
...td,
fontWeight: 600,
color:
(r.perc ?? 0) >= 50
? "#0a7a3a"
: (r.perc ?? 0) >= 20
? "#9a6700"
: "#5b6066",
}}
>
{r.perc != null ? `${r.perc}%` : "—"}
</td>
<td style={{ ...td, width: 140 }}>
<Sparkline data={r.sparkline_realised} />
</td>
<td style={{ ...td, fontSize: 12, color: "#5b6066" }}>
{r.ready_dt?.slice(0, 7) ?? "—"} · {r.site_status ?? ""}
</td>
<td style={td}>
<Link
href={`/analytics/objects/${r.obj_id}`}
style={{
fontSize: 12,
color: "#1d4ed8",
textDecoration: "none",
padding: "4px 8px",
border: "1px solid #93c5fd",
borderRadius: 4,
whiteSpace: "nowrap",
}}
>
Drill-in
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
const td = { padding: "10px 12px" };

View file

@ -10,9 +10,18 @@ import type {
DeveloperTopRow, DeveloperTopRow,
DistrictRow, DistrictRow,
MarketPulsePoint, MarketPulsePoint,
ObjectDetail,
ObjectInfraPoi,
ObjectPhoto,
ObjectSaleGraphPoint,
ObjectSalesAggRow,
PipelineRow, PipelineRow,
PrinzipDistrictRow, PrinzipDistrictRow,
PrinzipFunnelMonthRow,
PrinzipFunnelObjectRow,
PrinzipFunnelSourceRow,
PrinzipInsights, PrinzipInsights,
PrinzipObjectRow,
QuartirographyDealsRow, QuartirographyDealsRow,
QuartirographyPortfolioRow, QuartirographyPortfolioRow,
YandexListingsSummary, YandexListingsSummary,
@ -123,3 +132,105 @@ export function usePrinzipDistricts() {
queryFn: () => apiFetch<PrinzipDistrictRow[]>(`${BASE}/prinzip/districts`), queryFn: () => apiFetch<PrinzipDistrictRow[]>(`${BASE}/prinzip/districts`),
}); });
} }
export function usePrinzipObjects() {
return useQuery({
queryKey: ["analytics", "prinzip-objects"],
queryFn: () => apiFetch<PrinzipObjectRow[]>(`${BASE}/prinzip/objects`),
});
}
export function usePrinzipFunnelMonthly(months = 24) {
return useQuery({
queryKey: ["analytics", "prinzip-funnel-monthly", months],
queryFn: () =>
apiFetch<PrinzipFunnelMonthRow[]>(
`${BASE}/prinzip/funnel/monthly?months=${months}`,
),
});
}
export function usePrinzipFunnelBySource(months = 12) {
return useQuery({
queryKey: ["analytics", "prinzip-funnel-by-source", months],
queryFn: () =>
apiFetch<PrinzipFunnelSourceRow[]>(
`${BASE}/prinzip/funnel/by-source?months=${months}`,
),
});
}
export function usePrinzipFunnelByObject() {
return useQuery({
queryKey: ["analytics", "prinzip-funnel-by-object"],
queryFn: () =>
apiFetch<PrinzipFunnelObjectRow[]>(`${BASE}/prinzip/funnel/by-object`),
});
}
// ── Per-object drill-in ─────────────────────────────────────────────────────
export function useObjectDetail(objId: number | string) {
return useQuery({
queryKey: ["analytics", "object", objId],
queryFn: () => apiFetch<ObjectDetail>(`${BASE}/object/${objId}`),
enabled: !!objId,
});
}
export function useObjectSaleGraph(objId: number | string, type?: string) {
return useQuery({
queryKey: ["analytics", "object-sale-graph", objId, type],
queryFn: () => {
const q = type ? `?type=${type}` : "";
return apiFetch<ObjectSaleGraphPoint[]>(
`${BASE}/object/${objId}/sale_graph${q}`,
);
},
enabled: !!objId,
});
}
export function useObjectSalesAgg(objId: number | string) {
return useQuery({
queryKey: ["analytics", "object-sales-agg", objId],
queryFn: () =>
apiFetch<ObjectSalesAggRow[]>(`${BASE}/object/${objId}/sales_agg`),
enabled: !!objId,
});
}
export function useObjectInfrastructure(
objId: number | string,
opts?: { category?: string; max_distance?: number },
) {
return useQuery({
queryKey: [
"analytics",
"object-infra",
objId,
opts?.category,
opts?.max_distance,
],
queryFn: () => {
const params = new URLSearchParams();
if (opts?.category) params.set("category", opts.category);
if (opts?.max_distance != null)
params.set("max_distance", String(opts.max_distance));
const qs = params.toString();
return apiFetch<ObjectInfraPoi[]>(
`${BASE}/object/${objId}/infrastructure${qs ? "?" + qs : ""}`,
);
},
enabled: !!objId,
});
}
export function useObjectPhotos(objId: number | string, limit = 100) {
return useQuery({
queryKey: ["analytics", "object-photos", objId, limit],
queryFn: () =>
apiFetch<ObjectPhoto[]>(`${BASE}/object/${objId}/photos?limit=${limit}`),
enabled: !!objId,
});
}

View file

@ -129,6 +129,111 @@ export interface PrinzipDistrictRow {
share_in_district_pct: number; share_in_district_pct: number;
} }
export interface ObjectDetail {
obj_id: number;
hobj_id: number | null;
comm_name: string | null;
addr: string | null;
short_addr: string | null;
region_cd: number | null;
dev_id: string | null;
dev_name: string | null;
floor_min: number | null;
floor_max: number | null;
flat_count: number | null;
square_living: number | null;
ready_dt: string | null;
site_status: string | null;
escrow: boolean | null;
obj_class: string | null;
latitude: number | null;
longitude: number | null;
obj_status: number | null;
snapshot_date: string | null;
}
export interface ObjectSaleGraphPoint {
report_month: string | null;
type: string;
realised: number | null;
contracted: number | null;
area_sq: number | null;
price_avg: number | null;
}
export interface ObjectSalesAggRow {
type: string;
name: string | null;
total: number | null;
realised: number | null;
perc: number | null;
}
export interface ObjectInfraPoi {
poi_name: string;
poi_subtitle: string | null;
poi_category: string | null;
poi_address: string | null;
lat: number | null;
lon: number | null;
distance_m: number | null;
}
export interface ObjectPhoto {
obj_file_id: string;
ord_num: number | null;
photo_url: string | null;
photo_dttm: string | null;
period_dt: string | null;
size_bytes: number | null;
photo_name: string | null;
ready_desc: string | null;
build_type: string | null;
local_path: string | null;
}
export interface PrinzipFunnelMonthRow {
month: string | null;
source: string;
leads: number;
engaged: number;
converted: number;
conv_pct: number | null;
}
export interface PrinzipFunnelSourceRow {
source: string;
leads: number;
engaged: number;
converted: number;
conv_pct: number | null;
}
export interface PrinzipFunnelObjectRow {
obj_id: number;
comm_name: string | null;
leads_count: number;
deals_count: number;
conv_pct: number | null;
total_revenue: number | null;
avg_deal_price: number | null;
}
export interface PrinzipObjectRow {
obj_id: number;
comm_name: string | null;
addr: string | null;
flat_count: number | null;
square_living: number | null;
ready_dt: string | null;
site_status: string | null;
total: number | null;
realised: number | null;
perc: number | null;
sparkline_realised: number[];
months: string[];
}
export interface PrinzipInsights { export interface PrinzipInsights {
headline: string; headline: string;
key_gaps: { key_gaps: {

File diff suppressed because one or more lines are too long