Связь ДКП Росреестра с listings (avito/cian/yandex/n1) через street
normalization + area window + listing-date window. Foundation для
sales-vs-asks comparison (discount_pct per pair).
DDL:
- data/sql/067_v_street_sales_vs_listings.sql:
* table-valued function street_sales_vs_listings(street, area, rooms,
window_days, area_tol) для filtered query с indexes
* view v_street_sales_vs_listings — full crossjoin для analytical queries
(used в reporting; для transactional path use TVF)
* Encapsulated JOIN logic: street ILIKE + area ±15% + listing_date в окне
[deal_date - window_days, deal_date]
* Computed columns: days_listing_to_deal, discount_pct
API:
- GET /api/v1/sales-vs-listings?address=...&area_m2=...&rooms=...
* Pydantic SalesVsListingsResponse с street/period/total_deals/
deals_with_listings/linkage_rate_pct/median_discount_pct/pairs list
* Использует extract_street_name() из existing estimator code
Schema:
- schemas/trade_in.py: SalesListingPair + SalesVsListingsResponse
Production verification:
- Migration applied на проде: CREATE FUNCTION + CREATE VIEW
- View total: 50,139 (deal, listing) pairs
- Sample case: «Бисертская» — ДКП 6.3 млн (86 м²) vs listing 8.5 млн (79 м²)
с listing_date за 4 дня до сделки → discount_pct = −25.88%
Tests: 8/8 pass в test_sales_vs_listings.py — happy path, no matches,
LEFT JOIN behavior, median discount, defaults, response shape.
Ruff clean.
Out of scope (separate PR L+M):
- Frontend extend StreetDealsCard «Связанные ASK»
- Estimator revert NOTE 2026-05-24 (rosreestr_deals в actual_deals)
Refs #564 (Phase 1 — backend foundation).
Replaces tradein.cad_buildings snapshot with live postgres_fdw foreign table reading gendesign.v_tradein_cad_buildings. Fixes /trade-in/api/v1/geocode/reverse 500 (Nominatim ban) and address_not_geocoded for cadastre addresses (e.g. Хохрякова 81).
Security (deep-review fixes):
- 100_tradein_fdw_role.sql: passwordless CREATE ROLE; password set by deploy.yml ALTER ROLE bootstrap reading GENDESIGN_FDW_PASSWORD from backend/.env.runtime (via psql :'pw' var → format %L — injection-safe).
- core/fdw.py: regex whitelist [A-Za-z0-9_-]{32,256} on password, ValueError without echoing value, try/rollback on commit.
- 060_postgres_fdw_extension.sql: connect_timeout='3' on FOREIGN SERVER + ALTER ADD/SET fallback.
- geocoder.py: _cadastral_forward_sync / _cadastral_reverse_sync wrapped in asyncio.to_thread.
- 100_*.sql: REVOKE ALL ON ALL TABLES/SEQUENCES/FUNCTIONS IN SCHEMA public; only GRANT SELECT on v_tradein_cad_buildings.
- pg_user_mappings query handles PUBLIC mapping (usename IS NULL).
Tests: 3 SQL-injection guards on ensure_fdw_user_mapping + rewritten cadastral suite.
AggregatedEstimate не нёс параметры целевой квартиры (площадь, этаж,
комнаты, год, тип дома). HeroSummary брал их из формы-инпута — у
свежей оценки работало, но при открытии оценки по ссылке (?id=, из
Истории) формы нет → «Сводка» показывала «ЭТАЖ 0/0, Площадь 0 м²».
Параметры теперь проходят сквозь весь стек:
- AggregatedEstimate + TS-тип получили area_m2/rooms/floor/
total_floors/year_built/house_type/repair_state/has_balcony;
- estimate_quality заполняет их из payload, get_estimate — из строки
trade_in_estimates (колонки там уже есть);
- HeroSummary читает из estimate с откатом на input.
Заодно в TS-тип добавлен пропущенный est_days_on_market.
При открытии оценки по ссылке (?id=) или перезагрузке страницы
get_estimate отдавал огрызок: без sources_used, confidence_explanation,
target_address, координат, data_freshness_minutes. Из-за этого карточка
«Сбор данных по квартире» пустела до «0 / 7 источников · ожидает
запрос» — хотя данные в БД есть.
Теперь SELECT и ответ включают полный набор полей AggregatedEstimate.
period_months 24 → 12 (совпадает с estimator / DEALS_PERIOD_MONTHS).
Заглушки навбара (href=#) → рабочие страницы.
- GET /api/v1/trade-in/history — последние оценки из trade_in_estimates.
- GET /api/v1/trade-in/cache-stats — счётчики данных/кэшей.
- app/history/page.tsx — таблица прошлых оценок, ссылки на отчёты.
- app/cache/page.tsx — состояние данных (объявления, сделки, кадастр,
кэш геокодера, последний парсинг).
- navbar — ссылки История / Кэш ведут на страницы.
Closes#399
Фото квартиры прикрепляются к оценке, хранятся в Postgres (bytea) —
без отдельного файлового тома, уезжают вместе с бэкапом БД.
- data/sql/007_estimate_photos.sql — таблица estimate_photos
(FK на trade_in_estimates, ON DELETE CASCADE).
- POST /estimate/{id}/photos — multipart-загрузка (image/*, ≤10 МБ,
≤12 фото на оценку).
- GET /estimate/{id}/photos — список метаданных.
- GET /estimate/{id}/photos/{photo_id} — отдать содержимое.
- python-multipart добавлен в зависимости (FastAPI UploadFile).
Фронтенд (file-drop в форме) — отдельным PR.