feat(127): polkurier integration foundation

Single-instance globalna konfiguracja polkurier.pl jako alternatywa
dla Apaczki: szyfrowany login + Token API, karta w hubie integracji
i realny test polaczenia przez apimetod=test_auth_api zweryfikowany
na zywym koncie operatora (Autoryzacja: 1).

ShipmentProviderRegistry netkniety - PolkurierShipmentService/
TrackingService w kolejnych fazach.

Kluczowe ustalenia kontraktu API (z SDK polkurier-sdk):
- POST https://api.polkurier.pl/ (jeden endpoint)
- JSON body: {authorization:{login,token}, apimetod, data}
- Sukces: top-level status === 'success' (nie 'ok')
- Blad: tresc w polu 'response' envelope'a
- Content-Type: application/json (strict, bez charset suffix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 11:43:11 +02:00
parent 541e61bf7d
commit 3443879f59
17 changed files with 1391 additions and 17 deletions

View File

@@ -24,7 +24,8 @@ final class IntegrationsHubController
private readonly ShopproIntegrationsRepository $shoppro,
private readonly FakturowniaIntegrationRepository $fakturownia,
private readonly HostedSmsIntegrationRepository $hostedSms,
private readonly SmsplanetIntegrationRepository $smsplanet
private readonly SmsplanetIntegrationRepository $smsplanet,
private readonly PolkurierIntegrationRepository $polkurier
) {
}
@@ -34,6 +35,7 @@ final class IntegrationsHubController
$this->buildAllegroRow('sandbox'),
$this->buildAllegroRow('production'),
$this->buildApaczkaRow(),
$this->buildPolkurierRow(),
$this->buildInpostRow(),
$this->buildShopproRow(),
$this->buildFakturowniaRow(),
@@ -224,6 +226,30 @@ final class IntegrationsHubController
];
}
/**
* @return array<string, mixed>
*/
private function buildPolkurierRow(): array
{
$settings = $this->polkurier->getSettings();
$isConfigured = !empty($settings['login']) && !empty($settings['has_api_token']);
return [
'provider' => $this->translator->get('settings.integrations_hub.providers.polkurier'),
'instance' => 'polkurier.pl',
'authorization_status' => $isConfigured
? $this->translator->get('settings.integrations_hub.status.configured')
: $this->translator->get('settings.integrations_hub.status.not_configured'),
'secret_status' => !empty($settings['has_api_token'])
? $this->translator->get('settings.integrations_hub.status.saved')
: $this->translator->get('settings.integrations_hub.status.missing'),
'is_active' => !empty($settings['is_active']),
'last_test_at' => trim((string) ($settings['last_test_at'] ?? '')),
'configure_url' => '/settings/integrations/polkurier',
'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'),
];
}
/**
* @return array<string, mixed>
*/

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Http\SslCertificateResolver;
use RuntimeException;
/**
* polkurier.pl Web Service API client (Phase 127).
*
* Kontrakt API zweryfikowany na podstawie oficjalnego SDK (https://github.com/Polkurier/polkurier-sdk):
* - Base URL: https://api.polkurier.pl/ (single endpoint, brak path per metoda)
* - HTTP POST, Content-Type: application/json
* - Body: {"authorization": {"login": "...", "token": "..."}, "apimetod": "<method_name>", "data": {...}}
* - Test polaczenia: apimetod = "test_auth_api"
* - Sukces: top-level "status" === "success" (ResponseStatus::SUCCESS w SDK), authorization w polu "response"
* - Blad: top-level "status" !== "success"; tresc bledu w polu "response" (string lub tablica)
*
* Stuby createShipment/getLabel/getStatus/cancelOrder dolozone w kolejnych fazach.
*/
final class PolkurierApiClient
{
private const API_URL = 'https://api.polkurier.pl/';
private const PLATFORM = 'orderPRO';
private const PLATFORM_VERSION = '1.0';
public function __construct(private readonly int $timeoutSeconds = 15)
{
}
/**
* @return array{ok: bool, http_code: int, message: string}
*/
public function testConnection(string $login, string $apiToken): array
{
$payload = [
'authorization' => [
'login' => trim($login),
'token' => trim($apiToken),
],
'apimetod' => 'test_auth_api',
'data' => [
'platform' => self::PLATFORM,
'platform_version' => self::PLATFORM_VERSION,
],
];
[$body, $httpCode, $curlError] = $this->postJson($payload);
if ($curlError !== null) {
return [
'ok' => false,
'http_code' => $httpCode,
'message' => 'Blad polaczenia: ' . $curlError,
];
}
$decoded = json_decode(ltrim($body, "\xEF\xBB\xBF \t\n\r\0\x0B"), true);
if (!is_array($decoded)) {
return [
'ok' => false,
'http_code' => $httpCode,
'message' => 'Niepoprawna odpowiedz JSON polkurier: ' . substr(trim(strip_tags($body)), 0, 180),
];
}
$status = strtolower(trim((string) ($decoded['status'] ?? '')));
$responseField = $decoded['response'] ?? null;
if ($httpCode >= 200 && $httpCode < 300 && $status === 'success') {
$authorization = '';
if (is_array($responseField)) {
$authorization = trim((string) ($responseField['authorization'] ?? ''));
}
$message = $authorization !== ''
? 'Autoryzacja: ' . $authorization
: 'Polaczenie OK (HTTP ' . $httpCode . ').';
return [
'ok' => true,
'http_code' => $httpCode,
'message' => $message,
];
}
// Error path: w SDK polkuriera (PolkurierWebService.php) gdy status != 'success',
// tresc bledu jest rzucana z $response->get('response') — czyli pole 'response' z envelope JSON.
// Pole 'response' moze byc stringiem (tekst bledu), tablica (struktura) lub null.
$errorMessage = '';
if (is_string($responseField)) {
$errorMessage = trim($responseField);
} elseif (is_array($responseField)) {
$errorMessage = trim((string) (
$responseField['error_message']
?? $responseField['errorMessage']
?? $responseField['message']
?? $responseField['error']
?? ''
));
if ($errorMessage === '') {
$jsonDump = json_encode($responseField, JSON_UNESCAPED_UNICODE);
if (is_string($jsonDump)) {
$errorMessage = substr($jsonDump, 0, 240);
}
}
}
if ($errorMessage === '') {
$errorMessage = trim((string) (
$decoded['error_message']
?? $decoded['errorMessage']
?? $decoded['message']
?? $decoded['error']
?? ''
));
}
if ($errorMessage === '') {
$errorMessage = $status !== '' ? 'Status: ' . $status . ' (HTTP ' . $httpCode . ')' : 'HTTP ' . $httpCode;
}
return [
'ok' => false,
'http_code' => $httpCode,
'message' => $errorMessage,
];
}
public function createShipment(): never
{
throw new RuntimeException('PolkurierApiClient::createShipment not implemented in Phase 127.');
}
public function getLabel(): never
{
throw new RuntimeException('PolkurierApiClient::getLabel not implemented in Phase 127.');
}
public function getStatus(): never
{
throw new RuntimeException('PolkurierApiClient::getStatus not implemented in Phase 127.');
}
public function cancelOrder(): never
{
throw new RuntimeException('PolkurierApiClient::cancelOrder not implemented in Phase 127.');
}
/**
* @param array<string, mixed> $payload
* @return array{0: string, 1: int, 2: ?string}
*/
private function postJson(array $payload): array
{
$ch = curl_init(self::API_URL);
if ($ch === false) {
return ['', 0, 'Nie udalo sie zainicjowac cURL.'];
}
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
return ['', 0, 'Nie udalo sie zakodowac payloadu JSON.'];
}
$options = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $encoded,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Content-Type: application/json',
'User-Agent: orderPRO/1.0',
],
];
$caPath = SslCertificateResolver::resolve();
if ($caPath !== null) {
$options[CURLOPT_CAINFO] = $caPath;
}
curl_setopt_array($ch, $options);
$rawBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
unset($ch);
if ($rawBody === false) {
return ['', $httpCode, $curlError !== '' ? $curlError : 'Brak odpowiedzi z API.'];
}
return [(string) $rawBody, $httpCode, null];
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Http\RedirectPathResolver;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use Throwable;
final class PolkurierIntegrationController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly PolkurierIntegrationRepository $repository,
private readonly PolkurierApiClient $apiClient,
private readonly IntegrationsRepository $integrations
) {
}
public function index(Request $request): Response
{
$html = $this->template->render('settings/polkurier', [
'title' => $this->translator->get('settings.polkurier.title'),
'activeMenu' => 'settings',
'activeSettings' => 'integrations',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'settings' => $this->repository->getSettings(),
'errorMessage' => (string) Flash::get('settings_error', ''),
'successMessage' => (string) Flash::get('settings_success', ''),
'testMessage' => (string) Flash::get('polkurier_test', ''),
], 'layouts/app');
return Response::html($html);
}
public function save(Request $request): Response
{
$redirectTo = $this->resolveRedirect($request);
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
try {
$this->repository->saveSettings([
'login' => (string) $request->input('login', ''),
'api_token' => (string) $request->input('api_token', ''),
'default_label_format' => (string) $request->input('default_label_format', 'PDF'),
'is_active' => $request->input('is_active', ''),
]);
Flash::set('settings_success', $this->translator->get('settings.polkurier.flash.saved'));
} catch (Throwable $exception) {
Flash::set(
'settings_error',
$this->translator->get('settings.polkurier.flash.save_failed') . ' ' . $exception->getMessage()
);
}
return Response::redirect($redirectTo);
}
public function test(Request $request): Response
{
$redirectTo = $this->resolveRedirect($request);
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
try {
$credentials = $this->repository->getCredentials();
if ($credentials === null) {
throw new IntegrationConfigException('Najpierw zapisz kompletna konfiguracje polkurier (login + Token API + aktywacja).');
}
$result = $this->apiClient->testConnection(
$credentials['login'],
$credentials['api_token']
);
$status = $result['ok'] ? 'ok' : 'fail';
$this->integrations->updateTestResult(
$credentials['integration_id'],
$status,
(int) $result['http_code'],
(string) $result['message']
);
if ($result['ok']) {
Flash::set('polkurier_test', $this->translator->get('settings.polkurier.flash.test_success', [
'message' => (string) $result['message'],
]));
} else {
Flash::set('settings_error', $this->translator->get('settings.polkurier.flash.test_failed') . ' ' . $result['message']);
}
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.polkurier.flash.test_failed') . ' ' . $exception->getMessage());
}
return Response::redirect($redirectTo);
}
private function resolveRedirect(Request $request): string
{
return RedirectPathResolver::resolve(
(string) $request->input('return_to', '/settings/integrations/polkurier'),
['/settings/integrations'],
'/settings/integrations/polkurier'
);
}
}

View File

@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Support\StringHelper;
use PDO;
use Throwable;
final class PolkurierIntegrationRepository
{
private const INTEGRATION_TYPE = 'polkurier';
private const INTEGRATION_NAME = 'polkurier';
private const INTEGRATION_BASE_URL = 'https://api.polkurier.pl/';
private readonly IntegrationsRepository $integrations;
private readonly IntegrationSecretCipher $cipher;
public function __construct(
private readonly PDO $pdo,
private readonly string $secret
) {
$this->integrations = new IntegrationsRepository($this->pdo);
$this->cipher = new IntegrationSecretCipher($this->secret);
}
/**
* @return array<string, mixed>
*/
public function getSettings(): array
{
$this->ensureRow();
$integrationId = $this->ensureBaseIntegration();
$row = $this->fetchRow();
$integration = $this->integrations->findById($integrationId);
$tokenEncrypted = $this->resolveTokenEncrypted($row, $integration);
return [
'integration_id' => $integrationId,
'login' => trim((string) ($row['login'] ?? '')),
'default_label_format' => trim((string) ($row['default_label_format'] ?? 'PDF')) ?: 'PDF',
'has_api_token' => $tokenEncrypted !== null && $tokenEncrypted !== '',
'is_active' => (int) ($integration['is_active'] ?? 1) === 1,
'last_test_status' => trim((string) ($integration['last_test_status'] ?? '')),
'last_test_http_code' => isset($integration['last_test_http_code']) ? (int) $integration['last_test_http_code'] : null,
'last_test_message' => trim((string) ($integration['last_test_message'] ?? '')),
'last_test_at' => trim((string) ($integration['last_test_at'] ?? '')),
'updated_at' => trim((string) ($row['updated_at'] ?? '')),
];
}
/**
* @param array<string, mixed> $payload
*/
public function saveSettings(array $payload): void
{
$this->ensureRow();
$integrationId = $this->ensureBaseIntegration();
$row = $this->fetchRow();
if ($row === null) {
throw new IntegrationConfigException('Brak rekordu konfiguracji polkurier.');
}
$login = trim((string) ($payload['login'] ?? ''));
if ($login === '' || strlen($login) > 190) {
throw new IntegrationConfigException('Podaj login polkurier (e-mail lub identyfikator z panelu klienta, maks. 190 znakow).');
}
$labelFormatRaw = strtoupper(trim((string) ($payload['default_label_format'] ?? 'PDF')));
$allowedFormats = ['PDF', 'ZPL', 'EPL'];
$labelFormat = in_array($labelFormatRaw, $allowedFormats, true) ? $labelFormatRaw : 'PDF';
$currentEncrypted = $this->resolveTokenEncrypted($row, $this->integrations->findById($integrationId));
$token = trim((string) ($payload['api_token'] ?? ''));
$nextEncrypted = $currentEncrypted;
if ($token !== '') {
$nextEncrypted = $this->cipher->encrypt($token);
}
if ($nextEncrypted === null || $nextEncrypted === '') {
throw new IntegrationConfigException('Podaj Token API polkurier (z Panel Klienta -> Ustawienia -> Token API).');
}
$statement = $this->pdo->prepare(
'UPDATE polkurier_integration_settings
SET login = :login,
api_token_encrypted = :api_token_encrypted,
default_label_format = :default_label_format,
updated_at = NOW()
WHERE id = 1'
);
$statement->execute([
'login' => $login,
'api_token_encrypted' => $nextEncrypted,
'default_label_format' => $labelFormat,
]);
$this->updateIntegrationActive($integrationId, !empty($payload['is_active']));
$this->integrations->updateApiKeyEncrypted($integrationId, $nextEncrypted);
}
/**
* @return array{integration_id: int, login: string, api_token: string, default_label_format: string}|null
*/
public function getCredentials(): ?array
{
$this->ensureRow();
$integrationId = $this->ensureBaseIntegration();
$row = $this->fetchRow();
if ($row === null) {
return null;
}
$integration = $this->integrations->findById($integrationId);
if (empty($integration) || (int) ($integration['is_active'] ?? 0) !== 1) {
return null;
}
$login = trim((string) ($row['login'] ?? ''));
$encrypted = $this->resolveTokenEncrypted($row, $integration);
if ($login === '' || $encrypted === null || $encrypted === '') {
return null;
}
$token = trim((string) $this->cipher->decrypt($encrypted));
if ($token === '') {
return null;
}
return [
'integration_id' => $integrationId,
'login' => $login,
'api_token' => $token,
'default_label_format' => trim((string) ($row['default_label_format'] ?? 'PDF')) ?: 'PDF',
];
}
public function getIntegrationId(): ?int
{
$integration = $this->integrations->findByTypeAndName(self::INTEGRATION_TYPE, self::INTEGRATION_NAME);
if ($integration === null) {
return null;
}
$id = (int) ($integration['id'] ?? 0);
return $id > 0 ? $id : null;
}
private function ensureBaseIntegration(): int
{
return $this->integrations->ensureIntegration(
self::INTEGRATION_TYPE,
self::INTEGRATION_NAME,
self::INTEGRATION_BASE_URL,
15,
true
);
}
private function ensureRow(): void
{
$integrationId = $this->ensureBaseIntegration();
$statement = $this->pdo->prepare(
'INSERT INTO polkurier_integration_settings (id, integration_id, created_at, updated_at)
VALUES (1, :integration_id, NOW(), NOW())
ON DUPLICATE KEY UPDATE integration_id = VALUES(integration_id), updated_at = VALUES(updated_at)'
);
$statement->execute(['integration_id' => $integrationId]);
}
/**
* @return array<string, mixed>|null
*/
private function fetchRow(): ?array
{
try {
$statement = $this->pdo->prepare('SELECT * FROM polkurier_integration_settings WHERE id = 1 LIMIT 1');
$statement->execute();
$row = $statement->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return null;
}
return is_array($row) ? $row : null;
}
/**
* @param array<string, mixed>|null $row
* @param array<string, mixed>|null $integration
*/
private function resolveTokenEncrypted(?array $row, ?array $integration): ?string
{
$settingsValue = trim((string) ($row['api_token_encrypted'] ?? ''));
if ($settingsValue !== '') {
return $settingsValue;
}
$baseValue = trim((string) ($integration['api_key_encrypted'] ?? ''));
return StringHelper::nullableString($baseValue);
}
private function updateIntegrationActive(int $integrationId, bool $isActive): void
{
$statement = $this->pdo->prepare(
'UPDATE integrations
SET is_active = :is_active,
updated_at = NOW()
WHERE id = :id AND type = :type'
);
$statement->execute([
'id' => $integrationId,
'type' => self::INTEGRATION_TYPE,
'is_active' => $isActive ? 1 : 0,
]);
}
}