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:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user