feat(07-pre-expansion-fixes): complete phase 07 — milestone v0.2 done

Phase 7 complete (5 plans):
- 07-01: Performance (N+1→LEFT JOIN, static cache, DB indexes)
- 07-02: Stability (SSL verification, cron throttle DB, migration 000014b)
- 07-03: UX (orderpro_to_allegro disable, lista zamówień fixes, SSL hotfix)
- 07-04: Tests (12 unit tests for AllegroTokenManager + AllegroOrderImportService)
- 07-05: InPost ShipX API (natywny provider, workaround remap usunięty)

Additional fixes:
- 5 broken use-statements fixed across 4 files
- vendor/ excluded from ftp-kr auto-upload
- PHPUnit + dg/bypass-finals infrastructure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 00:37:21 +01:00
parent 62a68e9ec2
commit 5ab87a5a20
18 changed files with 1474 additions and 54 deletions

View File

@@ -5,7 +5,7 @@ namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use PDO;
use AppCorexceptionsIntegrationConfigException;
use App\Core\Exceptions\IntegrationConfigException;
use Throwable;
final class AllegroIntegrationRepository

View File

@@ -8,6 +8,7 @@ use App\Modules\Orders\OrderImportRepository;
use App\Modules\Orders\OrdersRepository;
use App\Core\Constants\IntegrationSources;
use App\Core\Exceptions\AllegroApiException;
use RuntimeException;
use Throwable;
final class AllegroOrderImportService

View File

@@ -5,7 +5,7 @@ namespace App\Modules\Settings;
use DateInterval;
use DateTimeImmutable;
use AppCorexceptionsAllegroOAuthException;
use App\Core\Exceptions\AllegroOAuthException;
use Throwable;
final class AllegroTokenManager

View File

@@ -8,8 +8,8 @@ use App\Modules\Settings\AllegroApiClient;
use App\Modules\Settings\AllegroTokenManager;
use App\Modules\Settings\CompanySettingsRepository;
use RuntimeException;
use AppCoreExceptionsIntegrationConfigException;
use AppCoreExceptionsShipmentException;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Exceptions\ShipmentException;
use Throwable;
final class AllegroShipmentService implements ShipmentProviderInterface

View File

@@ -0,0 +1,577 @@
<?php
declare(strict_types=1);
namespace App\Modules\Shipments;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Exceptions\ShipmentException;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\InpostIntegrationRepository;
use RuntimeException;
use Throwable;
final class InpostShipmentService implements ShipmentProviderInterface
{
private const API_BASE_PRODUCTION = 'https://api-shipx-pl.easypack24.net/v1';
private const API_BASE_SANDBOX = 'https://sandbox-api-shipx-pl.easypack24.net/v1';
public function __construct(
private readonly InpostIntegrationRepository $inpostRepository,
private readonly ShipmentPackageRepository $packages,
private readonly CompanySettingsRepository $companySettings,
private readonly OrdersRepository $ordersRepository
) {
}
public function code(): string
{
return 'inpost';
}
/**
* @return array<int, array<string, mixed>>
*/
public function getDeliveryServices(): array
{
return [
['id' => 'inpost_locker_standard', 'name' => 'InPost Paczkomat - Standard', 'type' => 'locker'],
['id' => 'inpost_courier_standard', 'name' => 'InPost Kurier - Standard', 'type' => 'courier'],
['id' => 'inpost_courier_express', 'name' => 'InPost Kurier - Express', 'type' => 'courier'],
];
}
/**
* @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 ShipmentException('Zamowienie nie znalezione.');
}
$token = $this->resolveToken();
$settings = $this->inpostRepository->getSettings();
$organizationId = trim((string) ($settings['organization_id'] ?? ''));
if ($organizationId === '') {
throw new IntegrationConfigException('Brak organization_id w konfiguracji InPost.');
}
$company = $this->companySettings->getSettings();
$sender = $this->companySettings->getSenderAddress();
$this->validateSenderAddress($sender);
$receiver = $this->buildReceiverFromOrder($order, $formData);
$senderPayload = $this->buildSenderPayload($sender);
$serviceType = $this->resolveServiceType($formData, $settings);
$parcelPayload = $this->buildParcelPayload($formData, $settings, $company, $serviceType);
$apiPayload = [
'receiver' => $receiver,
'sender' => $senderPayload,
'parcels' => [$parcelPayload],
'service' => $serviceType,
'reference' => $this->buildReference($order, $orderId),
];
$codAmount = (float) ($formData['cod_amount'] ?? 0);
if ($codAmount > 0) {
$apiPayload['cod'] = [
'amount' => number_format($codAmount, 2, '.', ''),
'currency' => strtoupper(trim((string) ($formData['cod_currency'] ?? 'PLN'))),
];
}
$insuranceAmount = (float) ($formData['insurance_amount'] ?? 0);
if ($insuranceAmount <= 0 && !empty($settings['auto_insurance_value'])) {
$orderData = is_array($order['order'] ?? null) ? $order['order'] : [];
$totalWithTax = (float) ($orderData['total_with_tax'] ?? 0);
if ($totalWithTax > 0) {
$insuranceAmount = $totalWithTax;
}
}
if ($insuranceAmount > 0) {
$apiPayload['insurance'] = [
'amount' => number_format($insuranceAmount, 2, '.', ''),
'currency' => strtoupper(trim((string) ($formData['insurance_currency'] ?? 'PLN'))),
];
}
$labelFormat = trim((string) ($formData['label_format'] ?? ($settings['label_format'] ?? 'Pdf')));
$packageId = $this->packages->create([
'order_id' => $orderId,
'provider' => 'inpost',
'delivery_method_id' => $serviceType,
'credentials_id' => null,
'command_id' => null,
'status' => 'pending',
'carrier_id' => 'inpost',
'package_type' => $parcelPayload['dimensions'] ? 'PACKAGE' : 'LOCKER',
'weight_kg' => isset($parcelPayload['weight']['amount']) ? (float) $parcelPayload['weight']['amount'] : null,
'length_cm' => isset($parcelPayload['dimensions']['length']) ? (float) $parcelPayload['dimensions']['length'] : null,
'width_cm' => isset($parcelPayload['dimensions']['width']) ? (float) $parcelPayload['dimensions']['width'] : null,
'height_cm' => isset($parcelPayload['dimensions']['height']) ? (float) $parcelPayload['dimensions']['height'] : null,
'insurance_amount' => $insuranceAmount > 0 ? $insuranceAmount : null,
'insurance_currency' => $insuranceAmount > 0 ? strtoupper(trim((string) ($formData['insurance_currency'] ?? 'PLN'))) : null,
'cod_amount' => $codAmount > 0 ? $codAmount : null,
'cod_currency' => $codAmount > 0 ? strtoupper(trim((string) ($formData['cod_currency'] ?? 'PLN'))) : null,
'label_format' => $labelFormat,
'receiver_point_id' => trim((string) ($formData['receiver_point_id'] ?? '')),
'sender_point_id' => trim((string) ($formData['sender_point_id'] ?? '')),
'reference_number' => $apiPayload['reference'],
'payload_json' => $apiPayload,
]);
$env = (string) ($settings['environment'] ?? 'sandbox');
$url = $this->apiBaseUrl($env) . '/organizations/' . rawurlencode($organizationId) . '/shipments';
try {
$response = $this->apiRequest('POST', $url, $token, $apiPayload);
} catch (Throwable $exception) {
$this->packages->update($packageId, [
'status' => 'error',
'error_message' => $exception->getMessage(),
]);
throw $exception;
}
$shipmentId = trim((string) ($response['id'] ?? ''));
$trackingNumber = trim((string) ($response['tracking_number'] ?? ''));
$status = strtolower(trim((string) ($response['status'] ?? 'created')));
$this->packages->update($packageId, [
'shipment_id' => $shipmentId !== '' ? $shipmentId : null,
'tracking_number' => $trackingNumber !== '' ? $trackingNumber : null,
'status' => $status === 'created' || $status === 'confirmed' ? 'created' : 'pending',
'payload_json' => $response,
]);
return [
'package_id' => $packageId,
'command_id' => $shipmentId,
];
}
/**
* @return array<string, mixed>
*/
public function checkCreationStatus(int $packageId): array
{
$package = $this->packages->findById($packageId);
if ($package === null) {
throw new ShipmentException('Paczka nie znaleziona.');
}
$shipmentId = trim((string) ($package['shipment_id'] ?? ''));
if ($shipmentId === '') {
return ['status' => 'error', 'error' => 'Brak shipment_id — przesylka nie zostala utworzona.'];
}
$token = $this->resolveToken();
$settings = $this->inpostRepository->getSettings();
$env = (string) ($settings['environment'] ?? 'sandbox');
$url = $this->apiBaseUrl($env) . '/shipments/' . rawurlencode($shipmentId);
$response = $this->apiRequest('GET', $url, $token);
$status = strtolower(trim((string) ($response['status'] ?? '')));
$trackingNumber = trim((string) ($response['tracking_number'] ?? ''));
if (in_array($status, ['created', 'confirmed', 'dispatched', 'collected', 'delivered'], true)) {
$this->packages->update($packageId, [
'status' => 'created',
'tracking_number' => $trackingNumber !== '' ? $trackingNumber : null,
'payload_json' => $response,
]);
return [
'status' => 'created',
'shipment_id' => $shipmentId,
'tracking_number' => $trackingNumber,
];
}
if (in_array($status, ['cancelled', 'expired'], true)) {
$this->packages->update($packageId, [
'status' => 'error',
'error_message' => 'Przesylka anulowana/wygasla (status: ' . $status . ')',
'payload_json' => $response,
]);
return ['status' => 'error', 'error' => 'Przesylka: ' . $status];
}
return ['status' => 'in_progress'];
}
/**
* @return array<string, mixed>
*/
public function downloadLabel(int $packageId, string $storagePath): array
{
$package = $this->packages->findById($packageId);
if ($package === null) {
throw new ShipmentException('Paczka nie znaleziona.');
}
$shipmentId = trim((string) ($package['shipment_id'] ?? ''));
if ($shipmentId === '') {
throw new ShipmentException('Przesylka nie zostala jeszcze utworzona.');
}
$token = $this->resolveToken();
$settings = $this->inpostRepository->getSettings();
$env = (string) ($settings['environment'] ?? 'sandbox');
$labelFormat = trim((string) ($package['label_format'] ?? ($settings['label_format'] ?? 'Pdf')));
$url = $this->apiBaseUrl($env) . '/shipments/' . rawurlencode($shipmentId) . '/label';
$queryParams = ['format' => $labelFormat, 'type' => 'normal'];
$url .= '?' . http_build_query($queryParams);
$binary = $this->apiRequestRaw('GET', $url, $token);
$dir = rtrim($storagePath, '/\\') . '/labels';
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$ext = strtolower($labelFormat) === 'zpl' ? 'zpl' : 'pdf';
$filename = 'label_' . $packageId . '_inpost_' . $shipmentId . '.' . $ext;
$filePath = $dir . '/' . $filename;
file_put_contents($filePath, $binary);
$updateFields = [
'status' => 'label_ready',
'label_path' => 'labels/' . $filename,
];
if (trim((string) ($package['tracking_number'] ?? '')) === '') {
try {
$detailsUrl = $this->apiBaseUrl($env) . '/shipments/' . rawurlencode($shipmentId);
$details = $this->apiRequest('GET', $detailsUrl, $token);
$trackingNumber = trim((string) ($details['tracking_number'] ?? ''));
if ($trackingNumber !== '') {
$updateFields['tracking_number'] = $trackingNumber;
}
} catch (Throwable) {
}
}
$this->packages->update($packageId, $updateFields);
return [
'label_path' => 'labels/' . $filename,
'full_path' => $filePath,
];
}
private function resolveToken(): string
{
$token = $this->inpostRepository->getDecryptedToken();
if ($token === null || trim($token) === '') {
throw new IntegrationConfigException('Brak tokenu API InPost. Skonfiguruj w Ustawienia > Integracje > InPost.');
}
return trim($token);
}
private function apiBaseUrl(string $environment): string
{
return strtolower(trim($environment)) === 'production'
? self::API_BASE_PRODUCTION
: self::API_BASE_SANDBOX;
}
/**
* @param array<string, mixed>|null $body
* @return array<string, mixed>
*/
private function apiRequest(string $method, string $url, string $token, ?array $body = null): array
{
$binary = $this->apiRequestRaw($method, $url, $token, $body, 'application/json');
$json = json_decode($binary, true);
if (!is_array($json)) {
throw new ShipmentException('Nieprawidlowa odpowiedz JSON z InPost API.');
}
return $json;
}
/**
* @param array<string, mixed>|null $body
*/
private function apiRequestRaw(
string $method,
string $url,
string $token,
?array $body = null,
string $accept = 'application/pdf'
): string {
$ch = curl_init($url);
if ($ch === false) {
throw new ShipmentException('Nie udalo sie zainicjowac polaczenia z InPost API.');
}
$headers = [
'Authorization: Bearer ' . $token,
'Accept: ' . $accept,
];
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CUSTOMREQUEST => $method,
];
$caPath = $this->getCaBundlePath();
if ($caPath !== null) {
$opts[CURLOPT_CAINFO] = $caPath;
}
if ($body !== null) {
$jsonBody = json_encode($body, JSON_UNESCAPED_UNICODE);
$headers[] = 'Content-Type: application/json';
$opts[CURLOPT_POSTFIELDS] = $jsonBody;
}
$opts[CURLOPT_HTTPHEADER] = $headers;
curl_setopt_array($ch, $opts);
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
$ch = null;
if ($responseBody === false) {
throw new ShipmentException('Blad polaczenia z InPost API: ' . $curlError);
}
if ($httpCode < 200 || $httpCode >= 300) {
$errorMsg = $this->extractApiErrorMessage((string) $responseBody, $httpCode);
throw new ShipmentException($errorMsg);
}
return (string) $responseBody;
}
private function extractApiErrorMessage(string $body, int $httpCode): string
{
$json = json_decode($body, true);
if (is_array($json)) {
$message = trim((string) ($json['message'] ?? ''));
if ($message !== '') {
return 'InPost API [HTTP ' . $httpCode . ']: ' . $message;
}
$details = $json['details'] ?? $json['error'] ?? null;
if (is_string($details) && trim($details) !== '') {
return 'InPost API [HTTP ' . $httpCode . ']: ' . trim($details);
}
}
return 'InPost API zwrocilo blad HTTP ' . $httpCode;
}
private function getCaBundlePath(): ?string
{
$envPath = (string) ($_ENV['CURL_CA_BUNDLE_PATH'] ?? '');
if ($envPath !== '' && is_file($envPath)) {
return $envPath;
}
$iniPath = (string) ini_get('curl.cainfo');
if ($iniPath !== '' && is_file($iniPath)) {
return $iniPath;
}
$candidates = [
'C:/xampp/apache/bin/curl-ca-bundle.crt',
'C:/xampp/php/extras/ssl/cacert.pem',
'/etc/ssl/certs/ca-certificates.crt',
];
foreach ($candidates as $path) {
if (is_file($path)) {
return $path;
}
}
return null;
}
/**
* @param array<string, mixed> $orderDetails
* @param array<string, mixed> $formData
* @return array<string, mixed>
*/
private function buildReceiverFromOrder(array $orderDetails, array $formData): 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;
}
}
$addr = $deliveryAddr ?? $customerAddr ?? [];
$name = trim((string) ($formData['receiver_name'] ?? ($addr['name'] ?? '')));
$company = trim((string) ($formData['receiver_company'] ?? ($addr['company_name'] ?? '')));
$phone = trim((string) ($formData['receiver_phone'] ?? ($addr['phone'] ?? '')));
$email = trim((string) ($formData['receiver_email'] ?? ($addr['email'] ?? '')));
$receiver = [
'name' => $name !== '' ? $name : ($company !== '' ? $company : 'Odbiorca'),
'phone' => $phone,
'email' => $email,
];
if ($company !== '') {
$receiver['company_name'] = $company;
}
$street = trim((string) ($formData['receiver_street'] ?? ($addr['street_name'] ?? '')));
$city = trim((string) ($formData['receiver_city'] ?? ($addr['city'] ?? '')));
$postalCode = trim((string) ($formData['receiver_postal_code'] ?? ($addr['zip_code'] ?? '')));
$countryCode = strtoupper(trim((string) ($formData['receiver_country_code'] ?? ($addr['country'] ?? 'PL'))));
$receiver['address'] = [
'street' => $street,
'city' => $city,
'post_code' => $postalCode,
'country_code' => $countryCode,
];
return $receiver;
}
/**
* @param array<string, mixed> $sender
* @return array<string, mixed>
*/
private function buildSenderPayload(array $sender): array
{
$name = trim((string) ($sender['name'] ?? ''));
$company = trim((string) ($sender['company'] ?? ''));
return [
'name' => $name !== '' ? $name : ($company !== '' ? $company : 'Nadawca'),
'company_name' => $company !== '' ? $company : null,
'phone' => trim((string) ($sender['phone'] ?? '')),
'email' => trim((string) ($sender['email'] ?? '')),
'address' => [
'street' => trim((string) ($sender['street'] ?? '')),
'city' => trim((string) ($sender['city'] ?? '')),
'post_code' => trim((string) ($sender['postalCode'] ?? '')),
'country_code' => strtoupper(trim((string) ($sender['countryCode'] ?? 'PL'))),
],
];
}
/**
* @param array<string, mixed> $formData
* @param array<string, mixed> $settings
*/
private function resolveServiceType(array $formData, array $settings): string
{
$deliveryMethodId = trim((string) ($formData['delivery_method_id'] ?? ''));
if ($deliveryMethodId !== '') {
return $deliveryMethodId;
}
$pointId = trim((string) ($formData['receiver_point_id'] ?? ''));
if ($pointId !== '') {
return 'inpost_locker_standard';
}
$dispatchMethod = (string) ($settings['default_dispatch_method'] ?? 'pop');
if ($dispatchMethod === 'parcel_locker') {
return 'inpost_locker_standard';
}
return 'inpost_courier_standard';
}
/**
* @param array<string, mixed> $formData
* @param array<string, mixed> $settings
* @param array<string, mixed> $company
* @return array<string, mixed>
*/
private function buildParcelPayload(array $formData, array $settings, array $company, string $serviceType): array
{
$isLocker = str_contains($serviceType, 'locker');
if ($isLocker) {
$size = trim((string) ($formData['locker_size'] ?? ($settings['default_locker_size'] ?? 'small')));
if (!in_array($size, ['small', 'medium', 'large'], true)) {
$size = 'small';
}
$targetPointId = trim((string) ($formData['receiver_point_id'] ?? ''));
$parcel = [
'template' => $size,
];
if ($targetPointId !== '') {
$parcel['target_point'] = $targetPointId;
}
return $parcel;
}
$lengthCm = (float) ($formData['length_cm'] ?? ($settings['default_courier_length'] ?? $company['default_package_length_cm'] ?? 20));
$widthCm = (float) ($formData['width_cm'] ?? ($settings['default_courier_width'] ?? $company['default_package_width_cm'] ?? 15));
$heightCm = (float) ($formData['height_cm'] ?? ($settings['default_courier_height'] ?? $company['default_package_height_cm'] ?? 8));
$weightKg = (float) ($formData['weight_kg'] ?? ($company['default_package_weight_kg'] ?? 1));
return [
'dimensions' => [
'length' => $lengthCm,
'width' => $widthCm,
'height' => $heightCm,
'unit' => 'mm',
],
'weight' => [
'amount' => $weightKg,
'unit' => 'kg',
],
];
}
/**
* @param array<string, mixed> $orderDetails
*/
private function buildReference(array $orderDetails, int $orderId): string
{
$orderData = is_array($orderDetails['order'] ?? null) ? $orderDetails['order'] : [];
$sourceOrderId = trim((string) ($orderData['source_order_id'] ?? ''));
return $sourceOrderId !== '' ? $sourceOrderId : (string) $orderId;
}
/**
* @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 IntegrationConfigException('Uzupelnij dane nadawcy w Ustawienia > Dane firmy (brak: ' . $field . ').');
}
}
$name = trim((string) ($sender['name'] ?? ''));
$company = trim((string) ($sender['company'] ?? ''));
if ($name === '' && $company === '') {
throw new IntegrationConfigException('Uzupelnij dane nadawcy w Ustawienia > Dane firmy (brak nazwy/firmy).');
}
}
}

View File

@@ -13,7 +13,7 @@ use App\Modules\Auth\AuthService;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\CarrierDeliveryMethodMappingRepository;
use App\Modules\Settings\CompanySettingsRepository;
use AppCorexceptionsShipmentException;
use App\Core\Exceptions\ShipmentException;
use Throwable;
final class ShipmentController
@@ -162,9 +162,6 @@ final class ShipmentController
try {
$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 ShipmentException('Nieznany provider przesylek: ' . $providerCode);