From 8fc9d3ec8a68fa0fc430dd7a5fd230de084bb185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 11 May 2026 01:42:08 +0300 Subject: [PATCH] =?UTF-8?q?feat(supplier):=20Plan=203=20Task=204=20?= =?UTF-8?q?=E2=80=94=20SupplierPortalClient=20HTTP-=D0=BE=D0=B1=D1=91?= =?UTF-8?q?=D1=80=D1=82=D0=BA=D0=B0=20=D0=BD=D0=B0=D0=B4=20rt-*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Компоненты: - 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 при расхождении наблюдаемого формата с предполагаемым). --- .../Supplier/SupplierAuthException.php | 11 ++ .../Supplier/SupplierClientException.php | 10 ++ .../Exceptions/Supplier/SupplierException.php | 25 +++ .../Supplier/SupplierTransientException.php | 10 ++ .../Supplier/RefreshSupplierSessionJob.php | 25 +++ .../Supplier/Dto/SupplierProjectDto.php | 57 ++++++ .../Supplier/SupplierPortalClient.php | 169 ++++++++++++++++++ app/config/services.php | 7 + .../Supplier/SupplierPortalClientTest.php | 147 +++++++++++++++ 9 files changed, 461 insertions(+) create mode 100644 app/app/Exceptions/Supplier/SupplierAuthException.php create mode 100644 app/app/Exceptions/Supplier/SupplierClientException.php create mode 100644 app/app/Exceptions/Supplier/SupplierException.php create mode 100644 app/app/Exceptions/Supplier/SupplierTransientException.php create mode 100644 app/app/Jobs/Supplier/RefreshSupplierSessionJob.php create mode 100644 app/app/Services/Supplier/Dto/SupplierProjectDto.php create mode 100644 app/app/Services/Supplier/SupplierPortalClient.php create mode 100644 app/tests/Unit/Supplier/SupplierPortalClientTest.php diff --git a/app/app/Exceptions/Supplier/SupplierAuthException.php b/app/app/Exceptions/Supplier/SupplierAuthException.php new file mode 100644 index 00000000..ab764f2d --- /dev/null +++ b/app/app/Exceptions/Supplier/SupplierAuthException.php @@ -0,0 +1,11 @@ + $workdays [1,2,3,4,5,6,7] (Пн..Вс) + * @param array $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; + } +} diff --git a/app/app/Services/Supplier/SupplierPortalClient.php b/app/app/Services/Supplier/SupplierPortalClient.php new file mode 100644 index 00000000..11ee29be --- /dev/null +++ b/app/app/Services/Supplier/SupplierPortalClient.php @@ -0,0 +1,169 @@ + + */ + 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 $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 + */ + 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, + ]; + } +} diff --git a/app/config/services.php b/app/config/services.php index 6a90eb83..f9aea09a 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -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'), + ], + ]; diff --git a/app/tests/Unit/Supplier/SupplierPortalClientTest.php b/app/tests/Unit/Supplier/SupplierPortalClientTest.php new file mode 100644 index 00000000..4c1ed191 --- /dev/null +++ b/app/tests/Unit/Supplier/SupplierPortalClientTest.php @@ -0,0 +1,147 @@ +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); +});