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

Компоненты:
- SupplierProjectDto (readonly DTO, fromModel + equals)
- SupplierException иерархия (Auth/Transient/Client + abstract base)
  - SupplierAuthException: 401/403 sticky после refresh-retry
  - SupplierTransientException: 5xx/network/timeout (retryable)
  - SupplierClientException: 4xx 400/404/422 (наша ошибка payload)
- 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
- RefreshSupplierSessionJob stub (handle() пустой; full impl в Task 5)
- config/services.supplier (login/password/portal_url/alert_email)

+9 тестов через Http::fake() в Unit/Supplier/SupplierPortalClientTest.php:
- cookie/CSRF attach
- 401 retry flow (single retry, sticky 401 → SupplierAuthException)
- 5xx → SupplierTransientException
- 4xx → SupplierClientException
- network error → SupplierTransientException
- save/update/delete payload shapes + responses

NOTE: toPayload() shape — placeholder; точные поля адаптируются
после Task 1 discovery + Task 2 spec §4.4 (отдельный fixup commit
перед Task 6 при расхождении наблюдаемого формата с предполагаемым).
This commit is contained in:
Дмитрий
2026-05-11 01:42:08 +03:00
parent 8c70255d2b
commit 8fc9d3ec8a
9 changed files with 461 additions and 0 deletions
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Supplier;
/**
* Sticky 401/403 Task 5 RefreshSupplierSessionJob не помог.
* Эскалация через email + Sentry critical.
*/
final class SupplierAuthException extends SupplierException {}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Supplier;
/**
* 4xx (400/404/422) наша ошибка payload'а, retry бесполезен.
*/
final class SupplierClientException extends SupplierException {}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Supplier;
use RuntimeException;
use Throwable;
/**
* Базовый класс для всех ошибок взаимодействия с supplier-порталом crm.bp-gr.ru.
*
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.4
*/
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);
}
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Supplier;
/**
* 5xx, network timeout, connection refused retryable на job-уровне.
*/
final class SupplierTransientException extends SupplierException {}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Supplier;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Plan 3 Task 4 stub. Полная реализация Task 5 (PlaywrightBridge integration).
* До Task 5 dispatch_sync(RefreshSupplierSessionJob) noop (handle() пустой).
*/
final class RefreshSupplierSessionJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
// Task 5 implementation
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Services\Supplier\Dto;
use App\Models\SupplierProject;
/**
* Read-only DTO для передачи aggregate-состояния supplier_project
* между SupplierQuotaAllocator и SupplierPortalClient.
*
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.4
*/
final readonly class SupplierProjectDto
{
/**
* @param array<int, int> $workdays [1,2,3,4,5,6,7] (Пн..Вс)
* @param array<int, int|string> $regions массив кодов регионов РФ
*/
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,
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;
}
}
@@ -0,0 +1,169 @@
<?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\ConnectionException;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
/**
* HTTP-обёртка над rt-* AJAX endpoints supplier-портала crm.bp-gr.ru.
*
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.4
*
* Endpoints (placeholder, точные имена адаптируются после Task 1 discovery):
* - GET /admin/rt-projects-load список проектов
* - POST /admin/rt-project-save создание
* - POST /admin/rt-project-update обновление
* - POST /admin/rt-project-delete удаление
*
* Авторизация: PHPSESSID cookie + X-CSRF-Token header (Redis cache 'supplier:session').
* На 401/403 single retry через dispatch_sync(RefreshSupplierSessionJob).
*/
final class SupplierPortalClient
{
public function __construct(
private readonly HttpFactory $http,
) {}
/**
* @return array<int, mixed>
*/
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]);
}
/**
* @param array<string, mixed> $body
*/
private function request(string $method, string $path, array $body = [], bool $isRetry = false): Response
{
$session = $this->loadSession();
$portalUrl = (string) config('services.supplier.portal_url');
$url = rtrim($portalUrl, '/').$path;
$host = (string) parse_url($url, PHP_URL_HOST);
$request = $this->http
->withCookies(['PHPSESSID' => $session['phpsessid']], $host)
->withHeaders(['X-CSRF-Token' => $session['csrf']])
->timeout(30);
try {
if ($method === 'GET') {
$response = $request->get($url, $body);
} else {
$response = $request->asForm()->post($url, $body);
}
} catch (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;
}
/**
* @return array{phpsessid: string, csrf: string, refreshed_at?: string}
*/
private function loadSession(): array
{
/** @var array{phpsessid?: string, csrf?: string, refreshed_at?: string}|null $session */
$session = Cache::store('redis')->get('supplier:session');
if ($session === null || ! isset($session['phpsessid'], $session['csrf'])) {
dispatch_sync(new RefreshSupplierSessionJob);
/** @var array{phpsessid?: string, csrf?: string, refreshed_at?: string}|null $session */
$session = Cache::store('redis')->get('supplier:session');
if ($session === null || ! isset($session['phpsessid'], $session['csrf'])) {
throw new SupplierAuthException('Session refresh failed: cache still empty');
}
}
/** @var array{phpsessid: string, csrf: string, refreshed_at?: string} $session */
return $session;
}
/**
* NOTE: payload-shape placeholder. Точные поля будут адаптированы
* после Task 1 discovery + Task 2 spec §4.4 (отдельный fixup commit
* перед Task 6 при расхождении).
*
* @return array<string, mixed>
*/
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,
];
}
}
+7
View File
@@ -35,4 +35,11 @@ return [
],
],
'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'),
],
];
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
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 App\Services\Supplier\SupplierPortalClient;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function (): void {
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 (): void {
Cache::store('redis')->forget('supplier:session');
});
test('listProjects attaches PHPSESSID cookie and X-CSRF-Token header', function (): void {
Http::fake([
'crm.bp-gr.ru/admin/rt-projects-load*' => Http::response('[]', 200),
]);
app(SupplierPortalClient::class)->listProjects();
Http::assertSent(function ($request): bool {
$cookieHeader = $request->header('Cookie')[0] ?? '';
$csrfHeader = $request->header('X-CSRF-Token')[0] ?? '';
return str_contains($cookieHeader, 'PHPSESSID=abc123sessid')
&& $csrfHeader === 'csrf-token-xyz';
});
});
test('401 triggers RefreshSupplierSessionJob sync and retries once', function (): void {
Bus::fake([RefreshSupplierSessionJob::class]);
Http::fakeSequence('crm.bp-gr.ru/*')
->push('Unauthorized', 401)
->push('[]', 200);
app(SupplierPortalClient::class)->listProjects();
Bus::assertDispatchedSync(RefreshSupplierSessionJob::class);
Http::assertSentCount(2);
});
test('sticky 401 throws SupplierAuthException after one retry', function (): void {
Bus::fake([RefreshSupplierSessionJob::class]);
Http::fakeSequence('crm.bp-gr.ru/*')
->push('Unauthorized', 401)
->push('Still unauthorized', 401);
expect(fn () => app(SupplierPortalClient::class)->listProjects())
->toThrow(SupplierAuthException::class);
});
test('5xx response throws SupplierTransientException', function (): void {
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 (): void {
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 (): void {
Http::fake(function (): void {
throw new ConnectionException('Connection refused');
});
expect(fn () => app(SupplierPortalClient::class)->listProjects())
->toThrow(SupplierTransientException::class);
});
test('saveProject POSTs to /admin/rt-project-save with full payload and returns external id', function (): void {
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): bool => $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 (): void {
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): bool => $r->method() === 'POST'
&& str_ends_with($r->url(), '/admin/rt-project-update')
&& ((int) ($r['id'] ?? 0)) === 12345);
});
test('deleteProject POSTs to /admin/rt-project-delete with id only', function (): void {
Http::fake(['crm.bp-gr.ru/admin/rt-project-delete' => Http::response('', 200)]);
app(SupplierPortalClient::class)->deleteProject(externalId: 12345);
Http::assertSent(fn ($r): bool => $r->method() === 'POST'
&& str_ends_with($r->url(), '/admin/rt-project-delete')
&& ((int) ($r['id'] ?? 0)) === 12345);
});