Commit Graph

2944 Commits

Author SHA1 Message Date
Дмитрий fc78ee1ece fix(billing): письма о заморозке баланса не доходили — воркер не видел клиента под RLS
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Прод-инцидент 14.07.2026. Пять писем (заморозка, напоминание, финальное,
разморозка, «проект остановлен — нет денег») уезжали в очередь с Eloquent-моделью
Tenant. SerializesModels заменяет модель на id, а воркер грузит её заново — под
ролью crm_app_user, где RLS-policy tenants_self_isolation без app.current_tenant_id
отдаёт 0 строк → ModelNotFoundException. Клиент №7 заморожен с 12.07 и не получил
ни одного письма; на проде это ломало письма о заморозке для ВСЕХ клиентов.

Письма больше не ходят в БД при отправке: несут снимок данных (без SerializesModels).

Заодно: сторож incidents:watch-failures плодил копию persistent-инцидента каждый час
(строка в failed_jobs живёт вечно, а дедуп был окном в 60 мин) — 2 залипшие ошибки
дали 31 запись за сутки и красную лампу «Очереди/джобы». Дедуп persistent теперь по
факту незакрытого инцидента, а не по возрасту последней копии.

Регрессия: BalanceMailsQueueRestoreTest (6 кейсов) + 2 теста сторожа.
Прогон: 165/165 billing+incidents, phpstan 0, pint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 08:55:37 +03:00
Дмитрий 7672e64497 docs(visitors): короткие ссылки для рассылок — liderra.ru/s, /hh, /tg
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:58:33 +03:00
Дмитрий 55d002e5c1 feat(visitors): короткие ссылки для рассылок — liderra.ru/s, /hh, /tg
Accessibility (Pa11y live) / a11y (push) Has been cancelled
В смс дорог каждый символ: вместо длинного хвоста с метками сервер сам
подставляет канал и дату рассылки (дд-мм), чтобы разные рассылки не слипались.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:53:35 +03:00
Дмитрий c67385b7c1 docs(pilot): снимок боевого 14.07 — свой учёт посетителей выкачен
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Робот/человек по исполнению JS, кука-склейка лендинг↔кабинет, раздел
«Посетители» в админке, гео офлайн, $host в логах nginx. Отдельно записаны
грабли: перекрытие партиций (42P17), обрыв SSH на migrate, исходник лендинга
живёт в feat/sales-finder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:44:55 +03:00
Дмитрий 3d3f11b717 fix(visitors): партиции нарезает только штатный механизм + сторож схемы на 80 таблиц
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Полный прогон вскрыл реальный баг: миграция создавала партицию site_events_2026_07
своими границами (в местном времени), а MonthlyPartitionManager — site_events_y2026_m07
в UTC. Партиции перекрывались (42P17) и роняли 19 ЧУЖИХ тестов. Теперь партиции
нарезает только менеджер (ensureRange), как у всех остальных таблиц.

Плюс сторож схемы обновлён под +2 таблицы учёта (80 таблиц, 141 индекс) и
записаны готовые ссылки с метками для смс/hh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 05:22:46 +03:00
Дмитрий b7cd063c28 feat(visitors): чистка данных старше 180 дней (152-ФЗ)
Гостей чистит visitors:prune (воскресенье 03:15 МСК), партиции событий —
существующий partitions:drop-expired по новой настройке retention=6 месяцев.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 05:06:27 +03:00
Дмитрий bf501799d2 feat(visitors): раздел «Посетители» в админке — воронка, каналы, визиты, кабинет
Воронка подсвечивает красным шаг, где теряем больше всего людей. Гости с VPN
и хостингов показаны отдельным числом, а не подмешаны в конверсию. Город при
VPN честно помечается как недостоверный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 05:03:38 +03:00
Дмитрий 6c1f3f8cba feat(visitors): админ-API воронки, каналов, визитов и активности в кабинете
Воронка — по уникальным живым гостям (is_datacenter=false); гости с хостингов
и VPN считаются отдельным числом, в конверсию не попадают.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 05:00:00 +03:00
Дмитрий 83b72b785d feat(visitors): маячок на лендинге (view/alive/cta_click)
Метрику и Вебвизор не трогает — работает параллельно. Исходник лендинга взят
из feat/sales-finder (в боевую ветку он никогда не попадал); при выкате маячок
вставляется в БОЕВУЮ версию файла, чтобы не тащить на прод незавершённую стройку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 04:17:15 +03:00
Дмитрий 6d764068a8 feat(visitors): маячок портала (view/alive/register_open/portal_screen) 2026-07-13 20:02:56 +03:00
Дмитрий 47bcdb2b35 feat(visitors): события регистрации и входа пишет backend, гость связывается с клиентом
Три точки: обычный вход, вход через 2FA (оба пути — код и резервный),
подтверждение почты (именно там клиент реально создаётся, а не на /register).
Учёт в try/catch — ошибка учёта не может уронить вход. Регресс авторизации 114/114.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:56:54 +03:00
Дмитрий 481dc6e183 feat(visitors): маячок POST /api/track + кука гостя на .liderra.ru
Task 4 плана 2026-07-13-visitors-analytics.md: VisitTracker (создание/обновление
гостя, запись событий, привязка к клиенту) + публичный TrackController@store,
маршрут POST /api/track (throttle:track 60/мин), лимитер в AppServiceProvider,
CSRF-исключение api/track в bootstrap/app.php.

Отклонения от плана (тест-харнесс, не прод-код):
- getCookie('lid_vid', false) в тесте убрал decrypt=false — с withCookie() это
  давало двойное шифрование (withCookie сам шифрует plain-значение).
- добавлен withCredentials() перед withCookie()+postJson/getJson — Laravel
  тест-клиент по умолчанию не шлёт cookie на JSON-запросы (как XHR
  credentials:'omit'); реальный маячок шлёт fetch с credentials:'include',
  так что прод не затронут.

Pest: 5/5 (TrackEndpointTest). Pint: app/Services/Tracking + TrackController.
2026-07-13 19:46:48 +03:00
Дмитрий ab840ffe2a feat(visitors): определение города и оператора по офлайн-базе + признак VPN/хостинга
Гео читается из локального файла (DB-IP Lite) — IP посетителя наружу не уходит.
Нет файла базы → город не определяется, учёт продолжает работать (fail-open).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:32:34 +03:00
Дмитрий 752ded204a feat(visitors): определение канала и устройства посетителя 2026-07-13 19:25:44 +03:00
Дмитрий 5d4e6e93bd feat(visitors): таблицы site_visitors + site_events (партиции, GRANT'ы, маскирование ПДн) 2026-07-13 19:21:29 +03:00
Дмитрий 151380f3ad docs(plans): план учёта посетителей — 12 задач, TDD, выкат отдельным шагом
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:33:33 +03:00
Дмитрий 74a8f717c9 docs(specs): собственный учёт посетителей + раздел «Посетители» в админке
Точное разделение робот/браузер/человек (отклик страницы, а не запрос),
сквозная склейка лендинг→портал через куку на .liderra.ru, воронка и каналы
в админке. Метрику и Вебвизор не трогаем.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:33:21 +03:00
Дмитрий 6a51d34cc5 docs(pilot): снимок боевого 12.07 — метка канала B_n_ + хвосты прогона + сверка main==прод
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Две записи в ПИЛОТ.md:

1. Кабинет поставщика стирал метку канала при обновлении строки. Разбор письма робота
   «нашёл расхождение»: тревога ложная, но за ней пряталась потеря атрибуции канала —
   лид от строки без метки писался как DIRECT вместо B1/B2/B3. Корень, фикс, выкат,
   результат на бою 81 строка починена, 138 строк вместо 57, 0 расхождений, откат.

2. Хвосты полного прогона: нарезчик партиций не делал прошлый месяц, три аудит-теста
   ADR-021 не доехали в main. Плюс итог сверки боевой против gitea/main — 569 из 569
   файлов бэкенда совпали побайтово, различий ноль.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:38:57 +03:00
Дмитрий 7feab849a6 fix(tests): партиция за прошлый месяц + возврат аудит-тестов, отставших от ADR-021
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Разбор хвостов полного прогона: было 2407/2418, стало 2417/2418 — единственное
оставшееся падение ExampleTest воспроизводится только там, где не собран фронт
Vite manifest not found, к коду отношения не имеет.

1. partitions:create-months — новый флаг --behind, по умолчанию 1.
   Нарезалось только «текущий месяц и вперёд», поэтому на свежей базе партиции за
   прошлый месяц не было НИКОГДА. Между тем строки с датой в прошлом месяце — штатное
   явление: тридцатидневное окно списаний метрика runway и вставки на стыке месяцев.
   На боевом не всплывало — там партиции копятся с самого начала, проверено: нарезаны
   до января 2027, крон жив. А на свежей базе и в CI INSERT падал «no partition found»
   в первой половине КАЖДОГО месяца. Ловилось двумя тестами AdminTenantShowTest.runway
   и роутингом лида.

2. Возвращены три аудит-теста, потерянных при сведении main 09.07.
   ADR-021 сделал цепочки журналов общими и AuditChainConfig в main это уже отражает,
   а тесты остались на старом per-tenant поведении ADR-018 и падали. Правильные версии
   лежали на ветке fix/audit-chain-global-scope, но в main не доехали — тот же класс
   потери, что и CsvReconcileJobTest.

Тесты: полный прогон 2417/2418 зелёный, Pint чисто.

Известное и НЕ тронутое, вынесено в отдельный список:
  - Larastan на main даёт 30 замечаний уровня типов, ни одного живого бага:
    мёртвые ветки в AuditRebuildChain, оставшиеся от ADR-021; ресурсы автоподбора без
    аннотации модели; ProjectController читает виртуальные пометки gate_payload и
    launch_deferred, которых нет в таблице.
  - Тест лимита входов флакует только в длинном прогоне, в одиночку зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:02:21 +03:00
Дмитрий 8c1d1710fb fix(supplier): метка канала B1_/B2_/B3_ не стирается при обновлении заказа
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Прод-инцидент 11-12.07.2026: робот итоговой проверки слал письма «нет заказа у
поставщика» на строки, которые в кабинете ЕСТЬ, включены и с верным лимитом.

Корень: кабинет дописывает метку канала к имени строки только при СОЗДАНИИ, а при
обновлении сохраняет имя ровно как прислали. Наш ежедневный updateProject слал голый
uniqueKey и каждый прогон стирал метку. Последствия:
  - итоговая проверка выводила площадку из префикса имени и переставала узнавать
    строку -> ложное missing 11.07 и 12.07;
  - лид от такой строки приходил с project без метки -> webhook не мог определить
    канал и писал platform=DIRECT вместо B1/B2/B3, то есть терялась атрибуция канала.

Что сделано:
  - SupplierPortalClient::toPayload — на update имя уходит с меткой канала; на create
    остаётся голым, там метку ставит сам кабинет и один save с тремя флагами рождает
    три строки, общего префикса у них нет.
  - VerifySupplierOrderJob::normalizeLive — площадка берётся из служебного поля src
    rt/bl/mt, а не из префикса имени; сверка больше не зависит от имени вообще.
  - Новая разовая команда supplier:repair-project-names — возвращает метку строкам,
    у которых её уже стёрли. Payload собирается ИЗ ЖИВОЙ строки кабинета, меняется
    ровно одно поле name; по умолчанию сухой прогон, запись только с --apply.

Ветка пересобрана на gitea/main — закрывает follow-up «фича итоговой проверки заказа
не сведена в main». Попутно возвращён CsvReconcileJobTest, отставший от кода после
сведения main 09.07: он не фейкал fetchDeliveredLeads и падал 9 из 11.

Боевой liderra.ru: выкачено, починена 81 строка, робот показывает 0 расхождений
138 наших строк вместо 57. Двум лидам восстановлен канал по журналу выдач поставщика.

Тесты: Pest supplier 277/277, Pint clean, Larastan 0 новых ошибок.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:14:22 +03:00
Дмитрий a028af4bc8 feat(autopodbor): сторож валидности телефонов на шаге 2 (реестр Россвязи + ДаДата)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Экстрактор кода страницы ловил любые 11 цифр (7/8 + телефонная группировка) без
проверки, что это настоящий российский номер — со страниц-агрегаторов пролезал мусор
с несуществующими кодами (227/303/453/613/761) и подписывался «городской».

PhoneValidityGate между CandidateBuilder и SourceAggregator:
- слой 1 (бесплатно, всегда): реестр Россвязи (phone_ranges) — нет в реестре → выкидываем;
- слой 2 (платно, за флагом autopodbor.phone_dadata_enabled, по умолчанию ВЫКЛ):
  DaDataValidityLayer перепроверяет прошедшие реестр номера, снимает qc 2/7,
  под дневным бюджетом + кэш вердикта; платим только за выживших (мусор отсекается ДО ДаДаты).
- fail-open при пустом реестре / сбое БД (иначе внешний catch в SiteOpener съел бы все номера);
- короткие/uncertain номера не трогаем (нельзя сверить).

Закрыты ОБА пути шага 2: обычный (RealCompetitorAgent) и глубокий (SiteOpener).
Миграций нет — реестр уже на проде (453k диапазонов).

Тесты: PhoneValidityGateTest (7), DaDataValidityLayerTest (6), SiteOpenerValidityTest (1);
весь набор автоподбора 519 зелёных, Pint + Larastan чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:34:19 +03:00
Дмитрий 46a89537eb feat(privacy): срезать канальный префикс B<N>_ в API сделок и CSV-экспорте
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Серверный 3-й слой защиты (SupplierProjectName::strip) в DealController,
V1/DealsController, DealExportController — паритет с фронтом stripChannelPrefix.
На входе префикс и так срезается (RouteSupplierLeadJob/CsvLeadsParser), это
belt-and-suspenders на случай будущих правок парсера. Тесты: API+CSV не палят B<N>_.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 13:21:59 +03:00
Дмитрий 7024a1edc7 reconcile: main = боевой прод (deploy-tariff + фича автоподбора + 3 фикса 09.07 + 2 orphan-файла)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Старая линия main сохранена: тег main-legacy-20260709 + off-site бэкап. Дерево этого
коммита побайтово равно боевому серверу (495/495 бэкенд-файлов сверены).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:22:51 +03:00
Дмитрий 1d8a6f04ac reconcile: канон main = боевой прод (deploy-tariff + фича + 3 фикса 09.07)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:19:55 +03:00
Дмитрий 247a7b07c6 fix(autopodbor): RLS-политика межтенантного распорядителя + фронт 422-дискриминатор + price=0 гейт
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:45:12 +03:00
Дмитрий 856d85cbd4 feat(autopodbor): кнопка «Собрать источники (N)» в подвале + яркий алерт баланса 2026-07-09 09:45:12 +03:00
Дмитрий 23da9550f3 feat(autopodbor): endpoint POST /api/autopodbor/study/batch 2026-07-09 09:45:11 +03:00
Дмитрий f667a1079d feat(autopodbor): джоба зовёт распорядителя после завершения + крон autopodbor:study-tick
Задача 5: RunAutopodborStudyJob::handle() оборачивает существующее тело в
try/finally — после ЛЮБОГО завершения прогона (done/empty/failed, включая
проброшенное исключение) распорядитель AutopodborStudyScheduler::tick()
берёт следующего кандидата с освободившейся дорожки шага 2.

Задача 6: artisan autopodbor:study-tick — крон-страховка раз в минуту на
случай, если дорожка освободилась не через завершение джобы. Без
withoutOverlapping() — требует таблицу cache_locks, которой нет в схеме
(см. комментарий в routes/console.php); overlap уже защищён внутренним
Cache::lock в AutopodborStudyScheduler::tick().
2026-07-09 09:45:10 +03:00
Дмитрий 6c0ed2d300 feat(autopodbor): startStudyBatch + перевод одиночных сборов на распорядителя 2026-07-09 09:45:10 +03:00
Дмитрий aca5183a4e feat(autopodbor): AutopodborStudyScheduler — одна дорожка + round-robin 2026-07-09 09:45:09 +03:00
Дмитрий f2321f52a2 feat(autopodbor): AutopodborBudgetGate — деньги пакета с резервом проектов
Переиспользует BalancePreflightService.evaluate() для проверки, что после
списания за пакет ещё хватает баланса на дневной лимит активных проектов.
Бинарный поиск максимально доступного количества единиц пакета (maxAffordable)
и расчёт недостающей суммы (topupRub) при отказе.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:45:08 +03:00
Дмитрий 35f42c22d4 feat(autopodbor): миграция batch_id + индексы очереди шага 2 2026-07-09 09:45:08 +03:00
Дмитрий 9229323af5 fix(supplier): пауза доходит до поставщика в batch — ночной джоб 18:00 гасит осиротевшие заказы
Боевой в batch-режиме: пауза проекта не доходила до поставщика — заказ висел активным
(галочка вкл, лимит>0), поставщик мог лить лиды и списывать за остановленный проект
(подтв. живой базой: omega, 57 заказов, 0 команд после паузы 08.07).

Поставщик фиксирует завтрашнюю заливку снимком в 21:00, поэтому гасить надо в 18:00-прогоне
ДО снимка (мгновенно бессмысленно — сегодня уже зафиксировано). Добавлен проход
deactivateOrphanedOrders в SyncSupplierProjectsJob: заказам источников, которых нет в
активных группах слепка за завтра, шлётся updateProject(status=paused) с ненулевым лимитом,
локально помечается inactive_since. online-режим не тронут (там пауза уже мгновенна).
Активность берётся из слепка (слепок-инвариант), не из live is_active.

Тесты (TDD RED->GREEN): batch pause + стык с delete-хвостом. 19/19 supplier+delete,
238/238 supplier+projects, pint чисто, PHPStan без новых замечаний.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:44:52 +03:00
Дмитрий 576981c1c9 feat(supplier): аварийный spike-детектор ручной очереди в incidents:watch-failures
Блок 6: если за окно (10 мин) в supplier_manual_sync_queue упала пачка
(>= threshold-manual-queue, дефолт 10) — вероятное падение кабинета поставщика.
Инцидент дедупится 60 мин, письмо шлётся каждый прогон на kdv1@bk.ru + ops@liderra.ru
(громко при реальной аварии, но одним сводным письмом). Флаг sendMail в createIncident.
phpstan-baseline: +4 TestCall::artisan() (5→9) от новых тестов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 74d4d9f21d)
2026-07-09 09:44:52 +03:00
Дмитрий 58fda75d52 fix(supplier): FailoverProjectChannel не шлёт per-project CRITICAL письма
Убраны письма manual_required и failover_to_form (по проекту, без троттла) —
источник потопа ~116 писем на ops@ 07.07. Строка в ручной очереди остаётся
(запись видна в админке). Зависимость Mailer удалена из канала и DI-привязки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 72818f5a80)
2026-07-09 09:44:51 +03:00
Дмитрий d64aaa3e9b docs(pilot): зафиксированы 2 боевых выката 09.07 — пауза→поставщик (batch, dfa14862) + audit-chain global (ADR-021, 1495cd64)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Отметка в ПИЛОТ.md обоих моих выкатов: (1) SyncSupplierProjectsJob deactivateOrphanedOrders
(пауза гасит заказ у поставщика в 18:00 до снимка 21:00; бэкап .bak-20260709-023707);
(2) AuditChainConfig global (verify-chains EXIT 0, 78 партиций; бэкап .bak-20260709-060558).
NB про параллельный CSV-reconcile money-фикс (91c5a119) — уже в gitea main, дрейфа нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main-legacy-20260709
2026-07-09 09:12:33 +03:00
Дмитрий 91c5a119eb fix(supplier): CSV-reconcile сверяет журнал ОТДАННОГО (по vid), не пул
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Причина фантомных сделок kdv1 (09.07.2026): CsvReconcileJob брал отчёт «Запрос
номеров» = ПУЛ собранных номеров (phones_cnt), а не ОТДАННОЕ (crms_cnt), и лепил
из пула сделки клиенту + выедал дневной лимит. Пример: пул 157, отдано 15,
у клиента 15 фантомов, а 15 реально отданных выбило лимитом.

Фикс: SupplierPortalClient::fetchDeliveredLeads() читает журнал «Мои сделки»
(index-visit, по vid) — только реально отгруженное. CsvReconcileJob сверяет по vid,
добор несёт настоящий vid (idx_supplier_leads_vid_unique -> идемпотентно с webhook).
Пул не трогается -> фантомы невозможны, лимит не забивается мусором.

Проверено: 12 новых тестов + 294 supplier зелёные, Pint/Larastan чисто. Выкачено и
проверено на проде (reconcile читает отданное, 0 фантомов за прогон).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:05:52 +03:00
Дмитрий 1495cd643d fix(audit): hash-chain canonical → global-within-partition (ADR-021, supersedes ADR-018)
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Ночной audit:verify-chains падал на pd_processing_log (ложный «high»-инцидент + письма
kdv1@bk.ru). Проверка на живом кластере (read-only, хеши server-side) показала: ВСЕ 4
«per-tenant» audit-таблицы (activity_log, tenant_operations_log, balance_transactions,
pd_processing_log) записаны ГЛОБАЛЬНОЙ цепочкой (0 global-mismatch), per-tenant верификация
ложно ругается на стыках клиентов. Предпосылка ADR-018 (триггер даёт per-tenant через RLS)
эмпирически неверна — изоляция никогда не была реализована. Данные целостны.

Решение владельца (с учётом что задет и денежный balance_transactions): канон — global.
AuditChainConfig: partition='' для 4 таблиц (verify/rebuild читают конфиг → авто-global).
Данные/схему/триггер/деньги НЕ трогаем (0 mismatch под global, rebuild не нужен).

TDD: новый тест «cross-tenant rows chained by trigger (global) verify intact»; обновлены 3
теста старого per-tenant поведения (config-mirror, per-tenant regression → supersede,
rebuild per-tenant → global). Тесты: 30 audit + 23 Pd/Scheduler зелёные, pint + PHPStan(0) чисто.

Решение задокументировано в docs/adr/ADR-021; ADR-018 → Superseded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 08:54:10 +03:00
Дмитрий dfa14862b4 fix(supplier): пауза доходит до поставщика в batch — ночной джоб 18:00 гасит осиротевшие заказы
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Боевой в batch-режиме: пауза проекта не доходила до поставщика — заказ висел активным
(галочка вкл, лимит>0), поставщик мог лить лиды и списывать за остановленный проект
(подтв. живой базой: omega, 57 заказов, 0 команд после паузы 08.07).

Поставщик фиксирует завтрашнюю заливку снимком в 21:00, поэтому гасить надо в 18:00-прогоне
ДО снимка (мгновенно бессмысленно — сегодня уже зафиксировано). Добавлен проход
deactivateOrphanedOrders в SyncSupplierProjectsJob: заказам источников, которых нет в
активных группах слепка за завтра, шлётся updateProject(status=paused) с ненулевым лимитом,
локально помечается inactive_since. online-режим не тронут (там пауза уже мгновенна).
Активность берётся из слепка (слепок-инвариант), не из live is_active.

Тесты (TDD RED->GREEN): batch pause + стык с delete-хвостом. 19/19 supplier+delete,
238/238 supplier+projects, pint чисто, PHPStan без новых замечаний.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 05:35:19 +03:00
Дмитрий ec805957f1 chore(deploy): защищённый выкат сборки портала — не даёт затереть чужие фичи
Портал выкатывается полной заменой public/build; сборка из устаревшей ветки
молча сносила чужую работу (инцидент 08.07: Метрика снесла колонки «Сделок»).
deploy-build.sh перед подменой сверяет маркеры критичных фич (по активным
чанкам manifest.json, не по orphan-осколкам) — теряет живую фичу → ОТКАЗ.
Осознанный откат — FORCE=1, только проверка — DRY_RUN=1. Установлен на боевом.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:19:17 +03:00
Дмитрий 995aae4571 fix(portal): карточки вместо таблицы на планшете-портрете (Сделки/Списания)
Широкая таблица (7 колонок) не влезала в узкую колонку планшета рядом с
rail-меню — правые колонки «Комментарий»/«Поставлен» обрезались на 600–1279px.
ResponsiveTable теперь показывает карточки на телефоне И планшете, таблицу —
только на десктопе (>=1280). Тесты табличного слоя зафиксированы на десктоп-ширину.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:57:07 +03:00
Дмитрий 8a4ee4c69b fix(deals): колонка «Конкурент» показывает только бренд до первой запятой
Имя конкурента хранится как «Бренд, описание» — в колонке «Конкурент»
показываем часть до первой запятой, полное имя — в title при наведении.
Применено к десктопной таблице и мобильной карточке. +тест на обрезку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:37:30 +03:00
Дмитрий abdb6e20c4 feat(metrika): Метрика+Вебвизор в линию портала (чтобы выкаты не теряли)
Метрика была потеряна, т.к. клалась мимо конвейера: выкат = git archive HEAD ветки
+ пересборка, а Метрика жила отдельно → перезатёрлась. Кладём в feat/portal-mobile-adaptive
(линию, из которой режутся deploy/*-ветки) → любой будущий выкат несёт её сам.

Загрузчик metrika.ts (только кабинет layout=app), маскировка ПДн лидов
(ym-hide-content/ym-disable-keys) — десктоп-таблица + МОБИЛЬНЫЕ карточки (телефон+
комментарий+aria-label) + канбан + карточка сделки + диалог. Сборка проходит.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:00:37 +03:00
Дмитрий 567e950758 fix(admin): клиентский диапазон ступеней в тарифной сетке + предпросмотр в редакторе
Cherry-pick b35d3888 поверх боевой ветки feat/portal-mobile-adaptive для
выката ТОЛЬКО правки тарифов на прод. Логика денег не менялась.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:29:40 +03:00
Дмитрий 8d9af40637 feat(billing): приветственный подарок при регистрации 300 ₽ → 1000 ₽
START_BALANCE_RUB в RegistrationService: '300.00' → '1000.00' — новый тенант
получает 1000 ₽ на баланс при подтверждении почты. Обновлён докблок
RegistrationController и тест RegistrationTest (баланс 1000, 13 тестов зелёные).
Автоподборные 300 ₽ (цена «Собрать конкурентов») НЕ тронуты — это другая сумма.
Миграций нет (константа применяется при регистрации). В UI/лендинге сумма
подарка не отображается — фронт не менялся.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:35:22 +03:00
Дмитрий 6d6ce28e54 fix(autopodbor): защита route.query в deep-link — чистый прогон тестов
Влитый из автоподбора deep-link «Сделки → Автоподбор» читал route.query
в onMounted без защиты. В приложении роутер всегда есть, но при монтировании
в юнит-тесте без роутера route === undefined → unhandled error (2 ошибки,
vitest exit 1). Добавил route?.query ?? {} — поведение в бою не меняется,
полный прогон снова зелёный (1184 , exit 0). Правка нужна для чистого
CI-гейта выката мобильной ветки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 08:44:48 +03:00
Дмитрий dead7e48c5 feat(portal-mobile): новые колонки «Конкурент» и «Источник» в карточке сделки на телефоне
После вливания переделки сделок (колонки Конкурент/Источник со ссылками
в автоподбор) эти поля были только в таблице на компьютере — в карточке
телефона их не было. Добавил в мобильную карточку:
- «Конкурент» — имя конкурента зелёной ссылкой, ведёт в автоподбор
  (/autopodbor?competitor=ID), рядом город;
- «Источник» — отдельной строкой домен/идентификатор ссылкой в автоподбор
  (+ «· +N» если источников несколько) и канал (Сайт/Звонки/СМС).
Для сделок без источника автоподбора карточка выглядит как раньше
(проект · канал · город). @click.stop — ссылки не открывают деталь сделки.

Проверено на 390px: конкурент+источник видны, вбок не едет, переход
по ссылке в автоподбор работает; тесты DealsTable/mapper/ResponsiveTable
зелёные (23).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 08:26:29 +03:00
Дмитрий a231fe25a7 Merge branch 'worktree-avtopodbor' into feat/portal-mobile-adaptive 2026-07-08 08:16:51 +03:00
Дмитрий 4194d86ff7 docs(сделки): спека + план «Конкурент/Источник» в реестре сделок
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 07:56:45 +03:00
Дмитрий a4c9200261 feat(сделки): колонки «Конкурент» и «Источник» со ссылками в автоподбор
Раздел «Сделки»: колонка «Источник» разбита на две — «Конкурент» (ссылка на
экран конкурента) и «Источник» (ссылка на «Настройки проекта»). В модалку
настроек добавлена кнопка пуск/стоп (срабатывает не закрывая окно).

- бэк DealController::index: батч-резолв конкурента+источника по
  deal.project_id → autopodbor_sources.created_project_id → competitor (без N+1)
- контракт ApiDeal/MockDeal + маппер: competitor_*/source_* поля
- DealsTable: 1 колонка → 2 (Конкурент/Источник), router-link, fallback без автоподбора
- AutopodborView: deep-link ?competitor=&project= открывает экран конкурента + модалку
- FieldCompetitorScreen: кнопка ⏸/▶ в модалке настроек + авто-открытие по ссылке

Тесты: бэк DealIndexTest +4 (31 ), фронт +новые (66 ). Дизайн не менялся.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 07:56:30 +03:00