Commit graph

229 commits

Author SHA1 Message Date
8db357ef30 test(tradein): гейт «снять noindex можно только вместе с плейсхолдерами» (#2904) (#2948)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m18s
Deploy Trade-In / deploy (push) Successful in 1m6s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
2026-08-20 07:00:09 +00:00
bot-backend
73c4c487ed fix(mera/b2c): житель Серова получал «вы вне области», а подсказки игнорировали выбранный город
All checks were successful
CI / changes (pull_request) Successful in 10s
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 / frontend-checks (pull_request) Successful in 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 4m50s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Successful in 48s
Два дефекта, найденных прогоном сценария глазами посетителя на живом домене.

## 1. Город предлагали выбрать, но отвечать по нему не умели

Дропдаун на сайте (`OBLAST_CITIES`, city-registry.ts) и списки покрытия
(`COVERAGE_GREEN/YELLOW_CITIES`, trade_in.py) — одно множество, записанное в
двух местах. Они разошлись в обе стороны:

  предлагали, но не отвечали:  Серов
  отвечали, но не предлагали:  Берёзовский, Среднеуральск, Ревда

Житель Серова выбирал СВОЙ город из НАШЕГО дропдауна и получал:

  «Этот адрес вне области, по которой мы собираем данные.
   Сейчас это Свердловская область: Екатеринбург целиком и ещё несколько
   городов вокруг.»

Про город в той же самой области. Серов при этом покрыт данными: 363 активных
объявления в радиусе 15 км, все свежие (замер по проде). Поэтому добавлен в
жёлтый тир, а не убран из дропдаунa; три недостающих города добавлены на фронт.

Шапка city-registry.ts этот риск прямо предсказывала — «перед добавлением
7-го города сверить оба списка вручную, теста на это пока нет». Теперь тест
есть: бэкендовый сьют читает TS-реестр и требует РАВЕНСТВА множеств. Плюс
проверка, что у каждого города с порогом есть центроид, — иначе порог мёртвый,
город по координатам не резолвится.

## 2. Подсказки не слушались выбранного города

`city_hint` доезжает до геокодера, но на выдачу не влияет: его смотрит только
екатеринбургский кадастровый тир (как признак «речь не про ЕКБ, тир
пропускаем»), а DaData-тир ограничен регионом целиком и хинта не принимает.

Замер: выбран Серов, введено «Ленина 1» → первой подсказкой «Невьянский р-н,
пгт Верх-Нейвинский». Человек выбирает верхний вариант и считает чужой дом —
ровно баг #2576, ради которого город и спрашивают.

Публичная ручка теперь подставляет город в саму строку запроса. Проверено на
проде: «Серов Ленина 1» даёт серовскую выдачу целиком. Для Екатеринбурга
подстановка безвредна — три разных адреса дали тот же результат с префиксом и
без, поэтому правило одно на все города, без исключения для основного трафика.

Чинится в публичной ручке, а не в геокодере: там от `city_hint` зависит
поведение закрытого контура (`target_city_ambiguous`).

## Фикстура теста

`_FAR_AWAY_CITY` стояла в 21 км от центра Серова и работала как «далеко от
всех» лишь потому, что Серов не был поддержан. Переехала в Тавду — 271 км до
ближайшего центроида.

## Мутации

  убрать Серов из покрытия (состояние прода) → падает сверка списков
  не подставлять город в строку             → падает проверка ручки
  откат                                     → 21 passed

Плюс backend 75 passed, vitest 56 passed, tsc, lint, build, isolation guard.

`city-registry.ts` добавлен в paths-фильтр БЭКЕНДОВОГО лэйна: сверку списков
делает бэкендовый тест, и без этой строки правка одного лишь дропдауна её бы
не запускала — то есть ровно тот путь, которым списки и разошлись.
2026-08-16 13:03:19 +03:00
bot-backend
e74e9106b3 fix(mera/b2c): /estimate снова закрывался гвардом — короткий адрес завели в Caddy, но не в RouteGuard
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (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 / frontend-checks (pull_request) Successful in 55s
Замер на проде сразу после выкатки #2913: страница `/estimate` отдаёт 200 и
выглядит правильно, но после гидратации монтируется `GuardedRoute` —

  404 /trade-in/api/v1/me                             ×3
  404 /trade-in/api/v1/trade-in/support/anon/unread

плюс виджет чата поддержки из закрытого контура и экран «Не удалось проверить
доступ» внизу страницы. Форма при этом переставала работать ВОВСЕ: разметка с
сервера видна, поле принимает текст, а обработчики не подключены — человек
печатает адрес и не получает ни подсказок, ни ошибки. Проверено, что дело не в
API: ручной `fetch` из той же страницы отдаёт 200 и восемь подсказок.

Причина ровно та же, что чинил PR #2910 для `/`: список публичных путей в
гварде и матчер `@meraPages` в Caddyfile — одно множество, записанное в двух
местах. Тогда разошлись на `/`, теперь на `/estimate`. Оба раза страница
отдавала 200, ломалась только в браузере, и ни один тест этого не видел.

Комментарий в шапке гварда инцидент не предотвратил (он там был), поэтому
совпадение теперь запинено ТЕСТОМ: он читает Caddyfile и требует, чтобы каждый
путь из `@meraPages` проходил `isPublicPath`. Сверяемся с источником, а не
дублируем список третий раз.

Мутация — вернуть список к состоянию, которое уехало на прод:
  2 failed / 3 passed
  × пропускает короткие адреса публичного домена
  × совпадает с тем, что публичный домен реально раздаёт (Caddyfile)
откат → 5 passed.

Плюс vitest 56 passed, tsc, lint, isolation guard 41 файл.

`Caddyfile` уже в paths-фильтре фронтового лэйна (#2913), так что правка одного
лишь конфига теперь тоже гоняет этот гейт.
2026-08-16 10:39:15 +03:00
bot-backend
56194c606a fix(mera/b2c): семь дефектов публичного периметра, найденных состязательным ревью
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Failing after 8s
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 / browser-tests (pull_request) Successful in 46s
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 4m42s
Ревью четырьмя независимыми линзами (периметр, семантика Caddy, политика ПДн
против кода, фронт) + по два проверяющих на каждую находку. Ниже — то, что
пережило проверку и воспроизведено на живом коде, а не выведено из чтения.

## Caddy: открытый редирект и потерянные ссылки

Захват хвоста регекспом (`^/trade-in/mera-public/(.+)$` → `redir /{re…1}`) —
открытый редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
`/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`, а
браузеры трактуют `/\` как `//` — Location уводит на чужой хост. Готовая
фишинговая заготовка с домена, который напечатан внутри оферты и уходит
модератору эквайера. Заменено поимённым списком путей: такой адрес просто не
матчится.

Адреса со слэшем на конце (`/oferta/`, и длинные `…/oferta/`) отдавали 404 —
ровно те ссылки, ради сохранности которых редирект и делался. Добавлена
нормализация, цепочка замкнута (проверено: 2 перехода → 200).

Query-строка терялась: размещённые ссылки с UTM приходили бы в аналитику как
прямой заход. `uri strip_prefix` + `{uri}` переносит её. Обёртка `route`
обязательна — без неё `redir` выполняется раньше `uri` и Location равен
исходному адресу (бесконечный цикл, поймано на стенде).

`/v3` — черновое превью с маркетинговыми плейсхолдерами — было открыто на
боевом домене молча. Теперь названо вслух и запинено тестом.

## Гейты, которых не было

`caddy validate` не звал НИ ОДИН workflow, а deploy применяет конфиг не через
`reload` (тот отказался бы принять битый), а через `up -d --force-recreate` —
опечатка уводит контейнер в crash-loop и роняет ВСЕ домены. Добавлен гейт в
ci.yml, тем же образом caddy:2, что и на проде.

Проверка «роут ↔ Caddy» была односторонней и пропускала обратную ошибку —
путь, открытый наружу, о котором приложение не знает. Так и уехал `/v3`.
Теперь двусторонняя, плюс проверка, что для каждой страницы есть 301.

## Бюджет внешнего геокодера

Per-IP окна ограничивают одного клиента, но не сумму: 40/мин с адреса — это
57 600 в сутки при бесплатном тире DaData в 10 000, ОБЩЕМ с закрытым контуром.
Подтверждено на проде: достаточно упомянуть не-екатеринбургский город, чтобы
локальный тир отключился и запрос гарантированно ушёл во внешний сервис. То
есть один скрипт оставлял без подсказок платящих пилотов.

Per-IP снижен до 20/мин, добавлен общий суточный потолок 2000 и потолок
одновременных подсказок (4): кадастровый тир уходит в FDW-скан чужой базы,
держит соединение около секунды, а пул общий с B2B — полтора десятка
параллельных публичных запросов клали бы закрытый контур.

## «Адрес нигде не сохраняется» — теперь правда целиком

Две утечки, обе воспроизведены:

1. ЖУРНАЛЫ. Геокодер печатает введённую строку открытым текстом на каждый
   вызов, прод пишет stdout в persistent journald — адрес ложился на диск
   рядом с IP того же запроса в access-логе Caddy. Закрыто фильтром логов на
   время публичного запроса (contextvar, переживает await и to_thread).
   Закрытый контур логи сохраняет: они нужны для разбора жалоб пилотов.
2. МОНИТОРИНГ. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса — а тело
   публичной ручки это ровно `{"q": "<адрес>"}`; `send_default_pii=False` тут
   не гейт, он про куки. Плюс брэдкрамб httpx несёт адрес в query геокодера.
   Закрыто `scrub_public_address`.

Текст п. 5.4 политики расширен до «ни в журналы веб-сервера, ни в технические
журналы, ни в мониторинг» — ровно то, что теперь обеспечено кодом.

## Фронт

- Отмена запроса подсказок откладывалась внутрь следующего debounce-такта и
  не наступала вовсе, если человек переставал печатать: ответ по старой строке
  долетал и ложился в список. Контроллер создаётся сразу, отменяется в cleanup.
- Список схлопывался на каждое нажатие — клик по намеченному пункту
  промахивался. Старая выдача висит, пока не пришла новая.
- «Комнат» с лэндинга — свободный текст: «студия» не совпадала ни с одним
  option, селект показывал пустоту, parseInt давал NaN, на сервер уходил
  rooms: null → 422 с текстом «сломалось на нашей стороне». Нормализация
  вынесена чистой функцией и покрыта тестами.
- У пробы покрытия не было ни таймаута, ни отмены: оборванное соединение
  оставляло кнопку в «Смотрим данные…» навсегда. 15 с + понятный текст.
- Ошибка подсказок глушилась в пустой список — тупик без объяснения.
- Комбобокс: Tab проваливался в кнопки подсказок, список не закрывался по
  уходу фокуса и перекрывал поля, Escape оставлял висячий aria-activedescendant.

## Проверено

Локальный стенд (реальный site-блок Caddy + заглушка): 18 маршрутов, включая
`%5C`, `//`, `%2F` — все три теперь 404. vitest 55 passed, backend 17 passed по
публичному API, tsc, lint, build, isolation guard 41 файл, caddy validate.

Мутации: снять редакцию логов → падает тест журналов; не вырезать тело запроса
→ падает тест мониторинга; убрать /estimate из Caddy → падает тест маршрутов.
2026-08-16 10:01:47 +03:00
bot-backend
7424c283d5 feat(mera/b2c): отдельный экран оценки на meraocenka.ru и короткие адреса
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
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 / browser-tests (pull_request) Successful in 44s
CI Trade-In / frontend-checks (pull_request) Successful in 1m2s
CI Trade-In / backend-tests (pull_request) Successful in 4m41s
## Экран проверки — /estimate

Проверка квартиры вынесена на собственный адрес: там у автокомплита есть
место под список подсказок, а у результата — место рядом с полями. Форма в
герое лэндинга осталась входной точкой и уводит сюда, донося набранное через
sessionStorage (НЕ через query — адрес в URL попал бы в access-лог Caddy рядом
с IP посетителя, а мы на той же странице обещаем ничего не хранить).

Показывает живую пробу покрытия: сколько похожих квартир продаётся рядом и
сколько в среднем висят их объявления. Ни одной рублёвой цифры — цену продаёт
платный шаг. Тексты вердикта вынесены чистой функцией (coverage-copy.ts) и
покрыты тестами: подпись под возрастом обязана говорить «объявление», а не
«продаётся» (выборка цензурирована), при неизвестном возрасте плитки нет
вообще, а пустая когорта объясняется как факт о рынке с подсказкой, что
поменять, — директива «никогда не блокировать вывод».

## Короткие адреса

Человек больше не видит /trade-in/mera-public/... — только /, /estimate,
/oferta, /refund, /privacy. Длинные адреса отдают 301 на короткие: у страницы
один канонический адрес, старые ссылки живы.

Цена решения: ссылки работают только на meraocenka.ru (короткие пути раздаёт
этот хост). Открывать лэндинг для проверки нужно там же, а не с gendsgn.ru.

Ссылки эмитятся обычным <a> (PublicLink) — next/link подставляет basePath, и
href="/estimate" уехал бы на несуществующий /trade-in/estimate.

## Три дефекта, найденных на живом сайте

1. Палитра v3 никуда не доезжала. b2c-tokens.ts не импортировал НИКТО, ни одна
   --b2c-* переменная не объявлялась, каскад молча пропускал такие декларации —
   лэндинг отдавал 200 бесцветным. Добавлен мост b2cVars, гейтом стал тест:
   каждая использованная в CSS переменная обязана быть объявлена.
2. Голый /trade-in/mera-public падал в 404 — матчер был со слэшем и звёздочкой.
   Ровно туда вела «Главная» в подвале.
3. «Для бизнеса» вела на «/» — то есть на сам лэндинг. Теперь абсолютный адрес
   B2B-контура. Пункты «Проверьте себя» и «Продажа под ключ» вели на якоря,
   которых нет нигде: приведены к виду «Статьи» — видны, но не кликабельны.

## Периметр и приватность

Подсказки переведены на POST: access-лог публичного домена пишет URI целиком,
то есть GET с ?q= сохранял бы адрес квартиры в файл. Тело в лог не попадает.
Метод запинен тестом — это часть обещания, а не стиль.

П. 5.4 политики ПДн переписан ВМЕСТЕ с кодом: прежний текст утверждал, что
адрес не покидает браузер, и это перестало быть правдой. Новый говорит точно —
передаётся, используется однократно, в базах не сохраняется. Последнее
проверено по коду: suggest() работает без кэша, проба — один SELECT, аудит
пишет строку только при наличии username. PUBLIC_ESTIMATE_ENABLED сузился до
платного шага (бесплатная проба не хранит ничего, платный расчёт хранит).

## Проверено

vitest 47 passed (9 файлов), tsc, next lint, next build, isolation guard
40 файлов, backend 75 passed, caddy validate = Valid configuration.

Мутации структурных гейтов:
  убрать --b2c-accent-text из b2cVars   → падает тест палитры
  убрать /estimate из @meraPages        → падает тест маршрутов
  добавить импорт next/link             → падает тест basePath
  откат                                 → 14 passed

Caddyfile добавлен в paths-filter фронтового лэйна: его читает тест маршрутов,
и без этой строки правка одного лишь Caddyfile не запускала бы ни один гейт.

Refs #2894, #2895
2026-08-16 09:27:04 +03:00
bot-backend
6348f51ff4 fix(mera/b2c): публичный лэндинг больше не показывает «Не удалось проверить доступ»
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 54s
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
На meraocenka.ru посетитель видел внизу страницы экран проверки доступа из
закрытого контура. Замер на проде: три вызова GET /trade-in/api/v1/me → 404
плюс подтянутый следом клиент чата поддержки.

Причина. Caddy на коротком домене переписывает путь ВНУТРЕННЕ (rewrite, не
redir): посетитель остаётся на meraocenka.ru/, а Next получает
/trade-in/mera-public. Поэтому SSR видит /mera-public и bypass срабатывает, а
после гидратации usePathname() возвращает «/» — путь из адресной строки — и
bypass отваливается. Монтируется GuardedRoute, летит запрос к /me, а на этом
хосте Caddy намеренно не проксирует /trade-in/api/* → 404 → NoAccessScreen.

В шапке RouteGuard прямо стояло допущение «домен/rewrite настраиваются
отдельным PR — на поведение гварда это не влияет, он смотрит на путь». Оно
сломалось ровно в тот момент, когда домен появился; заменено разбором факта.

Правка: четыре коротких адреса публичного домена (/, /oferta, /refund,
/privacy) добавлены в bypass ТОЧНЫМ совпадением, отдельной веткой. Префиксную
проверку для «/» применять нельзя — startsWith("/") истинно для любого пути,
то есть один такой элемент сделал бы публичным всё приложение. Эти адреса
существуют только на публичном домене: на gendsgn.ru их ловит catch-all 404, а
«/» отдаёт server-side redirect на /v2 из next.config, то есть приложение по
этому пути не рендерится вовсе.

Тест на оба инварианта: короткие адреса публичны И закрытый контур (/v2,
/admin/*, /history, /scrapers/*, /login) — нет. Мутация «точное совпадение →
префиксное» роняет два теста из четырёх, проверено прогоном.

Проверено: vitest 33 passed (7 файлов), tsc --noEmit, isolation guard 31 файл.
2026-08-15 22:06:57 +03:00
bot-backend
8f954fb98c feat(mera/b2c): лэндинг v3 по макету — превью на /mera-public/v3
All checks were successful
CI Trade-In / changes (pull_request) Successful in 16s
CI / changes (pull_request) Successful in 14s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (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 / frontend-checks (pull_request) Successful in 1m13s
Дизайн-макет владельца (15.08.2026). Собрано рядом с живым лэндингом, а не
вместо него: /mera-public открыт на meraocenka.ru, менять его посреди
согласования дизайна нельзя.

- b2c-tokens.ts — палитра макета отдельно от v2/tokens.ts. Красить теми же
  токенами B2B-экран нельзя, это другой продукт; hex-литералы остаются в
  одном файле.
- marketing-v3.ts — числа из макета в ОДНОМ месте, с таблицей расхождений
  с нашими замерами и гейтом: заменить вычисляемыми до снятия noindex.
- _components/v3/ — шапка с селектором города, подвал, липкая CTA, герой
  с карточкой проверки, три шага, «Точность», «Цена ошибки».
- layout.tsx больше не рендерит шапку/подвал: вложенный layout в Next
  ДОБАВЛЯЕТСЯ к родительскому, поэтому v3 со своим хромом получал бы две
  шапки. Теперь хром выбирает страница — v1 берёт SiteHeader/SiteFooter,
  v3 свой.

Отступления от макета (все задокументированы в коде):
- реквизиты и документы берутся из LEGAL_ENTITY, а не из маски
  «ООО «МЕРА» · ИНН 66700XXXXX» в макете;
- в подвал добавлены Политика возврата и почта поддержки, которых в макете
  нет, — обязательные элементы платного контура;
- бейджи МИР/СБП/VISA не рендерятся: приём платежей не подключён;
- форма не выдаёт результат — анонимного расчёта нет, а одинаковые
  «14 объявлений» на любой адрес это обман конкретного человека, а не
  продающая формулировка. Ветка результата свёрстана и ждёт /coverage;
- замыленная рублёвая цифра не перенесена: в макете она лежит в разметке
  текстом под filter:blur и достаётся из исходника;
- акцент #16A9BC не используется как цвет текста (2,6:1 на фоне страницы) —
  для текста затемнённый #0B6B79, 5,8:1;
- города и объёмы в селекторе — из нашего реестра, а не 42 города РФ.

Секции макета, требующие отсутствующих данных и контуров (лента
прогноз→факт, игра, продажа под ключ, статьи, модалка, оплата), не
верстались — заведены задачами #2894-#2904.

Проверено: tsc --noEmit, next lint, isolation guard (31 файл),
next build — /mera-public/v3 пререндерится статикой.
2026-08-15 19:24:32 +03:00
bot-backend
f1491068f4 merge main: объединить legal-copy.ts (PRIVACY_PATH + SHORT_ESTIMATE_DISCLAIMER)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (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 / frontend-checks (pull_request) Successful in 1m29s
CI Trade-In / backend-tests (pull_request) Successful in 4m49s
Оба модуля были созданы параллельно в разных ветках под один и тот же файл:
здесь — путь к политике ПДн для ссылки в чекбоксе согласия (блок 2),
в #2884 — короткая оговорка под диапазоном цены (блок 4.1). Обе константы
живут в одном модуле-без-импортов, шапка объединена.
2026-08-14 15:41:16 +03:00
1d7702a862 feat(mera): «Ориентировочный расчёт. Не является официальной оценкой.» под диапазоном цены (блок 4.1) (#2884)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m49s
Deploy Trade-In / deploy (push) Successful in 1m7s
2026-08-14 12:40:28 +00:00
bot-backend
74e7969ef9 fix(tradein/lead): кликабельная ссылка на политику ПДн в чекбоксе согласия
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (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 / frontend-checks (pull_request) Successful in 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 4m31s
РКН/владелец: рядом с чекбоксом согласия должна быть ссылка на сам документ
политики обработки ПДн, а не упоминание закона. Чекбокс в LeadForm.tsx
(v2, живой /trade-in/v2) теперь линкует "Политикой обработки персональных
данных" на /mera-public/privacy (target=_blank, чтобы не терять заполненную
форму). Путь вынесен в новый src/lib/legal-copy.ts (модуль без импортов) —
content.ts ре-экспортирует оттуда, чтобы B2B-виджет не тянул B2C-лэндинг-модуль
целиком.

_CONSENT_TEXT_SNAPSHOT/_CONSENT_POLICY_VERSION в lead.py обновлены под новый
плоский текст и дату утверждения политики (PRIVACY_APPROVAL: 2026-08-13).
test_consent_text_frontend_sync.py: экстрактор теперь снимает JSX-теги/{" "}
спейсеры перед сравнением (иначе сломался бы на разметке ссылки) + новый тест
держит _CONSENT_POLICY_VERSION в синхроне с PRIVACY_APPROVAL из content.ts,
чтобы версия не расходилась молча с редакцией документа.

Легаси-дубль в HeroTransparency.tsx (недостижим с живого роута) — текст
приведён в соответствие без ссылки: компонент не смонтирован нигде, и нет
теста, который держал бы там ссылку в актуальном состоянии.
2026-08-14 15:29:49 +03:00
bot-backend
91732d1acb feat(mera): формулировки шапки и заголовка вкладки по юр-требованиям (блок 3)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (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 / frontend-checks (pull_request) Successful in 56s
Юр-документ владельца от 14.08.2026, блок 3: верхний дескриптор и заголовок
вкладки должны уводить сервис от слова, отсылающего к отчёту оценщика по
135-ФЗ, и явно называть основание расчёта — рыночные данные.

- Шапка лэндинга: «Оценка вторичного жилья · <регион>» →
  «Оценка вторичного жилья по рыночным данным · <регион>». Слово «МЕРА»
  остаётся вордмарком слева (у него свой letter-spacing), строка складывается
  из двух узлов; регион по-прежнему из REGION_NAME, не литералом.
- Заголовок вкладки: «МЕРА — оценка квартиры на вторичном рынке» →
  «Мера · расчёт стоимости квартиры по рыночным данным».

H1 первого экрана («Сколько на самом деле стоит ваша квартира») НЕ меняется —
решение владельца: он не заявляет ничего про официальную оценку, а продающий
заголовок терять незачем. Заголовки трёх юридических подстраниц живут по
своему шаблону «<Документ> — МЕРА» и не затронуты.

Проверено: tsc --noEmit, next lint, mera-public isolation guard (18 файлов).
2026-08-14 15:28:35 +03:00
bot-backend
1b7e047210 feat(tradein/estimate): юр-строка «Ориентировочный расчёт. Не является официальной оценкой.»
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (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 / frontend-checks (pull_request) Successful in 1m3s
Блок 4.1 юр-требований владельца (14.08.2026): под диапазоном цены на
экране результата обязана быть эта строка. Вынесена в новый модуль
lib/legal-copy.ts (SHORT_ESTIMATE_DISCLAIMER, без импортов) — используется
и в v2/ResultPanel.tsx (боевой экран /v2), и в HeroSummary.tsx (legacy-контур
/trade-in/ui-preview/estimate), первым предложением в уже существующем
абзаце-дисклеймере про рыночный разброс. В ResultPanel.tsx подрезаны
lineHeight/marginTop/padding соседнего блока, чтобы новая строка не сжимала
плитки "ИСТОЧНИКИ ДАННЫХ" на фиксированной высоте артборда.
2026-08-14 15:25:58 +03:00
bot-backend
c6243b166f feat(mera): оферта, политика возврата и утверждённая политика ПДн + реквизиты
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (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 / frontend-checks (pull_request) Successful in 56s
Юридический блокер публичного B2C-запуска (G6 в mera-b2c-paid-flow-decision.md:
`LEGAL_ENTITY == null` → нет реквизитов, нет продажи) и входное требование
модерации эквайера: оферты не было вообще, а privacy-страница в собственной
шапке писала, что она НЕ утверждённая политика по ст. 18.1 152-ФЗ.

Что сделано:
- content.ts: `LEGAL_ENTITY` заполнен (ООО «ПРОЕКТ ФЛЭТ», ИНН/КПП/ОГРН, адреса,
  директор, банковские реквизиты), добавлены `SUPPORT_EMAIL`,
  `SERVICE_PRICE_RUB`, пути и короткие публичные URL документов. Единый
  источник — подвал, оферта, возврат и ПДн рендерят эти поля, а не повторяют
  строки. ОГРН сверен с открытыми данными ЕГРЮЛ (в исходном сообщении владельца
  был с опечаткой …028028, верный …028281).
- Новые страницы /mera-public/oferta и /mera-public/refund — редакции владельца
  от 13.08.2026 дословно, плейсхолдер `[адрес электронной почты]` заменён на
  support@meraocenka.ru.
- privacy/page.tsx переписана на утверждённую редакцию с оператором и
  реквизитами приказа. Сохранены оба инварианта честности: раздел про страницу
  ввода адреса условен по `PUBLIC_ESTIMATE_ENABLED` (п. 5.4 — пока публичный
  расчёт выключен, адрес не покидает браузер), срок хранения оплаченного отчёта
  рендерится из `PAID_REPORT_RETENTION_MONTHS`, а не числом в тексте
  (test_paid_retention_text_consistency.py).
- Caddyfile: короткие адреса /oferta, /refund, /privacy → rewrite на поддерево
  лэндинга. Именно они напечатаны внутри документов и уйдут в заявку эквайеру.
  Пути перечислены поимённо — allowlist-by-default периметра не ослаблен.
- smoke-mera-perimeter.sh: три новые проверки на короткие адреса.

Публикация оферты НЕ включает приём оплаты: платёжного контура в коде нет,
`PUBLIC_ESTIMATE_ENABLED` по-прежнему false. Оферта публикуется раньше кнопки
намеренно — без неё эквайер не примет заявку.

Проверено локально: tsc, next lint, vitest (29), isolation guard, next build
(три страницы пререндерены), pytest test_paid_retention_text_consistency (3),
caddy validate + adapt (rewrite на месте), рендер всех трёх страниц через
next start — реквизиты, почта и цена на месте.
2026-08-13 20:10:38 +03:00
9e83eb4a53 fix(tradein/ui): этаж сделки скрывался у всех 96 974 — требовалось второе поле (#2674) (#2861)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m16s
Deploy Trade-In / deploy (push) Successful in 1m7s
2026-08-13 08:46:45 +00:00
e59a102b16 fix(tradein/dkp): «ФАКТИЧЕСКИЕ СДЕЛКИ» называют свой возраст, а не окно поиска (#2847)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m13s
Deploy Trade-In / test (push) Successful in 3m33s
Deploy Trade-In / build-backend (push) Successful in 1m39s
Deploy Trade-In / deploy (push) Successful in 7m1s
2026-08-12 18:51:10 +00:00
82d8db9f42 fix(tradein/estimate): оживлять мёртвые сохранённые оценки при открытии (#2826)
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m11s
Deploy Trade-In / test (push) Successful in 3m42s
Deploy Trade-In / build-backend (push) Successful in 1m8s
Deploy Trade-In / deploy (push) Successful in 6m59s
2026-08-11 04:15:40 +00:00
8423af5dd5 feat(tradein): версионирование продукта — единый источник, подвал, PDF, /versions (#2824)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Successful in 36s
Deploy Trade-In / build-frontend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m14s
Deploy Trade-In / build-backend (push) Successful in 4m19s
Deploy Trade-In / deploy (push) Successful in 1m44s
2026-08-10 16:00:54 +00:00
9d9457f67d fix(tradein/estimate): не блокировать оценку — расширять подбор и честно предупреждать (#2823)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m30s
Deploy Trade-In / test (push) Successful in 3m14s
Deploy Trade-In / build-backend (push) Successful in 7m24s
Deploy Trade-In / deploy (push) Successful in 1m25s
2026-08-10 15:42:22 +00:00
bot-backend
7431615415 Merge remote-tracking branch 'forgejo/main' into feat/tradein-paid-retention
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 3m57s
# Conflicts:
#	tradein-mvp/backend/data/sql/_manifest_applied.txt
#	tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts
2026-08-07 15:40:52 +03:00
bot-backend
e6591a450a fix(tradein/payments): миграция 234 → 240 — номер снова занят на main
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) — правки текстовые, ни один тест не читает миграцию по
имени файла.
2026-08-07 15:31:01 +03:00
9de690ad4f chore(tradein/frontend): удалить остаточный слой фикстур (#2755) (#2785)
Some checks failed
Deploy / changes (push) Successful in 18s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 24s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m21s
Deploy Trade-In / test (push) Successful in 3m52s
Deploy / build-frontend (push) Successful in 4m39s
Deploy Trade-In / build-backend (push) Successful in 38s
Deploy / deploy (push) Successful in 1m27s
Deploy Trade-In / deploy (push) Failing after 10m5s
2026-08-07 10:00:48 +00:00
05bc321728 build(tradein/frontend): состав зависимостей перестаёт зависеть от даты сборки (#2770) (#2772)
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-08-07 09:07:58 +00:00
a034d7a1c1 test(tradein/frontend): первые проверки поведения — пустое поле, погашенное число, отказ 429 (#2766) (#2769)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m6s
Deploy Trade-In / deploy (push) Successful in 57s
2026-08-07 07:55:37 +00:00
bot-backend
48664dfe0e fix(tradein/payments): pre-flight должен ловить аномалию, не штатное состояние (review PR #2754)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (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 / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 3m49s
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.
2026-08-06 22:49:09 +03:00
76c3f5c110 chore(frontend): фикстуры макета не по умолчанию + удаление осиротевших компонентов (#2747)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m12s
Deploy Trade-In / deploy (push) Successful in 1m4s
Deploy / build-frontend (push) Successful in 3m35s
Deploy / deploy (push) Successful in 1m9s
МЕРА: у 8 компонентов витрины v2 проп data больше не имеет дефолта из fixtures.ts — при сбое передачи данных компонент обязан упасть на TS-ошибке, а не отрисовать выдуманные числа на платном экране оценки. Цепная правка в SectionOverlay (4 поля стали обязательными в такт с детьми).

Птица: удалены 6 осиротевших компонентов (ноль импортов подтверждён репо-wide), подчищены 2 ссылающихся комментария.

Проверено ревьюером: tsc --noEmit и next lint реально отработали на 91b460b1 (лог задачи 18031), vitest 32/264 зелёные (лог 18033); storybook в репозитории отсутствует вовсе — «unwired/storybook usage» как обоснование дефолтов никогда не имело потребителя; ui-preview/estimate использует v1-компоненты со своей локальной фикстурой и не задет.
2026-08-06 18:59:50 +00:00
bot-backend
5ff06d25b4 feat(tradein/payments): оплаченный отчёт хранится год — retain_until и предохранители в задаче удаления
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (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 / frontend-checks (pull_request) Successful in 1m4s
CI Trade-In / backend-tests (pull_request) Successful in 3m51s
Мина: 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 не тронуты.
2026-08-06 21:48:06 +03:00
3c5f535e6c fix(tradein/admin): гейт отмены по источнику, честный комментарий view, лимит 50 (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
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 / frontend-checks (pull_request) Successful in 1m2s
CI Trade-In / backend-tests (pull_request) Successful in 2m58s
Ревью 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 чистый.
2026-08-06 03:57:46 +05:00
4d0795ae7a fix(tradein/admin): убрать показатели, которые не могут быть ненулевыми, и брать список источников из данных (#2674)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
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 / frontend-checks (pull_request) Successful in 1m1s
CI Trade-In / backend-tests (pull_request) Successful in 2m57s
Четыре находки одного класса: админка показывает числа, которые никогда не
бывают ненулевыми, и подаёт это как результат. Ноль читается оператором как
«всё чисто», а не как «мы это не считаем» — такой показатель хуже отсутствующего.

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).
2026-08-06 03:34:18 +05:00
b88535425e fix(tradein): гейт правдоподобия на «медианный торг» — не показывать артефакт пейринга как рыночный факт (#2666)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 / frontend-checks (pull_request) Successful in 1m10s
CI Trade-In / backend-tests (pull_request) Successful in 2m51s
/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 не трогаются.
2026-08-05 23:49:03 +05:00
b800760c24 fix(tradein/scraper): фильтр skipped в админке + освежение схлопнутой строки (#2658)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 2m46s
Правки по ревью 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 с.
2026-08-05 23:10:47 +05:00
5162659277 fix(tradein/ui): чистить TanStack Query cache при смене identity (#2567) (#2651)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m2s
Deploy Trade-In / deploy (push) Successful in 1m9s
2026-08-05 10:06:31 +00:00
59c072fc4c chore(tradein): удалить мёртвые mobileproxy env-переменные и rotate-ip (#2616 шаги 2-3) (#2650)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 2m13s
Deploy Trade-In / test (push) Successful in 2m45s
Deploy Trade-In / build-browser (push) Successful in 3m2s
Deploy Trade-In / build-backend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 2m29s
2026-08-05 09:35:54 +00:00
8e909faca8 chore(mera-public): isolation guard + favicon + Caddy handle (#2631) (#2644)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy / build-frontend (push) Successful in 35s
Deploy Trade-In / build-frontend (push) Successful in 3m32s
Deploy Trade-In / deploy (push) Successful in 1m6s
Deploy / build-backend (push) Successful in 5m42s
Deploy / build-worker (push) Successful in 5m54s
Deploy / deploy (push) Successful in 1m47s
2026-08-05 06:20:40 +00:00
2aa79e2143 fix(tradein): честный радиус поиска аналогов на карте v2 (#2632) (#2643)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m0s
Deploy Trade-In / test (push) Successful in 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m51s
Deploy Trade-In / deploy (push) Successful in 1m23s
2026-08-05 06:13:58 +00:00
966415dc8a feat(tradein/v2): срок экспозиции подстрокой в карточке «Ожидаемая цена сделки» (#2639)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m6s
Deploy Trade-In / deploy (push) Successful in 55s
2026-08-02 16:06:03 +00:00
bot-backend
9ba788f3a9 fix(tradein/ui): показывать цену по сделкам Росреестра вместо «недостаточно данных»
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (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 / frontend-checks (pull_request) Successful in 1m2s
v2/page.tsx считало insufficient=true как только n_analogs===0, даже когда
median_price_rub>0 — backend уже отдаёт честную цену по deals-фолбэку
(PR #2629), но v2 всё равно рисовал «недостаточно данных» (23 оценки по
Серову, v1/PDF-экспорт показывали этот же случай правильно). Сужено до
единственного честного признака backend'а — estimate.insufficient_data
(= median_price_rub<=0). Когда цена есть, а листинговых аналогов нет,
результат теперь явно подписан «по сделкам Росреестра» вместо ложного
«в объявлении», и таблица аналогов не рисует пустой заголовок без строк.

Заодно (M1 аудита #2583): confidence_explanation бэкенда нигде не
читался в v2 — выведен рядом с «ДОСТОВЕРНОСТЬ» (tooltip в ResultPanel +
видимая строка в ObjectSummary).
2026-08-02 16:06:15 +03:00
d0d0d1fbab fix(tradein/maps): убрать украинский флаг Leaflet и починить атрибуцию OSM (#2621)
All checks were successful
Deploy Trade-In / changes (push) Successful in 8s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m57s
Deploy Trade-In / deploy (push) Successful in 52s
2026-08-02 12:12:43 +00:00
6ac4ad9867 feat(mera): публичный лэндинг «МЕРА» — контент и вёрстка
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (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 / frontend-checks (pull_request) Successful in 1m7s
Только фронт. Caddyfile, DNS и бэкенд не тронуты: периметр и домен
meraocenka.ru делаются отдельным PR, чтобы горячий Caddyfile не менялся
параллельно с эпиком «единый вход».

Заменяет заглушку из feat/mera-b2c-perimeter (48 строк «скоро откроется»)
на полноценную страницу: первый экран, как это работает, что человек
получает, откуда данные, вопросы-ответы, подвал и страница обработки ПДн.

## Починен живой баг, из-за которого лэндинг уводил бы людей на форму входа

RouteGuard регистрировал useEffect с router.push('/login') ДО early-return
для публичных путей. Хуки выполняются всегда, поэтому на проде аноним на
/mera-public получал фоновый GET /api/v1/me → 401 → редирект на логин, и
«безусловный bypass» до этого просто не доходил.

Guard разделён: RouteGuard теперь только смотрит pathname, а весь закрытый
контур (useMe, RBAC, экраны отказа) вынесен в GuardedRoute и подключён через
next/dynamic — это точка разрыва графа импортов, а не только логическая
развилка. Проверено на живой странице: запросов к API — НОЛЬ.

## Обещания приведены в соответствие с продуктом

Ревью по честности нашло 1 critical и 4 high — всё это обещания, которых
продукт не выполняет. Убрано или переписано:

- «Отчёт в PDF» — ручка owner-scoped, анониму отдаёт 401 by design;
- «список объектов, на которых построен расчёт» — план прямо запрещает
  показывать анониму сырые объявления конкурентов;
- шесть городов подавались как равнозначные, хотя сбор вне Екатеринбурга
  выключен (миграция 179, enabled=false) и данных единицы. Теперь честно:
  полное покрытие — Екатеринбург, по области данных меньше;
- «в расчёте два типа данных» умалчивало третий — чужие оценочные модели,
  которые реально двигают итоговую цифру (estimator.py, IMV/Yandex blend);
- страница ПДн обещала удаление данных, механизма которого нет.

Тексты и правовые формулировки вынесены в content.ts одним местом, рядом с
ссылками на код, который их подтверждает. Финальная редакция privacy —
за юристом, это помечено в файле.

## Форма адреса — честная заглушка, и это вынужденно

Живого автокомплита быть не может: _PUBLIC_PATHS «Меры» (rbac.py) открывает
анониму только health/docs/login/logout и анонимный чат. /geocode/suggest и
/trade-in/estimate отдают анониму 401. Открывать их до анти-абуза (этап 2
плана B2C) прямо запрещено планом. Форма честно говорит, что произойдёт,
и не изображает работу, которой нет. Переключается флагом
PUBLIC_ESTIMATE_ENABLED, рядом с ним — гейт из трёх условий.

## Проверено живьём, не по отчёту

- 360px и 390px: горизонтального переполнения нет (единственный элемент за
  экраном — skip-link, так и задумано);
- один h1, иерархия H1→H2→H3 без пропусков, landmark-разметка;
- внешних ресурсов ноль — ни CDN, ни шрифтов, ни картинок с чужих доменов;
- ноль запросов к /api/** со страницы;
- tsc --noEmit чист.

NB для ревьюера: .claude/rules/ui-*.md по frontmatter paths: матчат
frontend/**, то есть корневой фронт Site Finder, а не tradein-mvp/frontend.
Здесь применяется дизайн-система v2/tokens.ts. Tailwind в этом фронте нет.
2026-08-01 23:01:19 +03:00
eccb895db1 feat(tradein): переключаемый реестр людей — подготовка переезда «Меры» в БД auth [PR-2b/6]
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
Дефолт не меняет ничего: 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, тот же красный).
2026-08-01 02:50:14 +03:00
d6a8a693c5 Merge pull request 'chore(tradein): удалить остатки источника N1 (#2593)' (#2595) from chore/tradein-remove-n1 into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m38s
Deploy Trade-In / build-frontend (push) Successful in 2m43s
Deploy Trade-In / build-backend (push) Successful in 1m26s
Deploy Trade-In / deploy (push) Successful in 1m17s
2026-07-31 18:47:01 +00:00
24387d0455 Merge pull request 'fix(tradein/ui): не обрезать длинные названия городов в селекторе (#2576)' (#2591) from fix/tradein-ui-city-label-clip into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-07-31 18:46:54 +00:00
bot-backend
146139fbf9 chore(tradein): удалить остатки источника N1 (#2593)
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 9s
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 / frontend-checks (pull_request) Successful in 1m13s
CI Trade-In / backend-tests (pull_request) Successful in 2m29s
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 — индексация не затронута.
2026-07-31 21:01:47 +03:00
bot-backend
762d1f68c6 chore(ci+rbac): auth/** в paths-фильтры + «Поиск домов» только у админа
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Successful in 1m56s
CI / frontend-tests (pull_request) Successful in 2m14s
CI Trade-In / backend-tests (pull_request) Successful in 3m1s
CI / openapi-codegen-check (pull_request) Successful in 2m15s
CI / backend-tests (pull_request) Successful in 15m48s
Две связанные вещи, обе — по решению владельца продукта.

1. auth/** в paths-фильтры обоих CI (ci.yml, ci-tradein.yml).
   auth/roles.yaml — общий RBAC-конфиг двух стеков, но лежит в корне репы и не
   попадал НИ В ОДИН фильтр: правка ролей/пользователей не запускала ни backend-,
   ни tradein-сьют. Так 2026-07-30 в main уехал красный test_get_role_known_users
   (user2 переведён в expired, тест ждал pilot) — обнаружен только вручную и
   починен в PR #2587. Теперь правка roles.yaml гоняет оба гейта.

2. «Поиск домов» (/trade-in/sale-share) — ТЕСТОВЫЙ продукт, доступ только у
   админа. Раньше он был закрыт от клиентских ролей (employee/manager/pilot), но
   оставался открыт внутренней роли analyst. «Только у админа» включает и
   внутренние роли → analyst добавлен в deny по sale-share.

   Асимметрия с «Кэшем» намеренная и запиннена тестом: Кэш — не продукт, а
   диагностика кэшей/скраперов, т.е. ровно тот инструмент, ради которого роль
   analyst заведена; ему он оставлен.

Замеры после правки (реальный is_path_allowed поверх roles.yaml):
  роль     | Поиск домов | Кэш  | ядро продукта
  admin    | True        | True | True
  analyst  | False       | True | True
  pilot    | False       | False| True

Тест test_yaml_roles_deliberately_outside_client_deny переписан: пиннит ОБЕ
стороны асимметрии, а не только «analyst видит всё». Набор внутренних путей
разрезан на _SALE_SHARE_PATHS / _CACHE_TOOL_PATHS с assert'ом, что разрез
покрывает исходный набор целиком — иначе новый путь добавят и забудут отнести
к продукту, оставив analyst непроверенным.

Заодно поправлены устаревшие комментарии «Доступ: pilot + admin» в Caddyfile
(vanity-редирект gendsgn.ru/sale-share) и в докстринге самой страницы.

Тесты: 77 passed (tradein rbac/auth_session/auth_api) + 24 passed (site-finder).
tsc --noEmit + next build — зелёные. YAML обоих workflow провалидирован.
2026-07-31 20:24:24 +03:00
bot-backend
62aca75994 fix(tradein/ui): не обрезать длинные названия городов в селекторе (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (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 / frontend-checks (pull_request) Successful in 1m8s
Живая проверка прода после #2584: триггер городского дропдауна
(pp-dd-trigger-dashed, ParamsPanel.tsx) — фиксированные 176x22px,
font-size 11px. Дефолтный лейбл "Определить автоматически" (~146px
в Manrope 400, замерено opentype.js против реального шрифта прода)
не влезал в однострочный бюджет ~143px, переносился на вторую
строку и обрезался высотой триггера.

Заменил UNCONFIRMED_CITY_LABEL на "Автоопределение" (~96px, большой
запас) — сохраняет смысл, перекликается с "Авто" у РАДИУС АНАЛИЗА,
но не двусмысленно рядом с названиями городов. Самое длинное
название города в CITY_LABELS, "Каменск-Уральский" (~108px),
укладывается в тот же бюджет без переноса — второго фикса не
требует.
2026-07-31 19:08:42 +03:00
a7fbd348e0 Merge pull request 'feat(tradein/ui): передавать город в геокодирование + честный признак неопределённости (#2576)' (#2584) from feat/tradein-ui-city-hint into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m28s
Deploy Trade-In / build-frontend (push) Successful in 2m40s
Deploy Trade-In / build-backend (push) Successful in 1m17s
Deploy Trade-In / deploy (push) Successful in 6m57s
2026-07-31 15:46:01 +00:00
bot-backend
49ee85aae2 fix(tradein/ui): не называть неподтверждённый город в плашке неопределённости (#2576)
All checks were successful
CI / changes (pull_request) Successful in 14s
CI Trade-In / changes (pull_request) Successful in 14s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / 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 / frontend-checks (pull_request) Successful in 1m33s
Deep-review R3: плашка city_ambiguous всегда подставляла {city} — внутреннее
состояние с дефолтом "Екатеринбург" (initCityLabel), а не то, что реально
определил бэкенд (в ответе только булев target_city_ambiguous, угаданного
города там нет). Ровно в целевом сценарии фикса — нетронутая форма, «Ленина
1», cityConfirmed=false — текст утверждал «если это не Екатеринбург»
независимо от реального результата (там мог быть Нижний Тагил) — та же
нечестность, которую предыдущий коммит убирал из запроса, только в тексте.

Текст плашки теперь ветвится по cityConfirmed:
- cityConfirmed=true (город реально был подтверждён и отправлен) — прежний
  текст с конкретным {city} уместен, не меняю.
- cityConfirmed=false (это и есть путь, где cityAmbiguous обычно и
  срабатывает после предыдущего коммита) — нейтральная формулировка без
  упоминания конкретного города: «Если это неверно, выберите город выше и
  повторите оценку.»

tsc --noEmit / next lint / next build — чисто (те же 2 pre-existing warning в
несвязанных файлах).
2026-07-31 18:42:40 +03:00
bot-backend
abb9398f3f fix(tradein/rbac): скрыть «Доля в продаже» и «Кэш» от клиентских аккаунтов
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 2m19s
CI Trade-In / backend-tests (pull_request) Successful in 3m13s
CI / openapi-codegen-check (pull_request) Successful in 3m34s
CI / backend-tests (pull_request) Successful in 16m7s
Аккаунт praktika (DB-роль manager) видел оба пункта в топбаре на /trade-in/team.
Это внутренние инструменты — аналитика рынка и состояние кэшей/скраперов, —
клиентские аккаунты их видеть не должны (решение владельца продукта).

Гейт один — deny-список роли, потому что все три места сверяются с ним через
общий матчер: пункт меню (Topbar по scopePath из /me), страница (RouteGuard) и
серверные ручки (rbac_guard). Правка только фронта спрятала бы пункт, оставив
прямой URL и API открытыми.

Закрыто для employee/manager (DB_ROLE_PATHS) и для legacy pilot (roles.yaml):
  /trade-in/sale-share/**
  /trade-in/cache/**
  /trade-in/api/v1/buildings/**
  /trade-in/api/v1/trade-in/cache-stats/**

У cache-stats ГЛОБ, а не точный путь: точный паттерн — строгое равенство, его
обходит трейлинг-слэш ('…/cache-stats/' → allowed=True), и защита держалась бы
на Starlette redirect_slashes, а не на RBAC. Замерено после правки: все варианты
(слэш, %2f, ./, ../) дают 403, утечек нет.

Основной продукт не задет: buildings.py обслуживает ТОЛЬКО sale-share, секция
«Продажи в доме» на экране оценки питается estimate-хендлерами. admin и analyst
сознательно вне deny — запиннено тестом, иначе «синхронизация» списков закрыла
бы их молча.

Заодно починен КРАСНЫЙ pre-existing тест главного бэкенда:
backend/tests/test_rbac.py::test_get_role_known_users ждал pilot у всех
user1..user10, но user2 («Брусника») стал expired 2026-07-30. CI это пропустил —
auth/roles.yaml не входит в paths-filter backend/**, из-за чего сьют не бежал.

Тесты: 153 passed (tradein) + 24 passed (site-finder, было 23+1 failed).
Новые — e2e через реальный rbac_guard по session-ветке (именно ею ходит
praktika), пин deny_paths в выдаче /me, границы глоба и regression-guard'ы.
Проверены снятием deny: 7 тестов краснеют, т.е. не тавтологии.
2026-07-31 18:20:03 +03:00
bot-backend
a8d29fa031 fix(tradein/ui): не слать city_hint по умолчанию — только явный выбор или город из адреса (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
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 / frontend-checks (pull_request) Successful in 1m15s
Deep-review R2 на #2580/#2576: предыдущий коммит слал city_hint="Екатеринбург"
даже когда дропдаун не тронут — бэкенд трактует ЛЮБОЙ city_hint как «пользователь
назвал город» (city_specified=True), так что target_city_ambiguous становился
false практически всегда, а необнаруженный житель Нижнего Тагила («Ленина, 1»
без явного упоминания города) молча резолвился бы в Екатеринбург — ровно баг,
который чинил backend, только переехавший из geocoder.py в city-registry.ts.

Вариант A (по рекомендации ревьюера): город реально известен (и поэтому
отправляется в city_hint) ТОЛЬКО когда пользователь явно выбрал его в
дропдауне ИЛИ detectCityInText нашёл совпадение в наборном тексте / выбранной
подсказке. Нетронутый дефолт → city_hint не уходит вовсе (ни в geocode/suggest,
ни в POST /estimate) — тогда backend честно возвращает target_city_ambiguous и
не форсит ЕКБ-bias без запроса.

- Новое состояние `cityConfirmed` (ParamsPanel.tsx) — гейт на отправку,
  раздельный от `city` (best-guess для отображения/текста плашки). true после
  explicit dropdown pick ИЛИ автодетекта из текста/подсказки; sticky —
  мелкая правка адреса без нового совпадения его не сбрасывает.
- До подтверждения дропдаун показывает `UNCONFIRMED_CITY_LABEL`
  ("Определить автоматически"), не статичное "Екатеринбург" — не выдаёт
  внутренний best-guess за подтверждённый пользователем выбор.
- useGeocodeSuggest получает city_hint только при cityConfirmed=true — для
  нетронутой формы автокомплит тоже больше не форсит ЕКБ-bias молча, а видит
  кандидатов из всей области (в т.ч. Нижний Тагил) — это и есть тот сценарий
  из заголовка эпика.
- ЕКБ happy path не усложнён: как только пользователь печатает город в адресе
  или (обычный путь) выбирает любую подсказку из автокомплита, detectCityInText
  почти всегда находит "Екатеринбург" в full_address (провайдер возвращает
  город как часть резолвленного адреса независимо от того, был ли отправлен
  hint) — дропдаун сам переключается на "Екатеринбург" и cityConfirmed
  становится true без отдельного клика. Требует лишнего действия только
  редкий путь "напечатал произвольный адрес без города и нажал Enter, не
  выбрав ни одной подсказки".
- city-registry.ts: явный комментарий-ссылка на бэкендовый гэзеттир
  `SVERDLOVSK_OBLAST_CITIES` (tradein-mvp/backend/app/services/geocoder.py) —
  parity-риск при добавлении нового города остаётся видимым с фронтовой
  стороны (backend/тесты не трогаю — другой PR, вне моего scope).

tsc --noEmit / next lint / next build — чисто (только 2 pre-existing warning в
несвязанных файлах, как и в предыдущем коммите).
2026-07-31 18:15:40 +03:00
bot-backend
2779ef5e79 feat(tradein/ui): передавать город в геокодирование + честный признак неопределённости (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (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 / frontend-checks (pull_request) Successful in 1m7s
Раньше интерфейс город вообще не передавал — backend (#2580) больше не
подставляет "Екатеринбург" молча, из-за чего житель Нижнего Тагила, вводя
«Ленина, 1», получал бы результат по одноимённой екатеринбургской улице.

- Новый справочник src/lib/city-registry.ts (растущий список городов области,
  сейчас: Екатеринбург, Нижний Тагил, Каменск-Уральский, Первоуральск,
  Верхняя Пышма, Серов) — DEFAULT_CITY = Екатеринбург, чтобы ЕКБ-сценарий не
  требовал никаких лишних действий.
- ParamsPanel: компактный дропдаун «Город» рядом с лейблом адреса (переиспользует
  существующий <Dd> HUD-комбобокс) + автоопределение города из набранного
  текста/выбранной подсказки (detectCityInText, word-boundary safe — не путает
  "Серов" с "ул. Серова" в ЕКБ). city_hint уходит в geocode/suggest и в
  POST /trade-in/estimate.
- useGeocodeSuggest(query, cityHint, limit) — city_hint в query-параметрах и в
  queryKey, чтобы смена города рефетчила подсказки.
- Честная подсказка в ParamsPanel, когда estimate.target_city_ambiguous===true:
  спокойный (не danger) текст «Город определён автоматически — результат может
  относиться к другому населённому пункту области. Если это не {city},
  выберите верный город выше и повторите оценку.» — не блокирует форму.
- types/trade-in.ts: TradeInEstimateInput.city_hint,
  AggregatedEstimate.target_city_ambiguous (зеркалит backend PR #2580, ещё не
  смёржен — codegen не запускался, поля добавлены вручную по контракту схемы).

tsc --noEmit / next lint / next build — чисто (только 2 pre-existing warning
в несвязанных файлах).
2026-07-31 17:08:46 +03:00
103dc22b8c Merge pull request 'feat(tradein/support): чат поддержки без входа — экран логина и «доступа нет»' (#2577) from feat/tradein-anon-support into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 17s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m33s
Deploy Trade-In / build-frontend (push) Successful in 3m1s
Deploy Trade-In / build-backend (push) Successful in 1m16s
Deploy Trade-In / deploy (push) Successful in 1m22s
2026-07-31 13:22:20 +00:00