After Stage 2 запуска, 18:05 МСК sync читает project_routing_snapshots за tomorrow
МСК, не live projects.is_active. Это закрывает race 18:02 (snapshot) → 18:05 (sync):
клиент мог нажать «пауза» в эти 3 минуты, но мы всё равно докатываем зафиксированный
slepok поставщику (slepok-инвариант).
collectEligibleProjects() переписан с Project::on()->where('is_active', true)
на Project::on()->join('project_routing_snapshots AS snap', ...). Snapshot уже
отфильтрован по is_active/preflight_blocked/frozen_tenant; повторно проверяем
frozen-фильтр на случай freeze в эти 3 минуты. daily_limit_target /
delivery_days_mask / regions переопределяются значениями snapshot (slepok-семантика);
downstream syncGroup() работает без изменений.
Spec §4.2.4b. Closes race 18:02→18:05.
Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.9
Tests:
- tests/Feature/Jobs/Supplier/SyncSupplierProjectsJobSnapshotTest.php (4 new tests, PASS).
- tests/Feature/Supplier/SyncSupplierProjectsJobTest.php — 12 existing tests patched
with insertSnapshotForTomorrow($project) helper (12/12 GREEN).
- tests/Feature/Supplier/SyncSupplierPreflightFilterTest.php — 2 existing tests
patched (2/2 GREEN).
- tests/Pest.php — global helper insertSnapshotForTomorrow().
Combined sync regression: 19/20 PASS + 1 skipped (pre-existing).
Patched via 2 parallel Sonnet subagents per Pravila §15.1; controller-verified
combined regression.
Портал поставщика НЕ делит лимит по площадкам сам (Plan 3 R6 «verified 15→5»
оказался ложным — проверено вживую 2026-05-21 через listProjects): каждый
B-проект честно набирает до своего лимита, поэтому одинаковый лимит на B1/B2/B3
= заказ ×N (звонки/сайт ×3, sms+keyword ×2) → переплата поставщику.
Восстановлен per-platform split (был удалён в R6):
- SupplierQuotaAllocator::distributeForPlatform(order, platforms) —
largest-remainder, Σ долей == заказу (18→6/6/6, 10→4/3/3, 5→3/2).
- SyncSupplierProjectJob (online) + SyncSupplierProjectsJob (ночной):
create / dead-donor / missing / update — по одной save на площадку с её долей.
Online делит daily_limit_target; ночной делит групповой computeOrder.
Сторона выдачи клиенту не затронута (RouteSupplierLeadJob по-прежнему режет по
лимиту клиента). Утечка была только на стороне заказа у поставщика.
Tests: allocator 27/27, online job 9/9, nightly job 12/12, broad supplier
suite green. 2 SupplierPortalClient PlaywrightBridge-теста падают только в
worktree-окружении (нет node-модуля playwright) — pre-existing, доказано stash.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handleOnline/syncGroup: сверка external_id со списком живых проектов портала (listProjects); пересоздание удалённых на портале доноров in-place без удаления записей (на supplier_projects могут висеть лиды/списания). online-режим заполняет supplier_b1/b2/b3_project_id, чтобы UI sync-бейдж не залипал в pending. +3 Pest.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Портал crm.bp-gr.ru возвращает status=Doubles при попытке создать
вторую группу с тем же unique_key. Старый код делал одну B1/B2/B3-группу
на каждый регион проекта — вторая группа молча пропадала.
Теперь оба джоба (SyncSupplierProjectJob + SyncSupplierProjectsJob)
формируют ровно одну группу на идентификатор со всеми регионами:
- regions=[82,83] → tag='РФ', regions=[82,83] в одной группе
- regions=[] → tag='РФ', regions=[] (вся РФ)
- regions=[82] → tag='Москва', regions=[82]
subject_code=null во всех supplier_projects и project_supplier_links.
ProjectService::update() теперь триггерит SyncSupplierProjectJob
при изменении поля regions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Оба job'а инжектят SupplierProjectChannel (DI → FailoverProjectChannel)
вместо прямого SupplierPortalClient. Catch TierEscalatedException +
WindowDeferredException — эскалация/перенос пропускают элемент, не валят job.
SyncSupplierProjectJob (singular): handle переписан — find-or-create local
supplier_projects row, portal-create через channel. ОТКЛОНЕНИЕ ОТ plan Step 8.1:
план писал channel-результат (portal external_id) прямо в projects.supplier_b*_
project_id, но эта колонка — FK на supplier_projects.id (local), не portal id.
Сохранена семантика ensureSupplierProject — job создаёт local row с
supplier_external_id и пишет в FK local id. ensureSupplierProject удалён из
SupplierPortalClient (был единственный consumer — этот job).
SyncSupplierProjectsJob (plural): handle/syncOne принимают channel; create →
createProjectForLiderra, update → updateProjectForLiderra (context-project из
liderraProjects->first() для project_id в очереди яруса 3).
Tests: singular переписан под SupplierProjectChannel mock (6 tests, incl.
idempotency reuse); plural — handle(AjaxProjectChannel) для non-failover
ветки (Http::fake-контракт сохранён). Larastan отложен на T12 (worktree
quirk — гонится в основной копии). Регрессия Pest 966/963/0 / 3 skipped.
Spec §5. Task 8 of 12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live discovery через Playwright MCP (Task 1):
- создан LIDPOTOK_TEST_DELETE_ME (B1+B2+B3) → 3 rt-проекта на портале;
- записаны сетевые запросы /admin/visit/rt-*;
- все три проекта удалены вручную, портал чист.
Endpoints (verified):
- POST /admin/visit/rt-project-save (create id:0, update id:N — same URL)
- POST /admin/visit/rt-project-delete (id строкой)
- GET /admin/visit/rt-projects-load?src=none
Все три — application/json. Конверт ответа:
- success: HTTP 200 + {status:OK, message, result, id?:string}
- error: HTTP 200 + {status:Error, message, result:null}
ID — строка (12721245), приводится к int (fits в int64).
Один save с B1+B2+B3 включёнными создаёт 3 rt-проекта — toPayload()
шлёт ровно один платформенный флаг (srcrt|srcbl|srcmt).
SupplierPortalClient:
- docblock переписан под verified контракт
- listProjects: путь /admin/visit/rt-projects-load + ?src=none query
- saveProject: путь /admin/visit/rt-project-save, asJson, парсинг id
- updateProject: тот же endpoint что save, id:N в body
- deleteProject: путь /admin/visit/rt-project-delete, asJson, id строкой
- new assertStatusOk() — HTTP 200 + status:Error → SupplierClientException
- toPayload(): полный Vuex-payload с маппингом DTO → portal:
- platform B1/B2/B3 → srcrt/srcbl/srcmt (single-true)
- signalType site/call/sms → type:hosts/calls/sms
- workdays int[] → string[]
- status active/paused → bool
- + tag:_lidpotok, name/content из uniqueKey, defaults для show/depth/etc
Tests:
- new: tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php (7 tests,
contract: save+update+delete+list + 2 status:Error error-paths + B2/calls
mapping)
- Sync/Cleanup/Unit тесты обновлены под новый URL + envelope shape.
Закрывает spec §1 honest-caveat «placeholder, не верифицирован»
и журнал решений запись 9. Регрессия: Pest 944/941/0 failed / 3 skipped
/ 2768 assertions / 59.2s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
C1: ProjectResource не возвращал regions → edit-диалог/drawer затирали
сохранённые регионы при сохранении. +поле в toArray().
C2: +integration-тест outbound regions[] через полный SyncSupplierProjectsJob::handle().
I1: расскип NewProjectDialog payload-теста (regions в POST).
I2: assert data.regions в ProjectsStore/UpdateTest (ловит C1 на backend-уровне).
I4: docblock — bulkUpdateRegions legacy (region_mask, не влияет на outbound до Plan 6.5).
M1: CHANGELOG v8.22 — исправлен неверный пример регионов (Москва=82).
Регрессия: Pest 905/902/3sk/0, Vitest 104f/884/3sk/0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SyncSupplierProjectsJob:77 has a time-budget guard that breaks the
sync loop after 20:55 Europe/Moscow. Five of the eight tests in
SyncSupplierProjectsJobTest omitted Carbon::setTestNow(), so they
inherited real wall-clock time and silently failed (job no-ops)
every evening after 20:55 MSK -- a latent test bug since dedaae5
(Plan 3), mis-attributed to a Redis race (quirk 72) in earlier audits.
Pins beforeEach to a fixed pre-cutoff clock; the job code is correct
and unchanged. Verified: 8/8 in isolation, full suite back to green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SyncSupplierProjectsJob::adaptProjectsForAllocator no longer converts
8-bit region_mask via bitmaskToList. Instead direct-copies projects.regions[]
(89-code subject array) into supplier_projects.current_regions / DTO.
region_mask still dual-written for PhonePrefixService backward-compat (Plan 6.5
cleanup will switch readers and drop dual-write).
+2 Pest tests verifying direct copy + empty-array semantics.
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).