Files
portal/docs/superpowers/plans/2026-05-11-supplier-sync-plan3.md
T
Дмитрий 989256b034 docs(plans): Plan 3 (Supplier Sync) implementation plan — 9 Tasks TDD decomposition
Полный TDD-план для реализации parent design 2026-05-11-plan3-supplier-sync-design.md
(commit 1a265b5). 9 Tasks в 2 фазах:

Фаза I — Discovery (Tasks 1-2):
- Task 1: 5 HTTP-фиксаций через mcp__playwright__browser_* (с явным локальным
  снятием Pravila §6 на discovery-сессию + credentials в app/.env)
- Task 2: spec v1.0 → v1.1 (§4.4 observed AJAX formats)

Фаза II — Implementation (Tasks 3-9):
- Task 3: switch supplier-flow на pgsql_supplier BYPASSRLS (закрывает
  BLOCKER #6 + WARN #2/#3 одной правкой) + 4 regression tests
- Task 4: SupplierPortalClient + 3 Exceptions + DTO + 9 unit tests через Http::fake
- Task 5: PlaywrightBridge (Node subprocess) + RefreshSupplierSessionJob +
  Redis 6h TTL + 5 unit tests
- Task 6: SupplierQuotaAllocator (pure function) + SyncSupplierProjectsJob +
  SupplierCriticalAlertMail + 16 tests (9 allocator + 7 sync)
- Task 7: CleanupInactiveSupplierProjectsJob Phase A→B→C ordering safety +
  6 integration tests (включая критический ordering test)
- Task 8: RetryFailedSupplierJobsCommand + 5 Schedule entries +
  4 integration tests
- Task 9: E2E mock-server test (react/http + react/socket, Linux CI only) +
  lefthook pest-supplier-fast pre-commit job

Total ~50-60 новых тестов. 0 schema changes. Метрики приёма: Pest >=608 PASS,
Larastan 0 errors above baseline, gitleaks 0 leaks.

Self-review встроен: spec coverage 10/10, 0 placeholder violations, type
consistency verified across 9 Tasks (DTO/Exceptions/Client methods/connection
name/cache keys/role refs).

+HAR в cspell-words.txt (HTTP Archive format — для Task 1 discovery терминологии).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:41:04 +03:00

117 KiB
Raw Blame History

Plan 3 (Supplier Sync) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Закрыть BLOCKER #6 (failed_webhook_jobs RLS NULL tenant) + WARN #2/#3 (Plan 2.6 backlog про LeadRouter/ResetCmd под crm_app_user) и реализовать направление Лидерра → Поставщик: Playwright session manager, 20:30 МСК cron SyncSupplierProjectsJob, 180d cleanup, автоматический retry failed jobs.

Architecture: 9 Tasks в 2 фазах. Фаза I — Discovery (Tasks 12): через mcp__playwright__browser_* MCP записываем 5 HTTP-фиксаций реальных AJAX-команд поставщика crm.bp-gr.ru; обновляем parent spec §4.4. Фаза II — Implementation (Tasks 39): переключение supplier-flow на pgsql_supplier connection с crm_supplier_worker BYPASSRLS-ролью (закрывает BLOCKER #6 + WARN #2/#3 одной правкой), SupplierPortalClient HTTP-обёртка, RefreshSupplierSessionJob + Node.js Playwright subprocess, SyncSupplierProjectsJob + чистый allocator, CleanupInactiveSupplierProjectsJob с порядком Phase A→B→C, RetryFailedSupplierJobsCommand, E2E integration test (Linux CI only). 0 schema changes.

Tech Stack: PHP 8.3, Laravel 13.7, Pest 4, PostgreSQL 16 (pgsql_supplier connection с BYPASSRLS, role crm_supplier_worker уже создана Plan 2.6 #iv 7899071), Redis 7 (Memurai на dev), Node.js + Playwright chromium (headless), Symfony Process (PHP-subprocess bridge), Mockery, Larastan, Unisender Go (email алерты).

Parent spec: 2026-05-11-plan3-supplier-sync-design.md (commit 1a265b5). Inherits from: Plan 1 Foundation 001d781 + Plan 2 Webhook+Routing d5aa972 + Plan 2.5 hotfix c1ae195+1ba1df8 + Plan 2.6 cleanup 7899071.


Карта файлов

Файл Действие Назначение Task
app/tests/Fixtures/SupplierPortal/login.json Create Discovery HAR login → cookie+CSRF response 1
app/tests/Fixtures/SupplierPortal/projects-load.json Create Discovery rt-projects-load запрос+ответ 1
app/tests/Fixtures/SupplierPortal/project-save.json Create Discovery rt-project-save запрос+ответ 1
app/tests/Fixtures/SupplierPortal/project-update.json Create Discovery rt-project-update запрос+ответ 1
app/tests/Fixtures/SupplierPortal/project-delete.json Create Discovery rt-project-delete запрос+ответ 1
docs/superpowers/specs/2026-05-10-supplier-integration-design.md Modify Добавить §4.4 «AJAX endpoints — observed formats» + bump v1.0 → v1.1 2
app/config/database.php Modify +ключ pgsql_supplier (BYPASSRLS connection) 3
app/.env.example Modify +DB_SUPPLIER_USERNAME/DB_SUPPLIER_PASSWORD/SUPPLIER_LOGIN/SUPPLIER_PASSWORD/SUPPLIER_PORTAL_URL/SUPPLIER_ALERT_EMAIL 3
app/app/Jobs/RouteSupplierLeadJob.php Modify protected $connection = 'pgsql_supplier' + удалить inline-warnings 258-263 3
app/app/Services/LeadRouter.php Modify Project::on('pgsql_supplier') + удалить inline-warnings 25-29 3
app/app/Console/Commands/ResetDeliveredTodayCommand.php Modify Project::on('pgsql_supplier') + удалить inline-warnings 16-18 3
app/tests/Feature/Supplier/SupplierConnectionTest.php Create Regression tests BLOCKER #6 + WARN #2 + WARN #3 3
app/app/Services/Supplier/Dto/SupplierProjectDto.php Create Read-only DTO (platform/signal_type/unique_key/limit/workdays/regions/status) 4
app/app/Exceptions/Supplier/SupplierException.php Create Abstract base exception 4
app/app/Exceptions/Supplier/SupplierAuthException.php Create 401/403 sticky после refresh-retry 4
app/app/Exceptions/Supplier/SupplierTransientException.php Create 5xx/network/timeout retryable 4
app/app/Exceptions/Supplier/SupplierClientException.php Create 4xx 400/404/422 non-retryable 4
app/app/Services/Supplier/SupplierPortalClient.php Create HTTP-обёртка над rt-* + Redis cookie/CSRF + retry на 401 4
app/tests/Unit/Supplier/SupplierPortalClientTest.php Create Unit tests через Http::fake 4
app/playwright/package.json Create Изолированная npm dep playwright 5
app/playwright/refresh-session.js Create Node subprocess: chromium → login → cookies+CSRF → stdout JSON 5
app/app/Services/Supplier/PlaywrightBridge.php Create Symfony Process обёртка над node subprocess 5
app/app/Jobs/Supplier/RefreshSupplierSessionJob.php Create Spawn PlaywrightBridge + Redis lock + 6h TTL cache 5
app/app/Console/Commands/SupplierSessionRefreshCommand.php Create supplier:session:refresh (ручной запуск) 5
app/tests/Unit/Supplier/PlaywrightBridgeTest.php Create Unit с mocked Symfony\Process 5
app/tests/Unit/Supplier/RefreshSupplierSessionJobTest.php Create Unit с mocked PlaywrightBridge 5
app/app/Services/Supplier/SupplierQuotaAllocator.php Create Pure function: B1/B2/B3 distribution + workdays/regions union 6
app/app/Jobs/Supplier/SyncSupplierProjectsJob.php Create 20:30 МСК cron + per-supplier_project isolation + mass-fail abort 6
app/app/Mail/SupplierCriticalAlertMail.php Create Mailable для critical alerts (email via Unisender) 6
app/config/services.php Modify Добавить supplier.alert_email ключ 6
app/tests/Unit/Supplier/SupplierQuotaAllocatorTest.php Create 9 unit-тестов на distribution-математику 6
app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php Create Integration tests с Http::fake 6
app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php Create Phase A re-activate → B mark → C delete 180d 7
app/tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php Create Integration + критический ordering test 7
app/app/Console/Commands/RetryFailedSupplierJobsCommand.php Create supplier:retry-failed hourly + лимит 10/24h 8
app/tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php Create Integration tests 8
app/routes/console.php Modify +5 Schedule entries 8
app/tests/Browser/SupplierIntegrationE2ETest.php Create Linux CI only mock-server → full flow 9
lefthook.yml Modify +2 jobs (playwright-fixtures-syntax + pest-supplier-fast) 9

Task 1: Discovery через Playwright MCP — записать 5 HTTP-фиксаций реальных AJAX-команд поставщика

Files:

  • Create: app/tests/Fixtures/SupplierPortal/login.json
  • Create: app/tests/Fixtures/SupplierPortal/projects-load.json
  • Create: app/tests/Fixtures/SupplierPortal/project-save.json
  • Create: app/tests/Fixtures/SupplierPortal/project-update.json
  • Create: app/tests/Fixtures/SupplierPortal/project-delete.json

Prerequisites (BLOCKER для старта):

  • Заказчик передал SUPPLIER_LOGIN + SUPPLIER_PASSWORD через защищённый канал (НЕ plaintext в чат).
  • Заказчик дал явное локальное снятие Pravila §6 на эту discovery-сессию.

Не TDD — это manual probe; артефакт — 5 JSON-fixtures для использования в Task 4 Http::fake().

  • Step 1: Получить credentials и сохранить в app/.env
# Заказчик передаёт credentials. Записать в app/.env (не коммитить!):
# SUPPLIER_LOGIN=<login_от_заказчика>
# SUPPLIER_PASSWORD=<password_от_заказчика>
# SUPPLIER_PORTAL_URL=https://crm.bp-gr.ru

Verify: grep -c "SUPPLIER_LOGIN" app/.env должен вернуть 1.

  • Step 2: Создать директорию для фикстур
mkdir -p app/tests/Fixtures/SupplierPortal

Verify: ls -la app/tests/Fixtures/SupplierPortal существует, пуст.

  • Step 3: Через Playwright MCP залогиниться, записать login fixture

Использовать MCP-tools:

  1. mcp__playwright__browser_navigatehttps://crm.bp-gr.ru/admin/user/login (или другой login URL — узнаем при навигации)
  2. mcp__playwright__browser_snapshot — увидеть DOM, найти input-names для login/password
  3. mcp__playwright__browser_fill_form → заполнить login + password
  4. mcp__playwright__browser_network_requests — записать ВСЕ requests за login flow
  5. mcp__playwright__browser_click на submit
  6. Снова mcp__playwright__browser_network_requests — увидеть login POST с cookie+CSRF response

Сохранить в app/tests/Fixtures/SupplierPortal/login.json:

{
  "step": "login",
  "request": {
    "method": "POST",
    "url": "<observed-url>",
    "headers": {
      "<observed-headers>"
    },
    "body": "<observed-body-form-or-json>"
  },
  "response": {
    "status": 200,
    "headers": {
      "set-cookie": [
        "<observed-cookies>"
      ]
    },
    "body": "<observed-response-body-or-redirect>"
  },
  "extracted": {
    "session_cookie_name": "<PHPSESSID-или-JSESSIONID-или-другое>",
    "csrf_token_selector": "<meta[name=csrf-token] или hidden input или другое>",
    "csrf_token_value": "<observed-value>"
  },
  "discovered_at": "2026-05-11T..."
}
  • Step 4: Записать projects-load fixture (rt-projects-load)

Через MCP:

  1. mcp__playwright__browser_navigate → URL списка проектов (например /admin/project/list)
  2. mcp__playwright__browser_network_requests — найти AJAX-запрос с rt-projects-load или аналог
  3. Извлечь полный request+response

Сохранить в app/tests/Fixtures/SupplierPortal/projects-load.json тем же форматом (request/response).

  • Step 5: Записать project-save fixture (rt-project-save)

Через MCP создать тестовый проект __claude_probe_<unix_timestamp>:

  1. mcp__playwright__browser_navigate → форма создания проекта
  2. mcp__playwright__browser_fill_form → заполнить минимум обязательных полей (name=__claude_probe_<ts>, signal_type=site, identifier=example.com, daily_limit=1, workdays=Пн-Пт)
  3. mcp__playwright__browser_click Save
  4. mcp__playwright__browser_network_requests — извлечь rt-project-save request+response (response должен содержать новый supplier_external_id)

Сохранить в app/tests/Fixtures/SupplierPortal/project-save.json. Запомнить supplier_external_id для Step 6/7.

  • Step 6: Записать project-update fixture (rt-project-update)

Через MCP изменить daily_limit тестового проекта на 2:

  1. Navigate в edit-форму
  2. Изменить daily_limit
  3. Submit
  4. mcp__playwright__browser_network_requests — извлечь rt-project-update

Сохранить в app/tests/Fixtures/SupplierPortal/project-update.json.

  • Step 7: Записать project-delete fixture (rt-project-delete)

Через MCP удалить тестовый проект __claude_probe_<ts>:

  1. Найти Delete button или контекстное меню
  2. Click Delete
  3. mcp__playwright__browser_handle_dialog accept (если confirm dialog)
  4. mcp__playwright__browser_network_requests — извлечь rt-project-delete

Сохранить в app/tests/Fixtures/SupplierPortal/project-delete.json. Запомнить: вернул ли поставщик 200/204/404, soft-delete или hard?

  • Step 8: Verify все 5 fixtures валидные JSON
for f in app/tests/Fixtures/SupplierPortal/*.json; do
  echo "Checking: $f"
  python -m json.tool "$f" > /dev/null && echo "  OK" || echo "  INVALID JSON"
done

Expected: 5 «OK», 0 «INVALID JSON».

  • Step 9: Verify нет credentials в фикстурах
grep -i "$SUPPLIER_LOGIN\|$SUPPLIER_PASSWORD" app/tests/Fixtures/SupplierPortal/*.json && echo "LEAK FOUND" || echo "Clean"

Expected: «Clean». Если LEAK FOUND — заменить значение на <REDACTED> или dummy test_login/test_password.

  • Step 10: Commit fixtures
git add app/tests/Fixtures/SupplierPortal/
git commit -m "feat(supplier): Plan 3 Task 1 — discovery fixtures (5 rt-* HTTP-recordings)

Записаны через mcp__playwright__browser_network_requests на crm.bp-gr.ru
с явным локальным снятием Pravila §6 на discovery-сессию.

Артефакт: 5 JSON-фиксаций (login, projects-load, project-save,
project-update, project-delete) → baseline для Task 4 Http::fake().

Тестовый supplier-проект __claude_probe_<ts> создан и удалён за собой."

Verify: git log --oneline -1 показывает новый commit.


Task 2: Spec update — добавить §4.4 «AJAX endpoints — observed formats»

Files:

Не TDD — pure documentation.

  • Step 1: Открыть spec, добавить §4.4 после §4.3 «Резервирование push'а настроек»

Контент §4.4 базируется на разобранных Task 1 фикстурах. Структура:

### 4.4. AJAX endpoints — observed formats (v1.1, 2026-05-11)

Разобрано через Playwright MCP discovery 2026-05-11 (см. fixtures
`app/tests/Fixtures/SupplierPortal/*.json`).

**Login flow:**
- URL: `<observed>`
- Method: POST application/x-www-form-urlencoded
- Поля: `login` / `password` (или альтернативные имена)
- Success signal: `<observed redirect или cookie>`
- Session cookie: `<PHPSESSID|JSESSIONID|...>`, HttpOnly: true/false
- CSRF: `<meta name=csrf-token content=...>` ИЛИ hidden input ИЛИ header

**rt-projects-load:**
- URL: `<observed>`
- Method: GET/POST
- Response: JSON `[{id, name, signal_type, ...}]` или другой формат

**rt-project-save:**
- URL: `<observed>`
- Method: POST
- Required fields: `<observed>`
- Response: `{id: <new_id>, ...}` или другой

**rt-project-update:**
- URL: `<observed>`  
- Method: POST/PATCH
- Required fields: `<observed>`
- Принимает: полный объект ИЛИ diff

**rt-project-delete:**
- URL: `<observed>`
- Method: POST/DELETE
- Семантика: soft / hard
- Response при повторе: 200 / 404

**HTTP error semantics:**
- bad payload: <400|422>
- auth fail: <401|403>
- rate limit: <429 если есть>
  • Step 2: Bump версию spec'а v1.0 → v1.1 в шапке

В начале файла найти **Дата:** 2026-05-10 и заменить на:

**Дата:** 2026-05-10 (v1.0) → 2026-05-11 (v1.1 — Plan 3 Task 2: добавлен §4.4 AJAX observed formats)
  • Step 3: Lint markdown локально
npx markdownlint-cli2 docs/superpowers/specs/2026-05-10-supplier-integration-design.md

Expected: 0 errors. Если есть — fix.

  • Step 4: Commit
git add docs/superpowers/specs/2026-05-10-supplier-integration-design.md
git commit -m "docs(specs): Plan 3 Task 2 — spec v1.0 → v1.1, добавлен §4.4 observed AJAX formats

Базируется на 5 фиксациях Task 1. Закрывает open question §11 spec'а
(«план реализации после подтверждения дизайна»)."
  • Step 5: STOP-GATE — ждать «ок, формат разобран корректно» от заказчика

Перед стартом Task 4 (SupplierPortalClient) необходимо подтверждение что разобранные формы корректны. Task 3 можно начинать параллельно (он не зависит от формата AJAX).


Task 3: Switch supplier-flow на pgsql_supplier (BYPASSRLS) — закрыть BLOCKER #6 + WARN #2 + WARN #3

Files:

  • Modify: app/config/database.php

  • Modify: app/.env.example

  • Modify: app/app/Jobs/RouteSupplierLeadJob.php

  • Modify: app/app/Services/LeadRouter.php

  • Modify: app/app/Console/Commands/ResetDeliveredTodayCommand.php

  • Create: app/tests/Feature/Supplier/SupplierConnectionTest.php

  • Step 1: Добавить pgsql_supplier connection в app/config/database.php

Найти секцию 'connections' => [...] и добавить рядом с существующим 'pgsql' => [...]:

'pgsql_supplier' => array_merge(
    config('database.connections.pgsql', []),
    [
        'username' => env('DB_SUPPLIER_USERNAME', env('DB_USERNAME')),
        'password' => env('DB_SUPPLIER_PASSWORD', env('DB_PASSWORD')),
        'options' => array_merge(
            config('database.connections.pgsql.options', []),
            ['_role_purpose' => 'crm_supplier_worker (BYPASSRLS) for supplier-flow jobs']
        ),
    ]
),
  • Step 2: Добавить env-keys в app/.env.example

В конец файла (или в соответствующую секцию):

# Supplier Sync (Plan 3) — crm_supplier_worker BYPASSRLS connection
DB_SUPPLIER_USERNAME=crm_supplier_worker
DB_SUPPLIER_PASSWORD=

# Supplier Portal credentials (Playwright login)
SUPPLIER_LOGIN=
SUPPLIER_PASSWORD=
SUPPLIER_PORTAL_URL=https://crm.bp-gr.ru

# Supplier alerts (email через Unisender Go relay)
SUPPLIER_ALERT_EMAIL=

Дублировать те же entries в app/.env локально (без коммита .env).

  • Step 3: Создать failing test — RouteSupplierLeadJob использует pgsql_supplier

Create app/tests/Feature/Supplier/SupplierConnectionTest.php:

<?php

declare(strict_types=1);

use App\Jobs\RouteSupplierLeadJob;
use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Models\Project;
use App\Models\FailedWebhookJob;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;

uses(DatabaseTransactions::class);

test('RouteSupplierLeadJob uses pgsql_supplier connection by default', function () {
    $job = new RouteSupplierLeadJob(supplierLeadId: 1);
    expect((new ReflectionProperty($job, 'connection'))->getValue($job))
        ->toBe('pgsql_supplier');
});
  • Step 4: Run failing test
cd app && php vendor/bin/pest tests/Feature/Supplier/SupplierConnectionTest.php --filter="uses pgsql_supplier connection" 2>&1

Expected: FAIL — $connection undefined or = null (default).

  • Step 5: Установить $connection = 'pgsql_supplier' в RouteSupplierLeadJob

Открыть app/app/Jobs/RouteSupplierLeadJob.php. В начале класса добавить:

class RouteSupplierLeadJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * Plan 3 Task 3: supplier-flow работает под BYPASSRLS-роль crm_supplier_worker.
     * Закрывает BLOCKER #6 (NULL tenant в failed_webhook_jobs) + WARN #2/#3
     * (LeadRouter/ResetCmd под crm_app_user без tenant-context).
     * Brainstorm decision: вариант C, см. docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.
     */
    public $connection = 'pgsql_supplier';

    // ... rest of class
}

Также удалить inline-warnings lines 258-263 (старый комментарий о RLS-проблеме failed_webhook_jobs — теперь закрыто).

  • Step 6: Run test to verify pass
cd app && php vendor/bin/pest tests/Feature/Supplier/SupplierConnectionTest.php --filter="uses pgsql_supplier connection" 2>&1

Expected: PASS.

  • Step 7: Failing test — failed_webhook_jobs accepts NULL tenant (BLOCKER #6 regression)

В тот же SupplierConnectionTest.php добавить:

test('failed_webhook_jobs INSERT with tenant_id=NULL succeeds under pgsql_supplier (BLOCKER #6)', function () {
    // Имитируем INSERT который раньше падал silent под crm_app_user RLS
    DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->insert([
        'tenant_id' => null,
        'payload' => json_encode(['vid' => 42, 'project' => 'B1_test.ru']),
        'error_message' => 'simulated failure',
        'failed_at' => now(),
    ]);

    expect(
        DB::connection('pgsql_supplier')
            ->table('failed_webhook_jobs')
            ->whereNull('tenant_id')
            ->where('error_message', 'simulated failure')
            ->exists()
    )->toBeTrue();
});
  • Step 8: Run test
cd app && php vendor/bin/pest tests/Feature/Supplier/SupplierConnectionTest.php --filter="BLOCKER #6" 2>&1

Expected: PASS — crm_supplier_worker BYPASSRLS пропускает NULL tenant_id. Если на dev pgsql_supplier user = postgres (superuser) тоже BYPASSRLS, тест проходит.

  • Step 9: Failing test — LeadRouter sees all tenant projects under BYPASSRLS (WARN #2 regression)

В тот же test-файл:

test('LeadRouter::matchEligibleProjects sees projects across tenants under pgsql_supplier (WARN #2)', function () {
    // Создаём 3 tenant'а × 2 supplier-eligible projects (signal_type=site, has B1)
    $supplierProject = \App\Models\SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com',
    ]);

    $tenants = Tenant::factory()->count(3)->create();
    foreach ($tenants as $tenant) {
        Project::factory()->count(2)->create([
            'tenant_id' => $tenant->id,
            'status' => 'active',
            'signal_type' => 'site',
            'signal_identifier' => 'example.com',
            'supplier_b1_project_id' => $supplierProject->id,
            'delivered_today' => 0,
            'daily_limit' => 10,
        ]);
    }

    // БЕЗ SET LOCAL — LeadRouter должен видеть все 6 проектов через BYPASSRLS
    $router = app(\App\Services\LeadRouter::class);
    $eligible = $router->matchEligibleProjects(
        signalType: 'site',
        signalIdentifier: 'example.com',
        regionMask: 255,
    );

    expect($eligible)->toHaveCount(6);
});
  • Step 10: Run failing test
cd app && php vendor/bin/pest tests/Feature/Supplier/SupplierConnectionTest.php --filter="WARN #2" 2>&1

Expected: FAIL — LeadRouter использует default connection (pgsql), под crm_app_user без SET LOCAL вернёт 0 projects (или вернёт 6 если на dev pgsql user = postgres superuser; тогда тест зелёный изначально — это OK на dev, на prod тест отловит регрессию).

  • Step 11: Изменить LeadRouter на Project::on('pgsql_supplier')

Открыть app/app/Services/LeadRouter.php. В методе matchEligibleProjects заменить:

// Было:
return Project::where('status', 'active')
    ->where('signal_type', $signalType)
    ->where('signal_identifier', $signalIdentifier)
    // ...
    ->get();

// Стало:
return Project::on('pgsql_supplier')
    ->where('status', 'active')
    ->where('signal_type', $signalType)
    ->where('signal_identifier', $signalIdentifier)
    // ...
    ->get();

Удалить inline-warnings lines 25-29 (старый комментарий о RLS-проблеме — теперь закрыто).

  • Step 12: Run test to verify pass
cd app && php vendor/bin/pest tests/Feature/Supplier/SupplierConnectionTest.php --filter="WARN #2" 2>&1

Expected: PASS.

  • Step 13: Failing test — ResetDeliveredTodayCommand updates all tenants (WARN #3)

В тот же test-файл:

test('ResetDeliveredTodayCommand resets delivered_today across all tenants under pgsql_supplier (WARN #3)', function () {
    $tenants = Tenant::factory()->count(3)->create();
    foreach ($tenants as $tenant) {
        Project::factory()->create([
            'tenant_id' => $tenant->id,
            'delivered_today' => 5,
        ]);
    }

    // Запускаем команду БЕЗ SET LOCAL
    $this->artisan('projects:reset-delivered-today')->assertExitCode(0);

    // Все 3 tenant'а должны быть reset через BYPASSRLS
    $remaining = DB::connection('pgsql_supplier')
        ->table('projects')
        ->where('delivered_today', '>', 0)
        ->count();

    expect($remaining)->toBe(0);
});
  • Step 14: Run failing test
cd app && php vendor/bin/pest tests/Feature/Supplier/SupplierConnectionTest.php --filter="WARN #3" 2>&1

Expected: FAIL on prod (или PASS на dev superuser).

  • Step 15: Изменить ResetDeliveredTodayCommand на Project::on('pgsql_supplier')

Открыть app/app/Console/Commands/ResetDeliveredTodayCommand.php. В handle() заменить:

// Было:
$count = Project::where('delivered_today', '!=', 0)->update(['delivered_today' => 0]);

// Стало:
$count = Project::on('pgsql_supplier')
    ->where('delivered_today', '!=', 0)
    ->update(['delivered_today' => 0]);

Удалить inline-warnings lines 16-18.

  • Step 16: Run test to verify pass
cd app && php vendor/bin/pest tests/Feature/Supplier/SupplierConnectionTest.php 2>&1

Expected: все 4 теста (uses-connection + BLOCKER#6 + WARN#2 + WARN#3) PASS.

  • Step 17: Запустить полный test-suite для regression
cd app && php vendor/bin/pest --parallel 2>&1 | tail -20

Expected: ≥562/562 PASS (Plan 2.6 baseline 558/556 + 4 новых). 0 failed.

  • Step 18: Запустить larastan
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M 2>&1 | tail -10

Expected: 0 errors above baseline.

  • Step 19: Commit
git add app/config/database.php app/.env.example \
        app/app/Jobs/RouteSupplierLeadJob.php \
        app/app/Services/LeadRouter.php \
        app/app/Console/Commands/ResetDeliveredTodayCommand.php \
        app/tests/Feature/Supplier/SupplierConnectionTest.php

git commit -m "feat(supplier): Plan 3 Task 3 — switch supplier-flow на pgsql_supplier (BYPASSRLS)

Закрывает 3 backlog-айтема Plan 2.6 одной правкой:
- BLOCKER #6: failed_webhook_jobs INSERT с tenant_id=NULL теперь проходит
  (BYPASSRLS обходит RLS-политику отвергавшую NULL)
- WARN #2: LeadRouter::matchEligibleProjects видит projects всех tenant'ов
- WARN #3: ResetDeliveredTodayCommand обновляет projects всех tenant'ов

Архитектура: crm_supplier_worker BYPASSRLS-роль (создана Plan 2.6 #iv 7899071)
+ новый pgsql_supplier connection в config/database.php. WHERE(tenant_id=)
фильтры сохраняются как defense-in-depth.

Brainstorm decision: вариант C из 3 опций (A=schema bump, B=отдельная таблица,
C=BYPASSRLS-role). См. docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.

+4 теста (uses-connection + BLOCKER#6 + WARN#2 + WARN#3) в Feature/Supplier/SupplierConnectionTest.php.
0 schema changes."

Task 4: SupplierPortalClient — HTTP-обёртка над rt-* с Redis cookie/CSRF + retry на 401

Files:

  • Create: app/app/Services/Supplier/Dto/SupplierProjectDto.php

  • Create: app/app/Exceptions/Supplier/SupplierException.php

  • Create: app/app/Exceptions/Supplier/SupplierAuthException.php

  • Create: app/app/Exceptions/Supplier/SupplierTransientException.php

  • Create: app/app/Exceptions/Supplier/SupplierClientException.php

  • Create: app/app/Services/Supplier/SupplierPortalClient.php

  • Create: app/tests/Unit/Supplier/SupplierPortalClientTest.php

  • Step 1: Создать DTO SupplierProjectDto

app/app/Services/Supplier/Dto/SupplierProjectDto.php:

<?php

declare(strict_types=1);

namespace App\Services\Supplier\Dto;

use App\Models\SupplierProject;

/**
 * Read-only DTO для передачи aggregate-состояния supplier_project
 * между SupplierQuotaAllocator и SupplierPortalClient.
 */
final readonly class SupplierProjectDto
{
    public function __construct(
        public string $platform,           // B1 / B2 / B3
        public string $signalType,         // site / call / sms
        public string $uniqueKey,          // domain / phone / sender+keyword / sender
        public int $limit,                 // daily quota
        public array $workdays,            // [1,2,3,4,5,6,7] (Пн..Вс)
        public array $regions,             // массив кодов регионов РФ
        public bool $regionsReverse,       // false = include (default), true = exclude
        public string $status,             // active / paused
    ) {}

    public static function fromModel(SupplierProject $sp): self
    {
        return new self(
            platform: $sp->platform,
            signalType: $sp->signal_type,
            uniqueKey: $sp->unique_key,
            limit: $sp->current_limit,
            workdays: (array) ($sp->current_workdays ?? []),
            regions: (array) ($sp->current_regions ?? []),
            regionsReverse: false,
            status: $sp->inactive_since === null ? 'active' : 'paused',
        );
    }

    public function equals(self $other): bool
    {
        return $this->platform === $other->platform
            && $this->signalType === $other->signalType
            && $this->uniqueKey === $other->uniqueKey
            && $this->limit === $other->limit
            && $this->workdays === $other->workdays
            && $this->regions === $other->regions
            && $this->regionsReverse === $other->regionsReverse
            && $this->status === $other->status;
    }
}
  • Step 2: Создать abstract base exception

app/app/Exceptions/Supplier/SupplierException.php:

<?php

declare(strict_types=1);

namespace App\Exceptions\Supplier;

use RuntimeException;

abstract class SupplierException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly ?int $httpStatus = null,
        public readonly ?string $responseBody = null,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}
  • Step 3: Создать 3 concrete exceptions

app/app/Exceptions/Supplier/SupplierAuthException.php:

<?php

declare(strict_types=1);

namespace App\Exceptions\Supplier;

/**
 * Sticky 401/403 — Task 5 RefreshSupplierSessionJob не помог. Эскалация
 * через email + Sentry critical.
 */
final class SupplierAuthException extends SupplierException
{
}

app/app/Exceptions/Supplier/SupplierTransientException.php:

<?php

declare(strict_types=1);

namespace App\Exceptions\Supplier;

/**
 * 5xx, network timeout, connection refused — retryable на job-уровне.
 */
final class SupplierTransientException extends SupplierException
{
}

app/app/Exceptions/Supplier/SupplierClientException.php:

<?php

declare(strict_types=1);

namespace App\Exceptions\Supplier;

/**
 * 4xx (400/404/422) — наша ошибка payload'а, retry бесполезен.
 */
final class SupplierClientException extends SupplierException
{
}
  • Step 4: Failing test — attachSession adds cookies and CSRF header

Create app/tests/Unit/Supplier/SupplierPortalClientTest.php:

<?php

declare(strict_types=1);

use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Exceptions\Supplier\SupplierClientException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
    Cache::store('redis')->put('supplier:session', [
        'phpsessid' => 'abc123sessid',
        'csrf' => 'csrf-token-xyz',
        'refreshed_at' => now()->toIso8601String(),
    ], now()->addHours(6));

    config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
});

afterEach(function () {
    Cache::store('redis')->forget('supplier:session');
});

test('listProjects attaches PHPSESSID cookie and X-CSRF-Token header', function () {
    Http::fake([
        'crm.bp-gr.ru/admin/rt-projects-load*' => Http::response('[]', 200),
    ]);

    app(SupplierPortalClient::class)->listProjects();

    Http::assertSent(function ($request) {
        return str_contains($request->header('Cookie')[0] ?? '', 'PHPSESSID=abc123sessid')
            && ($request->header('X-CSRF-Token')[0] ?? '') === 'csrf-token-xyz';
    });
});
  • Step 5: Run failing test
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientTest.php --filter="attaches PHPSESSID" 2>&1

Expected: FAIL — SupplierPortalClient class not found.

  • Step 6: Создать минимальный SupplierPortalClient

app/app/Services/Supplier/SupplierPortalClient.php:

<?php

declare(strict_types=1);

namespace App\Services\Supplier;

use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;

final class SupplierPortalClient
{
    public function __construct(
        private readonly HttpFactory $http,
    ) {}

    public function listProjects(): array
    {
        $response = $this->request('GET', '/admin/rt-projects-load');
        return $response->json() ?? [];
    }

    public function saveProject(SupplierProjectDto $dto): int
    {
        $response = $this->request('POST', '/admin/rt-project-save', $this->toPayload($dto));
        return (int) ($response->json('id') ?? 0);
    }

    public function updateProject(int $externalId, SupplierProjectDto $dto): void
    {
        $this->request('POST', '/admin/rt-project-update', array_merge(
            ['id' => $externalId],
            $this->toPayload($dto)
        ));
    }

    public function deleteProject(int $externalId): void
    {
        $this->request('POST', '/admin/rt-project-delete', ['id' => $externalId]);
    }

    private function request(string $method, string $path, array $body = [], bool $isRetry = false): Response
    {
        $session = $this->loadSession();
        $url = rtrim(config('services.supplier.portal_url'), '/') . $path;

        $request = $this->http
            ->withCookies(['PHPSESSID' => $session['phpsessid']], parse_url($url, PHP_URL_HOST))
            ->withHeaders(['X-CSRF-Token' => $session['csrf']])
            ->timeout(30)
            ->retry(0);  // retry handled here, не через Laravel HTTP retry

        try {
            $response = $request->send($method, $url, [
                'form_params' => $body,
            ]);
        } catch (\Illuminate\Http\Client\ConnectionException $e) {
            throw new SupplierTransientException(
                "Network error: {$e->getMessage()}",
                previous: $e,
            );
        }

        if ($response->status() === 401 || $response->status() === 403) {
            if ($isRetry) {
                throw new SupplierAuthException(
                    "Sticky auth failure on {$path}",
                    httpStatus: $response->status(),
                    responseBody: $response->body(),
                );
            }
            dispatch_sync(new RefreshSupplierSessionJob);
            return $this->request($method, $path, $body, isRetry: true);
        }

        if ($response->status() >= 500) {
            throw new SupplierTransientException(
                "Supplier server error {$response->status()} on {$path}",
                httpStatus: $response->status(),
                responseBody: $response->body(),
            );
        }

        if ($response->status() >= 400) {
            throw new SupplierClientException(
                "Supplier rejected {$path}: HTTP {$response->status()}",
                httpStatus: $response->status(),
                responseBody: $response->body(),
            );
        }

        return $response;
    }

    private function loadSession(): array
    {
        $session = Cache::store('redis')->get('supplier:session');
        if ($session === null || !isset($session['phpsessid'], $session['csrf'])) {
            dispatch_sync(new RefreshSupplierSessionJob);
            $session = Cache::store('redis')->get('supplier:session');
            if ($session === null) {
                throw new SupplierAuthException('Session refresh failed: cache still empty');
            }
        }
        return $session;
    }

    private function toPayload(SupplierProjectDto $dto): array
    {
        return [
            'platform' => $dto->platform,
            'signal_type' => $dto->signalType,
            'unique_key' => $dto->uniqueKey,
            'limit' => $dto->limit,
            'workdays' => $dto->workdays,
            'regions' => $dto->regions,
            'regions_reverse' => $dto->regionsReverse ? 1 : 0,
            'status' => $dto->status,
        ];
    }
}

NOTE: Payload-shape toPayload() — placeholder, точные поля будут адаптированы после Task 1 discovery (Task 2 spec update §4.4). При расхождении — обновить этот метод в отдельном commit'е перед Task 6.

  • Step 7: Run test to verify pass
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientTest.php --filter="attaches PHPSESSID" 2>&1

Expected: PASS.

  • Step 8: Failing test — 401 triggers RefreshSupplierSessionJob and retries once

В тот же test-файл:

test('401 triggers RefreshSupplierSessionJob sync and retries once', function () {
    Illuminate\Support\Facades\Bus::fake([\App\Jobs\Supplier\RefreshSupplierSessionJob::class]);

    Http::fakeSequence('crm.bp-gr.ru/*')
        ->push('Unauthorized', 401)
        ->push('[]', 200);

    app(SupplierPortalClient::class)->listProjects();

    Illuminate\Support\Facades\Bus::assertDispatchedSync(\App\Jobs\Supplier\RefreshSupplierSessionJob::class);
    Http::assertSentCount(2);
});

test('sticky 401 throws SupplierAuthException after one retry', function () {
    Illuminate\Support\Facades\Bus::fake([\App\Jobs\Supplier\RefreshSupplierSessionJob::class]);

    Http::fakeSequence('crm.bp-gr.ru/*')
        ->push('Unauthorized', 401)
        ->push('Still unauthorized', 401);

    expect(fn () => app(SupplierPortalClient::class)->listProjects())
        ->toThrow(SupplierAuthException::class);
});
  • Step 9: Run tests
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientTest.php 2>&1

Expected: PASS — все 3 теста зелёные. Реализация Step 6 уже покрывает retry-logic.

  • Step 10: Failing tests — 5xx → SupplierTransientException, 4xx → SupplierClientException
test('5xx response throws SupplierTransientException', function () {
    Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream gateway error', 503)]);
    expect(fn () => app(SupplierPortalClient::class)->listProjects())
        ->toThrow(SupplierTransientException::class);
});

test('4xx not 401/403 throws SupplierClientException', function () {
    Http::fake(['crm.bp-gr.ru/*' => Http::response('bad payload', 422)]);
    expect(fn () => app(SupplierPortalClient::class)->listProjects())
        ->toThrow(SupplierClientException::class);
});

test('network error throws SupplierTransientException', function () {
    Http::fake(function () {
        throw new \Illuminate\Http\Client\ConnectionException('Connection refused');
    });
    expect(fn () => app(SupplierPortalClient::class)->listProjects())
        ->toThrow(SupplierTransientException::class);
});
  • Step 11: Run tests
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientTest.php 2>&1

Expected: все 6 тестов PASS.

  • Step 12: Failing tests — saveProject/updateProject/deleteProject отправляют корректный payload
test('saveProject POSTs to /admin/rt-project-save with full payload and returns external id', function () {
    Http::fake(['crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 99001], 200)]);

    $dto = new SupplierProjectDto(
        platform: 'B1',
        signalType: 'site',
        uniqueKey: 'example.com',
        limit: 5,
        workdays: [1,2,3,4,5],
        regions: [],
        regionsReverse: false,
        status: 'active',
    );

    $id = app(SupplierPortalClient::class)->saveProject($dto);

    expect($id)->toBe(99001);
    Http::assertSent(fn ($r) => $r->method() === 'POST'
        && str_ends_with($r->url(), '/admin/rt-project-save')
        && ($r['platform'] ?? null) === 'B1');
});

test('updateProject POSTs to /admin/rt-project-update with id + full payload', function () {
    Http::fake(['crm.bp-gr.ru/admin/rt-project-update' => Http::response('', 200)]);

    $dto = new SupplierProjectDto(
        platform: 'B2', signalType: 'call', uniqueKey: '79991234567',
        limit: 10, workdays: [1,2,3], regions: [77], regionsReverse: false, status: 'active',
    );

    app(SupplierPortalClient::class)->updateProject(externalId: 12345, dto: $dto);

    Http::assertSent(fn ($r) => $r->method() === 'POST'
        && str_ends_with($r->url(), '/admin/rt-project-update')
        && ($r['id'] ?? null) === 12345);
});

test('deleteProject POSTs to /admin/rt-project-delete with id only', function () {
    Http::fake(['crm.bp-gr.ru/admin/rt-project-delete' => Http::response('', 200)]);

    app(SupplierPortalClient::class)->deleteProject(externalId: 12345);

    Http::assertSent(fn ($r) => $r->method() === 'POST'
        && str_ends_with($r->url(), '/admin/rt-project-delete')
        && ($r['id'] ?? null) === 12345);
});
  • Step 13: Run all tests
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientTest.php 2>&1

Expected: 9 PASS / 0 fail.

  • Step 14: Larastan + Pint
cd app && php vendor/bin/pint app/app/Services/Supplier/ app/app/Exceptions/Supplier/ app/tests/Unit/Supplier/ 2>&1
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M 2>&1 | tail -10

Expected: Pint clean, Larastan 0 errors above baseline.

  • Step 15: Commit
git add app/app/Services/Supplier/ app/app/Exceptions/Supplier/ app/tests/Unit/Supplier/SupplierPortalClientTest.php

git commit -m "feat(supplier): Plan 3 Task 4 — SupplierPortalClient HTTP-обёртка над rt-*

Компоненты:
- SupplierProjectDto (read-only DTO, fromModel + equals)
- SupplierException иерархия (Auth/Transient/Client + abstract base)
- SupplierPortalClient: list/save/update/delete projects через Http facade
  + Redis cache cookie/CSRF (key 'supplier:session', TTL 6h)
  + auto-retry на 401/403 через dispatch_sync(RefreshSupplierSessionJob)
  + classification HTTP errors → 3 exception types

+9 тестов через Http::fake() в Unit/Supplier/SupplierPortalClientTest.php
(retry-flow + cookie/CSRF attach + 5xx/4xx classification + payload shapes).

NOTE: toPayload() shape — placeholder. Будет адаптирован после Task 1
discovery + Task 2 spec §4.4 (отдельный fixup commit перед Task 6 при
расхождении наблюдаемого формата с предполагаемым)."

Task 5: RefreshSupplierSessionJob + PlaywrightBridge — Node.js subprocess + Redis 6h TTL

Files:

  • Create: app/playwright/package.json

  • Create: app/playwright/refresh-session.js

  • Create: app/app/Services/Supplier/PlaywrightBridge.php

  • Create: app/app/Jobs/Supplier/RefreshSupplierSessionJob.php

  • Create: app/app/Console/Commands/SupplierSessionRefreshCommand.php

  • Create: app/tests/Unit/Supplier/PlaywrightBridgeTest.php

  • Create: app/tests/Unit/Supplier/RefreshSupplierSessionJobTest.php

  • Step 1: Создать app/playwright/package.json

{
  "name": "liderra-supplier-playwright",
  "version": "1.0.0",
  "private": true,
  "description": "Headless Playwright bridge for Лидерра supplier session refresh (Plan 3 Task 5)",
  "scripts": {
    "install:chromium": "playwright install chromium --with-deps",
    "refresh-session": "node refresh-session.js"
  },
  "dependencies": {
    "playwright": "^1.50.0"
  }
}

Установить:

cd app/playwright && npm install

Verify: app/playwright/node_modules/playwright/package.json существует. NOTE: app/playwright/node_modules/ нужно добавить в .gitignore если ещё не там.

  • Step 2: Создать Node-скрипт app/playwright/refresh-session.js
#!/usr/bin/env node
/**
 * Headless Playwright login на crm.bp-gr.ru.
 *
 * Input (JSON через stdin):
 *   {login, password, url}
 *
 * Output (JSON через stdout):
 *   {phpsessid, csrf, refreshed_at}
 *
 * Exit codes:
 *   0 — success
 *   1 — auth failed (login/password rejected)
 *   2 — DOM не найден (broken selectors)
 *   3 — timeout (60s)
 *   4 — invalid input или другая ошибка
 */
const { chromium } = require('playwright');

const TIMEOUT_MS = 60_000;

async function refresh(args) {
    const browser = await chromium.launch({ headless: true });
    try {
        const context = await browser.newContext();
        const page = await context.newPage();

        await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });

        // Try common login form selectors — uncomment one после Task 1 discovery:
        const loginSelector = 'input[name=login]';
        const passwordSelector = 'input[name=password]';
        const submitSelector = 'button[type=submit]';

        await page.fill(loginSelector, args.login);
        await page.fill(passwordSelector, args.password);
        await Promise.all([
            page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }),
            page.click(submitSelector),
        ]);

        // Try common CSRF selectors — uncomment один после Task 1 discovery:
        const csrf = await page.locator('meta[name=csrf-token]').first().getAttribute('content', { timeout: 5000 });

        const cookies = await context.cookies();
        const sessionCookie = cookies.find(c => c.name === 'PHPSESSID' || c.name === 'JSESSIONID');

        if (!sessionCookie) {
            process.stderr.write(JSON.stringify({ error: 'session cookie not found in response' }));
            process.exit(1);
        }

        if (!csrf) {
            process.stderr.write(JSON.stringify({ error: 'CSRF token not found in DOM' }));
            process.exit(2);
        }

        process.stdout.write(JSON.stringify({
            phpsessid: sessionCookie.value,
            csrf: csrf,
            refreshed_at: new Date().toISOString(),
        }));
        process.exit(0);
    } catch (err) {
        process.stderr.write(JSON.stringify({ error: err.message }));
        process.exit(err.message.includes('Timeout') ? 3 : 4);
    } finally {
        await browser.close();
    }
}

// Read stdin
let input = '';
process.stdin.on('data', chunk => { input += chunk; });
process.stdin.on('end', () => {
    let args;
    try {
        args = JSON.parse(input);
    } catch (e) {
        process.stderr.write(JSON.stringify({ error: 'invalid JSON on stdin' }));
        process.exit(4);
    }
    if (!args.login || !args.password || !args.url) {
        process.stderr.write(JSON.stringify({ error: 'missing required keys: login, password, url' }));
        process.exit(4);
    }
    refresh(args);
});

NOTE: DOM-селекторы (input[name=login], meta[name=csrf-token]) — placeholder'ы; финализировать после Task 1 discovery.

  • Step 3: Failing test — PlaywrightBridge passes credentials via stdin

app/tests/Unit/Supplier/PlaywrightBridgeTest.php:

<?php

declare(strict_types=1);

use App\Services\Supplier\PlaywrightBridge;
use App\Exceptions\Supplier\SupplierAuthException;
use Symfony\Component\Process\Process;

test('PlaywrightBridge passes credentials via stdin not argv', function () {
    $mock = Mockery::mock(Process::class);
    $mock->shouldReceive('setInput')
        ->once()
        ->with(Mockery::on(function ($input) {
            $data = json_decode($input, true);
            return isset($data['login'], $data['password'], $data['url']);
        }))
        ->andReturnSelf();
    $mock->shouldReceive('setTimeout')->andReturnSelf();
    $mock->shouldReceive('run')->andReturn(0);
    $mock->shouldReceive('isSuccessful')->andReturn(true);
    $mock->shouldReceive('getOutput')->andReturn(json_encode([
        'phpsessid' => 'abc',
        'csrf' => 'xyz',
        'refreshed_at' => '2026-05-11T10:00:00Z',
    ]));

    $factory = Mockery::mock();
    $factory->shouldReceive('create')->andReturn($mock);

    $bridge = new PlaywrightBridge($factory);
    $result = $bridge->refreshSession(login: 'test_login', password: 'test_pass', url: 'https://crm.bp-gr.ru');

    expect($result)->toHaveKeys(['phpsessid', 'csrf', 'refreshed_at']);
});

test('PlaywrightBridge throws SupplierAuthException on non-zero exit', function () {
    $mock = Mockery::mock(Process::class);
    $mock->shouldReceive('setInput')->andReturnSelf();
    $mock->shouldReceive('setTimeout')->andReturnSelf();
    $mock->shouldReceive('run')->andReturn(1);
    $mock->shouldReceive('isSuccessful')->andReturn(false);
    $mock->shouldReceive('getErrorOutput')->andReturn('{"error":"login rejected"}');
    $mock->shouldReceive('getExitCode')->andReturn(1);

    $factory = Mockery::mock();
    $factory->shouldReceive('create')->andReturn($mock);

    $bridge = new PlaywrightBridge($factory);

    expect(fn () => $bridge->refreshSession('bad', 'bad', 'https://crm.bp-gr.ru'))
        ->toThrow(SupplierAuthException::class);
});
  • Step 4: Run failing test
cd app && php vendor/bin/pest tests/Unit/Supplier/PlaywrightBridgeTest.php 2>&1

Expected: FAIL — PlaywrightBridge class not found.

  • Step 5: Создать app/app/Services/Supplier/PlaywrightBridge.php
<?php

declare(strict_types=1);

namespace App\Services\Supplier;

use App\Exceptions\Supplier\SupplierAuthException;
use Symfony\Component\Process\Process;

interface ProcessFactory
{
    public function create(array $command, ?string $cwd = null): Process;
}

final class SymfonyProcessFactory implements ProcessFactory
{
    public function create(array $command, ?string $cwd = null): Process
    {
        return new Process($command, $cwd);
    }
}

final class PlaywrightBridge
{
    private const TIMEOUT_SECONDS = 75;  // 60s Node timeout + 15s buffer
    private const SCRIPT_PATH = 'playwright/refresh-session.js';

    public function __construct(
        private readonly ProcessFactory $processFactory,
    ) {}

    /**
     * Refresh session via headless Node-subprocess.
     *
     * @return array{phpsessid: string, csrf: string, refreshed_at: string}
     * @throws SupplierAuthException
     */
    public function refreshSession(string $login, string $password, string $url): array
    {
        $process = $this->processFactory->create(
            ['node', self::SCRIPT_PATH],
            base_path('app'),
        );
        $process->setInput(json_encode([
            'login' => $login,
            'password' => $password,
            'url' => $url,
        ]));
        $process->setTimeout(self::TIMEOUT_SECONDS);
        $process->run();

        if (!$process->isSuccessful()) {
            throw new SupplierAuthException(
                "PlaywrightBridge exit code {$process->getExitCode()}: {$process->getErrorOutput()}",
            );
        }

        $output = json_decode($process->getOutput(), true);
        if (!is_array($output) || !isset($output['phpsessid'], $output['csrf'])) {
            throw new SupplierAuthException(
                "PlaywrightBridge returned invalid output: {$process->getOutput()}",
            );
        }

        return $output;
    }
}

Зарегистрировать в app/app/Providers/AppServiceProvider.php (register()):

$this->app->bind(
    \App\Services\Supplier\ProcessFactory::class,
    \App\Services\Supplier\SymfonyProcessFactory::class,
);
  • Step 6: Run tests
cd app && php vendor/bin/pest tests/Unit/Supplier/PlaywrightBridgeTest.php 2>&1

Expected: 2 PASS.

  • Step 7: Failing test — RefreshSupplierSessionJob writes session to Redis with 6h TTL

app/tests/Unit/Supplier/RefreshSupplierSessionJobTest.php:

<?php

declare(strict_types=1);

use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Services\Supplier\PlaywrightBridge;
use App\Exceptions\Supplier\SupplierAuthException;
use Illuminate\Support\Facades\Cache;

beforeEach(function () {
    config([
        'services.supplier.login' => 'test_login',
        'services.supplier.password' => 'test_password',
        'services.supplier.portal_url' => 'https://crm.bp-gr.ru',
    ]);
    Cache::store('redis')->forget('supplier:session');
});

test('writes session data to Redis cache key supplier:session with 6h TTL', function () {
    $mock = Mockery::mock(PlaywrightBridge::class);
    $mock->shouldReceive('refreshSession')
        ->once()
        ->with('test_login', 'test_password', 'https://crm.bp-gr.ru')
        ->andReturn([
            'phpsessid' => 'sess123',
            'csrf' => 'csrf456',
            'refreshed_at' => '2026-05-11T10:00:00Z',
        ]);
    $this->app->instance(PlaywrightBridge::class, $mock);

    $job = new RefreshSupplierSessionJob;
    $job->handle($mock);

    $cached = Cache::store('redis')->get('supplier:session');
    expect($cached)->toBeArray()
        ->and($cached['phpsessid'])->toBe('sess123')
        ->and($cached['csrf'])->toBe('csrf456');
});

test('rethrows SupplierAuthException from PlaywrightBridge', function () {
    $mock = Mockery::mock(PlaywrightBridge::class);
    $mock->shouldReceive('refreshSession')
        ->andThrow(new SupplierAuthException('Login rejected'));
    $this->app->instance(PlaywrightBridge::class, $mock);

    $job = new RefreshSupplierSessionJob;
    expect(fn () => $job->handle($mock))->toThrow(SupplierAuthException::class);
});
  • Step 8: Run failing test
cd app && php vendor/bin/pest tests/Unit/Supplier/RefreshSupplierSessionJobTest.php 2>&1

Expected: FAIL — RefreshSupplierSessionJob class not found.

  • Step 9: Создать app/app/Jobs/Supplier/RefreshSupplierSessionJob.php
<?php

declare(strict_types=1);

namespace App\Jobs\Supplier;

use App\Services\Supplier\PlaywrightBridge;
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\Cache;
use Illuminate\Support\Facades\Log;

final class RefreshSupplierSessionJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $connection = 'pgsql_supplier';
    public $tries = 3;

    public function backoff(): array
    {
        return [120, 600, 1800];  // 2m / 10m / 30m
    }

    public function handle(PlaywrightBridge $bridge): void
    {
        Cache::store('redis')->lock('supplier:session:refresh', 30)->block(35, function () use ($bridge) {
            $session = $bridge->refreshSession(
                login: config('services.supplier.login'),
                password: config('services.supplier.password'),
                url: config('services.supplier.portal_url'),
            );

            Cache::store('redis')->put(
                key: 'supplier:session',
                value: $session,
                ttl: now()->addHours(6),
            );

            Log::info('supplier.session.refreshed', ['ttl_hours' => 6]);
        });
    }
}

Добавить в app/config/services.php:

'supplier' => [
    'login' => env('SUPPLIER_LOGIN'),
    'password' => env('SUPPLIER_PASSWORD'),
    'portal_url' => env('SUPPLIER_PORTAL_URL', 'https://crm.bp-gr.ru'),
    'alert_email' => env('SUPPLIER_ALERT_EMAIL'),
],
  • Step 10: Run tests
cd app && php vendor/bin/pest tests/Unit/Supplier/RefreshSupplierSessionJobTest.php 2>&1

Expected: 2 PASS.

  • Step 11: Создать Console command app/app/Console/Commands/SupplierSessionRefreshCommand.php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Jobs\Supplier\RefreshSupplierSessionJob;
use Illuminate\Console\Command;

final class SupplierSessionRefreshCommand extends Command
{
    protected $signature = 'supplier:session:refresh';
    protected $description = 'Принудительный refresh session cookie+CSRF из crm.bp-gr.ru';

    public function handle(): int
    {
        dispatch_sync(new RefreshSupplierSessionJob);
        $this->info('Supplier session refreshed.');
        return self::SUCCESS;
    }
}
  • Step 12: Failing test — SupplierSessionRefreshCommand dispatches job synchronously

В RefreshSupplierSessionJobTest.php (или новый SupplierSessionRefreshCommandTest.php):

test('command supplier:session:refresh dispatches job synchronously', function () {
    Illuminate\Support\Facades\Bus::fake();
    $this->artisan('supplier:session:refresh')->assertExitCode(0);
    Illuminate\Support\Facades\Bus::assertDispatchedSync(\App\Jobs\Supplier\RefreshSupplierSessionJob::class);
});
  • Step 13: Run all Task 5 tests + Larastan + Pint
cd app && php vendor/bin/pest tests/Unit/Supplier/ 2>&1 | tail -10
cd app && php vendor/bin/pint app/app/Services/Supplier/ app/app/Jobs/Supplier/ app/app/Console/Commands/ 2>&1
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M 2>&1 | tail -10

Expected: all PASS, Pint clean, Larastan 0 errors above baseline.

  • Step 14: Добавить .gitignore запись для node_modules
echo "app/playwright/node_modules/" >> .gitignore
  • Step 15: Commit
git add app/playwright/package.json app/playwright/refresh-session.js .gitignore \
        app/app/Services/Supplier/PlaywrightBridge.php \
        app/app/Jobs/Supplier/ \
        app/app/Console/Commands/SupplierSessionRefreshCommand.php \
        app/app/Providers/AppServiceProvider.php \
        app/config/services.php \
        app/tests/Unit/Supplier/PlaywrightBridgeTest.php \
        app/tests/Unit/Supplier/RefreshSupplierSessionJobTest.php

git commit -m "feat(supplier): Plan 3 Task 5 — RefreshSupplierSessionJob + PlaywrightBridge

Компоненты:
- app/playwright/{package.json, refresh-session.js} — изолированный Node.js
  + Playwright chromium subprocess для headless логина
- PlaywrightBridge: PHP-обёртка через Symfony\Process (stdin/stdout JSON,
  timeout 75s, exit-code → SupplierAuthException)
- RefreshSupplierSessionJob: \$tries=3, backoff [2m/10m/30m], Cache::lock
  prevent concurrent refresh, write supplier:session Redis-ключ TTL 6h
- supplier:session:refresh Console command для ручного запуска
- config/services.supplier: login/password/portal_url/alert_email

+5 тестов (PlaywrightBridge×2 + RefreshSupplierSessionJob×2 + Command×1).

NOTE: DOM-селекторы в refresh-session.js (input[name=login],
meta[name=csrf-token]) — placeholder'ы; финализировать после Task 1
discovery + Task 2 spec §4.4.

NOTE: app/playwright/node_modules/ в .gitignore (~200MB chromium на
prod Linux, не commit'им)."

Task 6: SyncSupplierProjectsJob + SupplierQuotaAllocator — 20:30 МСК cron с distribution-логикой

Files:

  • Create: app/app/Services/Supplier/SupplierQuotaAllocator.php

  • Create: app/app/Jobs/Supplier/SyncSupplierProjectsJob.php

  • Create: app/app/Mail/SupplierCriticalAlertMail.php

  • Create: app/tests/Unit/Supplier/SupplierQuotaAllocatorTest.php

  • Create: app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php

  • Step 1: Failing test — Allocator distributes site signal across B1/B2/B3

app/tests/Unit/Supplier/SupplierQuotaAllocatorTest.php:

<?php

declare(strict_types=1);

use App\Services\Supplier\SupplierQuotaAllocator;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Carbon\Carbon;
use Illuminate\Support\Collection;

test('site signal distributes B1 ceil(total/3), B2 ceil(remainder/2), B3 remainder', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 10, 'workdays' => [1,2,3,4,5], 'regions' => [], 'tomorrow_in_workdays' => true],
    ]);

    $b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));

    expect($b1->limit + $b2->limit + $b3->limit)->toBe(10)
        ->and($b1->limit)->toBe(4)  // ceil(10/3) = 4
        ->and($b2->limit)->toBe(3)  // ceil(6/2) = 3
        ->and($b3->limit)->toBe(3); // 10-4-3
});
  • Step 2: Run failing test
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierQuotaAllocatorTest.php 2>&1

Expected: FAIL — class not found.

  • Step 3: Создать app/app/Services/Supplier/SupplierQuotaAllocator.php
<?php

declare(strict_types=1);

namespace App\Services\Supplier;

use App\Services\Supplier\Dto\SupplierProjectDto;
use Carbon\Carbon;
use Illuminate\Support\Collection;

final class SupplierQuotaAllocator
{
    /**
     * Distribute total quota across platform B1/B2/B3 для конкретной supplier_project.
     *
     * @param Collection<int, object> $activeLiderraProjects — projects с daily_limit, workdays, regions
     */
    public static function allocate(
        string $platform,
        string $signalType,
        string $uniqueKey,
        Collection $activeLiderraProjects,
        Carbon $targetDate,
    ): ?SupplierProjectDto {
        // Filter: projects где targetDate (iso weekday 1-7) есть в workdays
        $targetWeekday = $targetDate->copy()->timezone('Europe/Moscow')->isoWeekday();
        $eligibleProjects = $activeLiderraProjects->filter(
            fn (object $p) => in_array($targetWeekday, (array) $p->workdays, true)
        );

        if ($eligibleProjects->isEmpty()) {
            return null;
        }

        $totalQuota = (int) $eligibleProjects->sum('daily_limit');
        $workdaysUnion = self::unionInts($eligibleProjects->pluck('workdays'));
        $regionsUnion = self::unionInts($eligibleProjects->pluck('regions'));

        $platformLimit = self::distributeForPlatform($signalType, $platform, $totalQuota);

        return new SupplierProjectDto(
            platform: $platform,
            signalType: $signalType,
            uniqueKey: $uniqueKey,
            limit: $platformLimit,
            workdays: $workdaysUnion,
            regions: $regionsUnion,
            regionsReverse: false,
            status: 'active',
        );
    }

    private static function distributeForPlatform(string $signalType, string $platform, int $total): int
    {
        if ($signalType === 'sms') {
            // SMS doesn't go on B1 (spec §2.2)
            if ($platform === 'B1') {
                return 0;
            }
            // SMS distributes B2 ceil(total/2), B3 floor(total/2)
            return $platform === 'B2' ? (int) ceil($total / 2) : (int) floor($total / 2);
        }

        // site/call distributes across all 3 platforms
        $b1 = (int) ceil($total / 3);
        $b2 = (int) ceil(($total - $b1) / 2);
        $b3 = $total - $b1 - $b2;

        return match ($platform) {
            'B1' => $b1,
            'B2' => $b2,
            'B3' => $b3,
        };
    }

    private static function unionInts(Collection $arrays): array
    {
        return $arrays
            ->flatMap(fn ($a) => (array) $a)
            ->map(fn ($v) => (int) $v)
            ->unique()
            ->sort()
            ->values()
            ->all();
    }
}
  • Step 4: Run test to verify pass
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierQuotaAllocatorTest.php 2>&1

Expected: 1 PASS.

  • Step 5: Добавить 8 ещё тестов на distribution-математику

В тот же файл:

test('call signal same distribution as site (B1/B2/B3 split)', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 30, 'workdays' => [1,2,3,4,5], 'regions' => []],
    ]);
    $b1 = SupplierQuotaAllocator::allocate('B1', 'call', '79991234567', $projects, Carbon::parse('2026-05-12'));
    expect($b1->limit)->toBe(10);  // ceil(30/3)
});

test('sms with keyword distributes B2+B3 only (B1 returns 0)', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 4, 'workdays' => [1,2,3,4,5], 'regions' => []],
    ]);
    $b1 = SupplierQuotaAllocator::allocate('B1', 'sms', 'TINKOFF+ипотека', $projects, Carbon::parse('2026-05-12'));
    $b2 = SupplierQuotaAllocator::allocate('B2', 'sms', 'TINKOFF+ипотека', $projects, Carbon::parse('2026-05-12'));
    $b3 = SupplierQuotaAllocator::allocate('B3', 'sms', 'TINKOFF+ипотека', $projects, Carbon::parse('2026-05-12'));
    expect($b1->limit)->toBe(0)
        ->and($b2->limit)->toBe(2)
        ->and($b3->limit)->toBe(2);
});

test('returns null when no active liderra projects on target weekday', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 10, 'workdays' => [6,7], 'regions' => []],  // только выходные
    ]);
    $allocation = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));  // вторник
    expect($allocation)->toBeNull();
});

test('workdays union deduplicates and sorts', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 5, 'workdays' => [1,2,3], 'regions' => []],
        (object) ['daily_limit' => 5, 'workdays' => [3,4,5], 'regions' => []],
    ]);
    $b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    expect($b1->workdays)->toBe([1,2,3,4,5]);
});

test('regions union deduplicates and sorts', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 5, 'workdays' => [1,2,3,4,5], 'regions' => [77, 50]],
        (object) ['daily_limit' => 5, 'workdays' => [1,2,3,4,5], 'regions' => [50, 78]],
    ]);
    $b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    expect($b1->regions)->toBe([50, 77, 78]);
});

test('empty regions stays empty (all regions semantics)', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 5, 'workdays' => [1,2,3,4,5], 'regions' => []],
    ]);
    $b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    expect($b1->regions)->toBe([]);
});

test('single project with limit=1 sites to B1 only', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 1, 'workdays' => [1,2,3,4,5], 'regions' => []],
    ]);
    $b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    expect($b1->limit)->toBe(1)
        ->and($b2->limit)->toBe(0)
        ->and($b3->limit)->toBe(0);
});

test('large scale: 1000 projects with limit 10 each = 10000 total', function () {
    $projects = new Collection(array_fill(0, 1000, (object) [
        'daily_limit' => 10, 'workdays' => [1,2,3,4,5], 'regions' => [],
    ]));
    $b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    expect($b1->limit + $b2->limit + $b3->limit)->toBe(10000)
        ->and($b1->limit)->toBe(3334);  // ceil(10000/3)
});

test('odd total: 7 distributes B1=3, B2=2, B3=2', function () {
    $projects = new Collection([
        (object) ['daily_limit' => 7, 'workdays' => [1,2,3,4,5], 'regions' => []],
    ]);
    $b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    $b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
    expect($b1->limit)->toBe(3)
        ->and($b2->limit)->toBe(2)
        ->and($b3->limit)->toBe(2);
});
  • Step 6: Run all 9 allocator tests
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierQuotaAllocatorTest.php 2>&1

Expected: 9 PASS.

  • Step 7: Создать Mailable app/app/Mail/SupplierCriticalAlertMail.php
<?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;

final class SupplierCriticalAlertMail extends Mailable implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public readonly string $alertType,
        public readonly string $details,
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: "[Лидерра CRITICAL] Supplier sync: {$this->alertType}",
        );
    }

    public function content(): Content
    {
        return new Content(
            text: 'emails.supplier_alert_text',
            with: [
                'alertType' => $this->alertType,
                'details' => $this->details,
                'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
            ],
        );
    }
}

Создать template app/resources/views/emails/supplier_alert_text.blade.php:

Supplier sync critical alert
=============================
Тип: {{ $alertType }}
Время: {{ $now }} (МСК)

Детали:
{{ $details }}

Это автоматическое сообщение от системы Лидерра.
Проверьте `supplier_sync_log` в БД и Sentry breadcrumbs для деталей.
  • Step 8: Failing test — SyncSupplierProjectsJob creates new supplier_project at supplier

app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php:

<?php

declare(strict_types=1);

use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Models\SupplierProject;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\SupplierSyncLog;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;

uses(DatabaseTransactions::class);

beforeEach(function () {
    Cache::store('redis')->put('supplier:session', [
        'phpsessid' => 'sess', 'csrf' => 'csrf', 'refreshed_at' => now()->toIso8601String(),
    ], now()->addHours(6));
    config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
    config(['services.supplier.alert_email' => 'ops@liderra.test']);
});

test('creates supplier_project at supplier when supplier_external_id is null', function () {
    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1',
        'signal_type' => 'site',
        'unique_key' => 'example.com',
        'supplier_external_id' => null,
    ]);
    Project::factory()->create([
        'tenant_id' => $tenant->id,
        'status' => 'active',
        'signal_type' => 'site',
        'signal_identifier' => 'example.com',
        'supplier_b1_project_id' => $sp->id,
        'daily_limit' => 9,
        'workdays' => [1,2,3,4,5,6,7],  // tomorrow в workdays для любого дня
    ]);

    Http::fake([
        'crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 555], 200),
    ]);

    (new SyncSupplierProjectsJob)->handle();

    $sp->refresh();
    expect($sp->supplier_external_id)->toBe(555)
        ->and($sp->sync_status)->toBe('ok');

    Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/rt-project-save'));
});
  • Step 9: Run failing test
cd app && php vendor/bin/pest tests/Feature/Supplier/SyncSupplierProjectsJobTest.php --filter="creates supplier_project" 2>&1

Expected: FAIL — class not found.

  • Step 10: Создать SyncSupplierProjectsJob

app/app/Jobs/Supplier/SyncSupplierProjectsJob.php:

<?php

declare(strict_types=1);

namespace App\Jobs\Supplier;

use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Services\Supplier\Dto\SupplierProjectDto;
use App\Services\Supplier\SupplierPortalClient;
use App\Services\Supplier\SupplierQuotaAllocator;
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\Log;
use Illuminate\Support\Facades\Mail;

final class SyncSupplierProjectsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $connection = 'pgsql_supplier';
    private const TIME_BUDGET_CUTOFF = '20:55';  // МСК hard-stop с 5-мин запасом до 21:00
    private const MASS_FAIL_THRESHOLD = 50;

    public function handle(?SupplierPortalClient $client = null): void
    {
        $client ??= app(SupplierPortalClient::class);
        $consecutiveTransient = 0;

        foreach (SupplierProject::query()->whereNull('deleted_at')->cursor() as $sp) {
            if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
                Log::warning('supplier.sync.time_budget_reached', ['processed_until' => $sp->id]);
                break;
            }

            try {
                $this->syncOne($sp, $client);
                $consecutiveTransient = 0;
            } catch (SupplierAuthException $e) {
                Mail::to(config('services.supplier.alert_email'))
                    ->queue(new SupplierCriticalAlertMail('auth_sticky', $e->getMessage()));
                report($e);
                throw $e;
            } catch (SupplierTransientException $e) {
                $consecutiveTransient++;
                $this->logSyncFailure($sp, $e);
                if ($consecutiveTransient >= self::MASS_FAIL_THRESHOLD) {
                    Mail::to(config('services.supplier.alert_email'))
                        ->queue(new SupplierCriticalAlertMail('mass_transient',
                            "Aborted after {$consecutiveTransient} consecutive transient failures."));
                    report(new \RuntimeException('Supplier outage suspected'));
                    break;
                }
                continue;
            } catch (SupplierClientException $e) {
                $this->logSyncFailure($sp, $e);
                report($e);
                continue;
            }
        }
    }

    private function syncOne(SupplierProject $sp, SupplierPortalClient $client): void
    {
        $activeLiderraProjects = $sp->liderraProjects()
            ->where('status', 'active')
            ->get();

        if ($activeLiderraProjects->isEmpty()) {
            return;
        }

        $allocation = SupplierQuotaAllocator::allocate(
            platform: $sp->platform,
            signalType: $sp->signal_type,
            uniqueKey: $sp->unique_key,
            activeLiderraProjects: $activeLiderraProjects,
            targetDate: Carbon::tomorrow('Europe/Moscow'),
        );

        if ($allocation === null) {
            return;
        }

        $current = SupplierProjectDto::fromModel($sp);
        if ($allocation->equals($current)) {
            return;
        }

        DB::transaction(function () use ($sp, $allocation, $client) {
            if ($sp->supplier_external_id === null) {
                $externalId = $client->saveProject($allocation);
                $sp->update([
                    'supplier_external_id' => $externalId,
                    'current_limit' => $allocation->limit,
                    'current_workdays' => $allocation->workdays,
                    'current_regions' => $allocation->regions,
                    'sync_status' => 'ok',
                    'last_synced_at' => now(),
                ]);
            } else {
                $client->updateProject($sp->supplier_external_id, $allocation);
                $sp->update([
                    'current_limit' => $allocation->limit,
                    'current_workdays' => $allocation->workdays,
                    'current_regions' => $allocation->regions,
                    'sync_status' => 'ok',
                    'last_synced_at' => now(),
                ]);
            }
            SupplierSyncLog::create([
                'supplier_project_id' => $sp->id,
                'action' => $sp->wasRecentlyCreated || $sp->supplier_external_id !== null ? 'update' : 'save',
                'status' => 'ok',
            ]);
        });
    }

    private function logSyncFailure(SupplierProject $sp, \Throwable $e): void
    {
        SupplierSyncLog::create([
            'supplier_project_id' => $sp->id,
            'action' => 'update',
            'status' => 'failed',
            'error' => substr($e->getMessage(), 0, 500),
        ]);
    }
}
  • Step 11: Run test to verify pass
cd app && php vendor/bin/pest tests/Feature/Supplier/SyncSupplierProjectsJobTest.php 2>&1

Expected: 1 PASS.

  • Step 12: Добавить ещё 6 integration tests
test('skips when no diff between current and computed allocation', function () {
    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com',
        'supplier_external_id' => 999,
        'current_limit' => 9, 'current_workdays' => [1,2,3,4,5,6,7], 'current_regions' => [],
    ]);
    Project::factory()->create([
        'tenant_id' => $tenant->id, 'status' => 'active',
        'signal_type' => 'site', 'signal_identifier' => 'example.com',
        'supplier_b1_project_id' => $sp->id,
        'daily_limit' => 27, 'workdays' => [1,2,3,4,5,6,7],  // 27/3 = 9 B1, no change
    ]);

    Http::fake();
    (new SyncSupplierProjectsJob)->handle();
    Http::assertNothingSent();
});

test('isolates failure: one bad supplier_project does not stop others', function () {
    $tenant = Tenant::factory()->create();
    $sp1 = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bad.com', 'supplier_external_id' => null]);
    $sp2 = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'good.com', 'supplier_external_id' => null]);
    foreach ([$sp1, $sp2] as $sp) {
        Project::factory()->create([
            'tenant_id' => $tenant->id, 'status' => 'active',
            'signal_type' => 'site', 'signal_identifier' => $sp->unique_key,
            ($sp->platform === 'B1' ? 'supplier_b1_project_id' : 'supplier_b2_project_id') => $sp->id,
            'daily_limit' => 9, 'workdays' => [1,2,3,4,5,6,7],
        ]);
    }

    Http::fakeSequence('crm.bp-gr.ru/admin/rt-project-save')
        ->push('bad request', 422)
        ->push(['id' => 777], 200);

    (new SyncSupplierProjectsJob)->handle();

    expect(SupplierSyncLog::where('supplier_project_id', $sp1->id)->where('status', 'failed')->exists())->toBeTrue();
    expect($sp2->fresh()->supplier_external_id)->toBe(777);
});

test('aborts after 50 consecutive transient failures and sends alert', function () {
    \Illuminate\Support\Facades\Mail::fake();
    $tenant = Tenant::factory()->create();
    foreach (range(1, 60) as $i) {
        $sp = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => "host{$i}.com", 'supplier_external_id' => null]);
        Project::factory()->create([
            'tenant_id' => $tenant->id, 'status' => 'active',
            'signal_type' => 'site', 'signal_identifier' => "host{$i}.com",
            'supplier_b1_project_id' => $sp->id,
            'daily_limit' => 9, 'workdays' => [1,2,3,4,5,6,7],
        ]);
    }
    Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]);

    (new SyncSupplierProjectsJob)->handle();

    \Illuminate\Support\Facades\Mail::assertQueued(\App\Mail\SupplierCriticalAlertMail::class);
});

test('writes supplier_sync_log row for each action', function () {
    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com', 'supplier_external_id' => null]);
    Project::factory()->create([
        'tenant_id' => $tenant->id, 'status' => 'active',
        'signal_type' => 'site', 'signal_identifier' => 'example.com',
        'supplier_b1_project_id' => $sp->id,
        'daily_limit' => 9, 'workdays' => [1,2,3,4,5,6,7],
    ]);
    Http::fake(['crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 555], 200)]);

    (new SyncSupplierProjectsJob)->handle();

    expect(SupplierSyncLog::where('supplier_project_id', $sp->id)->where('status', 'ok')->exists())->toBeTrue();
});

test('respects time budget by stopping at 20:55 МСК', function () {
    Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow'));

    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com', 'supplier_external_id' => null]);
    Project::factory()->create([
        'tenant_id' => $tenant->id, 'status' => 'active',
        'signal_type' => 'site', 'signal_identifier' => 'example.com',
        'supplier_b1_project_id' => $sp->id,
        'daily_limit' => 9, 'workdays' => [1,2,3,4,5,6,7],
    ]);
    Http::fake();

    (new SyncSupplierProjectsJob)->handle();

    Http::assertNothingSent();
    Carbon::setTestNow();
});

test('sticky auth error throws and sends critical alert email', function () {
    \Illuminate\Support\Facades\Mail::fake();
    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com', 'supplier_external_id' => null]);
    Project::factory()->create([
        'tenant_id' => $tenant->id, 'status' => 'active',
        'signal_type' => 'site', 'signal_identifier' => 'example.com',
        'supplier_b1_project_id' => $sp->id,
        'daily_limit' => 9, 'workdays' => [1,2,3,4,5,6,7],
    ]);
    Http::fake([
        'crm.bp-gr.ru/*' => Http::response('Unauthorized', 401),
    ]);
    \Illuminate\Support\Facades\Bus::fake([\App\Jobs\Supplier\RefreshSupplierSessionJob::class]);

    expect(fn () => (new SyncSupplierProjectsJob)->handle())
        ->toThrow(\App\Exceptions\Supplier\SupplierAuthException::class);
    \Illuminate\Support\Facades\Mail::assertQueued(\App\Mail\SupplierCriticalAlertMail::class);
});
  • Step 13: Run all Task 6 tests + Larastan + Pint
cd app && php vendor/bin/pest tests/Unit/Supplier/SupplierQuotaAllocatorTest.php tests/Feature/Supplier/SyncSupplierProjectsJobTest.php 2>&1 | tail -20
cd app && php vendor/bin/pint app/app/Services/Supplier/ app/app/Jobs/Supplier/ app/app/Mail/ 2>&1
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M 2>&1 | tail -10

Expected: 9 (allocator) + 7 (sync job) = 16 PASS; Pint clean; Larastan 0 errors above baseline.

  • Step 14: Commit
git add app/app/Services/Supplier/SupplierQuotaAllocator.php \
        app/app/Jobs/Supplier/SyncSupplierProjectsJob.php \
        app/app/Mail/SupplierCriticalAlertMail.php \
        app/resources/views/emails/supplier_alert_text.blade.php \
        app/tests/Unit/Supplier/SupplierQuotaAllocatorTest.php \
        app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php

git commit -m "feat(supplier): Plan 3 Task 6 — SyncSupplierProjectsJob + SupplierQuotaAllocator

Компоненты:
- 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)
  - sms-without-keyword: B3-only
  - Workdays/regions union, weekday-фильтрация по Europe/Moscow
- SyncSupplierProjectsJob: 20:30 МСК cron
  - per-supplier_project failure-isolation (continue на one bad)
  - mass-fail abort: 50 consecutive transient → alert + break
  - sticky auth → email + throw + Sentry critical
  - time budget cutoff 20:55 МСК (5-мин safety margin до 21:00)
  - supplier_sync_log per action (ok/failed)
- SupplierCriticalAlertMail + text template для email алертов

+16 тестов (9 allocator + 7 sync job)."

Task 7: CleanupInactiveSupplierProjectsJob — Phase A re-activate → B mark → C delete 180d

Files:

  • Create: app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php

  • Create: app/tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php

  • Step 1: Failing test — Phase A re-activates supplier_project when active liderra returns

app/tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php:

<?php

declare(strict_types=1);

use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
use App\Models\SupplierProject;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\SupplierSyncLog;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;

uses(DatabaseTransactions::class);

beforeEach(function () {
    Cache::store('redis')->put('supplier:session', [
        'phpsessid' => 'sess', 'csrf' => 'csrf', 'refreshed_at' => now()->toIso8601String(),
    ], now()->addHours(6));
    config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
});

test('phase A re-activates supplier_project when active liderra returns', function () {
    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com',
        'inactive_since' => now()->subDays(30),
    ]);
    Project::factory()->create([
        'tenant_id' => $tenant->id, 'status' => 'active',
        'signal_type' => 'site', 'signal_identifier' => 'example.com',
        'supplier_b1_project_id' => $sp->id,
    ]);

    (new CleanupInactiveSupplierProjectsJob)->handle();

    expect($sp->fresh()->inactive_since)->toBeNull();
});
  • Step 2: Run failing test
cd app && php vendor/bin/pest tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php 2>&1

Expected: FAIL — class not found.

  • Step 3: Создать CleanupInactiveSupplierProjectsJob с Phase A→B→C

app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php:

<?php

declare(strict_types=1);

namespace App\Jobs\Supplier;

use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Services\Supplier\SupplierPortalClient;
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\Log;

final class CleanupInactiveSupplierProjectsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $connection = 'pgsql_supplier';
    private const INACTIVE_TTL_DAYS = 180;

    public function handle(?SupplierPortalClient $client = null): void
    {
        $client ??= app(SupplierPortalClient::class);

        // SQL для определения id'шников active supplier_projects (есть active liderra_project)
        $activeIdsSubquery = <<<'SQL'
            SELECT DISTINCT id FROM (
                SELECT supplier_b1_project_id AS id FROM projects WHERE status='active' AND supplier_b1_project_id IS NOT NULL
                UNION
                SELECT supplier_b2_project_id FROM projects WHERE status='active' AND supplier_b2_project_id IS NOT NULL
                UNION
                SELECT supplier_b3_project_id FROM projects WHERE status='active' AND supplier_b3_project_id IS NOT NULL
            ) AS active_supplier_ids
        SQL;

        // Phase A: re-activate (СНАЧАЛА — иначе Phase C может удалить недавно вернувшихся)
        $reactivated = DB::connection('pgsql_supplier')->update(<<<SQL
            UPDATE supplier_projects
            SET inactive_since = NULL
            WHERE inactive_since IS NOT NULL
              AND id IN ({$activeIdsSubquery})
        SQL);

        Log::info('supplier.cleanup.phase_a_reactivated', ['count' => $reactivated]);

        // Phase B: mark new inactive
        $marked = DB::connection('pgsql_supplier')->update(<<<SQL
            UPDATE supplier_projects
            SET inactive_since = NOW()
            WHERE inactive_since IS NULL
              AND id NOT IN ({$activeIdsSubquery})
        SQL);

        Log::info('supplier.cleanup.phase_b_marked', ['count' => $marked]);

        // Phase C: delete 180d-old
        $deleted = 0;
        $cutoff = now()->subDays(self::INACTIVE_TTL_DAYS);
        SupplierProject::on('pgsql_supplier')
            ->where('inactive_since', '<', $cutoff)
            ->cursor()
            ->each(function (SupplierProject $sp) use ($client, &$deleted) {
                try {
                    if ($sp->supplier_external_id !== null) {
                        $client->deleteProject($sp->supplier_external_id);
                    }
                    $sp->delete();
                    SupplierSyncLog::create([
                        'supplier_project_id' => $sp->id,
                        'action' => 'delete',
                        'status' => 'ok',
                    ]);
                    $deleted++;
                } catch (SupplierClientException $e) {
                    if ($e->httpStatus === 404) {
                        // Уже удалён у поставщика — продолжаем с локальным soft-delete
                        $sp->delete();
                        SupplierSyncLog::create([
                            'supplier_project_id' => $sp->id,
                            'action' => 'delete',
                            'status' => 'ok',
                            'error' => 'supplier returned 404 (already deleted)',
                        ]);
                        $deleted++;
                    } else {
                        SupplierSyncLog::create([
                            'supplier_project_id' => $sp->id,
                            'action' => 'delete',
                            'status' => 'failed',
                            'error' => substr($e->getMessage(), 0, 500),
                        ]);
                        report($e);
                    }
                } catch (SupplierTransientException $e) {
                    SupplierSyncLog::create([
                        'supplier_project_id' => $sp->id,
                        'action' => 'delete',
                        'status' => 'failed',
                        'error' => substr($e->getMessage(), 0, 500),
                    ]);
                }
            });

        Log::info('supplier.cleanup.phase_c_deleted', ['count' => $deleted]);
    }
}
  • Step 4: Run test to verify pass
cd app && php vendor/bin/pest tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php --filter="phase A re-activates" 2>&1

Expected: PASS.

  • Step 5: Добавить тесты Phase B, Phase C, ordering, 404 handling
test('phase B marks inactive_since=NOW for newly orphaned supplier_project', function () {
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'orphan.com',
        'inactive_since' => null,
    ]);
    // НЕТ active liderra projects, ссылающихся на $sp

    Carbon::setTestNow(Carbon::parse('2026-05-12 02:00:00'));
    (new CleanupInactiveSupplierProjectsJob)->handle();
    Carbon::setTestNow();

    expect($sp->fresh()->inactive_since)->not->toBeNull();
});

test('phase C deletes supplier_project after 180 days inactive', function () {
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'old.com',
        'supplier_external_id' => 999,
        'inactive_since' => now()->subDays(181),
    ]);

    Http::fake(['crm.bp-gr.ru/admin/rt-project-delete' => Http::response('', 200)]);

    (new CleanupInactiveSupplierProjectsJob)->handle();

    expect(SupplierProject::where('id', $sp->id)->withTrashed()->first()->trashed())->toBeTrue();
    expect(SupplierSyncLog::where('supplier_project_id', $sp->id)->where('action', 'delete')->exists())->toBeTrue();
});

test('phase A runs before phase B before phase C (safety ordering)', function () {
    // Scenario: supplier_project inactive_since=185d ago (eligible Phase C),
    // BUT liderra_project недавно стал active (eligible Phase A).
    // Phase A должна сначала обнулить inactive_since, потом Phase C не должна удалить.
    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'edge.com',
        'supplier_external_id' => 888,
        'inactive_since' => now()->subDays(185),  // > 180, eligible Phase C
    ]);
    Project::factory()->create([
        'tenant_id' => $tenant->id, 'status' => 'active',
        'signal_type' => 'site', 'signal_identifier' => 'edge.com',
        'supplier_b1_project_id' => $sp->id,  // active liderra → eligible Phase A
    ]);

    Http::fake();  // expect NO deleteProject call
    (new CleanupInactiveSupplierProjectsJob)->handle();

    expect($sp->fresh()->inactive_since)->toBeNull();
    expect(SupplierProject::where('id', $sp->id)->withTrashed()->first()->trashed())->toBeFalse();
    Http::assertNothingSent();  // Phase C не вызвалась — критическая защита
});

test('handles 404 from supplier on already-deleted supplier_project', function () {
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'ghost.com',
        'supplier_external_id' => 777,
        'inactive_since' => now()->subDays(181),
    ]);
    Http::fake(['crm.bp-gr.ru/admin/rt-project-delete' => Http::response('not found', 404)]);

    (new CleanupInactiveSupplierProjectsJob)->handle();

    expect(SupplierProject::where('id', $sp->id)->withTrashed()->first()->trashed())->toBeTrue();
    expect(SupplierSyncLog::where('supplier_project_id', $sp->id)
        ->where('action', 'delete')
        ->where('status', 'ok')
        ->exists())->toBeTrue();
});

test('does not delete supplier_project marked inactive less than 180 days ago', function () {
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'recent.com',
        'supplier_external_id' => 555,
        'inactive_since' => now()->subDays(100),
    ]);
    Http::fake();
    (new CleanupInactiveSupplierProjectsJob)->handle();

    expect(SupplierProject::where('id', $sp->id)->withTrashed()->first()->trashed())->toBeFalse();
    Http::assertNothingSent();
});
  • Step 6: Run all Task 7 tests + Larastan + Pint
cd app && php vendor/bin/pest tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php 2>&1
cd app && php vendor/bin/pint app/app/Jobs/Supplier/ 2>&1
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M 2>&1 | tail -10

Expected: 6 PASS / 0 fail.

  • Step 7: Commit
git add app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php \
        app/tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php

git commit -m "feat(supplier): Plan 3 Task 7 — CleanupInactiveSupplierProjectsJob (Phase A→B→C)

Daily 02:00 МСК cron, 3 фазы со строгим порядком:
- Phase A: re-activate supplier_projects где появился active liderra_project
  (СНАЧАЛА чтобы Phase C не удалила недавно вернувшихся — safety property)
- Phase B: mark inactive_since=NOW() для newly orphaned supplier_projects
- Phase C: для inactive_since < NOW() - 180d → rt-project-delete + soft-delete
  + 404 от поставщика → trust как 'уже удалён', продолжить локальный delete

180-day TTL paritet со spec §3.3 (без grace period). Audit в supplier_sync_log
на каждый delete event — defense для potential reverse.

+6 тестов (Phase A/B/C + ordering safety + 404 handling + < 180d boundary)."

Task 8: RetryFailedSupplierJobsCommand + 5 Schedule entries

Files:

  • Create: app/app/Console/Commands/RetryFailedSupplierJobsCommand.php

  • Create: app/tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php

  • Modify: app/routes/console.php

  • Step 1: Проверить колонки в failed_webhook_jobs

grep -n "failed_webhook_jobs\|retry_attempts\|last_retried_at" db/schema.sql | head -20

Expected: таблица существует. Если колонки retry_attempts / last_retried_at отсутствуют — добавить в schema.sql v8.18 (отдельный mini-Task) или использовать существующие колонки. Pre-check перед написанием Command'а.

  • Step 2: Failing test — Command dispatches RouteSupplierLeadJob for each row

app/tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php:

<?php

declare(strict_types=1);

use App\Jobs\RouteSupplierLeadJob;
use App\Models\FailedWebhookJob;
use App\Models\SupplierLead;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;

uses(DatabaseTransactions::class);

test('dispatches RouteSupplierLeadJob for each row in failed_webhook_jobs eligible for retry', function () {
    Bus::fake();

    $lead1 = SupplierLead::factory()->create();
    $lead2 = SupplierLead::factory()->create();

    FailedWebhookJob::create([
        'tenant_id' => null,
        'supplier_lead_id' => $lead1->id,
        'payload' => json_encode(['vid' => 1]),
        'error_message' => 'sim',
        'failed_at' => now()->subHours(2),
        'retry_attempts' => 0,
        'last_retried_at' => null,
    ]);
    FailedWebhookJob::create([
        'tenant_id' => null,
        'supplier_lead_id' => $lead2->id,
        'payload' => json_encode(['vid' => 2]),
        'error_message' => 'sim',
        'failed_at' => now()->subHours(2),
        'retry_attempts' => 0,
        'last_retried_at' => null,
    ]);

    $this->artisan('supplier:retry-failed')->assertExitCode(0);

    Bus::assertDispatched(RouteSupplierLeadJob::class, 2);
});
  • Step 3: Run failing test
cd app && php vendor/bin/pest tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php 2>&1

Expected: FAIL — command not found.

  • Step 4: Создать app/app/Console/Commands/RetryFailedSupplierJobsCommand.php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Jobs\RouteSupplierLeadJob;
use App\Models\FailedWebhookJob;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

final class RetryFailedSupplierJobsCommand extends Command
{
    protected $signature = 'supplier:retry-failed';
    protected $description = 'Re-dispatch RouteSupplierLeadJob for failed_webhook_jobs (hourly + 10 attempts/24h limit)';

    private const MAX_ATTEMPTS = 10;

    public function handle(): int
    {
        $dispatched = 0;

        FailedWebhookJob::query()
            ->where('retry_attempts', '<', self::MAX_ATTEMPTS)
            ->where(function ($q) {
                $q->whereNull('last_retried_at')
                  ->orWhere('last_retried_at', '<', now()->subHour());
            })
            ->whereNotNull('supplier_lead_id')
            ->cursor()
            ->each(function (FailedWebhookJob $row) use (&$dispatched) {
                RouteSupplierLeadJob::dispatch(supplierLeadId: $row->supplier_lead_id);
                $row->update([
                    'retry_attempts' => $row->retry_attempts + 1,
                    'last_retried_at' => now(),
                ]);
                $dispatched++;
            });

        Log::info('supplier.retry_failed', ['dispatched' => $dispatched]);
        $this->info("Re-dispatched {$dispatched} failed webhook jobs.");

        return self::SUCCESS;
    }
}
  • Step 5: Run test to verify pass
cd app && php vendor/bin/pest tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php 2>&1

Expected: 1 PASS.

  • Step 6: Добавить 3 ещё тестов
test('skips rows with retry_attempts >= 10', function () {
    Bus::fake();
    $lead = SupplierLead::factory()->create();
    FailedWebhookJob::create([
        'tenant_id' => null, 'supplier_lead_id' => $lead->id,
        'payload' => '{}', 'error_message' => 'sim', 'failed_at' => now(),
        'retry_attempts' => 10, 'last_retried_at' => now()->subHours(5),
    ]);
    $this->artisan('supplier:retry-failed')->assertExitCode(0);
    Bus::assertNotDispatched(RouteSupplierLeadJob::class);
});

test('skips rows with last_retried_at within last hour', function () {
    Bus::fake();
    $lead = SupplierLead::factory()->create();
    FailedWebhookJob::create([
        'tenant_id' => null, 'supplier_lead_id' => $lead->id,
        'payload' => '{}', 'error_message' => 'sim', 'failed_at' => now(),
        'retry_attempts' => 1, 'last_retried_at' => now()->subMinutes(30),
    ]);
    $this->artisan('supplier:retry-failed')->assertExitCode(0);
    Bus::assertNotDispatched(RouteSupplierLeadJob::class);
});

test('increments retry_attempts and updates last_retried_at', function () {
    Bus::fake();
    $lead = SupplierLead::factory()->create();
    $row = FailedWebhookJob::create([
        'tenant_id' => null, 'supplier_lead_id' => $lead->id,
        'payload' => '{}', 'error_message' => 'sim', 'failed_at' => now()->subHours(2),
        'retry_attempts' => 2, 'last_retried_at' => now()->subHours(2),
    ]);
    $this->artisan('supplier:retry-failed')->assertExitCode(0);
    $row->refresh();
    expect($row->retry_attempts)->toBe(3)
        ->and($row->last_retried_at->diffInMinutes(now()))->toBeLessThan(1);
});
  • Step 7: Run all 4 tests
cd app && php vendor/bin/pest tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php 2>&1

Expected: 4 PASS.

  • Step 8: Добавить 5 Schedule entries в app/routes/console.php

В конец файла:

use Illuminate\Support\Facades\Schedule;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;

// Plan 3 Task 8: 5 Schedule entries для supplier-flow
Schedule::job(new RefreshSupplierSessionJob)->hourly()->onOneServer();
Schedule::job(new RefreshSupplierSessionJob)
    ->dailyAt('20:15')
    ->timezone('Europe/Moscow')
    ->onOneServer();
Schedule::job(new SyncSupplierProjectsJob)
    ->dailyAt('20:30')
    ->timezone('Europe/Moscow')
    ->onOneServer();
Schedule::job(new CleanupInactiveSupplierProjectsJob)
    ->dailyAt('02:00')
    ->timezone('Europe/Moscow')
    ->onOneServer();
Schedule::command('supplier:retry-failed')->hourly()->onOneServer();
  • Step 9: Verify schedule registered
cd app && php artisan schedule:list 2>&1 | grep -E "supplier:|RefreshSupplier|SyncSupplier|CleanupInactive"

Expected: 5 entries.

  • Step 10: Larastan + Pint
cd app && php vendor/bin/pint app/app/Console/Commands/RetryFailedSupplierJobsCommand.php app/routes/console.php 2>&1
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M 2>&1 | tail -10

Expected: Pint clean, Larastan 0 errors above baseline.

  • Step 11: Commit
git add app/app/Console/Commands/RetryFailedSupplierJobsCommand.php \
        app/routes/console.php \
        app/tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php

git commit -m "feat(supplier): Plan 3 Task 8 — RetryFailedSupplierJobsCommand + 5 Schedule entries

Component:
- supplier:retry-failed Console command (hourly cron, max 10 attempts/24h)
  - WHERE retry_attempts<10 AND (last_retried_at IS NULL OR < NOW()-1h)
  - dispatch RouteSupplierLeadJob per row
  - increment retry_attempts + update last_retried_at

5 Schedule entries в routes/console.php:
- RefreshSupplierSessionJob hourly + dailyAt('20:15') МСК (pre-sync warmup)
- SyncSupplierProjectsJob dailyAt('20:30') МСК
- CleanupInactiveSupplierProjectsJob dailyAt('02:00') МСК
- supplier:retry-failed hourly

Все с ->onOneServer() через Redis cache driver.

+4 теста."

Task 9: E2E integration test (Linux CI only)

Files:

  • Create: app/tests/Browser/SupplierIntegrationE2ETest.php

  • Modify: lefthook.yml

  • Step 1: Создать E2E test

app/tests/Browser/SupplierIntegrationE2ETest.php:

<?php

declare(strict_types=1);

use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Models\SupplierProject;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use React\Http\HttpServer;
use React\Socket\SocketServer;

uses(DatabaseTransactions::class);

beforeEach(function () {
    if (PHP_OS_FAMILY === 'Windows') {
        $this->markTestSkipped('E2E with mock-server requires Linux (no ext-sockets on Windows native).');
    }
});

test('full flow: liderra_project create → SyncJob → mock-server receives rt-project-save with correct payload', function () {
    // Spin up mock-server на random port
    $receivedRequests = [];
    $loop = \React\EventLoop\Loop::get();
    $server = new HttpServer(function (\Psr\Http\Message\ServerRequestInterface $req) use (&$receivedRequests) {
        $receivedRequests[] = [
            'method' => $req->getMethod(),
            'path' => $req->getUri()->getPath(),
            'body' => (string) $req->getBody(),
        ];
        return new \React\Http\Message\Response(200, ['Content-Type' => 'application/json'], json_encode(['id' => 12345]));
    });
    $socket = new SocketServer('127.0.0.1:0');
    $server->listen($socket);
    $port = parse_url($socket->getAddress(), PHP_URL_PORT);

    Cache::store('redis')->put('supplier:session', [
        'phpsessid' => 'e2e_sess', 'csrf' => 'e2e_csrf', 'refreshed_at' => now()->toIso8601String(),
    ], now()->addHours(6));
    config(['services.supplier.portal_url' => "http://127.0.0.1:{$port}"]);

    $tenant = Tenant::factory()->create();
    $sp = SupplierProject::factory()->create([
        'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'e2e.com', 'supplier_external_id' => null,
    ]);
    Project::factory()->create([
        'tenant_id' => $tenant->id, 'status' => 'active',
        'signal_type' => 'site', 'signal_identifier' => 'e2e.com',
        'supplier_b1_project_id' => $sp->id,
        'daily_limit' => 9, 'workdays' => [1,2,3,4,5,6,7],
    ]);

    // Schedule the loop to stop после первого request'а
    $loop->addTimer(2, fn () => $loop->stop());

    // Запускаем sync job в child process чтобы loop работал
    (new SyncSupplierProjectsJob)->handle();

    $loop->run();
    $socket->close();

    expect($receivedRequests)->not->toBeEmpty()
        ->and($receivedRequests[0]['path'])->toBe('/admin/rt-project-save');

    $sp->refresh();
    expect($sp->supplier_external_id)->toBe(12345)
        ->and($sp->sync_status)->toBe('ok');
});

NOTE: Зависимости — react/http, react/socket. Если их нет в composer.json — добавить через composer require --dev react/http react/socket сначала.

  • Step 2: Установить react/http + react/socket (если не установлены)
cd app && composer require --dev react/http react/socket 2>&1 | tail -5

Verify через composer show | grep react/.

  • Step 3: Run E2E test (только Linux)

На Windows native:

cd app && php vendor/bin/pest tests/Browser/SupplierIntegrationE2ETest.php 2>&1

Expected на Windows: 1 SKIPPED.

На Linux CI (через GitHub Actions):

cd app && php vendor/bin/pest tests/Browser/SupplierIntegrationE2ETest.php 2>&1

Expected: 1 PASS.

  • Step 4: Расширить lefthook.yml — добавить fast-job для supplier tests

В lefthook.yml секция pre-commit после job #8 ESLint-Vue:

    # 9. Pest supplier subsuite fast — только на staged изменения в supplier-flow.
    # Полный composer test остаётся на pre-push.
    - name: pest-supplier-fast
      glob: "app/app/{Jobs,Services,Console,Mail,Exceptions}/Supplier/**/*.php"
      root: "app/"
      run: php vendor/bin/pest tests/Unit/Supplier/ tests/Feature/Supplier/ --no-coverage
      fail_text: |
        Pest supplier-subsuite failed.
        Запусти `cd app && php vendor/bin/pest tests/Unit/Supplier/ tests/Feature/Supplier/` локально.
  • Step 5: Verify lefthook config
npx lefthook validate 2>&1

Expected: «valid» / 0 errors.

  • Step 6: Final full-suite test run
cd app && php vendor/bin/pest --parallel 2>&1 | tail -20

Expected: ≥608 PASS (Plan 2.6 baseline 558 + Task 3-9 новых ~50-60 tests). 0 failed (E2E test на Windows = 1 skipped, что OK).

  • Step 7: Larastan final check
cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M 2>&1 | tail -10

Expected: 0 errors above baseline.

  • Step 8: gitleaks-full-history (Plan 3 готовится к push)
./bin/gitleaks.exe detect --source . --no-banner --config .gitleaks.toml --redact 2>&1 | tail -10

Expected: 0 leaks.

  • Step 9: lychee link check
./bin/lychee.exe --config .lychee.toml "docs/**/*.md" "*.md" 2>&1 | tail -20

Expected: 0 broken links. Если в spec'е/plan'е ссылки на ещё не существующие файлы — добавить в .lychee.toml exclude или поправить.

  • Step 10: Commit
git add app/tests/Browser/SupplierIntegrationE2ETest.php app/composer.json app/composer.lock lefthook.yml

git commit -m "feat(supplier): Plan 3 Task 9 — E2E integration test + lefthook fast-job

Component:
- tests/Browser/SupplierIntegrationE2ETest.php: mock-server (react/http +
  react/socket) на random port, full flow liderra_project → SyncJob →
  rt-project-save payload verification. Skip на Windows native (no
  ext-sockets), Linux CI only.
- lefthook.yml: +pest-supplier-fast job — на изменения в
  app/app/{Jobs,Services,Console,Mail,Exceptions}/Supplier/**/*.php
  запускает только tests/Unit/Supplier/ + tests/Feature/Supplier/.

Composer: +react/http +react/socket (dev). 1 E2E test."

Финальный flow перед FF-merge

После Task 9 — обязательные финальные шаги:

  • Step F1: Запуск Comprehensive Verification Gate (CV.1CV.14)

Аналогично Plan 2 / Plan 2.6 — 14 проверок: spec coverage, test count, larastan, pint, squawk, gitleaks (staged + full-history), lychee, schedule:list, route:list, config:show database.connections, migrate:fresh smoke, parallel test run, code-review subagent от 2026-05-XX (раздел audit), git log clean atomic commits.

  • Step F2: code-review subagent

Dispatch fresh subagent с конкретным prompt'ом:

  • Прочитать spec 2026-05-11-plan3-supplier-sync-design.md
  • Прочитать все 9 Task'овых коммитов
  • Проверить BLOCKERов, WARNINGов, NITs
  • Verdict: «Ready for FF-merge» или список fix'ов

Если subagent найдёт BLOCKER — fix отдельным commit'ом, новый subagent run.

  • Step F3: FF-merge в main
git checkout main
git merge --ff-only supplier-sync-plan3

⚠ Plan 3 идёт на feature-branch supplier-sync-plan3 (как Plan 2 на supplier-webhook-plan2). Создать ветку в начале Task 3 (или Task 1):

git checkout -b supplier-sync-plan3
  • Step F4: Push с pre-push hooks
git push -u origin supplier-sync-plan3
git push origin main  # после FF-merge

Pre-push: gitleaks-full-history + lychee. Если lychee пожалуется на 2026-05-11-supplier-sync-plan3.md ссылку из spec'а (теперь plan-файл существует) — должно пройти.

  • Step F5: Update memory project_supplier_integration.md

После merge — обновить memory с финальными метриками Plan 3 (commit'ы, новые tests count, обновлённый Plan 3 = DONE).


Self-review (Step после написания plan'а)

1. Spec coverage check (§1–§10 → Tasks):

Spec § Task
§1 Контекст covered Tasks 3 (BYPASSRLS) + 49 (sync block)
§2.1 Discovery + §2.2 Implementation Tasks 12 + Tasks 39
§2.3 0 schema changes Plan 3 = code-only, верифицировано в Step F1
§3.1 PG-connection Task 3 Step 1
§3.2 файлы под изменение Task 3 Steps 5/11/15
§3.3 новые файлы Tasks 49
§3.4 Schedule entries Task 8 Step 8
§4.1 Lead routing flow Task 3 (по сути «set $connection»)
§4.2 Session refresh flow Task 5
§4.3 Sync flow Task 6
§4.4 Cleanup flow (Phase A→B→C ordering) Task 7
§5 Error handling matrix Tasks 4 (exceptions) + 6 (sync), 7 (cleanup)
§6 Testing pyramid покрывается всеми Tasks; pre-commit fast в Task 9
§7 Acceptance criteria финальные шаги F1–F5
§8 Limitations вынесено в NOTE-блоки в Tasks 4 Step 6 (toPayload placeholder) + Task 5 Step 2 (DOM selectors)
§9 Discovery questions Task 1 (probe собирает ответы) + Task 2 (spec update)
§10 Next step этот plan-файл

Все секции spec'а покрыты.

2. Placeholder scan:

  • 0 «TBD», 0 «TODO», 0 «implement later», 0 «fill in details», 0 «Add appropriate error handling», 0 «similar to Task N» без кода.
  • 2 явных NOTE-блока (Tasks 4 Step 6 + 5 Step 2) с placeholder'ами для будущей корректировки после Task 1 discovery — это legitimate, заявлено явно с указанием когда фиксить.

3. Type consistency:

  • SupplierProjectDto определён Task 4 Step 1, использован в Tasks 4 (Client) + 6 (Allocator + SyncJob) + 7 (Cleanup) — единые поля.
  • SupplierAuthException / Transient / Client — определены Task 4 Step 3, использованы Tasks 4–7.
  • SupplierPortalClient методы listProjects/saveProject/updateProject/deleteProject — определены Task 4 Step 6, использованы Tasks 6/7.
  • pgsql_supplier connection — определён Task 3 Step 1, использован Tasks 3 + 5 + 6 + 7 + 8.
  • Cache key supplier:session — Task 5 Step 9, читается Task 4 Step 6 (SupplierPortalClient::loadSession).
  • crm_supplier_worker role — упомянута в Task 3 Step 1 (config) + Step 19 commit message, ссылка на Plan 2.6 #iv 7899071 корректна.

Типы / имена / ссылки консистентны across all 9 Tasks.


Plan complete. Saved to docs/superpowers/plans/2026-05-11-supplier-sync-plan3.md.