Парсер CSV-выгрузки лидов crm.bp-gr.ru (ТЗ §6.2/§6.3): срезает UTF-8 BOM,
разбирает строки через str_getcsv, валидирует телефон (7XXXXXXXXXX) и даты
(Y/m/d H:i:s), срезает префикс B[123]_ из названия проекта. Невалидные
строки не роняют парсинг — собираются в errors[] с абсолютным номером строки.
Тесты: 5/5 (unit, без DB).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Компоненты:
- SupplierQuotaAllocator: pure function distribution-логики
- site/call: B1=ceil(t/3), B2=ceil(r/2), B3=remainder
- sms-with-keyword: B2+B3 only (B1=0, spec §2.2 — B1 не поддерживает СМС)
- Workdays/regions union, weekday-фильтрация по Europe/Moscow
- Возвращает null когда нет projects на targetWeekday
- SyncSupplierProjectsJob: 20:30 МСК cron
- SupplierProject::on('pgsql_supplier') — cross-tenant видимость
- whereNull('inactive_since') — sync только активные
- Адаптер Project → stdClass: daily_limit_target → daily_limit,
delivery_days_mask bits → workdays, region_mask bits → regions
(mask=255 catch-all → regions=[])
- per-supplier_project failure-isolation (continue на one bad)
- mass-fail abort: 50 consecutive transient → SupplierCriticalAlertMail
+ Sentry + break
- sticky auth → email('sticky_auth') + Sentry + throw
- time budget cutoff 20:55 МСК (5-мин safety margin до 21:00)
- supplier_sync_log per action (action='create'/'update', http_status,
error_message)
- SupplierCriticalAlertMail: ShouldQueue Mailable + text template
- Unisender Go SMTP relay через config('services.supplier.alert_email')
NOTE про connection: следуем Task 3 learning — не используем public \$connection
(это queue connection, не DB). Queries через Model::on('pgsql_supplier').
NOTE про DB::transaction: НЕ оборачиваем syncOne, т.к. HTTP-call к supplier
выходит за границы транзакции (атомарности всё равно нет). Два DB-write
последовательно; ошибка между ними recoverable через retry на следующем cron-tick
(supplier_external_id уже записан, скип через SupplierProjectDto::equals()).
+18 тестов (10 allocator + 8 sync job).
phpstan-baseline.neon: +7 entries для PHPStan template-covariance issue в
SupplierQuotaAllocatorTest — \`Collection<int, object{...literal}&stdClass>\` не
suptype \`Collection<int, stdClass>\` per PHPStan invariance rule. Production
code clean (0 baseline entries).
Закрывает 4 Important issues из code-review Task 4 (a2c5374):
- #1 SupplierPortalClient: parse_url host validation → SupplierClientException
вместо silent cookie skip + false-positive SupplierAuthException
- #2 dispatch_sync(RefreshSupplierSessionJob) обёрнут try/catch (request retry +
loadSession) → raw exceptions translated в SupplierAuthException для
consistency с error taxonomy перед Task 5 real Playwright impl
- #3 RefreshSupplierSessionJob stub handle() теперь throws LogicException
с понятным сообщением (вместо silent no-op → confusing 'cache still empty'
error). После Task 5 — LogicException заменяется real Playwright code.
Снят final-модификатор класса (test override через container bind + Laravel
dispatchSync serialization не работает с anonymous classes).
- #5 SupplierProjectDto::equals → canonical order для workdays/regions
через sort в constructor (defense vs PG jsonb non-deterministic order).
Без этого Task 6 SyncJob false-positive обнаруживал бы diff где его нет
→ unnecessary updateProject HTTP calls.
+3 tests в SupplierPortalClientTest (malformed url, 2 retry-translation paths)
+2 tests в новом SupplierProjectDtoTest (order-independent equals + non-equal)
+1 stub-класс ThrowingRefreshSupplierSessionJob (anonymous classes несовместимы
с SerializesModels trait в dispatchSync).
Pest: 38/38 supplier-suite, 574/574 full suite (576 total, 2 skipped, +5 new
tests vs Task 4 baseline). PHPStan 0 errors. Pint clean.
Маппинг мобильных + 30+ ABC-кодов городов на 8 ФО RF (synchronized
с projects.region_mask битами). Мобильные → all-districts (255).
Полный Минсвязи справочник отложен на Plan 5+.
Spec: §6 step 2 routing geo-filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>