From b28f85841915cd176b44dc0fff3e7e494dd5cefa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 12 Jul 2026 19:14:22 +0300 Subject: [PATCH] =?UTF-8?q?fix(supplier):=20=D0=BC=D0=B5=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=B0=20B1=5F/B2=5F/B3=5F=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=81=D1=82=D0=B8=D1=80=D0=B0=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BF=D1=80=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прод-инцидент 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) --- .gitleaks.toml | 45 +- .lychee.toml | 4 + .../RepairSupplierProjectNamesCommand.php | 100 ++ .../Commands/SupplierDeadlineWatchCommand.php | 52 + .../Jobs/Supplier/SyncSupplierProjectsJob.php | 13 +- .../Jobs/Supplier/VerifySupplierOrderJob.php | 176 +++ app/app/Mail/SupplierDeadlineWarningMail.php | 44 + app/app/Mail/SupplierOrderMismatchMail.php | 41 + .../Services/Supplier/SupplierOrderPlan.php | 74 ++ .../Supplier/SupplierOrderVerifier.php | 91 ++ .../Supplier/SupplierPortalClient.php | 66 +- ...09_190000_create_supplier_order_checks.php | 63 + app/phpstan-baseline.neon | 12 + .../supplier_deadline_warning_text.blade.php | 8 + .../supplier_order_mismatch_text.blade.php | 12 + app/routes/console.php | 14 + .../Feature/Supplier/CsvReconcileJobTest.php | 348 +++--- .../SupplierDeadlineWatchCommandTest.php | 47 + .../SupplierPortalClientRtProjectTest.php | 66 + .../SupplierRepairProjectNamesCommandTest.php | 128 ++ .../Supplier/SyncSupplierProjectsJobTest.php | 19 + .../Supplier/VerifySupplierOrderJobTest.php | 214 ++++ .../Unit/Supplier/SupplierOrderPlanTest.php | 45 + .../Supplier/SupplierOrderVerifierTest.php | 63 + ...r-order-verification-and-deadline-watch.md | 1086 +++++++++++++++++ ...-verification-and-deadline-watch-design.md | 197 +++ 26 files changed, 2821 insertions(+), 207 deletions(-) create mode 100644 app/app/Console/Commands/RepairSupplierProjectNamesCommand.php create mode 100644 app/app/Console/Commands/SupplierDeadlineWatchCommand.php create mode 100644 app/app/Jobs/Supplier/VerifySupplierOrderJob.php create mode 100644 app/app/Mail/SupplierDeadlineWarningMail.php create mode 100644 app/app/Mail/SupplierOrderMismatchMail.php create mode 100644 app/app/Services/Supplier/SupplierOrderPlan.php create mode 100644 app/app/Services/Supplier/SupplierOrderVerifier.php create mode 100644 app/database/migrations/2026_07_09_190000_create_supplier_order_checks.php create mode 100644 app/resources/views/emails/supplier_deadline_warning_text.blade.php create mode 100644 app/resources/views/emails/supplier_order_mismatch_text.blade.php create mode 100644 app/tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php create mode 100644 app/tests/Feature/Supplier/SupplierRepairProjectNamesCommandTest.php create mode 100644 app/tests/Feature/Supplier/VerifySupplierOrderJobTest.php create mode 100644 app/tests/Unit/Supplier/SupplierOrderPlanTest.php create mode 100644 app/tests/Unit/Supplier/SupplierOrderVerifierTest.php create mode 100644 docs/superpowers/plans/2026-07-09-supplier-order-verification-and-deadline-watch.md create mode 100644 docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md diff --git a/.gitleaks.toml b/.gitleaks.toml index c2707987..4d76e3b8 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -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''' ] diff --git a/.lychee.toml b/.lychee.toml index 5698e21b..23432e33 100644 --- a/.lychee.toml +++ b/.lychee.toml @@ -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/", diff --git a/app/app/Console/Commands/RepairSupplierProjectNamesCommand.php b/app/app/Console/Commands/RepairSupplierProjectNamesCommand.php new file mode 100644 index 00000000..75103fe1 --- /dev/null +++ b/app/app/Console/Commands/RepairSupplierProjectNamesCommand.php @@ -0,0 +1,100 @@ +_» к имени строки только при её + * СОЗДАНИИ; при обновлении он сохраняет имя ровно как прислали. Наш ночной робот слал + * голый ключ → каждый прогон стирал метку. Последствия: (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 $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; + } +} diff --git a/app/app/Console/Commands/SupplierDeadlineWatchCommand.php b/app/app/Console/Commands/SupplierDeadlineWatchCommand.php new file mode 100644 index 00000000..48afc515 --- /dev/null +++ b/app/app/Console/Commands/SupplierDeadlineWatchCommand.php @@ -0,0 +1,52 @@ +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; + } +} diff --git a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php index af425a12..ed0a4727 100644 --- a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php +++ b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php @@ -204,7 +204,7 @@ class SyncSupplierProjectsJob implements ShouldQueue $this->deactivateOrphanedOrders($activeKeys); } } finally { - $this->recordRunSummary( + $runId = $this->recordRunSummary( startedAt: $startedAt, groupsTotal: count($groups), syncedOk: $syncedOk, @@ -213,6 +213,11 @@ class SyncSupplierProjectsJob implements ShouldQueue failed: $failed, aborted: $aborted, ); + + // Итоговая проверка: после «готово» перечитать живой кабинет и сверить. + // Идёт всегда — и при штатном финише, и при обрыве. При auth-сбое (кабинет + // недоступен) сам джоб зафиксирует unable_to_verify. + VerifySupplierOrderJob::dispatch(1, $runId); } } @@ -325,7 +330,7 @@ class SyncSupplierProjectsJob implements ShouldQueue int $deferred, int $failed, bool $aborted, - ): void { + ): int { if ($aborted) { $status = 'aborted'; } elseif ($failed > 0 && $syncedOk === 0) { @@ -336,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, @@ -347,6 +352,8 @@ class SyncSupplierProjectsJob implements ShouldQueue 'status' => $status, 'created_at' => now(), ]); + + return $id; } /** diff --git a/app/app/Jobs/Supplier/VerifySupplierOrderJob.php b/app/app/Jobs/Supplier/VerifySupplierOrderJob.php new file mode 100644 index 00000000..8d39d6a8 --- /dev/null +++ b/app/app/Jobs/Supplier/VerifySupplierOrderJob.php @@ -0,0 +1,176 @@ +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_» портал ставит + * только при создании строки и теряет при обновлении (прод-инцидент 11–12.07.2026 — + * строки в кабинете были, но проверка объявляла их missing). `src` — то же поле, + * по которому матчит saveProjectMultiFlag; оно от имени не зависит. + * + * @param array> $rows + * @param list $ourExternalIds + * @return list + */ + 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{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(), + ]); + } +} diff --git a/app/app/Mail/SupplierDeadlineWarningMail.php b/app/app/Mail/SupplierDeadlineWarningMail.php new file mode 100644 index 00000000..ff073025 --- /dev/null +++ b/app/app/Mail/SupplierDeadlineWarningMail.php @@ -0,0 +1,44 @@ +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(), + ], + ); + } +} diff --git a/app/app/Mail/SupplierOrderMismatchMail.php b/app/app/Mail/SupplierOrderMismatchMail.php new file mode 100644 index 00000000..4b42c168 --- /dev/null +++ b/app/app/Mail/SupplierOrderMismatchMail.php @@ -0,0 +1,41 @@ + $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(), + ], + ); + } +} diff --git a/app/app/Services/Supplier/SupplierOrderPlan.php b/app/app/Services/Supplier/SupplierOrderPlan.php new file mode 100644 index 00000000..6cc341df --- /dev/null +++ b/app/app/Services/Supplier/SupplierOrderPlan.php @@ -0,0 +1,74 @@ + list], где 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 $eligibleProjects проекты со slepok-полями (daily_limit_target/delivery_days_mask/regions) + * @return array{intended: list} + */ + public static function build(Collection $eligibleProjects, Carbon $targetDate): array + { + $targetWeekday = $targetDate->copy()->timezone('Europe/Moscow')->isoWeekday(); + + // Группировка (signal_type|identifier) — как в SyncSupplierProjectsJob. + /** @var array, limits:list}> $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]; + } +} diff --git a/app/app/Services/Supplier/SupplierOrderVerifier.php b/app/app/Services/Supplier/SupplierOrderVerifier.php new file mode 100644 index 00000000..5a459c1e --- /dev/null +++ b/app/app/Services/Supplier/SupplierOrderVerifier.php @@ -0,0 +1,91 @@ + $intended + * @param list $shouldBeOff + * @param list $live + * @return list + */ + 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, + ]; + } +} diff --git a/app/app/Services/Supplier/SupplierPortalClient.php b/app/app/Services/Supplier/SupplierPortalClient.php index 32178eed..862abbd2 100644 --- a/app/app/Services/Supplier/SupplierPortalClient.php +++ b/app/app/Services/Supplier/SupplierPortalClient.php @@ -204,6 +204,51 @@ class SupplierPortalClient return $out; } + /** + * Разовая починка имени строки в кабинете (прод-инцидент 11–12.07.2026): отдаём живую + * строку обратно с исправленным `name`. Кроме имени не меняем НИЧЕГО — лимит, дни, + * регионы и вкл/выкл берём из самого кабинета. Регионы там уже в кодах поставщика, + * второй раз переводить их через SupplierRegions нельзя. + * + * @param array $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( @@ -507,9 +552,17 @@ class SupplierPortalClient * один rt-проект (множественные флаги создают N проектов, мы привязываемся * к одному external_id). * - signalType: site → type:"hosts"; call → type:"calls"; sms → type:"sms". - * - uniqueKey → одновременно `name` (label проекта на портале — портал - * префиксует "B_" автоматически) и `content` (домен/телефон в полях - * сбора). + * - uniqueKey → одновременно `name` (label проекта на портале) и `content` + * (домен/телефон в полях сбора). + * - `name` и метка канала «B_» (прод-инцидент 11–12.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. @@ -534,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, diff --git a/app/database/migrations/2026_07_09_190000_create_supplier_order_checks.php b/app/database/migrations/2026_07_09_190000_create_supplier_order_checks.php new file mode 100644 index 00000000..f4cbacbe --- /dev/null +++ b/app/database/migrations/2026_07_09_190000_create_supplier_order_checks.php @@ -0,0 +1,63 @@ +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(<<statement('DROP TABLE IF EXISTS supplier_order_checks CASCADE'); + } +}; diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 66ab2d3d..10cdde8a 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -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 diff --git a/app/resources/views/emails/supplier_deadline_warning_text.blade.php b/app/resources/views/emails/supplier_deadline_warning_text.blade.php new file mode 100644 index 00000000..5fe2de84 --- /dev/null +++ b/app/resources/views/emails/supplier_deadline_warning_text.blade.php @@ -0,0 +1,8 @@ +@if ($level === 'red') +🔴 КРАСНЫЙ. Робот-заказчик к 20:40 МСК ещё не закончил. До дедлайна поставщика (21:00) ~20 минут — есть риск не успеть заказать на завтра. +@else +🟡 ЖЁЛТЫЙ. Робот-заказчик к 20:00 МСК ещё не закончил. До дедлайна поставщика (21:00) меньше часа — следите. +@endif + +Причина: {{ $reason }} +Время письма: {{ $now }} МСК. diff --git a/app/resources/views/emails/supplier_order_mismatch_text.blade.php b/app/resources/views/emails/supplier_order_mismatch_text.blade.php new file mode 100644 index 00000000..a6abebf8 --- /dev/null +++ b/app/resources/views/emails/supplier_order_mismatch_text.blade.php @@ -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 — у поставщика лишняя наша строка. diff --git a/app/routes/console.php b/app/routes/console.php index 50c45482..c7217c23 100644 --- a/app/routes/console.php +++ b/app/routes/console.php @@ -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') diff --git a/app/tests/Feature/Supplier/CsvReconcileJobTest.php b/app/tests/Feature/Supplier/CsvReconcileJobTest.php index e5df371e..cbf908a6 100644 --- a/app/tests/Feature/Supplier/CsvReconcileJobTest.php +++ b/app/tests/Feature/Supplier/CsvReconcileJobTest.php @@ -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 $rows + * @param list $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}_ + телефон. + * + * @param list $leads + */ +function deliveredHtml(array $leads): string +{ + $rows = ''; + foreach ($leads as $l) { + $rows .= '' + .'' + .'Открыть '.$l['project'].' РФ'.$l['phone'].' -' + .''; } - // 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 ''.$rows.'
'; +} - 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; }); diff --git a/app/tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php b/app/tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php new file mode 100644 index 00000000..752fa6f7 --- /dev/null +++ b/app/tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php @@ -0,0 +1,47 @@ +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'); +}); diff --git a/app/tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php b/app/tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php index 6ad7e14f..e52abf2a 100644 --- a/app/tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php +++ b/app/tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php @@ -137,6 +137,72 @@ it('updateProject POSTs to /admin/visit/rt-project-save with id:N (same endpoint }); }); +/* + * Прод-инцидент 11–12.07.2026. Кабинет дописывает метку канала «B_» к имени только + * при СОЗДАНИИ строки; при обновлении он сохраняет имя ровно как прислали. Мы слали + * голый uniqueKey → каждый ночной прогон стирал метку. Последствия: (1) итоговая + * проверка переставала узнавать строку → ложное «нет заказа»; (2) поставщик присылал + * лид с project=«79135397707» без метки → webhook не мог определить канал и писал + * platform=DIRECT вместо B1/B2/B3 (потеря атрибуции канала). + * + * Поэтому при ОБНОВЛЕНИИ имя уходит уже с меткой; при СОЗДАНИИ — голым (там метку + * ставит сам кабинет, и один save с тремя флагами рождает три строки — единого + * префикса для них быть не может). + */ +it('updateProject sends name WITH the B_ 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:""}', function (): void { Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response( diff --git a/app/tests/Feature/Supplier/SupplierRepairProjectNamesCommandTest.php b/app/tests/Feature/Supplier/SupplierRepairProjectNamesCommandTest.php new file mode 100644 index 00000000..0bb3e89f --- /dev/null +++ b/app/tests/Feature/Supplier/SupplierRepairProjectNamesCommandTest.php @@ -0,0 +1,128 @@ +_». Строка без метки → лид от неё + * приходит с 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> $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); +}); diff --git a/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php b/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php index 6b91533d..73f3b04e 100644 --- a/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php +++ b/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php @@ -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(); @@ -687,3 +692,17 @@ test('nightly 18:00: a deleted project leftover order is turned OFF but KEPT in 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); +}); diff --git a/app/tests/Feature/Supplier/VerifySupplierOrderJobTest.php b/app/tests/Feature/Supplier/VerifySupplierOrderJobTest.php new file mode 100644 index 00000000..a47787ce --- /dev/null +++ b/app/tests/Feature/Supplier/VerifySupplierOrderJobTest.php @@ -0,0 +1,214 @@ +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); +}); + +/* + * Прод-инцидент 11–12.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); +}); diff --git a/app/tests/Unit/Supplier/SupplierOrderPlanTest.php b/app/tests/Unit/Supplier/SupplierOrderPlanTest.php new file mode 100644 index 00000000..54f4d35c --- /dev/null +++ b/app/tests/Unit/Supplier/SupplierOrderPlanTest.php @@ -0,0 +1,45 @@ +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([]); +}); diff --git a/app/tests/Unit/Supplier/SupplierOrderVerifierTest.php b/app/tests/Unit/Supplier/SupplierOrderVerifierTest.php new file mode 100644 index 00000000..9b5666eb --- /dev/null +++ b/app/tests/Unit/Supplier/SupplierOrderVerifierTest.php @@ -0,0 +1,63 @@ + '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'); +}); diff --git a/docs/superpowers/plans/2026-07-09-supplier-order-verification-and-deadline-watch.md b/docs/superpowers/plans/2026-07-09-supplier-order-verification-and-deadline-watch.md new file mode 100644 index 00000000..8c208e56 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-supplier-order-verification-and-deadline-watch.md @@ -0,0 +1,1086 @@ +# План: итоговая проверка заказа у поставщика + сторож времени + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** После ночного прогона робота сверять живой кабинет поставщика с заказом по формуле (письмо при расхождении) и отдельным сторожем предупреждать письмом, если робот к 20:00/20:40 не закончил. + +**Architecture:** Два независимых компонента. (1) `VerifySupplierOrderJob` диспатчится из `SyncSupplierProjectsJob` в `finally`; строит «задуманное» из тех же eligible-проектов через `SupplierOrderPlan`, читает живой кабинет `SupplierPortalClient::listProjects()`, сверяет чистой функцией `SupplierOrderVerifier::diff()`, при расхождении перепроверяет через 90 сек, пишет `supplier_order_checks` и шлёт письмо. (2) `supplier:deadline-watch` — artisan-команда на 20:00/20:40 МСК, смотрит `supplier_sync_runs` за сегодня; не закончил → письмо. + +**Tech Stack:** PHP 8.3, Laravel 13, PostgreSQL 16, Pest 4, очередь через Redis. Соединение к нашей БД для supplier-домена — `pgsql_supplier` (BYPASSRLS). + +**Спека:** [docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md](../specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md) + +--- + +## Файловая карта + +- Create `app/database/migrations/2026_07_09_190000_create_supplier_order_checks.php` — таблица истории проверок. +- Create `app/app/Services/Supplier/SupplierOrderVerifier.php` — чистая функция `diff()`. +- Create `app/app/Services/Supplier/SupplierOrderPlan.php` — «задуманное» из eligible-проектов. +- Create `app/app/Jobs/Supplier/VerifySupplierOrderJob.php` — оркестратор проверки. +- Create `app/app/Console/Commands/SupplierDeadlineWatchCommand.php` — сторож времени. +- Create `app/app/Mail/SupplierOrderMismatchMail.php` (+ `app/resources/views/emails/supplier_order_mismatch_text.blade.php`). +- Create `app/app/Mail/SupplierDeadlineWarningMail.php` (+ `app/resources/views/emails/supplier_deadline_warning_text.blade.php`). +- Modify `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` — диспатч `VerifySupplierOrderJob` в `finally`. +- Modify `app/routes/console.php` — два расписания сторожа. +- Tests: `app/tests/Unit/Supplier/SupplierOrderVerifierTest.php`, `app/tests/Unit/Supplier/SupplierOrderPlanTest.php`, `app/tests/Feature/Supplier/VerifySupplierOrderJobTest.php`, `app/tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php`. + +Порядок задач: сначала чистые функции (2, 3), потом инфраструктура (1, письма 6), потом джоб (5) и провод (7), потом сторож (8). + +--- + +## Task 1: Миграция `supplier_order_checks` + +**Files:** +- Create: `app/database/migrations/2026_07_09_190000_create_supplier_order_checks.php` + +- [ ] **Step 1: Написать миграцию** + +```php +bigIncrements('id'); + $table->timestampTz('checked_at'); + $table->unsignedBigInteger('sync_run_id')->nullable(); + $table->integer('intended_rows')->default(0); + $table->integer('live_rows')->default(0); + $table->integer('mismatch_count')->default(0); + // ok | mismatch | unable_to_verify + $table->string('status', 32); + $table->jsonb('details')->nullable(); + $table->timestampTz('created_at')->useCurrent(); + + $table->index('checked_at'); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('supplier_order_checks'); + } +}; +``` + +- [ ] **Step 2: Записать в CHANGELOG схемы** + +Добавить строку в `db/CHANGELOG_schema.md` (правило §4.2): дата 2026-07-09, «+ таблица `supplier_order_checks` (история итоговой проверки заказа у поставщика), не партиционируется, не содержит ПДн». + +- [ ] **Step 3: Применить миграцию на тестовую БД и проверить** + +Тесты в проекте идут на соединении `pgsql` + БД `liderra_testing` (см. `app/phpunit.xml`), +миграции подхватываются `RefreshDatabase`. Отдельного соединения `pgsql_testing` НЕТ. +Проверка миграции — применить её на тестовую БД (creds берутся из локального `.env`): + +Run: `cd app && DB_DATABASE=liderra_testing php artisan migrate` +Expected: `Migrated: 2026_07_09_190000_create_supplier_order_checks`. + +- [ ] **Step 4: Commit** + +```bash +git add app/database/migrations/2026_07_09_190000_create_supplier_order_checks.php db/CHANGELOG_schema.md +git commit -m "feat(supplier): таблица supplier_order_checks — история итоговой проверки заказа" +``` + +--- + +## Task 2: `SupplierOrderVerifier::diff()` — чистая сверка + +Тип строки «задуманного»: `intended` = список `['signal_type','identifier','platform','limit']` (только активные, limit≥1). «Выключенные» передаём отдельным списком ключей `['signal_type','identifier','platform']`, которые ДОЛЖНЫ быть off. «Живое» = список `['signal_type','identifier','platform','limit','on']` (`on` = bool, статус у поставщика). + +**Files:** +- Create: `app/app/Services/Supplier/SupplierOrderVerifier.php` +- Test: `app/tests/Unit/Supplier/SupplierOrderVerifierTest.php` + +- [ ] **Step 1: Написать падающий тест** + +```php + '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'); +}); +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierOrderVerifierTest.php` +Expected: FAIL «Class SupplierOrderVerifier not found». + +- [ ] **Step 3: Реализовать** + +```php + $intended + * @param list $shouldBeOff + * @param list $live + * @return list + */ + 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, + ]; + } +} +``` + +- [ ] **Step 4: Запустить — зелёные** + +Run: `cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierOrderVerifierTest.php` +Expected: PASS (6 тестов). + +- [ ] **Step 5: Commit** + +```bash +git add app/app/Services/Supplier/SupplierOrderVerifier.php app/tests/Unit/Supplier/SupplierOrderVerifierTest.php +git commit -m "feat(supplier): SupplierOrderVerifier::diff — чистая сверка заказа с живым кабинетом" +``` + +--- + +## Task 3: `SupplierOrderPlan::build()` — «задуманное» по формуле + +Строит из eligible-проектов карту «что должно стоять активным» + список «что должно быть выключено». Использует те же публичные хелперы, что и робот: `SupplierProjectGrouping::{resolvePlatforms,buildUniqueKeyAgnostic}` и `SupplierQuotaAllocator::{computeOrder,distributeForPlatform}`. + +**Files:** +- Create: `app/app/Services/Supplier/SupplierOrderPlan.php` +- Test: `app/tests/Unit/Supplier/SupplierOrderPlanTest.php` + +- [ ] **Step 1: Написать падающий тест** + +```php +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([]); +}); +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierOrderPlanTest.php` +Expected: FAIL «Class SupplierOrderPlan not found». + +- [ ] **Step 3: Реализовать** + +```php + list], где 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 $eligibleProjects проекты со slepok-полями (daily_limit_target/delivery_days_mask/regions) + * @return array{intended: list} + */ + public static function build(Collection $eligibleProjects, Carbon $targetDate): array + { + $targetWeekday = $targetDate->copy()->timezone('Europe/Moscow')->isoWeekday(); + + // Группировка (signal_type|identifier) — как в SyncSupplierProjectsJob. + /** @var array, limits:list}> $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]; + } +} +``` + +- [ ] **Step 4: Запустить — зелёные** + +Run: `cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierOrderPlanTest.php` +Expected: PASS (2 теста). + +- [ ] **Step 5: Commit** + +```bash +git add app/app/Services/Supplier/SupplierOrderPlan.php app/tests/Unit/Supplier/SupplierOrderPlanTest.php +git commit -m "feat(supplier): SupplierOrderPlan::build — задуманный заказ по формуле" +``` + +--- + +## Task 4: Письма (mailables + шаблоны) + +**Files:** +- Create: `app/app/Mail/SupplierOrderMismatchMail.php` +- Create: `app/resources/views/emails/supplier_order_mismatch_text.blade.php` +- Create: `app/app/Mail/SupplierDeadlineWarningMail.php` +- Create: `app/resources/views/emails/supplier_deadline_warning_text.blade.php` + +- [ ] **Step 1: `SupplierOrderMismatchMail`** + +```php + $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(), + ], + ); + } +} +``` + +- [ ] **Step 2: Шаблон `supplier_order_mismatch_text.blade.php`** + +```blade +Итоговая проверка заказа у поставщика ({{ $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 — у поставщика лишняя наша строка. +``` + +- [ ] **Step 3: `SupplierDeadlineWarningMail`** + +```php +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(), + ], + ); + } +} +``` + +- [ ] **Step 4: Шаблон `supplier_deadline_warning_text.blade.php`** + +```blade +@if ($level === 'red') +🔴 КРАСНЫЙ. Робот-заказчик к 20:40 МСК ещё не закончил. До дедлайна поставщика (21:00) ~20 минут — есть риск не успеть заказать на завтра. +@else +🟡 ЖЁЛТЫЙ. Робот-заказчик к 20:00 МСК ещё не закончил. До дедлайна поставщика (21:00) меньше часа — следите. +@endif + +Причина: {{ $reason }} +Время письма: {{ $now }} МСК. +``` + +- [ ] **Step 5: Проверить рендер (быстрый smoke через tinker)** + +Run: `cd app && php artisan tinker --execute="(new App\Mail\SupplierDeadlineWarningMail('red','нет finished_at за сегодня'))->render(); echo 'RENDER_OK';"` +Expected: `RENDER_OK` без исключений. + +- [ ] **Step 6: Commit** + +```bash +git add app/app/Mail/SupplierOrderMismatchMail.php app/app/Mail/SupplierDeadlineWarningMail.php app/resources/views/emails/supplier_order_mismatch_text.blade.php app/resources/views/emails/supplier_deadline_warning_text.blade.php +git commit -m "feat(supplier): письма о расхождении заказа и о срыве дедлайна" +``` + +--- + +## Task 5: `VerifySupplierOrderJob` — оркестратор + +Собирает eligible-проекты (публичный `SyncSupplierProjectsJob::collectEligibleProjects()`), строит intended (`SupplierOrderPlan`), список shouldBeOff (наши `supplier_projects` с `inactive_since IS NOT NULL`, `tag`-эквивалент — все они из автоматизации), читает живой кабинет (`SupplierPortalClient::listProjects()` → нормализует в формат live), сверяет (`SupplierOrderVerifier::diff`). При расхождении на попытке 1 — переочередить себя с задержкой 90 сек (`attempt=2`); на попытке 2 при расхождении — письмо. Пишет строку в `supplier_order_checks`. + +**Files:** +- Create: `app/app/Jobs/Supplier/VerifySupplierOrderJob.php` +- Test: `app/tests/Feature/Supplier/VerifySupplierOrderJobTest.php` + +- [ ] **Step 1: Написать падающий feature-тест** + +```php +mock(SupplierPortalClient::class, function ($m): void { + $m->shouldReceive('listProjects')->andReturn([ + ['name' => 'B1_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => '_lidpotok'], + ['name' => 'B2_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => '_lidpotok'], + ['name' => 'B3_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => '_lidpotok'], + ]); + }); + + (new VerifySupplierOrderJob(attempt: 2))->handle(); + + Mail::assertNothingQueued(); + $row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first(); + expect($row->status)->toBe('ok')->and($row->mismatch_count)->toBe(0); +}); + +it('пауза не дошла (should_be_off) → письмо + статус mismatch на попытке 2', function (): void { + Mail::fake(); + + // Выключенная наша supplier_project (inactive_since), а кабинет показывает её ВКЛ. + seedPausedSupplierProject('automoney.ru', 'B3', 'site'); + + $this->mock(SupplierPortalClient::class, function ($m): void { + $m->shouldReceive('listProjects')->andReturn([ + ['name' => 'B3_automoney.ru', 'content' => 'automoney.ru', 'type' => 'hosts', 'lim' => 1, 'status' => '1', 'tag' => '_lidpotok'], + ]); + }); + + (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(1); +}); +``` + +> Хелперы `seedActiveCallProject` / `seedPausedSupplierProject` добавить в `app/tests/Pest.php` или локальный helper-файл: создают строки в `projects` / `project_routing_snapshots` (snapshot_date = завтра) / `supplier_projects` через соединение `pgsql_supplier`. Значения полей — по существующим фабрикам проекта (см. `database/factories`, следовать паттерну соседних supplier-тестов `tests/Feature/Supplier/*`). + +- [ ] **Step 2: Запустить — падает** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/VerifySupplierOrderJobTest.php` +Expected: FAIL «Class VerifySupplierOrderJob not found». + +- [ ] **Step 3: Реализовать джоб** + +```php +collectEligibleProjects(); + $plan = SupplierOrderPlan::build($eligible, $targetDate); + $intended = $plan['intended']; + + // 2. shouldBeOff — наши выключенные supplier_projects. + $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, + ])->all(); + + // 3. Живой кабинет (только наши строки: tag _lidpotok). + try { + $live = $this->normalizeLive($client->listProjects()); + } 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)); + } + } + + /** + * Портальные строки → формат SupplierOrderVerifier (только наши _lidpotok, с распознанной площадкой). + * + * @param array> $rows + * @return list + */ + private function normalizeLive(array $rows): array + { + $out = []; + foreach ($rows as $row) { + if (($row['tag'] ?? null) !== '_lidpotok') { + continue; + } + $name = (string) ($row['name'] ?? ''); + if (preg_match('/^(B[123])_/', $name, $mm) !== 1) { + 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' => $mm[1], + 'limit' => (int) ($row['lim'] ?? 0), + 'on' => (string) ($row['status'] ?? '') === '1', + ]; + } + + return $out; + } + + /** + * @param list> $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(), + ]); + } +} +``` + +- [ ] **Step 4: Запустить — зелёные** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/VerifySupplierOrderJobTest.php` +Expected: PASS (2 теста). + +- [ ] **Step 5: Прогнать статанализ и стиль** + +Run: `cd app && composer pint && composer stan` +Expected: без ошибок по новым файлам. + +- [ ] **Step 6: Commit** + +```bash +git add app/app/Jobs/Supplier/VerifySupplierOrderJob.php app/tests/Feature/Supplier/VerifySupplierOrderJobTest.php app/tests/Pest.php +git commit -m "feat(supplier): VerifySupplierOrderJob — итоговая проверка заказа с перепроверкой" +``` + +--- + +## Task 6: Провод — диспатч проверки из робота + +**Files:** +- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` (метод `handle()`, блок `finally` около строки 195) + +- [ ] **Step 1: Обновить feature-тест робота — проверка диспатчится** + +В `app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php` добавить тест: + +```php +it('в конце диспатчит VerifySupplierOrderJob', function (): void { + Bus::fake([VerifySupplierOrderJob::class]); + + (new SyncSupplierProjectsJob)->handle(fakeChannelWithNoGroups()); + + Bus::assertDispatched(VerifySupplierOrderJob::class); +}); +``` + +> `fakeChannelWithNoGroups()` — вернуть заглушку `SupplierProjectChannel`, при которой групп нет (пустой eligible). Следовать существующим стабам в этом же тест-файле. + +- [ ] **Step 2: Запустить — падает** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SyncSupplierProjectsJobTest.php --filter="диспатчит VerifySupplierOrderJob"` +Expected: FAIL (не диспатчится). + +- [ ] **Step 3: Внести диспатч в `finally`** + +В `SyncSupplierProjectsJob::handle()`, внутри `finally` после `$this->recordRunSummary(...)`, добавить: + +```php + $this->recordRunSummary( + startedAt: $startedAt, + groupsTotal: count($groups), + syncedOk: $syncedOk, + manualQueued: $manualQueued, + deferred: $deferred, + failed: $failed, + aborted: $aborted, + ); + + // Итоговая проверка: после «готово» перечитать живой кабинет и сверить. + // Идёт всегда — и при штатном финише, и при обрыве (кроме auth: кабинет + // недоступен, джоб сам зафиксирует unable_to_verify). + VerifySupplierOrderJob::dispatch(); +``` + +`use` не требуется: `VerifySupplierOrderJob` лежит в том же namespace `App\Jobs\Supplier`, что и робот — вызывать по короткому имени. + +- [ ] **Step 4: Запустить — зелёные (и весь файл робота не сломан)** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SyncSupplierProjectsJobTest.php` +Expected: PASS (все тесты файла). + +- [ ] **Step 5: Commit** + +```bash +git add app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php +git commit -m "feat(supplier): робот запускает итоговую проверку заказа после прогона" +``` + +--- + +## Task 7: `supplier:deadline-watch` — сторож времени + +**Files:** +- Create: `app/app/Console/Commands/SupplierDeadlineWatchCommand.php` +- Modify: `app/routes/console.php` +- Test: `app/tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php` + +- [ ] **Step 1: Написать падающий feature-тест** + +```php +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(); + // Запуск начат, но не финиширован (finished_at NULL). + 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'); +}); +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php` +Expected: FAIL «command supplier:deadline-watch is not defined». + +- [ ] **Step 3: Реализовать команду** + +```php +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; + } +} +``` + +- [ ] **Step 4: Запустить — зелёные** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php` +Expected: PASS (3 теста). + +- [ ] **Step 5: Добавить расписание** + +В `app/routes/console.php` рядом с блоком supplier-flow (после планирования `SyncSupplierProjectsJob`, около строки 163) добавить: + +```php +// Сторож дедлайна поставщика (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)); +``` + +> Проверить, что переменная `$hb` (heartbeat) в области видимости в этом месте файла — если нет, повторить паттерн `->onSuccess/onFailure` соседних `Schedule::command(...)` в файле (использовать тот же способ, что у `scheduler:check-heartbeats`). + +- [ ] **Step 6: Проверить, что расписание валидно** + +Run: `cd app && php artisan schedule:list` +Expected: в списке есть `supplier:deadline-watch yellow` (20:00) и `red` (20:40). + +- [ ] **Step 7: Commit** + +```bash +git add app/app/Console/Commands/SupplierDeadlineWatchCommand.php app/routes/console.php app/tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php +git commit -m "feat(supplier): сторож дедлайна 20:00/20:40 — письмо если робот не успел" +``` + +--- + +## Task 8: Полный прогон + самопроверка + +- [ ] **Step 1: Прогнать все новые тесты вместе** + +Run: `cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierOrderVerifierTest.php tests/Unit/Supplier/SupplierOrderPlanTest.php tests/Feature/Supplier/VerifySupplierOrderJobTest.php tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php tests/Feature/Supplier/SyncSupplierProjectsJobTest.php` +Expected: PASS всё. + +- [ ] **Step 2: Стиль + статанализ по всему diff** + +Run: `cd app && composer pint && composer stan` +Expected: без ошибок. + +- [ ] **Step 3: Регрессия supplier-домена (не сломали робота)** + +Run: `cd app && ./vendor/bin/pest tests/Feature/Supplier` +Expected: PASS. + +- [ ] **Step 4: Финальный commit при необходимости** + +```bash +git add -A +git commit -m "test(supplier): зелёная регрессия проверки заказа + сторожа" || echo "нечего коммитить" +``` + +--- + +## Выкат (ОТДЕЛЬНО, только по явному «выкатываем» заказчика) + +НЕ входит в реализацию плана — отдельный шаг после ревью: + +1. `rls-reviewer` — ревью миграции `supplier_order_checks` (новая таблица; ПДн нет, но GRANT'ы под 5 ролей проверить). +2. `prod-deploy-validator` — GO/NO-GO. +3. Миграция на **живой Managed-кластер** через `crm_migrator` (не мёртвая VM-копия), из `main`. +4. Хирургический overlay новых файлов + `composer dump-autoload -o` + restart `php8.3-fpm` + `queue:restart` (воркер кэширует код — память `feedback-queue-worker-restart-after-change`). +5. Проверить `php artisan schedule:list` на боевом: два новых `supplier:deadline-watch`. +6. Smoke: дождаться ближайшего ночного прогона (или ручной `VerifySupplierOrderJob::dispatch()`) → строка в `supplier_order_checks` со `status=ok`. +``` diff --git a/docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md b/docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md new file mode 100644 index 00000000..98b89f80 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md @@ -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-обрыв) — решаются на этапе плана, дизайн не меняют.