Полный 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>
117 KiB
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 1–2): через mcp__playwright__browser_* MCP записываем 5 HTTP-фиксаций реальных AJAX-команд поставщика crm.bp-gr.ru; обновляем parent spec §4.4. Фаза II — Implementation (Tasks 3–9): переключение 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:
mcp__playwright__browser_navigate→https://crm.bp-gr.ru/admin/user/login(или другой login URL — узнаем при навигации)mcp__playwright__browser_snapshot— увидеть DOM, найти input-names для login/passwordmcp__playwright__browser_fill_form→ заполнить login + passwordmcp__playwright__browser_network_requests— записать ВСЕ requests за login flowmcp__playwright__browser_clickна submit- Снова
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:
mcp__playwright__browser_navigate→ URL списка проектов (например/admin/project/list)mcp__playwright__browser_network_requests— найти AJAX-запрос сrt-projects-loadили аналог- Извлечь полный request+response
Сохранить в app/tests/Fixtures/SupplierPortal/projects-load.json тем же форматом (request/response).
- Step 5: Записать project-save fixture (rt-project-save)
Через MCP создать тестовый проект __claude_probe_<unix_timestamp>:
mcp__playwright__browser_navigate→ форма создания проектаmcp__playwright__browser_fill_form→ заполнить минимум обязательных полей (name=__claude_probe_<ts>, signal_type=site, identifier=example.com, daily_limit=1, workdays=Пн-Пт)mcp__playwright__browser_clickSavemcp__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:
- Navigate в edit-форму
- Изменить daily_limit
- Submit
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>:
- Найти Delete button или контекстное меню
- Click Delete
mcp__playwright__browser_handle_dialogaccept (если confirm dialog)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_supplierconnection в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.1–CV.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) + 4–9 (sync block) |
| §2.1 Discovery + §2.2 Implementation | Tasks 1–2 + Tasks 3–9 |
| §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 4–9 |
| §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_supplierconnection — определён 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_workerrole — упомянута в Task 3 Step 1 (config) + Step 19 commit message, ссылка на Plan 2.6 #iv7899071корректна.
✅ Типы / имена / ссылки консистентны across all 9 Tasks.
Plan complete. Saved to docs/superpowers/plans/2026-05-11-supplier-sync-plan3.md.