Deep-review MEDIUM: предполётная проверка purge_expired_trade_in_data считала
по базовому предикату без retain_until — здоровая оплаченная строка (retain_until
проставлен, платёж есть) через сутки после продажи тоже попадала под счётчик,
и джоба аварийно останавливалась на первой же честной продаже навсегда
(вместе с ней — и 180-дневное удаление лидов, вызываемое из той же функции
после этой проверки).
- _PREFLIGHT_PAID_CANDIDATES_SQL: добавлен терм `retain_until IS NULL` —
теперь считает только реальную аномалию (retain_until не проставлен, а
платёж есть), а не штатное состояние. Докстринги функции/модуля поправлены
под фактическое поведение.
- Тест на неверный инвариант (`"retain_until" not in sql`) заменён на
позитивный (`"retain_until IS NULL" in sql`) + добавлены live-DB тесты на
оба случая из ревью (здоровая оплаченная строка не поднимает тревогу,
джоба не блокируется).
- privacy/page.tsx: константа "12 месяцев" вынесена в content.ts
(PAID_REPORT_RETENTION_MONTHS) вместо литерала + расходящегося комментария;
добавлен сверяющий тест (test_paid_retention_text_consistency.py) по
образцу _CONSENT_TEXT_SNAPSHOT. Смягчена формулировка про автоматическое
удаление — задача на проде выключена и ни разу не запускалась, текст
теперь описывает установленный порядок, а не наблюдаемый факт.
- Все 10 висячих ссылок на untracked `mera-pr-d-spec.md` (7 файлов) заменены
на краткое изложение сути в комментарии + ссылку на PR #2754.
Мина: 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, не про эту
пару.)
Ревью PR #2684 — четыре MINOR.
1. Починка фильтра открыла кнопку отмены на все 53 источника. Раньше таблица была
пуста на каждой вкладке, поэтому кнопка не рендерилась НИ РАЗУ и дыра не
проявлялась: ручки отмены source не проверяют вовсе. Оператор на вкладке Авито
мог бы «отменить» refresh_search_matview — задача продолжила бы работать под
статусом 'cancelled' (ещё один врущий статус ровно в тот день, когда их
вычищаем), а has_running_run перестал бы держать single-run guard, который
существует из-за инцидента с двойным свипом и баном (2026-05-31).
Гейт поставлен на общем узле всех пяти ручек — scrape_runs.honors_cancel +
отказ в mark_cancelled, — а не в UI: иначе ручной POST по-прежнему снимал бы
guard. Флаг cancellable отдаётся в строке, UI по нему прячет кнопку.
Состав набора выведен из call-site'ов runs.is_cancelled: city-sweep'ы (все
площадки и города), full-load'ы, avito_newbuilding_sweep, rosreestr_dkp_import.
Правило НЕ «любой *_sweep»: yandex_newbuilding_sweep отмену не опрашивает.
2. Комментарий пересозданного v_data_quality утверждал, что его обновляет
/api/v1/admin/data-quality. Читателей у view нет ни одного — живая ручка строит
свой запрос. PR с тезисом «ложный показатель хуже отсутствующего» не имеет права
переносить в прод ложное утверждение о читателе.
3. Лимит выдачи 20 → 50: первые 20 строк по started_at на три четверти —
сердцебиение proxy_healthcheck (1631 из 3245), часовой сбор мог не поместиться.
Привязка к вкладке НЕ возвращается.
4. Тест «действующее определение view» искал маркер подстрокой с OR REPLACE —
миграция с обычным CREATE VIEW или парой DROP+CREATE была бы невидима, и тест
проверял бы 214, пока показатель уже вернулся. Заменено регуляркой на обе формы.
Фальсификация трёх новых тестов патч-методом — все три красные. Полный прогон
3490 passed / 9 skipped, tsc --noEmit чистый.
Четыре находки одного класса: админка показывает числа, которые никогда не
бывают ненулевыми, и подаёт это как результат. Ноль читается оператором как
«всё чисто», а не как «мы это не считаем» — такой показатель хуже отсутствующего.
1. «Помечено выбросов» (v_data_quality.outliers_flagged) — УБРАН вместе с
колонкой listings.is_outlier. Механизм не «не доделан»: «выброс» у эстиматора
вычисляется Tukey-фильтром по КОНКРЕТНОЙ подборке аналогов и живёт один
запрос — один и тот же лот выброс для одной оценки и нормальный аналог для
соседней. Persist-флаг на объявлении такое отношение выразить не может,
реализовать пометку нечем.
2. http_requests / http_errors / returning_count / disappeared_count — УБРАНЫ.
HTTP-запросы не считает ни один фетчер (заполнить нечем без сквозной
инструментации). Ошибки и «пропало/вернулось» уже считает тот, кто их знает,
и кладёт в counters jsonb: errors_count у pipeline, deactivated/revived у
deactivate_stale_*. Отдельные колонки были бы вторым определением того же.
3. run_type — УБРАН из API, из таблицы админки и из схемы. Ни одно место кода
его не задавало; DEFAULT из 051 подписывал 'city_sweep' даже proxy_healthcheck.
Колонка «Тип» в UI заменена на «Источник» — там осмысленное значение.
4. Фильтр источников — теперь из данных (GET /scrape/runs/sources, SELECT
DISTINCT source). Захардкоженная тройка не просто была неполной: сравнение
точное, а строк с source='avito'/'cian'/'yandex' в таблице нет вообще, то
есть каждый пункт фильтра давал пустую выдачу, и пустой выбор («Все») тоже —
он молча подставлял source вкладки. Новый источник появляется в списке сам.
Числа с прода (tradein-postgres, 2026-08-06): is_outlier=true у 0 из 93 408
listings (NULL у 0 — только DEFAULT); четыре счётчика = 0 во всех 3244 прогонах
с миграции 015; run_type — одно значение на 3244 строки; 53 реальных источника,
2466 прогонов (76%) вне трёх площадок, включая весь Домклик.
Миграция 214 идемпотентна; v_data_quality пересоздан тем же DDL минус
outliers_flagged (порядок DROP VIEW → DROP COLUMN → CREATE как в 095).
Правки по ревью PR #2671.
Текст «надёжная медиана начинается от 10» обещал то, чего мы гарантировать не
можем: пары — псевдореплики (одно объявление переиспользуется на многих
сделках, на живом кейсе Космонавтов 2-комн. 42 пары стоят на 2 различных
объявлениях), и 10 пар надёжности не дают. Теперь отказ сообщает факт: сколько
пар есть и что на такой выборке медиана гуляет на десятки п.п.
Формулировка диапазонной ветки укорочена: она дублировала street_only-
дисклеймер, который идёт следующим блоком. Проверено скриншотом отрендеренной
карточки — две формулировки подряд читались как стена текста; теперь три
однострочных хинта, на 820px — по две строки, переполнения нет.
В шапку секции добавлен потолок гейта, найденный ревью: бутстрап пересэмплировал
ПАРЫ, т.е. мерил дисперсию со стороны сделок, а доминирует дисперсия со стороны
ОБЪЯВЛЕНИЙ (джекнайф p90 17.3 п.п., max 63.8); 22 из 64 переживших групп стоят
на одном объявлении. Плюс нижняя граница оказалась слишком мягкой, а не строгой:
26 из 64 показываемых значений ниже −23.8%, самое глубокое −58.5%. Оба пункта —
отдельная задача, здесь только зафиксированы, чтобы порог не перечитали как
гарантию.
Тесты: пустое утверждение "1" in explanation (всегда истинно из-за "10")
заменено на «всего 1 —». Добавлены два недостающих — отсутствие пар со скидкой
даёт explanation=None, и порядок проверок (3 пары по +80% отчитываются «мало
пар», а не «вне диапазона»).
/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 не трогаются.
Ревью нашло два способа положить сервис ровно под той нагрузкой, ради
которой писалась защита.
Первый: `min()` вычисляет оба аргумента, поэтому `float(2 ** (excess - 1))`
при 1045 неудачах по имени за окно падал с OverflowError. Счётчик ничем
не ограничен сверху — `record()` только копит метки и на лимит не смотрит.
С этой попытки и до конца окна вход отдавал 500 мгновенно, без задержки и
без записи в аудит: терялись обе ценности PR, и трение, и сигнал. Показатель
степени зажат; 2**16 заведомо выше любого разумного потолка, поэтому видимое
поведение не меняется.
Второй: сон шёл внутри области жизни сессии БД. В дефолтном режиме
`get_identity_db` отдаёт ту же сессию, что `get_db`, а SELECT в
`get_user_by_username` оставляет её в открытой транзакции — соединение
висело занятым все восемь секунд. Пятнадцати одновременных неудач хватало,
чтобы выбрать QueuePool целиком и уронить любой другой эндпоинт по
pool_timeout. Отказ в обслуживании против всех сразу — хуже той блокировки
учётки, ради ухода от которой замедление и выбиралось. Соединение теперь
возвращается в пул перед сном.
Заодно: длина имени ограничена 64 (верх CHECK'а реестра) — сырое имя
становится ключом обоих лимитеров, а их словарь при часовом окне не
подчищается; и явно записано, что `limit` у счётчика на имя не порог.
Правки по ревью PR #2662.
Фильтр статуса. `GET /admin/scrape/runs?status=skipped` отдавал 422 — 'skipped' не
было в Literal, а во фронте не было чипа. Строки рисовались, но задать вопрос
«что сейчас пропускается» на единственной поверхности, построенной ровно для
этого, было нельзя. Добавлено в оба места (translateStatus «пропущено» и
нейтральный бейдж уже умели).
Схлопывание освежает строку. UPDATE двигал только finished_at/heartbeat_at, из-за
чего живой стрик замерзал: списки прогонов сортируют ORDER BY started_at DESC и
берут limit=20, поэтому 37-дневный пропуск утонул бы под свежими прогонами других
источников — след в базе есть, на экране нет. Теперь started_at = NOW(), а начало
стрика переезжает в counters.first_skip_at; сортировку общего списка не трогаем
(она про все источники, чинить надо было одну строку). Там же обновляется
counters.detail — иначе в строке 37 дней висел текст «протухли 1 день назад»,
хотя именно эта цифра и есть предмет issue. jsonb_set заменён на `||` +
jsonb_build_object: три вложенных jsonb_set читать в 3 ночи невозможно, а NULL в
jsonb_set обнуляет весь counters.
Поиск последней строки. `ORDER BY id DESC` не ложится на индекс
(source, started_at DESC) из миграции 015 — для unknown_source (тикает каждые
60 с бессрочно) это отбор всех строк источника с сортировкой раз в минуту.
Теперь ORDER BY started_at DESC, id DESC.
session_expires_at получил valid_only: предупреждение «скоро протухнут» считает
срок ИМЕННО той записи, которую взял load_session — при нескольких аккаунтах
свежайшая-любая может быть чужой протухшей строкой. Диагностика после None
по-прежнему смотрит на свежайшую любую (валидных там нет по определению).
Запись пропуска намеренно НЕ обёрнута в свой try/except: если db.execute падает,
то падает и claim следующего расписания в этом же тике — тик срывается в любом
случае, а глушить исключение здесь значило бы вернуть ровно тот немой пропуск,
ради которого заведён #2658. Самовосстановление через 60 с.
Пользовательская половина разбора #2574: витрины читают listings без сегмента
и без свежести, поэтому показывают числа, посчитанные не по тому пулу.
1. Миграция 211 — гард #1186 в window_listings у street_sales_vs_listings().
27.3% кандидатов на пару «ДКП ↔ объявление» были новостройками, и
девелоперский прайс (который не торгуется) формировал показываемый процент
торга. is_active здесь по-прежнему НЕ фильтруется — осознанно: функция
намеренно смотрит и снятые объявления, иначе к сделке нечего подставить.
Сигнатура не меняется, значит CREATE OR REPLACE — замена, а не вторая
перегрузка (грабли #2627 закрыты тестом-сравнением сигнатур с м.205).
2. location_index — предикат свежести + сегментный гард в обоих запросах
медианы, симметрично _COMMON_WHERE эстиматора. Витрина обязана смотреть на
тот же пул, на котором считается цена; окно свежести берётся импортом
LISTINGS_FRESH_DAYS, второго определения константы не заводим.
3. /scraper/data-quality и /cache-stats — «активно» не прячем, а разделяем:
рядом отдаётся «из них не виделись N дней» (+ сам порог N в ответе).
Именно слепой count(*) WHERE is_active заставлял #2574 месяц выглядеть
как «всё собирается».
Refs #2660
Лимит на логине ключевался парой (username, IP), поэтому распределённый
перебор одного имени с тысячи адресов получал по 5 попыток с каждого
источника и не упирался ни во что. После снятия Caddy basic_auth с
/trade-in (#2558) POST /auth/login — единственная ручка, доступная из
интернета без кредов, так что дыра открыта прямо сейчас.
Поверх существующего per-IP лимита добавлен глобальный счётчик неудач
на ИМЯ, без IP в ключе. Превышение порога не блокирует учётку, а растит
задержку ответа (удвоение от 1с до потолка): блокировка по имени была бы
вектором отказа в обслуживании против конкретного человека — не зная
пароля, злоумышленник гарантированно выключал бы чужой вход.
Задержка применяется по ПРИСЛАННОМУ имени, без проверки его в реестре, и
из одного места — общего хвоста всех отказов по кредам. Иначе «быстрый
401» для несуществующего имени стал бы оракулом существования учётки, то
есть ровно той user-enumeration, от которой уже защищают одинаковый
generic-ответ и безусловный bcrypt.
Первый коммит починил только scripts/geocode_deals_nominatim.py — ручной скрипт.
Тот же дефект оставался на живом пути: POST /admin/geocode-missing?target=deals
отдавал сырой row["city"] в city_hint, а deals.city росреестровое и в хвосте
распределения содержит не-города («Бессонова», «Билейский рыбопитомник»). Любой
не-ЕКБ хинт жёстко закрывает EKB-локальные тиры и уезжает префиксом в запрос
провайдеру, то есть мусорный хинт хуже отсутствия хинта.
Гейт вынесен в geocoder.known_city_hint (сверка с SVERDLOVSK_OBLAST_CITIES —
тем же набором, который уже питает _names_non_ekb_city / _ekb_local_tiers_allowed)
и переиспользуется всеми тремя потребителями city_hint: скриптом, admin-ручкой и
задачей geocode_missing. Копий функции нет — четвёртый потребитель, если появится,
получит гейт сам.
Тесты: мусорный город -> хинт не передаётся, валидный -> передаётся; проверено
фальсификацией (без фикса все три новых теста краснеют).
Правки по deep-review PR #2654.
MEDIUM. Внутренний EXISTS считал backup'ом любой enabled-узел affinity. До п.2 это
было эквивалентно «пригоден», потому что бан выключал узел глобально; теперь узел
бывает enabled и одновременно забанен СВОИМ же источником. Fallback мог увести
последний реально рабочий узел выделенной affinity (два domclick-узла, один забанен
domclick'ом → второй уходит под avito → domclick без прокси). Добавлено требование,
что backup не забанен своим источником — в acquire и зеркально в защите mark_banned.
MEDIUM. У оператора не осталось способа снять бан: в п.1 ложное срабатывание
лечилось PATCH enabled=true (он обнулял disabled_reason), теперь бан живёт в
отдельной таблице и истекает только по таймеру, до 72ч при эскалации. Добавлен
proxy_pool.clear_source_bans; зовётся из patch_proxy при ручном включении и после
УСПЕШНОЙ ротации exit-IP (бан привязан к proxy_id, а банился IP — после смены
адреса строка держала бы узел вне выдачи без причины).
LOW. Тест защиты дублировал логику вместо её проверки: ban-предикаты в фейксессии
теперь гейтятся по подстрокам боевого SQL (как в acquire-ветке) — проверено
мутацией, тесты краснеют при удалении NOT EXISTS из запроса.
LOW. Конверсия в миграции 210 матчила disabled_reason по LIKE 'banned:%' и могла
отменить ручное выключение оператора (формат подсказан комментарием 209-й) — сужено
до точного списка значений домена provider_affinity.
LOW. Docstring report_ban в browser_fetcher описывал старую модель (enabled=false);
формула в COMMENT ON COLUMN была на шаг мимо (срок ТЕКУЩЕГО бана, не следующего).
Расхождение с acquire по leased_by зафиксировано в докстринге как осознанное.
Refs #2600
Бан площадкой был глобальным: п.1 на распознанный бан выключал узел целиком
(enabled=false, disabled_reason='banned:<source>'). Реальность другая — Авито
банит IP, а Яндекс через тот же IP ходит чисто, поэтому один забаненный источник
выкидывал живой узел из пула для всех и худил пул быстрее, чем его пополняют
(#2638). Плюс такое состояние не самолечилось: ipify площадку не эмулирует, бан
не видит, а non-NULL disabled_reason блокирует авто-воскрешение (#2610) — нужен
был ручной PATCH.
Теперь бан — свойство ПАРЫ (proxy_id, source) в scrape_proxy_source_bans:
acquire(source) не выдаёт узел только этому источнику, для остальных узел
первосортный; снимается сам по времени. Срок эскалирует 6ч → 12 → 24 → 48 → 72
(потолок) на повторных банах той же пары; ban_count сбрасывается purge'ем
истёкших строк через 7 суток — поэтому purge намеренно отложенный, а не по
banned_until < now(). Защита последнего узла сохранена, но считается по
источнику: если после бана у acquire(source) не останется кандидатов — бан не
пишется, WARNING зовёт пополнять пул.
Миграция 210 конвертирует прод-остатки п.1 (enabled=false + disabled_reason
LIKE 'banned:%') в 6-часовые per-source баны и возвращает узлы в строй — иначе
они висели бы выключенными вечно.
Оператору активные баны видны в GET/PATCH /admin/proxies (source_bans) — без
этого «узел включён, но не выдаётся» необъяснимо.
Refs #2600
Дефолт не меняет ничего: IDENTITY_STORE="tradein" — это сегодняшний прод,
tradein_users/tradein_sessions, соединение с БД auth не открывается вообще.
Переключение делается одной переменной окружения ПОСЛЕ того, как на проде
появится пароль auth_app и будут скопированы данные. Так сделано намеренно:
мерж, который зависит от невыполненного ручного шага, — это мерж, который
ломает прод в момент невнимательности.
Ядро. app/services/identity_store.py — единственное место, знающее, в какой БД
и в каких таблицах живёт реестр. Имена таблиц берутся из фиксированного словаря
по значению флага, не конкатенацией с вводом. app/core/auth_db.py — ЛЕНИВЫЙ
engine БД auth (core/db.py создаёт свой на импорте; такое же для auth роняло бы
старт без DSN).
Одно понятие состояния доступа вместо двух. В tradein_users состояние — булев
is_active, в auth.users — access_state из трёх значений. Конверсия живёт в одной
функции to_access_state(): True→active, False→disabled, а неизвестная строка,
NULL или чужой тип → disabled с WARNING. Fail-closed выбран сознательно: если
следующая миграция добавит четвёртое состояние, оно по умолчанию НЕ будет
пускать. Проверка доступа — свойство can_sign_in, а не сравнение со строкой.
Логин в режиме auth. Пароль проверяется ВСЕГДА и ДО ветвления по состоянию —
иначе появляется timing-oracle и перечисление логинов. Верный пароль +
trial_expired → 403 с машиночитаемым code="access_expired", сессия НЕ создаётся.
Верный пароль + disabled → тот же generic 401, что и при неверном пароле.
Резолв уже выданной сессии пропускает только active — блокировка обрывает
сессию немедленно, а не по истечении sliding-refresh.
Старт падает явно, если IDENTITY_STORE=auth, а DSN не задан. Без этого ошибка
конфигурации не похожа на аварию: продуктовая БД жива, приложение работает, а
rbac_guard ловит исключение резолва вместе с любым другим сбоем и падает в
legacy trusted-header ветку — то есть сутками раздаёт права из roles.yaml мимо
реестра, включая аккаунты с disabled.
Форма входа понимает новый код ответа. Ветвление по detail.code, а не по тексту:
текст бэк вправе менять, код — нет.
Гранты соблюдены, а не обойдены: auth_app не имеет UPDATE на role/manager_id и
не имеет DELETE на users (миграция 004, column-level).
Тесты: 2996 passed (+59). Единственный красный — test_search_cache_hit —
предсуществующий: проверен контрольным полным прогоном на чистом main
(2937 passed, тот же красный).
Замыкает петлю "город объявления -> геокодирование" (issue #2594, шаг 2/3).
listings.city (миграция 196) заполняется скрапером из контекста развёртки, но
три caller-места геокодера решали город по ТЕКСТУ адреса и игнорировали
колонку - голый адрес без города в тексте ("ул. Победы, 30", тагильский)
уходил в Екатеринбург.
- app/tasks/geocode_missing.py: группировка по (address, city) вместо
address, city_hint в geocode(), UPDATE/tried_at-пометка по паре через
city IS NOT DISTINCT FROM :city (обычный `=` не поймал бы NULL-город и
не даёт нужной симметрии между группами).
- app/tasks/backfill_listings_coords_geoportal.py: гейт по колонке city
ПЕРЕД матчем против EKB-only ekb_geoportal_buildings, ПЕРЕД текстовым
гейтом _names_non_ekb_city (сохранён как fallback для city IS NULL).
Это окно идёт раньше geocode_missing_listings, поэтому раньше успевало
испортить координаты первым.
- app/api/v1/admin.py: per-ID endpoint /geocode-missing читает city из
SELECT (listings.city / deals.city) и передаёт как city_hint.
geocoder.py не тронут (запрещено ТЗ).
Тесты: falsification-прогон (stash impl, тесты остаются) - 7 failed / 36
passed на старом коде, все 7 - новые тесты на новое поведение; после
stash pop - 43 passed / 0 failed. Полный pytest tradein-mvp/backend:
2970 passed, 1 failed (pre-existing tests/test_search_api.py::test_search_cache_hit,
несвязан), 9 skipped.
N1 не собирается с 16 июня, в scrape_schedules его нет. Миграция 165 удалила
источник на 90% (allowlist/scheduler/settings) — оставались точечные литералы:
- SourcesMap.tsx: цвет для мёртвого source в легенде карты (fallback серый).
- admin.py geocode-missing: N1-ветка address-плейсхолдер фильтра + стале
докстринги, упоминавшие N1 как активный источник listings.
- test_estimator_source_quota.py: докстринг регрессии с упоминанием N1
среди вытесняемых источников.
Данные (382 listings source='n1', is_active=false) не трогаются — все
поверхности уже провайдер-агностичны с safe fallback для неизвестных id
(source-registry.ts, trade_in_pdf.py _SOURCE_LOGO_COLORS.get, SourcesMap.tsx
colorForSource). Денормализованные счётчики (TOTAL_SOURCES/mappers.ts,
_TOTAL_SOURCES/trade_in_pdf.py, LIVE_SOURCE_COUNT/source-registry.ts) уже
производные от актуальных ростеров без n1 — индексация не затронута.
Раньше _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'а на свою авторизацию (#2558) единственным каналом в поддержку
остался чат ЗА логином, а самая частая причина писать в поддержку — как раз
«не могу войти». 2026-07-31 это выстрелило: «Практика» весь день билась в форму
входа (5 неудачных попыток с трёх разных IP, ни одной успешной) и сообщить об
этом из продукта не могла ничем — на /login не было ни чата, ни контакта.
Backend — 4 ручки /api/v1/trade-in/support/anon/* (public в rbac_guard):
- Идентичность анонима — opaque-токен в httpOnly+Secure куке; тред живёт в тех
же web_support_threads под ключом `anon:<token>`. Двоеточие делает коллизию с
реальным логином структурно невозможной (CHECK миграции 193 разрешает только
`^[A-Za-z0-9._-]{3,64}$`) — аноним не может попасть в чужой тред.
- Изоляция та же, что у авторизованной ветки: thread_id снаружи не принимается
ни в каком виде, тред резолвится ИСКЛЮЧИТЕЛЬНО из куки.
- Форма куки валидируется — мусор из браузера не становится ключом треда.
- В Telegram-топик уходит не токен (это bearer треда), а `anon-<6 hex sha256>`;
зеркало помечено «[С САЙТА · БЕЗ ВХОДА]» — оператору важно, что аккаунта нет.
- Анти-абуз: два бюджета — per-token (12/мин) и per-IP (10/10мин). Второй ловит
обход ротацией куки, без него публичная ручка записи в общий топик беззащитна.
- Кука и запись в БД — только после успешного sendMessage (порядок операций H1),
неудачная отправка не закрепляет за посетителем пустой тред.
Frontend:
- `SupportScope = "auth" | "anon"` в useSupportChat: scope выбирает базовый путь
и входит в ключ кэша (иначе после логина в панели висела бы переписка анонима).
Дефолт "auth" — существующие места монтирования не меняются.
- `AnonSupportWidget` монтируется на /login и в NoAccessScreen — обе точки тупики,
из которых пользователю больше некуда идти. На /login добавлена подсказка.
Ответы оператора маршрутизируются без изменений в bridge.py: реплай резолвится
по topic_message_id → thread_id, кто автор треда — там неважно.
Тесты: 13 новых на анонимную ветку + 2 на границу public/authed в rbac_guard.
62 passed (test_support + test_rbac).
После 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 существующих обновлены под новое ожидание списка.