fix(supplier): метка канала B1_/B2_/B3_ не стирается при обновлении заказа
Прод-инцидент 11-12.07.2026: робот итоговой проверки слал письма «нет заказа у
поставщика» на строки, которые в кабинете ЕСТЬ, включены и с верным лимитом.
Корень: кабинет дописывает метку канала к имени строки только при СОЗДАНИИ, а при
обновлении сохраняет имя ровно как прислали. Наш ежедневный updateProject слал голый
uniqueKey и каждый прогон стирал метку. Последствия:
- итоговая проверка выводила площадку из префикса имени и переставала узнавать
строку -> ложное missing 11.07 и 12.07;
- лид от такой строки приходил с project без метки -> webhook не мог определить
канал и писал platform=DIRECT вместо B1/B2/B3, то есть терялась атрибуция канала.
Что сделано:
- SupplierPortalClient::toPayload — на update имя уходит с меткой канала; на create
остаётся голым, там метку ставит сам кабинет и один save с тремя флагами рождает
три строки, общего префикса у них нет.
- VerifySupplierOrderJob::normalizeLive — площадка берётся из служебного поля src
rt/bl/mt, а не из префикса имени; сверка больше не зависит от имени вообще.
- Новая разовая команда supplier:repair-project-names — возвращает метку строкам,
у которых её уже стёрли. Payload собирается ИЗ ЖИВОЙ строки кабинета, меняется
ровно одно поле name; по умолчанию сухой прогон, запись только с --apply.
Ветка пересобрана на gitea/main — закрывает follow-up «фича итоговой проверки заказа
не сведена в main». Попутно возвращён CsvReconcileJobTest, отставший от кода после
сведения main 09.07: он не фейкал fetchDeliveredLeads и падал 9 из 11.
Боевой liderra.ru: выкачено, починена 81 строка, робот показывает 0 расхождений
138 наших строк вместо 57. Двум лидам восстановлен канал по журналу выдач поставщика.
Тесты: Pest supplier 277/277, Pint clean, Larastan 0 новых ошибок.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+38
-7
@@ -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'''
|
||||
]
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Разовая починка после прод-инцидента 11–12.07.2026.
|
||||
*
|
||||
* Кабинет поставщика дописывает метку канала «B<n>_» к имени строки только при её
|
||||
* СОЗДАНИИ; при обновлении он сохраняет имя ровно как прислали. Наш ночной робот слал
|
||||
* голый ключ → каждый прогон стирал метку. Последствия: (1) итоговая проверка заказа
|
||||
* переставала узнавать строку и слала ложное «нет заказа»; (2) лид от такой строки
|
||||
* приходил с project без метки → webhook не мог определить канал и писал
|
||||
* platform=DIRECT вместо B1/B2/B3 (потеря атрибуции канала).
|
||||
*
|
||||
* Корень закрыт в SupplierPortalClient::toPayload (на update имя уходит с меткой) —
|
||||
* эта команда возвращает метку тем строкам, у которых её уже стёрли.
|
||||
*
|
||||
* По умолчанию — сухой прогон. Реальная запись в кабинет — только с --apply.
|
||||
*/
|
||||
final class RepairSupplierProjectNamesCommand extends Command
|
||||
{
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
/** Канал у поставщика — служебное поле src (rt=Ростелеком, bl=Билайн, mt=МТС). */
|
||||
private const SRC_TO_PLATFORM = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
|
||||
|
||||
protected $signature = 'supplier:repair-project-names
|
||||
{--apply : Записать исправленные имена в кабинет (без флага — только показать)}
|
||||
{--only-active : Чинить лишь строки, включённые у нас (выключенные лидов не носят)}';
|
||||
|
||||
protected $description = 'Вернуть метку канала B1_/B2_/B3_ в имена наших строк у поставщика';
|
||||
|
||||
public function handle(SupplierPortalClient $client): int
|
||||
{
|
||||
$apply = (bool) $this->option('apply');
|
||||
|
||||
$query = SupplierProject::on(self::DB_CONNECTION)
|
||||
->whereNotNull('supplier_external_id');
|
||||
|
||||
if ($this->option('only-active')) {
|
||||
$query->whereNull('inactive_since');
|
||||
}
|
||||
|
||||
/** @var array<string, true> $ourIds */
|
||||
$ourIds = $query->pluck('supplier_external_id')
|
||||
->mapWithKeys(fn ($id): array => [(string) $id => true])
|
||||
->all();
|
||||
|
||||
$repaired = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($client->listProjects() as $row) {
|
||||
$externalId = (string) ($row['id'] ?? '');
|
||||
if (! isset($ourIds[$externalId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$platform = self::SRC_TO_PLATFORM[(string) ($row['src'] ?? '')] ?? null;
|
||||
if ($platform === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = (string) ($row['name'] ?? '');
|
||||
if (preg_match('/^B[123]_/', $name) === 1) {
|
||||
continue; // Метка на месте — не трогаем.
|
||||
}
|
||||
|
||||
$newName = $platform.'_'.(string) ($row['content'] ?? '');
|
||||
$this->line(sprintf('%s %s «%s» → «%s»', $externalId, $platform, $name, $newName));
|
||||
|
||||
if (! $apply) {
|
||||
$repaired++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$client->renameProject($row, $newName);
|
||||
$repaired++;
|
||||
} catch (Throwable $e) {
|
||||
$failed++;
|
||||
$this->error(sprintf(' не удалось: %s', $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info($apply
|
||||
? sprintf('Починено строк: %d, ошибок: %d.', $repaired, $failed)
|
||||
: sprintf('Сухой прогон: строк с потерянной меткой — %d. Записать: --apply.', $repaired));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\SupplierDeadlineWarningMail;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Сторож дедлайна поставщика: если робот SyncSupplierProjectsJob к порогу не закончил —
|
||||
* письмо. yellow=20:00, red=20:40 (МСК). Ловит и медленную работу, и зависание, и
|
||||
* несработавший планировщик (за сегодня нет завершённого запуска).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §5
|
||||
*/
|
||||
final class SupplierDeadlineWatchCommand extends Command
|
||||
{
|
||||
protected $signature = 'supplier:deadline-watch {level : yellow|red}';
|
||||
|
||||
protected $description = 'Проверить, успел ли робот-заказчик до дедлайна поставщика (21:00 МСК)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
/** @var 'yellow'|'red' $level */
|
||||
$level = $this->argument('level') === 'red' ? 'red' : 'yellow';
|
||||
$today = Carbon::today('Europe/Moscow')->toDateString();
|
||||
|
||||
$finished = DB::connection('pgsql_supplier')->table('supplier_sync_runs')
|
||||
->whereRaw("(started_at AT TIME ZONE 'Europe/Moscow')::date = ?", [$today])
|
||||
->whereNotNull('finished_at')
|
||||
->where('status', '!=', 'aborted')
|
||||
->exists();
|
||||
|
||||
if ($finished) {
|
||||
$this->info('OK: робот сегодня завершил заказ.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$reason = 'За сегодня нет завершённого (не aborted) запуска SyncSupplierProjectsJob с finished_at.';
|
||||
Mail::to((string) config('services.supplier.alert_email'))
|
||||
->queue(new SupplierDeadlineWarningMail($level, $reason));
|
||||
|
||||
$this->warn("{$level}: робот не закончил — письмо отправлено.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Supplier;
|
||||
|
||||
use App\Mail\SupplierOrderMismatchMail;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\SupplierOrderPlan;
|
||||
use App\Services\Supplier\SupplierOrderVerifier;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Итоговая проверка заказа: живой кабинет поставщика vs задуманное по формуле.
|
||||
* Диспатчится из SyncSupplierProjectsJob в finally. При расхождении на attempt=1
|
||||
* переочередь через 90 сек (attempt=2) — гасит транзиентный лаг применения.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §4
|
||||
*/
|
||||
final class VerifySupplierOrderJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
public const RECHECK_DELAY_SECONDS = 90;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $attempt = 1,
|
||||
public readonly ?int $syncRunId = null,
|
||||
) {}
|
||||
|
||||
public function handle(?SupplierPortalClient $client = null): void
|
||||
{
|
||||
$client ??= app(SupplierPortalClient::class);
|
||||
$targetDate = Carbon::tomorrow('Europe/Moscow');
|
||||
|
||||
// 1. Задуманное по формуле.
|
||||
$eligible = (new SyncSupplierProjectsJob)->collectEligibleProjects();
|
||||
$plan = SupplierOrderPlan::build($eligible, $targetDate);
|
||||
$intended = $plan['intended'];
|
||||
|
||||
// Ключи активных по формуле — для (а) исключения из shouldBeOff, (б) ничего более.
|
||||
$intendedKeys = [];
|
||||
foreach ($intended as $r) {
|
||||
$intendedKeys[$r['signal_type'].'|'.$r['identifier'].'|'.$r['platform']] = true;
|
||||
}
|
||||
|
||||
// Наши external_id в кабинете — признак «наша строка» (tag НЕ годится: робот
|
||||
// шлёт tag=регион/«РФ», не маркер). Сверка живого идёт только по нашим строкам.
|
||||
$ourExternalIds = SupplierProject::on(self::DB_CONNECTION)
|
||||
->whereNotNull('supplier_external_id')
|
||||
->pluck('supplier_external_id')
|
||||
->map(fn ($id) => (string) $id)
|
||||
->all();
|
||||
|
||||
// 2. shouldBeOff — наши выключенные supplier_projects, КРОМЕ ключей,
|
||||
// которые сейчас активны по формуле (иначе ложный should_be_off на
|
||||
// реактивированной группе с зависшим inactive_since).
|
||||
$shouldBeOff = SupplierProject::on(self::DB_CONNECTION)
|
||||
->whereNotNull('inactive_since')
|
||||
->get(['signal_type', 'unique_key', 'platform'])
|
||||
->map(fn (SupplierProject $sp) => [
|
||||
'signal_type' => (string) $sp->signal_type,
|
||||
'identifier' => (string) $sp->unique_key,
|
||||
'platform' => (string) $sp->platform,
|
||||
])
|
||||
->reject(fn (array $r): bool => isset($intendedKeys[$r['signal_type'].'|'.$r['identifier'].'|'.$r['platform']]))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
// 3. Живой кабинет (только наши строки — по supplier_external_id).
|
||||
try {
|
||||
$live = $this->normalizeLive($client->listProjects(), $ourExternalIds);
|
||||
} catch (Throwable $e) {
|
||||
$this->record('unable_to_verify', count($intended), 0, 0, ['error' => $e->getMessage()]);
|
||||
Mail::to((string) config('services.supplier.alert_email'))
|
||||
->queue(new SupplierOrderMismatchMail([[
|
||||
'kind' => 'unable_to_verify', 'signal_type' => '-', 'identifier' => '-',
|
||||
'platform' => '-', 'expected' => 'чтение кабинета', 'actual' => $e->getMessage(),
|
||||
]]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Сверка.
|
||||
$mismatches = SupplierOrderVerifier::diff($intended, $shouldBeOff, $live);
|
||||
|
||||
if ($mismatches !== [] && $this->attempt < 2) {
|
||||
// Транзиентный лаг применения — перепроверить через 90 сек.
|
||||
self::dispatch(2, $this->syncRunId)->delay(now()->addSeconds(self::RECHECK_DELAY_SECONDS));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$status = $mismatches === [] ? 'ok' : 'mismatch';
|
||||
$this->record($status, count($intended), count($live), count($mismatches), $mismatches);
|
||||
|
||||
if ($mismatches !== []) {
|
||||
Mail::to((string) config('services.supplier.alert_email'))
|
||||
->queue(new SupplierOrderMismatchMail($mismatches));
|
||||
}
|
||||
}
|
||||
|
||||
/** Канал у поставщика — служебное поле src, не имя. rt=Ростелеком, bl=Билайн, mt=МТС. */
|
||||
private const SRC_TO_PLATFORM = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
|
||||
|
||||
/**
|
||||
* Портальные строки → формат SupplierOrderVerifier. «Наши» = те, чей портальный id
|
||||
* совпадает с нашим supplier_external_id (tag НЕ используется — робот шлёт регион).
|
||||
*
|
||||
* Площадка берётся из `src`, а НЕ из префикса имени: метку «B<n>_» портал ставит
|
||||
* только при создании строки и теряет при обновлении (прод-инцидент 11–12.07.2026 —
|
||||
* строки в кабинете были, но проверка объявляла их missing). `src` — то же поле,
|
||||
* по которому матчит saveProjectMultiFlag; оно от имени не зависит.
|
||||
*
|
||||
* @param array<int, array<string,mixed>> $rows
|
||||
* @param list<string> $ourExternalIds
|
||||
* @return list<array{signal_type:string,identifier:string,platform:string,limit:int,on:bool}>
|
||||
*/
|
||||
private function normalizeLive(array $rows, array $ourExternalIds): array
|
||||
{
|
||||
$ours = array_flip($ourExternalIds);
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if (! isset($ours[(string) ($row['id'] ?? '')])) {
|
||||
continue;
|
||||
}
|
||||
$platform = self::SRC_TO_PLATFORM[(string) ($row['src'] ?? '')] ?? null;
|
||||
if ($platform === null) {
|
||||
continue;
|
||||
}
|
||||
$signalType = match ($row['type'] ?? null) {
|
||||
'hosts' => 'site', 'calls' => 'call', 'sms' => 'sms', default => null,
|
||||
};
|
||||
if ($signalType === null) {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'signal_type' => $signalType,
|
||||
'identifier' => (string) ($row['content'] ?? ''),
|
||||
'platform' => $platform,
|
||||
'limit' => (int) ($row['lim'] ?? 0),
|
||||
'on' => (string) ($row['status'] ?? '') === '1',
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>>|array{error:string} $details
|
||||
*/
|
||||
private function record(string $status, int $intendedRows, int $liveRows, int $mismatchCount, array $details): void
|
||||
{
|
||||
DB::connection(self::DB_CONNECTION)->table('supplier_order_checks')->insert([
|
||||
'checked_at' => now(),
|
||||
'sync_run_id' => $this->syncRunId,
|
||||
'intended_rows' => $intendedRows,
|
||||
'live_rows' => $liveRows,
|
||||
'mismatch_count' => $mismatchCount,
|
||||
'status' => $status,
|
||||
'details' => json_encode($details, JSON_UNESCAPED_UNICODE),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Сторож времени: робот-заказчик не закончил к порогу (yellow=20:00 / red=20:40).
|
||||
*/
|
||||
final class SupplierDeadlineWarningMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/** @param 'yellow'|'red' $level */
|
||||
public function __construct(
|
||||
public readonly string $level,
|
||||
public readonly string $reason,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$tag = $this->level === 'red' ? 'КРАСНЫЙ' : 'ЖЁЛТЫЙ';
|
||||
|
||||
return new Envelope(subject: "[Лидерра {$tag}] Робот-заказчик не успевает к 21:00");
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
text: 'emails.supplier_deadline_warning_text',
|
||||
with: [
|
||||
'level' => $this->level,
|
||||
'reason' => $this->reason,
|
||||
'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Итоговая проверка нашла расхождения заказа с живым кабинетом поставщика.
|
||||
*
|
||||
* @param list<array{kind:string,signal_type:string,identifier:string,platform:string,expected:mixed,actual:mixed}> $mismatches
|
||||
*/
|
||||
final class SupplierOrderMismatchMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public readonly array $mismatches) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$n = count($this->mismatches);
|
||||
|
||||
return new Envelope(subject: "[Лидерра] Заказ у поставщика: расхождений {$n}");
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
text: 'emails.supplier_order_mismatch_text',
|
||||
with: [
|
||||
'mismatches' => $this->mismatches,
|
||||
'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier;
|
||||
|
||||
use App\Models\Project;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* «Задуманный заказ» на targetDate — та же группировка и формула, что у робота
|
||||
* SyncSupplierProjectsJob (DRY через SupplierProjectGrouping + SupplierQuotaAllocator).
|
||||
*
|
||||
* Возвращает ['intended' => list<row>], где row = ['signal_type','identifier','platform','limit'],
|
||||
* только доли ≥1 (как distributeForPlatform).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §3, §4.2
|
||||
*/
|
||||
final class SupplierOrderPlan
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, Project> $eligibleProjects проекты со slepok-полями (daily_limit_target/delivery_days_mask/regions)
|
||||
* @return array{intended: list<array{signal_type:string,identifier:string,platform:string,limit:int}>}
|
||||
*/
|
||||
public static function build(Collection $eligibleProjects, Carbon $targetDate): array
|
||||
{
|
||||
$targetWeekday = $targetDate->copy()->timezone('Europe/Moscow')->isoWeekday();
|
||||
|
||||
// Группировка (signal_type|identifier) — как в SyncSupplierProjectsJob.
|
||||
/** @var array<string, array{signal_type:string, identifier:string, platforms:list<string>, limits:list<int>}> $groups */
|
||||
$groups = [];
|
||||
|
||||
foreach ($eligibleProjects as $project) {
|
||||
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
|
||||
if ($platforms === []) {
|
||||
continue;
|
||||
}
|
||||
// eligible-today (маска дня на targetDate).
|
||||
if (((int) $project->delivery_days_mask & (1 << ($targetWeekday - 1))) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$identifier = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);
|
||||
$key = $project->signal_type.'|'.$identifier;
|
||||
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'identifier' => $identifier,
|
||||
'platforms' => $platforms,
|
||||
'limits' => [],
|
||||
];
|
||||
}
|
||||
$groups[$key]['limits'][] = (int) $project->daily_limit_target;
|
||||
}
|
||||
|
||||
$intended = [];
|
||||
foreach ($groups as $group) {
|
||||
$order = SupplierQuotaAllocator::computeOrder($group['limits']);
|
||||
$shares = SupplierQuotaAllocator::distributeForPlatform($order, $group['platforms']);
|
||||
foreach ($shares as $platform => $limit) {
|
||||
$intended[] = [
|
||||
'signal_type' => $group['signal_type'],
|
||||
'identifier' => $group['identifier'],
|
||||
'platform' => $platform,
|
||||
'limit' => $limit,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['intended' => $intended];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier;
|
||||
|
||||
/**
|
||||
* Чистая сверка «задуманный заказ» vs «живой кабинет поставщика».
|
||||
*
|
||||
* Возвращает список расхождений; каждый — ['kind','signal_type','identifier','platform','expected','actual'].
|
||||
* kind ∈ missing | limit_drift | should_be_off | should_be_on | orphan_extra.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §4.2
|
||||
*/
|
||||
final class SupplierOrderVerifier
|
||||
{
|
||||
/**
|
||||
* @param list<array{signal_type:string,identifier:string,platform:string,limit:int}> $intended
|
||||
* @param list<array{signal_type:string,identifier:string,platform:string}> $shouldBeOff
|
||||
* @param list<array{signal_type:string,identifier:string,platform:string,limit:int,on:bool}> $live
|
||||
* @return list<array{kind:string,signal_type:string,identifier:string,platform:string,expected:int|string|bool|null,actual:int|string|bool|null}>
|
||||
*/
|
||||
public static function diff(array $intended, array $shouldBeOff, array $live): array
|
||||
{
|
||||
$key = static fn (array $r): string => $r['signal_type'].'|'.$r['identifier'].'|'.$r['platform'];
|
||||
|
||||
$liveByKey = [];
|
||||
foreach ($live as $row) {
|
||||
$liveByKey[$key($row)] = $row;
|
||||
}
|
||||
|
||||
$mismatches = [];
|
||||
$accountedLive = [];
|
||||
|
||||
// 1. Активные по формуле.
|
||||
foreach ($intended as $row) {
|
||||
$k = $key($row);
|
||||
$accountedLive[$k] = true;
|
||||
$liveRow = $liveByKey[$k] ?? null;
|
||||
|
||||
if ($liveRow === null) {
|
||||
$mismatches[] = self::m('missing', $row, $row['limit'], null);
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($liveRow['on'] !== true) {
|
||||
$mismatches[] = self::m('should_be_on', $row, true, false);
|
||||
|
||||
continue;
|
||||
}
|
||||
if ((int) $liveRow['limit'] !== (int) $row['limit']) {
|
||||
$mismatches[] = self::m('limit_drift', $row, (int) $row['limit'], (int) $liveRow['limit']);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Должны быть выключены.
|
||||
foreach ($shouldBeOff as $row) {
|
||||
$k = $key($row);
|
||||
$accountedLive[$k] = true;
|
||||
$liveRow = $liveByKey[$k] ?? null;
|
||||
if ($liveRow !== null && $liveRow['on'] === true) {
|
||||
$mismatches[] = self::m('should_be_off', $row, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Лишние наши строки у поставщика, которых нет ни в intended, ни в shouldBeOff.
|
||||
foreach ($live as $row) {
|
||||
if (! isset($accountedLive[$key($row)]) && $row['on'] === true) {
|
||||
$mismatches[] = self::m('orphan_extra', $row, null, (int) $row['limit']);
|
||||
}
|
||||
}
|
||||
|
||||
return $mismatches;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{signal_type:string,identifier:string,platform:string} $row
|
||||
* @return array{kind:string,signal_type:string,identifier:string,platform:string,expected:int|string|bool|null,actual:int|string|bool|null}
|
||||
*/
|
||||
private static function m(string $kind, array $row, int|string|bool|null $expected, int|string|bool|null $actual): array
|
||||
{
|
||||
return [
|
||||
'kind' => $kind,
|
||||
'signal_type' => $row['signal_type'],
|
||||
'identifier' => $row['identifier'],
|
||||
'platform' => $row['platform'],
|
||||
'expected' => $expected,
|
||||
'actual' => $actual,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,51 @@ class SupplierPortalClient
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Разовая починка имени строки в кабинете (прод-инцидент 11–12.07.2026): отдаём живую
|
||||
* строку обратно с исправленным `name`. Кроме имени не меняем НИЧЕГО — лимит, дни,
|
||||
* регионы и вкл/выкл берём из самого кабинета. Регионы там уже в кодах поставщика,
|
||||
* второй раз переводить их через SupplierRegions нельзя.
|
||||
*
|
||||
* @param array<string,mixed> $liveRow строка из listProjects()
|
||||
*/
|
||||
public function renameProject(array $liveRow, string $newName): void
|
||||
{
|
||||
$src = (string) ($liveRow['src'] ?? '');
|
||||
|
||||
$regions = array_values(array_filter(array_map(
|
||||
static fn (string $code): int => (int) trim($code),
|
||||
preg_split('/[;,]/', (string) ($liveRow['regions'] ?? '')) ?: [],
|
||||
)));
|
||||
|
||||
$response = $this->request('POST', '/admin/visit/rt-project-save', [
|
||||
'id' => (int) ($liveRow['id'] ?? 0),
|
||||
'tag' => (string) ($liveRow['tag'] ?? ''),
|
||||
'name' => $newName,
|
||||
'type' => (string) ($liveRow['type'] ?? ''),
|
||||
'content' => (string) ($liveRow['content'] ?? ''),
|
||||
'srcrt' => $src === 'rt',
|
||||
'srcbl' => $src === 'bl',
|
||||
'srcmt' => $src === 'mt',
|
||||
'srcmg' => false,
|
||||
'srclal' => false,
|
||||
'srcdop' => false,
|
||||
'srcwz' => false,
|
||||
'srcseg' => false,
|
||||
'limit' => (int) ($liveRow['lim'] ?? 0),
|
||||
'workdays' => array_map(static fn ($day): string => (string) $day, (array) ($liveRow['workdays'] ?? [])),
|
||||
'regions' => $regions,
|
||||
'regions_reverse' => (bool) ($liveRow['regions_reverse'] ?? false),
|
||||
'status' => (bool) ($liveRow['status'] ?? false),
|
||||
'show' => true,
|
||||
'multisignals' => false,
|
||||
'multigroup' => false,
|
||||
'depth' => (int) ($liveRow['depth'] ?? 1),
|
||||
], asJson: true);
|
||||
|
||||
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
|
||||
}
|
||||
|
||||
public function deleteProject(int $externalId): void
|
||||
{
|
||||
$response = $this->request(
|
||||
@@ -507,9 +552,17 @@ class SupplierPortalClient
|
||||
* один rt-проект (множественные флаги создают N проектов, мы привязываемся
|
||||
* к одному external_id).
|
||||
* - signalType: site → type:"hosts"; call → type:"calls"; sms → type:"sms".
|
||||
* - uniqueKey → одновременно `name` (label проекта на портале — портал
|
||||
* префиксует "B<n>_" автоматически) и `content` (домен/телефон в полях
|
||||
* сбора).
|
||||
* - uniqueKey → одновременно `name` (label проекта на портале) и `content`
|
||||
* (домен/телефон в полях сбора).
|
||||
* - `name` и метка канала «B<n>_» (прод-инцидент 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,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* supplier_order_checks — история итоговой проверки заказа у поставщика.
|
||||
*
|
||||
* VerifySupplierOrderJob после ночного прогона робота перечитывает живой кабинет
|
||||
* поставщика и сверяет каждую нашу строку с формулой; результат
|
||||
* (ok / mismatch / unable_to_verify + список расхождений) пишется сюда одной строкой.
|
||||
*
|
||||
* Системная таблица (как supplier_sync_runs / supplier_deferred_sync) — без RLS/
|
||||
* tenant_id, пишет supplier-flow джоб под crm_supplier_worker (BYPASSRLS), читает SaaS-admin.
|
||||
* Создаётся через pgsql_supplier + инлайн-GRANT (guard IF EXISTS) — на проде миграции
|
||||
* катит crm_migrator, поэтому дефолтные привилегии crm_supplier_worker не наследуются.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §6.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$supplier = DB::connection('pgsql_supplier');
|
||||
|
||||
$supplier->statement(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS supplier_order_checks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
checked_at TIMESTAMPTZ NOT NULL,
|
||||
sync_run_id BIGINT,
|
||||
intended_rows INTEGER NOT NULL DEFAULT 0,
|
||||
live_rows INTEGER NOT NULL DEFAULT 0,
|
||||
mismatch_count INTEGER NOT NULL DEFAULT 0,
|
||||
status VARCHAR(32) NOT NULL
|
||||
CHECK (status IN ('ok','mismatch','unable_to_verify')),
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
SQL);
|
||||
$supplier->statement('CREATE INDEX IF NOT EXISTS idx_supplier_order_checks_checked ON supplier_order_checks (checked_at DESC)');
|
||||
$supplier->statement('CREATE INDEX IF NOT EXISTS idx_supplier_order_checks_status ON supplier_order_checks (status)');
|
||||
|
||||
foreach (['crm_supplier_worker'] as $role) {
|
||||
$supplier->statement(<<<SQL
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
|
||||
GRANT SELECT, INSERT ON supplier_order_checks TO {$role};
|
||||
GRANT USAGE, SELECT ON SEQUENCE supplier_order_checks_id_seq TO {$role};
|
||||
END IF;
|
||||
END
|
||||
\$\$
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::connection('pgsql_supplier')->statement('DROP TABLE IF EXISTS supplier_order_checks CASCADE');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
@if ($level === 'red')
|
||||
🔴 КРАСНЫЙ. Робот-заказчик к 20:40 МСК ещё не закончил. До дедлайна поставщика (21:00) ~20 минут — есть риск не успеть заказать на завтра.
|
||||
@else
|
||||
🟡 ЖЁЛТЫЙ. Робот-заказчик к 20:00 МСК ещё не закончил. До дедлайна поставщика (21:00) меньше часа — следите.
|
||||
@endif
|
||||
|
||||
Причина: {{ $reason }}
|
||||
Время письма: {{ $now }} МСК.
|
||||
@@ -0,0 +1,12 @@
|
||||
Итоговая проверка заказа у поставщика ({{ $now }} МСК) нашла расхождения: {{ count($mismatches) }}.
|
||||
|
||||
@foreach ($mismatches as $m)
|
||||
- [{{ $m['kind'] }}] {{ $m['signal_type'] }} {{ $m['identifier'] }} / {{ $m['platform'] }}: задумано «{{ var_export($m['expected'], true) }}», у поставщика «{{ var_export($m['actual'], true) }}»
|
||||
@endforeach
|
||||
|
||||
Расшифровка:
|
||||
- missing — задумали строку, у поставщика её нет.
|
||||
- limit_drift — лимит не совпал.
|
||||
- should_be_off — должно быть выключено, а у поставщика включено (деньги!).
|
||||
- should_be_on — должно быть включено, а выключено.
|
||||
- orphan_extra — у поставщика лишняя наша строка.
|
||||
@@ -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')
|
||||
|
||||
@@ -16,7 +16,6 @@ use App\Services\Supplier\SupplierPortalClient;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -57,49 +56,41 @@ afterEach(function (): void {
|
||||
});
|
||||
|
||||
/**
|
||||
* 3-колоночный CSV «Запрос номеров»: Name;Tag;Phone.
|
||||
* Мокает журнал ОТДАННОГО (Мои сделки): SupplierPortalClient::fetchDeliveredLeads
|
||||
* возвращает лиды, ключ = vid (как реальный метод).
|
||||
*
|
||||
* @param array<int, array{project: string, phone: string}> $rows
|
||||
* @param list<array{vid:int, phone:string, project:string}> $leads
|
||||
*/
|
||||
function csvBody(array $rows): string
|
||||
function fakeDelivered(array $leads): void
|
||||
{
|
||||
$out = "Name;Tag;Phone\n";
|
||||
foreach ($rows as $r) {
|
||||
$out .= "{$r['project']};tag;{$r['phone']}\n";
|
||||
$byVid = [];
|
||||
foreach ($leads as $l) {
|
||||
$byVid[(int) $l['vid']] = $l;
|
||||
}
|
||||
|
||||
return $out;
|
||||
$mock = Mockery::mock(SupplierPortalClient::class);
|
||||
$mock->shouldReceive('fetchDeliveredLeads')->andReturn($byVid);
|
||||
app()->instance(SupplierPortalClient::class, $mock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Мокает весь async-флоу отчёта (реальные endpoint'ы — discovery T3 2026-05-19):
|
||||
* POST /admin/report/save-report → "OK"
|
||||
* GET /admin/report/load-reports → array [{id, title, status:"1", ...}] (id извлекается по title-match)
|
||||
* GET /admin/report/getfile?id=N → raw CSV
|
||||
*
|
||||
* Title включает фактически использованные dateFrom/dateTo — захватываем их из save-report body
|
||||
* и возвращаем тот же диапазон в load-reports, чтобы матч requestNumbersReport состоялся.
|
||||
*/
|
||||
function fakeReportFlow(string $csv): void
|
||||
function fakeDeliveredThrows(Throwable $e): void
|
||||
{
|
||||
$captured = ['from' => '', 'to' => ''];
|
||||
$mock = Mockery::mock(SupplierPortalClient::class);
|
||||
/* @phpstan-ignore-next-line method.notFound (Mockery andThrow — union-type false positive, как в др. supplier-тестах) */
|
||||
$mock->shouldReceive('fetchDeliveredLeads')->andThrow($e);
|
||||
app()->instance(SupplierPortalClient::class, $mock);
|
||||
}
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/report/save-report' => function (Request $r) use (&$captured) {
|
||||
$body = $r->data();
|
||||
$captured['from'] = (string) ($body['reportFilter']['dateFrom'] ?? '');
|
||||
$captured['to'] = (string) ($body['reportFilter']['dateTo'] ?? '');
|
||||
|
||||
return Http::response('OK', 200);
|
||||
},
|
||||
'crm.bp-gr.ru/admin/report/load-reports' => function () use (&$captured) {
|
||||
$title = sprintf('Запрос номеров с %s по %s', $captured['from'], $captured['to']);
|
||||
|
||||
return Http::response([
|
||||
['id' => '700001', 'title' => $title, 'status' => '1', 'is_file' => '1', 'percent' => '100'],
|
||||
], 200);
|
||||
},
|
||||
'crm.bp-gr.ru/admin/report/getfile*' => Http::response($csv, 200),
|
||||
/** Принятый вебхуком лид (source=webhook, реальный vid). */
|
||||
function webhookLead(int $vid, string $phone, string $project = 'B1_a.com', ?Carbon $at = null): void
|
||||
{
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => str_starts_with($project, 'B') ? substr($project, 0, 2) : 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => $vid,
|
||||
'raw_payload' => ['project' => $project, 'phone' => $phone, 'vid' => $vid],
|
||||
'received_at' => $at ?? now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -112,24 +103,20 @@ function runCsvReconcile(): void
|
||||
);
|
||||
}
|
||||
|
||||
it('no missing leads — status=ok, no recovery, no alert', function (): void {
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => "7999000000{$i}",
|
||||
'vid' => 800000 + $i,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Сверка по vid против журнала ОТДАННОГО (Мои сделки) — Путь 2, переработка 09.07.2026.
|
||||
// Раньше сверялись с пулом «Запрос номеров» (phones_cnt) → фантомы + выедание лимита.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
$rows = [];
|
||||
it('all delivered vids already received via webhook — recovered 0, status ok', function (): void {
|
||||
$delivered = [];
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"];
|
||||
$vid = 800000 + $i;
|
||||
$phone = "7999000000{$i}";
|
||||
webhookLead($vid, $phone);
|
||||
$delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com'];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
fakeDelivered($delivered);
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
@@ -139,28 +126,23 @@ it('no missing leads — status=ok, no recovery, no alert', function (): void {
|
||||
expect((int) $log->matched_count)->toBe(10);
|
||||
expect((int) $log->recovered_count)->toBe(0);
|
||||
|
||||
Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots
|
||||
Mail::assertNotSent(CsvDriftAlertMail::class);
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
it('1 missing of 10 (drift 10%) — recovery + drift alert', function (): void {
|
||||
it('recovers a delivered lead missing from webhook — with the REAL vid (not null)', function (): void {
|
||||
$delivered = [];
|
||||
for ($i = 0; $i < 9; $i++) {
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => "7999111000{$i}",
|
||||
'vid' => 810000 + $i,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
$vid = 810000 + $i;
|
||||
$phone = "7999111000{$i}";
|
||||
webhookLead($vid, $phone);
|
||||
$delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com'];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
// 10-й отдан поставщиком, но вебхук его потерял.
|
||||
$missingVid = 810099;
|
||||
$missingPhone = '79991119999';
|
||||
$delivered[] = ['vid' => $missingVid, 'phone' => $missingPhone, 'project' => 'B1_a.com'];
|
||||
fakeDelivered($delivered);
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
@@ -171,71 +153,87 @@ it('1 missing of 10 (drift 10%) — recovery + drift alert', function (): void {
|
||||
|
||||
$recovered = SupplierLead::where('source', 'csv_recovery')->first();
|
||||
expect($recovered)->not->toBeNull();
|
||||
expect($recovered->vid)->toBeNull();
|
||||
// КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: recovery несёт НАСТОЯЩИЙ vid (раньше был null) — точная личность лида.
|
||||
expect((int) $recovered->vid)->toBe($missingVid);
|
||||
expect($recovered->phone)->toBe($missingPhone);
|
||||
expect($recovered->recovered_from_csv_at)->not->toBeNull();
|
||||
|
||||
Mail::assertSent(CsvDriftAlertMail::class, 1);
|
||||
Bus::assertDispatched(RouteSupplierLeadJob::class, 1);
|
||||
});
|
||||
|
||||
it('1 missing of 100 (drift 1%) — recovery without alert', function (): void {
|
||||
for ($i = 0; $i < 99; $i++) {
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
|
||||
'vid' => 820000 + $i,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
it('reconciles ONLY the delivered ledger — pool numbers (roistat identity) are structurally impossible to recover', function (): void {
|
||||
// Поставщик реально ОТДАЛ 3 лида (vid+phone), вебхук их принял.
|
||||
// В старом баге ПУЛ по этому проекту содержал десятки ДРУГИХ телефонов (пул 157 vs отдано 15),
|
||||
// и старый reconcile лепил из них фантомы. Новый источник = только отданное (по vid) → лишнему
|
||||
// взяться неоткуда.
|
||||
$delivered = [];
|
||||
foreach ([[900001, '79000000001'], [900002, '79000000002'], [900003, '79000000003']] as [$vid, $phone]) {
|
||||
webhookLead($vid, $phone, 'B2_74950001122');
|
||||
$delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B2_74950001122'];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
fakeDelivered($delivered);
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect($log->status)->toBe('ok');
|
||||
expect((int) $log->recovered_count)->toBe(1);
|
||||
Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots
|
||||
expect((int) $log->recovered_count)->toBe(0);
|
||||
expect(SupplierLead::where('source', 'csv_recovery')->count())->toBe(0);
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
it('dedup is keyed by (phone, project) — same phone on different project is NOT a duplicate', function (): void {
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => '79995550000',
|
||||
'vid' => 830000,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79995550000'],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
|
||||
fakeReportFlow(csvBody([
|
||||
['project' => 'B1_a.com', 'phone' => '79995550000'],
|
||||
['project' => 'B2_b.com', 'phone' => '79995550000'],
|
||||
]));
|
||||
it('delivered vid already exists (any time) — matched, not re-recovered, no duplicate', function (): void {
|
||||
// Лид принят вебхуком 3 дня назад (вне 2-дневного окна reconcile). В журнале отданного он ещё
|
||||
// виден. Глобальная проверка vid должна засчитать его как matched, а не пытаться вставить дубль.
|
||||
$vid = 920000;
|
||||
$phone = '79200000000';
|
||||
webhookLead($vid, $phone, 'B1_a.com', now()->subDays(3));
|
||||
fakeDelivered([['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']]);
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
expect(SupplierLead::where('vid', $vid)->count())->toBe(1); // без дубля
|
||||
expect(SupplierLead::where('source', 'csv_recovery')->count())->toBe(0);
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect((int) $log->recovered_count)->toBe(0);
|
||||
expect((int) $log->matched_count)->toBe(1);
|
||||
expect((int) $log->recovered_count)->toBe(1);
|
||||
});
|
||||
|
||||
it('empty CSV — status=ok, drift=0', function (): void {
|
||||
fakeReportFlow("Name;Tag;Phone\n");
|
||||
it('unparseable project in delivered ledger — skipped, counted, excluded from drift', function (): void {
|
||||
// 5 нормальных отданных (webhook принял) + 5 отданных с мусорным project (extractPlatform=null).
|
||||
$delivered = [];
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$vid = 930000 + $i;
|
||||
$phone = "7993000000{$i}";
|
||||
webhookLead($vid, $phone);
|
||||
$delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com'];
|
||||
}
|
||||
$junk = ['???', '!@#', '%%%', '$$$', '***'];
|
||||
foreach ($junk as $j => $bad) {
|
||||
$delivered[] = ['vid' => 931000 + $j, 'phone' => "7993100000{$j}", 'project' => $bad];
|
||||
}
|
||||
fakeDelivered($delivered);
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect((int) $log->total_csv_rows)->toBe(10);
|
||||
expect((int) $log->matched_count)->toBe(5);
|
||||
expect((int) $log->recovered_count)->toBe(0);
|
||||
expect((int) $log->unparseable_count)->toBe(5);
|
||||
expect((float) $log->drift_ratio)->toBe(0.0); // только junk, реального missing нет
|
||||
expect($log->status)->toBe('ok');
|
||||
});
|
||||
|
||||
it('empty delivered ledger — status=ok, drift=0', function (): void {
|
||||
fakeDelivered([]);
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect($log->status)->toBe('ok');
|
||||
expect((int) $log->total_csv_rows)->toBe(0);
|
||||
expect((int) $log->recovered_count)->toBe(0);
|
||||
});
|
||||
|
||||
it('overlap lock held — job skips, no log row', function (): void {
|
||||
@@ -253,8 +251,8 @@ it('overlap lock held — job skips, no log row', function (): void {
|
||||
expect(DB::table('supplier_csv_reconcile_log')->count())->toBe($countBefore);
|
||||
});
|
||||
|
||||
it('SupplierTransientException — status=failed, error recorded, rethrown', function (): void {
|
||||
Http::fake(['crm.bp-gr.ru/*' => Http::response('Server Error', 500)]);
|
||||
it('SupplierTransientException from delivered fetch — status=failed, rethrown', function (): void {
|
||||
fakeDeliveredThrows(new SupplierTransientException('Supplier server error 500'));
|
||||
|
||||
expect(fn () => runCsvReconcile())->toThrow(SupplierTransientException::class);
|
||||
|
||||
@@ -263,96 +261,65 @@ it('SupplierTransientException — status=failed, error recorded, rethrown', fun
|
||||
expect($log->error_message)->toContain('500');
|
||||
});
|
||||
|
||||
it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows → status=ok, unparseable_count=10', function (): void {
|
||||
// 100 нормальных webhook-лидов.
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
|
||||
'vid' => 840000 + $i,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchDeliveredLeads — парсинг HTML «Мои сделки» + пагинация (реальный клиент, Http::fake).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Строит HTML таблицы «Мои сделки»: строка = checkbox value(vid) + B{n}_<proj> + телефон.
|
||||
*
|
||||
* @param list<array{vid:int, phone:string, project:string}> $leads
|
||||
*/
|
||||
function deliveredHtml(array $leads): string
|
||||
{
|
||||
$rows = '';
|
||||
foreach ($leads as $l) {
|
||||
$rows .= '<tr class="users-table__item">'
|
||||
.'<td><input type="checkbox" name="visit-checbox" value="'.$l['vid'].'"></td>'
|
||||
.'<td>Открыть '.$l['project'].' РФ'.$l['phone'].' -</td>'
|
||||
.'</tr>';
|
||||
}
|
||||
|
||||
// CSV: те же 100 (matched) + 10 строк с настоящим мусорным project (extractPlatform = null).
|
||||
// Phase 3 (2026-05-25): расширили DIRECT-распознавание — теперь цифровые callback-проекты
|
||||
// (79135551234) — валидный DIRECT, не junk. Реальный junk — это символы вне whitelist regex.
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
$junkProjects = ['???', '!@#', '%%%', '$$$', '???!!!', '~~~', '***', '|||', '^^^', '&&&'];
|
||||
foreach ($junkProjects as $j => $junk) {
|
||||
$rows[] = ['project' => $junk, 'phone' => '7999500000'.$j];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
return '<table><tbody>'.$rows.'</tbody></table>';
|
||||
}
|
||||
|
||||
runCsvReconcile();
|
||||
it('fetchDeliveredLeads parses vid+phone+project from Мои сделки HTML', function (): void {
|
||||
$html = deliveredHtml([
|
||||
['vid' => 1718932476, 'phone' => '79001112233', 'project' => 'B3_roistat.com'],
|
||||
['vid' => 1718932472, 'phone' => '79001112244', 'project' => 'B2_74950001122'],
|
||||
]);
|
||||
Http::fake(['crm.bp-gr.ru/admin/visit/index-visit*' => Http::response($html, 200)]);
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect((int) $log->total_csv_rows)->toBe(110);
|
||||
expect((int) $log->matched_count)->toBe(100);
|
||||
expect((int) $log->recovered_count)->toBe(0);
|
||||
expect((int) $log->unparseable_count)->toBe(10);
|
||||
// Реального missing'а нет — только junk; drift должен быть 0, не 10/110.
|
||||
expect((float) $log->drift_ratio)->toBe(0.0);
|
||||
expect($log->status)->toBe('ok');
|
||||
$result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now());
|
||||
|
||||
Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots
|
||||
expect($result)->toHaveCount(2);
|
||||
expect($result[1718932476])->toMatchArray(['vid' => 1718932476, 'phone' => '79001112233', 'project' => 'B3_roistat.com']);
|
||||
expect($result[1718932472]['project'])->toBe('B2_74950001122');
|
||||
});
|
||||
|
||||
it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recovered=3, drift по реальным', function (): void {
|
||||
for ($i = 0; $i < 95; $i++) {
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
|
||||
'vid' => 850000 + $i,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
it('fetchDeliveredLeads paginates until a page returns < 50 rows', function (): void {
|
||||
$page1 = [];
|
||||
for ($i = 0; $i < 50; $i++) {
|
||||
$page1[] = ['vid' => 1000 + $i, 'phone' => '790000'.str_pad((string) $i, 5, '0', STR_PAD_LEFT), 'project' => 'B1_a.com'];
|
||||
}
|
||||
$page2 = [];
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$page2[] = ['vid' => 2000 + $i, 'phone' => '790010'.str_pad((string) $i, 5, '0', STR_PAD_LEFT), 'project' => 'B1_a.com'];
|
||||
}
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/index-visit*' => Http::sequence()
|
||||
->push(deliveredHtml($page1), 200)
|
||||
->push(deliveredHtml($page2), 200),
|
||||
]);
|
||||
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 95; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
// Phase 3: реальный junk — символы вне whitelist (не \w/.-/cyrillic/digits/slash/parens/space/plus).
|
||||
$junkProjects = ['???', '!!!@@@', '%%%', '****', '???!!!'];
|
||||
foreach ($junkProjects as $j => $junk) {
|
||||
$rows[] = ['project' => $junk, 'phone' => '7999600000'.$j];
|
||||
}
|
||||
for ($k = 0; $k < 3; $k++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '7999700000'.$k];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
$result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now());
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect((int) $log->total_csv_rows)->toBe(103);
|
||||
expect((int) $log->matched_count)->toBe(95);
|
||||
expect((int) $log->recovered_count)->toBe(3);
|
||||
expect((int) $log->unparseable_count)->toBe(5);
|
||||
// real_missing = (103 - 95) - 5 = 3; parseable_total = 103 - 5 = 98; drift = 3/98 ≈ 0.0306 < 5% → ok.
|
||||
expect((float) $log->drift_ratio)->toBeLessThan(0.05);
|
||||
expect((float) $log->drift_ratio)->toBeGreaterThan(0.0);
|
||||
expect($log->status)->toBe('ok');
|
||||
expect($result)->toHaveCount(53); // 50 + 3, остановились на неполной странице
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 4 / Task 4.5 — R-05 (spec §4.4.4): business-drift second pass.
|
||||
// After existing webhook-loss drift detection, CsvReconcileJob runs a second
|
||||
// pass on project_routing_snapshots: per (snapshot_date, tenant_id) groups
|
||||
// where (expected - delivered) / expected > 20% → TenantBusinessDriftAlertMail.
|
||||
// This is orthogonal to webhook-loss drift (R-05.1) — same lead can be:
|
||||
// - delivered & webhook OK (no alerts)
|
||||
// - delivered & webhook miss (R-05.1 CsvDriftAlertMail)
|
||||
// - not delivered at all (R-05.2 TenantBusinessDriftAlertMail — this task)
|
||||
// R-05 business-drift (spec §4.4.4) — второй проход по project_routing_snapshots.
|
||||
// Ортогонален webhook-loss drift: тот же лид может быть не доставлен вовсе.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function insertSnapshotForTenant(int $tenantId, string $date, int $expected, int $delivered): void
|
||||
@@ -386,12 +353,10 @@ function insertSnapshotForTenant(int $tenantId, string $date, int $expected, int
|
||||
|
||||
it('R-05 business-drift: tenant with shortfall > 20% → TenantBusinessDriftAlertMail sent', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
// Yesterday's snapshot: expected 10, delivered 2 → shortfall 80% (>20% threshold).
|
||||
$yesterday = Carbon::yesterday('Europe/Moscow')->toDateString();
|
||||
insertSnapshotForTenant($tenant->id, $yesterday, 10, 2);
|
||||
|
||||
// Empty CSV — primary drift pass is trivially OK; we exercise only the second pass.
|
||||
fakeReportFlow(csvBody([]));
|
||||
fakeDelivered([]);
|
||||
runCsvReconcile();
|
||||
|
||||
Mail::assertSent(TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
|
||||
@@ -405,15 +370,12 @@ it('R-05 business-drift: tenant with shortfall > 20% → TenantBusinessDriftAler
|
||||
|
||||
it('R-05 business-drift: tenant with shortfall <= 20% → NO TenantBusinessDriftAlertMail', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
// Yesterday's snapshot: expected 10, delivered 9 → shortfall 10% (<=20% threshold).
|
||||
$yesterday = Carbon::yesterday('Europe/Moscow')->toDateString();
|
||||
insertSnapshotForTenant($tenant->id, $yesterday, 10, 9);
|
||||
|
||||
fakeReportFlow(csvBody([]));
|
||||
fakeDelivered([]);
|
||||
runCsvReconcile();
|
||||
|
||||
// Scoped assertion: prior-run leaked snapshots may fire mails for other tenants;
|
||||
// this test only owns one tenant, so assert no mail was sent for IT.
|
||||
Mail::assertNotSent(TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
|
||||
return $mail->tenantId === $tenant->id;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Mail\SupplierDeadlineWarningMail;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
DB::connection('pgsql_supplier')->table('supplier_sync_runs')->delete();
|
||||
});
|
||||
|
||||
it('робот сегодня закончил → сторож молчит', function (): void {
|
||||
Mail::fake();
|
||||
DB::connection('pgsql_supplier')->table('supplier_sync_runs')->insert([
|
||||
'started_at' => now(), 'finished_at' => now(), 'groups_total' => 1, 'synced_ok' => 1,
|
||||
'manual_queued' => 0, 'deferred' => 0, 'failed' => 0, 'status' => 'ok', 'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->artisan('supplier:deadline-watch', ['level' => 'yellow'])->assertExitCode(0);
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
it('робот сегодня НЕ закончил → красное письмо', function (): void {
|
||||
Mail::fake();
|
||||
DB::connection('pgsql_supplier')->table('supplier_sync_runs')->insert([
|
||||
'started_at' => now(), 'finished_at' => null, 'groups_total' => 0, 'synced_ok' => 0,
|
||||
'manual_queued' => 0, 'deferred' => 0, 'failed' => 0, 'status' => 'ok', 'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->artisan('supplier:deadline-watch', ['level' => 'red'])->assertExitCode(0);
|
||||
|
||||
Mail::assertQueued(SupplierDeadlineWarningMail::class, fn ($m) => $m->level === 'red');
|
||||
});
|
||||
|
||||
it('запуска за сегодня нет вовсе → письмо (планировщик не сработал)', function (): void {
|
||||
Mail::fake();
|
||||
|
||||
$this->artisan('supplier:deadline-watch', ['level' => 'yellow'])->assertExitCode(0);
|
||||
|
||||
Mail::assertQueued(SupplierDeadlineWarningMail::class, fn ($m) => $m->level === 'yellow');
|
||||
});
|
||||
@@ -137,6 +137,72 @@ it('updateProject POSTs to /admin/visit/rt-project-save with id:N (same endpoint
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* Прод-инцидент 11–12.07.2026. Кабинет дописывает метку канала «B<n>_» к имени только
|
||||
* при СОЗДАНИИ строки; при обновлении он сохраняет имя ровно как прислали. Мы слали
|
||||
* голый uniqueKey → каждый ночной прогон стирал метку. Последствия: (1) итоговая
|
||||
* проверка переставала узнавать строку → ложное «нет заказа»; (2) поставщик присылал
|
||||
* лид с project=«79135397707» без метки → webhook не мог определить канал и писал
|
||||
* platform=DIRECT вместо B1/B2/B3 (потеря атрибуции канала).
|
||||
*
|
||||
* Поэтому при ОБНОВЛЕНИИ имя уходит уже с меткой; при СОЗДАНИИ — голым (там метку
|
||||
* ставит сам кабинет, и один save с тремя флагами рождает три строки — единого
|
||||
* префикса для них быть не может).
|
||||
*/
|
||||
it('updateProject sends name WITH the B<n>_ channel prefix (portal would otherwise strip it)', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '13625654'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B2',
|
||||
signalType: 'call',
|
||||
uniqueKey: '79135397707',
|
||||
limit: 1,
|
||||
workdays: [1, 2, 3, 4, 5],
|
||||
regions: [],
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
platforms: ['B2'],
|
||||
);
|
||||
|
||||
app(SupplierPortalClient::class)->updateProject(13625654, $dto);
|
||||
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
return $request['id'] === 13625654
|
||||
&& $request['name'] === 'B2_79135397707'
|
||||
// content — по-прежнему голый ключ: это поле сбора (телефон/домен).
|
||||
&& $request['content'] === '79135397707';
|
||||
});
|
||||
});
|
||||
|
||||
it('saveProject (create) keeps the name bare — the portal adds the prefix itself', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '777'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1',
|
||||
signalType: 'call',
|
||||
uniqueKey: '79135397707',
|
||||
limit: 1,
|
||||
workdays: [1, 2, 3, 4, 5],
|
||||
regions: [],
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
app(SupplierPortalClient::class)->saveProject($dto);
|
||||
|
||||
Http::assertSent(fn (Request $request): bool => $request['id'] === 0 && $request['name'] === '79135397707');
|
||||
});
|
||||
|
||||
it('deleteProject POSTs to /admin/visit/rt-project-delete with JSON {id:"<string>"}', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response(
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
/*
|
||||
* Разовая починка после прод-инцидента 11–12.07.2026: наш ежедневный updateProject слал
|
||||
* имя без метки канала, и кабинет стирал метку «B<n>_». Строка без метки → лид от неё
|
||||
* приходит с project без метки → webhook пишет platform=DIRECT вместо B1/B2/B3.
|
||||
*
|
||||
* Команда возвращает метку на место. Payload собирается ИЗ ЖИВОЙ СТРОКИ кабинета
|
||||
* (лимит/дни/регионы/вкл-выкл — как есть), меняется ровно одно поле — name. Регионы
|
||||
* из кабинета уже в кодах поставщика, повторно их переводить нельзя.
|
||||
*/
|
||||
|
||||
beforeEach(function (): void {
|
||||
Cache::store('redis')->put('supplier:session', [
|
||||
'phpsessid' => 'test-session',
|
||||
'csrf' => 'test-csrf',
|
||||
], now()->addHour());
|
||||
|
||||
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
|
||||
});
|
||||
|
||||
/** @param array<int, array<string,mixed>> $rows */
|
||||
function fakeCabinet(array $rows): void
|
||||
{
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => $rows], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '1'], 200),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Строка кабинета с потерянной меткой канала. */
|
||||
function bareCabinetRow(string $id, string $src, string $content): array
|
||||
{
|
||||
return [
|
||||
'id' => $id, 'src' => $src, 'tag' => 'РФ', 'name' => $content, 'content' => $content,
|
||||
'type' => 'calls', 'lim' => '5', 'status' => true, 'workdays' => ['1', '2', '3', '4', '5'],
|
||||
'regions' => '24;66', 'regions_reverse' => false,
|
||||
];
|
||||
}
|
||||
|
||||
it('dry-run: показывает строки с потерянной меткой, но НИЧЕГО не пишет в кабинет', function (): void {
|
||||
SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79135397707',
|
||||
'supplier_external_id' => '6001', 'inactive_since' => null,
|
||||
]);
|
||||
fakeCabinet([bareCabinetRow('6001', 'rt', '79135397707')]);
|
||||
|
||||
$this->artisan('supplier:repair-project-names')
|
||||
->expectsOutputToContain('B1_79135397707')
|
||||
->assertExitCode(0);
|
||||
|
||||
Http::assertNotSent(fn (Request $r): bool => str_contains($r->url(), 'rt-project-save'));
|
||||
});
|
||||
|
||||
it('--apply: возвращает метку канала, сохраняя лимит/дни/регионы/вкл-выкл как есть', function (): void {
|
||||
SupplierProject::factory()->create([
|
||||
'platform' => 'B2', 'signal_type' => 'call', 'unique_key' => '79135397707',
|
||||
'supplier_external_id' => '6002', 'inactive_since' => null,
|
||||
]);
|
||||
fakeCabinet([bareCabinetRow('6002', 'bl', '79135397707')]);
|
||||
|
||||
$this->artisan('supplier:repair-project-names', ['--apply' => true])->assertExitCode(0);
|
||||
|
||||
Http::assertSent(function (Request $r): bool {
|
||||
if (! str_contains($r->url(), 'rt-project-save')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $r['id'] === 6002
|
||||
&& $r['name'] === 'B2_79135397707'
|
||||
&& $r['content'] === '79135397707'
|
||||
&& $r['srcbl'] === true && $r['srcrt'] === false && $r['srcmt'] === false
|
||||
&& $r['limit'] === 5
|
||||
&& $r['status'] === true
|
||||
&& $r['workdays'] === ['1', '2', '3', '4', '5']
|
||||
// Регионы из кабинета — уже коды поставщика, отдаём обратно без перевода.
|
||||
&& $r['regions'] === [24, 66];
|
||||
});
|
||||
});
|
||||
|
||||
it('не трогает строки, у которых метка на месте, и чужие строки кабинета', function (): void {
|
||||
SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79135397707',
|
||||
'supplier_external_id' => '6003', 'inactive_since' => null,
|
||||
]);
|
||||
|
||||
$withPrefix = bareCabinetRow('6003', 'rt', '79135397707');
|
||||
$withPrefix['name'] = 'B1_79135397707';
|
||||
$foreign = bareCabinetRow('9999', 'rt', 'chужой.ru'); // не наш external_id
|
||||
|
||||
fakeCabinet([$withPrefix, $foreign]);
|
||||
|
||||
$this->artisan('supplier:repair-project-names', ['--apply' => true])->assertExitCode(0);
|
||||
|
||||
Http::assertNotSent(fn (Request $r): bool => str_contains($r->url(), 'rt-project-save'));
|
||||
});
|
||||
|
||||
it('--only-active: чинит только включённые у нас строки (выключенные лидов не носят)', function (): void {
|
||||
SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79135397707',
|
||||
'supplier_external_id' => '6004', 'inactive_since' => null,
|
||||
]);
|
||||
SupplierProject::factory()->create([
|
||||
'platform' => 'B3', 'signal_type' => 'call', 'unique_key' => '79990001122',
|
||||
'supplier_external_id' => '6005', 'inactive_since' => now(),
|
||||
]);
|
||||
fakeCabinet([
|
||||
bareCabinetRow('6004', 'rt', '79135397707'),
|
||||
bareCabinetRow('6005', 'mt', '79990001122'),
|
||||
]);
|
||||
|
||||
$this->artisan('supplier:repair-project-names', ['--apply' => true, '--only-active' => true])
|
||||
->assertExitCode(0);
|
||||
|
||||
Http::assertSent(fn (Request $r): bool => str_contains($r->url(), 'rt-project-save') && $r['id'] === 6004);
|
||||
Http::assertNotSent(fn (Request $r): bool => str_contains($r->url(), 'rt-project-save') && $r['id'] === 6005);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
use App\Exceptions\Supplier\SupplierAuthException;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||||
use App\Jobs\Supplier\VerifySupplierOrderJob;
|
||||
use App\Mail\SupplierCriticalAlertMail;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
@@ -400,6 +401,10 @@ test('idempotent: repeat run with no changes → updateProject not duplicate', f
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('respects time budget by stopping at 20:55 МСК', function (): void {
|
||||
// Итоговая проверка (VerifySupplierOrderJob) диспатчится в finally и сама шлёт HTTP
|
||||
// при реальном прогоне — вне области этого теста (тут проверяем только САМ робот).
|
||||
Bus::fake([VerifySupplierOrderJob::class]);
|
||||
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow'));
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Supplier\VerifySupplierOrderJob;
|
||||
use App\Mail\SupplierOrderMismatchMail;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-07-09 18:04:00', 'Europe/Moscow'));
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
/**
|
||||
* Активный call-проект daily_limit_target=3 → задуманный заказ order=max(3, ceil(3/3))=3,
|
||||
* поделённый на B1/B2/B3 (largest-remainder) → по 1 на каждую площадку.
|
||||
* Снимок на завтра ОБЯЗАТЕЛЕН — collectEligibleProjects() читает project_routing_snapshots,
|
||||
* не live projects (Task 2.9 slepok-инвариант).
|
||||
*/
|
||||
function seedActiveCallProject(string $identifier, int $limit): Project
|
||||
{
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => $identifier,
|
||||
'daily_limit_target' => $limit,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project, dailyLimit: $limit, deliveryDaysMask: 127);
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Наша supplier_project, которую мы уже выключили (inactive_since IS NOT NULL) —
|
||||
* SupplierOrderVerifier::diff должен поймать kind=should_be_off, если поставщик
|
||||
* до сих пор показывает её включённой (status=1). supplier_external_id — признак
|
||||
* «наша строка» в normalizeLive() (баг 1: tag НЕ годится — робот шлёт регион/«РФ»).
|
||||
*/
|
||||
function seedPausedSupplierProject(string $identifier, string $platform, string $signalType, string $externalId): SupplierProject
|
||||
{
|
||||
return SupplierProject::factory()->create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => $signalType,
|
||||
'unique_key' => $identifier,
|
||||
'supplier_external_id' => $externalId,
|
||||
'inactive_since' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('нет расхождений → письма нет, статус ok', function (): void {
|
||||
Mail::fake();
|
||||
seedActiveCallProject('79135397707', 3);
|
||||
|
||||
SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79135397707',
|
||||
'supplier_external_id' => '1001', 'current_limit' => 1, 'inactive_since' => null,
|
||||
]);
|
||||
SupplierProject::factory()->create([
|
||||
'platform' => 'B2', 'signal_type' => 'call', 'unique_key' => '79135397707',
|
||||
'supplier_external_id' => '1002', 'current_limit' => 1, 'inactive_since' => null,
|
||||
]);
|
||||
SupplierProject::factory()->create([
|
||||
'platform' => 'B3', 'signal_type' => 'call', 'unique_key' => '79135397707',
|
||||
'supplier_external_id' => '1003', 'current_limit' => 1, 'inactive_since' => null,
|
||||
]);
|
||||
|
||||
$this->mock(SupplierPortalClient::class, function ($m): void {
|
||||
$m->shouldReceive('listProjects')->andReturn([
|
||||
['id' => '1001', 'src' => 'rt', 'name' => 'B1_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
|
||||
['id' => '1002', 'src' => 'bl', 'name' => 'B2_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
|
||||
['id' => '1003', 'src' => 'mt', 'name' => 'B3_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
|
||||
]);
|
||||
});
|
||||
|
||||
(new VerifySupplierOrderJob(attempt: 2))->handle();
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
$row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first();
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row->status)->toBe('ok')->and($row->mismatch_count)->toBe(0);
|
||||
});
|
||||
|
||||
it('пауза не дошла (should_be_off) → письмо + статус mismatch на попытке 2', function (): void {
|
||||
Mail::fake();
|
||||
seedPausedSupplierProject('automoney.ru', 'B3', 'site', '2001');
|
||||
|
||||
$this->mock(SupplierPortalClient::class, function ($m): void {
|
||||
$m->shouldReceive('listProjects')->andReturn([
|
||||
['id' => '2001', 'src' => 'mt', 'name' => 'B3_automoney.ru', 'content' => 'automoney.ru', 'type' => 'hosts', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'],
|
||||
]);
|
||||
});
|
||||
|
||||
(new VerifySupplierOrderJob(attempt: 2))->handle();
|
||||
|
||||
Mail::assertQueued(SupplierOrderMismatchMail::class);
|
||||
$row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first();
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row->status)->toBe('mismatch')->and($row->mismatch_count)->toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
* Прод-инцидент 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);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Services\Supplier\SupplierOrderPlan;
|
||||
use Carbon\Carbon;
|
||||
|
||||
it('строит intended по формуле: два клиента 3 и 2 на один call-источник → заказ 3, деление 1/1/1', function (): void {
|
||||
$tomorrow = Carbon::parse('2026-07-13'); // понедельник
|
||||
$mask = 1 << ($tomorrow->isoWeekday() - 1); // бит пн
|
||||
|
||||
$p1 = new Project(['signal_type' => 'call', 'signal_identifier' => '79135397707']);
|
||||
$p1->daily_limit_target = 3;
|
||||
$p1->delivery_days_mask = $mask;
|
||||
$p1->regions = [];
|
||||
|
||||
$p2 = new Project(['signal_type' => 'call', 'signal_identifier' => '79135397707']);
|
||||
$p2->daily_limit_target = 2;
|
||||
$p2->delivery_days_mask = $mask;
|
||||
$p2->regions = [];
|
||||
|
||||
$plan = SupplierOrderPlan::build(collect([$p1, $p2]), $tomorrow);
|
||||
|
||||
// order = max(3, ceil(5/3)=2) = 3; split B1/B2/B3 = 1/1/1
|
||||
expect($plan['intended'])->toEqualCanonicalizing([
|
||||
['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B1', 'limit' => 1],
|
||||
['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B2', 'limit' => 1],
|
||||
['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B3', 'limit' => 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('проект не eligible на завтра (нет бита дня) → не попадает в intended', function (): void {
|
||||
$tomorrow = Carbon::parse('2026-07-13'); // пн
|
||||
$notMondayMask = 1 << (Carbon::parse('2026-07-14')->isoWeekday() - 1); // вт
|
||||
|
||||
$p = new Project(['signal_type' => 'call', 'signal_identifier' => '7911']);
|
||||
$p->daily_limit_target = 5;
|
||||
$p->delivery_days_mask = $notMondayMask;
|
||||
$p->regions = [];
|
||||
|
||||
$plan = SupplierOrderPlan::build(collect([$p]), $tomorrow);
|
||||
|
||||
expect($plan['intended'])->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Supplier\SupplierOrderVerifier;
|
||||
|
||||
it('нет расхождений когда живое совпадает с задуманным', function (): void {
|
||||
$intended = [
|
||||
['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B1', 'limit' => 1],
|
||||
];
|
||||
$shouldBeOff = [];
|
||||
$live = [
|
||||
['signal_type' => 'call', 'identifier' => '79135397707', 'platform' => 'B1', 'limit' => 1, 'on' => true],
|
||||
];
|
||||
|
||||
expect(SupplierOrderVerifier::diff($intended, $shouldBeOff, $live))->toBe([]);
|
||||
});
|
||||
|
||||
it('ловит limit_drift', function (): void {
|
||||
$intended = [['signal_type' => 'site', 'identifier' => 'inssmart.ru', 'platform' => 'B2', 'limit' => 2]];
|
||||
$live = [['signal_type' => 'site', 'identifier' => 'inssmart.ru', 'platform' => 'B2', 'limit' => 5, 'on' => true]];
|
||||
|
||||
$m = SupplierOrderVerifier::diff($intended, [], $live);
|
||||
|
||||
expect($m)->toHaveCount(1)
|
||||
->and($m[0]['kind'])->toBe('limit_drift')
|
||||
->and($m[0]['expected'])->toBe(2)
|
||||
->and($m[0]['actual'])->toBe(5);
|
||||
});
|
||||
|
||||
it('ловит should_be_off (пауза не дошла = деньги)', function (): void {
|
||||
$shouldBeOff = [['signal_type' => 'site', 'identifier' => 'automoney.ru', 'platform' => 'B3']];
|
||||
$live = [['signal_type' => 'site', 'identifier' => 'automoney.ru', 'platform' => 'B3', 'limit' => 1, 'on' => true]];
|
||||
|
||||
$m = SupplierOrderVerifier::diff([], $shouldBeOff, $live);
|
||||
|
||||
expect($m)->toHaveCount(1)->and($m[0]['kind'])->toBe('should_be_off');
|
||||
});
|
||||
|
||||
it('ловит should_be_on', function (): void {
|
||||
$intended = [['signal_type' => 'call', 'identifier' => '7911', 'platform' => 'B1', 'limit' => 1]];
|
||||
$live = [['signal_type' => 'call', 'identifier' => '7911', 'platform' => 'B1', 'limit' => 1, 'on' => false]];
|
||||
|
||||
$m = SupplierOrderVerifier::diff($intended, [], $live);
|
||||
|
||||
expect($m)->toHaveCount(1)->and($m[0]['kind'])->toBe('should_be_on');
|
||||
});
|
||||
|
||||
it('ловит missing (задумали, у поставщика нет)', function (): void {
|
||||
$intended = [['signal_type' => 'call', 'identifier' => '7911', 'platform' => 'B2', 'limit' => 1]];
|
||||
|
||||
$m = SupplierOrderVerifier::diff($intended, [], []);
|
||||
|
||||
expect($m)->toHaveCount(1)->and($m[0]['kind'])->toBe('missing');
|
||||
});
|
||||
|
||||
it('ловит orphan_extra (у поставщика лишняя наша строка)', function (): void {
|
||||
$live = [['signal_type' => 'site', 'identifier' => 'ghost.ru', 'platform' => 'B1', 'limit' => 3, 'on' => true]];
|
||||
|
||||
$m = SupplierOrderVerifier::diff([], [], $live);
|
||||
|
||||
expect($m)->toHaveCount(1)->and($m[0]['kind'])->toBe('orphan_extra');
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+197
@@ -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-обрыв) — решаются на этапе плана, дизайн не меняют.
|
||||
Reference in New Issue
Block a user