Повторная проверка /coverage закрыла оба MAJOR из #2894, но выявила три
новых дефекта:
1. Город больше не резолвится из моды listings.city найденной когорты —
эта колонка хранит город SWEEP-контекста скрейпера (миграция 196), не
геокод адреса объявления. Замер на проде: 90/90 строк в радиусе 1000м
вокруг Берёзовского имеют city='Екатеринбург', 74/74 вокруг Ревды —
city='Первоуральск'. Города-спутники из COVERAGE_GREEN/YELLOW_CITIES были
физически недостижимы. Город теперь резолвится детерминированно по
lat/lon запроса — ближайший центроид из статичной константы (8 городов,
рядом с ручкой, не в БД — comment объясняет почему) в пределах 25 км.
city_hint остаётся в схеме (фронт его шлёт для соседних ручек), но чисто
информационный — на порог/статус не влияет.
2. test_max_age_outlier_days_passed_to_sql проверял подстроку, которая
встречается в SQL дважды (count и percentile_cont) — мутация «убрать
FILTER у percentile_cont, оставив у count» проходила зелёной. Добавлен
живой поведенческий тест (вставляет когорту + выброс days_on_market=4000,
проверяет что медиана не сдвигается) — ловит эту мутацию (подтверждено:
median 8→9 при мутации).
3. _live_session() вызывался в pytest.mark.skipif на этапе сбора тестов и
создавал никогда не закрываемый Session, плюс дублировался в теле теста.
Заменено на _live_db_available() (open+close голого connection) для
skipif и pytest-фикстуру live_session с гарантированным close/dispose.
4. Nit: пустая когорта в поддерживаемом городе отдавала status=not_covered
вместе с ненулевым threshold — противоречило докстрингу
CoverageProbeResponse.threshold ("0, когда порог неприменим"). threshold
теперь всегда 0 при not_covered, независимо от причины.
Independent review found two MAJOR defects in POST /api/v1/trade-in/coverage:
MAJOR-1: the probe cohort WHERE clause was missing three predicates present
in estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision !=
'city', price_rub > 0) — the free probe could answer "ok" at points where
the paid estimator's own 1000m radius tier sees zero real analogs. Prod
example: 56.868904/60.837955, 2 rooms, 50 m2 gave n_listings=22/status=ok
while the estimator's cohort at the same radius was 0 (all 54 rows were
novostroyki). Added the three predicates verbatim from estimator.py, plus
both a static SQL-text regression test and a real-Postgres integration test
(skip_allowlist.txt, same _live_session() pattern as test_gar_flats_loader)
that inserts novostroyka/geo_precision=city/price=0 rows and asserts they
are not counted.
MAJOR-2: median_listing_age_days was computed from days_on_market, which on
prod is populated almost exclusively by one source (yandex) — thin cohorts
produced a "median" over 1-2 listings. Added n_with_age to the response
(honest count of listings the median is based on); median is now null below
COVERAGE_MIN_AGE_SAMPLES=5, and values above COVERAGE_MAX_AGE_DAYS=365 (near
-certainly dead listings, per prod: 15% of fresh yandex rows exceed 365d,
max 4261d) are excluded as outliers before the percentile is computed.
MINOR: city_hint was trusted at face value and echoed back verbatim — a
client could pass city_hint="Екатеринбург" with coordinates in Серов and get
threshold=8/status=ok. _resolve_coverage_city now prioritizes the SQL
cohort's mode city (ground truth) over the client hint, falling back to hint
only when the cohort is empty (where status is forced not_covered anyway).
Unmatched cities no longer echo the raw client string in the city field.
POST /api/v1/trade-in/coverage — до оплаты пользователь видит только n похожих
объявлений в радиусе 1000м и медианный возраст листинга, без единой цены.
Один SQL (радиус GIST + rooms + area ±15% + freshness 14д + тот же дедуп/cap-
канон, что у estimator._fetch_analogs), ноль внешних вызовов, ноль записей.
Пороги ok/thin/not_covered — константы рядом с ручкой (зелёные города >=8,
жёлтые >=12, остальные всегда not_covered). Поле median_listing_age_days
(не "срок продажи" — возраст активного объявления, цензурированная выборка).
RBAC не тронут — путь остаётся закрытым, открытие анонимного периметра
вынесено в #2895.
28/1084 прод-оценок имели lat IS NULL — гарантированный ноль аналогов, клиент
не получал оценку вовсе. Дом уже был в houses (скрейпленные листинги), но не
резолвился ни geoportal/cad_buildings, ни Nominatim: разговорное/усечённое имя
улицы («Онуфриева» вместо ГАР-каноничного «Начдива Онуфриева») или отсутствующий
в вводе корпус («49» вместо реального «49к1»). Добавлен последний тир geocode()
с двумя defensive-допущениями (суффиксный матч улицы + опциональная догадка
«номер+к1») — при любой неоднозначности возвращает None, а не гадает; проверено
живыми прод-адресами (Онуфриева/Хрустальногорская резолвятся, Крестинского
корректно остаётся неоднозначным — два разных дома в houses под одним номером).
Отдельно: HTTP 403 «услуга CLEAN выключена на аккаунте» логировался как ERROR
на каждый /estimate (164 события) — это статичная конфигурация аккаунта, а не
сбой; понижено до WARNING (первый раз за процесс) + DEBUG на повторы, чтобы
ERROR продолжал значить настоящую проблему.
main уехал вперёд за сутки: 234 занял 234_scrape_runs_ban_kind_unknown.sql
(0de22f4b), максимум на main сейчас 239 (235-237 — дыры). max+1=240 безопаснее
дыр; ни один открытый PR номер 235-240 не занимает (сверено по forgejo/main и
всем открытым веткам).
Переименован файл + обновлены все 7 упоминаний "migration 234"
(_manifest_applied.txt, config.py, schemas/trade_in.py,
purge_expired_trade_in_data.py, test_estimate_idor.py, content.ts,
types/trade-in.ts) — правки текстовые, ни один тест не читает миграцию по
имени файла.
Мина: purge_expired_trade_in_data (сейчас enabled=false) удаляет строки
WHERE expires_at < NOW() AND created_by IS NULL — это ровно популяция
будущих платящих физлиц (владелец продаёт отчёт за 150 руб., отчёт должен
жить год на нашей стороне, а не 24ч). Первый прогон после запуска продаж
безвозвратно снёс бы оплаченное.
Делается ДО платёжного кода, которого в этом PR нет:
- migration 234: колонка trade_in_estimates.retain_until (NULL = неоплачено,
бэкенд-бита-в-бит не меняется) + частичный индекс под purge-предикат.
- config.py: trade_in_paid_retention_days=365 (ENV) — единственный источник
"12 месяцев" для будущей оферты/экрана/SQL продления.
- Единый гейт чтения ESTIMATE_READABLE_SQL + estimate_readable() — раньше
SQL-фильтр (404) и Python-проверка (410) в trade_in.py уже разошлись по
тексту ответа; текст "estimate expired (24h TTL)" убран (стал бы ложью при
годовом хранении).
- purge_expired_trade_in_data: retain_until IS NULL (не < NOW() — оплаченное
не удаляем в принципе) + NOT EXISTS(payments) как независимая страховка +
pre-flight, который считает оплаченных кандидатов и падает в mark_failed
ДО первого батча при ненулевом результате.
- PDF: "Ссылка доступна до …" только при retain_until IS NOT NULL;
"ДЕЙСТВИТЕЛЕН ДО" (expires_at, актуальность расчёта) не тронут.
- Фронт: retain_until прокинут в mapper (validUntil остаётся на expires_at).
- privacy-страница: убрано устаревшее "механизма удаления нет" (неправда
после #2547), добавлен срок 12 месяцев для оплаченных отчётов.
Ни строчки платёжного кода. expires_at, trade_in_estimate_retention_hours,
_DELETE_EXPIRED_LEADS_SQL не тронуты.
192/193 -> 229/230: main занял 192_tradein_users_auth.sql и
193_tradein_users_seed.sql за время простоя PR. 228 зарезервирован
открытым PR #2732 (228_payments.sql) - следующие реально свободные
229/230, порядок consent_proof -> retention сохранён.
Правки ссылок на старые имена/префиксы: docstring-заголовки самих
SQL-файлов, перекрёстная ссылка 229 -> 230 в комментарии-докстринге,
комментарии migration 192/193 в lead.py / config.py / schemas/trade_in.py
/ purge_expired_trade_in_data.py, переменные и имена тестов в
test_estimate_consent_gate.py / test_purge_expired_trade_in_data.py.
(Оставлены нетронутыми ссылки на migration 192/193 в auth_session.py и
test_team_api.py - это про другие, уже существующие на main миграции
192_tradein_users_auth.sql / 193_tradein_users_seed.sql, не про эту
пару.)
Три находки эпика #2674 про верхние тиры матчинга домов. Замеры — прод
tradein-postgres, 2026-08-05/06.
Кадастр от площадок не приходит вообще. listings.cadastral_number (кадастр
КВАРТИРЫ) — 0 из 93 408; единственный писатель, парсер Циана, читает
offer["cadastralNumber"], которого в ответе нет. Все 28 504 заполненных
building_cadastral_number на 100% пришли из локального гео-зеркала ЕГРН
(tasks/cadastral_geo_match.py, KNN <=50 м) — проверено джойном к
cad_buildings_local. Поэтому снят фильтр поиска has_kadastr: предикат
`cadastral_number IS NOT NULL` мог вернуть только пустую выдачу. Колонка и
писатель оставлены — заработают сами, если площадка начнёт отдавать кадастр.
Tier 0 cadastr_exact оставлен, но не подключён к гео-кадастру. Он достижим по
построению (ScrapedLot -> адаптер -> матчер), просто данных нет; подать туда
KNN-заполнение НЕЛЬЗЯ: как ключ здания оно не инъективно — 656 из 3 260
значений накрывают >1 здание ГАР (20.1%), 751 из 2 864 зданий получают >1
значение (26.2%). Это был бы over-merge с confidence 1.0. Заодно исправлено
ложное утверждение в шапке cadastral_geo_match.py, будто Tier 0 трактует эту
колонку как подсказку.
Tier 0.5 fias_exact удалён из match_or_create_house. Параметра house_fias_id
не было ни в Protocol scraper_kit.contracts.HouseMatcher, ни в
RealMatcherAdapter, ни у двух прямых вызывающих — передать его было некому.
В match_house_readonly тир оставлен: у estimate-пути источник ФИАС есть
(payload.target_fias_id / DaData).
Что чинит сопоставление на самом деле: ключ идентичности в house_dedup_merge
расширен с house_fias_id до COALESCE(house_fias_id, gar_house_guid). Это один
и тот же UUID здания в ГАР (3 666 совпадений из 3 667 домов, где заполнены оба),
но заполняют его разные источники, и половина в проход не входила. Read-only
прогон отрендеренного mapping-SQL на проде: старый ключ — 0 пар, новый — 781
(8.3% таблицы houses, 6 389 объявлений на них). Канон-проход эти дома узнаёт
(900 пар из 919 имеют один канон-адрес), но блокирует гео-стражем: 356 пар
с NULL geom, 457 дальше 250 м (максимум 5 065 км — битый геокод). Ровно
аргумент #2187: общий UUID здания старше близости.
Качество сопоставления сейчас: 0 из 49 502 строк house_sources сматчены
верхними тирами; fingerprint 58.97%, new 22.65%, geo_proximity 18.36%.
Тесты: новый tests/test_matching_tier_reachability_2674.py сверяет параметры
матчера с границей вызова (Protocol + адаптер) — ловит класс «ветка есть,
передать некому», который обычный тест не видит, потому что зовёт функцию
напрямую. Удалены два теста мёртвого fias-тира: они были зелёными ровно
потому, что обходили границу вызова.
Refs #2674
/sales-vs-listings отдавал median_discount_pct без всякой проверки: после
сегментного гарда #2660 по `%Космонавтов%` 2-комн. значение уехало с −11.9%
на +36.4%, то есть пользователю написали бы «продали на 36% дороже, чем
просили». Корень унаследованный — пейринг ДКП↔объявление идёт по улице без
номера дома (ADR #721), так что на длинной улице в пару попадают квартиры
разных ценовых классов. Пейринг здесь не чиним, перестаём показывать число,
которому нельзя верить.
Пороги подобраны по проду (симуляция эндпоинта на 238 реальных
пользовательских запросах из trade_in_estimates, 128 дали хотя бы одну пару):
- MIN_PAIRS = 10 — бутстрап по 12 плотным группам: p90 отклонения медианы
подвыборки от полной 18.8 п.п. при k=5, 12.0 при k=10, 9.9 при k=15.
Кривая ломается на 10; совпадает с уже принятым в продукте
sell_time_sensitivity_min_n_lots.
- Санитарный диапазон [−60%, +20%] — асимметричный. Сверху распределение
разорвано (…+16.9, пусто, +33.7…+103.1), отсечка попадает в разрыв; ни один
городской бакет asking_to_sold_ratios не даёт плюса вообще (max 0.9132).
Снизу разрыва нет (у большого минуса есть механизм — занижение цены в ДКП),
граница грубая «заведомо не рынок»: 2.5× худшего бакета (студии, −23.8%).
Форма отказа — не пустота: новое поле median_discount_explanation по образцу
confidence_explanation оценщика, фронт рендерит его вместо числа. Гаснет ровно
строка «медианный торг»: сделки, медиана ₽/м², диапазон, linkage_rate_pct и
per-pair discount_pct не трогаются.
Раньше _yandex_lookup/_yandex_suggest/_nominatim_suggest молча подставляли
"Екатеринбург, " в запрос, если в адресе не было маркера города/области.
Житель Нижнего Тагила, вводя «Ленина, 1», получал уверенно неверную цену по
екатеринбургской улице Ленина (обе улицы называются одинаково) — фронт город
вообще не передаёт.
- geocode()/suggest() принимают опциональный city_hint: str | None; без него
внешние тиры больше НЕ подставляют город, а bias (ll/spn) смещается на всю
область (OBLAST66_VIEWBOX) вместо ЕКБ-центра. Явный маркер города в адресе
или city_hint сохраняют прежнее поведение (ЕКБ-путь не деградирует).
- GeocodeResult.city_ambiguous — честный флаг «город определил провайдер, а
не пользователь» (не эвристика на корректность), проброшен в
AggregatedEstimate.target_city_ambiguous (ephemeral, не персистится).
- Cache-ключ geocode_cache учитывает city_hint (address|city=...) — без hint'а
формат не меняется (backward-compat), с hint'ом разные города для одного
текста адреса больше не делят одну запись.
- API: /api/v1/geocode/lookup, /suggest и POST /trade-in/estimate получили
опциональный city_hint — контракт не ломается (default None).
23 новых теста в test_geocoder_city_hint.py; проверено что они падают
(ImportError на _cache_key) на коде до фикса через git stash.
После cutover'а на DB-auth (#2558) аккаунты `kopylov` и `praktika` живут с
`role='manager'`, а team-API жёстко фильтровал `role='employee'` — сбросить
менеджеру пароль или заблокировать его было НЕЧЕМ, кроме ручного psql на проде.
Всплыло 2026-07-31: «Практика» весь день билась в логин (5 failed, 0 успешных),
а восстановить доступ через UI админ не мог.
Что меняется:
- `_fetch_employee_row` берёт actor: admin → `role IN ('employee','manager')`,
manager → по-прежнему только `role='employee'` + свои по `manager_id`.
- `GET /employees` без фильтра отдаёт admin'у и менеджеров (`?manager_id=` —
без изменений, только сотрудники этого менеджера).
- `EmployeeOut.role` — новое поле, UI показывает бейдж «менеджер» и
склоняет тексты («Заблокировать менеджера ...» вместо «сотрудника»).
Инвариант self-lockout сохранён и усилен тестом: строки `role='admin'`
недостижимы через этот роутер ни для кого, включая самого админа, поэтому
ни block, ни смена пароля с `revoke_user_sessions` не могут вырубить
действующего админа. Раздача роли admin остаётся вне API.
Тесты: 6 новых (список с менеджерами, сброс пароля менеджеру + отзыв сессий,
блокировка, manager не достаёт до чужого менеджера, admin не достаёт до
admin-строки), 3 существующих обновлены под новое ожидание списка.
Deep-review seed'а (#2564) нашёл смежный дефект в уже смерженном коде (#2563):
Python `$` матчит перед trailing newline (re.match(r'...\$', 'admin\n') -> True),
а Postgres `~` в CHECK tradein_users_username_ascii_ck (миграция 193) - False.
username="admin\n" проходил Pydantic-валидацию и падал уже в БД -> 500 вместо
честного 422. `\Z` - конец строки без поблажки на trailing newline, совпадает
с семантикой Postgres `~`.
Grep по app/schemas/ (pattern=/regex=/re.compile/re.match/re.fullmatch) -
других regex-валидаторов с `$` в схемах trade-in нет, team.py - единственный
файл с regex-based полем.
Session-only identity (current_team_actor, admin|manager) поверх tradein_users/
tradein_sessions (#2552 foundation). Org-изоляция manager <-> employee через
manager_id: чужой/несуществующий employee_id -> 404 (не 403 — не палим
существование), POST с чужим manager_id в теле от manager игнорируется
(принудительно свой id). Квота — upsert в account_quota_overrides (существующий
паттерн, без правки account_quota.py). История оценок — user_events LEFT JOIN
trade_in_estimates. Team-события (employee_created/blocked/unblocked/
password_reset/quota_changed) без пароля в payload.
Три дефекта, каждый блокировал легальный публичный запуск.
1. Адрес физлица сохранялся в базу ДО любого согласия: согласие фиксировалось
только на форме заявки, то есть ПОСЛЕ записи адреса. Для пилота с договором
терпимо, для человека с улицы — нет. Проверка согласия поставлена первой
строкой расчёта, до геокодирования и до обоих мест записи адреса.
Хранение — колонками на самой оценке, 1:1 с уже работающим прецедентом для
заявок (миграция 182): IP клиента, версия политики, дословный снимок текста.
Отдельная таблица событий не заводилась: согласие даётся ровно на создание
этой строки, и когда строка удаляется по сроку, исчезновение доказательства
вместе с данными логично.
Enforcement НЕ выводится из пустого created_by — первая версия так и делала
и сломала 92 несвязанных теста оценщика, которые зовут расчёт без имени
пользователя, проверяя ценовую логику. Вместо этого явный флаг, который
выставляет единственный боевой вызывающий. B2B-поток не тронут: поле
согласия опционально, иначе сломались бы пилоты, чей фронт его не шлёт.
2. Срок жизни оценки применялся только как фильтр при чтении — физического
удаления не было ни в одной фоновой задаче, данные жили вечно вопреки
декларированному сроку. Заведена задача удаления пачками с ограничением на
прогон и коммитом после каждой пачки, идемпотентная. В расписании она
ВЫКЛЮЧЕНА: это первая автоматическая задача, удаляющая персональные данные,
и первый прогон должен быть под наблюдением.
3. Пути «удалите мои данные» не было. Добавлен сервис удаления и админская
ручка. Ключи: имя пользователя, идентификатор оценки, телефон, чат в
телеграме.
Честно зафиксировано в коде: аноним без ссылки на оценку, без оставленного
телефона и без обращения в поддержку неидентифицируем — удалить его данные
без дополнительной идентификации нельзя. Отдельно: удаление чистит только
копию в базе, зеркало переписки в телеграм-топике не удаляется ничем в
кодовой базе, нужен ручной шаг.
4. Соответствие текста согласия на фронте и снимка на бэке держалось на
комментарии. Теперь есть тест, который ловит расхождение.
Сроки хранения вынесены в настройки. Значение для заявок предложено инженерно
(типичный отраслевой диапазон), юридически обоснованный срок — за юристом, и
это записано в коде.
Тесты: 2775 passed.
Admin-only read API: GET /admin/audit/accounts, /admin/audit/accounts/{username}, /admin/analytics over user_events. Read-only, empty-safe, RBAC via central gate.
Финальный PR issue #2045 (BE-3): GET /api/v1/trade-in/location-coef для
LocationDrawer. FDW foreign table -> локальное зеркало osm_poi_ekb_local
(TRUNCATE+INSERT, тот же паттерн что cad_buildings_local/cadastral_geo_match,
избегает ~1.16s/row FDW round-trip) -> straight-line POI-скоринг, портированный
из Site Finder poi_score.py::compute_poi_weighted_top7 (CATEGORY_WEIGHTS as-is,
радиус 1200м для квартир вместо Ptica 2000м для участков). score->coef -
новая MVP-эвристика (0.95..1.05, не откалибрована на реальных дельтах).
Graceful fallback (не 500, не фабрикуем факторы): пустая/не отрефрешенная
osm_poi_ekb_local или отсутствие lat/lon у оценки -> coef=1.0, factors=[],
geo_source="unavailable".
Scheduler: source=osm_poi_ekb_refresh, daily, зарегистрирован и в боевом
dispatch (scheduler.py), и в kit-registry (product_handlers.py) - иначе
test_kit_registry_completeness падает на ship-dark инварианте (#2192).
Frontend wiring (mappers.ts/LocationDrawer.tsx) - вне scope, отдельная задача
после проверки endpoint'а curl'ом на деплое.
- listings.phones: миграция 166 обнуляет данные; cian.py + scraper-kit
serp.py (golden-parity зеркало) перестают читать offer.get("phones") на
входе — write-path выключен у источника, колонка/поле в модели остаются.
Мёртвая запись "phones" в LISTING_FIELD_PRIORITY убрана.
- trade_in_estimates.client_name/client_phone: миграция 167 дропает колонки;
синхронно убраны из TradeInEstimateInput, обоих INSERT в estimator.py
(основной путь + _empty_estimate), frontend TradeInEstimateInput и
CRM-блока EstimateForm (поля "Имя клиента"/"Телефон").
- sentry_scrub.py и management_companies.phones не тронуты — вне scope.
Данные уже считаются в estimator — отдаём наружу для /trade-in/v2 (снимает
approximate-флаги CV / счётчиков источников / даты отчёта / «ИСТОЧНИКОВ N/7»).
- AggregatedEstimate += cv (float|None), source_counts (dict[str,int]), created_at.
- estimator: _cv_from_ppm2 / _source_counts helpers. cv прокинут через
PricingResult — anchor-путь берёт CV комплов (anchor["cv"]), radius-путь — CV
радиусной ₽/м²-выборки. source_counts считается по ПОЛНОЙ выборке (metadata_lots)
до top-N отсечки. created_at = момент создания.
- POST /estimate возвращает все три; GET /estimate/{id} пересчитывает cv/
source_counts из сохранённых analogs (best-effort) + created_at из колонки.
- /history: += sources_used (jsonb) в проекции → колонка «ИСТОЧНИКОВ N/7».
- /cache-stats: += avg_median_price (по median_price>0) + repeat_address_pct
(доля строк с неуникальным address). Честный best-effort по persisted-оценкам.
Refs #2043
5a: _load_sber_index_series логирует warning если latest месяц серии
старее sber_index_max_age_days (дефолт 35) — расчёт не блокируется.
5b: AvitoImvSummary.thin_market (bool, дефолт False) — True когда
market_count < avito_imv_thin_market_threshold (дефолт 10). Все три
точки конструирования AvitoImvSummary обновлены. Warning при thin.
Добавляет AggregatedEstimate.analog_tier — стабильный enum для фронта:
same_building (Tier A), micro_radius (Tier C), district (S/H/0), city (W),
null (нет данных). Фронт не парсит tier из explanation.
address_precision уже был в схеме (подтверждено: line 208). Explanation не
удаляется — фронт fallback'ает на него.