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:
|
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
2
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
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`.
|
- **Веб-аналитика** (`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`.
|
||||||
|
|
||||||
### 🎯 Аналитика — что делать с этими данными
|
### 🎯 Аналитика — что делать с этими данными
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
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 { 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)."
|
||||||
|
|
|
||||||
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,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Reference in a new issue