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:
parent
7c4197000f
commit
522ecca5c4
21 changed files with 2053 additions and 28 deletions
16
.github/workflows/deploy.yml
vendored
16
.github/workflows/deploy.yml
vendored
|
|
@ -11,6 +11,7 @@ concurrency:
|
|||
|
||||
env:
|
||||
IMAGE_BACKEND: ghcr.io/lekss361/gendesign-backend
|
||||
IMAGE_WORKER: ghcr.io/lekss361/gendesign-worker
|
||||
IMAGE_FRONTEND: ghcr.io/lekss361/gendesign-frontend
|
||||
|
||||
jobs:
|
||||
|
|
@ -37,10 +38,11 @@ jobs:
|
|||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build & push backend
|
||||
- name: Build & push backend (lean — без Chromium)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./backend
|
||||
target: runner
|
||||
push: true
|
||||
cache-from: type=gha,scope=backend
|
||||
cache-to: type=gha,mode=max,scope=backend
|
||||
|
|
@ -48,6 +50,18 @@ jobs:
|
|||
${{ env.IMAGE_BACKEND }}:latest
|
||||
${{ 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
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -30,6 +30,8 @@ out/
|
|||
|
||||
# Local data (raw CSVs from Rosreestr/etc — too large for git)
|
||||
data/raw/
|
||||
data/raw/prinzip_crm.sqlite
|
||||
data/raw/domrf_photos/
|
||||
*.csv
|
||||
*.csv.zip
|
||||
!backend/db/init/*.sql
|
||||
|
|
|
|||
50
README.md
50
README.md
|
|
@ -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`.
|
||||
- **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 фото-метаданных.
|
||||
- **Расписание + ручной триггер** — 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.
|
||||
|
||||
### Запуск 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
|
||||
|
|
@ -166,6 +202,14 @@ Acceptance criteria разбиты на 3 блока (функционально
|
|||
| `kn_scrape_runs` | 3 | журнал прогонов парсера: regions, developers, objects_count, flats_count, requests_count, status |
|
||||
| `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_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
|
||||
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`.
|
||||
|
||||
### 🎯 Аналитика — что делать с этими данными
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
# 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 ----
|
||||
FROM python:3.12-slim AS builder
|
||||
|
|
@ -28,14 +35,13 @@ COPY alembic.ini ./
|
|||
COPY alembic ./alembic
|
||||
|
||||
|
||||
# ---- runner ----
|
||||
# ---- runner (lean — без Chromium) ----
|
||||
FROM python:3.12-slim AS runner
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Runtime libs only — no -dev / no build-essential.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
libgdal36 \
|
||||
|
|
@ -46,8 +52,31 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
libpangoft2-1.0-0 \
|
||||
fonts-dejavu-core \
|
||||
curl \
|
||||
# Chromium runtime libs for Playwright (отдельно вместо --with-deps, чтобы apt-lists
|
||||
# были доступны до самой команды playwright install).
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& 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 \
|
||||
libnspr4 \
|
||||
libdbus-1-3 \
|
||||
|
|
@ -62,20 +91,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
libxrandr2 \
|
||||
libgbm1 \
|
||||
libasound2t64 \
|
||||
libatspi2.0-0 \
|
||||
&& useradd --create-home --uid 1000 app
|
||||
libatspi2.0-0
|
||||
|
||||
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
|
||||
|
||||
# Playwright Chromium binary (~150 MB) — устанавливаем под root, кладём в общую
|
||||
# директорию, чтобы и app-user мог использовать. Без --with-deps: системные
|
||||
# библиотеки уже поставлены apt-get выше, а --with-deps требует apt-lists
|
||||
# которые мы тут же чистим в одной команде ради размера слоя.
|
||||
# Playwright Chromium binary под root → shared dir, чтобы и app-user мог использовать.
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright
|
||||
RUN mkdir -p $PLAYWRIGHT_BROWSERS_PATH \
|
||||
&& /app/.venv/bin/playwright install chromium \
|
||||
|
|
@ -83,7 +101,3 @@ RUN mkdir -p $PLAYWRIGHT_BROWSERS_PATH \
|
|||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
|
|||
|
|
@ -103,6 +103,61 @@ def developer_portfolio(
|
|||
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 -------------------------------------------------------
|
||||
|
||||
|
||||
|
|
@ -115,3 +170,35 @@ def prinzip_insights() -> dict[str, Any]:
|
|||
@router.get("/prinzip/districts")
|
||||
def prinzip_districts(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
301
data/sql/52_import_prinzip_crm.py
Normal file
301
data/sql/52_import_prinzip_crm.py
Normal 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()
|
||||
108
data/sql/52_schema_prinzip_crm.sql
Normal file
108
data/sql/52_schema_prinzip_crm.sql
Normal 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.';
|
||||
|
|
@ -71,6 +71,30 @@ services:
|
|||
depends_on:
|
||||
- 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:
|
||||
image: caddy:2
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ services:
|
|||
retries: 5
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
build:
|
||||
context: ./backend
|
||||
target: runner
|
||||
env_file: ./backend/.env
|
||||
depends_on:
|
||||
postgres:
|
||||
|
|
@ -55,7 +57,9 @@ services:
|
|||
- backend
|
||||
|
||||
worker:
|
||||
build: ./backend
|
||||
build:
|
||||
context: ./backend
|
||||
target: runner-with-chromium # +Playwright Chromium для kn-scraper
|
||||
env_file: ./backend/.env
|
||||
depends_on:
|
||||
postgres:
|
||||
|
|
@ -64,10 +68,13 @@ services:
|
|||
condition: service_started
|
||||
volumes:
|
||||
- ./backend/app:/app/app
|
||||
- ./data:/app/data # storage_state.json + photo binaries
|
||||
command: ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=2"]
|
||||
|
||||
beat:
|
||||
build: ./backend
|
||||
build:
|
||||
context: ./backend
|
||||
target: runner # lean — beat только триггерит, без Chromium
|
||||
env_file: ./backend/.env
|
||||
depends_on:
|
||||
redis:
|
||||
|
|
|
|||
145
frontend/src/app/analytics/objects/[id]/page.tsx
Normal file
145
frontend/src/app/analytics/objects/[id]/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,10 @@ import { useState } from "react";
|
|||
import { InsightCards } from "@/components/analytics/InsightCards";
|
||||
import { KpiCard } from "@/components/analytics/KpiCard";
|
||||
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 { PrinzipObjectsTable } from "@/components/analytics/PrinzipObjectsTable";
|
||||
import { PrinzipQuartirographyPie } from "@/components/analytics/PrinzipQuartirographyPie";
|
||||
import { PrinzipVelocityChart } from "@/components/analytics/PrinzipVelocityChart";
|
||||
import { Section } from "@/components/analytics/Section";
|
||||
|
|
@ -133,6 +136,35 @@ export default function PrinzipPage() {
|
|||
<PrinzipDistrictsBar />
|
||||
</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
|
||||
title="Инсайты и рекомендации"
|
||||
subtitle="На основе анализа портфеля + реальных ДДУ-сделок Свердл (PRINZIP_Strategy_Apr27)."
|
||||
|
|
|
|||
170
frontend/src/components/analytics/ObjectInfraMap.tsx
Normal file
170
frontend/src/components/analytics/ObjectInfraMap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
frontend/src/components/analytics/ObjectPhotoGallery.tsx
Normal file
116
frontend/src/components/analytics/ObjectPhotoGallery.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/analytics/ObjectSaleChart.tsx
Normal file
81
frontend/src/components/analytics/ObjectSaleChart.tsx
Normal 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 />
|
||||
);
|
||||
}
|
||||
96
frontend/src/components/analytics/PrinzipFunnelMonthly.tsx
Normal file
96
frontend/src/components/analytics/PrinzipFunnelMonthly.tsx
Normal 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 />
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/analytics/PrinzipFunnelSankey.tsx
Normal file
67
frontend/src/components/analytics/PrinzipFunnelSankey.tsx
Normal 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 />
|
||||
);
|
||||
}
|
||||
134
frontend/src/components/analytics/PrinzipObjectsTable.tsx
Normal file
134
frontend/src/components/analytics/PrinzipObjectsTable.tsx
Normal 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" };
|
||||
|
|
@ -10,9 +10,18 @@ import type {
|
|||
DeveloperTopRow,
|
||||
DistrictRow,
|
||||
MarketPulsePoint,
|
||||
ObjectDetail,
|
||||
ObjectInfraPoi,
|
||||
ObjectPhoto,
|
||||
ObjectSaleGraphPoint,
|
||||
ObjectSalesAggRow,
|
||||
PipelineRow,
|
||||
PrinzipDistrictRow,
|
||||
PrinzipFunnelMonthRow,
|
||||
PrinzipFunnelObjectRow,
|
||||
PrinzipFunnelSourceRow,
|
||||
PrinzipInsights,
|
||||
PrinzipObjectRow,
|
||||
QuartirographyDealsRow,
|
||||
QuartirographyPortfolioRow,
|
||||
YandexListingsSummary,
|
||||
|
|
@ -123,3 +132,105 @@ export function usePrinzipDistricts() {
|
|||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,6 +129,111 @@ export interface PrinzipDistrictRow {
|
|||
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 {
|
||||
headline: string;
|
||||
key_gaps: {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue