feat(shipments): add ShipmentProviderInterface and ShipmentProviderRegistry

- Introduced ShipmentProviderInterface to define the contract for shipment providers.
- Implemented ShipmentProviderRegistry to manage and retrieve shipment providers.
- Added a new tool for probing Apaczka order_send payload variants, enhancing debugging capabilities.
This commit is contained in:
2026-03-08 23:45:10 +01:00
parent af052e1ff5
commit 2b12fde248
34 changed files with 3285 additions and 233 deletions

View File

@@ -51,8 +51,10 @@ final class AllegroIntegrationController
private readonly AllegroOrderImportService $orderImportService,
private readonly AllegroStatusDiscoveryService $statusDiscoveryService,
private readonly string $appUrl,
private readonly ?AllegroDeliveryMethodMappingRepository $deliveryMappings = null,
private readonly ?AllegroApiClient $apiClient = null
private readonly ?CarrierDeliveryMethodMappingRepository $deliveryMappings = null,
private readonly ?AllegroApiClient $apiClient = null,
private readonly ?ApaczkaIntegrationRepository $apaczkaRepository = null,
private readonly ?ApaczkaApiClient $apaczkaApiClient = null
) {
}
@@ -77,7 +79,7 @@ final class AllegroIntegrationController
$importIntervalSeconds = $this->currentImportIntervalSeconds();
$statusSyncDirection = $this->currentStatusSyncDirection();
$statusSyncIntervalMinutes = $this->currentStatusSyncIntervalMinutes();
$deliveryServicesData = $tab === 'delivery' ? $this->loadDeliveryServices($settings) : [[], ''];
$deliveryServicesData = $tab === 'delivery' ? $this->loadDeliveryServices($settings) : [[], [], ''];
$html = $this->template->render('settings/allegro', [
'title' => $this->translator->get('settings.allegro.title'),
@@ -96,10 +98,11 @@ final class AllegroIntegrationController
'errorMessage' => (string) Flash::get('settings_error', ''),
'successMessage' => (string) Flash::get('settings_success', ''),
'warningMessage' => (string) Flash::get('settings_warning', ''),
'deliveryMappings' => $this->deliveryMappings !== null ? $this->deliveryMappings->listMappings() : [],
'orderDeliveryMethods' => $this->deliveryMappings !== null ? $this->deliveryMappings->getDistinctOrderDeliveryMethods() : [],
'deliveryMappings' => $this->deliveryMappings !== null ? $this->deliveryMappings->listMappings('allegro', 0) : [],
'orderDeliveryMethods' => $this->deliveryMappings !== null ? $this->deliveryMappings->getDistinctOrderDeliveryMethods('allegro', 0) : [],
'allegroDeliveryServices' => $deliveryServicesData[0],
'allegroDeliveryServicesError' => $deliveryServicesData[1],
'apaczkaDeliveryServices' => $deliveryServicesData[1],
'allegroDeliveryServicesError' => $deliveryServicesData[2],
'inpostDeliveryServices' => array_values(array_filter(
$deliveryServicesData[0],
static fn(array $svc) => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false
@@ -518,6 +521,7 @@ final class AllegroIntegrationController
$orderMethods = (array) $request->input('order_delivery_method', []);
$carriers = (array) $request->input('carrier', []);
$allegroMethodIds = (array) $request->input('allegro_delivery_method_id', []);
$apaczkaMethodIds = (array) $request->input('apaczka_delivery_method_id', []);
$credentialsIds = (array) $request->input('allegro_credentials_id', []);
$carrierIds = (array) $request->input('allegro_carrier_id', []);
$serviceNames = (array) $request->input('allegro_service_name', []);
@@ -526,22 +530,25 @@ final class AllegroIntegrationController
foreach ($orderMethods as $idx => $orderMethod) {
$orderMethod = trim((string) $orderMethod);
$carrier = trim((string) ($carriers[$idx] ?? 'allegro'));
$allegroMethodId = trim((string) ($allegroMethodIds[$idx] ?? ''));
if ($orderMethod === '' || $allegroMethodId === '') {
$provider = $carrier === 'apaczka' ? 'apaczka' : 'allegro_wza';
$providerServiceId = $provider === 'apaczka'
? trim((string) ($apaczkaMethodIds[$idx] ?? ''))
: trim((string) ($allegroMethodIds[$idx] ?? ''));
if ($orderMethod === '' || $providerServiceId === '') {
continue;
}
$mappings[] = [
'order_delivery_method' => $orderMethod,
'carrier' => $carrier,
'allegro_delivery_method_id' => $allegroMethodId,
'allegro_credentials_id' => trim((string) ($credentialsIds[$idx] ?? '')),
'allegro_carrier_id' => trim((string) ($carrierIds[$idx] ?? '')),
'allegro_service_name' => trim((string) ($serviceNames[$idx] ?? '')),
'provider' => $provider,
'provider_service_id' => $providerServiceId,
'provider_account_id' => $provider === 'allegro_wza' ? trim((string) ($credentialsIds[$idx] ?? '')) : '',
'provider_carrier_id' => $provider === 'allegro_wza' ? trim((string) ($carrierIds[$idx] ?? '')) : '',
'provider_service_name' => trim((string) ($serviceNames[$idx] ?? '')),
];
}
try {
$this->deliveryMappings->saveMappings($mappings);
$this->deliveryMappings->saveMappings('allegro', 0, $mappings);
Flash::set('settings_success', $this->translator->get('settings.allegro.delivery.flash.saved'));
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.allegro.delivery.flash.save_failed') . ' ' . $exception->getMessage());
@@ -552,49 +559,73 @@ final class AllegroIntegrationController
/**
* @param array<string, mixed> $settings
* @return array{0: array<int, array<string, mixed>>, 1: string}
* @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>, 2: string}
*/
private function loadDeliveryServices(array $settings): array
{
if ($this->apiClient === null) {
return [[], ''];
}
$allegroServices = [];
$apaczkaServices = [];
$errorMessage = '';
$isConnected = (bool) ($settings['is_connected'] ?? false);
if (!$isConnected) {
return [[], $this->translator->get('settings.allegro.delivery.not_connected')];
}
if ($this->apiClient !== null) {
$isConnected = (bool) ($settings['is_connected'] ?? false);
if (!$isConnected) {
$errorMessage = $this->translator->get('settings.allegro.delivery.not_connected');
} else {
try {
$oauth = $this->repository->getTokenCredentials();
if ($oauth === null) {
$errorMessage = $this->translator->get('settings.allegro.delivery.not_connected');
} else {
$env = (string) ($oauth['environment'] ?? 'sandbox');
$accessToken = trim((string) ($oauth['access_token'] ?? ''));
if ($accessToken === '') {
$errorMessage = $this->translator->get('settings.allegro.delivery.not_connected');
} else {
try {
$response = $this->apiClient->getDeliveryServices($env, $accessToken);
} catch (RuntimeException $ex) {
if (trim($ex->getMessage()) === 'ALLEGRO_HTTP_401') {
$refreshed = $this->refreshOAuthToken($oauth);
if ($refreshed === null) {
$errorMessage = $this->translator->get('settings.allegro.delivery.not_connected');
$response = [];
} else {
$response = $this->apiClient->getDeliveryServices($env, $refreshed);
}
} else {
throw $ex;
}
}
try {
$oauth = $this->repository->getTokenCredentials();
if ($oauth === null) {
return [[], $this->translator->get('settings.allegro.delivery.not_connected')];
}
$env = (string) ($oauth['environment'] ?? 'sandbox');
$accessToken = trim((string) ($oauth['access_token'] ?? ''));
if ($accessToken === '') {
return [[], $this->translator->get('settings.allegro.delivery.not_connected')];
}
try {
$response = $this->apiClient->getDeliveryServices($env, $accessToken);
} catch (RuntimeException $ex) {
if (trim($ex->getMessage()) === 'ALLEGRO_HTTP_401') {
$refreshed = $this->refreshOAuthToken($oauth);
if ($refreshed === null) {
return [[], $this->translator->get('settings.allegro.delivery.not_connected')];
if (is_array($response ?? null)) {
$allegroServices = is_array($response['services'] ?? null) ? $response['services'] : [];
}
}
}
$response = $this->apiClient->getDeliveryServices($env, $refreshed);
} else {
throw $ex;
} catch (Throwable $e) {
$errorMessage = $e->getMessage();
}
}
$services = is_array($response['services'] ?? null) ? $response['services'] : [];
return [$services, ''];
} catch (Throwable $e) {
return [[], $e->getMessage()];
}
if ($this->apaczkaRepository !== null && $this->apaczkaApiClient !== null) {
try {
$credentials = $this->apaczkaRepository->getApiCredentials();
if (is_array($credentials)) {
$apaczkaServices = $this->apaczkaApiClient->getServiceStructure(
(string) ($credentials['app_id'] ?? ''),
(string) ($credentials['app_secret'] ?? '')
);
}
} catch (Throwable $exception) {
if ($errorMessage === '') {
$errorMessage = $exception->getMessage();
}
}
}
return [$allegroServices, $apaczkaServices, $errorMessage];
}
/**

View File

@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use RuntimeException;
final class ApaczkaApiClient
{
private const API_BASE_URL = 'https://www.apaczka.pl/api/v2';
/**
* @return array<int, array<string, mixed>>
*/
public function getServiceStructure(string $appId, string $appSecret): array
{
$response = $this->request('/service_structure/', 'service_structure', $appId, $appSecret, []);
$services = $response['response']['services'] ?? $response['services'] ?? [];
return is_array($services) ? $services : [];
}
/**
* @param array<string, mixed> $orderPayload
* @return array<string, mixed>
*/
public function sendOrder(string $appId, string $appSecret, array $orderPayload): array
{
return $this->request('/order_send/', 'order_send', $appId, $appSecret, [
'order' => $orderPayload,
]);
}
/**
* @return array<string, mixed>
*/
public function getOrderDetails(string $appId, string $appSecret, int $orderId): array
{
$safeOrderId = max(1, $orderId);
return $this->request('/order/' . $safeOrderId . '/', 'order/' . $safeOrderId, $appId, $appSecret, []);
}
/**
* @return array<string, mixed>
*/
public function getWaybill(string $appId, string $appSecret, int $orderId): array
{
$safeOrderId = max(1, $orderId);
return $this->request('/waybill/' . $safeOrderId . '/', 'waybill/' . $safeOrderId, $appId, $appSecret, []);
}
/**
* @return array<int, array<string, mixed>>
*/
public function getPoints(string $appId, string $appSecret, string $type = 'parcel_locker'): array
{
$safeType = trim($type) !== '' ? trim($type) : 'parcel_locker';
$route = 'points/' . $safeType;
$response = $this->request('/' . $route . '/', $route, $appId, $appSecret, []);
$points = $response['response']['points'] ?? $response['points'] ?? [];
return is_array($points) ? $points : [];
}
/**
* @param array<string, mixed> $request
* @return array<string, mixed>
*/
private function request(string $endpointPath, string $route, string $appId, string $appSecret, array $request): array
{
$normalizedRoute = trim($route, " \t\n\r\0\x0B/");
if ($normalizedRoute === '') {
throw new RuntimeException('Nie podano endpointu API Apaczka.');
}
$routeWithTrailingSlash = $normalizedRoute . '/';
$expires = (string) (time() + 60);
$requestJson = json_encode($request);
if (!is_string($requestJson)) {
throw new RuntimeException('Nie mozna zakodowac payloadu Apaczka.');
}
$basePayload = [
'app_id' => trim($appId),
'request' => $requestJson,
'expires' => $expires,
];
$signatureVariants = $this->buildSignatureVariants(
trim((string) $basePayload['app_id']),
$endpointPath,
$routeWithTrailingSlash,
$requestJson,
$expires,
$appSecret
);
$lastSignatureError = null;
foreach ($signatureVariants as $signature) {
$payload = $basePayload;
$payload['signature'] = $signature;
[$decoded, $httpCode] = $this->executeRequest($endpointPath, $payload);
$status = (int) ($decoded['status'] ?? 0);
$message = $this->resolveErrorMessage($decoded);
$isSignatureMismatch = $status === 400 && stripos($message, 'signature') !== false;
if ($isSignatureMismatch) {
$lastSignatureError = [$decoded, $status, $message];
continue;
}
if ($httpCode < 200 || $httpCode >= 300) {
throw new RuntimeException('API Apaczka HTTP ' . $httpCode . ': ' . $message);
}
if ($status !== 200) {
$responsePreview = substr(json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '', 0, 240);
throw new RuntimeException(
'Blad API Apaczka (status ' . $status . '): ' . $message . '. Odpowiedz: ' . $responsePreview
);
}
return $decoded;
}
if ($lastSignatureError !== null) {
[$decoded, $status, $message] = $lastSignatureError;
$responsePreview = substr(json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '', 0, 240);
throw new RuntimeException(
'Blad API Apaczka (status ' . $status . '): ' . $message . '. Odpowiedz: ' . $responsePreview
);
}
throw new RuntimeException('Blad API Apaczka.');
}
private function buildSignature(
string $appId,
string $route,
string $requestJson,
string $expires,
string $appSecret
): string
{
return hash_hmac('sha256', $appId . ':' . $route . ':' . $requestJson . ':' . $expires, trim($appSecret));
}
/**
* @return array<int, string>
*/
private function buildSignatureVariants(
string $appId,
string $endpointPath,
string $route,
string $requestJson,
string $expires,
string $appSecret
): array {
$endpointTrimmed = trim($endpointPath, " \t\n\r\0\x0B/");
$endpointWithSlashes = '/' . $endpointTrimmed . '/';
$endpointWithTrailingSlash = $endpointTrimmed . '/';
$variants = [];
$variants[] = hash_hmac('sha256', $appId . ':' . $endpointWithTrailingSlash . ':' . $requestJson . ':' . $expires, trim($appSecret));
$variants[] = hash_hmac('sha256', $appId . ':' . $route . ':' . $requestJson . ':' . $expires, trim($appSecret));
$variants[] = hash_hmac('sha256', $appId . ':' . $endpointWithSlashes . ':' . $requestJson . ':' . $expires, trim($appSecret));
$variants[] = hash_hmac('sha256', $appId . ':' . $endpointTrimmed . ':' . $requestJson . ':' . $expires, trim($appSecret));
$variants[] = hash('sha256', $appId . ':' . $endpointWithTrailingSlash . ':' . $requestJson . ':' . $expires . ':' . trim($appSecret));
$variants[] = hash('sha256', $appId . ':' . $route . ':' . $requestJson . ':' . $expires . ':' . trim($appSecret));
$variants[] = hash('sha256', $appId . ':' . $endpointWithSlashes . ':' . $requestJson . ':' . $expires . ':' . trim($appSecret));
return array_values(array_unique($variants));
}
/**
* @param array<string, string> $payload
* @return array{0: array<string, mixed>, 1: int}
*/
private function executeRequest(string $endpointPath, array $payload): array
{
$url = rtrim(self::API_BASE_URL, '/') . '/' . ltrim($endpointPath, '/');
$ch = curl_init($url);
if ($ch === false) {
throw new RuntimeException('Nie udalo sie zainicjowac polaczenia z API Apaczka.');
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($payload),
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Content-Type: application/x-www-form-urlencoded',
'User-Agent: orderPRO/1.0',
],
]);
$rawBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
unset($ch);
if ($rawBody === false) {
throw new RuntimeException('Blad polaczenia z API Apaczka: ' . $curlError);
}
$normalizedBody = ltrim((string) $rawBody, "\xEF\xBB\xBF \t\n\r\0\x0B");
$decoded = json_decode($normalizedBody, true);
if (!is_array($decoded)) {
$snippet = substr(trim(strip_tags($normalizedBody)), 0, 180);
throw new RuntimeException(
'Nieprawidlowa odpowiedz API Apaczka (brak JSON, HTTP ' . $httpCode . '). Fragment: ' . $snippet
);
}
return [$decoded, $httpCode];
}
/**
* @param array<string, mixed> $decoded
*/
private function resolveErrorMessage(array $decoded): string
{
$topMessage = trim((string) ($decoded['message'] ?? ''));
if ($topMessage !== '') {
return $topMessage;
}
$responseMessage = trim((string) ($decoded['response']['message'] ?? ''));
if ($responseMessage !== '') {
return $responseMessage;
}
$errorMessage = trim((string) ($decoded['error']['message'] ?? ''));
if ($errorMessage !== '') {
return $errorMessage;
}
return 'Blad API Apaczka.';
}
}

View File

@@ -18,7 +18,8 @@ final class ApaczkaIntegrationController
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly ApaczkaIntegrationRepository $repository
private readonly ApaczkaIntegrationRepository $repository,
private readonly ApaczkaApiClient $apiClient
) {
}
@@ -49,15 +50,16 @@ final class ApaczkaIntegrationController
return Response::redirect($redirectTo);
}
$apiKey = trim((string) $request->input('api_key', ''));
if ($apiKey === '') {
Flash::set('settings_error', $this->translator->get('settings.apaczka.validation.api_key_required'));
$appId = trim((string) $request->input('app_id', ''));
if ($appId === '') {
Flash::set('settings_error', $this->translator->get('settings.apaczka.validation.app_id_required'));
return Response::redirect($redirectTo);
}
try {
$this->repository->saveSettings([
'api_key' => $apiKey,
'app_id' => $appId,
'app_secret' => trim((string) $request->input('app_secret', '')),
]);
Flash::set('settings_success', $this->translator->get('settings.apaczka.flash.saved'));
} catch (Throwable $exception) {
@@ -70,6 +72,29 @@ final class ApaczkaIntegrationController
return Response::redirect($redirectTo);
}
public function test(Request $request): Response
{
$redirectTo = $this->resolveRedirectPath((string) $request->input('return_to', '/settings/integrations/apaczka'));
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
try {
$services = $this->repository->testConnection($this->apiClient);
Flash::set('settings_success', $this->translator->get('settings.apaczka.flash.test_success', [
'count' => (string) count($services),
]));
} catch (Throwable $exception) {
Flash::set(
'settings_error',
$this->translator->get('settings.apaczka.flash.test_failed') . ' ' . $exception->getMessage()
);
}
return Response::redirect($redirectTo);
}
private function resolveRedirectPath(string $candidate): string
{
$value = trim($candidate);

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
use RuntimeException;
use Throwable;
final class ApaczkaIntegrationRepository
{
@@ -27,11 +29,20 @@ final class ApaczkaIntegrationRepository
*/
public function getSettings(): array
{
$this->ensureRow();
$integrationId = $this->ensureBaseIntegration();
$row = $this->fetchRow();
$integration = $this->integrations->findById($integrationId);
$appId = trim((string) ($row['app_id'] ?? ''));
$secretEncrypted = $this->resolveSecretEncrypted($row, $integration);
return [
'has_api_key' => trim((string) ($integration['api_key_encrypted'] ?? '')) !== '',
'app_id' => $appId,
'has_app_id' => $appId !== '',
'has_app_secret' => $secretEncrypted !== null && $secretEncrypted !== '',
'has_api_key' => $secretEncrypted !== null && $secretEncrypted !== '',
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
@@ -40,15 +51,91 @@ final class ApaczkaIntegrationRepository
*/
public function saveSettings(array $payload): void
{
$this->ensureRow();
$integrationId = $this->ensureBaseIntegration();
$apiKey = trim((string) ($payload['api_key'] ?? ''));
if ($apiKey === '') {
return;
$row = $this->fetchRow();
if ($row === null) {
throw new RuntimeException('Brak rekordu konfiguracji Apaczka.');
}
$encrypted = $this->cipher->encrypt($apiKey);
$this->integrations->updateApiKeyEncrypted($integrationId, $encrypted);
$appId = trim((string) ($payload['app_id'] ?? ''));
if ($appId === '') {
throw new RuntimeException('Podaj App ID Apaczka.');
}
$currentEncrypted = $this->resolveSecretEncrypted($row, $this->integrations->findById($integrationId));
$appSecret = trim((string) ($payload['app_secret'] ?? ($payload['api_key'] ?? '')));
$nextEncrypted = $currentEncrypted;
if ($appSecret !== '') {
$nextEncrypted = $this->cipher->encrypt($appSecret);
}
if ($nextEncrypted === null || $nextEncrypted === '') {
throw new RuntimeException('Podaj App Secret Apaczka.');
}
$stmt = $this->pdo->prepare(
'UPDATE apaczka_integration_settings
SET app_id = :app_id,
app_secret_encrypted = :app_secret_encrypted,
api_key_encrypted = :api_key_encrypted,
updated_at = NOW()
WHERE id = 1'
);
$stmt->execute([
'app_id' => $appId,
'app_secret_encrypted' => $nextEncrypted,
'api_key_encrypted' => $nextEncrypted,
]);
$this->integrations->updateApiKeyEncrypted($integrationId, $nextEncrypted);
}
/**
* @return array{app_id:string, app_secret:string}|null
*/
public function getApiCredentials(): ?array
{
$settings = $this->getSettings();
$appId = trim((string) ($settings['app_id'] ?? ''));
if ($appId === '') {
return null;
}
$row = $this->fetchRow();
$integrationId = $this->ensureBaseIntegration();
$integration = $this->integrations->findById($integrationId);
$encrypted = $this->resolveSecretEncrypted($row, $integration);
if ($encrypted === null || $encrypted === '') {
return null;
}
$secret = trim($this->cipher->decrypt($encrypted));
if ($secret === '') {
return null;
}
return [
'app_id' => $appId,
'app_secret' => $secret,
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function testConnection(ApaczkaApiClient $apiClient): array
{
$credentials = $this->getApiCredentials();
if ($credentials === null) {
throw new RuntimeException('Brak konfiguracji App ID/App Secret Apaczka.');
}
return $apiClient->getServiceStructure(
(string) $credentials['app_id'],
(string) $credentials['app_secret']
);
}
private function ensureBaseIntegration(): int
@@ -61,4 +148,52 @@ final class ApaczkaIntegrationRepository
true
);
}
private function ensureRow(): void
{
$integrationId = $this->ensureBaseIntegration();
$stmt = $this->pdo->prepare(
'INSERT INTO apaczka_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)'
);
$stmt->execute(['integration_id' => $integrationId]);
}
/**
* @return array<string, mixed>|null
*/
private function fetchRow(): ?array
{
try {
$stmt = $this->pdo->prepare('SELECT * FROM apaczka_integration_settings WHERE id = 1 LIMIT 1');
$stmt->execute();
$row = $stmt->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 resolveSecretEncrypted(?array $row, ?array $integration): ?string
{
$base = trim((string) ($integration['api_key_encrypted'] ?? ''));
if ($base !== '') {
return $base;
}
$modern = trim((string) ($row['app_secret_encrypted'] ?? ''));
if ($modern !== '') {
return $modern;
}
$legacy = trim((string) ($row['api_key_encrypted'] ?? ''));
return $legacy !== '' ? $legacy : null;
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
use Throwable;
final class CarrierDeliveryMethodMappingRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array<string, mixed>>
*/
public function listMappings(string $sourceSystem, int $sourceIntegrationId = 0): array
{
$stmt = $this->pdo->prepare(
'SELECT *
FROM carrier_delivery_method_mappings
WHERE source_system = :source_system
AND source_integration_id = :source_integration_id
ORDER BY order_delivery_method ASC'
);
$stmt->execute([
'source_system' => $this->normalizeSourceSystem($sourceSystem),
'source_integration_id' => max(0, $sourceIntegrationId),
]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<string, mixed>|null
*/
public function findByOrderMethod(string $sourceSystem, int $sourceIntegrationId, string $orderDeliveryMethod): ?array
{
$method = trim($orderDeliveryMethod);
if ($method === '') {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT *
FROM carrier_delivery_method_mappings
WHERE source_system = :source_system
AND source_integration_id = :source_integration_id
AND order_delivery_method = :order_delivery_method
LIMIT 1'
);
$stmt->execute([
'source_system' => $this->normalizeSourceSystem($sourceSystem),
'source_integration_id' => max(0, $sourceIntegrationId),
'order_delivery_method' => $method,
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
public function hasMappingsForSource(string $sourceSystem, int $sourceIntegrationId = 0): bool
{
$stmt = $this->pdo->prepare(
'SELECT 1
FROM carrier_delivery_method_mappings
WHERE source_system = :source_system
AND source_integration_id = :source_integration_id
LIMIT 1'
);
$stmt->execute([
'source_system' => $this->normalizeSourceSystem($sourceSystem),
'source_integration_id' => max(0, $sourceIntegrationId),
]);
$value = $stmt->fetchColumn();
return $value !== false;
}
/**
* @param array<int, array<string, string>> $mappings
*/
public function saveMappings(string $sourceSystem, int $sourceIntegrationId, array $mappings): void
{
$normalizedSource = $this->normalizeSourceSystem($sourceSystem);
$normalizedIntegrationId = max(0, $sourceIntegrationId);
$this->pdo->beginTransaction();
try {
$deleteStmt = $this->pdo->prepare(
'DELETE FROM carrier_delivery_method_mappings
WHERE source_system = :source_system
AND source_integration_id = :source_integration_id'
);
$deleteStmt->execute([
'source_system' => $normalizedSource,
'source_integration_id' => $normalizedIntegrationId,
]);
if ($mappings !== []) {
$insertStmt = $this->pdo->prepare(
'INSERT INTO carrier_delivery_method_mappings (
source_system,
source_integration_id,
order_delivery_method,
provider,
provider_service_id,
provider_account_id,
provider_carrier_id,
provider_service_name
) VALUES (
:source_system,
:source_integration_id,
:order_delivery_method,
:provider,
:provider_service_id,
:provider_account_id,
:provider_carrier_id,
:provider_service_name
)'
);
foreach ($mappings as $mapping) {
$orderMethod = $this->limit(trim((string) ($mapping['order_delivery_method'] ?? '')), 200);
$provider = $this->limit(strtolower(trim((string) ($mapping['provider'] ?? ''))), 50);
$providerServiceId = $this->limit(trim((string) ($mapping['provider_service_id'] ?? '')), 128);
if ($orderMethod === '' || $provider === '' || $providerServiceId === '') {
continue;
}
$insertStmt->execute([
'source_system' => $normalizedSource,
'source_integration_id' => $normalizedIntegrationId,
'order_delivery_method' => $orderMethod,
'provider' => $provider,
'provider_service_id' => $providerServiceId,
'provider_account_id' => $this->nullableLimited((string) ($mapping['provider_account_id'] ?? ''), 128),
'provider_carrier_id' => $this->nullableLimited((string) ($mapping['provider_carrier_id'] ?? ''), 128),
'provider_service_name' => $this->nullableLimited((string) ($mapping['provider_service_name'] ?? ''), 255),
]);
}
}
$this->pdo->commit();
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $exception;
}
}
/**
* @return array<int, string>
*/
public function getDistinctOrderDeliveryMethods(string $sourceSystem, int $sourceIntegrationId = 0): array
{
$normalizedSource = $this->normalizeSourceSystem($sourceSystem);
if ($normalizedSource === 'shoppro') {
$stmt = $this->pdo->prepare(
"SELECT DISTINCT external_carrier_id
FROM orders
WHERE external_carrier_id IS NOT NULL
AND external_carrier_id <> ''
AND source = 'shoppro'
AND integration_id = :integration_id
ORDER BY external_carrier_id ASC"
);
$stmt->execute(['integration_id' => max(0, $sourceIntegrationId)]);
$rows = $stmt->fetchAll(PDO::FETCH_COLUMN);
return is_array($rows) ? $rows : [];
}
$stmt = $this->pdo->query(
"SELECT DISTINCT external_carrier_id
FROM orders
WHERE external_carrier_id IS NOT NULL
AND external_carrier_id <> ''
AND source = 'allegro'
AND external_carrier_id NOT REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
ORDER BY external_carrier_id ASC"
);
$rows = $stmt !== false ? $stmt->fetchAll(PDO::FETCH_COLUMN) : [];
return is_array($rows) ? $rows : [];
}
private function normalizeSourceSystem(string $value): string
{
$source = strtolower(trim($value));
return in_array($source, ['allegro', 'shoppro'], true) ? $source : 'allegro';
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function nullableLimited(string $value, int $max): ?string
{
$trimmed = $this->limit(trim($value), $max);
return $trimmed === '' ? null : $trimmed;
}
private function limit(string $value, int $max): string
{
if ($max <= 0) {
return '';
}
if (mb_strlen($value) <= $max) {
return $value;
}
return mb_substr($value, 0, $max);
}
}

View File

@@ -50,6 +50,7 @@ final class CompanySettingsController
$this->repository->saveSettings([
'company_name' => (string) $request->input('company_name', ''),
'person_name' => (string) $request->input('person_name', ''),
'sender_contact_person' => (string) $request->input('sender_contact_person', ''),
'street' => (string) $request->input('street', ''),
'city' => (string) $request->input('city', ''),
'postal_code' => (string) $request->input('postal_code', ''),

View File

@@ -33,6 +33,7 @@ final class CompanySettingsRepository
return [
'company_name' => trim((string) ($row['company_name'] ?? '')),
'person_name' => trim((string) ($row['person_name'] ?? '')),
'sender_contact_person' => trim((string) ($row['sender_contact_person'] ?? '')),
'street' => trim((string) ($row['street'] ?? '')),
'city' => trim((string) ($row['city'] ?? '')),
'postal_code' => trim((string) ($row['postal_code'] ?? '')),
@@ -61,6 +62,7 @@ final class CompanySettingsRepository
'UPDATE company_settings SET
company_name = :company_name,
person_name = :person_name,
sender_contact_person = :sender_contact_person,
street = :street,
city = :city,
postal_code = :postal_code,
@@ -82,6 +84,7 @@ final class CompanySettingsRepository
$statement->execute([
'company_name' => $this->nullableString((string) ($data['company_name'] ?? '')),
'person_name' => $this->nullableString((string) ($data['person_name'] ?? '')),
'sender_contact_person' => $this->nullableString((string) ($data['sender_contact_person'] ?? '')),
'street' => $this->nullableString((string) ($data['street'] ?? '')),
'city' => $this->nullableString((string) ($data['city'] ?? '')),
'postal_code' => $this->nullableString((string) ($data['postal_code'] ?? '')),
@@ -109,6 +112,9 @@ final class CompanySettingsRepository
$settings = $this->getSettings();
return [
'name' => $settings['person_name'] !== '' ? $settings['person_name'] : ($settings['company_name'] !== '' ? $settings['company_name'] : null),
'contactPerson' => $settings['sender_contact_person'] !== ''
? $settings['sender_contact_person']
: ($settings['person_name'] !== '' ? $settings['person_name'] : null),
'company' => $settings['company_name'] !== '' ? $settings['company_name'] : null,
'street' => $settings['street'] !== '' ? $settings['street'] : null,
'city' => $settings['city'] !== '' ? $settings['city'] : null,
@@ -140,6 +146,7 @@ final class CompanySettingsRepository
return [
'company_name' => '',
'person_name' => '',
'sender_contact_person' => '',
'street' => '',
'city' => '',
'postal_code' => '',

View File

@@ -82,14 +82,15 @@ final class IntegrationsHubController
{
$settings = $this->apaczka->getSettings();
$meta = $this->integrations->findByTypeAndName('apaczka', 'Apaczka') ?? [];
$isConfigured = !empty($settings['has_app_id']) && !empty($settings['has_app_secret']);
return [
'provider' => $this->translator->get('settings.integrations_hub.providers.apaczka'),
'instance' => 'Apaczka',
'authorization_status' => !empty($settings['has_api_key'])
'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_key'])
'secret_status' => $isConfigured
? $this->translator->get('settings.integrations_hub.status.saved')
: $this->translator->get('settings.integrations_hub.status.missing'),
'is_active' => (int) ($meta['is_active'] ?? 0) === 1,

View File

@@ -39,10 +39,12 @@ final class ShopproIntegrationsController
private readonly ShopproStatusMappingRepository $statusMappings,
private readonly OrderStatusRepository $orderStatuses,
private readonly CronRepository $cronRepository,
private readonly ShopproDeliveryMethodMappingRepository $deliveryMappings,
private readonly CarrierDeliveryMethodMappingRepository $deliveryMappings,
private readonly AllegroIntegrationRepository $allegroIntegrationRepository,
private readonly AllegroOAuthClient $allegroOAuthClient,
private readonly AllegroApiClient $allegroApiClient
private readonly AllegroApiClient $allegroApiClient,
private readonly ?ApaczkaIntegrationRepository $apaczkaRepository = null,
private readonly ?ApaczkaApiClient $apaczkaApiClient = null
) {
}
@@ -68,12 +70,12 @@ final class ShopproIntegrationsController
: [];
$deliveryServicesData = $activeTab === 'delivery'
? $this->loadDeliveryServices()
: [[], ''];
: [[], [], ''];
$deliveryMappings = $selectedIntegration !== null
? $this->deliveryMappings->listMappings((int) ($selectedIntegration['id'] ?? 0))
? $this->deliveryMappings->listMappings('shoppro', (int) ($selectedIntegration['id'] ?? 0))
: [];
$orderDeliveryMethods = $selectedIntegration !== null
? $this->deliveryMappings->getDistinctOrderDeliveryMethods((int) ($selectedIntegration['id'] ?? 0))
? $this->deliveryMappings->getDistinctOrderDeliveryMethods('shoppro', (int) ($selectedIntegration['id'] ?? 0))
: [];
$html = $this->template->render('settings/shoppro', [
@@ -94,7 +96,8 @@ final class ShopproIntegrationsController
'deliveryMappings' => $deliveryMappings,
'orderDeliveryMethods' => $orderDeliveryMethods,
'allegroDeliveryServices' => $deliveryServicesData[0],
'allegroDeliveryServicesError' => $deliveryServicesData[1],
'apaczkaDeliveryServices' => $deliveryServicesData[1],
'allegroDeliveryServicesError' => $deliveryServicesData[2],
'inpostDeliveryServices' => array_values(array_filter(
$deliveryServicesData[0],
static fn (array $svc): bool => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false
@@ -350,31 +353,36 @@ final class ShopproIntegrationsController
$orderMethods = (array) $request->input('order_delivery_method', []);
$carriers = (array) $request->input('carrier', []);
$allegroMethodIds = (array) $request->input('allegro_delivery_method_id', []);
$apaczkaMethodIds = (array) $request->input('apaczka_delivery_method_id', []);
$credentialsIds = (array) $request->input('allegro_credentials_id', []);
$carrierIds = (array) $request->input('allegro_carrier_id', []);
$serviceNames = (array) $request->input('allegro_service_name', []);
$mappings = [];
foreach ($orderMethods as $index => $orderMethod) {
$orderMethodValue = trim((string) $orderMethod);
$carrier = trim((string) ($carriers[$index] ?? 'allegro'));
$allegroMethodId = trim((string) ($allegroMethodIds[$index] ?? ''));
if ($orderMethodValue === '' || $allegroMethodId === '') {
$provider = $carrier === 'apaczka' ? 'apaczka' : 'allegro_wza';
$providerServiceId = $provider === 'apaczka'
? trim((string) ($apaczkaMethodIds[$index] ?? ''))
: trim((string) ($allegroMethodIds[$index] ?? ''));
if ($orderMethodValue === '' || $providerServiceId === '') {
continue;
}
$mappings[] = [
'order_delivery_method' => $orderMethodValue,
'carrier' => $carrier,
'allegro_delivery_method_id' => $allegroMethodId,
'allegro_credentials_id' => trim((string) ($credentialsIds[$index] ?? '')),
'allegro_carrier_id' => trim((string) ($carrierIds[$index] ?? '')),
'allegro_service_name' => trim((string) ($serviceNames[$index] ?? '')),
'provider' => $provider,
'provider_service_id' => $providerServiceId,
'provider_account_id' => $provider === 'allegro_wza' ? trim((string) ($credentialsIds[$index] ?? '')) : '',
'provider_carrier_id' => $provider === 'allegro_wza' ? trim((string) ($carrierIds[$index] ?? '')) : '',
'provider_service_name' => trim((string) ($serviceNames[$index] ?? '')),
];
}
try {
$this->deliveryMappings->saveMappings($integrationId, $mappings);
$this->deliveryMappings->saveMappings('shoppro', $integrationId, $mappings);
Flash::set('settings_success', $this->translator->get('settings.integrations.delivery.flash.saved'));
} catch (Throwable $exception) {
Flash::set(
@@ -792,41 +800,66 @@ final class ShopproIntegrationsController
}
/**
* @return array{0: array<int, array<string, mixed>>, 1: string}
* @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>, 2: string}
*/
private function loadDeliveryServices(): array
{
$allegroServices = [];
$apaczkaServices = [];
$errorMessage = '';
try {
$oauth = $this->allegroIntegrationRepository->getTokenCredentials();
if (!is_array($oauth)) {
return [[], $this->translator->get('settings.integrations.delivery.not_connected')];
}
$errorMessage = $this->translator->get('settings.integrations.delivery.not_connected');
} else {
$env = (string) ($oauth['environment'] ?? 'sandbox');
$accessToken = trim((string) ($oauth['access_token'] ?? ''));
if ($accessToken === '') {
$errorMessage = $this->translator->get('settings.integrations.delivery.not_connected');
} else {
try {
$response = $this->allegroApiClient->getDeliveryServices($env, $accessToken);
} catch (RuntimeException $exception) {
if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
throw $exception;
}
$env = (string) ($oauth['environment'] ?? 'sandbox');
$accessToken = trim((string) ($oauth['access_token'] ?? ''));
if ($accessToken === '') {
return [[], $this->translator->get('settings.integrations.delivery.not_connected')];
}
$refreshedToken = $this->refreshAllegroAccessToken($oauth);
if ($refreshedToken === null) {
$errorMessage = $this->translator->get('settings.integrations.delivery.not_connected');
$response = [];
} else {
$response = $this->allegroApiClient->getDeliveryServices($env, $refreshedToken);
}
}
try {
$response = $this->allegroApiClient->getDeliveryServices($env, $accessToken);
} catch (RuntimeException $exception) {
if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
throw $exception;
if (is_array($response ?? null)) {
$allegroServices = is_array($response['services'] ?? null) ? $response['services'] : [];
}
}
$refreshedToken = $this->refreshAllegroAccessToken($oauth);
if ($refreshedToken === null) {
return [[], $this->translator->get('settings.integrations.delivery.not_connected')];
}
$response = $this->allegroApiClient->getDeliveryServices($env, $refreshedToken);
}
$services = is_array($response['services'] ?? null) ? $response['services'] : [];
return [$services, ''];
} catch (Throwable $exception) {
return [[], $exception->getMessage()];
$errorMessage = $exception->getMessage();
}
if ($this->apaczkaRepository !== null && $this->apaczkaApiClient !== null) {
try {
$credentials = $this->apaczkaRepository->getApiCredentials();
if (is_array($credentials)) {
$apaczkaServices = $this->apaczkaApiClient->getServiceStructure(
(string) ($credentials['app_id'] ?? ''),
(string) ($credentials['app_secret'] ?? '')
);
}
} catch (Throwable $exception) {
if ($errorMessage === '') {
$errorMessage = $exception->getMessage();
}
}
}
return [$allegroServices, $apaczkaServices, $errorMessage];
}
/**

View File

@@ -434,7 +434,7 @@ final class ShopproOrdersSyncService
'customer_login' => $this->nullableString((string) $this->readPath($payload, [
'buyer_email', 'customer.email', 'buyer.email', 'client.email', 'email', 'customer.login', 'buyer.login',
])),
'is_invoice' => !empty($this->readPath($payload, ['is_invoice', 'invoice.required'])),
'is_invoice' => $this->resolveInvoiceRequested($payload),
'is_encrypted' => false,
'is_canceled_by_buyer' => false,
'currency' => $currency,
@@ -548,6 +548,11 @@ final class ShopproOrdersSyncService
],
];
$invoiceAddress = $this->buildInvoiceAddress($payload, $customerName, $customerEmail, $customerPhone);
if ($invoiceAddress !== null) {
$result[] = $invoiceAddress;
}
$deliveryFirstName = $this->nullableString((string) $this->readPath($payload, [
'delivery.address.first_name', 'delivery.address.firstname', 'shipping.address.first_name', 'shipping.address.firstname',
'delivery_address.first_name', 'delivery_address.firstname', 'shipping_address.first_name', 'shipping_address.firstname',
@@ -638,6 +643,119 @@ final class ShopproOrdersSyncService
return $result;
}
/**
* @param array<string, mixed> $payload
*/
private function resolveInvoiceRequested(array $payload): bool
{
$explicitInvoice = $this->readPath($payload, ['is_invoice', 'invoice.required', 'invoice']);
if (!empty($explicitInvoice)) {
return true;
}
$companyName = $this->nullableString((string) $this->readPath($payload, [
'invoice.company_name', 'invoice.company', 'billing.company_name', 'billing.company',
'firm_name', 'company_name', 'client_company', 'buyer_company',
]));
$taxNumber = $this->nullableString((string) $this->readPath($payload, [
'invoice.tax_id', 'invoice.nip', 'billing.tax_id', 'billing.nip',
'firm_nip', 'company_nip', 'tax_id', 'nip',
]));
return $companyName !== null || $taxNumber !== null;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>|null
*/
private function buildInvoiceAddress(
array $payload,
?string $customerName,
?string $customerEmail,
?string $customerPhone
): ?array {
$companyName = $this->nullableString((string) $this->readPath($payload, [
'invoice.company_name', 'invoice.company', 'billing.company_name', 'billing.company',
'firm_name', 'company_name', 'client_company', 'buyer_company',
]));
$companyTaxNumber = $this->nullableString((string) $this->readPath($payload, [
'invoice.tax_id', 'invoice.nip', 'billing.tax_id', 'billing.nip',
'firm_nip', 'company_nip', 'tax_id', 'nip',
]));
$invoiceFirstName = $this->nullableString((string) $this->readPath($payload, [
'invoice.first_name', 'invoice.firstname', 'billing_address.first_name', 'billing_address.firstname',
'buyer.first_name', 'customer.first_name', 'client_name',
]));
$invoiceLastName = $this->nullableString((string) $this->readPath($payload, [
'invoice.last_name', 'invoice.lastname', 'billing_address.last_name', 'billing_address.lastname',
'buyer.last_name', 'customer.last_name', 'client_surname',
]));
$invoiceName = $companyName ?? $this->composeName($invoiceFirstName, $invoiceLastName, $customerName ?? 'Faktura');
$streetName = $this->nullableString((string) $this->readPath($payload, [
'invoice.address.street', 'invoice.street', 'billing_address.street', 'billing.street',
'firm_street', 'company_street',
]));
$streetNumber = $this->nullableString((string) $this->readPath($payload, [
'invoice.address.street_number', 'invoice.street_number', 'invoice.house_number',
'billing_address.street_number', 'billing_address.house_number',
'billing.street_number', 'house_number', 'street_number',
]));
$city = $this->nullableString((string) $this->readPath($payload, [
'invoice.address.city', 'invoice.city', 'billing_address.city', 'billing.city',
'firm_city', 'company_city',
]));
$zipCode = $this->nullableString((string) $this->readPath($payload, [
'invoice.address.zip', 'invoice.address.postcode', 'invoice.zip', 'invoice.postcode',
'billing_address.zip', 'billing_address.postcode', 'billing.zip', 'billing.postcode',
'firm_postal_code', 'company_postal_code',
]));
$country = $this->nullableString((string) $this->readPath($payload, [
'invoice.address.country', 'invoice.country', 'billing_address.country', 'billing.country',
'firm_country', 'company_country',
]));
$email = $this->nullableString((string) $this->readPath($payload, [
'invoice.email', 'billing_address.email', 'billing.email', 'client_email',
])) ?? $customerEmail;
$phone = $this->nullableString((string) $this->readPath($payload, [
'invoice.phone', 'billing_address.phone', 'billing.phone', 'client_phone',
])) ?? $customerPhone;
$hasInvoiceData = $companyName !== null
|| $companyTaxNumber !== null
|| $streetName !== null
|| $city !== null
|| $zipCode !== null;
if (!$hasInvoiceData) {
return null;
}
return [
'address_type' => 'invoice',
'name' => $invoiceName ?? 'Faktura',
'phone' => $phone,
'email' => $email,
'street_name' => $streetName,
'street_number' => $streetNumber,
'city' => $city,
'zip_code' => $zipCode,
'country' => $country,
'company_tax_number' => $companyTaxNumber,
'company_name' => $companyName,
'payload_json' => [
'invoice' => $this->readPath($payload, ['invoice']),
'billing' => $this->readPath($payload, ['billing']),
'billing_address' => $this->readPath($payload, ['billing_address']),
'firm_name' => $this->readPath($payload, ['firm_name']),
'firm_nip' => $this->readPath($payload, ['firm_nip']),
'firm_street' => $this->readPath($payload, ['firm_street']),
'firm_postal_code' => $this->readPath($payload, ['firm_postal_code']),
'firm_city' => $this->readPath($payload, ['firm_city']),
],
];
}
private function composeName(?string $firstName, ?string $lastName, ?string $fallback): ?string
{
$name = trim(trim((string) $firstName) . ' ' . trim((string) $lastName));

View File

@@ -13,7 +13,7 @@ use DateTimeImmutable;
use RuntimeException;
use Throwable;
final class AllegroShipmentService
final class AllegroShipmentService implements ShipmentProviderInterface
{
public function __construct(
private readonly AllegroIntegrationRepository $integrationRepository,
@@ -25,6 +25,11 @@ final class AllegroShipmentService
) {
}
public function code(): string
{
return 'allegro_wza';
}
/**
* @return array<int, array<string, mixed>>
*/

View File

@@ -0,0 +1,882 @@
<?php
declare(strict_types=1);
namespace App\Modules\Shipments;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\ApaczkaApiClient;
use App\Modules\Settings\ApaczkaIntegrationRepository;
use App\Modules\Settings\CompanySettingsRepository;
use RuntimeException;
use Throwable;
final class ApaczkaShipmentService implements ShipmentProviderInterface
{
/**
* @var array<string, array{street:string,postal_code:string,city:string}>
*/
private array $pointAddressCache = [];
public function __construct(
private readonly ApaczkaIntegrationRepository $integrationRepository,
private readonly ApaczkaApiClient $apiClient,
private readonly ShipmentPackageRepository $packages,
private readonly CompanySettingsRepository $companySettings,
private readonly OrdersRepository $ordersRepository
) {
}
public function code(): string
{
return 'apaczka';
}
/**
* @return array<int, array<string, mixed>>
*/
public function getDeliveryServices(): array
{
[$appId, $appSecret] = $this->requireCredentials();
return $this->apiClient->getServiceStructure($appId, $appSecret);
}
/**
* @param array<string, mixed> $formData
* @return array<string, mixed>
*/
public function createShipment(int $orderId, array $formData): array
{
$order = $this->ordersRepository->findDetails($orderId);
if ($order === null) {
throw new RuntimeException('Zamowienie nie znalezione.');
}
[$appId, $appSecret] = $this->requireCredentials();
$sender = $this->companySettings->getSenderAddress();
$this->validateSenderAddress($sender);
$orderData = is_array($order['order'] ?? null) ? $order['order'] : [];
$sourceOrderId = trim((string) ($orderData['source_order_id'] ?? ''));
$deliveryMethodId = trim((string) ($formData['delivery_method_id'] ?? ''));
if ($deliveryMethodId === '') {
throw new RuntimeException('Nie podano uslugi Apaczka.');
}
$serviceDefinition = $this->resolveServiceDefinition($appId, $appSecret, $deliveryMethodId);
$receiverAddress = $this->buildReceiverAddress($order, $formData, $appId, $appSecret);
$senderAddress = $this->normalizeSender($sender);
$senderPointId = trim((string) ($formData['sender_point_id'] ?? ''));
if ($senderPointId !== '') {
$this->applyPointIdentifiers($senderAddress, $senderPointId);
}
$weightKg = max(0.001, (float) ($formData['weight_kg'] ?? 1.0));
$lengthCm = max(1.0, (float) ($formData['length_cm'] ?? 25.0));
$widthCm = max(1.0, (float) ($formData['width_cm'] ?? 20.0));
$heightCm = max(1.0, (float) ($formData['height_cm'] ?? 8.0));
$insuranceAmount = max(0.0, (float) ($formData['insurance_amount'] ?? 0));
$codAmount = max(0.0, (float) ($formData['cod_amount'] ?? 0));
$apiPayload = [
'service_id' => ctype_digit($deliveryMethodId) ? (int) $deliveryMethodId : $deliveryMethodId,
'address' => [
'sender' => $senderAddress,
'receiver' => $receiverAddress,
],
'shipment' => [[
'shipment_type_code' => 'PACZKA',
'dimension1' => (int) round($lengthCm),
'dimension2' => (int) round($widthCm),
'dimension3' => (int) round($heightCm),
'weight' => (float) round($weightKg, 3),
'is_nstd' => 0,
]],
'content' => 'orderPRO ' . ($sourceOrderId !== '' ? $sourceOrderId : (string) $orderId),
'comment' => 'orderPRO ' . ($sourceOrderId !== '' ? $sourceOrderId : (string) $orderId),
];
$pickup = $this->buildPickupPayload($serviceDefinition, $senderPointId, $formData);
if ($pickup !== []) {
$apiPayload['pickup'] = $pickup;
}
$this->validateServiceRequirements($serviceDefinition, $receiverAddress, $senderAddress);
if ($insuranceAmount > 0) {
$apiPayload['shipment_value'] = (int) round($insuranceAmount * 100);
$apiPayload['shipment_currency'] = strtoupper(trim((string) ($formData['insurance_currency'] ?? 'PLN')));
}
if ($codAmount > 0) {
$apiPayload['cod'] = [
'amount' => (int) round($codAmount * 100),
'currency' => strtoupper(trim((string) ($formData['cod_currency'] ?? 'PLN'))),
];
}
$packageId = $this->packages->create([
'order_id' => $orderId,
'provider' => 'apaczka',
'delivery_method_id' => $deliveryMethodId,
'credentials_id' => null,
'command_id' => null,
'status' => 'pending',
'carrier_id' => null,
'package_type' => strtoupper(trim((string) ($formData['package_type'] ?? 'PACKAGE'))),
'weight_kg' => $weightKg,
'length_cm' => $lengthCm,
'width_cm' => $widthCm,
'height_cm' => $heightCm,
'insurance_amount' => $insuranceAmount > 0 ? $insuranceAmount : null,
'insurance_currency' => strtoupper(trim((string) ($formData['insurance_currency'] ?? 'PLN'))),
'cod_amount' => $codAmount > 0 ? $codAmount : null,
'cod_currency' => strtoupper(trim((string) ($formData['cod_currency'] ?? 'PLN'))),
'label_format' => 'PDF',
'receiver_point_id' => trim((string) ($formData['receiver_point_id'] ?? '')),
'sender_point_id' => trim((string) ($formData['sender_point_id'] ?? '')),
'reference_number' => $sourceOrderId !== '' ? $sourceOrderId : (string) $orderId,
'payload_json' => $apiPayload,
]);
try {
$response = $this->apiClient->sendOrder($appId, $appSecret, $apiPayload);
} catch (Throwable $exception) {
$errorMessage = $this->buildShipmentErrorMessage(
$exception,
$serviceDefinition,
$receiverAddress,
$senderAddress,
$apiPayload
);
$this->packages->update($packageId, [
'status' => 'error',
'error_message' => $errorMessage,
]);
throw new RuntimeException($errorMessage, 0, $exception);
}
$orderResponse = is_array($response['response']['order'] ?? null) ? $response['response']['order'] : [];
$apaczkaOrderId = (int) ($orderResponse['id'] ?? 0);
$tracking = trim((string) ($orderResponse['waybill_number'] ?? ''));
$this->packages->update($packageId, [
'status' => $apaczkaOrderId > 0 ? 'created' : 'pending',
'shipment_id' => $apaczkaOrderId > 0 ? (string) $apaczkaOrderId : null,
'tracking_number' => $tracking !== '' ? $tracking : null,
'payload_json' => $response,
]);
return [
'package_id' => $packageId,
'command_id' => $apaczkaOrderId > 0 ? (string) $apaczkaOrderId : null,
];
}
/**
* @return array<string, mixed>
*/
public function checkCreationStatus(int $packageId): array
{
$package = $this->packages->findById($packageId);
if ($package === null) {
throw new RuntimeException('Paczka nie znaleziona.');
}
$shipmentId = max(0, (int) ($package['shipment_id'] ?? 0));
if ($shipmentId <= 0) {
return ['status' => 'in_progress'];
}
[$appId, $appSecret] = $this->requireCredentials();
$details = $this->apiClient->getOrderDetails($appId, $appSecret, $shipmentId);
$order = is_array($details['response']['order'] ?? null) ? $details['response']['order'] : [];
$tracking = trim((string) ($order['waybill_number'] ?? ($package['tracking_number'] ?? '')));
$this->packages->update($packageId, [
'status' => 'created',
'tracking_number' => $tracking !== '' ? $tracking : null,
'payload_json' => $details,
]);
return [
'status' => 'created',
'shipment_id' => (string) $shipmentId,
'tracking_number' => $tracking,
];
}
/**
* @return array<string, mixed>
*/
public function downloadLabel(int $packageId, string $storagePath): array
{
$package = $this->packages->findById($packageId);
if ($package === null) {
throw new RuntimeException('Paczka nie znaleziona.');
}
$shipmentId = max(0, (int) ($package['shipment_id'] ?? 0));
if ($shipmentId <= 0) {
throw new RuntimeException('Przesylka nie zostala jeszcze utworzona.');
}
[$appId, $appSecret] = $this->requireCredentials();
try {
$response = $this->apiClient->getWaybill($appId, $appSecret, $shipmentId);
} catch (Throwable $exception) {
$message = trim($exception->getMessage());
if ($this->isLabelUnavailableError($message)) {
$this->packages->update($packageId, [
'status' => 'error',
'error_message' => $message,
]);
}
throw $exception;
}
$waybillRaw = $response['response']['waybill'] ?? null;
$base64 = '';
if (is_string($waybillRaw)) {
$base64 = trim($waybillRaw);
} elseif (is_array($waybillRaw)) {
$base64 = trim((string) ($waybillRaw['data'] ?? ''));
}
if ($base64 === '') {
throw new RuntimeException('Apaczka nie zwrocila danych etykiety.');
}
$binary = base64_decode($base64, true);
if (!is_string($binary) || $binary === '') {
throw new RuntimeException('Nie mozna odczytac etykiety Apaczka.');
}
$dir = rtrim($storagePath, '/\\') . '/labels';
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$filename = 'label_' . $packageId . '_' . $shipmentId . '.pdf';
$filePath = $dir . '/' . $filename;
file_put_contents($filePath, $binary);
$this->packages->update($packageId, [
'status' => 'label_ready',
'label_path' => 'labels/' . $filename,
'payload_json' => $response,
]);
return [
'label_path' => 'labels/' . $filename,
'full_path' => $filePath,
];
}
private function isLabelUnavailableError(string $message): bool
{
$normalized = strtolower(trim($message));
if ($normalized === '') {
return false;
}
return str_contains($normalized, 'label is not available for this order');
}
/**
* @return array{0: string, 1: string}
*/
private function requireCredentials(): array
{
$credentials = $this->integrationRepository->getApiCredentials();
$appId = trim((string) ($credentials['app_id'] ?? ''));
$appSecret = trim((string) ($credentials['app_secret'] ?? ''));
if ($appId === '' || $appSecret === '') {
throw new RuntimeException('Brak konfiguracji Apaczka (app_id/app_secret).');
}
return [$appId, $appSecret];
}
/**
* @param array<string, mixed>|null $orderDetails
* @param array<string, mixed> $formData
* @return array<string, mixed>
*/
private function buildReceiverAddress(
?array $orderDetails,
array $formData,
string $appId,
string $appSecret
): array
{
$addresses = is_array($orderDetails['addresses'] ?? null) ? $orderDetails['addresses'] : [];
$deliveryAddr = null;
$customerAddr = null;
foreach ($addresses as $addr) {
$type = (string) ($addr['address_type'] ?? '');
if ($type === 'delivery') {
$deliveryAddr = $addr;
}
if ($type === 'customer') {
$customerAddr = $addr;
}
}
$delivery = is_array($deliveryAddr) ? $deliveryAddr : [];
$customer = is_array($customerAddr) ? $customerAddr : [];
$deliveryStreet = $this->composeStreetLine($delivery);
$customerStreet = $this->composeStreetLine($customer);
$pickupMeta = $this->parsePickupMeta(
trim((string) ($delivery['parcel_name'] ?? '')),
trim((string) ($delivery['parcel_external_id'] ?? ''))
);
$receiverPointId = trim((string) ($formData['receiver_point_id'] ?? ($delivery['parcel_external_id'] ?? '')));
$pointAddress = $receiverPointId !== ''
? $this->resolvePointAddress($appId, $appSecret, $receiverPointId)
: ['street' => '', 'postal_code' => '', 'city' => ''];
$name = $this->resolveStringField(
$formData,
'receiver_name',
[
$delivery['name'] ?? null,
$customer['name'] ?? null,
$delivery['company_name'] ?? null,
$customer['company_name'] ?? null,
$orderDetails['order']['customer_login'] ?? null,
'Klient',
]
);
$street = $this->resolveStringField(
$formData,
'receiver_street',
[
$deliveryStreet,
$pickupMeta['street'] ?? null,
$pointAddress['street'] ?? null,
$customerStreet,
$receiverPointId !== '' ? ('Punkt odbioru ' . $receiverPointId) : null,
]
);
$city = $this->resolveStringField(
$formData,
'receiver_city',
[
$delivery['city'] ?? null,
$pickupMeta['city'] ?? null,
$pointAddress['city'] ?? null,
$customer['city'] ?? null,
]
);
$postalCode = $this->resolveStringField(
$formData,
'receiver_postal_code',
[
$delivery['zip_code'] ?? null,
$pickupMeta['postal_code'] ?? null,
$pointAddress['postal_code'] ?? null,
$customer['zip_code'] ?? null,
]
);
$countryCode = strtoupper($this->resolveStringField(
$formData,
'receiver_country_code',
[$delivery['country'] ?? null, $customer['country'] ?? null, 'PL']
));
$phone = $this->resolveStringField(
$formData,
'receiver_phone',
[$delivery['phone'] ?? null, $customer['phone'] ?? null]
);
$email = $this->resolveStringField(
$formData,
'receiver_email',
[$delivery['email'] ?? null, $customer['email'] ?? null]
);
if ($receiverPointId !== '') {
if ($street === '') {
$street = 'Punkt odbioru ' . $receiverPointId;
}
if ($city === '') {
$city = $this->resolveStringField($formData, 'receiver_city', [$customer['city'] ?? null, 'Warszawa']);
}
if ($postalCode === '') {
$postalCode = $this->resolveStringField($formData, 'receiver_postal_code', [$customer['zip_code'] ?? null, '00-000']);
}
}
if ($name === '' || $street === '' || $city === '' || $postalCode === '' || $countryCode === '') {
throw new RuntimeException('Brak wymaganych danych adresowych odbiorcy.');
}
$receiver = [
'name' => $name,
'line1' => $street,
'postal_code' => $postalCode,
'city' => $city,
'country_code' => $countryCode,
];
$contactPerson = $this->resolveStringField(
$formData,
'receiver_contact_person',
[$name, $customer['name'] ?? null, $delivery['name'] ?? null]
);
if ($contactPerson !== '') {
$receiver['contact_person'] = $contactPerson;
$receiver['person'] = $contactPerson;
}
if ($receiverPointId !== '') {
$this->applyPointIdentifiers($receiver, $receiverPointId);
}
if ($phone !== '') {
$receiver['phone'] = $phone;
}
if ($email !== '') {
$receiver['email'] = $email;
}
return $receiver;
}
/**
* @param array<string, mixed> $address
*/
private function applyPointIdentifiers(array &$address, string $pointId): void
{
$normalizedPointId = trim($pointId);
if ($normalizedPointId === '') {
return;
}
$address['point'] = $normalizedPointId;
$address['foreign_address_id'] = $normalizedPointId;
$address['point_id'] = $normalizedPointId;
}
/**
* @return array{street:string,postal_code:string,city:string}
*/
private function resolvePointAddress(string $appId, string $appSecret, string $pointId): array
{
$normalizedPointId = strtoupper(trim($pointId));
if ($normalizedPointId === '') {
return ['street' => '', 'postal_code' => '', 'city' => ''];
}
if (isset($this->pointAddressCache[$normalizedPointId])) {
return $this->pointAddressCache[$normalizedPointId];
}
$types = ['parcel_locker', 'pickup_point'];
foreach ($types as $type) {
try {
$points = $this->apiClient->getPoints($appId, $appSecret, $type);
} catch (Throwable) {
continue;
}
foreach ($points as $point) {
if (!is_array($point)) {
continue;
}
$candidateId = strtoupper(trim((string) ($point['id'] ?? $point['point_id'] ?? $point['code'] ?? '')));
if ($candidateId === '' || $candidateId !== $normalizedPointId) {
continue;
}
$resolved = [
'street' => trim((string) ($point['street'] ?? $point['line1'] ?? $point['address'] ?? $point['street_name'] ?? '')),
'postal_code' => trim((string) ($point['postal_code'] ?? $point['zip_code'] ?? $point['zip'] ?? $point['postcode'] ?? '')),
'city' => trim((string) ($point['city'] ?? $point['town'] ?? '')),
];
$this->pointAddressCache[$normalizedPointId] = $resolved;
return $resolved;
}
}
$this->pointAddressCache[$normalizedPointId] = ['street' => '', 'postal_code' => '', 'city' => ''];
return $this->pointAddressCache[$normalizedPointId];
}
/**
* @param array<string, mixed> $address
*/
private function composeStreetLine(array $address): string
{
$street = trim((string) ($address['street_name'] ?? ''));
$number = trim((string) ($address['street_number'] ?? ''));
if ($street === '') {
return '';
}
if ($number === '') {
return $street;
}
return trim($street . ' ' . $number);
}
/**
* @param array<string, mixed> $formData
* @param array<int, mixed> $fallbacks
*/
private function resolveStringField(array $formData, string $fieldName, array $fallbacks): string
{
$direct = trim((string) ($formData[$fieldName] ?? ''));
if ($direct !== '') {
return $direct;
}
foreach ($fallbacks as $fallback) {
$value = trim((string) $fallback);
if ($value !== '') {
return $value;
}
}
return '';
}
/**
* @return array{point_id:string,street:string,postal_code:string,city:string}
*/
private function parsePickupMeta(string $parcelName, string $parcelExternalId): array
{
$label = trim($parcelName);
$pointId = trim($parcelExternalId);
if ($label === '') {
return [
'point_id' => $pointId,
'street' => '',
'postal_code' => '',
'city' => '',
];
}
$addressPart = $label;
if (str_contains($label, '|')) {
$parts = explode('|', $label, 2);
$left = trim((string) ($parts[0] ?? ''));
$right = trim((string) ($parts[1] ?? ''));
if ($pointId === '' && $left !== '') {
$pointId = $left;
}
if ($right !== '') {
$addressPart = $right;
}
}
$street = '';
$postalCode = '';
$city = '';
if (preg_match('/^(.*?),\s*(\d{2}-\d{3})\s+(.+)$/u', $addressPart, $matches) === 1) {
$street = trim((string) ($matches[1] ?? ''));
$postalCode = trim((string) ($matches[2] ?? ''));
$city = trim((string) ($matches[3] ?? ''));
} elseif (preg_match('/(\d{2}-\d{3})\s+(.+)$/u', $addressPart, $matches) === 1) {
$postalCode = trim((string) ($matches[1] ?? ''));
$city = trim((string) ($matches[2] ?? ''));
$street = trim(preg_replace('/\s*\d{2}-\d{3}\s+.+$/u', '', $addressPart) ?? '');
}
return [
'point_id' => $pointId,
'street' => $street,
'postal_code' => $postalCode,
'city' => $city,
];
}
/**
* @param array<string, mixed> $sender
* @return array<string, mixed>
*/
private function normalizeSender(array $sender): array
{
$result = [
'name' => trim((string) ($sender['name'] ?? $sender['company'] ?? '')),
'line1' => trim((string) ($sender['street'] ?? '')),
'postal_code' => trim((string) ($sender['postalCode'] ?? '')),
'city' => trim((string) ($sender['city'] ?? '')),
'country_code' => strtoupper(trim((string) ($sender['countryCode'] ?? 'PL'))),
'phone' => trim((string) ($sender['phone'] ?? '')),
'email' => trim((string) ($sender['email'] ?? '')),
];
$contactPerson = trim((string) ($sender['contactPerson'] ?? $sender['name'] ?? ''));
if ($contactPerson !== '') {
$result['contact_person'] = $contactPerson;
$result['person'] = $contactPerson;
}
return $result;
}
/**
* @return array<string, mixed>|null
*/
private function resolveServiceDefinition(string $appId, string $appSecret, string $serviceId): ?array
{
if ($serviceId === '') {
return null;
}
try {
$services = $this->apiClient->getServiceStructure($appId, $appSecret);
} catch (Throwable) {
return null;
}
foreach ($services as $service) {
if (!is_array($service)) {
continue;
}
$currentServiceId = trim((string) ($service['service_id'] ?? $service['id'] ?? ''));
if ($currentServiceId === $serviceId) {
return $service;
}
}
return null;
}
/**
* @param array<string, mixed>|null $serviceDefinition
* @param array<string, mixed> $receiverAddress
* @param array<string, mixed> $senderAddress
*/
private function validateServiceRequirements(
?array $serviceDefinition,
array $receiverAddress,
array $senderAddress
): void {
if ($serviceDefinition === null) {
return;
}
$requiresReceiverPoint = ((int) ($serviceDefinition['door_to_point'] ?? 0) === 1)
|| ((int) ($serviceDefinition['point_to_point'] ?? 0) === 1);
$pickupCourierMode = (int) ($serviceDefinition['pickup_courier'] ?? 0);
$requiresSenderPoint = $pickupCourierMode === 0;
$receiverPoint = trim((string) ($receiverAddress['foreign_address_id'] ?? ''));
$senderPoint = trim((string) ($senderAddress['foreign_address_id'] ?? ''));
$serviceName = trim((string) ($serviceDefinition['name'] ?? ''));
if ($requiresReceiverPoint && $receiverPoint === '') {
throw new RuntimeException(
'Wybrana usluga Apaczka (' . ($serviceName !== '' ? $serviceName : 'ID')
. ') wymaga punktu odbioru (`receiver_point_id`).'
);
}
if ($requiresSenderPoint && $senderPoint === '') {
throw new RuntimeException(
'Wybrana usluga Apaczka (' . ($serviceName !== '' ? $serviceName : 'ID')
. ') wymaga punktu nadania (`sender_point_id`).'
);
}
}
/**
* @param array<string, mixed>|null $serviceDefinition
*/
private function resolvePickupType(?array $serviceDefinition, string $senderPointId): string
{
$hasSenderPoint = trim($senderPointId) !== '';
if ($hasSenderPoint) {
return 'SELF';
}
if ($serviceDefinition === null) {
return 'COURIER';
}
$pickupCourierMode = (int) ($serviceDefinition['pickup_courier'] ?? 0);
if ($pickupCourierMode === 0) {
return 'SELF';
}
return 'COURIER';
}
/**
* @param array<string, mixed>|null $serviceDefinition
* @param array<string, mixed> $formData
* @return array<string, mixed>
*/
private function buildPickupPayload(?array $serviceDefinition, string $senderPointId, array $formData): array
{
$pickupType = $this->resolvePickupType($serviceDefinition, $senderPointId);
if ($pickupType === '') {
return [];
}
$pickup = ['type' => $pickupType];
if ($pickupType !== 'COURIER') {
return $pickup;
}
$pickupDate = trim((string) ($formData['pickup_date'] ?? ''));
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $pickupDate) !== 1) {
$pickupDate = date('Y-m-d');
}
$pickupDate = $this->normalizeCourierPickupDate($pickupDate);
$hoursFrom = trim((string) ($formData['pickup_hours_from'] ?? ''));
if (preg_match('/^\d{2}:\d{2}$/', $hoursFrom) !== 1) {
$hoursFrom = '09:00';
}
$hoursTo = trim((string) ($formData['pickup_hours_to'] ?? ''));
if (preg_match('/^\d{2}:\d{2}$/', $hoursTo) !== 1) {
$hoursTo = '16:00';
}
[$hoursFrom, $hoursTo] = $this->normalizeCourierPickupHours($hoursFrom, $hoursTo);
$pickup['date'] = $pickupDate;
$pickup['hours_from'] = $hoursFrom;
$pickup['hours_to'] = $hoursTo;
return $pickup;
}
private function normalizeCourierPickupDate(string $pickupDate): string
{
$ts = strtotime($pickupDate);
if ($ts === false) {
$ts = time();
}
// Apaczka rejects Sunday as pickup date.
$weekday = (int) date('N', $ts);
if ($weekday === 7) {
$ts = strtotime('+1 day', $ts);
}
return date('Y-m-d', $ts);
}
/**
* @return array{0:string,1:string}
*/
private function normalizeCourierPickupHours(string $hoursFrom, string $hoursTo): array
{
$normalizedFrom = $hoursFrom;
$normalizedTo = $hoursTo;
$maxTo = '16:00';
if ($this->compareTime($normalizedTo, $maxTo) > 0) {
$normalizedTo = $maxTo;
}
if ($this->compareTime($normalizedFrom, $normalizedTo) >= 0) {
$normalizedFrom = '09:00';
}
if ($this->compareTime($normalizedFrom, $normalizedTo) >= 0) {
$normalizedFrom = '08:00';
}
if ($this->compareTime($normalizedFrom, $normalizedTo) >= 0) {
$normalizedFrom = '00:00';
}
return [$normalizedFrom, $normalizedTo];
}
private function compareTime(string $left, string $right): int
{
return strcmp($left, $right);
}
/**
* @param array<string, mixed>|null $serviceDefinition
* @param array<string, mixed> $receiverAddress
* @param array<string, mixed> $senderAddress
* @param array<string, mixed> $apiPayload
*/
private function buildShipmentErrorMessage(
Throwable $exception,
?array $serviceDefinition,
array $receiverAddress,
array $senderAddress,
array $apiPayload
): string {
$message = trim($exception->getMessage());
if ($message === '') {
$message = 'Nieznany blad tworzenia przesylki Apaczka.';
}
$isValuationError = stripos($message, 'Brak wyceny dla podanych parametr') !== false;
$isPickupMethodError = stripos($message, 'Niepoprawny sposób nadania przesyłki') !== false
|| stripos($message, 'Niepoprawny sposob nadania przesylki') !== false;
if (!$isValuationError && !$isPickupMethodError) {
return $message;
}
$serviceId = trim((string) ($apiPayload['service_id'] ?? ''));
$serviceName = trim((string) ($serviceDefinition['name'] ?? ''));
$supplier = trim((string) ($serviceDefinition['supplier'] ?? ''));
$receiverPoint = trim((string) ($receiverAddress['foreign_address_id'] ?? ''));
$senderPoint = trim((string) ($senderAddress['foreign_address_id'] ?? ''));
$weight = (string) ($apiPayload['shipment'][0]['weight'] ?? '0');
$width = (string) ((int) ($apiPayload['shipment'][0]['dimension2'] ?? 0));
$height = (string) ((int) ($apiPayload['shipment'][0]['dimension3'] ?? 0));
$length = (string) ((int) ($apiPayload['shipment'][0]['dimension1'] ?? 0));
$parts = [];
$parts[] = 'service_id=' . ($serviceId !== '' ? $serviceId : '-');
if ($serviceName !== '') {
$parts[] = 'service_name=' . $serviceName;
}
if ($supplier !== '') {
$parts[] = 'supplier=' . $supplier;
}
$parts[] = 'receiver_point_id=' . ($receiverPoint !== '' ? $receiverPoint : '(brak)');
$parts[] = 'sender_point_id=' . ($senderPoint !== '' ? $senderPoint : '(brak)');
$parts[] = 'gabaryt_cm=' . $length . 'x' . $width . 'x' . $height;
$parts[] = 'waga_kg=' . $weight;
$pickupType = trim((string) ($apiPayload['pickup']['type'] ?? ''));
$pickupDate = trim((string) ($apiPayload['pickup']['date'] ?? ''));
$pickupFrom = trim((string) ($apiPayload['pickup']['hours_from'] ?? ''));
$pickupTo = trim((string) ($apiPayload['pickup']['hours_to'] ?? ''));
if ($pickupType !== '') {
$parts[] = 'pickup.type=' . $pickupType;
}
if ($pickupDate !== '') {
$parts[] = 'pickup.date=' . $pickupDate;
}
if ($pickupFrom !== '' || $pickupTo !== '') {
$parts[] = 'pickup.hours=' . ($pickupFrom !== '' ? $pickupFrom : '-') . '-' . ($pickupTo !== '' ? $pickupTo : '-');
}
$hint = 'Sprawdz zgodnosc typu uslugi z typem punktu oraz uzupelnij wymagane punkty nadania/odbioru.';
if ($isPickupMethodError) {
$hint = 'Apaczka odrzucila sposob nadania. Dla uslug punktowych uzupelnij `sender_point_id` '
. 'lub wybierz usluge z odbiorem przez kuriera (door_to_point).';
}
if ($receiverPoint !== '' && str_starts_with(strtoupper($receiverPoint), 'POP-') && strtoupper($supplier) === 'INPOST') {
$hint = 'Prefiks punktu (`POP-`) moze byc mapowany roznie zaleznie od konfiguracji przewoznika. '
. 'Jesli to punkt InPost, zignoruj ta sugestie.';
}
return $message . ' Diagnostyka: ' . implode('; ', $parts) . '. ' . $hint;
}
/**
* @param array<string, mixed> $sender
*/
private function validateSenderAddress(array $sender): void
{
$required = ['street', 'city', 'postalCode', 'phone', 'email'];
foreach ($required as $field) {
if (trim((string) ($sender[$field] ?? '')) === '') {
throw new RuntimeException('Uzupelnij dane nadawcy w Ustawienia > Dane firmy (brak: ' . $field . ').');
}
}
$name = trim((string) ($sender['name'] ?? ''));
$company = trim((string) ($sender['company'] ?? ''));
if ($name === '' && $company === '') {
throw new RuntimeException('Uzupelnij dane nadawcy w Ustawienia > Dane firmy (brak nazwy/firmy).');
}
$contactPerson = trim((string) ($sender['contactPerson'] ?? $name ?? ''));
if ($contactPerson === '') {
throw new RuntimeException('Uzupelnij dane nadawcy w Ustawienia > Dane firmy (brak osoby kontaktowej).');
}
}
}

View File

@@ -10,8 +10,9 @@ use App\Core\Security\Csrf;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\AllegroDeliveryMethodMappingRepository;
use App\Modules\Settings\CarrierDeliveryMethodMappingRepository;
use App\Modules\Settings\CompanySettingsRepository;
use RuntimeException;
use Throwable;
final class ShipmentController
@@ -22,10 +23,10 @@ final class ShipmentController
private readonly AuthService $auth,
private readonly OrdersRepository $ordersRepository,
private readonly CompanySettingsRepository $companySettings,
private readonly AllegroShipmentService $shipmentService,
private readonly ShipmentProviderRegistry $providerRegistry,
private readonly ShipmentPackageRepository $packageRepository,
private readonly string $storagePath,
private readonly ?AllegroDeliveryMethodMappingRepository $deliveryMappings = null
private readonly ?CarrierDeliveryMethodMappingRepository $deliveryMappings = null
) {
}
@@ -55,17 +56,33 @@ final class ShipmentController
}
}
$receiverAddr = $deliveryAddr ?? $customerAddr ?? [];
$receiverAddr = $this->buildReceiverAddress($deliveryAddr, $customerAddr);
$preferences = is_array($order['preferences_json'] ?? null)
? $order['preferences_json']
: (is_string($order['preferences_json'] ?? null) ? (json_decode($order['preferences_json'], true) ?: []) : []);
$deliveryServices = [];
$apaczkaServices = [];
$deliveryServicesError = '';
try {
$deliveryServices = $this->shipmentService->getDeliveryServices();
} catch (Throwable $exception) {
$deliveryServicesError = $exception->getMessage();
$allegroProvider = $this->providerRegistry->get('allegro_wza');
if ($allegroProvider !== null) {
try {
$deliveryServices = $allegroProvider->getDeliveryServices();
} catch (Throwable $exception) {
$deliveryServicesError = $exception->getMessage();
}
}
$apaczkaProvider = $this->providerRegistry->get('apaczka');
if ($apaczkaProvider !== null) {
try {
$apaczkaServices = $apaczkaProvider->getDeliveryServices();
} catch (Throwable $exception) {
if ($deliveryServicesError === '') {
$deliveryServicesError = $exception->getMessage();
}
}
}
$inpostServices = array_values(array_filter(
@@ -78,9 +95,26 @@ final class ShipmentController
unset($_SESSION['shipment_flash_success'], $_SESSION['shipment_flash_error']);
$deliveryMapping = null;
$deliveryMappingDiagnostic = '';
$orderCarrierName = trim((string) ($order['external_carrier_id'] ?? ''));
if ($orderCarrierName !== '' && $this->deliveryMappings !== null) {
$deliveryMapping = $this->deliveryMappings->findByOrderMethod($orderCarrierName);
$source = strtolower(trim((string) ($order['source'] ?? '')));
$sourceIntegrationId = $source === 'shoppro'
? max(0, (int) ($order['integration_id'] ?? 0))
: 0;
if ($orderCarrierName !== '' && $this->deliveryMappings !== null && in_array($source, ['allegro', 'shoppro'], true)) {
$deliveryMapping = $this->deliveryMappings->findByOrderMethod($source, $sourceIntegrationId, $orderCarrierName);
if ($deliveryMapping === null) {
$hasMappingsForSource = $this->deliveryMappings->hasMappingsForSource($source, $sourceIntegrationId);
if (!$hasMappingsForSource && $source === 'shoppro' && $sourceIntegrationId > 0) {
$deliveryMappingDiagnostic = 'Brak mapowan form dostawy dla tej instancji shopPRO (ID integracji: '
. $sourceIntegrationId
. ').';
} elseif (!$hasMappingsForSource) {
$deliveryMappingDiagnostic = 'Brak skonfigurowanych mapowan form dostawy dla tego zrodla zamowienia.';
} else {
$deliveryMappingDiagnostic = 'Brak mapowania dla metody dostawy: ' . $orderCarrierName . '.';
}
}
}
$html = $this->template->render('shipments/prepare', [
@@ -96,11 +130,13 @@ final class ShipmentController
'preferences' => $preferences,
'company' => $company,
'deliveryServices' => $deliveryServices,
'apaczkaServices' => $apaczkaServices,
'deliveryServicesError' => $deliveryServicesError,
'existingPackages' => $existingPackages,
'flashSuccess' => $flashSuccess,
'flashError' => $flashError,
'deliveryMapping' => $deliveryMapping,
'deliveryMappingDiagnostic' => $deliveryMappingDiagnostic,
'inpostServices' => $inpostServices,
], 'layouts/app');
@@ -125,7 +161,17 @@ final class ShipmentController
$actorName = ($actorName !== null && $actorName !== '') ? $actorName : null;
try {
$result = $this->shipmentService->createShipment($orderId, [
$providerCode = strtolower(trim((string) $request->input('provider_code', 'allegro_wza')));
if ($providerCode === 'inpost') {
$providerCode = 'allegro_wza';
}
$provider = $this->providerRegistry->get($providerCode);
if ($provider === null) {
throw new RuntimeException('Nieznany provider przesylek: ' . $providerCode);
}
$result = $provider->createShipment($orderId, [
'provider_code' => $providerCode,
'delivery_method_id' => (string) $request->input('delivery_method_id', ''),
'credentials_id' => (string) $request->input('credentials_id', ''),
'carrier_id' => (string) $request->input('carrier_id', ''),
@@ -155,8 +201,8 @@ final class ShipmentController
$this->ordersRepository->recordActivity(
$orderId,
'shipment_created',
'Zlecono utworzenie przesylki WZA (ID paczki: ' . $packageId . ')',
['package_id' => $packageId, 'command_id' => $result['command_id'] ?? null],
'Zlecono utworzenie przesylki (' . $providerCode . ', ID paczki: ' . $packageId . ')',
['package_id' => $packageId, 'command_id' => $result['command_id'] ?? null, 'provider' => $providerCode],
'user',
$actorName
);
@@ -185,14 +231,25 @@ final class ShipmentController
}
try {
$result = $this->shipmentService->checkCreationStatus($packageId);
$package = $this->packageRepository->findById($packageId);
if ($package === null) {
return Response::json(['error' => 'Not found'], 404);
}
$providerCode = strtolower(trim((string) ($package['provider'] ?? 'allegro_wza')));
$provider = $this->providerRegistry->get($providerCode);
if ($provider === null) {
return Response::json(['status' => 'error', 'error' => 'Brak providera: ' . $providerCode]);
}
$result = $provider->checkCreationStatus($packageId);
if (($result['status'] ?? '') === 'created') {
try {
$this->shipmentService->downloadLabel($packageId, $this->storagePath);
$provider->downloadLabel($packageId, $this->storagePath);
$result['status'] = 'label_ready';
} catch (Throwable) {
// label generation failed return created so user can retry manually
// label generation failed, user can retry manually
}
}
@@ -221,10 +278,20 @@ final class ShipmentController
$actorName = ($actorName !== null && $actorName !== '') ? $actorName : null;
try {
$result = $this->shipmentService->downloadLabel($packageId, $this->storagePath);
$package = $this->packageRepository->findById($packageId);
if ($package === null) {
throw new RuntimeException('Paczka nie znaleziona.');
}
$providerCode = strtolower(trim((string) ($package['provider'] ?? 'allegro_wza')));
$provider = $this->providerRegistry->get($providerCode);
if ($provider === null) {
throw new RuntimeException('Brak providera: ' . $providerCode);
}
$result = $provider->downloadLabel($packageId, $this->storagePath);
$fullPath = (string) ($result['full_path'] ?? '');
if ($fullPath !== '' && file_exists($fullPath)) {
$package = $this->packageRepository->findById($packageId);
$labelFormat = strtoupper(trim((string) ($package['label_format'] ?? 'PDF')));
$contentType = $labelFormat === 'ZPL' ? 'application/octet-stream' : 'application/pdf';
$filename = basename($fullPath);
@@ -233,7 +300,7 @@ final class ShipmentController
$orderId,
'shipment_label_downloaded',
'Pobrano etykiete dla przesylki #' . $packageId,
['package_id' => $packageId, 'filename' => $filename],
['package_id' => $packageId, 'filename' => $filename, 'provider' => $providerCode],
'user',
$actorName
);
@@ -263,4 +330,43 @@ final class ShipmentController
return Response::redirect('/orders/' . $orderId . '/shipment/prepare');
}
/**
* @param array<string, mixed>|null $deliveryAddr
* @param array<string, mixed>|null $customerAddr
* @return array<string, mixed>
*/
private function buildReceiverAddress(?array $deliveryAddr, ?array $customerAddr): array
{
$delivery = is_array($deliveryAddr) ? $deliveryAddr : [];
$customer = is_array($customerAddr) ? $customerAddr : [];
if ($delivery === []) {
return $customer;
}
$result = $delivery;
$deliveryName = trim((string) ($delivery['name'] ?? ''));
$customerName = trim((string) ($customer['name'] ?? ''));
if (($this->isPickupPointDelivery($delivery) || $deliveryName === '') && $customerName !== '') {
$result['name'] = $customerName;
}
if (trim((string) ($result['phone'] ?? '')) === '' && trim((string) ($customer['phone'] ?? '')) !== '') {
$result['phone'] = $customer['phone'];
}
if (trim((string) ($result['email'] ?? '')) === '' && trim((string) ($customer['email'] ?? '')) !== '') {
$result['email'] = $customer['email'];
}
return $result;
}
/**
* @param array<string, mixed> $deliveryAddr
*/
private function isPickupPointDelivery(array $deliveryAddr): bool
{
$parcelId = trim((string) ($deliveryAddr['parcel_external_id'] ?? ''));
$parcelName = trim((string) ($deliveryAddr['parcel_name'] ?? ''));
return $parcelId !== '' || $parcelName !== '';
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Modules\Shipments;
interface ShipmentProviderInterface
{
public function code(): string;
/**
* @return array<int, array<string, mixed>>
*/
public function getDeliveryServices(): array;
/**
* @param array<string, mixed> $formData
* @return array<string, mixed>
*/
public function createShipment(int $orderId, array $formData): array;
/**
* @return array<string, mixed>
*/
public function checkCreationStatus(int $packageId): array;
/**
* @return array<string, mixed>
*/
public function downloadLabel(int $packageId, string $storagePath): array;
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Modules\Shipments;
final class ShipmentProviderRegistry
{
/**
* @var array<string, ShipmentProviderInterface>
*/
private array $providers = [];
/**
* @param iterable<int, ShipmentProviderInterface> $providers
*/
public function __construct(iterable $providers)
{
foreach ($providers as $provider) {
$this->providers[$provider->code()] = $provider;
}
}
public function get(string $code): ?ShipmentProviderInterface
{
$key = strtolower(trim($code));
if ($key === '') {
return null;
}
return $this->providers[$key] ?? null;
}
/**
* @return array<string, ShipmentProviderInterface>
*/
public function all(): array
{
return $this->providers;
}
}