Миграция 178 засеяла deal_city_price_bands разово (N>=30 сделок), без scheduler'а
на refresh. Город без строки падал на глобальный DEAL_MIN_PPM2=50000 (ЕКБ-калибровка)
— для дешёвых городов области это не anti-outlier guard, а cut-off легитимного рынка
(Североуральск median ~21.7k). Замер по прод-данным: 289 из 369 не-ЕКБ городов
(1265 сделок) не имели строки и падали на ЕКБ-порог.
- 194_deal_city_price_bands_tiers.sql — трёхуровневая схема (tier колонка):
full (N>=30, own p1/p99, unchanged) / rough (N 10-29, own p1 floor + фикс.
ceiling 800000) / region_fallback (N 1-9, pooled областной p1=15263 вместо
ЕКБ-порога). Екатеринбург по-прежнему не в таблице — estimator fallback
byte-identical.
- 195_scrape_schedules_seed_deal_city_price_bands_refresh.sql — scrape_schedules
row, окно 07:00-08:00 UTC (после rosreestr_dkp_import + asking_to_sold_ratio_refresh).
- app/tasks/deal_city_price_bands_refresh.py — периодический re-derive (kit-scheduler,
byte-identical 194 derivation), без DELETE (множество городов монотонно растёт).
- app/services/product_handlers.py — регистрация Handler для нового source.
Валидация: scratch-БД (syntax_check) в прод-контейнере, synthetic данные на
границах тиров (N=9/10/29/30) + Екатеринбург/non-rosreestr/NULL exclusion, оба
файла применены дважды (идемпотентность подтверждена), scratch-БД удалена.
Deep-review #2564: manager_id синкался из EXCLUDED безусловно — повторный прогон сида
тихо обнулял связь сотрудник->менеджер, назначенную через team-API (#2563), сотрудник
выпадал из _LIST_EMPLOYEES_BY_MANAGER_SQL. Защищён COALESCE, как остальные UI-managed
поля.
is_active убран из ON CONFLICT DO UPDATE SET вовсе (не COALESCE — колонка NOT NULL
DEFAULT true делала бы COALESCE-ветку недостижимой, мёртвый код вводил в заблуждение
симметрией с реально работающими COALESCE-полями). Open/close доступа — решение
владельца продукта через UI (#2556), не повторный прогон seed-файла.
Migration 193: переносит org-карту (admin/kopylov/praktika/user1-10), утверждённую
владельцем продукта, из auth/roles.yaml в tradein_users (Foundation — миграция 192).
password_hash=NULL для всех — пароли админ проставит вручную через team-UI (#2556).
ASCII-CHECK на username (deep-review #2561): rbac кодирует session-username через
encode("latin-1","replace"), кириллические логины одинаковой длины схлопываются
в общий downstream-identity (IDOR) — constraint запрещает это fail-closed.
ON CONFLICT DO UPDATE защищает password_hash/is_active/display_name/org_name/email
через COALESCE — повторный прогон (recovery / staging без _schema_migrations
tracking) не затирает то, что менеджер поменял через UI.
Три дефекта, каждый блокировал легальный публичный запуск.
1. Адрес физлица сохранялся в базу ДО любого согласия: согласие фиксировалось
только на форме заявки, то есть ПОСЛЕ записи адреса. Для пилота с договором
терпимо, для человека с улицы — нет. Проверка согласия поставлена первой
строкой расчёта, до геокодирования и до обоих мест записи адреса.
Хранение — колонками на самой оценке, 1:1 с уже работающим прецедентом для
заявок (миграция 182): IP клиента, версия политики, дословный снимок текста.
Отдельная таблица событий не заводилась: согласие даётся ровно на создание
этой строки, и когда строка удаляется по сроку, исчезновение доказательства
вместе с данными логично.
Enforcement НЕ выводится из пустого created_by — первая версия так и делала
и сломала 92 несвязанных теста оценщика, которые зовут расчёт без имени
пользователя, проверяя ценовую логику. Вместо этого явный флаг, который
выставляет единственный боевой вызывающий. B2B-поток не тронут: поле
согласия опционально, иначе сломались бы пилоты, чей фронт его не шлёт.
2. Срок жизни оценки применялся только как фильтр при чтении — физического
удаления не было ни в одной фоновой задаче, данные жили вечно вопреки
декларированному сроку. Заведена задача удаления пачками с ограничением на
прогон и коммитом после каждой пачки, идемпотентная. В расписании она
ВЫКЛЮЧЕНА: это первая автоматическая задача, удаляющая персональные данные,
и первый прогон должен быть под наблюдением.
3. Пути «удалите мои данные» не было. Добавлен сервис удаления и админская
ручка. Ключи: имя пользователя, идентификатор оценки, телефон, чат в
телеграме.
Честно зафиксировано в коде: аноним без ссылки на оценку, без оставленного
телефона и без обращения в поддержку неидентифицируем — удалить его данные
без дополнительной идентификации нельзя. Отдельно: удаление чистит только
копию в базе, зеркало переписки в телеграм-топике не удаляется ничем в
кодовой базе, нужен ручной шаг.
4. Соответствие текста согласия на фронте и снимка на бэке держалось на
комментарии. Теперь есть тест, который ловит расхождение.
Сроки хранения вынесены в настройки. Значение для заявок предложено инженерно
(типичный отраслевой диапазон), юридически обоснованный срок — за юристом, и
это записано в коде.
Тесты: 2775 passed.
Клиент пишет боту в личку → воркер зеркалит сообщение через copyMessage
в топик супергруппы-форума → оператор отвечает реплаем на зеркало → бот
доставляет ответ клиенту. Полный лог переписки в Postgres.
Отдельный контейнер на long-polling, а не webhook в tradein-backend:
не нужно пробивать дырку в auth-middleware (_PUBLIC_PATHS, #2213) и
маршрут в Caddy, нулевая внешняя поверхность, падение бота не задевает API.
Без aiogram — httpx уже в зависимостях, нужны только getUpdates/copyMessage.
Маршрутизация ответа — по topic_message_id: message_id в Telegram уникален
в пределах чата сквозь все топики, а все зеркала лежат в одном support-чате,
поэтому спутать адресата нельзя. Реплай на шапку/на ответ другого оператора
не резолвится (у direction='out' topic_message_id IS NULL) → тихий игнор.
Безопасность (найдено ревью, воспроизведено эмпирически):
- токен Telegram живёт в PATH URL, поэтому sanitize_url его не режет;
утекал в GlitchTip через locals стек-фреймов (include_local_variables
по умолчанию True) и через span data HttpxIntegration. Закрыто
include_local_variables=False + regex-редактор в before_send (обе формы:
/bot<id>:<secret> и голая <id>:<secret>), поверх существующего PII-scrub.
- httpx-логгер печатает полный URL на INFO → боевой токен уходил бы в
docker logs каждые 30с. Приглушён до WARNING.
Надёжность:
- kill-switch при пустом токене — idle-блокировка, не exit(0): при
restart: unless-stopped выход с любым кодом даёт рестарт-луп.
unless-stopped выбран сознательно — только он гарантирует автозапуск
после ребута VPS.
- stop_grace_period: 120s — дефолтные 10с убивали бы контейнер раньше,
чем докрутится long-poll (30с) и отработает drain (100с).
- сбой SQL теперь ловится отдельно и делает rollback перед сдвигом offset:
иначе сессия в failed-transaction не давала сохранить offset, апдейт
переигрывался и зеркалился в топик по кругу.
152-ФЗ: переписка — ПДн, ON DELETE CASCADE по chat_id, удаление клиента
одним DELETE. Ретенция — follow-up.
Бот не включается автоматически: TELEGRAM_* задаются в runtime-env на VPS,
без них воркер штатно висит в idle. Порядок — в DEPLOY.md.
Тесты: 51 passed (маршрутизация обоих направлений, дедуп, 403→is_blocked,
throttle-окно шапки, redaction токена во всех формах event).
Добавляет sber_freshness_monitor по образцу deals_freshness_monitor:
staleness данных СберИндекса теперь видна на MONITOR-частоте, а не тонет
в per-estimate warning'ах estimator._load_sber_index_series (#audit-5a).
- app/tasks/sber_freshness_monitor.py: чистая evaluate_sber_freshness()
(frozen-now, без БД) + check_sber_freshness() (один SELECT
max(period_month) вторичного сегмента по региону, #R2-H1 фильтр как в
эстиматоре; WARNING при stale, mark_done при алерте — это монитор, не
сбой; mark_failed только при пустой таблице).
- app/services/product_handlers.py: _job_sber_freshness_monitor + Handler
в build_product_handlers (run_in_executor, как deals-монитор).
- data/sql/180_seed_sber_freshness_monitor.sql: seed scrape_schedules
(enabled, daily 09:00-10:00 UTC, lag_allowance_days=25).
- tests/test_sber_freshness_monitor.py: frozen-now (fresh/stale/границы) +
FakeDB (fresh/stale/empty/кастомный lag) + свойства миграции + registry.
Порог алерта: sber_index_max_age_days (35) + lag_allowance (25) = 60д.
+25 — запас на инхерентный лаг публикации источника (1-2 мес), чтобы не
шуметь на штатном отставании. Прод 2026-07-12: max=2026-05-01, age=72д >
60 → alert=1.
- scheduler.py import_rosreestr_dkp: снят фильтр city ILIKE '%катеринбург%', address из реального city источника, deals.region_code + новая deals.city заполняются (было: хардкод 'Екатеринбург,' + region_code NULL)
- migration 177: deals.city + индекс + бэкфилл существующих EKB-строк region_code=66/city
- guard city IS NOT NULL → address не NULL для ~15 null-city строк источника
- sync deploy/import-rosreestr.sh (ops-fallback) под тот же oblast-scope
- split dedup-теста: 077 (историческая) хранит EKB-фильтр, живой импорт — нет
Открывает +47183 не-ЕКБ сделок region 66, уже сидящих в источнике, ранее резавшихся на импорте.
Финальный PR issue #2045 (BE-3): GET /api/v1/trade-in/location-coef для
LocationDrawer. FDW foreign table -> локальное зеркало osm_poi_ekb_local
(TRUNCATE+INSERT, тот же паттерн что cad_buildings_local/cadastral_geo_match,
избегает ~1.16s/row FDW round-trip) -> straight-line POI-скоринг, портированный
из Site Finder poi_score.py::compute_poi_weighted_top7 (CATEGORY_WEIGHTS as-is,
радиус 1200м для квартир вместо Ptica 2000м для участков). score->coef -
новая MVP-эвристика (0.95..1.05, не откалибрована на реальных дельтах).
Graceful fallback (не 500, не фабрикуем факторы): пустая/не отрефрешенная
osm_poi_ekb_local или отсутствие lat/lon у оценки -> coef=1.0, factors=[],
geo_source="unavailable".
Scheduler: source=osm_poi_ekb_refresh, daily, зарегистрирован и в боевом
dispatch (scheduler.py), и в kit-registry (product_handlers.py) - иначе
test_kit_registry_completeness падает на ship-dark инварианте (#2192).
Frontend wiring (mappers.ts/LocationDrawer.tsx) - вне scope, отдельная задача
после проверки endpoint'а curl'ом на деплое.
- listings.phones: миграция 166 обнуляет данные; cian.py + scraper-kit
serp.py (golden-parity зеркало) перестают читать offer.get("phones") на
входе — write-path выключен у источника, колонка/поле в модели остаются.
Мёртвая запись "phones" в LISTING_FIELD_PRIORITY убрана.
- trade_in_estimates.client_name/client_phone: миграция 167 дропает колонки;
синхронно убраны из TradeInEstimateInput, обоих INSERT в estimator.py
(основной путь + _empty_estimate), frontend TradeInEstimateInput и
CRM-блока EstimateForm (поля "Имя клиента"/"Телефон").
- sentry_scrub.py и management_companies.phones не тронуты — вне scope.
Листинги примагничиваются к НЕ ТОМУ дому (кривой house_id_fk): прод-замер 373
дома (5.5%) / 1685 листингов (7.2%) дальше 300м от своего дома. Пример — house
131237 «Мамина-Сибиряка 126» (ЖКХ=51 кв, знаменатель ВЕРНЫЙ): из 20 активных
листингов реально его ~2, остальные — Свердлова 32 / Азина 31 (до 2.2км) →
ложная доля 39% вместо ~4%.
- мигр.150: v_building_sale_share — числители (active/45д) + медианы считают
только листинги ≤300м от geom дома (ST_DistanceSphere; листинг/дом без geom —
keep). Знаменатель не тронут.
- buildings_query.build_listings_query: тот же гео-фильтр в детальной выдаче
(карточка дома больше не показывает чужие листинги).
Симуляция на проде: меняется 351 дом, обнуляется 164 (чистые «мусорные вёдра»),
числитель −8.3%. Мамина-126: 20→2. Это митигация на уровне view; корневой фикс
house_id_fk-матчера — отдельно. ruff clean, pytest 24 green.