gendesign/tradein-mvp
bot-backend d63702cfec
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m44s
fix(tradein/geocoder): литера дома — часть идентичности, а не tie-break
`_cadastral_house_match` сравнивал дом только по ЦИФРАМ: литера была
опциональна в WHERE (`[а-яё]?`) и участвовала лишь как tie-break в
ORDER BY. Баг двусторонний и молчаливый:

  «Новгородцевой 13б» → «дом 13»   (дома 13б в реестре нет вообще)
  «Малышева 30»       → «д. 30-б»  (обратное направление, живой прод-кейс)

Оба результата возвращались с `confidence="exact"` и оседали в
`geocode_cache` на 90 дней — пользователь получал оценку ЧУЖОГО здания,
помеченную как точная.

Почему так: номер дома теперь сравнивается РАВЕНСТВОМ нормализованных
форм. Обе стороны приводятся к одному канону (`13б` / `13 б` / `13-б` /
`13Б` → `13б`): запрос — существующим `_norm_house`, реестр — тем же
выражением на стороне Postgres. Дешёвый regex-anchor по цифрам оставлен
prefilter'ом (пушится в FDW, режет «(1-83)»-диапазоны), но корректность
теперь на равенстве, а не на нём.

Побочно закрыты той же логикой:
  • «58» больше не матчит «58/3», «13» не матчит «130» — угловой номер
    это часть номера дома, а не мусор;
  • маркер дома ищется только с начала слова (`\m`), иначе «проезд 8
    Марта, д 5» давал дом «8» — старый `д\.?` ловил «д» внутри «проезд»
    (20 таких строк реестра были достижимы ТОЛЬКО через этот баг);
  • номер дома больше не конкатенируется в regex — regex-injection
    поверхность сузилась до цифр prefilter'а.

Промаха не превращаем в тихую подстановку соседа: нет дома с нужной
литерой → None, дальше отрабатывают следующие тиры (forward → DaData →
Nominatim). Понижать confidence здесь нечем — `GeocodeSuggestion` его не
несёт, все локальные тиры хардкодят "exact"; честный фолбэк дешевле, чем
протаскивать новый канал уверенности через три тира.

DaData-тир, который должен подхватывать такие адреса, был мёртв: в
`locations` уходило `region="Свердловская область"`, тогда как DaData
хранит имя региона БЕЗ типа (`region="Свердловская"`, `region_type="обл"`).
Hard-filter не совпадал ни с чем и молча схлопывал выдачу в 0 подсказок
(замер на проде: 0 хитов против 5 с «Свердловская», первый — искомый
«д 13б» с fias_id). Добавлен warning на пустую выдачу под region-
констрейнтом, чтобы следующая такая регрессия не была невидимой.

Миграция 203 инвалидирует уже отравленные записи `geocode_cache` —
точечно (запрос с литерой, либо запрос без литеры с закэшированным
домом С литерой), а не весь кэш: полная очистка сожгла бы квоту DaData
на ре-резолв заведомо корректных адресов.

Проверено: SQL-выражение прогнано против живого реестра (47k строк) —
«13б»→∅, «13»→дом 13, «30»→сооружение 30 (не «30-б»), «23б»→«д. 23-б»;
предикат миграции — против таблицы кейсов (4 delete / 8 keep).
Полный pytest: 1 failed, 3123 passed — падение
`test_search_api.py::test_search_cache_hit` (401) воспроизводится и на
чистом main (baseline 1 failed, 3081 passed), к этому изменению
отношения не имеет.

НЕ проверено вживую: ответ DaData с новым region-констрейнтом (нет
доступа к прод-токену из этой сессии) — опираюсь на замер из отчёта об
issue и на модель данных DaData.
2026-08-02 13:30:16 +03:00
..
backend fix(tradein/geocoder): литера дома — часть идентичности, а не tie-break 2026-08-02 13:30:16 +03:00
browser feat(tradein/domclick): cookie-injection MVP для обхода QRATOR-блока (#2000) (#2433) 2026-07-04 17:49:40 +00:00
deploy fix(tradein/ingest): раз-хардкод rosreestr DKP импорта на всю обл. 66 2026-07-12 18:11:47 +03:00
docs feat(tradein): канонический матч ГАР↔houses (0%→41% yield) + tradein_canon_addr + --match-only (#2063) 2026-06-28 15:50:07 +00:00
frontend feat(mera): публичный лэндинг «МЕРА» — контент и вёрстка 2026-08-01 23:01:19 +03:00
ops fix(sf): psql stdin-bootstrap пароля gendesign_reader + COALESCE-protect внешних id (review fixup, #976) 2026-06-12 11:26:40 +03:00
packages/scraper-kit fix(tradein/scraper): проставлять город объявления из контекста развёртки (#2594) 2026-07-31 22:14:47 +03:00
scripts chore(tradein/scrapers): удалить весь legacy scrapers/ каталог — final E (#2397, #2277) 2026-07-04 15:58:15 +03:00
.dockerignore chore(scraper-kit): docker-context wiring + restore backend workspace dep (#2137) 2026-07-02 14:21:38 +00:00
.env.example chore(tradein/geocoder): убрать мёртвую YANDEX_GEOCODER_API_KEY из .env.example (#2593) 2026-07-31 23:36:52 +03:00
.gitignore feat: add tradein-mvp subproject (Trade-In Estimator под /trade-in) 2026-05-21 00:25:39 +03:00
DEPLOY.md chore(tradein/geocoder): удалить остатки скриптов Яндекс-геокодера, часть 3 (#2593) 2026-07-31 23:16:19 +03:00
docker-compose.prod.yml fix(tradein/devops): проверка всего стека после деплоя + запас на остановку (#2540) 2026-07-26 22:57:41 +00:00
docker-compose.yml chore(scraper-kit): docker-context wiring + restore backend workspace dep (#2137) 2026-07-02 14:21:38 +00:00
Makefile feat: add tradein-mvp subproject (Trade-In Estimator под /trade-in) 2026-05-21 00:25:39 +03:00
pyproject.toml chore(scraper-kit): scaffold internal package + uv workspace, build-safe (#2128) 2026-07-02 14:06:07 +00:00
README.md feat: add tradein-mvp subproject (Trade-In Estimator под /trade-in) 2026-05-21 00:25:39 +03:00
uv.lock feat(tradein/auth): password hashing + session config foundation (#2550) 2026-07-30 09:40:51 +03:00

Trade-In MVP

Локальный standalone-форк фичи Trade-In Estimator из проекта gendesign — оценка выкупной стоимости квартиры на вторичном рынке по аналогам и реальным сделкам. Layout повторяет PDF-отчёт «Брусника.Обмен» (см. docs/).

Что это и откуда взято

Источник Что Где
gendesign/main PR #316 TI-1 mock endpoint + Pydantic + SQL migration backend/
gendesign/main PR #317 TI-3 Next.js страница + 5 компонентов + hooks frontend/
gendesign/main PR #319 TI-2 PDF export 4 страницы (как у Брусники) backend/app/services/exporters/
gendesign/main PR #283 статичный tradein.html mockup (для Геныча) frontend/public/tradein.html
Встреча 19.05.2026 («Птица») требования к MVP оценки вторички docs/PTITSA_MEETING_2026-05-19.pdf
PDF Брусники EКБ-2485 референс layout-а отчёта docs/BRUSNIKA_REFERENCE_EKB-2485.pdf

Быстрый старт

make up              # build + up весь стек (caddy + frontend + backend + postgres)
open http://localhost:8080

Откроется / → автоматически редирект на /trade-in. Заполняешь форму (адрес/площадь/комнаты/этаж/...), нажимаешь «Оценить» — backend возвращает mock-оценку, фронт показывает median + диапазон цен + список аналогов.

Проверка backend напрямую:

make test-estimate
# или вручную:
curl -sS -X POST http://localhost:8080/api/v1/trade-in/estimate \
  -H 'Content-Type: application/json' \
  -d '{"address":"ул. Малышева, 1","area_m2":54,"rooms":2,"floor":5,"total_floors":17}' \
  | python3 -m json.tool

OpenAPI документация: http://localhost:8000/docs

Для сравнения макет vs реальная фича:

Структура

tradein-mvp/
├── docker-compose.yml         # caddy + frontend + backend + postgres
├── Makefile                   # удобные команды (up/down/logs/test-estimate)
├── deploy/
│   └── Caddyfile              # local reverse-proxy на http://localhost:8080
├── backend/                   # FastAPI + WeasyPrint
│   ├── Dockerfile
│   ├── pyproject.toml
│   ├── app/
│   │   ├── main.py            # FastAPI entry — только trade-in router
│   │   ├── core/
│   │   │   ├── config.py      # минимальный pydantic-settings
│   │   │   └── db.py          # SQLAlchemy engine + get_db
│   │   ├── api/v1/
│   │   │   └── trade_in.py    # 3 endpoint'а: POST /estimate, GET /estimate/{id}, GET /estimate/{id}/pdf
│   │   ├── schemas/
│   │   │   └── trade_in.py    # Pydantic: TradeInEstimateInput / AnalogLot / AggregatedEstimate
│   │   └── services/exporters/
│   │       └── trade_in_pdf.py  # WeasyPrint → 4-страничный PDF (cover/listings/deals/offer)
│   └── data/sql/
│       └── 001_trade_in_estimates.sql  # CREATE TABLE; применяется при первом старте postgres
├── frontend/                  # Next.js 15 + React 19 + TanStack Query
│   ├── Dockerfile
│   ├── package.json
│   ├── next.config.ts         # rewrites /api/* → backend
│   ├── tsconfig.json
│   ├── src/
│   │   ├── app/
│   │   │   ├── layout.tsx
│   │   │   ├── page.tsx       # redirect → /trade-in
│   │   │   ├── globals.css
│   │   │   ├── providers.tsx  # QueryClientProvider
│   │   │   └── trade-in/
│   │   │       └── page.tsx
│   │   ├── components/trade-in/
│   │   │   ├── EstimateForm.tsx       # форма ввода (sticky 360px)
│   │   │   ├── EstimateProgress.tsx   # индикатор «Парсим Циан → Авито → ...»
│   │   │   ├── EstimateResult.tsx     # карточка результата
│   │   │   ├── PriceRangeBar.tsx      # визуализация диапазона цен (как у Брусники)
│   │   │   └── AnalogsTable.tsx       # таблица аналогов
│   │   ├── lib/
│   │   │   ├── api.ts                 # apiFetch + HTTPError
│   │   │   ├── sessionId.ts           # X-Session-Id из localStorage
│   │   │   └── trade-in-api.ts        # useEstimateMutation + useEstimate hooks
│   │   └── types/
│   │       └── trade-in.ts            # TS типы зеркалят Pydantic schemas
│   └── public/
│       └── tradein.html              # статичный mockup от 17.05 (для side-by-side review)
└── docs/
    ├── BRUSNIKA_REFERENCE_EKB-2485.pdf  # эталон layout-а
    └── PTITSA_MEETING_2026-05-19.pdf    # AI-протокол встречи с требованиями

API

POST /api/v1/trade-in/estimate — оценить квартиру.

Запрос:

{
  "address": "ул. Малышева, 1, кв. 5, Екатеринбург",
  "area_m2": 54.0,
  "rooms": 2,
  "floor": 5,
  "total_floors": 17,
  "year_built": 1985,
  "house_type": "panel",
  "repair_state": "good",
  "has_balcony": true
}

Ответ:

{
  "estimate_id": "...uuid...",
  "median_price_rub": 13125000,
  "range_low_rub": 11550000,
  "range_high_rub": 14700000,
  "median_price_per_m2": 243056,
  "confidence": "high",
  "n_analogs": 8,
  "period_months": 24,
  "analogs": [ {"address": "...", "area_m2": 56, "price_rub": 12700000, ...} ],
  "actual_deals": [ ... ],
  "expires_at": "2026-05-20T22:48:00Z"
}

GET /api/v1/trade-in/estimate/{id} — получить сохранённую оценку (TTL 24ч) GET /api/v1/trade-in/estimate/{id}/pdf — скачать 4-страничный PDF (cover / listings / deals / offer)

Что внутри _mock_estimate() (текущая реализация)

Формула:

price = base_price_by_rooms × floor_factor × repair_factor
Поле Значения
Базовая цена ЕКБ 2026 студия 6.5M (260K/м²) · 1к 9.0M (225K/м²) · 2к 12.5M (208K/м²) · 3к 17.0M (213K/м²)
floor_factor 1-й этаж = ×0.95, последний = ×0.97, остальные = ×1.00
repair_factor needs_repair = ×0.90, standard = ×1.00, good = ×1.05, excellent = ×1.10
confidence 1-3 комнаты = high, остальные = medium
Улицы аналогов реальные центральные ЕКБ (Малышева, Куйбышева, 8 Марта, Белинского, пр. Ленина, Толмачёва, Радищева, Мамина-Сибиряка, Луначарского, Первомайская)

Каждая оценка сохраняется в trade_in_estimates с TTL 24 часа — UUID можно использовать для shareable links и PDF-экспорта.

Roadmap — что доделать

Phase 1 — заменить mock на реальные данные (TODO TI-1b из gendesign)

Сейчас _mock_estimate() возвращает хардкод. На встрече Птица 19.05 решили:

  • источники: Циан, Авито, Дом.Клик, Я.Недвижимость, Н1, Дом РФ
  • Объектив НЕ использовать на вторичке (он про первичку/ДДУ)
  • Росреестр для исторических сделок (квартал глубины)
  • картография ЕКБ для проверки этажности/года/планировок

Phase 2 — то что обсуждали на встрече

Задача Из протокола Птицы
Парольный вход + учёт пользователей + аналитика 0:23:44, 0:25:54
Доступ только Геныч / Загайнов / Паша (НЕ Рожкова) 0:25:50, 0:22:35
PDF-отчёт под паролем 0:08:39
Real-time парсинг ≥1/час чтобы ловить быстрые продажи 0:40:19
MVP к понедельнику 25.05.2026 0:26:12
Демо для девелопера в четверг 28.05.2026 0:18:08

Phase 3 — следующие продукты (упоминалось на встрече)

  1. Птица — анализ участков + расселение домов (≥20% квартир дома в продаже → подсветить можно расселять)
  2. Расселение как сервис — следствие #1 и Птицы

См. полный протокол: docs/PTITSA_MEETING_2026-05-19.pdf.

Как это связано с прод gendesign

Аспект Прод (gendsgn.ru) Этот MVP
URL https://gendsgn.ru/trade-in http://localhost:8080/trade-in
Backend shared FastAPI /api/v1/trade-in/* то же самое, standalone
Frontend Next.js 15 в большом monorepo тот же код, standalone
Postgres 84 таблицы, 6.83M ДДУ partitioned только trade_in_estimates (1 таблица)
Caddy TLS + 5 доменов + reverse-proxy local :8080 без TLS
Что отрезано site-finder, analytics, generative, scraper, OSM, NSPD, sentry, celery, redis, playwright всё это — кроме trade-in

Важно: эти два инстанса полностью изолированы. Локальный backend пишет в свой Postgres контейнер (порт 5433), не трогает прод. Можно сломать локально что угодно — прод не пострадает.

Лицензия

Internal use only. Forked from gendesign monorepo (private).