28 Commits

Author SHA1 Message Date
Дмитрий 7feab849a6 fix(tests): партиция за прошлый месяц + возврат аудит-тестов, отставших от ADR-021
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
Разбор хвостов полного прогона: было 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) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
Прод-инцидент 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>
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
Дмитрий 9799c0eb0d feat(supplier): аварийный spike-детектор ручной очереди в incidents:watch-failures
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Блок 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-07 19:19:26 +03:00
Дмитрий 316bc2d852 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-07 19:19:09 +03:00
67 changed files with 4321 additions and 451 deletions
+38 -7
View File
@@ -87,11 +87,6 @@ paths = [
'''app/composer\.lock''',
# Pest-тесты с фиктивными data-фикстурами (не реальные ПДн)
'''app/tests/.*\.php''',
# Тест-фикстуры (HTML/JSON/CSV) — снятые публичные страницы справочников и
# синтетика для парсеров. Напр. карточка 2ГИС с ПУБЛИЧНЫМ бизнес-телефоном
# конкурента (опубликован в открытом справочнике), не клиентские ПДн.
# Та же категория, что app/tests/*.php выше.
'''app/tests/fixtures/.*''',
# Database seeders с демо-данными (admin@demo.local + +7916123XXXX демо-телефоны)
'''app/database/seeders/.*\.php''',
# Database factories — генераторы тестовых фикстур (фейковые телефоны/ИНН,
@@ -124,7 +119,37 @@ paths = [
'''tools/observer-pii-filter\.test\.mjs''',
# Test fixture for the secret-scanner / read-path-deny (M5) — PEM-header marker +
# AWS EXAMPLE key, used to verify detection. Not a real key; file deleted in brain split.
'''tools/enforce-read-path-deny\.test\.mjs'''
'''tools/enforce-read-path-deny\.test\.mjs''',
# Заглушка ИИ-агента автоподбора (Fake*CompetitorAgent) — синтетические демо-телефоны
# конкурентов (Казань 8432…, 8-800), а не реальные ПДн. Та же категория, что
# factories/doubles; заменяется реальным движком (binding в AutopodborServiceProvider).
'''app/app/Services/Autopodbor/Agent/Fake.*Agent\.php''',
# Кликабельные прототипы фичи (демо-телефоны для визуализации макета) — та же категория,
# что docs/superpowers/{specs,plans,audits,runbooks}; не реальные ПДн.
'''docs/superpowers/prototypes/.*\.html''',
# «Косяки глазами пользователя» — audit-internal находки живого прохода портала
# с синтетическим демо-телефоном 916-123-45-67 в разделе про нормализацию формата.
# Не реальные ПДн; та же категория, что docs/superpowers/{specs,plans,audits}.
'''косяки с точки зрения пользователя/.*\.md''',
# Исследование ИИ-вебмастера (выгрузка Perplexity) — пример HTTP-заголовка
# Idempotency-Key в доке про идемпотентность ловится как generic-api-key. Не ключ.
'''вебмастер-исходники-perplexity/.*\.md''',
# Тест-фикстуры — захваченные сторонние HTML/JSON (страницы 2ГИС с ИХ
# публичными фронтенд-токенами) + синтетические телефоны. Не наши секреты/ПДн.
# Та же категория, что app/tests/*.php (уже в allowlist).
'''app/tests/fixtures/.*''',
# bylo-stanet — демо «было/станет» лендинга (HTML-макеты с демо-телефонами).
# Историческая директория; та же категория, что liderra_v8_handoff/concepts.
'''bylo-stanet/.*''',
# Findings-доки — internal audit-находки живого прохода портала (демо-телефоны
# в примерах). Та же категория, что docs/superpowers/{specs,plans,audits,runbooks,prototypes}.
'''docs/superpowers/findings/.*\.md''',
# Autopodbor preview-экраны — демо-данные конкурентов для UI-разводки макета.
# Та же категория, что mockDeals.ts / settings/*.vue.
'''app/resources/js/views/autopodbor/screens/.*\.vue''',
# .env.example — шаблон переменных окружения (плейсхолдеры/пустые значения, не
# реальные секреты). Настоящие .env — в .gitignore и сканируются pre-commit.
'''app/\.env\.example'''
]
regexTarget = "match"
regexes = [
@@ -165,6 +190,9 @@ regexes = [
'''79991234567''',
'''74955551212''',
'''89991234567''',
# Демо-номер Красноярск (3912-71-33-33) из docblock-примера удалённого
# HtmlPhoneScanner (Autopodbor Extract). Историческая находка, в текущем коде нет.
'''73912713333''',
# Plan 2: ABC-коды городов с тестовым tail "1234567" (PhonePrefixService docs/tests)
'''7\d{3}1234567''',
'''799912345678''',
@@ -172,5 +200,8 @@ regexes = [
'''\+79991234567''',
'''7 999 123 45 67''',
# 12-значные номера-маски для скриншотов и тестов
'''[78]\(?[*X]{3}\)?\s?[*X]{3}[\s\-]?[*X]{2}[\s\-]?[*X0-9]{2}'''
'''[78]\(?[*X]{3}\)?\s?[*X]{3}[\s\-]?[*X]{2}[\s\-]?[*X0-9]{2}''',
# Демо-плейсхолдер автоподбора (экран DetailScreen) — Казань 843 + «200-00-00», явный фейк
'''7\s?843\s?200[\s\-]?00[\s\-]?00''',
'''78432000000'''
]
+4
View File
@@ -54,6 +54,10 @@ exclude = [
# Sample/примерные адреса
"^https?://example\\.com",
"^https?://example\\.org",
# Email-адреса в тексте, склеенные с кавычкой-ёлочкой (напр. «support@liderra.ru»),
# lychee ошибочно резолвит как относительный file://-путь. Это не ссылка на файл.
"support@liderra\\.ru",
"^file://.*@[a-z0-9.-]+\\.(ru|com|org)»?$",
# Покойный GitHub-аккаунт CoralMinister (suspended) — все ссылки на него мертвы:
# исторические compare/actions-runs в ПИЛОТ.md / handoffs / plans. Бэкап теперь Gitea.
"^https?://github\\.com/CoralMinister/",
@@ -33,7 +33,8 @@ class IncidentsWatchFailures extends Command
{--threshold-daily=50 : Порог суммы за 24ч для failed_jobs}
{--persistent-hours=3 : Порог возраста persistent-exception для failed_jobs}
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}
{--threshold-single-lead=1000 : Порог storm detection: failures одного supplier_lead_id за окно}';
{--threshold-single-lead=1000 : Порог storm detection: failures одного supplier_lead_id за окно}
{--threshold-manual-queue=10 : Порог spike ручной очереди поставщика (за окно) возможное падение кабинета}';
protected $description = 'Сканирует failed_webhook_jobs и failed_jobs, создаёт incidents_log на превышение порогов';
@@ -47,6 +48,7 @@ class IncidentsWatchFailures extends Command
$dedupMinutes = (int) $this->option('dedup-window');
$thresholdSingleLead = (int) $this->option('threshold-single-lead');
$thresholdManualQueue = (int) $this->option('threshold-manual-queue');
$since = Carbon::now()->subMinutes($windowMinutes);
$since24h = Carbon::now()->subHours(24);
@@ -221,6 +223,35 @@ class IncidentsWatchFailures extends Command
}
}
// ===== БЛОК 6: supplier-manual-queue spike =====
// Пачка проектов, ушедших в ручную очередь за короткое окно = вероятное падение
// кабинета поставщика. Инцидент дедупим (60 мин, чтобы не засорять журнал), НО письмо
// шлём на КАЖДОМ прогоне пока спайк держится — «сигналит как ненормальный» при реальной
// аварии, одним сводным письмом на оба адреса (не N писем на проект).
if ($thresholdManualQueue > 0) {
$manualQueueCount = (int) DB::connection(self::DB_CONNECTION)
->table('supplier_manual_sync_queue')
->where('created_at', '>=', $since)
->count();
if ($manualQueueCount >= $thresholdManualQueue) {
$dedupKey = 'supplier-manual-queue-spike';
$summary = "Похоже, кабинет поставщика упал: {$manualQueueCount} проект(ов) ушло в ручную очередь за {$windowMinutes} мин.";
// Инцидент — только если открытого с той же сигнатурой нет (дедуп 60 мин).
if (! $this->isDup($dedupKey, $dedupAt)) {
$this->createIncident($adminId, 'other', 'high', $summary, $since, $now, $dedupKey, sendMail: false);
$created++;
}
// Письмо — каждый прогон при активном спайке, на оба адреса.
Mail::to(['kdv1@bk.ru', 'ops@liderra.ru'])
->send(new IncidentDetectedMail($summary, 'high'));
$this->info("Supplier manual-queue spike [high]: {$manualQueueCount} in {$windowMinutes}m");
}
}
$this->info("Done. Created {$created} incident(s).");
return self::SUCCESS;
@@ -245,6 +276,7 @@ class IncidentsWatchFailures extends Command
Carbon $startedAt,
Carbon $now,
string $dedupKey = '',
bool $sendMail = true,
): void {
DB::connection(self::DB_CONNECTION)->table('incidents_log')->insert([
'type' => $type,
@@ -259,7 +291,7 @@ class IncidentsWatchFailures extends Command
'updated_at' => $now,
]);
if ($severity === 'high') {
if ($sendMail && $severity === 'high') {
Mail::to('kdv1@bk.ru')->send(new IncidentDetectedMail($summary, $severity));
}
}
@@ -10,10 +10,16 @@ use Illuminate\Support\Carbon;
/**
* Создаёт ежемесячные партиции для всех таблиц в MonthlyPartitionManager::PARTITIONED_TABLES
* на N месяцев вперёд от текущей даты.
* на N месяцев вперёд от текущей даты и на M месяцев назад.
*
* Hole #2 (23.05.2026): расширен с 2 бизнес-таблиц до 9 (+ 7 audit-таблиц).
*
* behind=1 (12.07.2026): нарезалось только «текущий + вперёд», поэтому на свежей базе
* партиции за прошлый месяц не было никогда. Между тем строки с датой в прошлом месяце
* штатное явление: тридцатидневное окно списаний (runway) и вставки на стыке месяцев.
* На проде не всплывало (партиции копятся с самого начала), но на свежей базе и в CI
* INSERT падал «no partition found» в первой половине каждого месяца.
*
* Замена `pg_partman` на native Windows-стеке (расширение недоступно
* без сборки из исходников). Запускается ежесуточно через Windows Task
* Scheduler / cron идемпотентна (проверяет pg_class перед CREATE).
@@ -27,20 +33,23 @@ use Illuminate\Support\Carbon;
class PartitionsCreateMonths extends Command
{
/** @var string */
protected $signature = 'partitions:create-months {--ahead=2 : Сколько месяцев вперёд создать партиций}';
protected $signature = 'partitions:create-months
{--ahead=2 : Сколько месяцев вперёд создать партиций}
{--behind=1 : Сколько месяцев назад создать партиций (окно последних 30 дней, стык месяцев)}';
/** @var string */
protected $description = 'Создаёт ежемесячные партиции для всех партиционированных таблиц на N месяцев вперёд (idempotent)';
protected $description = 'Создаёт ежемесячные партиции для всех партиционированных таблиц: N месяцев вперёд и M назад (idempotent)';
public function handle(MonthlyPartitionManager $manager): int
{
$ahead = max(1, (int) $this->option('ahead'));
$behind = max(0, (int) $this->option('behind'));
$now = Carbon::now()->startOfMonth();
$created = 0;
$skipped = 0;
for ($i = 0; $i <= $ahead; $i++) {
for ($i = -$behind; $i <= $ahead; $i++) {
$monthStart = $now->copy()->addMonths($i);
foreach (array_keys(MonthlyPartitionManager::PARTITIONED_TABLES) as $table) {
@@ -57,7 +66,7 @@ class PartitionsCreateMonths extends Command
}
$this->newLine();
$this->info("Done: {$created} created, {$skipped} skipped (ahead={$ahead}).");
$this->info("Done: {$created} created, {$skipped} skipped (ahead={$ahead}, behind={$behind}).");
return self::SUCCESS;
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\SupplierProject;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Console\Command;
use Throwable;
/**
* Разовая починка после прод-инцидента 1112.07.2026.
*
* Кабинет поставщика дописывает метку канала «B<n> к имени строки только при её
* СОЗДАНИИ; при обновлении он сохраняет имя ровно как прислали. Наш ночной робот слал
* голый ключ каждый прогон стирал метку. Последствия: (1) итоговая проверка заказа
* переставала узнавать строку и слала ложное «нет заказа»; (2) лид от такой строки
* приходил с project без метки webhook не мог определить канал и писал
* platform=DIRECT вместо B1/B2/B3 (потеря атрибуции канала).
*
* Корень закрыт в SupplierPortalClient::toPayload (на update имя уходит с меткой)
* эта команда возвращает метку тем строкам, у которых её уже стёрли.
*
* По умолчанию сухой прогон. Реальная запись в кабинет только с --apply.
*/
final class RepairSupplierProjectNamesCommand extends Command
{
private const DB_CONNECTION = 'pgsql_supplier';
/** Канал у поставщика — служебное поле src (rt=Ростелеком, bl=Билайн, mt=МТС). */
private const SRC_TO_PLATFORM = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
protected $signature = 'supplier:repair-project-names
{--apply : Записать исправленные имена в кабинет (без флага только показать)}
{--only-active : Чинить лишь строки, включённые у нас (выключенные лидов не носят)}';
protected $description = 'Вернуть метку канала B1_/B2_/B3_ в имена наших строк у поставщика';
public function handle(SupplierPortalClient $client): int
{
$apply = (bool) $this->option('apply');
$query = SupplierProject::on(self::DB_CONNECTION)
->whereNotNull('supplier_external_id');
if ($this->option('only-active')) {
$query->whereNull('inactive_since');
}
/** @var array<string, true> $ourIds */
$ourIds = $query->pluck('supplier_external_id')
->mapWithKeys(fn ($id): array => [(string) $id => true])
->all();
$repaired = 0;
$failed = 0;
foreach ($client->listProjects() as $row) {
$externalId = (string) ($row['id'] ?? '');
if (! isset($ourIds[$externalId])) {
continue;
}
$platform = self::SRC_TO_PLATFORM[(string) ($row['src'] ?? '')] ?? null;
if ($platform === null) {
continue;
}
$name = (string) ($row['name'] ?? '');
if (preg_match('/^B[123]_/', $name) === 1) {
continue; // Метка на месте — не трогаем.
}
$newName = $platform.'_'.(string) ($row['content'] ?? '');
$this->line(sprintf('%s %s «%s» → «%s»', $externalId, $platform, $name, $newName));
if (! $apply) {
$repaired++;
continue;
}
try {
$client->renameProject($row, $newName);
$repaired++;
} catch (Throwable $e) {
$failed++;
$this->error(sprintf(' не удалось: %s', $e->getMessage()));
}
}
$this->newLine();
$this->info($apply
? sprintf('Починено строк: %d, ошибок: %d.', $repaired, $failed)
: sprintf('Сухой прогон: строк с потерянной меткой — %d. Записать: --apply.', $repaired));
return self::SUCCESS;
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Mail\SupplierDeadlineWarningMail;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Сторож дедлайна поставщика: если робот SyncSupplierProjectsJob к порогу не закончил
* письмо. yellow=20:00, red=20:40 (МСК). Ловит и медленную работу, и зависание, и
* несработавший планировщик (за сегодня нет завершённого запуска).
*
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §5
*/
final class SupplierDeadlineWatchCommand extends Command
{
protected $signature = 'supplier:deadline-watch {level : yellow|red}';
protected $description = 'Проверить, успел ли робот-заказчик до дедлайна поставщика (21:00 МСК)';
public function handle(): int
{
/** @var 'yellow'|'red' $level */
$level = $this->argument('level') === 'red' ? 'red' : 'yellow';
$today = Carbon::today('Europe/Moscow')->toDateString();
$finished = DB::connection('pgsql_supplier')->table('supplier_sync_runs')
->whereRaw("(started_at AT TIME ZONE 'Europe/Moscow')::date = ?", [$today])
->whereNotNull('finished_at')
->where('status', '!=', 'aborted')
->exists();
if ($finished) {
$this->info('OK: робот сегодня завершил заказ.');
return self::SUCCESS;
}
$reason = 'За сегодня нет завершённого (не aborted) запуска SyncSupplierProjectsJob с finished_at.';
Mail::to((string) config('services.supplier.alert_email'))
->queue(new SupplierDeadlineWarningMail($level, $reason));
$this->warn("{$level}: робот не закончил — письмо отправлено.");
return self::SUCCESS;
}
}
@@ -14,6 +14,7 @@ use App\Models\SupplierLeadCost;
use App\Models\User;
use App\Services\Pd\PdAuditLogger;
use App\Services\SupplierResolver;
use App\Support\SupplierProjectName;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@@ -231,7 +232,7 @@ class DealController extends Controller
'id' => $d->id,
'tenant_id' => $d->tenant_id,
'project_id' => $d->project_id,
'project_name' => $d->project?->name,
'project_name' => SupplierProjectName::strip($d->project?->name),
'phone' => $d->phone,
'contact_name' => $d->contact_name,
'status' => $d->status,
@@ -343,7 +344,7 @@ class DealController extends Controller
'id' => $deal->id,
'tenant_id' => $deal->tenant_id,
'project_id' => $deal->project_id,
'project_name' => $deal->project?->name,
'project_name' => SupplierProjectName::strip($deal->project?->name),
'phone' => $deal->phone,
'contact_name' => $deal->contact_name,
'comment' => $deal->comment,
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Services\Pd\PdAuditLogger;
use App\Support\CsvFormulaGuard;
use App\Support\SupplierProjectName;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -121,7 +122,7 @@ class DealExportController extends Controller
foreach ($deals as $deal) {
/** @var Deal $deal */
$signal = $deal->project?->signal_type;
$source = trim(($deal->project?->name ?? '—').' · '
$source = trim((SupplierProjectName::strip($deal->project?->name) ?? '—').' · '
.(self::SIGNAL_LABELS[$signal] ?? '—'));
// F-CSV: свободный текст (телефон/источник/город/статус/
// комментарий) экранируем от formula-инъекции. Дата —
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Support\SupplierProjectName;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -87,7 +88,7 @@ class DealsController extends Controller
'contact_name' => $d->contact_name,
'city' => $d->city,
'status' => $d->status,
'project' => $d->project?->name,
'project' => SupplierProjectName::strip($d->project?->name),
])->all(),
'next_cursor' => $next,
]);
+36 -39
View File
@@ -100,36 +100,32 @@ final class CsvReconcileJob implements ShouldQueue
'created_at' => now(),
]);
$reportId = $portal->requestNumbersReport($windowStart, $windowEnd);
$portal->waitReportReady($reportId);
$csv = $portal->downloadReport($reportId);
// Гарантированный канал (Путь 2, переработан 09.07.2026): сверяемся с журналом
// ОТДАННОГО («Мои сделки», по vid), а НЕ с пулом «Запрос номеров» (phones_cnt).
// Инцидент roistat✓ 09.07: пул отдавал 157 собранных, из них поставщик отгрузил 15,
// а старый reconcile лепил из пула 15 фантомов + выедал лимит клиента. Сверка по vid
// (точная личность лида) исключает фантомы и ловит подмену при совпадении количества.
$delivered = $portal->fetchDeliveredLeads($windowStart, $windowEnd);
$totalCsvRows = count($delivered);
// CSV-строки по ключу phone|project (последняя строка с тем же ключом перетирает).
/** @var array<string, array{project: string, tag: string, phone: string}> $csvByKey */
$csvByKey = [];
foreach ($parser->parse($csv) as $row) {
$csvByKey[$this->dedupKey((string) $row['phone'], (string) $row['project'])] = $row;
// Уже принятые нами vid'ы — проверяем ГЛОБАЛЬНО и точечно по самим отданным vid
// (не по окну): idx_supplier_leads_vid_unique глобальный, а точечный whereIn ограничен
// числом отданных (сотни). Так дедуп исключает любой существующий vid ДО вставки —
// unique-violation в норме не наступает (иначе abort транзакции).
$existingVids = [];
$deliveredVids = array_map('intval', array_keys($delivered));
if ($deliveredVids !== []) {
DB::connection(self::DB_CONNECTION)
->table('supplier_leads')
->whereIn('vid', $deliveredVids)
->pluck('vid')
->each(function ($vid) use (&$existingVids): void {
$existingVids[(int) $vid] = true;
});
}
$totalCsvRows = count($csvByKey);
// Существующие лиды за окно → set ключей phone|project.
$existingKeys = [];
DB::connection(self::DB_CONNECTION)
->table('supplier_leads')
->where('received_at', '>=', $windowStart)
->select('phone', 'raw_payload')
->orderBy('id')
->chunk(500, function ($leads) use (&$existingKeys): void {
foreach ($leads as $lead) {
$payload = is_string($lead->raw_payload)
? json_decode($lead->raw_payload, true)
: (array) $lead->raw_payload;
$project = (string) ($payload['project'] ?? '');
$existingKeys[$this->dedupKey((string) $lead->phone, $project)] = true;
}
});
$missing = array_diff_key($csvByKey, $existingKeys);
// Недостача = отданные поставщиком vid'ы, которых у нас нет (webhook потерял).
$missing = array_diff_key($delivered, $existingVids);
$recoveredCount = 0;
$unparseableCount = 0;
@@ -149,11 +145,18 @@ final class CsvReconcileJob implements ShouldQueue
}
try {
// vid — НАСТОЯЩИЙ (из журнала отданного). idx_supplier_leads_vid_unique делает
// recovery идемпотентным с поздним webhook: SupplierWebhookController при том же
// vid вернёт существующий лид, второй сделки не будет.
$lead = SupplierLead::create([
'vid' => null,
'vid' => $row['vid'],
'platform' => $platform,
'phone' => (string) $row['phone'],
'raw_payload' => $row,
'raw_payload' => [
'project' => $row['project'],
'phone' => (string) $row['phone'],
'vid' => $row['vid'],
],
'received_at' => now(),
'recovered_from_csv_at' => now(),
'source' => 'csv_recovery',
@@ -162,8 +165,10 @@ final class CsvReconcileJob implements ShouldQueue
RouteSupplierLeadJob::dispatch($lead->id);
$recoveredCount++;
} catch (QueryException $e) {
Log::warning('csv_reconcile.lead_insert_failed', [
'phone' => $row['phone'],
// Гонка с webhook (тот же vid успел вставиться между chunk-снимком и insert'ом)
// ловится unique-индексом. Это не потеря — лид уже у нас, добор не нужен.
Log::info('csv_reconcile.lead_insert_skipped_race', [
'vid' => $row['vid'],
'project' => $row['project'],
'error' => $e->getMessage(),
]);
@@ -256,14 +261,6 @@ final class CsvReconcileJob implements ShouldQueue
}
}
/**
* Ключ дедупа: нормализованный phone + project.
*/
private function dedupKey(string $phone, string $project): string
{
return trim($phone).'|'.trim($project);
}
/** Был ли алерт о падении сверки за последнее окно троттла (анти-спам). */
private function failureAlertRecentlySent(): bool
{
@@ -134,6 +134,10 @@ class SyncSupplierProjectsJob implements ShouldQueue
$groups[$key]['projects'][] = $project;
}
// Активные сегодня источники (по слепку) — их заказы НЕ гасим в deactivation-проходе.
/** @var array<string, bool> $activeKeys */
$activeKeys = array_fill_keys(array_keys($groups), true);
// 3. Sync each group
try {
foreach ($groups as $group) {
@@ -192,8 +196,15 @@ class SyncSupplierProjectsJob implements ShouldQueue
continue;
}
}
// 4. Deactivation (money-critical, 18:00 ДО снимка поставщика в 21:00): гасим
// заказы источников, у которых на сегодняшний слепок не осталось ни одного
// активного проекта. Пропускаем при aborted (time-budget/mass-fail/auth).
if (! $aborted) {
$this->deactivateOrphanedOrders($activeKeys);
}
} finally {
$this->recordRunSummary(
$runId = $this->recordRunSummary(
startedAt: $startedAt,
groupsTotal: count($groups),
syncedOk: $syncedOk,
@@ -202,9 +213,111 @@ class SyncSupplierProjectsJob implements ShouldQueue
failed: $failed,
aborted: $aborted,
);
// Итоговая проверка: после «готово» перечитать живой кабинет и сверить.
// Идёт всегда — и при штатном финише, и при обрыве. При auth-сбое (кабинет
// недоступен) сам джоб зафиксирует unable_to_verify.
VerifySupplierOrderJob::dispatch(1, $runId);
}
}
/**
* Deactivation (18:00, money-critical owner-reported 2026-07-08, verified on prod live DB):
* гасим у поставщика заказы источников, у которых на СЕГОДНЯШНИЙ слепок не осталось ни
* одного активного проекта. Поставщик фиксирует завтрашнюю заливку своим снимком в 21:00,
* поэтому единственное окно остановить заказ этот 18:00-прогон ДО снимка. Без этого
* осиротевший заказ оставался включённым (галочка вкл, лимит > 0) поставщик лил лиды и
* списывал за остановленный клиентом проект.
*
* Активность из СЛЕПКА ($activeKeys = ключи sync-групп за завтра), НЕ из live is_active:
* слепок-инвариант проект, paused'нутый уже ПОСЛЕ снятия слепка, честно доезжает
* поставщику (см. collectEligibleProjects); гасим только то, чего в слепке нет.
*
* Осиротевший = supplier_project с external_id, inactive_since IS NULL, чей
* (signal_type|unique_key) не входит в $activeKeys. Шлём updateProject(status='paused') с
* ненулевым лимитом (кабинет отклоняет limit=0 даже при паузе), помечаем inactive_since
* ТОЛЬКО после успешной отправки (иначе ретрай следующим прогоном). Реактивацию при
* возврате проекта делают CleanupInactiveSupplierProjectsJob (Phase A) + syncGroup(active).
*
* @param array<string, bool> $activeKeys ключи «signal_type|identifier» активных групп
*/
private function deactivateOrphanedOrders(array $activeKeys): int
{
$paused = 0;
// get() (не cursor) — на shared PDO тестов курсор конфликтует с write'ами в цикле;
// набор ограничен (активные + ещё-не-погашенные осиротевшие заказы).
$candidates = SupplierProject::on(self::DB_CONNECTION)
->whereNotNull('supplier_external_id')
->whereNull('inactive_since')
->orderBy('id')
->get();
foreach ($candidates as $sp) {
if (isset($activeKeys[$sp->signal_type.'|'.$sp->unique_key])) {
continue; // источник ещё активен в сегодняшнем слепке — не трогаем
}
if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
break; // время вышло — недогашенное подберёт следующий прогон
}
$regions = array_map('intval', (array) ($sp->current_regions ?? []));
$workdays = $sp->current_workdays !== null && $sp->current_workdays !== []
? array_map('intval', (array) $sp->current_workdays)
: [1, 2, 3, 4, 5, 6, 7];
$tag = count($regions) === 1
? (RussianRegions::CODE_TO_NAME[$regions[0]] ?? (string) $regions[0])
: 'РФ';
$dto = new SupplierProjectDto(
platform: $sp->platform,
signalType: (string) $sp->signal_type,
uniqueKey: (string) $sp->unique_key,
limit: max(1, (int) $sp->current_limit),
workdays: $workdays,
regions: $regions,
regionsReverse: false,
status: 'paused',
tag: $tag,
platforms: [$sp->platform],
);
try {
$this->channel->updateProject((int) $sp->supplier_external_id, $dto);
} catch (Throwable $e) {
// Transient/portal error — inactive_since НЕ ставим, ретрай следующим прогоном.
Log::warning('supplier.sync.deactivate_failed', [
'unique_key' => (string) $sp->unique_key,
'platform' => $sp->platform,
'error' => $e->getMessage(),
]);
continue;
}
$sp->forceFill([
'sync_status' => 'ok',
'last_synced_at' => now(),
'inactive_since' => now(),
])->save();
SupplierSyncLog::on(self::DB_CONNECTION)->create([
'supplier_project_id' => $sp->id,
'action' => 'update',
'http_status' => 200,
'created_at' => now(),
]);
$paused++;
}
if ($paused > 0) {
Log::info('supplier.sync.deactivated_orphaned', ['count' => $paused]);
}
return $paused;
}
/**
* Эпик 5: одна строка-сводка в supplier_sync_runs по завершении заливки.
* Пишется и при раннем abort (time-budget / mass-fail / auth) через finally.
@@ -217,7 +330,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
int $deferred,
int $failed,
bool $aborted,
): void {
): int {
if ($aborted) {
$status = 'aborted';
} elseif ($failed > 0 && $syncedOk === 0) {
@@ -228,7 +341,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
$status = 'ok';
}
DB::connection(self::DB_CONNECTION)->table('supplier_sync_runs')->insert([
$id = DB::connection(self::DB_CONNECTION)->table('supplier_sync_runs')->insertGetId([
'started_at' => $startedAt,
'finished_at' => now(),
'groups_total' => $groupsTotal,
@@ -239,6 +352,8 @@ class SyncSupplierProjectsJob implements ShouldQueue
'status' => $status,
'created_at' => now(),
]);
return $id;
}
/**
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Supplier;
use App\Mail\SupplierOrderMismatchMail;
use App\Models\SupplierProject;
use App\Services\Supplier\SupplierOrderPlan;
use App\Services\Supplier\SupplierOrderVerifier;
use App\Services\Supplier\SupplierPortalClient;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Итоговая проверка заказа: живой кабинет поставщика vs задуманное по формуле.
* Диспатчится из SyncSupplierProjectsJob в finally. При расхождении на attempt=1
* переочередь через 90 сек (attempt=2) гасит транзиентный лаг применения.
*
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §4
*/
final class VerifySupplierOrderJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public const DB_CONNECTION = 'pgsql_supplier';
public const RECHECK_DELAY_SECONDS = 90;
public function __construct(
public readonly int $attempt = 1,
public readonly ?int $syncRunId = null,
) {}
public function handle(?SupplierPortalClient $client = null): void
{
$client ??= app(SupplierPortalClient::class);
$targetDate = Carbon::tomorrow('Europe/Moscow');
// 1. Задуманное по формуле.
$eligible = (new SyncSupplierProjectsJob)->collectEligibleProjects();
$plan = SupplierOrderPlan::build($eligible, $targetDate);
$intended = $plan['intended'];
// Ключи активных по формуле — для (а) исключения из shouldBeOff, (б) ничего более.
$intendedKeys = [];
foreach ($intended as $r) {
$intendedKeys[$r['signal_type'].'|'.$r['identifier'].'|'.$r['platform']] = true;
}
// Наши external_id в кабинете — признак «наша строка» (tag НЕ годится: робот
// шлёт tag=регион/«РФ», не маркер). Сверка живого идёт только по нашим строкам.
$ourExternalIds = SupplierProject::on(self::DB_CONNECTION)
->whereNotNull('supplier_external_id')
->pluck('supplier_external_id')
->map(fn ($id) => (string) $id)
->all();
// 2. shouldBeOff — наши выключенные supplier_projects, КРОМЕ ключей,
// которые сейчас активны по формуле (иначе ложный should_be_off на
// реактивированной группе с зависшим inactive_since).
$shouldBeOff = SupplierProject::on(self::DB_CONNECTION)
->whereNotNull('inactive_since')
->get(['signal_type', 'unique_key', 'platform'])
->map(fn (SupplierProject $sp) => [
'signal_type' => (string) $sp->signal_type,
'identifier' => (string) $sp->unique_key,
'platform' => (string) $sp->platform,
])
->reject(fn (array $r): bool => isset($intendedKeys[$r['signal_type'].'|'.$r['identifier'].'|'.$r['platform']]))
->values()
->all();
// 3. Живой кабинет (только наши строки — по supplier_external_id).
try {
$live = $this->normalizeLive($client->listProjects(), $ourExternalIds);
} catch (Throwable $e) {
$this->record('unable_to_verify', count($intended), 0, 0, ['error' => $e->getMessage()]);
Mail::to((string) config('services.supplier.alert_email'))
->queue(new SupplierOrderMismatchMail([[
'kind' => 'unable_to_verify', 'signal_type' => '-', 'identifier' => '-',
'platform' => '-', 'expected' => 'чтение кабинета', 'actual' => $e->getMessage(),
]]));
return;
}
// 4. Сверка.
$mismatches = SupplierOrderVerifier::diff($intended, $shouldBeOff, $live);
if ($mismatches !== [] && $this->attempt < 2) {
// Транзиентный лаг применения — перепроверить через 90 сек.
self::dispatch(2, $this->syncRunId)->delay(now()->addSeconds(self::RECHECK_DELAY_SECONDS));
return;
}
$status = $mismatches === [] ? 'ok' : 'mismatch';
$this->record($status, count($intended), count($live), count($mismatches), $mismatches);
if ($mismatches !== []) {
Mail::to((string) config('services.supplier.alert_email'))
->queue(new SupplierOrderMismatchMail($mismatches));
}
}
/** Канал у поставщика — служебное поле src, не имя. rt=Ростелеком, bl=Билайн, mt=МТС. */
private const SRC_TO_PLATFORM = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
/**
* Портальные строки формат SupplierOrderVerifier. «Наши» = те, чей портальный id
* совпадает с нашим supplier_external_id (tag НЕ используется робот шлёт регион).
*
* Площадка берётся из `src`, а НЕ из префикса имени: метку «B<n> портал ставит
* только при создании строки и теряет при обновлении (прод-инцидент 1112.07.2026
* строки в кабинете были, но проверка объявляла их missing). `src` то же поле,
* по которому матчит saveProjectMultiFlag; оно от имени не зависит.
*
* @param array<int, array<string,mixed>> $rows
* @param list<string> $ourExternalIds
* @return list<array{signal_type:string,identifier:string,platform:string,limit:int,on:bool}>
*/
private function normalizeLive(array $rows, array $ourExternalIds): array
{
$ours = array_flip($ourExternalIds);
$out = [];
foreach ($rows as $row) {
if (! isset($ours[(string) ($row['id'] ?? '')])) {
continue;
}
$platform = self::SRC_TO_PLATFORM[(string) ($row['src'] ?? '')] ?? null;
if ($platform === null) {
continue;
}
$signalType = match ($row['type'] ?? null) {
'hosts' => 'site', 'calls' => 'call', 'sms' => 'sms', default => null,
};
if ($signalType === null) {
continue;
}
$out[] = [
'signal_type' => $signalType,
'identifier' => (string) ($row['content'] ?? ''),
'platform' => $platform,
'limit' => (int) ($row['lim'] ?? 0),
'on' => (string) ($row['status'] ?? '') === '1',
];
}
return $out;
}
/**
* @param list<array<string,mixed>>|array{error:string} $details
*/
private function record(string $status, int $intendedRows, int $liveRows, int $mismatchCount, array $details): void
{
DB::connection(self::DB_CONNECTION)->table('supplier_order_checks')->insert([
'checked_at' => now(),
'sync_run_id' => $this->syncRunId,
'intended_rows' => $intendedRows,
'live_rows' => $liveRows,
'mismatch_count' => $mismatchCount,
'status' => $status,
'details' => json_encode($details, JSON_UNESCAPED_UNICODE),
'created_at' => now(),
]);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Сторож времени: робот-заказчик не закончил к порогу (yellow=20:00 / red=20:40).
*/
final class SupplierDeadlineWarningMail extends Mailable implements ShouldQueue
{
use Queueable;
/** @param 'yellow'|'red' $level */
public function __construct(
public readonly string $level,
public readonly string $reason,
) {}
public function envelope(): Envelope
{
$tag = $this->level === 'red' ? 'КРАСНЫЙ' : 'ЖЁЛТЫЙ';
return new Envelope(subject: "[Лидерра {$tag}] Робот-заказчик не успевает к 21:00");
}
public function content(): Content
{
return new Content(
text: 'emails.supplier_deadline_warning_text',
with: [
'level' => $this->level,
'reason' => $this->reason,
'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
],
);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Итоговая проверка нашла расхождения заказа с живым кабинетом поставщика.
*
* @param list<array{kind:string,signal_type:string,identifier:string,platform:string,expected:mixed,actual:mixed}> $mismatches
*/
final class SupplierOrderMismatchMail extends Mailable implements ShouldQueue
{
use Queueable;
public function __construct(public readonly array $mismatches) {}
public function envelope(): Envelope
{
$n = count($this->mismatches);
return new Envelope(subject: "[Лидерра] Заказ у поставщика: расхождений {$n}");
}
public function content(): Content
{
return new Content(
text: 'emails.supplier_order_mismatch_text',
with: [
'mismatches' => $this->mismatches,
'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
],
);
}
}
-2
View File
@@ -21,7 +21,6 @@ use App\Services\Supplier\ProcessFactory;
use App\Services\Supplier\SymfonyProcessFactory;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@@ -49,7 +48,6 @@ class AppServiceProvider extends ServiceProvider
fn ($app) => new FailoverProjectChannel(
$app->make(AjaxProjectChannel::class),
$app->make(FormProjectChannel::class),
$app->make(Mailer::class),
),
);
@@ -15,6 +15,7 @@ use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\CompetitorCardEnricher;
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
use App\Services\Autopodbor\Agent\Extract\HtmlTwoGisReader;
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
use App\Services\Autopodbor\Agent\Extract\TwoGisJsonReader;
use App\Services\Autopodbor\Agent\Extract\TwoGisReader;
use App\Services\Autopodbor\Agent\FakeCompetitorAgent;
@@ -33,6 +34,7 @@ use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher;
use App\Services\Autopodbor\Agent\Fetch\YandexListSource;
use App\Services\Autopodbor\Agent\FindCompetitorsAssembler;
use App\Services\Autopodbor\Agent\LiveFindCompetitors;
use App\Services\Autopodbor\Agent\Phone\DaDataValidityLayer;
use App\Services\Autopodbor\Agent\RealCompetitorAgent;
use App\Services\Autopodbor\Agent\Similarity\AitunnelEmbedder;
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
@@ -48,6 +50,7 @@ use App\Services\Autopodbor\Agent\Study\YandexPhoneReverseExpander;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Services\Autopodbor\RunProgressChannel;
use App\Services\RossvyazPrefixLookup;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Support\ServiceProvider;
@@ -94,10 +97,20 @@ class AutopodborServiceProvider extends ServiceProvider
directoryFetcher: new XfetchDirectoryFetcher($pages),
);
// Сторож валидности телефонов: слой 1 Россвязь (всегда, бесплатно) + слой 2
// ДаДата за флагом (платно, снимает подтверждённый мусор среди прошедших реестр).
// Общий на оба пути шага 2 (обычный и глубокий через SiteOpener).
$validityGate = new PhoneValidityGate(
new RossvyazPrefixLookup,
config('autopodbor.phone_dadata_enabled')
? $app->make(DaDataValidityLayer::class)
: null,
);
$deep = config('autopodbor.deep_study')
? new DeepStudyCollector(
xfetch: $pages,
opener: new SiteOpener($siteFetcher),
opener: new SiteOpener($siteFetcher, validityGate: $validityGate),
reverse: config('autopodbor.phone_reverse')
? $this->buildPhoneReverse($pages, $reader)
: null,
@@ -115,6 +128,7 @@ class AutopodborServiceProvider extends ServiceProvider
return new RealCompetitorAgent(
$fetcher,
new FakeCompetitorAgent,
validityGate: $validityGate,
liveFind: $liveFind,
deep: $deep,
progress: $app->make(RunProgressChannel::class),
+9 -6
View File
@@ -13,8 +13,11 @@ use InvalidArgumentException;
* verify (App\Console\Commands\VerifyAuditChains) and rebuild
* (App\Console\Commands\AuditRebuildChain).
*
* ADR-018: per-tenant via RLS scope for tenant tables,
* global for BYPASSRLS tables.
* ADR-021 (supersedes ADR-018): global-within-partition для ВСЕХ таблиц.
* Эмпирика на проде 2026-07-09 (все 4 бывшие «per-tenant» таблицы, включая
* balance_transactions): триггер `audit_chain_hash()` де-факто пишет глобальную
* цепочку (RLS не скоупит его SELECT) per-tenant изоляция ADR-018 никогда не
* была реализована. Verify/rebuild приведены к реальности (partition => '').
*
* columns: list in ordinal_position order from db/schema.sql.
* '__log_hash__' -- marker for log_hash position -> NULL::bytea in ROW().
@@ -43,7 +46,7 @@ final class AuditChainConfig
'old_value', 'new_value', 'context', 'ip_address', 'user_agent',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
],
'tenant_operations_log' => [
'columns' => [
@@ -51,7 +54,7 @@ final class AuditChainConfig
'event', 'payload_before', 'payload_after', 'ip_address', 'user_agent',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
],
'balance_transactions' => [
'columns' => [
@@ -60,7 +63,7 @@ final class AuditChainConfig
'related_type', 'related_id', 'user_id', 'admin_user_id',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
],
'pd_processing_log' => [
'columns' => [
@@ -68,7 +71,7 @@ final class AuditChainConfig
'purpose', 'actor_tenant_user_id', 'actor_admin_user_id', 'ip_address',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
],
'saas_admin_audit_log' => [
'columns' => [
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\RossvyazPrefixLookup;
use Illuminate\Support\Facades\DB;
/**
* Сторож валидности телефонов, пойманных при «вскрытии» кода страницы конкурента.
*
* Экстрактор ({@see HtmlPhoneScanner}) ловит любую последовательность из 11 цифр,
* начинающуюся на 7/8 и сгруппированную «по-телефонному» но НЕ проверяет, что это
* реальный российский номер. Со страниц-агрегаторов (отзовики, каталоги) так пролезает
* мусор: ID/коды/чужие блоки цифр с несуществующими кодами города (227, 303, 453, 613, 761).
* Этот гейт отсекает такой мусор ДО показа клиенту (spec §12.4-подбор).
*
* Слой 1 (здесь, бесплатно): реестр нумерации Россвязи (`phone_ranges`, 453k диапазонов на
* проде). Полный номер 7XXXXXXXXXX, которого нет ни в одном диапазоне выкидываем молча.
* Слой 2 (ДаДата, платно, за флагом) {@see DaDataValidityLayer}, добивает сомнительные.
*
* Защита: если реестр пуст (dev/тесты или сбой загрузки) НЕ режем ничего (fail-open),
* чтобы незагруженный справочник не выкосил все номера. Короткие/локальные номера
* (kind=uncertain, код города не определён) не трогаем их нельзя сверить с реестром.
*/
final class PhoneValidityGate
{
private ?bool $registryHasData = null;
public function __construct(
private readonly RossvyazPrefixLookup $rossvyaz = new RossvyazPrefixLookup,
private readonly ?PhoneValidityLayer $dadata = null,
) {}
/**
* @param list<array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}> $candidates
* @return list<array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}>
*/
public function filter(array $candidates): array
{
// Реестр не загружен → fail-open: не режем ничего.
if (! $this->registryHasData()) {
return array_values($candidates);
}
return array_values(array_filter($candidates, fn (array $c): bool => $this->keep((string) $c['number'])));
}
private function keep(string $number): bool
{
// Сверяем только полные российские номера. Короткие/локальные (7-значные и т.п.)
// нельзя проверить по реестру — не теряем.
if (preg_match('/^7\d{10}$/', $number) !== 1) {
return true;
}
// Слой 1 — Россвязь (авторитетный полный план нумерации): номера нет ни в одном
// диапазоне → это не настоящий российский номер → мусор, выкидываем молча (бесплатно).
if ($this->rossvyaz->find($number) === null) {
return false;
}
// Слой 2 — ДаДата (платно, за флагом): перепроверяет уже прошедший реестр номер и
// снимает подтверждённый мусор/иностранца (qc 2/7). Платим только за реальных выживших.
if ($this->dadata !== null) {
return $this->dadata->isValid($number);
}
return true;
}
private function registryHasData(): bool
{
if ($this->registryHasData !== null) {
return $this->registryHasData;
}
try {
return $this->registryHasData = DB::connection(RossvyazPrefixLookup::CONNECTION)
->table('phone_ranges')
->limit(1)
->exists();
} catch (\Throwable) {
// Сбой БД/соединения при проверке реестра → fail-open (не фильтруем ничего).
// Иначе внешний catch в SiteOpener::open() принял бы это за «нет номеров».
return $this->registryHasData = false;
}
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Phone\DaDataValidityLayer;
/**
* Второй слой проверки валидности телефона (после реестра Россвязи).
* Реализация {@see DaDataValidityLayer} (платно, за флагом).
*/
interface PhoneValidityLayer
{
/** true — номер годен (оставляем); false — мусор/иностранец (выкидываем). */
public function isValid(string $number): bool;
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Phone;
use App\Services\Autopodbor\Agent\Extract\PhoneValidityLayer;
use App\Services\DaData\DaDataBudgetGuard;
use App\Services\DaData\DaDataException;
use App\Services\DaData\DaDataPhoneClient;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
/**
* Второй слой валидности телефона (после реестра Россвязи) через DaData clean/phone.
* Платный, поэтому подключается только за флагом (`autopodbor.phone_dadata_enabled`) и
* под дневным бюджетом {@see DaDataBudgetGuard} тот же кошелёк, что резолв региона лида.
*
* Снимает ТОЛЬКО подтверждённый мусор: qc 2 (не телефон) и qc 7 (иностранный номер).
* Всё остальное (qc 0/3/1, исчерпанный бюджет, сбой сети) оставляет: номер уже прошёл
* авторитетный реестр Россвязи, наказывать его за нашу неуверенность/сбой нельзя.
*
* Вердикт кэшируется по sha256(номер) не платим дважды за один номер между прогонами.
* В кэше/ключе только хэш, без сырого номера (та же ПДн-конвенция, что LeadRegionResolver §7.3).
*/
final class DaDataValidityLayer implements PhoneValidityLayer
{
public function __construct(
private readonly DaDataPhoneClient $client,
private readonly DaDataBudgetGuard $budget,
private readonly CacheRepository $cache,
) {}
public function isValid(string $number): bool
{
$key = 'autopodbor:phone-valid:'.hash('sha256', $number);
$cached = $this->cache->get($key);
if (is_bool($cached)) {
return $cached;
}
// Бюджет исчерпан — не тратим и не режем (номер уже прошёл реестр).
if (! $this->budget->canSpend()) {
return true;
}
try {
$response = $this->client->cleanPhone($number);
$this->budget->recordSpend((int) config('services.dadata.call_cost_kopecks', 60));
} catch (DaDataException) {
return true; // сбой ДаДаты — не роняем и не режем
}
$valid = ! in_array($response->qc, [2, 7], true);
$ttlDays = max(1, (int) config('services.dadata.cache_ttl_days', 30));
$this->cache->put($key, $valid, now()->addDays($ttlDays));
return $valid;
}
}
@@ -12,6 +12,7 @@ use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult;
use App\Services\Autopodbor\Agent\Extract\CandidateBuilder;
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
use App\Services\Autopodbor\Agent\Extract\SourceAggregator;
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
use App\Services\Autopodbor\Agent\Study\CompetitorFingerprint;
@@ -26,6 +27,7 @@ final class RealCompetitorAgent implements CompetitorAgent
private Fetcher $fetcher,
private CompetitorAgent $fallback, // для resolve, и для find пока не подключён живой
private CandidateBuilder $builder = new CandidateBuilder,
private PhoneValidityGate $validityGate = new PhoneValidityGate,
private SourceAggregator $aggregator = new SourceAggregator,
private AutopodborNormalizer $norm = new AutopodborNormalizer,
private ?LiveFindCompetitors $liveFind = null, // живой поиск шага 1 (если подключён за флагом)
@@ -108,6 +110,8 @@ final class RealCompetitorAgent implements CompetitorAgent
// конкурента (только если на странице нет полных номеров; см. RegionAreaCode).
$areaCode = RegionAreaCode::forSubject($r->regionCode);
$candidates = $this->builder->build($sites, $cards, $areaCode);
// Отсекаем мусор: пойманные «номера» с несуществующими кодами (не в реестре Россвязи).
$candidates = $this->validityGate->filter($candidates);
$collected = $this->aggregator->aggregate($candidates);
// 3. Маппинг в существующий контракт
@@ -5,17 +5,20 @@ declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Study;
use App\Services\Autopodbor\Agent\Extract\CandidateBuilder;
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
/**
* Вскрытие одного сайта фирмы: Fetcher (curl+Playwright) CandidateBuilder (kind номеров)
* список кандидатов, флаг коллтрекинга, HTML в нижнем регистре и «код»-цифры (для якорей).
* сторож валидности (отсев мусора по реестру Россвязи) список кандидатов, флаг коллтрекинга,
* HTML в нижнем регистре и «код»-цифры (для якорей).
*/
final class SiteOpener
{
public function __construct(
private Fetcher $fetcher,
private CandidateBuilder $builder = new CandidateBuilder,
private PhoneValidityGate $validityGate = new PhoneValidityGate,
) {}
public function open(string $domain, ?string $areaCode): OpenedSite
@@ -23,7 +26,8 @@ final class SiteOpener
$url = 'https://'.$this->idn($domain).'/';
try {
$site = $this->fetcher->site($url);
$cands = $this->builder->build([$site], [], $areaCode);
// Отсекаем мусор (номера с несуществующими кодами) ДО якорей/разворота по телефонам.
$cands = $this->validityGate->filter($this->builder->build([$site], [], $areaCode));
$codeDigits = [];
foreach ($cands as $c) {
if ($c['kind'] === 'code' || $c['kind'] === 'contacts') {
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
/**
* Живость почты: TCP/TLS-connect к SMTP-порту Yandex 360 + чтение приветственного
* баннера (должен начинаться с «220»). Без логина/отправки денег/квоты не тратит.
* Соединитель инъектируется (тестируемость): возвращает первую строку баннера или бросает.
*/
class SmtpLivenessProbe implements LivenessProbe
{
/** @var (callable():string)|null */
private $connector;
/** @param (callable():string)|null $connector фейковый соединитель для тестов */
public function __construct(?callable $connector = null)
{
$this->connector = $connector;
}
public function serviceKey(): string
{
return 'email';
}
public function check(): LivenessReading
{
try {
$banner = ($this->connector ?? $this->defaultConnector())();
if (! str_starts_with(ltrim($banner), '220')) {
return LivenessReading::down('email', 'SMTP-баннер не 220: '.mb_substr(trim($banner), 0, 120));
}
return LivenessReading::alive('email', 'SMTP отвечает');
} catch (\Throwable $e) {
return LivenessReading::down('email', $e->getMessage());
}
}
/** @return callable():string */
private function defaultConnector(): callable
{
return function (): string {
$host = (string) config('services.smtp_probe.host');
$port = (int) config('services.smtp_probe.port');
$timeout = (int) config('services.smtp_probe.timeout', 5);
// 465 — implicit TLS; ssl:// нужен на connect.
$scheme = $port === 465 ? 'ssl://' : 'tcp://';
$fp = @stream_socket_client($scheme.$host.':'.$port, $errno, $errstr, $timeout);
if ($fp === false) {
throw new \RuntimeException($errstr !== '' ? $errstr : 'Connection refused (errno '.$errno.')');
}
try {
stream_set_timeout($fp, $timeout);
$line = fgets($fp, 512);
return $line === false ? '' : $line;
} finally {
fclose($fp);
}
};
}
}
@@ -7,13 +7,11 @@ namespace App\Services\Supplier\Channel;
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Support\Facades\Log;
use Throwable;
@@ -31,7 +29,6 @@ final class FailoverProjectChannel implements SupplierProjectChannel
public function __construct(
private readonly SupplierProjectChannel $tier1,
private readonly SupplierProjectChannel $tier2,
private readonly Mailer $mailer,
) {}
public function createProject(SupplierProjectDto $dto): int
@@ -81,7 +78,6 @@ final class FailoverProjectChannel implements SupplierProjectChannel
} catch (SupplierClientException|SupplierAuthException $e) {
try {
$id = $this->tier2->createProject($dto);
$this->alertFailoverToForm($project, 'create', $e);
return $id;
} catch (Throwable $tier2Error) {
@@ -108,7 +104,6 @@ final class FailoverProjectChannel implements SupplierProjectChannel
} catch (SupplierClientException|SupplierAuthException $e) {
try {
$this->tier2->updateProject($externalId, $dto);
$this->alertFailoverToForm($project, 'update', $e);
return;
} catch (Throwable $tier2Error) {
@@ -147,24 +142,9 @@ final class FailoverProjectChannel implements SupplierProjectChannel
'created_at' => now(),
]);
$this->mailer->to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'manual_required',
details: "Project #{$project->id} ({$dto->platform}/{$dto->uniqueKey}) — {$operation} queued #{$row->id}, reason: {$reason}. Cause: ".mb_substr($cause->getMessage(), 0, 300),
));
throw new TierEscalatedException($row->id, $reason);
}
private function alertFailoverToForm(Project $project, string $operation, Throwable $cause): void
{
$this->mailer->to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'failover_to_form',
details: "Project #{$project->id} {$operation}: Tier 1 (AJAX) failed, Tier 2 (browser) succeeded. Cause: ".mb_substr($cause->getMessage(), 0, 300),
));
}
/**
* Портальная сверка: ищет уже существующий проект на портале по тройке
* (platform, signal_type, unique_key). Возвращает external_id найденного
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier;
use App\Models\Project;
use Carbon\Carbon;
use Illuminate\Support\Collection;
/**
* «Задуманный заказ» на targetDate та же группировка и формула, что у робота
* SyncSupplierProjectsJob (DRY через SupplierProjectGrouping + SupplierQuotaAllocator).
*
* Возвращает ['intended' => list<row>], где row = ['signal_type','identifier','platform','limit'],
* только доли ≥1 (как distributeForPlatform).
*
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §3, §4.2
*/
final class SupplierOrderPlan
{
/**
* @param Collection<int, Project> $eligibleProjects проекты со slepok-полями (daily_limit_target/delivery_days_mask/regions)
* @return array{intended: list<array{signal_type:string,identifier:string,platform:string,limit:int}>}
*/
public static function build(Collection $eligibleProjects, Carbon $targetDate): array
{
$targetWeekday = $targetDate->copy()->timezone('Europe/Moscow')->isoWeekday();
// Группировка (signal_type|identifier) — как в SyncSupplierProjectsJob.
/** @var array<string, array{signal_type:string, identifier:string, platforms:list<string>, limits:list<int>}> $groups */
$groups = [];
foreach ($eligibleProjects as $project) {
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
if ($platforms === []) {
continue;
}
// eligible-today (маска дня на targetDate).
if (((int) $project->delivery_days_mask & (1 << ($targetWeekday - 1))) === 0) {
continue;
}
$identifier = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);
$key = $project->signal_type.'|'.$identifier;
if (! isset($groups[$key])) {
$groups[$key] = [
'signal_type' => (string) $project->signal_type,
'identifier' => $identifier,
'platforms' => $platforms,
'limits' => [],
];
}
$groups[$key]['limits'][] = (int) $project->daily_limit_target;
}
$intended = [];
foreach ($groups as $group) {
$order = SupplierQuotaAllocator::computeOrder($group['limits']);
$shares = SupplierQuotaAllocator::distributeForPlatform($order, $group['platforms']);
foreach ($shares as $platform => $limit) {
$intended[] = [
'signal_type' => $group['signal_type'],
'identifier' => $group['identifier'],
'platform' => $platform,
'limit' => $limit,
];
}
}
return ['intended' => $intended];
}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier;
/**
* Чистая сверка «задуманный заказ» vs «живой кабинет поставщика».
*
* Возвращает список расхождений; каждый ['kind','signal_type','identifier','platform','expected','actual'].
* kind missing | limit_drift | should_be_off | should_be_on | orphan_extra.
*
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §4.2
*/
final class SupplierOrderVerifier
{
/**
* @param list<array{signal_type:string,identifier:string,platform:string,limit:int}> $intended
* @param list<array{signal_type:string,identifier:string,platform:string}> $shouldBeOff
* @param list<array{signal_type:string,identifier:string,platform:string,limit:int,on:bool}> $live
* @return list<array{kind:string,signal_type:string,identifier:string,platform:string,expected:int|string|bool|null,actual:int|string|bool|null}>
*/
public static function diff(array $intended, array $shouldBeOff, array $live): array
{
$key = static fn (array $r): string => $r['signal_type'].'|'.$r['identifier'].'|'.$r['platform'];
$liveByKey = [];
foreach ($live as $row) {
$liveByKey[$key($row)] = $row;
}
$mismatches = [];
$accountedLive = [];
// 1. Активные по формуле.
foreach ($intended as $row) {
$k = $key($row);
$accountedLive[$k] = true;
$liveRow = $liveByKey[$k] ?? null;
if ($liveRow === null) {
$mismatches[] = self::m('missing', $row, $row['limit'], null);
continue;
}
if ($liveRow['on'] !== true) {
$mismatches[] = self::m('should_be_on', $row, true, false);
continue;
}
if ((int) $liveRow['limit'] !== (int) $row['limit']) {
$mismatches[] = self::m('limit_drift', $row, (int) $row['limit'], (int) $liveRow['limit']);
}
}
// 2. Должны быть выключены.
foreach ($shouldBeOff as $row) {
$k = $key($row);
$accountedLive[$k] = true;
$liveRow = $liveByKey[$k] ?? null;
if ($liveRow !== null && $liveRow['on'] === true) {
$mismatches[] = self::m('should_be_off', $row, false, true);
}
}
// 3. Лишние наши строки у поставщика, которых нет ни в intended, ни в shouldBeOff.
foreach ($live as $row) {
if (! isset($accountedLive[$key($row)]) && $row['on'] === true) {
$mismatches[] = self::m('orphan_extra', $row, null, (int) $row['limit']);
}
}
return $mismatches;
}
/**
* @param array{signal_type:string,identifier:string,platform:string} $row
* @return array{kind:string,signal_type:string,identifier:string,platform:string,expected:int|string|bool|null,actual:int|string|bool|null}
*/
private static function m(string $kind, array $row, int|string|bool|null $expected, int|string|bool|null $actual): array
{
return [
'kind' => $kind,
'signal_type' => $row['signal_type'],
'identifier' => $row['identifier'],
'platform' => $row['platform'],
'expected' => $expected,
'actual' => $actual,
];
}
}
@@ -69,6 +69,81 @@ class SupplierPortalClient
return is_array($projects) ? array_values($projects) : [];
}
/** Строк на странице «Мои сделки» (index-visit) — портал отдаёт по 50. */
private const DELIVERED_PAGE_SIZE = 50;
/** Backstop от бесконечной пагинации (аномалия портала). */
private const DELIVERED_MAX_PAGES = 500;
/**
* Журнал ОТДАННОГО («Мои сделки») за окно [from, to] гарантированный канал доставки.
*
* В отличие от «Запрос номеров» (selectType=49), который отдаёт ПУЛ собранных номеров
* (`phones_cnt`, `prophones:'curr'`, `state_id:0`), эта страница содержит только реально
* ОТГРУЖЕННЫЕ поставщиком лиды каждый с настоящим `vid` (= checkbox value / view?id).
* Сверка по `vid` даёт точную личность лида: фантомы из пула невозможны, а совпадение
* количества при подменённых номерах (инцидент roistat✓ 09.07.2026: отдано 15 = сделок 15,
* но все 15 пул) ловится, чего потолок по `crms_cnt` не умеет.
*
* GET /admin/visit/index-visit?visit=rt&date_from&date_to&page=N server-rendered HTML,
* 50 строк/страница, newest-first. Пагинируем до страницы с < 50 строк.
* Verified live 2026-07-09: строка = `name="visit-checbox" value="<vid>"` +
* `B{1,2,3}_<ident>` (проект) + `7\d{10}` (телефон). date_from/date_to формат d.m.Y.
*
* @return array<int, array{vid: int, phone: string, project: string}> ключ = vid (дедуп)
*/
public function fetchDeliveredLeads(CarbonInterface $from, CarbonInterface $to): array
{
$byVid = [];
$page = 1;
do {
$response = $this->request('GET', '/admin/visit/index-visit', [
'visit' => 'rt',
'date_from' => $from->format('d.m.Y'),
'date_to' => $to->format('d.m.Y'),
'page' => $page,
]);
$rows = $this->parseDeliveredRows($response->body());
foreach ($rows as $row) {
$byVid[$row['vid']] = $row;
}
$page++;
} while (count($rows) >= self::DELIVERED_PAGE_SIZE && $page <= self::DELIVERED_MAX_PAGES);
return $byVid;
}
/**
* Парсит строки таблицы «Мои сделки» [vid, phone, project].
* Строки без vid (шапка/служебные) или без телефона пропускаются.
*
* @return list<array{vid: int, phone: string, project: string}>
*/
private function parseDeliveredRows(string $html): array
{
$out = [];
foreach (preg_split('/<tr[ >]/', $html) ?: [] as $row) {
if (preg_match('/name="visit-checbox"\s+value="(\d+)"/', $row, $vid) !== 1) {
continue;
}
if (preg_match('/(7\d{10})/', $row, $phone) !== 1) {
continue;
}
$project = preg_match('#(B[123]_[^\s<"]+)#', $row, $proj) === 1 ? $proj[1] : '';
$out[] = [
'vid' => (int) $vid[1],
'phone' => $phone[1],
'project' => $project,
];
}
return $out;
}
public function saveProject(SupplierProjectDto $dto): int
{
$response = $this->request(
@@ -129,6 +204,51 @@ class SupplierPortalClient
return $out;
}
/**
* Разовая починка имени строки в кабинете (прод-инцидент 1112.07.2026): отдаём живую
* строку обратно с исправленным `name`. Кроме имени не меняем НИЧЕГО лимит, дни,
* регионы и вкл/выкл берём из самого кабинета. Регионы там уже в кодах поставщика,
* второй раз переводить их через SupplierRegions нельзя.
*
* @param array<string,mixed> $liveRow строка из listProjects()
*/
public function renameProject(array $liveRow, string $newName): void
{
$src = (string) ($liveRow['src'] ?? '');
$regions = array_values(array_filter(array_map(
static fn (string $code): int => (int) trim($code),
preg_split('/[;,]/', (string) ($liveRow['regions'] ?? '')) ?: [],
)));
$response = $this->request('POST', '/admin/visit/rt-project-save', [
'id' => (int) ($liveRow['id'] ?? 0),
'tag' => (string) ($liveRow['tag'] ?? ''),
'name' => $newName,
'type' => (string) ($liveRow['type'] ?? ''),
'content' => (string) ($liveRow['content'] ?? ''),
'srcrt' => $src === 'rt',
'srcbl' => $src === 'bl',
'srcmt' => $src === 'mt',
'srcmg' => false,
'srclal' => false,
'srcdop' => false,
'srcwz' => false,
'srcseg' => false,
'limit' => (int) ($liveRow['lim'] ?? 0),
'workdays' => array_map(static fn ($day): string => (string) $day, (array) ($liveRow['workdays'] ?? [])),
'regions' => $regions,
'regions_reverse' => (bool) ($liveRow['regions_reverse'] ?? false),
'status' => (bool) ($liveRow['status'] ?? false),
'show' => true,
'multisignals' => false,
'multigroup' => false,
'depth' => (int) ($liveRow['depth'] ?? 1),
], asJson: true);
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
}
public function deleteProject(int $externalId): void
{
$response = $this->request(
@@ -432,9 +552,17 @@ class SupplierPortalClient
* один rt-проект (множественные флаги создают N проектов, мы привязываемся
* к одному external_id).
* - signalType: site type:"hosts"; call type:"calls"; sms type:"sms".
* - uniqueKey одновременно `name` (label проекта на портале портал
* префиксует "B<n>_" автоматически) и `content` (домен/телефон в полях
* сбора).
* - uniqueKey одновременно `name` (label проекта на портале) и `content`
* (домен/телефон в полях сбора).
* - `name` и метка канала «B<n> (прод-инцидент 1112.07.2026): портал
* дописывает метку САМ, но только при СОЗДАНИИ (id=0); при обновлении он
* сохраняет имя ровно как прислали. Раньше мы и на update слали голый
* uniqueKey каждый ночной прогон стирал метку. Последствия: итоговая
* проверка переставала узнавать строку (ложное «нет заказа»), а лид от
* такой строки приходил с project без метки webhook не мог определить
* канал и писал platform=DIRECT вместо B1/B2/B3. Поэтому на update имя
* уходит уже с меткой; на create голым (там метку ставит портал, и один
* save с тремя флагами рождает три строки общего префикса у них нет).
* - workdays: int[1..7] string["1".."7"] (portal принимает строки).
* - regions: int[]; regions_reverse: bool.
* - status: "active" true; "paused" false.
@@ -459,13 +587,18 @@ class SupplierPortalClient
$srcbl = in_array('B2', $platforms, true);
$srcmt = in_array('B3', $platforms, true);
// Update (id != 0) одной площадки — имя с меткой канала, иначе портал её сотрёт.
$name = $externalId !== 0 && count($platforms) === 1
? $platforms[array_key_first($platforms)].'_'.$dto->uniqueKey
: $dto->uniqueKey;
// workdays: int → string (portal: ["1","2",...,"7"]).
$workdays = array_map(static fn (int $d): string => (string) $d, $dto->workdays);
return [
'id' => $externalId,
'tag' => $dto->tag,
'name' => $dto->uniqueKey,
'name' => $name,
'type' => $type,
'content' => $dto->uniqueKey,
'srcrt' => $srcrt,
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Утилита отображения имён проектов поставщика display-only.
*
* Поставщик префиксует имена проектов кодом канала-провайдера (B1_/B2_/B3_/B6_/B8_/B<N>_).
* Клиенту этот префикс показывать нельзя: он раскрывает нашу внутреннюю схему каналов и то,
* что лиды перекупаются. Срезаем префикс во ВСЕХ клиентских ответах СЕРВЕРНО (API, экспорт),
* а не только на фронте иначе прямой API-потребитель и скачанный CSV/XLSX всё равно видят «B1_…».
*
* Серверный аналог resources/js/composables/projectName.ts::stripChannelPrefix.
* Данные в БД (`supplier_projects.name` / `projects.name`) НЕ трогаем только вывод.
*/
final class SupplierProjectName
{
/** Любой B + одна-или-более цифр + подчёркивание в начале (B1_/B6_/B8_/B10_…), но не буква (BX_). */
private const CHANNEL_PREFIX_RE = '/^B\d+_/i';
/**
* Срезает канальный префикс из начала имени проекта.
* null null (не ломаем nullable-контракт API), '' '', остальное без префикса.
*/
public static function strip(?string $name): ?string
{
if ($name === null || $name === '') {
return $name;
}
return preg_replace(self::CHANNEL_PREFIX_RE, '', $name) ?? $name;
}
}
+16
View File
@@ -122,4 +122,20 @@ return [
*/
'render_cache' => env('AUTOPODBOR_RENDER_CACHE', false),
'render_cache_ttl_days' => (int) env('AUTOPODBOR_RENDER_CACHE_TTL_DAYS', 5),
/*
|--------------------------------------------------------------------------
| Второй слой отсева телефонов через ДаДату (после реестра Россвязи)
|--------------------------------------------------------------------------
|
| Пойманные при «вскрытии» кода страницы номера всегда чистятся реестром Россвязи
| (бесплатно): номера с несуществующим кодом выкидываются. Этот флаг добавляет
| ВТОРОЙ, ПЛАТНЫЙ слой ДаДата clean/phone (~0,6 /номер) перепроверяет уже прошедшие
| реестр номера и снимает подтверждённый мусор/иностранцев (qc 2/7). Платим только за
| реальных выживших (мусор отсекается бесплатно ДО ДаДаты), под дневным бюджетом
| services.dadata.daily_cap_rub, с кэшем вердикта. По умолчанию ВЫКЛ включается
| осознанно (тратит деньги). Требует services.dadata.api_key/secret.
|
*/
'phone_dadata_enabled' => env('AUTOPODBOR_PHONE_DADATA_ENABLED', false),
];
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* supplier_order_checks история итоговой проверки заказа у поставщика.
*
* VerifySupplierOrderJob после ночного прогона робота перечитывает живой кабинет
* поставщика и сверяет каждую нашу строку с формулой; результат
* (ok / mismatch / unable_to_verify + список расхождений) пишется сюда одной строкой.
*
* Системная таблица (как supplier_sync_runs / supplier_deferred_sync) без RLS/
* tenant_id, пишет supplier-flow джоб под crm_supplier_worker (BYPASSRLS), читает SaaS-admin.
* Создаётся через pgsql_supplier + инлайн-GRANT (guard IF EXISTS) на проде миграции
* катит crm_migrator, поэтому дефолтные привилегии crm_supplier_worker не наследуются.
*
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §6.
*/
return new class extends Migration
{
public function up(): void
{
$supplier = DB::connection('pgsql_supplier');
$supplier->statement(<<<'SQL'
CREATE TABLE IF NOT EXISTS supplier_order_checks (
id BIGSERIAL PRIMARY KEY,
checked_at TIMESTAMPTZ NOT NULL,
sync_run_id BIGINT,
intended_rows INTEGER NOT NULL DEFAULT 0,
live_rows INTEGER NOT NULL DEFAULT 0,
mismatch_count INTEGER NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL
CHECK (status IN ('ok','mismatch','unable_to_verify')),
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
SQL);
$supplier->statement('CREATE INDEX IF NOT EXISTS idx_supplier_order_checks_checked ON supplier_order_checks (checked_at DESC)');
$supplier->statement('CREATE INDEX IF NOT EXISTS idx_supplier_order_checks_status ON supplier_order_checks (status)');
foreach (['crm_supplier_worker'] as $role) {
$supplier->statement(<<<SQL
DO \$\$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
GRANT SELECT, INSERT ON supplier_order_checks TO {$role};
GRANT USAGE, SELECT ON SEQUENCE supplier_order_checks_id_seq TO {$role};
END IF;
END
\$\$
SQL);
}
}
public function down(): void
{
DB::connection('pgsql_supplier')->statement('DROP TABLE IF EXISTS supplier_order_checks CASCADE');
}
};
+13 -1
View File
@@ -1317,7 +1317,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 5
count: 9
path: tests/Feature/Console/IncidentsWatchFailuresTest.php
-
@@ -2958,6 +2958,12 @@ parameters:
count: 1
path: tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
@@ -3000,6 +3006,12 @@ parameters:
count: 6
path: tests/Feature/Supplier/SyncSupplierProjectJobTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Supplier/VerifySupplierOrderJobTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -6,6 +6,8 @@
*
* Billing v2 Spec A §3.4.8.
*/
import { tierRangeText } from '../../utils/pricingTiers';
interface TierPreview {
tier_no: number;
leads_in_tier: number | null;
@@ -18,14 +20,7 @@ const props = defineProps<{
}>();
function rangeText(idx: number): string {
let start = 1;
for (let i = 0; i < idx; i++) {
const cap = props.tiers[i].leads_in_tier;
if (cap !== null) start += cap;
}
const cap = props.tiers[idx].leads_in_tier;
if (cap === null) return `${start}+`;
return `${start}${start + cap - 1}`;
return tierRangeText(props.tiers, idx);
}
</script>
@@ -1,22 +1,24 @@
<script setup lang="ts">
/**
* Обёртка: десктоп/планшет — слот #table (обычный v-data-table),
* телефон — слот #card (список карточек по каждому item).
* Обёртка: десктоп (>=1280) — слот #table (обычный v-data-table),
* телефон и планшет (<1280) — слот #card (список карточек по каждому item).
* Широкие таблицы (Сделки/Списания) не влезают в узкую колонку планшета-портрета
* рядом с rail-меню — правые колонки обрезались, поэтому на планшете тоже карточки.
* Данные прокидываются через слот-пропсы, чтобы вызывающий сам рисовал карточку.
*/
import { useLayoutTier } from '../../composables/useLayoutTier';
defineProps<{ items: unknown[] }>();
const { isPhone } = useLayoutTier();
const { isDesktop } = useLayoutTier();
</script>
<template>
<div>
<template v-if="isPhone">
<slot name="card" :items="items" />
<template v-if="isDesktop">
<slot name="table" :items="items" />
</template>
<template v-else>
<slot name="table" :items="items" />
<slot name="card" :items="items" />
</template>
</div>
</template>
@@ -43,6 +43,14 @@ function hasSource(deal: MockDeal): boolean {
return Boolean(deal.sourceIdentifier || deal.sourceProjectId);
}
// «Конкурент» — только бренд, без описания: имя конкурента хранится как
// «Бренд, описание…», в колонке показываем часть до первой запятой.
function competitorShort(name: string | null | undefined): string {
if (!name) return '';
const i = name.indexOf(',');
return (i === -1 ? name : name.slice(0, i)).trim();
}
function formatDateTime(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
@@ -103,9 +111,10 @@ function toggleSelect(id: number): void {
<router-link
v-if="item.competitorId"
class="cell-link"
:title="item.competitorName ?? undefined"
:to="{ name: 'autopodbor', query: { competitor: String(item.competitorId) } }"
@click.stop
>{{ item.competitorName }}</router-link
>{{ competitorShort(item.competitorName) }}</router-link
>
<span v-else class="text-medium-emphasis">—</span>
</template>
@@ -199,9 +208,10 @@ function toggleSelect(id: number): void {
<router-link
v-if="deal.competitorId"
class="deal-card__competitor"
:title="deal.competitorName ?? undefined"
:to="{ name: 'autopodbor', query: { competitor: String(deal.competitorId) } }"
@click.stop
>{{ deal.competitorName }}</router-link
>{{ competitorShort(deal.competitorName) }}</router-link
>
<span v-else class="deal-card__project">{{ stripChannelPrefix(deal.project) }}</span>
<span v-if="!hasSource(deal) && signalLabel(deal.signalType)" class="deal-card__signal">{{
+33
View File
@@ -0,0 +1,33 @@
/**
* Единый источник истины для показа объёмных ступеней тарифа.
*
* В БД у каждой ступени хранится ШИРИНА (`leads_in_tier`) — сколько лидов
* помещается именно в эту ступень, а НЕ «до какого лида она действует».
* Живой расчёт списаний (PricingTierResolver / BalanceToLeadsConverter)
* накапливает эти ширины: tier 1 = 1..w1, tier 2 = w1+1..w1+w2, и т.д.
*
* Эта функция переводит ширину в тот накопительный диапазон, который видит
* клиент в кабинете. Держим её в ОДНОМ месте, чтобы админка и кабинет никогда
* не разошлись в цифрах.
*/
export interface TierWidth {
leads_in_tier: number | null;
}
/**
* Диапазон номеров лидов для ступени с индексом `idx` (0-based) в массиве
* ступеней, отсортированном по возрастанию tier_no.
*
* Пример (ширины 250/500/…): idx=0 → «1250», idx=1 → «251750».
* Последняя ступень (`leads_in_tier === null`) → «19251+».
*/
export function tierRangeText(tiers: ReadonlyArray<TierWidth>, idx: number): string {
let start = 1;
for (let i = 0; i < idx; i++) {
const cap = tiers[i].leads_in_tier;
if (cap !== null) start += cap;
}
const cap = tiers[idx].leads_in_tier;
if (cap === null) return `${start}+`;
return `${start}${start + cap - 1}`;
}
@@ -23,7 +23,7 @@
</span>
</v-card-title>
<v-data-table
:headers="tierHeaders"
:headers="activeHeaders"
:items="active"
:items-per-page="7"
density="comfortable"
@@ -33,10 +33,17 @@
<span v-if="item.leads_in_tier !== null">{{ item.leads_in_tier }}</span>
<span v-else class="text-medium-emphasis">все свыше</span>
</template>
<template #[`item.client_range`]="{ item }">
<span :data-testid="`active-range-${item.tier_no}`">{{ activeRangeText(item) }} лидов</span>
</template>
<template #[`item.price_rub`]="{ item }">
{{ fmtRub(item.price_per_lead_kopecks) }}
</template>
</v-data-table>
<div class="text-caption text-medium-emphasis px-4 pb-3">
«Лидов в ступени» сколько заявок помещается именно в эту ступень (ширина). «Диапазон»
как эти ступени видит клиент в кабинете: с какого по какой лид действует цена.
</div>
</v-card>
<v-card v-if="hasScheduled" class="mb-6" elevation="1">
@@ -68,11 +75,16 @@
style="max-width: 240px"
data-testid="effective-from-input"
/>
<p class="text-caption text-medium-emphasis mb-2">
В поле «Лидов в ступени» вводится <strong>ширина</strong> ступени (сколько заявок в неё
помещается). Справа сразу видно, <strong>что увидит клиент</strong> накопительный диапазон.
</p>
<table class="editor-table">
<thead>
<tr>
<th>Ступень</th>
<th>Лидов в ступени</th>
<th>Клиент увидит</th>
<th>Цена за лид ()</th>
</tr>
</thead>
@@ -90,6 +102,9 @@
/>
<span v-else class="text-medium-emphasis">все свыше</span>
</td>
<td :data-testid="`editor-range-${t.tier_no}`" class="editor-range num">
{{ editorRangeText(t) }} лидов
</td>
<td>
<v-text-field
v-model="t.price_rub"
@@ -148,6 +163,7 @@ import {
type PricingTierEditorRow,
} from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
import { tierRangeText } from '../../utils/pricingTiers';
/**
* SaaS-admin → Тарифная сетка (Plan 4 Task 9, Sprint 1 G1 error handling).
@@ -198,6 +214,28 @@ const tierHeaders = [
{ title: 'Цена за лид', key: 'price_rub', sortable: false },
];
// Активная сетка показывает ещё и накопительный диапазон — ровно то, что видит
// клиент в кабинете (устраняет путаницу «в админке одни числа, у клиента другие»).
const activeHeaders = [
{ title: '№', key: 'tier_no', sortable: false, width: 80 },
{ title: 'Лидов в ступени', key: 'leads_in_tier', sortable: false },
{ title: 'Диапазон (у клиента)', key: 'client_range', sortable: false },
{ title: 'Цена за лид', key: 'price_rub', sortable: false },
];
// Диапазон для строки активной сетки: находим позицию ступени по tier_no
// и переводим ширины в накопительный диапазон (общий помощник — как в кабинете).
function activeRangeText(item: AdminPricingTier): string {
const idx = active.value.findIndex((t) => t.tier_no === item.tier_no);
return tierRangeText(active.value, idx);
}
// Живой предпросмотр диапазона в редакторе — пересчитывается при правке ширины.
function editorRangeText(row: PricingTierEditorRow): string {
const idx = editor.value.findIndex((r) => r.tier_no === row.tier_no);
return tierRangeText(editor.value, idx);
}
const nextMonthStart = computed(() => {
const d = new Date();
d.setDate(1);
@@ -307,4 +345,12 @@ defineExpose({
padding: 8px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.num {
font-family: 'JetBrains Mono', monospace;
font-feature-settings: 'tnum';
}
.editor-range {
color: #6b6456;
white-space: nowrap;
}
</style>
@@ -0,0 +1,8 @@
@if ($level === 'red')
🔴 КРАСНЫЙ. Робот-заказчик к 20:40 МСК ещё не закончил. До дедлайна поставщика (21:00) ~20 минут есть риск не успеть заказать на завтра.
@else
🟡 ЖЁЛТЫЙ. Робот-заказчик к 20:00 МСК ещё не закончил. До дедлайна поставщика (21:00) меньше часа следите.
@endif
Причина: {{ $reason }}
Время письма: {{ $now }} МСК.
@@ -0,0 +1,12 @@
Итоговая проверка заказа у поставщика ({{ $now }} МСК) нашла расхождения: {{ count($mismatches) }}.
@foreach ($mismatches as $m)
- [{{ $m['kind'] }}] {{ $m['signal_type'] }} {{ $m['identifier'] }} / {{ $m['platform'] }}: задумано «{{ var_export($m['expected'], true) }}», у поставщика «{{ var_export($m['actual'], true) }}»
@endforeach
Расшифровка:
- missing задумали строку, у поставщика её нет.
- limit_drift лимит не совпал.
- should_be_off должно быть выключено, а у поставщика включено (деньги!).
- should_be_on должно быть включено, а выключено.
- orphan_extra у поставщика лишняя наша строка.
+14
View File
@@ -161,6 +161,20 @@ Schedule::job(new SyncSupplierProjectsJob)
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', false, 'Job failed', null));
// Сторож дедлайна поставщика (21:00 МСК). yellow=20:00, red=20:40 — письмо,
// если робот к порогу не закончил. Spec 2026-07-09-supplier-order-verification §5.
Schedule::command('supplier:deadline-watch yellow')
->dailyAt('20:00')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('supplier:deadline-watch yellow', true, null, null))
->onFailure(fn () => $hb->recordRunResult('supplier:deadline-watch yellow', false, 'Command failed', null));
Schedule::command('supplier:deadline-watch red')
->dailyAt('20:40')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('supplier:deadline-watch red', true, null, null))
->onFailure(fn () => $hb->recordRunResult('supplier:deadline-watch red', false, 'Command failed', null));
Schedule::job(new CleanupInactiveSupplierProjectsJob)
->dailyAt('02:00')
->timezone('Europe/Moscow')
@@ -232,7 +232,7 @@ it('audit:rebuild-chain rejects unknown partition names', function (): void {
// Column list for auth_log (must match AuditChainConfig::TABLES['auth_log']).
const AUTH_LOG_ROW_EXPR = 'ROW(t.id, t.actor_type, t.tenant_id, t.user_id, t.saas_admin_user_id, t.email, t.event, t.ip_address, t.user_agent, t.failure_reason, NULL::bytea, t.created_at)';
it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в activity_log', function (): void {
it('audit:rebuild-chain produces global chain matching trigger semantics в activity_log (ADR-021)', function (): void {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
@@ -253,15 +253,10 @@ it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в
$partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
$firstId = (int) DB::connection('pgsql_supplier')->table($partition)->min('id');
// NB: pre-rebuild sanity-check на trigger output опущен намеренно — в test env
// `SharesSupplierPdo` trait + postgres superuser обходят RLS, и trigger пишет
// global chain, а не per-tenant. На prod RLS активен и trigger пишет per-tenant
// (валидация — live `audit:verify-chains` на проде, не в этом тесте).
//
// Что тестируется здесь: AFTER rebuild чейн должен match семантике своего
// partition_clause (self-consistency). Pre-Task-4 rebuild делает global LAG →
// verify с PARTITION BY tenant_id обнаруживает mismatch → RED. Post-Task-4
// rebuild делает per-tenant LAG → verify с PARTITION BY tenant_id match → GREEN.
// ADR-021 (supersedes ADR-018): канон — global-within-partition для всех таблиц.
// На проде (проверено 2026-07-09) и в test env (postgres superuser) trigger пишет
// GLOBAL chain. rebuild читает partition='' из AuditChainConfig → тоже global →
// verify под global scope match → GREEN.
$exit = Artisan::call('audit:rebuild-chain', [
'--partition' => $partition,
@@ -270,8 +265,8 @@ it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в
]);
expect($exit)->toBe(0);
$postMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR);
expect($postMismatches)->toBe(0, 'Rebuild должен produce per-tenant chain matching PARTITION BY tenant_id semantics (ADR-018)');
$postMismatches = checkPartitionIntegrity($partition, '', ACTIVITY_LOG_ROW_EXPR);
expect($postMismatches)->toBe(0, 'Rebuild должен produce global chain (ADR-021, supersedes ADR-018 per-tenant)');
});
it('audit:rebuild-chain produces global chain for BYPASSRLS auth_log', function (): void {
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
use App\Services\Autopodbor\Agent\Extract\PhoneValidityLayer;
use App\Services\RossvyazPrefixLookup;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* Засеваем один диапазон Россвязи реестр становится непустым.
* По умолчанию: def_code=921, абоненты 5550000..5559999 (МегаФон, СПб).
*
* @param array<string, mixed> $overrides
*/
function seedGateRange(array $overrides = []): void
{
$importId = (int) DB::table('phone_ranges_imports')->insertGetId([
'source_url' => 'https://rossvyaz.gov.ru/test',
'checksum_sha256' => str_repeat('b', 64),
'status' => 'completed',
'imported_at' => now(),
]);
DB::table('phone_ranges')->insert(array_merge([
'def_code' => 921,
'from_num' => 5550000,
'to_num' => 5559999,
'operator' => 'МегаФон',
'region' => 'Санкт-Петербург',
'subject_code' => 83,
'imported_at' => now(),
'import_id' => $importId,
], $overrides));
}
/**
* @param array<string, mixed> $overrides
* @return array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}
*/
function gateCandidate(string $number, array $overrides = []): array
{
return array_merge([
'number' => $number,
'kind' => 'text',
'label' => 'в тексте сайта — проверьте',
'url' => 'https://review-group.ru/',
'office' => null,
'tracker' => false,
], $overrides);
}
it('выкидывает номер, чей код города отсутствует в реестре Россвязи', function (): void {
seedGateRange(); // реестр непустой (есть 921)
// 76132306130 — код «613», такого в реестре нет (мусор со страницы-агрегатора).
$out = app(PhoneValidityGate::class)->filter([gateCandidate('76132306130')]);
expect($out)->toBe([]);
});
it('оставляет номер, который есть в реестре Россвязи', function (): void {
seedGateRange(); // def_code=921, 5550000..5559999
$out = app(PhoneValidityGate::class)->filter([
gateCandidate('79215555123', ['kind' => 'code', 'label' => 'в коде сайта']),
]);
expect($out)->toHaveCount(1)
->and($out[0]['number'])->toBe('79215555123');
});
it('фильтрует только мусор, реальные соседи остаются', function (): void {
seedGateRange();
$out = app(PhoneValidityGate::class)->filter([
gateCandidate('76132306130'), // мусор (код 613)
gateCandidate('79215555123', ['kind' => 'code']), // реальный (921)
gateCandidate('72279347817'), // мусор (код 227 — вообще <300, запрещён реестром)
]);
expect($out)->toHaveCount(1)
->and($out[0]['number'])->toBe('79215555123');
});
it('при пустом реестре не режет ничего (fail-open)', function (): void {
// seedGateRange НЕ вызван → phone_ranges пуст (как на dev). Фильтр не должен
// выкосить всё из-за незагруженного реестра.
$out = app(PhoneValidityGate::class)->filter([
gateCandidate('76132306130'),
gateCandidate('79215555123'),
]);
expect($out)->toHaveCount(2);
});
it('никогда не трогает короткие номера «требует проверки» (их нельзя сверить)', function (): void {
seedGateRange();
// 2713333 — локальный 7-значный, код города не определён (kind=uncertain).
// Сверить с реестром нельзя → не теряем.
$out = app(PhoneValidityGate::class)->filter([
gateCandidate('2713333', ['kind' => 'uncertain']),
]);
expect($out)->toHaveCount(1)
->and($out[0]['number'])->toBe('2713333');
});
it('второй слой (ДаДата) снимает мусор, прошедший реестр', function (): void {
seedGateRange(); // 79215555123 есть в реестре
$dropAll = new class implements PhoneValidityLayer
{
public function isValid(string $number): bool
{
return false; // ДаДата сказала «мусор»
}
};
$gate = new PhoneValidityGate(new RossvyazPrefixLookup, $dropAll);
$out = $gate->filter([gateCandidate('79215555123', ['kind' => 'code'])]);
expect($out)->toBe([]);
});
it('второй слой НЕ вызывается для номеров вне реестра (мусор отсёк слой 1 бесплатно)', function (): void {
seedGateRange();
$spy = new class implements PhoneValidityLayer
{
public bool $called = false;
public function isValid(string $number): bool
{
$this->called = true;
return true;
}
};
$gate = new PhoneValidityGate(new RossvyazPrefixLookup, $spy);
// 76132306130 — код 613, вне реестра → слой 1 выкидывает бесплатно, ДаДата не дёргается.
$out = $gate->filter([gateCandidate('76132306130')]);
expect($out)->toBe([])
->and($spy->called)->toBeFalse();
});
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use App\Services\Autopodbor\Agent\Fetch\FetchedSite;
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
use App\Services\Autopodbor\Agent\Study\SiteOpener;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
function seedOpenerRange(): void
{
$importId = (int) DB::table('phone_ranges_imports')->insertGetId([
'source_url' => 'https://rossvyaz.gov.ru/test',
'checksum_sha256' => str_repeat('c', 64),
'status' => 'completed',
'imported_at' => now(),
]);
DB::table('phone_ranges')->insert([
'def_code' => 391,
'from_num' => 2000000,
'to_num' => 2999999,
'operator' => 'Ростелеком',
'region' => 'Красноярский край',
'subject_code' => 24,
'imported_at' => now(),
'import_id' => $importId,
]);
}
/** Fetcher, отдающий фиксированный HTML. */
function openerFetcher(string $html): Fetcher
{
return new class($html) implements Fetcher
{
public function __construct(private string $html) {}
public function site(string $url): FetchedSite
{
return new FetchedSite($url, $this->html);
}
public function directory(string $url): array
{
return [];
}
};
}
it('глубокий путь: мусорный номер (код вне реестра) не попадает в numbers', function (): void {
seedOpenerRange(); // есть только 391 2000000..2999999
// Настоящий 73912920000 (391) + мусор 76132306130 (код 613, вне реестра).
$html = '<a href="tel:+73912920000">звонок</a>'
.' <span>+7 613 230 61 30</span>';
$r = (new SiteOpener(openerFetcher($html)))->open('kraslombard24.ru', '391');
$numbers = collect($r->numbers)->pluck('number');
expect($numbers)->toContain('73912920000')
->and($numbers)->not->toContain('76132306130');
});
@@ -2,9 +2,13 @@
declare(strict_types=1);
use App\Mail\IncidentDetectedMail;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
@@ -107,3 +111,63 @@ test('creates separate incidents for different exception signatures', function (
expect(DB::table('incidents_log')->count())->toBe(2);
});
// ===== Блок 6: supplier-manual-queue spike =====
// Helper: seed a project + N supplier_manual_sync_queue rows within the window
function seedManualQueue(int $n, ?DateTimeInterface $at = null): int
{
$project = Project::factory()->for(Tenant::factory())->create();
for ($i = 0; $i < $n; $i++) {
DB::table('supplier_manual_sync_queue')->insert([
'project_id' => $project->id,
'platform' => 'B1',
'operation' => 'create',
'external_id' => null,
'payload_snapshot' => '{}',
'failure_reason' => 'portal_unreachable',
'status' => 'pending',
'created_at' => $at ?? now(),
]);
}
return $project->id;
}
test('manual-queue spike below threshold: no incident, no mail', function () {
Mail::fake();
seedManualQueue(3, now()); // < default threshold 10
$this->artisan('incidents:watch-failures')->assertSuccessful();
expect(DB::table('incidents_log')->where('root_cause', 'supplier-manual-queue-spike')->count())->toBe(0);
Mail::assertNothingSent();
});
test('manual-queue spike at/above threshold: high incident + mail to both addresses', function () {
Mail::fake();
seedManualQueue(10, now()); // == default threshold 10
$this->artisan('incidents:watch-failures')->assertSuccessful();
$incident = DB::table('incidents_log')->where('root_cause', 'supplier-manual-queue-spike')->first();
expect($incident)->not->toBeNull();
expect($incident->severity)->toBe('high');
expect($incident->summary)->toContain('10');
Mail::assertSent(IncidentDetectedMail::class, function ($mail) {
return $mail->hasTo('kdv1@bk.ru') && $mail->hasTo('ops@liderra.ru');
});
});
test('manual-queue spike dedup: second run in window makes no new incident BUT re-sends mail', function () {
Mail::fake();
seedManualQueue(12, now());
$this->artisan('incidents:watch-failures')->assertSuccessful();
$this->artisan('incidents:watch-failures')->assertSuccessful();
// Инцидент один (дедуп 60 мин), а письмо ушло на каждом прогоне (громкость).
expect(DB::table('incidents_log')->where('root_cause', 'supplier-manual-queue-spike')->count())->toBe(1);
Mail::assertSent(IncidentDetectedMail::class, 2);
});
@@ -151,117 +151,18 @@ test('multiple rows in auth_log all pass intact', function () {
});
// ===========================================================================
// MULTI-TENANT REGRESSION: per-scope validator must not false-positive on
// data with multiple tenants.
// MULTI-TENANT REGRESSION — SUPERSEDED by ADR-021.
//
// Problem reproduced on prod: the old global validator computed LAG OVER
// (ORDER BY id) across ALL tenants. When tenant B's first row followed
// tenant A's last row, its stored_hash was SHA256(A_last.log_hash || B_row),
// but the global recompute gave the same thing — so it was fine globally.
// However, on PROD (crm_app_user, NOT BYPASSRLS) the trigger's SELECT only
// sees the current tenant's rows, so B_row[0] is chained off '' (no prev),
// not off A_last. The global validator then reported a false breach at every
// tenant boundary.
//
// This test replicates PROD conditions by bypassing the trigger and manually
// inserting rows with CORRECTLY per-tenant-chained hashes (what the trigger
// would produce under RLS on prod). The per-scope validator must then report
// INTACT, confirming the partition logic is correct.
//
// Why bypass the trigger for this test:
// On dev (postgres = superuser), the trigger's SELECT has no RLS filter and
// sees ALL rows globally. Inserting via the trigger would produce a GLOBAL
// chain regardless of the app.current_tenant_id GUC. To test the validator's
// per-partition recompute, we need rows whose stored hashes were computed
// with per-tenant prev_hash — exactly what the trigger produces on prod.
// We reproduce that by disabling triggers and computing the hashes ourselves
// using the same digest(COALESCE(prev,'')||row::text,'sha256') formula with
// per-partition LAG, matching the validator's own recompute SQL.
// The former test here ('per-tenant chained tenant_operations_log validates
// intact') manually seeded PER-TENANT-chained hashes and asserted the per-tenant
// validator accepted them. Its premise — that the trigger produces a per-tenant
// chain on prod — was empirically disproven 2026-07-09: the trigger produces a
// GLOBAL chain for all tables (incl. balance_transactions). ADR-021 makes global
// the canonical scope. The real, prod-accurate scenario (mixed-tenant rows
// chained BY THE TRIGGER = global) is covered by
// 'cross-tenant rows chained by the trigger (global) verify intact — ADR-021'.
// ===========================================================================
test('per-tenant chained tenant_operations_log validates intact (prod-behaviour regression)', function () {
$tid1 = ensureTenant(1);
$tid2 = ensureTenant(2);
// Disable triggers so we can insert rows with manually computed hashes
// that simulate per-tenant chaining (what the trigger produces on prod).
DB::statement('ALTER TABLE tenant_operations_log DISABLE TRIGGER USER');
try {
// Insert 3 rows for tenant 1 and 2 rows for tenant 2, interleaved by id.
// We insert WITHOUT log_hash first, then update with per-tenant-correct hashes.
// (INSERT with log_hash=NULL, then fill via SQL digest to avoid PHP bytea handling.)
$rows = [
['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 10, 'event' => 'project.created', 'created_at' => now()],
['tenant_id' => $tid2, 'entity_type' => 'api_key', 'entity_id' => 20, 'event' => 'api_key.regenerated', 'created_at' => now()],
['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 11, 'event' => 'project.updated', 'created_at' => now()],
['tenant_id' => $tid2, 'entity_type' => 'project', 'entity_id' => 21, 'event' => 'project.created', 'created_at' => now()],
['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 12, 'event' => 'project.deleted', 'created_at' => now()],
];
$ids = [];
foreach ($rows as $row) {
// log_hash left NULL; we will fill it below via SQL
$ids[] = (int) DB::table('tenant_operations_log')->insertGetId($row);
}
// Now fill log_hash for each inserted row using per-tenant-partitioned prev_hash,
// mirroring exactly what the trigger produces under RLS on prod:
// log_hash = digest(COALESCE(prev_tenant_hash, ''::bytea) || ROW(...)::text::bytea, 'sha256')
// where prev_tenant_hash = last log_hash of the SAME tenant ordered by id.
//
// We do this in id-order one row at a time so each hash feeds the next.
$idList = implode(',', $ids);
DB::statement(<<<SQL
WITH ranked AS (
SELECT
id,
tenant_id,
ROW(id, tenant_id, user_id, entity_type, entity_id, event,
payload_before, payload_after, ip_address, user_agent,
NULL::bytea, created_at) AS row_val,
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY id) AS rn
FROM tenant_operations_log
WHERE id IN ({$idList})
)
UPDATE tenant_operations_log tgt
SET log_hash = (
WITH RECURSIVE chain(id, tenant_id, rn, hash) AS (
-- Base: first row per tenant (rn=1), prev_hash = ''
SELECT r.id, r.tenant_id, r.rn,
digest(''::bytea || r.row_val::text::bytea, 'sha256')
FROM ranked r
WHERE r.rn = 1
UNION ALL
-- Recursive: each subsequent row chains off previous
SELECT r.id, r.tenant_id, r.rn,
digest(c.hash || r.row_val::text::bytea, 'sha256')
FROM chain c
JOIN ranked r ON r.tenant_id = c.tenant_id AND r.rn = c.rn + 1
)
SELECT hash FROM chain WHERE id = tgt.id
)
WHERE id IN ({$idList})
SQL);
} finally {
DB::statement('ALTER TABLE tenant_operations_log ENABLE TRIGGER USER');
}
// Verify all 5 rows got their hashes set (sanity check on the SQL above)
$nullCount = DB::table('tenant_operations_log')
->whereIn('id', $ids)
->whereNull('log_hash')
->count();
expect($nullCount)->toBe(0, 'All inserted rows must have log_hash set by the chain SQL');
// The per-scope validator must report INTACT — no false breach at tenant boundary
$this->artisan('audit:verify-chains')->assertSuccessful();
Mail::assertNothingSent();
});
// ===========================================================================
// Tampering detection
// ===========================================================================
@@ -500,3 +401,36 @@ test('exit code is FAILURE on breach even when no active admin exists for incide
// But email alert must still be sent
Mail::assertSent(AuditChainBreachMail::class);
});
// ===========================================================================
// ADR-021: audit hash-chain canonical is GLOBAL-within-partition for all tables.
// The trigger produces a global chain (verified on prod 2026-07-09 for all 4
// formerly-"per-tenant" tables incl. balance_transactions; also true on
// dev/superuser). Mixed-tenant rows inserted VIA THE TRIGGER must therefore
// verify intact — the old per-tenant validator (ADR-018) false-positived at
// every tenant boundary. Supersedes the per-tenant regression test above.
// ===========================================================================
test('cross-tenant rows chained by the trigger (global) verify intact — ADR-021', function () {
$t1 = ensureTenant(1);
$t2 = ensureTenant(2);
// Insert via the normal path (trigger fills log_hash). On dev/superuser the
// trigger chains GLOBALLY — exactly what prod produces. Interleave tenants so
// the chain crosses tenant boundaries (id2:t2 after id1:t1, etc.).
insertTenantOpsRow($t1, 'project.created');
insertTenantOpsRow($t2, 'api_key.regenerated');
insertTenantOpsRow($t1, 'project.updated');
insertTenantOpsRow($t2, 'project.created');
insertTenantOpsRow($t1, 'project.deleted');
// Under global scope (ADR-021) this is a valid intact chain → no breach.
$this->artisan('audit:verify-chains')->assertSuccessful();
$count = DB::connection('pgsql_supplier')
->table('incidents_log')
->where('summary', 'like', '%chain%tenant_operations_log%')
->count();
expect($count)->toBe(0);
Mail::assertNothingSent();
});
+14
View File
@@ -100,3 +100,17 @@ test('POST /api/deals/export нейтрализует CSV-формулы в св
expect($body)->not->toContain('"=HYPERLINK(');
expect($body)->not->toContain(';@SUM');
});
test('POST /api/deals/export срезает канальный префикс B<N>_ из источника (не палим поставщика)', function () {
$bgProject = Project::factory()->for($this->tenant)->create(['name' => 'B6_okna.ru [12]']);
Deal::factory()->for($this->tenant)->for($bgProject)->create([
'phone' => '+7 999 333-33-33', 'received_at' => '2026-05-15 10:00:00',
]);
$body = $this->post('/api/deals/export', [
'received_from' => '2026-05-14', 'received_to' => '2026-05-16', 'format' => 'csv',
])->streamedContent();
expect($body)->toContain('okna.ru [12]');
expect($body)->not->toContain('B6_');
});
+10
View File
@@ -493,3 +493,13 @@ test('GET /api/deals: источник чужого tenant не подтягив
$res = $this->getJson('/api/deals')->assertOk()->json('deals.0');
expect($res['competitor_name'])->toBeNull();
});
test('GET /api/deals срезает канальный префикс B<N>_ из project_name (не палим поставщика)', function () {
$bgProject = Project::factory()->for($this->tenant)->create(['name' => 'B6_okna.ru']);
Deal::factory()->for($this->tenant)->for($bgProject)->create(['status' => 'new']);
$r = $this->getJson('/api/deals');
$r->assertStatus(200);
expect($r->json('deals.0.project_name'))->toBe('okna.ru');
});
@@ -77,15 +77,36 @@ test('идемпотентность: повторный запуск не па
expect($afterSecond)->toBe($afterFirst);
// Output второго запуска должен сказать «0 created» по всем партиционированным таблицам × 6 месяцев
// (текущий + ahead=5). Число таблиц берём из PARTITIONED_TABLES — тест не ломается при добавлении новых.
$expectedSkipped = count(MonthlyPartitionManager::PARTITIONED_TABLES) * 6;
// Output второго запуска должен сказать «0 created» по всем партиционированным таблицам × 7 месяцев
// (behind=1 + текущий + ahead=5). Число таблиц берём из PARTITIONED_TABLES — тест не ломается
// при добавлении новых.
$expectedSkipped = count(MonthlyPartitionManager::PARTITIONED_TABLES) * 7;
$output = Artisan::output();
expect($output)->toContain("0 created, {$expectedSkipped} skipped");
});
test('--ahead=0 создаёт только текущий месяц', function () {
Artisan::call('partitions:create-months', ['--ahead' => 0]);
/*
* Прошлый месяц (12.07.2026). Нарезчик делал только «текущий + вперёд», поэтому на свежей
* базе партиции за прошлый месяц не было НИКОГДА. А система штатно пишет строки с датой в
* прошлом месяце: тридцатидневное окно списаний (метрика runway) и вставки на стыке месяцев
* (джоб стартовал 31-го в 23:59, вставил после полуночи с ранее взятым временем). На проде
* это не всплывало там партиции копятся с самого начала; падало в тестах в первой половине
* каждого месяца и упало бы в CI.
*/
test('создаёт партицию за ПРОШЛЫЙ месяц — окно последних 30 дней всегда в него попадает', function () {
$exitCode = Artisan::call('partitions:create-months');
expect($exitCode)->toBe(0);
$prevMonth = now()->startOfMonth()->subMonth();
$name = 'balance_transactions_y'.$prevMonth->format('Y').'_m'.$prevMonth->format('m');
$row = DB::selectOne("SELECT 1 AS x FROM pg_class WHERE relname = ? AND relkind = 'r'", [$name]);
expect($row)->not->toBeNull();
});
test('--ahead=0 --behind=0 создаёт только текущий месяц', function () {
Artisan::call('partitions:create-months', ['--ahead' => 0, '--behind' => 0]);
$currentMonth = now()->startOfMonth();
$name = 'deals_y'.$currentMonth->format('Y').'_m'.$currentMonth->format('m');
@@ -5,7 +5,6 @@ declare(strict_types=1);
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
@@ -14,7 +13,6 @@ use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
@@ -46,7 +44,6 @@ function makeFailover(SupplierProjectChannel $tier1, ?SupplierProjectChannel $ti
return [];
}
},
app(Mailer::class),
);
}
@@ -125,7 +122,7 @@ it('createProject — Tier 1 transient-exhausted: skips Tier 2, jumps to Tier 3
expect($tier2Called)->toBeFalse();
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('failure_reason', 'portal_unreachable')->count())->toBe(1);
Mail::assertQueued(SupplierCriticalAlertMail::class);
Mail::assertNothingQueued();
});
it('createProject — Tier 1 client-exc → Tier 2 success: no queue', function (): void {
@@ -165,7 +162,7 @@ it('createProject — Tier 1 client-exc → Tier 2 success: no queue', function
expect($id)->toBe(800001);
expect(SupplierManualSyncQueue::count())->toBe(0);
Mail::assertQueued(SupplierCriticalAlertMail::class); // failover_to_form alert
Mail::assertNothingQueued(); // failover через форму — успех, не тревога
});
it('createProject — Tier 1 client-exc + Tier 2 fail: Tier 3 queue, manual_required alert', function (): void {
@@ -205,7 +202,7 @@ it('createProject — Tier 1 client-exc + Tier 2 fail: Tier 3 queue, manual_requ
->toThrow(TierEscalatedException::class);
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('status', 'pending')->count())->toBe(1);
Mail::assertQueued(SupplierCriticalAlertMail::class);
Mail::assertNothingQueued();
});
it('createProject — Tier 1 auth-exc → Tier 2 success', function (): void {
+155 -193
View File
@@ -16,7 +16,6 @@ use App\Services\Supplier\SupplierPortalClient;
use Carbon\Carbon;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
@@ -57,49 +56,41 @@ afterEach(function (): void {
});
/**
* 3-колоночный CSV «Запрос номеров»: Name;Tag;Phone.
* Мокает журнал ОТДАННОГО (Мои сделки): SupplierPortalClient::fetchDeliveredLeads
* возвращает лиды, ключ = vid (как реальный метод).
*
* @param array<int, array{project: string, phone: string}> $rows
* @param list<array{vid:int, phone:string, project:string}> $leads
*/
function csvBody(array $rows): string
function fakeDelivered(array $leads): void
{
$out = "Name;Tag;Phone\n";
foreach ($rows as $r) {
$out .= "{$r['project']};tag;{$r['phone']}\n";
$byVid = [];
foreach ($leads as $l) {
$byVid[(int) $l['vid']] = $l;
}
return $out;
$mock = Mockery::mock(SupplierPortalClient::class);
$mock->shouldReceive('fetchDeliveredLeads')->andReturn($byVid);
app()->instance(SupplierPortalClient::class, $mock);
}
/**
* Мокает весь async-флоу отчёта (реальные endpoint'ы discovery T3 2026-05-19):
* POST /admin/report/save-report "OK"
* GET /admin/report/load-reports array [{id, title, status:"1", ...}] (id извлекается по title-match)
* GET /admin/report/getfile?id=N raw CSV
*
* Title включает фактически использованные dateFrom/dateTo захватываем их из save-report body
* и возвращаем тот же диапазон в load-reports, чтобы матч requestNumbersReport состоялся.
*/
function fakeReportFlow(string $csv): void
function fakeDeliveredThrows(Throwable $e): void
{
$captured = ['from' => '', 'to' => ''];
$mock = Mockery::mock(SupplierPortalClient::class);
/* @phpstan-ignore-next-line method.notFound (Mockery andThrow — union-type false positive, как в др. supplier-тестах) */
$mock->shouldReceive('fetchDeliveredLeads')->andThrow($e);
app()->instance(SupplierPortalClient::class, $mock);
}
Http::fake([
'crm.bp-gr.ru/admin/report/save-report' => function (Request $r) use (&$captured) {
$body = $r->data();
$captured['from'] = (string) ($body['reportFilter']['dateFrom'] ?? '');
$captured['to'] = (string) ($body['reportFilter']['dateTo'] ?? '');
return Http::response('OK', 200);
},
'crm.bp-gr.ru/admin/report/load-reports' => function () use (&$captured) {
$title = sprintf('Запрос номеров с %s по %s', $captured['from'], $captured['to']);
return Http::response([
['id' => '700001', 'title' => $title, 'status' => '1', 'is_file' => '1', 'percent' => '100'],
], 200);
},
'crm.bp-gr.ru/admin/report/getfile*' => Http::response($csv, 200),
/** Принятый вебхуком лид (source=webhook, реальный vid). */
function webhookLead(int $vid, string $phone, string $project = 'B1_a.com', ?Carbon $at = null): void
{
SupplierLead::create([
'supplier_project_id' => null,
'platform' => str_starts_with($project, 'B') ? substr($project, 0, 2) : 'B1',
'phone' => $phone,
'vid' => $vid,
'raw_payload' => ['project' => $project, 'phone' => $phone, 'vid' => $vid],
'received_at' => $at ?? now()->subHour(),
'source' => 'webhook',
]);
}
@@ -112,24 +103,20 @@ function runCsvReconcile(): void
);
}
it('no missing leads — status=ok, no recovery, no alert', function (): void {
for ($i = 0; $i < 10; $i++) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => "7999000000{$i}",
'vid' => 800000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
}
// ---------------------------------------------------------------------------
// Сверка по vid против журнала ОТДАННОГО (Мои сделки) — Путь 2, переработка 09.07.2026.
// Раньше сверялись с пулом «Запрос номеров» (phones_cnt) → фантомы + выедание лимита.
// ---------------------------------------------------------------------------
$rows = [];
it('all delivered vids already received via webhook — recovered 0, status ok', function (): void {
$delivered = [];
for ($i = 0; $i < 10; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"];
$vid = 800000 + $i;
$phone = "7999000000{$i}";
webhookLead($vid, $phone);
$delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com'];
}
fakeReportFlow(csvBody($rows));
fakeDelivered($delivered);
runCsvReconcile();
@@ -139,28 +126,23 @@ it('no missing leads — status=ok, no recovery, no alert', function (): void {
expect((int) $log->matched_count)->toBe(10);
expect((int) $log->recovered_count)->toBe(0);
Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots
Mail::assertNotSent(CsvDriftAlertMail::class);
Bus::assertNothingDispatched();
});
it('1 missing of 10 (drift 10%) — recovery + drift alert', function (): void {
it('recovers a delivered lead missing from webhook — with the REAL vid (not null)', function (): void {
$delivered = [];
for ($i = 0; $i < 9; $i++) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => "7999111000{$i}",
'vid' => 810000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
$vid = 810000 + $i;
$phone = "7999111000{$i}";
webhookLead($vid, $phone);
$delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com'];
}
$rows = [];
for ($i = 0; $i < 10; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"];
}
fakeReportFlow(csvBody($rows));
// 10-й отдан поставщиком, но вебхук его потерял.
$missingVid = 810099;
$missingPhone = '79991119999';
$delivered[] = ['vid' => $missingVid, 'phone' => $missingPhone, 'project' => 'B1_a.com'];
fakeDelivered($delivered);
runCsvReconcile();
@@ -171,71 +153,87 @@ it('1 missing of 10 (drift 10%) — recovery + drift alert', function (): void {
$recovered = SupplierLead::where('source', 'csv_recovery')->first();
expect($recovered)->not->toBeNull();
expect($recovered->vid)->toBeNull();
// КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: recovery несёт НАСТОЯЩИЙ vid (раньше был null) — точная личность лида.
expect((int) $recovered->vid)->toBe($missingVid);
expect($recovered->phone)->toBe($missingPhone);
expect($recovered->recovered_from_csv_at)->not->toBeNull();
Mail::assertSent(CsvDriftAlertMail::class, 1);
Bus::assertDispatched(RouteSupplierLeadJob::class, 1);
});
it('1 missing of 100 (drift 1%) — recovery without alert', function (): void {
for ($i = 0; $i < 99; $i++) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
'vid' => 820000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
it('reconciles ONLY the delivered ledger — pool numbers (roistat identity) are structurally impossible to recover', function (): void {
// Поставщик реально ОТДАЛ 3 лида (vid+phone), вебхук их принял.
// В старом баге ПУЛ по этому проекту содержал десятки ДРУГИХ телефонов (пул 157 vs отдано 15),
// и старый reconcile лепил из них фантомы. Новый источник = только отданное (по vid) → лишнему
// взяться неоткуда.
$delivered = [];
foreach ([[900001, '79000000001'], [900002, '79000000002'], [900003, '79000000003']] as [$vid, $phone]) {
webhookLead($vid, $phone, 'B2_74950001122');
$delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B2_74950001122'];
}
$rows = [];
for ($i = 0; $i < 100; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
}
fakeReportFlow(csvBody($rows));
fakeDelivered($delivered);
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect($log->status)->toBe('ok');
expect((int) $log->recovered_count)->toBe(1);
Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots
expect((int) $log->recovered_count)->toBe(0);
expect(SupplierLead::where('source', 'csv_recovery')->count())->toBe(0);
Bus::assertNothingDispatched();
});
it('dedup is keyed by (phone, project) — same phone on different project is NOT a duplicate', function (): void {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79995550000',
'vid' => 830000,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79995550000'],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
fakeReportFlow(csvBody([
['project' => 'B1_a.com', 'phone' => '79995550000'],
['project' => 'B2_b.com', 'phone' => '79995550000'],
]));
it('delivered vid already exists (any time) — matched, not re-recovered, no duplicate', function (): void {
// Лид принят вебхуком 3 дня назад (вне 2-дневного окна reconcile). В журнале отданного он ещё
// виден. Глобальная проверка vid должна засчитать его как matched, а не пытаться вставить дубль.
$vid = 920000;
$phone = '79200000000';
webhookLead($vid, $phone, 'B1_a.com', now()->subDays(3));
fakeDelivered([['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']]);
runCsvReconcile();
expect(SupplierLead::where('vid', $vid)->count())->toBe(1); // без дубля
expect(SupplierLead::where('source', 'csv_recovery')->count())->toBe(0);
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->recovered_count)->toBe(0);
expect((int) $log->matched_count)->toBe(1);
expect((int) $log->recovered_count)->toBe(1);
});
it('empty CSV — status=ok, drift=0', function (): void {
fakeReportFlow("Name;Tag;Phone\n");
it('unparseable project in delivered ledger — skipped, counted, excluded from drift', function (): void {
// 5 нормальных отданных (webhook принял) + 5 отданных с мусорным project (extractPlatform=null).
$delivered = [];
for ($i = 0; $i < 5; $i++) {
$vid = 930000 + $i;
$phone = "7993000000{$i}";
webhookLead($vid, $phone);
$delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com'];
}
$junk = ['???', '!@#', '%%%', '$$$', '***'];
foreach ($junk as $j => $bad) {
$delivered[] = ['vid' => 931000 + $j, 'phone' => "7993100000{$j}", 'project' => $bad];
}
fakeDelivered($delivered);
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->total_csv_rows)->toBe(10);
expect((int) $log->matched_count)->toBe(5);
expect((int) $log->recovered_count)->toBe(0);
expect((int) $log->unparseable_count)->toBe(5);
expect((float) $log->drift_ratio)->toBe(0.0); // только junk, реального missing нет
expect($log->status)->toBe('ok');
});
it('empty delivered ledger — status=ok, drift=0', function (): void {
fakeDelivered([]);
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect($log->status)->toBe('ok');
expect((int) $log->total_csv_rows)->toBe(0);
expect((int) $log->recovered_count)->toBe(0);
});
it('overlap lock held — job skips, no log row', function (): void {
@@ -253,8 +251,8 @@ it('overlap lock held — job skips, no log row', function (): void {
expect(DB::table('supplier_csv_reconcile_log')->count())->toBe($countBefore);
});
it('SupplierTransientException — status=failed, error recorded, rethrown', function (): void {
Http::fake(['crm.bp-gr.ru/*' => Http::response('Server Error', 500)]);
it('SupplierTransientException from delivered fetch — status=failed, rethrown', function (): void {
fakeDeliveredThrows(new SupplierTransientException('Supplier server error 500'));
expect(fn () => runCsvReconcile())->toThrow(SupplierTransientException::class);
@@ -263,96 +261,65 @@ it('SupplierTransientException — status=failed, error recorded, rethrown', fun
expect($log->error_message)->toContain('500');
});
it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows → status=ok, unparseable_count=10', function (): void {
// 100 нормальных webhook-лидов.
for ($i = 0; $i < 100; $i++) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
'vid' => 840000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
// ---------------------------------------------------------------------------
// fetchDeliveredLeads — парсинг HTML «Мои сделки» + пагинация (реальный клиент, Http::fake).
// ---------------------------------------------------------------------------
/**
* Строит HTML таблицы «Мои сделки»: строка = checkbox value(vid) + B{n}_<proj> + телефон.
*
* @param list<array{vid:int, phone:string, project:string}> $leads
*/
function deliveredHtml(array $leads): string
{
$rows = '';
foreach ($leads as $l) {
$rows .= '<tr class="users-table__item">'
.'<td><input type="checkbox" name="visit-checbox" value="'.$l['vid'].'"></td>'
.'<td>Открыть '.$l['project'].' РФ'.$l['phone'].' -</td>'
.'</tr>';
}
// CSV: те же 100 (matched) + 10 строк с настоящим мусорным project (extractPlatform = null).
// Phase 3 (2026-05-25): расширили DIRECT-распознавание — теперь цифровые callback-проекты
// (79135551234) — валидный DIRECT, не junk. Реальный junk — это символы вне whitelist regex.
$rows = [];
for ($i = 0; $i < 100; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
}
$junkProjects = ['???', '!@#', '%%%', '$$$', '???!!!', '~~~', '***', '|||', '^^^', '&&&'];
foreach ($junkProjects as $j => $junk) {
$rows[] = ['project' => $junk, 'phone' => '7999500000'.$j];
}
fakeReportFlow(csvBody($rows));
return '<table><tbody>'.$rows.'</tbody></table>';
}
runCsvReconcile();
it('fetchDeliveredLeads parses vid+phone+project from Мои сделки HTML', function (): void {
$html = deliveredHtml([
['vid' => 1718932476, 'phone' => '79001112233', 'project' => 'B3_roistat.com'],
['vid' => 1718932472, 'phone' => '79001112244', 'project' => 'B2_74950001122'],
]);
Http::fake(['crm.bp-gr.ru/admin/visit/index-visit*' => Http::response($html, 200)]);
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->total_csv_rows)->toBe(110);
expect((int) $log->matched_count)->toBe(100);
expect((int) $log->recovered_count)->toBe(0);
expect((int) $log->unparseable_count)->toBe(10);
// Реального missing'а нет — только junk; drift должен быть 0, не 10/110.
expect((float) $log->drift_ratio)->toBe(0.0);
expect($log->status)->toBe('ok');
$result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now());
Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots
expect($result)->toHaveCount(2);
expect($result[1718932476])->toMatchArray(['vid' => 1718932476, 'phone' => '79001112233', 'project' => 'B3_roistat.com']);
expect($result[1718932472]['project'])->toBe('B2_74950001122');
});
it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recovered=3, drift по реальным', function (): void {
for ($i = 0; $i < 95; $i++) {
SupplierLead::create([
'supplier_project_id' => null,
'platform' => 'B1',
'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
'vid' => 850000 + $i,
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
'received_at' => now()->subHour(),
'source' => 'webhook',
]);
it('fetchDeliveredLeads paginates until a page returns < 50 rows', function (): void {
$page1 = [];
for ($i = 0; $i < 50; $i++) {
$page1[] = ['vid' => 1000 + $i, 'phone' => '790000'.str_pad((string) $i, 5, '0', STR_PAD_LEFT), 'project' => 'B1_a.com'];
}
$page2 = [];
for ($i = 0; $i < 3; $i++) {
$page2[] = ['vid' => 2000 + $i, 'phone' => '790010'.str_pad((string) $i, 5, '0', STR_PAD_LEFT), 'project' => 'B1_a.com'];
}
Http::fake([
'crm.bp-gr.ru/admin/visit/index-visit*' => Http::sequence()
->push(deliveredHtml($page1), 200)
->push(deliveredHtml($page2), 200),
]);
$rows = [];
for ($i = 0; $i < 95; $i++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
}
// Phase 3: реальный junk — символы вне whitelist (не \w/.-/cyrillic/digits/slash/parens/space/plus).
$junkProjects = ['???', '!!!@@@', '%%%', '****', '???!!!'];
foreach ($junkProjects as $j => $junk) {
$rows[] = ['project' => $junk, 'phone' => '7999600000'.$j];
}
for ($k = 0; $k < 3; $k++) {
$rows[] = ['project' => 'B1_a.com', 'phone' => '7999700000'.$k];
}
fakeReportFlow(csvBody($rows));
$result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now());
runCsvReconcile();
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
expect((int) $log->total_csv_rows)->toBe(103);
expect((int) $log->matched_count)->toBe(95);
expect((int) $log->recovered_count)->toBe(3);
expect((int) $log->unparseable_count)->toBe(5);
// real_missing = (103 - 95) - 5 = 3; parseable_total = 103 - 5 = 98; drift = 3/98 ≈ 0.0306 < 5% → ok.
expect((float) $log->drift_ratio)->toBeLessThan(0.05);
expect((float) $log->drift_ratio)->toBeGreaterThan(0.0);
expect($log->status)->toBe('ok');
expect($result)->toHaveCount(53); // 50 + 3, остановились на неполной странице
});
// ---------------------------------------------------------------------------
// Stage 4 / Task 4.5 — R-05 (spec §4.4.4): business-drift second pass.
// After existing webhook-loss drift detection, CsvReconcileJob runs a second
// pass on project_routing_snapshots: per (snapshot_date, tenant_id) groups
// where (expected - delivered) / expected > 20% → TenantBusinessDriftAlertMail.
// This is orthogonal to webhook-loss drift (R-05.1) — same lead can be:
// - delivered & webhook OK (no alerts)
// - delivered & webhook miss (R-05.1 CsvDriftAlertMail)
// - not delivered at all (R-05.2 TenantBusinessDriftAlertMail — this task)
// R-05 business-drift (spec §4.4.4) — второй проход по project_routing_snapshots.
// Ортогонален webhook-loss drift: тот же лид может быть не доставлен вовсе.
// ---------------------------------------------------------------------------
function insertSnapshotForTenant(int $tenantId, string $date, int $expected, int $delivered): void
@@ -386,12 +353,10 @@ function insertSnapshotForTenant(int $tenantId, string $date, int $expected, int
it('R-05 business-drift: tenant with shortfall > 20% → TenantBusinessDriftAlertMail sent', function (): void {
$tenant = Tenant::factory()->create();
// Yesterday's snapshot: expected 10, delivered 2 → shortfall 80% (>20% threshold).
$yesterday = Carbon::yesterday('Europe/Moscow')->toDateString();
insertSnapshotForTenant($tenant->id, $yesterday, 10, 2);
// Empty CSV — primary drift pass is trivially OK; we exercise only the second pass.
fakeReportFlow(csvBody([]));
fakeDelivered([]);
runCsvReconcile();
Mail::assertSent(TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
@@ -405,15 +370,12 @@ it('R-05 business-drift: tenant with shortfall > 20% → TenantBusinessDriftAler
it('R-05 business-drift: tenant with shortfall <= 20% → NO TenantBusinessDriftAlertMail', function (): void {
$tenant = Tenant::factory()->create();
// Yesterday's snapshot: expected 10, delivered 9 → shortfall 10% (<=20% threshold).
$yesterday = Carbon::yesterday('Europe/Moscow')->toDateString();
insertSnapshotForTenant($tenant->id, $yesterday, 10, 9);
fakeReportFlow(csvBody([]));
fakeDelivered([]);
runCsvReconcile();
// Scoped assertion: prior-run leaked snapshots may fire mails for other tenants;
// this test only owns one tenant, so assert no mail was sent for IT.
Mail::assertNotSent(TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
return $mail->tenantId === $tenant->id;
});
@@ -4,7 +4,6 @@ declare(strict_types=1);
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
@@ -12,13 +11,12 @@ use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
uses(RefreshDatabase::class);
test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row + queues alert mail', function (): void {
test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row, no per-project alert mail', function (): void {
Mail::fake();
config(['services.supplier.alert_email' => 'ops@liderra.local']);
@@ -32,7 +30,7 @@ test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row +
$tier2 = mock(SupplierProjectChannel::class);
$tier2->shouldReceive('createProject')->andThrow(new RuntimeException('Tier-2 manage-project.js selector break'));
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
$channel = new FailoverProjectChannel($tier1, $tier2);
$dto = new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'failover-smoke.example',
@@ -44,7 +42,7 @@ test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row +
expect(SupplierManualSyncQueue::where('project_id', $project->id)->count())->toBe(1);
Mail::assertQueued(SupplierCriticalAlertMail::class, fn ($m) => $m->alertType === 'manual_required');
Mail::assertNothingQueued(); // per-project тревога убрана — запись в ручной очереди достаточна
});
test('Tier-1 transient fail (portal unreachable) bypasses Tier-2 and goes straight to Tier-3', function (): void {
@@ -61,7 +59,7 @@ test('Tier-1 transient fail (portal unreachable) bypasses Tier-2 and goes straig
$tier2 = mock(SupplierProjectChannel::class);
$tier2->shouldNotReceive('createProject'); // КЛЮЧЕВОЕ — transient НЕ должен попасть в tier-2
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
$channel = new FailoverProjectChannel($tier1, $tier2);
$dto = new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'transient-smoke.example',
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Mail\SupplierDeadlineWarningMail;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(function (): void {
DB::connection('pgsql_supplier')->table('supplier_sync_runs')->delete();
});
it('робот сегодня закончил → сторож молчит', function (): void {
Mail::fake();
DB::connection('pgsql_supplier')->table('supplier_sync_runs')->insert([
'started_at' => now(), 'finished_at' => now(), 'groups_total' => 1, 'synced_ok' => 1,
'manual_queued' => 0, 'deferred' => 0, 'failed' => 0, 'status' => 'ok', 'created_at' => now(),
]);
$this->artisan('supplier:deadline-watch', ['level' => 'yellow'])->assertExitCode(0);
Mail::assertNothingQueued();
});
it('робот сегодня НЕ закончил → красное письмо', function (): void {
Mail::fake();
DB::connection('pgsql_supplier')->table('supplier_sync_runs')->insert([
'started_at' => now(), 'finished_at' => null, 'groups_total' => 0, 'synced_ok' => 0,
'manual_queued' => 0, 'deferred' => 0, 'failed' => 0, 'status' => 'ok', 'created_at' => now(),
]);
$this->artisan('supplier:deadline-watch', ['level' => 'red'])->assertExitCode(0);
Mail::assertQueued(SupplierDeadlineWarningMail::class, fn ($m) => $m->level === 'red');
});
it('запуска за сегодня нет вовсе → письмо (планировщик не сработал)', function (): void {
Mail::fake();
$this->artisan('supplier:deadline-watch', ['level' => 'yellow'])->assertExitCode(0);
Mail::assertQueued(SupplierDeadlineWarningMail::class, fn ($m) => $m->level === 'yellow');
});
@@ -137,6 +137,72 @@ it('updateProject POSTs to /admin/visit/rt-project-save with id:N (same endpoint
});
});
/*
* Прод-инцидент 1112.07.2026. Кабинет дописывает метку канала «B<n> к имени только
* при СОЗДАНИИ строки; при обновлении он сохраняет имя ровно как прислали. Мы слали
* голый uniqueKey каждый ночной прогон стирал метку. Последствия: (1) итоговая
* проверка переставала узнавать строку ложное «нет заказа»; (2) поставщик присылал
* лид с project=«79135397707» без метки webhook не мог определить канал и писал
* platform=DIRECT вместо B1/B2/B3 (потеря атрибуции канала).
*
* Поэтому при ОБНОВЛЕНИИ имя уходит уже с меткой; при СОЗДАНИИ голым (там метку
* ставит сам кабинет, и один save с тремя флагами рождает три строки единого
* префикса для них быть не может).
*/
it('updateProject sends name WITH the B<n>_ channel prefix (portal would otherwise strip it)', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '13625654'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B2',
signalType: 'call',
uniqueKey: '79135397707',
limit: 1,
workdays: [1, 2, 3, 4, 5],
regions: [],
regionsReverse: false,
status: 'active',
platforms: ['B2'],
);
app(SupplierPortalClient::class)->updateProject(13625654, $dto);
Http::assertSent(function (Request $request): bool {
return $request['id'] === 13625654
&& $request['name'] === 'B2_79135397707'
// content — по-прежнему голый ключ: это поле сбора (телефон/домен).
&& $request['content'] === '79135397707';
});
});
it('saveProject (create) keeps the name bare — the portal adds the prefix itself', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '777'],
200,
),
]);
$dto = new SupplierProjectDto(
platform: 'B1',
signalType: 'call',
uniqueKey: '79135397707',
limit: 1,
workdays: [1, 2, 3, 4, 5],
regions: [],
regionsReverse: false,
status: 'active',
);
app(SupplierPortalClient::class)->saveProject($dto);
Http::assertSent(fn (Request $request): bool => $request['id'] === 0 && $request['name'] === '79135397707');
});
it('deleteProject POSTs to /admin/visit/rt-project-delete with JSON {id:"<string>"}', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response(
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
use App\Models\SupplierProject;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/*
* Разовая починка после прод-инцидента 1112.07.2026: наш ежедневный updateProject слал
* имя без метки канала, и кабинет стирал метку «B<n>. Строка без метки лид от неё
* приходит с project без метки webhook пишет platform=DIRECT вместо B1/B2/B3.
*
* Команда возвращает метку на место. Payload собирается ИЗ ЖИВОЙ СТРОКИ кабинета
* (лимит/дни/регионы/вкл-выкл как есть), меняется ровно одно поле name. Регионы
* из кабинета уже в кодах поставщика, повторно их переводить нельзя.
*/
beforeEach(function (): void {
Cache::store('redis')->put('supplier:session', [
'phpsessid' => 'test-session',
'csrf' => 'test-csrf',
], now()->addHour());
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
});
/** @param array<int, array<string,mixed>> $rows */
function fakeCabinet(array $rows): void
{
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => $rows], 200),
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '1'], 200),
]);
}
/** Строка кабинета с потерянной меткой канала. */
function bareCabinetRow(string $id, string $src, string $content): array
{
return [
'id' => $id, 'src' => $src, 'tag' => 'РФ', 'name' => $content, 'content' => $content,
'type' => 'calls', 'lim' => '5', 'status' => true, 'workdays' => ['1', '2', '3', '4', '5'],
'regions' => '24;66', 'regions_reverse' => false,
];
}
it('dry-run: показывает строки с потерянной меткой, но НИЧЕГО не пишет в кабинет', function (): void {
SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79135397707',
'supplier_external_id' => '6001', 'inactive_since' => null,
]);
fakeCabinet([bareCabinetRow('6001', 'rt', '79135397707')]);
$this->artisan('supplier:repair-project-names')
->expectsOutputToContain('B1_79135397707')
->assertExitCode(0);
Http::assertNotSent(fn (Request $r): bool => str_contains($r->url(), 'rt-project-save'));
});
it('--apply: возвращает метку канала, сохраняя лимит/дни/регионы/вкл-выкл как есть', function (): void {
SupplierProject::factory()->create([
'platform' => 'B2', 'signal_type' => 'call', 'unique_key' => '79135397707',
'supplier_external_id' => '6002', 'inactive_since' => null,
]);
fakeCabinet([bareCabinetRow('6002', 'bl', '79135397707')]);
$this->artisan('supplier:repair-project-names', ['--apply' => true])->assertExitCode(0);
Http::assertSent(function (Request $r): bool {
if (! str_contains($r->url(), 'rt-project-save')) {
return false;
}
return $r['id'] === 6002
&& $r['name'] === 'B2_79135397707'
&& $r['content'] === '79135397707'
&& $r['srcbl'] === true && $r['srcrt'] === false && $r['srcmt'] === false
&& $r['limit'] === 5
&& $r['status'] === true
&& $r['workdays'] === ['1', '2', '3', '4', '5']
// Регионы из кабинета — уже коды поставщика, отдаём обратно без перевода.
&& $r['regions'] === [24, 66];
});
});
it('не трогает строки, у которых метка на месте, и чужие строки кабинета', function (): void {
SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79135397707',
'supplier_external_id' => '6003', 'inactive_since' => null,
]);
$withPrefix = bareCabinetRow('6003', 'rt', '79135397707');
$withPrefix['name'] = 'B1_79135397707';
$foreign = bareCabinetRow('9999', 'rt', 'chужой.ru'); // не наш external_id
fakeCabinet([$withPrefix, $foreign]);
$this->artisan('supplier:repair-project-names', ['--apply' => true])->assertExitCode(0);
Http::assertNotSent(fn (Request $r): bool => str_contains($r->url(), 'rt-project-save'));
});
it('--only-active: чинит только включённые у нас строки (выключенные лидов не носят)', function (): void {
SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79135397707',
'supplier_external_id' => '6004', 'inactive_since' => null,
]);
SupplierProject::factory()->create([
'platform' => 'B3', 'signal_type' => 'call', 'unique_key' => '79990001122',
'supplier_external_id' => '6005', 'inactive_since' => now(),
]);
fakeCabinet([
bareCabinetRow('6004', 'rt', '79135397707'),
bareCabinetRow('6005', 'mt', '79990001122'),
]);
$this->artisan('supplier:repair-project-names', ['--apply' => true, '--only-active' => true])
->assertExitCode(0);
Http::assertSent(fn (Request $r): bool => str_contains($r->url(), 'rt-project-save') && $r['id'] === 6004);
Http::assertNotSent(fn (Request $r): bool => str_contains($r->url(), 'rt-project-save') && $r['id'] === 6005);
});
@@ -5,6 +5,7 @@ declare(strict_types=1);
use App\Exceptions\Supplier\SupplierAuthException;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Jobs\Supplier\VerifySupplierOrderJob;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierProject;
@@ -400,6 +401,10 @@ test('idempotent: repeat run with no changes → updateProject not duplicate', f
// ---------------------------------------------------------------------------
test('respects time budget by stopping at 20:55 МСК', function (): void {
// Итоговая проверка (VerifySupplierOrderJob) диспатчится в finally и сама шлёт HTTP
// при реальном прогоне — вне области этого теста (тут проверяем только САМ робот).
Bus::fake([VerifySupplierOrderJob::class]);
Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow'));
$tenant = Tenant::factory()->create();
@@ -577,3 +582,127 @@ test('nightly: re-creates donor on portal when its external_id no longer exists
expect($sps)->toHaveCount(3);
expect($sps->pluck('supplier_external_id')->all())->toBe(['8001', '8002', '8003']);
});
// ---------------------------------------------------------------------------
// Deactivation (18:00): turn OFF orders whose source has no active project left.
// Money-critical (owner-reported 2026-07-08, verified on prod live DB): prod runs
// in BATCH mode. The supplier fixes tomorrow's delivery by its 21:00 snapshot, so the
// ONLY window to STOP an order is the 18:00 batch sync — before that snapshot. This
// nightly job only processed ACTIVE projects and never turned OFF a source whose
// projects are all now paused → the paused order stayed active at the supplier → next
// day leads (and charges) kept coming. The 18:00 job must push status=paused for such
// orphaned orders before 21:00.
// ---------------------------------------------------------------------------
test('nightly 18:00: a source with no active project left is turned OFF (status=paused) at the supplier', function (): void {
$tenant = Tenant::factory()->create();
$common = '79997778899';
// Project is PAUSED → excluded from tomorrow's snapshot (SnapshotProjectRoutingJob
// filters is_active=true). Deliberately NO insertSnapshotForTomorrow — nothing active
// references this source tonight, so its supplier order is orphaned.
Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => false, 'signal_type' => 'call',
'signal_identifier' => $common, 'daily_limit_target' => 6, 'delivery_days_mask' => 127, 'regions' => [],
]);
// Pre-seed the supplier orders created earlier when the project was live (numeric
// external ids so updateProject's (int) cast keeps id != 0).
foreach (['B1' => '90001', 'B2' => '90002', 'B3' => '90003'] as $platform => $ext) {
SupplierProject::on('pgsql_supplier')->create([
'platform' => $platform, 'signal_type' => 'call', 'unique_key' => $common,
'subject_code' => null, 'supplier_external_id' => $ext, 'current_limit' => 2,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], 'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
}
$savePayloads = [];
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$savePayloads) {
$savePayloads[] = $request->data();
return Http::response(['status' => 'OK', 'message' => '', 'id' => (string) ($request->data()['id'] ?? 0)], 200);
},
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => []], 200),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
// The 3 orphaned orders were turned OFF: update (id != 0) with status=false.
$offCalls = array_values(array_filter(
$savePayloads,
fn ($p) => (int) ($p['id'] ?? 0) !== 0 && ($p['status'] ?? null) === false
));
expect($offCalls)->toHaveCount(3);
// Non-zero limit kept (portal rejects limit=0 even when paused).
foreach ($offCalls as $p) {
expect((int) $p['limit'])->toBeGreaterThan(0);
}
// Local rows marked inactive_since so cleanup/UI reflect the pause.
expect(SupplierProject::on('pgsql_supplier')->where('unique_key', $common)->whereNotNull('inactive_since')->count())->toBe(3);
});
test('nightly 18:00: a deleted project leftover order is turned OFF but KEPT in DB while its snapshot tail still routes', function (): void {
// Composition of the new deactivation pass with delete-defer (DeleteSupplierProjectTailGuardTest):
// when a project is deleted but its donor is deferred (so already-ordered tail leads still
// route via the snapshot), the 18:00 pass must still turn the order OFF at the supplier
// (stop FUTURE ordering) — WITHOUT deleting the local donor (the tail still needs it).
$tenant = Tenant::factory()->create();
$common = '79996665544';
// Donor exists (created when the project was live). Project already hard-deleted → no pivot
// links, and NOT in tomorrow's active snapshot.
$sp = SupplierProject::on('pgsql_supplier')->create([
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => $common,
'subject_code' => null, 'supplier_external_id' => '95001', 'current_limit' => 3,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], 'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
// Tail snapshot for the source (fake project_id → not an active project, mirrors the
// delete tail-guard fixture). LeadRouter still needs the donor for tail matching.
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => Carbon::tomorrow('Europe/Moscow')->toDateString(),
'project_id' => 987654, 'tenant_id' => $tenant->id, 'daily_limit' => 5,
'delivery_days_mask' => 127, 'regions' => '{}', 'signal_type' => 'call',
'signal_identifier' => $common, 'sms_senders' => null, 'sms_keyword' => null,
'expected_volume' => 5, 'delivered_count' => 0, 'created_at' => now(),
]);
$savePayloads = [];
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$savePayloads) {
$savePayloads[] = $request->data();
return Http::response(['status' => 'OK', 'message' => '', 'id' => (string) ($request->data()['id'] ?? 0)], 200);
},
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => []], 200),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
// Order turned OFF at the supplier (update id != 0, status=false) — stops future ordering.
$off = array_values(array_filter(
$savePayloads,
fn ($p) => (int) ($p['id'] ?? 0) !== 0 && ($p['status'] ?? null) === false
));
expect($off)->toHaveCount(1);
// The local donor record is KEPT (not deleted) so the snapshot tail still routes; marked inactive.
$fresh = SupplierProject::on('pgsql_supplier')->find($sp->id);
expect($fresh)->not->toBeNull();
expect($fresh->inactive_since)->not->toBeNull();
});
// ---------------------------------------------------------------------------
// Итоговая проверка: робот после прогона диспатчит VerifySupplierOrderJob
// ---------------------------------------------------------------------------
test('handle() dispatches VerifySupplierOrderJob after run (finally block)', function (): void {
Bus::fake([VerifySupplierOrderJob::class]);
// Минимальный сценарий: нет ни одного eligible-проекта (как в тесте time-budget) —
// групп нет, но finally всё равно должен отработать и запустить итоговую проверку.
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
Bus::assertDispatched(VerifySupplierOrderJob::class);
});
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
use App\Jobs\Supplier\VerifySupplierOrderJob;
use App\Mail\SupplierOrderMismatchMail;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\SupplierPortalClient;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
Carbon::setTestNow(Carbon::parse('2026-07-09 18:04:00', 'Europe/Moscow'));
});
afterEach(function (): void {
Carbon::setTestNow();
});
/**
* Активный call-проект daily_limit_target=3 задуманный заказ order=max(3, ceil(3/3))=3,
* поделённый на B1/B2/B3 (largest-remainder) по 1 на каждую площадку.
* Снимок на завтра ОБЯЗАТЕЛЕН collectEligibleProjects() читает project_routing_snapshots,
* не live projects (Task 2.9 slepok-инвариант).
*/
function seedActiveCallProject(string $identifier, int $limit): Project
{
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'signal_type' => 'call',
'signal_identifier' => $identifier,
'daily_limit_target' => $limit,
'delivery_days_mask' => 127,
'regions' => [],
]);
insertSnapshotForTomorrow($project, dailyLimit: $limit, deliveryDaysMask: 127);
return $project;
}
/**
* Наша supplier_project, которую мы уже выключили (inactive_since IS NOT NULL)
* SupplierOrderVerifier::diff должен поймать kind=should_be_off, если поставщик
* до сих пор показывает её включённой (status=1). supplier_external_id признак
* «наша строка» в normalizeLive() (баг 1: tag НЕ годится робот шлёт регион/«РФ»).
*/
function seedPausedSupplierProject(string $identifier, string $platform, string $signalType, string $externalId): SupplierProject
{
return SupplierProject::factory()->create([
'platform' => $platform,
'signal_type' => $signalType,
'unique_key' => $identifier,
'supplier_external_id' => $externalId,
'inactive_since' => now(),
]);
}
it('нет расхождений → письма нет, статус ok', function (): void {
Mail::fake();
seedActiveCallProject('79135397707', 3);
SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79135397707',
'supplier_external_id' => '1001', 'current_limit' => 1, 'inactive_since' => null,
]);
SupplierProject::factory()->create([
'platform' => 'B2', 'signal_type' => 'call', 'unique_key' => '79135397707',
'supplier_external_id' => '1002', 'current_limit' => 1, 'inactive_since' => null,
]);
SupplierProject::factory()->create([
'platform' => 'B3', 'signal_type' => 'call', 'unique_key' => '79135397707',
'supplier_external_id' => '1003', 'current_limit' => 1, 'inactive_since' => null,
]);
$this->mock(SupplierPortalClient::class, function ($m): void {
$m->shouldReceive('listProjects')->andReturn([
['id' => '1001', 'src' => 'rt', 'name' => 'B1_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
['id' => '1002', 'src' => 'bl', 'name' => 'B2_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
['id' => '1003', 'src' => 'mt', 'name' => 'B3_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
]);
});
(new VerifySupplierOrderJob(attempt: 2))->handle();
Mail::assertNothingQueued();
$row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first();
expect($row)->not->toBeNull();
expect($row->status)->toBe('ok')->and($row->mismatch_count)->toBe(0);
});
it('пауза не дошла (should_be_off) → письмо + статус mismatch на попытке 2', function (): void {
Mail::fake();
seedPausedSupplierProject('automoney.ru', 'B3', 'site', '2001');
$this->mock(SupplierPortalClient::class, function ($m): void {
$m->shouldReceive('listProjects')->andReturn([
['id' => '2001', 'src' => 'mt', 'name' => 'B3_automoney.ru', 'content' => 'automoney.ru', 'type' => 'hosts', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
]);
});
(new VerifySupplierOrderJob(attempt: 2))->handle();
Mail::assertQueued(SupplierOrderMismatchMail::class);
$row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first();
expect($row)->not->toBeNull();
expect($row->status)->toBe('mismatch')->and($row->mismatch_count)->toBe(1);
});
/*
* Прод-инцидент 1112.07.2026: письма «missing» по строкам, которые в кабинете ЕСТЬ,
* включены и с верным лимитом. Причина normalizeLive() выводил площадку из ПРЕФИКСА
* ИМЕНИ (B1_/B2_/B3_), а кабинет ставит этот префикс только при СОЗДАНИИ строки: наш
* ежедневный updateProject слал имя без префикса и затирал его. Строка без префикса
* выпадала из live ложное «нет заказа у поставщика».
*
* Площадку надо брать из служебного поля src (rt→B1, bl→B2, mt→B3) как уже делает
* saveProjectMultiFlag. Тогда сверка не зависит от имени вообще.
*/
it('строки с затёртым префиксом имени опознаются по src → НЕ ложная тревога (прод 11–12.07)', function (): void {
Mail::fake();
seedActiveCallProject('79135397707', 3);
foreach (['B1' => '4001', 'B2' => '4002', 'B3' => '4003'] as $platform => $ext) {
SupplierProject::factory()->create([
'platform' => $platform, 'signal_type' => 'call', 'unique_key' => '79135397707',
'supplier_external_id' => $ext, 'current_limit' => 1, 'inactive_since' => null,
]);
}
$this->mock(SupplierPortalClient::class, function ($m): void {
// Имя — голый ключ, БЕЗ префикса (ровно то, что отдаёт боевой кабинет 12.07).
$m->shouldReceive('listProjects')->andReturn([
['id' => '4001', 'src' => 'rt', 'name' => '79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
['id' => '4002', 'src' => 'bl', 'name' => '79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
['id' => '4003', 'src' => 'mt', 'name' => '79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
]);
});
(new VerifySupplierOrderJob(attempt: 2))->handle();
Mail::assertNothingQueued();
$row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first();
expect($row)->not->toBeNull();
expect($row->status)->toBe('ok')->and($row->mismatch_count)->toBe(0);
});
it('настоящее «нет заказа» по-прежнему ловится: строки в кабинете нет вовсе', function (): void {
Mail::fake();
seedActiveCallProject('79135397707', 3);
foreach (['B1' => '5001', 'B2' => '5002', 'B3' => '5003'] as $platform => $ext) {
SupplierProject::factory()->create([
'platform' => $platform, 'signal_type' => 'call', 'unique_key' => '79135397707',
'supplier_external_id' => $ext, 'current_limit' => 1, 'inactive_since' => null,
]);
}
// Кабинет отдал только B1 (rt) — B2/B3 реально отсутствуют.
$this->mock(SupplierPortalClient::class, function ($m): void {
$m->shouldReceive('listProjects')->andReturn([
['id' => '5001', 'src' => 'rt', 'name' => '79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
]);
});
(new VerifySupplierOrderJob(attempt: 2))->handle();
Mail::assertQueued(SupplierOrderMismatchMail::class);
$row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first();
expect($row->status)->toBe('mismatch')->and($row->mismatch_count)->toBe(2);
});
it('ключ активен по формуле, но inactive_since завис → НЕ ложная тревога (баг 2)', function (): void {
Mail::fake();
seedActiveCallProject('79999999999', 3);
// B1 «завис» с inactive_since, хотя группа сейчас активна по формуле —
// должен быть исключён из shouldBeOff, а не дать ложный should_be_off.
SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79999999999',
'supplier_external_id' => '3001', 'current_limit' => 1, 'inactive_since' => now(),
]);
SupplierProject::factory()->create([
'platform' => 'B2', 'signal_type' => 'call', 'unique_key' => '79999999999',
'supplier_external_id' => '3002', 'current_limit' => 1, 'inactive_since' => null,
]);
SupplierProject::factory()->create([
'platform' => 'B3', 'signal_type' => 'call', 'unique_key' => '79999999999',
'supplier_external_id' => '3003', 'current_limit' => 1, 'inactive_since' => null,
]);
$this->mock(SupplierPortalClient::class, function ($m): void {
$m->shouldReceive('listProjects')->andReturn([
['id' => '3001', 'src' => 'rt', 'name' => 'B1_79999999999', 'content' => '79999999999', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
['id' => '3002', 'src' => 'bl', 'name' => 'B2_79999999999', 'content' => '79999999999', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
['id' => '3003', 'src' => 'mt', 'name' => 'B3_79999999999', 'content' => '79999999999', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
]);
});
(new VerifySupplierOrderJob(attempt: 2))->handle();
Mail::assertNothingQueued();
$row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first();
expect($row)->not->toBeNull();
expect($row->status)->toBe('ok')->and($row->mismatch_count)->toBe(0);
});
@@ -143,6 +143,62 @@ describe('AdminPricingTiersView', () => {
});
});
describe('AdminPricingTiersView — клиентский диапазон (устранение путаницы admin↔кабинет)', () => {
beforeEach(() => {
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
});
it('в активной сетке рядом с шириной показывает накопительный диапазон, как у клиента', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// ширины 100,200 → ступень 2 у клиента = 101–300 (а не «200»)
const cell = wrapper.find('[data-testid="active-range-2"]');
expect(cell.exists()).toBe(true);
expect(cell.text()).toContain('101300');
});
it('ступень 7 в активной сетке — открытый диапазон со знаком +', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// 100+200+400+800+1500+3000 = 6000 → ступень 7 = 6001+
expect(wrapper.find('[data-testid="active-range-7"]').text()).toContain('6001+');
});
it('редактор показывает живой клиентский диапазон рядом с полем ширины', async () => {
const wrapper = mount(AdminPricingTiersView, {
global: {
plugins: [vuetify],
stubs: { VDialog: { template: '<div><slot /></div>' } },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(wrapper.vm as any).editorOpen = true;
await wrapper.vm.$nextTick();
// defaultEditor: ширины 100,200 → ступень 2 = 101300
const cell = wrapper.find('[data-testid="editor-range-2"]');
expect(cell.exists()).toBe(true);
expect(cell.text()).toContain('101300');
});
it('предпросмотр в редакторе пересчитывается при изменении ширины', async () => {
const wrapper = mount(AdminPricingTiersView, {
global: {
plugins: [vuetify],
stubs: { VDialog: { template: '<div><slot /></div>' } },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.editorOpen = true;
vm.editor[0].leads_in_tier = 250; // была 100 → ступень 2 сдвигается на 251–450
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="editor-range-2"]').text()).toContain('251450');
});
});
describe('AdminPricingTiersView error handling (Sprint 1 G1)', () => {
afterEach(() => {
vi.clearAllMocks();
+27
View File
@@ -4,6 +4,10 @@ import { createVuetify } from 'vuetify';
import DealsTable from '../../resources/js/components/deals/DealsTable.vue';
import type { MockDeal } from '../../resources/js/composables/mockDeals';
// ResponsiveTable показывает таблицу только на десктопе (>=1280); эти тесты
// проверяют табличный слой — фиксируем десктоп-ширину.
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 });
const vuetify = createVuetify();
const sampleDeals: MockDeal[] = [
@@ -128,6 +132,29 @@ describe('DealsTable', () => {
expect(links[1].props('to')).toMatchObject({ name: 'autopodbor', query: { competitor: '3', project: '5' } });
});
it('«Конкурент» — только бренд до первой запятой, без описания', () => {
const deal: MockDeal = {
id: 12,
name: '79000000002',
phone: '79000000002',
statusSlug: 'new',
project: 'X',
manager: { initials: '—', name: '—' },
cost: 0,
receivedMinutesAgo: 1,
competitorId: 4,
competitorName: 'AutoMoney, микрокредитная компания',
sourceIdentifier: '79135396546',
sourceSignalType: 'call',
sourceProjectId: 5,
sourceIsActive: true,
sourceExtraCount: 0,
};
const w = mountTable([deal]);
expect(w.text()).toContain('AutoMoney');
expect(w.text()).not.toContain('микрокредитная');
});
it('показывает « · +N» при нескольких источниках проекта', () => {
const deal: MockDeal = {
id: 11,
@@ -23,6 +23,11 @@ describe('ResponsiveTable', () => {
expect(w.find('[data-testid="as-card"]').exists()).toBe(true);
expect(w.find('[data-testid="as-table"]').exists()).toBe(false);
});
it('на планшете-портрете (768) показывает слот card, а не обрезанную таблицу', () => {
const w = mountAt(768);
expect(w.find('[data-testid="as-card"]').exists()).toBe(true);
expect(w.find('[data-testid="as-table"]').exists()).toBe(false);
});
it('на десктопе (>=1280) показывает слот table', () => {
const w = mountAt(1440);
expect(w.find('[data-testid="as-table"]').exists()).toBe(true);
@@ -7,6 +7,10 @@ import type { BillingTransaction, TransactionsPage } from '../../resources/js/ap
vi.mock('../../resources/js/api/billing');
// ResponsiveTable показывает таблицу только на десктопе (>=1280); эти тесты
// проверяют табличный слой — фиксируем десктоп-ширину.
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 });
const vuetify = createVuetify();
function txn(over: Partial<BillingTransaction> = {}): BillingTransaction {
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { tierRangeText } from '../../resources/js/utils/pricingTiers';
/**
* Единый источник истины для «ширина ступени → накопительный диапазон».
* Числа — ровно те, что клиент видит в кабинете (боевая сетка 08.07.2026):
* ширины 250/500/1000/2500/5000/10000/∞ → 1250, 251750, …, 19251+.
*/
const tiers = [
{ leads_in_tier: 250 },
{ leads_in_tier: 500 },
{ leads_in_tier: 1000 },
{ leads_in_tier: 2500 },
{ leads_in_tier: 5000 },
{ leads_in_tier: 10000 },
{ leads_in_tier: null },
];
describe('tierRangeText', () => {
it('первая ступень начинается с 1', () => {
expect(tierRangeText(tiers, 0)).toBe('1250');
});
it('вторая ступень — накопительный диапазон, а не «сама ширина»', () => {
expect(tierRangeText(tiers, 1)).toBe('251750');
});
it('третья ступень продолжает накопление', () => {
expect(tierRangeText(tiers, 2)).toBe('7511750');
});
it('шестая ступень', () => {
// 250+500+1000+2500 = 4250 → пятая 42519250 → шестая 925119250
expect(tierRangeText(tiers, 5)).toBe('925119250');
});
it('последняя ступень (leads_in_tier=null) — открытый диапазон со знаком +', () => {
// 250+500+1000+2500+5000+10000 = 19250 → tier 7 стартует с 19251
expect(tierRangeText(tiers, 6)).toBe('19251+');
});
});
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use App\Services\Autopodbor\Agent\Phone\DaDataValidityLayer;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
beforeEach(function (): void {
config(['services.dadata.daily_cap_rub' => 10000]); // бюджет открыт
});
it('qc=2 (мусор) → номер невалиден (выкидываем)', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 2]], 200)]);
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeFalse();
});
it('qc=7 (иностранец) → номер невалиден', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 7]], 200)]);
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeFalse();
});
it('qc=0 (годный) → валиден', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]);
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
});
it('qc=1 (неоднозначный) → оставляем (не режем по сомнению)', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 1]], 200)]);
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
});
it('бюджет исчерпан → не тратим и не режем, ДаДату не дёргаем', function (): void {
config(['services.dadata.daily_cap_rub' => 0]); // касса закрыта
Http::fake();
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
Http::assertNothingSent();
});
it('сбой ДаДаты (сеть) → не роняем и не режем', function (): void {
Http::fake(fn () => throw new ConnectionException('timeout'));
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
});
@@ -15,14 +15,13 @@ it('exposes all 6 audit tables', function (): void {
]);
});
it('activity_log uses PARTITION BY tenant_id', function (): void {
expect(AuditChainConfig::TABLES['activity_log']['partition'])
->toEqual('PARTITION BY tenant_id');
});
it('auth_log and saas_admin_audit_log use global chain (empty partition)', function (): void {
expect(AuditChainConfig::TABLES['auth_log']['partition'])->toEqual('');
expect(AuditChainConfig::TABLES['saas_admin_audit_log']['partition'])->toEqual('');
it('all 6 audit tables use global-within-partition chain (ADR-021, supersedes ADR-018)', function (): void {
// ADR-021: триггер де-факто пишет глобальную цепочку для ВСЕХ таблиц (проверено на
// проде 2026-07-09, включая balance_transactions). per-tenant scope ADR-018 никогда
// не был реализован → все partition-clause пустые (global-within-partition).
foreach (AuditChainConfig::TABLES as $table => $cfg) {
expect($cfg['partition'])->toEqual('', "table {$table} must use global chain");
}
});
it('rowExpression builds ROW(...) with NULL::bytea at __log_hash__ position', function (): void {
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Services\Supplier\SupplierOrderPlan;
use Carbon\Carbon;
it('строит intended по формуле: два клиента 3 и 2 на один call-источник → заказ 3, деление 1/1/1', function (): void {
$tomorrow = Carbon::parse('2026-07-13'); // понедельник
$mask = 1 << ($tomorrow->isoWeekday() - 1); // бит пн
$p1 = new Project(['signal_type' => 'call', 'signal_identifier' => '79135397707']);
$p1->daily_limit_target = 3;
$p1->delivery_days_mask = $mask;
$p1->regions = [];
$p2 = new Project(['signal_type' => 'call', 'signal_identifier' => '79135397707']);
$p2->daily_limit_target = 2;
$p2->delivery_days_mask = $mask;
$p2->regions = [];
$plan = SupplierOrderPlan::build(collect([$p1, $p2]), $tomorrow);
// order = max(3, ceil(5/3)=2) = 3; split B1/B2/B3 = 1/1/1
expect($plan['intended'])->toEqualCanonicalizing([
['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B1', 'limit' => 1],
['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B2', 'limit' => 1],
['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B3', 'limit' => 1],
]);
});
it('проект не eligible на завтра (нет бита дня) → не попадает в intended', function (): void {
$tomorrow = Carbon::parse('2026-07-13'); // пн
$notMondayMask = 1 << (Carbon::parse('2026-07-14')->isoWeekday() - 1); // вт
$p = new Project(['signal_type' => 'call', 'signal_identifier' => '7911']);
$p->daily_limit_target = 5;
$p->delivery_days_mask = $notMondayMask;
$p->regions = [];
$plan = SupplierOrderPlan::build(collect([$p]), $tomorrow);
expect($plan['intended'])->toBe([]);
});
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use App\Services\Supplier\SupplierOrderVerifier;
it('нет расхождений когда живое совпадает с задуманным', function (): void {
$intended = [
['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B1', 'limit' => 1],
];
$shouldBeOff = [];
$live = [
['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B1', 'limit' => 1, 'on' => true],
];
expect(SupplierOrderVerifier::diff($intended, $shouldBeOff, $live))->toBe([]);
});
it('ловит limit_drift', function (): void {
$intended = [['signal_type' => 'site', 'identifier' => 'inssmart.ru', 'platform' => 'B2', 'limit' => 2]];
$live = [['signal_type' => 'site', 'identifier' => 'inssmart.ru', 'platform' => 'B2', 'limit' => 5, 'on' => true]];
$m = SupplierOrderVerifier::diff($intended, [], $live);
expect($m)->toHaveCount(1)
->and($m[0]['kind'])->toBe('limit_drift')
->and($m[0]['expected'])->toBe(2)
->and($m[0]['actual'])->toBe(5);
});
it('ловит should_be_off (пауза не дошла = деньги)', function (): void {
$shouldBeOff = [['signal_type' => 'site', 'identifier' => 'automoney.ru', 'platform' => 'B3']];
$live = [['signal_type' => 'site', 'identifier' => 'automoney.ru', 'platform' => 'B3', 'limit' => 1, 'on' => true]];
$m = SupplierOrderVerifier::diff([], $shouldBeOff, $live);
expect($m)->toHaveCount(1)->and($m[0]['kind'])->toBe('should_be_off');
});
it('ловит should_be_on', function (): void {
$intended = [['signal_type' => 'call', 'identifier' => '7911', 'platform' => 'B1', 'limit' => 1]];
$live = [['signal_type' => 'call', 'identifier' => '7911', 'platform' => 'B1', 'limit' => 1, 'on' => false]];
$m = SupplierOrderVerifier::diff($intended, [], $live);
expect($m)->toHaveCount(1)->and($m[0]['kind'])->toBe('should_be_on');
});
it('ловит missing (задумали, у поставщика нет)', function (): void {
$intended = [['signal_type' => 'call', 'identifier' => '7911', 'platform' => 'B2', 'limit' => 1]];
$m = SupplierOrderVerifier::diff($intended, [], []);
expect($m)->toHaveCount(1)->and($m[0]['kind'])->toBe('missing');
});
it('ловит orphan_extra (у поставщика лишняя наша строка)', function (): void {
$live = [['signal_type' => 'site', 'identifier' => 'ghost.ru', 'platform' => 'B1', 'limit' => 3, 'on' => true]];
$m = SupplierOrderVerifier::diff([], [], $live);
expect($m)->toHaveCount(1)->and($m[0]['kind'])->toBe('orphan_extra');
});
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# Лидерра — ЗАЩИЩЁННЫЙ выкат фронт-сборки портала (public/build).
#
# ЗАЧЕМ. Портал выкатывается ПОЛНОЙ заменой папки public/build. Если собрать
# из УСТАРЕВШЕЙ ветки (где нет чужих свежих фич) и залить — чужая работа
# МОЛЧА затирается. Реальный инцидент 08.07.2026: выкат Яндекс.Метрики,
# собранный из ветки без колонок «Сделок», снёс колонки «Конкурент/Источник».
#
# ЧТО ДЕЛАЕТ. Перед подменой сверяет: новая сборка НЕ должна ПОТЕРЯТЬ ни одной
# критичной фичи, которая есть в живой сборке. Потеряла — ОТКАЗ, боевой не тронут.
# Осознанный откат (когда фичу убираем намеренно) — только с FORCE=1.
#
# ВАЖНО. Маркеры ищем ТОЛЬКО в чанках, на которые ссылается manifest.json
# (активная сборка), а не по всей assets/ — там копятся «осколки» старых
# overlay-выкатов, из-за которых наивная проверка промахивалась.
#
# ИСПОЛЬЗОВАНИЕ (с dev-машины):
# tar czf build.tgz -C app/public/build .
# scp build.tgz liderra:/tmp/build.tgz
# ssh liderra 'sudo bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "ветка/sha/что выкатываем"'
# # только проверить, не выкатывая:
# ssh liderra 'sudo DRY_RUN=1 bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "проверка"'
# # осознанный откат/удаление фичи:
# ssh liderra 'sudo FORCE=1 bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "rollback: ..."'
#
# Откат в любой момент: mv build.bak-guard-<ts> build (см. вывод скрипта).
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
TGZ="${1:?укажи путь к tar.gz со сборкой (содержимое папки build)}"
INFO="${2:-}"
FORCE="${FORCE:-}"
DRY_RUN="${DRY_RUN:-}"
BASE=/var/www/liderra/app/public
LIVE="$BASE/build"
# Критичные маркеры фич: если строка есть в ЖИВОЙ сборке — обязана быть и в новой.
# ➕ Добавляй строку сюда, когда выкатываешь новую пользовательскую фичу,
# чтобы её тоже нельзя было случайно затереть следующим выкатом.
MARKERS=(
"Конкурент" # колонки «Сделок»: Конкурент / Источник
"mc.yandex" # Яндекс.Метрика (счётчик + Вебвизор)
"ym-hide-content" # маскировка ПДн лидов для Метрики (152-ФЗ) — критично
"deal-card" # мобильные/планшетные карточки сделок
"autopodbor" # автоподбор конкурентов
)
echo "▶ Защищённый выкат сборки портала. Инфо: ${INFO:-}"
[ -f "$TGZ" ] || { echo "❌ Нет файла: $TGZ"; exit 2; }
STAGING="$BASE/.build-staging.$$"
rm -rf "$STAGING"; mkdir -p "$STAGING"
tar xzf "$TGZ" -C "$STAGING"
[ -f "$STAGING/manifest.json" ] || { echo "❌ В сборке нет manifest.json — это не билд портала."; rm -rf "$STAGING"; exit 2; }
# Список АКТИВНЫХ чанков (js/css) по manifest — без orphan-осколков прошлых выкатов.
active_files() { # $1 = dir сборки → печатает полные пути к активным чанкам
local dir="$1"
grep -oE 'assets/[A-Za-z0-9_.-]+\.(js|css)' "$dir/manifest.json" | sort -u | sed "s|^|$dir/|"
}
mapfile -t LIVE_FILES < <(active_files "$LIVE" 2>/dev/null || true)
mapfile -t NEW_FILES < <(active_files "$STAGING" 2>/dev/null || true)
hits() { # $1 = маркер, далее список файлов → сколько файлов содержат маркер
local m="$1"; shift
[ "$#" -eq 0 ] && { echo 0; return 0; }
# grep возвращает 1 при «не найдено»; при pipefail+set -e это рвёт скрипт —
# поэтому глушим код через "|| true", а число берём из вывода wc.
local c
c=$(grep -lF -- "$m" "$@" 2>/dev/null | wc -l) || true
echo "${c:-0}"
}
# ── ГЛАВНАЯ ЗАЩИТА: новая сборка не должна терять фичи живой ──────────────────
missing=()
for m in "${MARKERS[@]}"; do
live_n=$(hits "$m" "${LIVE_FILES[@]}")
new_n=$(hits "$m" "${NEW_FILES[@]}")
if [ "$live_n" -gt 0 ] && [ "$new_n" -eq 0 ]; then
missing+=("$m")
fi
done
if [ "${#missing[@]}" -gt 0 ] && [ "$FORCE" != "1" ]; then
echo ""
echo "🛑 ОТКАЗ: новая сборка ТЕРЯЕТ фичи, которые есть на боевом:"
printf ' • %s\n' "${missing[@]}"
echo ""
echo " Похоже, собрано из УСТАРЕВШЕЙ ветки (без чужой работы)."
echo " Пересобери из ветки-надмножества — где есть ВСЁ живое."
echo " Если это ОСОЗНАННЫЙ откат/удаление фичи — повтори с FORCE=1."
rm -rf "$STAGING"
exit 3
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "⚠ FORCE=1: осознанно теряем фичи (${missing[*]}) — по прямому указанию."
fi
if [ "$DRY_RUN" = "1" ]; then
echo "✅ DRY_RUN: проверка пройдена, все живые фичи на месте. Выкат НЕ выполнялся."
rm -rf "$STAGING"
exit 0
fi
# ── Подмена с бэкапом и отметкой о выкате ────────────────────────────────────
ts="$(date -u +%Y%m%d-%H%M%S)"
mv "$LIVE" "$BASE/build.bak-guard-$ts"
mv "$STAGING" "$LIVE"
appjs=$(grep -oE 'assets/app-[a-zA-Z0-9_-]+\.js' "$LIVE/manifest.json" | head -1)
printf '{"deployed_at":"%s","info":"%s","bundle":"%s","guard_ok":true}\n' \
"$ts" "${INFO//\"/}" "$appjs" > "$LIVE/DEPLOYED.json"
chown -R www-data:www-data "$LIVE"
echo "✅ Выкат ок. Бандл: $appjs. Бэкап прежней сборки: build.bak-guard-$ts"
@@ -0,0 +1,197 @@
# Дизайн: итоговая проверка заказа у поставщика + сторож времени
**Дата:** 2026-07-09
**Статус:** утверждён заказчиком (устно, «го»), готов к плану реализации
**Автор:** Claude Code (сессия расследования «почему нет поставки»)
---
## 1. Контекст и проблема
Робот `App\Jobs\Supplier\SyncSupplierProjectsJob` раз в сутки (18:05 МСК) переносит
активные проекты клиентов в кабинет поставщика `crm.lead.store` (заводит/обновляет
строки B1/B2/B3, гасит осиротевшие). «Успехом» он считает **HTTP 200 + `status:OK`**
на каждую команду — то есть «поставщик **принял** команду», а НЕ «поставщик **реально
применил** её у себя».
Три дыры, вскрытые в расследовании 09.07.2026:
1. **Нет итоговой сверки с живым кабинетом.** Дашбордовый `SupplyReconciliation`
сравнивает задуманное с **нашими же** записями `supplier_projects`, а не с живым
списком поставщика (`listProjects`). Расхождение «мы попросили — поставщик не сделал»
автоматически никто не ловит.
2. **Именно через это прошёл денежный баг 08.07.2026** (память
`project-omega-pause-not-sent-to-supplier-batch-2026-07-08`): команды «пауза» до
поставщика вообще не уходили (омега: 57 заказов, 0 команд) → проекты продолжали
собирать = деньги. Нашли **вручную**.
3. **Стоп-кран по времени молчит.** Константа `TIME_BUDGET_CUTOFF = '20:55'` обрывает
робота, но только пишет `Log::warning` + помечает запуск `aborted`. Заказчику
**письма нет**, для heartbeat-монитора обрыв выглядит как «успех» (исключения нет).
При росте числа клиентов последовательный прогон может **перескочить 21:00**
дедлайн поставщика (изменения после 21:00 вступают в силу только на следующие сутки;
22:00–00:00 правки запрещены) — и мы **не успеем заказать**.
## 2. Цель и объём
**В объёме:**
- **Компонент 1 — «Итоговая проверка»:** после того как робот отчитался «готово»,
перечитать живой кабинет поставщика и сверить **каждую нашу строку** с формулой;
при расхождении — письмо.
- **Компонент 2 — «Сторож времени»:** независимая проверка в 20:00 (🟡) и 20:40 (🔴);
если робот к этому времени не закончил — письмо.
**Вне объёма (отдельная задача заказчика, НЕ проектируем сейчас):**
- Распараллеливание прогона робота. Пороги 20:00/20:40 здесь — **только сигнал письмом**,
без авто-переключения на параллель.
## 3. Формула заказа (эталон сверки)
Источник истины — `App\Services\Supplier\SupplierQuotaAllocator` (чистые функции):
```
заказ_группы = max( наибольший daily_limit , ceil( Σ daily_limit / 3 ) )
```
- `ceil(Σ/3)` — ёмкость шаринга (лид продаётся ≤3 клиентам);
- `max` — крупнейший клиент должен добрать своё.
Далее `distributeForPlatform($order, $platforms)` делит заказ по B1/B2/B3 методом
largest-remainder так, что **Σ по площадкам == заказ** (площадки с долей 0 опускаются —
кабинет отклоняет `limit=0`).
«Проверить по формуле» = пересчитать `computeOrder` + `distributeForPlatform` из того же
источника, что и робот: слепок `project_routing_snapshots` за завтра + группировка
`App\Services\Supplier\SupplierProjectGrouping` (resolvePlatforms / buildUniqueKeyAgnostic).
## 4. Компонент 1 — «Итоговая проверка»
### 4.1. Запуск
Новый джоб `App\Jobs\Supplier\VerifySupplierOrderJob`. `SyncSupplierProjectsJob`
диспатчит его **в `finally`-блоке `handle()`** (после `recordRunSummary`) — чтобы
проверка шла всегда: и при штатном финише, и при обрыве по времени/ошибке.
Исключение: если робот упал по `SupplierAuthException` (кабинет недоступен) — проверка
тоже не сможет прочитать `listProjects`; в этом случае она пишет статус
`unable_to_verify` (без ложных «расхождений») и шлёт письмо «проверка не смогла
достучаться до кабинета».
### 4.2. Алгоритм
1. **Задуманное состояние.** По слепку за завтра + формуле построить карту:
`intended[signal_type|identifier][platform] = limit` для активных групп, плюс
множество наших `supplier_projects` (`tag=_lidpotok`), которые должны быть **выключены**
(осиротевшие / `inactive_since IS NOT NULL`).
2. **Живое состояние.** `SupplierPortalClient::listProjects()` → фильтр по нашим строкам
(метка `_lidpotok`). Из каждой строки берём: `name`/`content` (identifier), префикс
B1/B2/B3, `type` (hosts/calls/sms → site/call/sms), `lim`, `status`.
3. **Сверка по строкам** и сбор расхождений `mismatches[]`:
- **missing** — задумали активную строку (platform+limit), у поставщика её нет;
- **limit_drift** — строка есть, но `lim ≠ intended`;
- **should_be_off** — наша строка помечена выключенной, а у поставщика `status`=ВКЛ;
- **should_be_on** — активная по формуле, а у поставщика ВЫКЛ;
- **orphan_extra** — у поставщика есть наша `_lidpotok`-строка, которой в задуманном нет.
4. **Защита от ложной тревоги (лаг применения).** Если `mismatches` не пусто — подождать
~60–90 сек и перечитать `listProjects` ещё раз; в письмо идут только расхождения,
которые **остались** после повторного чтения.
5. **Итог.** Записать строку в `supplier_order_checks` (см. §6). Если остались расхождения
→ письмо `SupplierOrderMismatchMail` со списком (identifier / платформа / задумали X /
у поставщика Y / тип расхождения).
### 4.3. Переиспользование
Логику «построить intended-карту из слепка» вынести в чистый сервис
`App\Services\Supplier\SupplierOrderPlan` — им пользуются и робот (косвенно, через ту же
формулу) и проверка. Саму сверку «intended vs live → mismatches[]» вынести в чистый
`App\Services\Supplier\SupplierOrderVerifier::diff($intended, $liveRows): array`
тестируется изолированно, без сети и БД.
## 5. Компонент 2 — «Сторож времени»
Artisan-команда `supplier:deadline-watch {level : yellow|red}`.
Расписание (`routes/console.php`), время МСК:
```
Schedule::command('supplier:deadline-watch yellow')->dailyAt('20:00')…
Schedule::command('supplier:deadline-watch red')->dailyAt('20:40')…
```
Логика:
1. Взять последнюю строку `supplier_sync_runs` за **сегодня** (МСК).
2. **Закончил** = строка есть и `finished_at IS NOT NULL` со `status != 'aborted'`.
3. Если НЕ закончил (нет строки / нет finished_at / обрыв) → письмо:
- `yellow``SupplierDeadlineWarningMail(level: yellow)` «к 20:00 робот не закончил,
до 21:00 меньше часа»;
- `red``SupplierDeadlineWarningMail(level: red)` «к 20:40 не закончил, до 21:00 ~20
минут, риск не успеть».
4. Если закончил → тихо (в норме робот финиширует за секунды в 18:05).
Побочный плюс: ловит и «планировщик не запустил робота» (строки за сегодня нет вовсе).
## 6. Хранение истории
Новая маленькая таблица `supplier_order_checks` (миграция):
| колонка | смысл |
|---|---|
| `id` | PK |
| `checked_at` | когда проверяли |
| `sync_run_id` | ссылка на `supplier_sync_runs` (nullable) |
| `intended_rows` | сколько строк задумано |
| `live_rows` | сколько наших строк у поставщика |
| `mismatch_count` | число расхождений после повторной проверки |
| `status` | `ok` / `mismatch` / `unable_to_verify` |
| `details` | JSONB со списком расхождений (для письма и разбора) |
| `created_at` | |
## 7. Письма
Три новых mailable, канал — существующий адрес критических писем поставщика
(`config('services.supplier.alert_email')` = `kdv1@bk.ru` + `ops@liderra.ru`):
- `SupplierOrderMismatchMail` — список расхождений после итоговой проверки.
- `SupplierDeadlineWarningMail` (yellow/red) — робот не успевает к порогу.
- «unable_to_verify» — переиспользовать `SupplierDeadlineWarningMail` с отдельным поводом
или отдельный mailable (решить на этапе плана; не блокер).
Тексты — простым русским, с конкретикой (сколько строк разошлось, до дедлайна X минут).
## 8. Обработка ошибок / крайние случаи
- **Кабинет недоступен при проверке** → `unable_to_verify`, письмо, НЕ ложные расхождения.
- **Лаг применения у поставщика** → повторное чтение через ~60–90 сек (§4.2 п.4).
- **Обрыв робота по 20:55** → сторож уже написал 🔴 в 20:40; дополнительно (мелкое
упрочнение, опционально) — заставить сам time-budget-обрыв слать письмо (сейчас молчит).
- **Гонка «проверка запустилась, а FlushDeferredOnlineSyncJob что-то дописал»** — проверка
сверяет состояние на момент чтения; расхождения из-за отложенных онлайн-правок в окне
18:00→00:00 — ожидаемы, поэтому проверку привязываем к завершению именно
`SyncSupplierProjectsJob`, а не к произвольному моменту.
## 9. Тестирование (Pest)
- **Unit** `SupplierOrderVerifier::diff` — таблица случаев: совпадение, limit_drift,
should_be_off, should_be_on, missing, orphan_extra.
- **Unit** `SupplierOrderPlan` — из фикстуры слепка строит корректную intended-карту
(в т.ч. деление по площадкам, «Вся РФ», дни недели).
- **Feature** `VerifySupplierOrderJob`с подставным `SupplierPortalClient` (Fake):
при расхождении шлёт письмо (`Mail::fake`), при совпадении — нет; повторное чтение
гасит транзиентное расхождение.
- **Feature** `supplier:deadline-watch` — есть завершённый прогон сегодня → тихо;
нет / не завершён / aborted → шлёт письмо нужного уровня.
## 10. Выкат
- Миграция `supplier_order_checks` — на **живой Managed-кластер** (не мёртвая VM-копия),
из `main`, через `crm_migrator`.
- Два новых расписания + диспатч проверки из робота.
- Только с явного «выкатываем» заказчика; предварительно — `prod-deploy-validator`
(GO/NO-GO) и `rls-reviewer` при правке схемы.
## 11. Открытые вопросы
Нет блокирующих. Мелочи (отдельный mailable для `unable_to_verify` vs переиспользование;
слать ли письмо на сам 20:55-обрыв) — решаются на этапе плана, дизайн не меняют.