diff --git a/.paul/STATE.md b/.paul/STATE.md
index d8a5347..415c62a 100644
--- a/.paul/STATE.md
+++ b/.paul/STATE.md
@@ -4,13 +4,27 @@
**Ostatnia aktualizacja:** 2026-05-20
## Aktywna praca
-UNIFY zakonczony dla `.paul/plans/20260520-1500-refactor-invoice-service/`. Petla zamknieta. `InvoiceService.php` 762 -> 118 lin. (84% redukcji). Wydzielono 5 nowych klas w `src/Modules/Accounting/`: `InvoiceMetadataResolver` (84), `InvoiceSnapshotBuilder` (223), `FakturowniaInvoicePayloadBuilder` (84), `LocalInvoiceIssuer` (66, deviation — symetria do delegated), `DelegatedInvoiceIssuer` (253). Zero zmian publicznego kontraktu (konstruktor 6-arg, `issue(array)`, statyk `extractBuyerTaxNumber` jako forwarder). Konsumenci niezmienieni: `InvoiceController`, `AccountingModule`, `tests/Unit/FakturowniaInvoiceIdempotencyTest.php`. `php -l` zielony 7/7. Deviation: incydentalna naprawa pre-existing bugu — `LocalInvoiceIssuer::issue` ma poprawne 14 paramow vs oryginalny `issueLocal` 15 paramow w sygnaturze (sciezka local rzucala `ArgumentCountError`). SUMMARY: `.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md`.
+UNIFY zakonczony dla `.paul/plans/20260520-1615-refactor-polkurier-shipment-service/`. Petla zamknieta. `PolkurierShipmentService.php` 776 -> 299 lin. (61% redukcji) — slim fasada implementujaca `ShipmentProviderInterface`, `createShipment` 34 lin. Wydzielono 3 klasy w `src/Modules/Shipments/`: `PolkurierCarrierCatalog` (118), `PolkurierShipmentPayloadBuilder` (393, z `buildShipmentRequest` montujacym apiPayload + rekord paczki), `PolkurierResponseParser` (113). Orkiestracja create rozbita na `sendCreateRequest` + `persistCreationResult`. Zero zmian publicznego kontraktu (konstruktor 5-arg readonly, interfejs). `buildCodPayload` pozostaje prywatnym forwarderem na fasadzie (pinned przez `ReflectionMethod` w tescie). `php -l` zielony 4/4. Konsumenci niezmienieni: `ShipmentsModule` (factory), `ShipmentController` (interfejs), `ShopproIntegrationsController` (tylko publiczne `getDeliveryServices()`). Quality Radar: ok (codebase-memory-mcp 3 481 -> 3 518 wezlow; jscpd/ast-grep disabled by policy). SUMMARY: `.paul/plans/20260520-1615-refactor-polkurier-shipment-service/SUMMARY.md`.
+
+**Rozszerzenie zakresu (zatwierdzone przez uzytkownika w trakcie APPLY):** po pierwszej dekompozycji (fasada 342, createShipment ~150) dolozono ekstrakcje montazu zadania do `PolkurierShipmentPayloadBuilder::buildShipmentRequest()` -> fasada 299, createShipment 34 lin. Tradeoff: `buildShipmentRequest` 86 lin. (plaskie literale tablic, zero zagniezdzen). Twardy prog AC-5 spelniony (299 < 388 = 50% z 776).
+
+⚠ **Do weryfikacji recznej (vendor/phpunit niedostepny w sesji):**
+1. `vendor/bin/phpunit tests/Unit/PolkurierShipmentServiceTest.php` — primary gate. UWAGA pre-existing: `testBuildCodPayloadRequiresBankAccount` asercja `'Przesylka...'` (`l`) vs rzucany komunikat `'Przesyłka...'` (`ł`) — rozjazd istnieje juz na HEAD (baseline 2/3, NIE 3/3); komunikat przeniesiono 1:1, wynik testu = baseline (NIE naprawiac bez zgody — to zmiana komunikatu uzytkownika).
+2. Smoke `POST /orders/{id}/shipment/prepare` dla zamowienia z mapowaniem polkurier; dropdown uslug w `/settings/integrations/shoppro`.
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Loop complete — gotowe na nastepny PLAN]
```
+## Poprzednia praca
+UNIFY zakonczony dla `.paul/plans/20260520-1500-refactor-invoice-service/`. Petla zamknieta. `InvoiceService.php` 762 -> 118 lin. (84% redukcji). Wydzielono 5 nowych klas w `src/Modules/Accounting/`: `InvoiceMetadataResolver` (84), `InvoiceSnapshotBuilder` (223), `FakturowniaInvoicePayloadBuilder` (84), `LocalInvoiceIssuer` (66, deviation — symetria do delegated), `DelegatedInvoiceIssuer` (253). Zero zmian publicznego kontraktu (konstruktor 6-arg, `issue(array)`, statyk `extractBuyerTaxNumber` jako forwarder). Konsumenci niezmienieni: `InvoiceController`, `AccountingModule`, `tests/Unit/FakturowniaInvoiceIdempotencyTest.php`. `php -l` zielony 7/7. Deviation: incydentalna naprawa pre-existing bugu — `LocalInvoiceIssuer::issue` ma poprawne 14 paramow vs oryginalny `issueLocal` 15 paramow w sygnaturze (sciezka local rzucala `ArgumentCountError`). SUMMARY: `.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md`.
+
+```
+PLAN ──▶ APPLY ──▶ UNIFY
+ ✓ ✓ ✓ [Loop complete]
+```
+
⚠ **Do weryfikacji recznej (vendor/phpunit niedostepny w sesji):**
1. `vendor/bin/phpunit tests/Unit/FakturowniaInvoiceIdempotencyTest.php` — 3/3 scenariusze idempotencji musza przejsc bez modyfikacji testu (primary gate).
2. Smoke `POST /orders/{id}/invoice/create` dla konfiguracji delegowanej (Fakturownia) i lokalnej (sciezka local teraz wlasciwie dziala po naprawie sygnatury).
diff --git a/.paul/changelog/2026-05-20.md b/.paul/changelog/2026-05-20.md
index 19f7a93..64facbb 100644
--- a/.paul/changelog/2026-05-20.md
+++ b/.paul/changelog/2026-05-20.md
@@ -8,6 +8,9 @@
- Zero zmian publicznego kontraktu: konstruktor 6-arg, `issue(array): array`, statyk `InvoiceService::extractBuyerTaxNumber()` jako forwarder. `InvoiceController`, `AccountingModule`, `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` niezmienione.
- Incydentalna naprawa pre-existing bugu: oryginalny `issueLocal()` mial 15 paramow w sygnaturze vs 14 przekazywanych — sciezka local rzucala `ArgumentCountError`. `LocalInvoiceIssuer::issue` ma poprawne 14 paramow.
- `php -l` zielony dla 7 plikow. Reczny UAT (vendor/phpunit niedostepny): `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` + smoke `POST /orders/{id}/invoice/create`.
+- [Plan 20260520-1615-refactor-polkurier-shipment-service] Dekompozycja `src/Modules/Shipments/PolkurierShipmentService.php` z 776 do 299 lin. (61% redukcji); `createShipment` 34 lin. Wydzielono 3 nowe klasy: `PolkurierCarrierCatalog` (118), `PolkurierShipmentPayloadBuilder` (393, z `buildShipmentRequest` montujacym apiPayload + rekord paczki), `PolkurierResponseParser` (113). Orkiestracja create rozbita na `sendCreateRequest` + `persistCreationResult`.
+- Zero zmian publicznego kontraktu: konstruktor 5-arg readonly, `ShipmentProviderInterface`. `buildCodPayload` pozostaje prywatnym forwarderem (pinned przez `ReflectionMethod` w tescie). Konsumenci niezmienieni (`ShipmentsModule`, `ShipmentController`, `ShopproIntegrationsController`).
+- Rozszerzenie zakresu zatwierdzone przez uzytkownika w trakcie APPLY (ekstrakcja `buildShipmentRequest`). Pre-existing rozjazd `ł`/`l` w `testBuildCodPayloadRequiresBankAccount` udokumentowany, NIE naprawiany. `php -l` zielony 4/4; reczny UAT (vendor/phpunit niedostepny): `tests/Unit/PolkurierShipmentServiceTest.php` + smoke `/orders/{id}/shipment/prepare`.
## Zmienione pliki
@@ -31,4 +34,10 @@
- `.paul/plans/20260520-1128-storage-cleanup-cron/SUMMARY.md`
- `.paul/plans/20260520-1500-refactor-invoice-service/PLAN.md`
- `.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md`
+- `src/Modules/Shipments/PolkurierShipmentService.php`
+- `src/Modules/Shipments/PolkurierCarrierCatalog.php`
+- `src/Modules/Shipments/PolkurierShipmentPayloadBuilder.php`
+- `src/Modules/Shipments/PolkurierResponseParser.php`
+- `.paul/plans/20260520-1615-refactor-polkurier-shipment-service/PLAN.md`
+- `.paul/plans/20260520-1615-refactor-polkurier-shipment-service/SUMMARY.md`
- `.paul/STATE.md`
diff --git a/.paul/codebase/architecture.md b/.paul/codebase/architecture.md
index 30a141a..1c97939 100644
--- a/.paul/codebase/architecture.md
+++ b/.paul/codebase/architecture.md
@@ -67,7 +67,7 @@ Korzysci wzgledem poprzedniego monolitycznego `routes/web.php` (859 lin.):
| Statistics | `src/Modules/Statistics/` | raporty zamowien (slim Controller + `OrdersStatisticsFilters` + `OrdersStatisticsTableBuilder` + `OrdersStatisticsSummaryBuilder` + Repository) |
| Settings/Allegro | `src/Modules/Settings/Allegro*.php` | integracja Allegro: slim `AllegroIntegrationController` + `AllegroIntegrationViewModel` + `AllegroOAuthFlowService` + `AllegroImportScheduleService` + `AllegroImportImageWarningFormatter` + `AllegroSaveSettingsValidator` + `AllegroOrderImportService` + `AllegroStatusDiscoveryService` + `AllegroStatusMappingController` + `AllegroDeliveryMappingController` + repozytoria (`AllegroIntegrationRepository`, `AllegroStatusMappingRepository`, `AllegroPullStatusMappingRepository`) + klienci (`AllegroApiClient`, `AllegroOAuthClient`, `AllegroTokenManager`). |
| Accounting | `src/Modules/Accounting/` | paragony, faktury (slim fasada `InvoiceService` + `InvoiceMetadataResolver` resolvery dat/oid + `InvoiceSnapshotBuilder` seller/buyer/items + statyka `extractBuyerTaxNumber` + `FakturowniaInvoicePayloadBuilder` payload API + `LocalInvoiceIssuer` sciezka lokalna + `DelegatedInvoiceIssuer` idempotentna sciezka Fakturowni), eksport ksiegowy |
-| Shipments | `src/Modules/Shipments/` | etykiety, tracking, presets paczek; slownik statusow dostawy jako fasada `DeliveryStatus` + `DeliveryStatusProviderMap` (mapy dostawcow) + `AllegroDescriptionGuesser` (heurystyki opisow) + `DeliveryTrackingUrlBuilder` (URL sledzenia) |
+| Shipments | `src/Modules/Shipments/` | etykiety, tracking, presets paczek; slownik statusow dostawy jako fasada `DeliveryStatus` + `DeliveryStatusProviderMap` (mapy dostawcow) + `AllegroDescriptionGuesser` (heurystyki opisow) + `DeliveryTrackingUrlBuilder` (URL sledzenia); polkurier jako slim fasada `PolkurierShipmentService` (implementuje `ShipmentProviderInterface`, `createShipment` orkiestruje przez `sendCreateRequest`/`persistCreationResult`) + `PolkurierCarrierCatalog` (katalog uslug + heurystyki punktow odbioru) + `PolkurierShipmentPayloadBuilder` (`buildShipmentRequest`: montaz apiPayload + rekordu paczki; payload nadawcy/odbiorcy/odbioru/COD + helpery adresowe) + `PolkurierResponseParser` (ekstrakcja order/tracking/etykieta) |
| Printing | `src/Modules/Printing/` | API drukowania etykiet (klient Windows) |
| Email | `src/Modules/Email/` | SMTP, wysylka maili |
| Sms | `src/Modules/Sms/` | wysylka SMS, webhook SmsPlanet, konwersacje |
diff --git a/.paul/codebase/quality_risks.md b/.paul/codebase/quality_risks.md
index 9542e28..b539a8a 100644
--- a/.paul/codebase/quality_risks.md
+++ b/.paul/codebase/quality_risks.md
@@ -18,7 +18,7 @@ Konwencja (`CLAUDE.md`): funkcja/klasa zwykle do 30-50 linii, max 3 poziomy zagn
| `src/Modules/Settings/ShopproOrderMapper.php` | 867 | mapper z wieloma galeziami. |
| `src/Modules/Settings/AllegroOrderImportService.php` | 834 | duzy import service. |
| `src/Modules/Automation/AutomationService.php` | 818 | silnik regul — kandydat na pipeline-strategy. |
-| `src/Modules/Shipments/PolkurierShipmentService.php` | 776 | |
+| ~~`src/Modules/Shipments/PolkurierShipmentService.php`~~ | ~~776~~ -> 299 | ✅ Zrefaktorowane 2026-05-20 — slim fasada (299, implementuje `ShipmentProviderInterface`, `createShipment` 34 lin.) + 3 wspolpracownikow: `PolkurierCarrierCatalog` (118), `PolkurierShipmentPayloadBuilder` (393, z `buildShipmentRequest` montujacym apiPayload + rekord paczki), `PolkurierResponseParser` (113). Zero zmian kontraktu (konstruktor 5-arg, interfejs); `buildCodPayload` prywatny forwarder (pinned przez test refleksji). Tradeoff: `buildShipmentRequest` 86 lin. (plaskie literale tablic). Patrz `.paul/plans/20260520-1615-refactor-polkurier-shipment-service/SUMMARY.md`. |
| ~~`src/Modules/Accounting/InvoiceService.php`~~ | ~~762~~ -> 118 | ✅ Zrefaktorowane 2026-05-20 — fasada (118) + 5 wspolpracownikow: `InvoiceMetadataResolver` (84), `InvoiceSnapshotBuilder` (223), `FakturowniaInvoicePayloadBuilder` (84), `LocalInvoiceIssuer` (66), `DelegatedInvoiceIssuer` (253). Zero zmian kontraktu (konstruktor 6-arg, `issue()`, statyk `extractBuyerTaxNumber`). Patrz `.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md`. |
| ~~`src/Modules/Automation/AutomationController.php`~~ | ~~677~~ -> 221 | ✅ Zrefaktorowane 2026-05-20 — wydzielono `AutomationRequestParser` (422), `AutomationFormViewModel` (80), `AutomationHistoryFilters` (58); szablon `destroy/duplicate/toggleStatus` zlozony do `runIdAction()`. Zero zmian kontraktu HTTP/widokow/DB. Patrz `.paul/plans/20260520-1400-refactor-automation-controller/SUMMARY.md`. |
| ~~`src/Modules/Shipments/DeliveryStatus.php`~~ | ~~657~~ -> 170 | ✅ Zrefaktorowane 2026-05-19 — fasada zachowujaca pelny kontrakt + wydzielono `DeliveryStatusProviderMap` (~330), `AllegroDescriptionGuesser` (~150), `DeliveryTrackingUrlBuilder` (~85). Zero zmian w 20 plikach konsumentow. Patrz `.paul/plans/20260519-1730-refactor-delivery-status/SUMMARY.md`. |
diff --git a/.paul/codebase/tech_changelog.md b/.paul/codebase/tech_changelog.md
index f29b148..4da1374 100644
--- a/.paul/codebase/tech_changelog.md
+++ b/.paul/codebase/tech_changelog.md
@@ -2,6 +2,43 @@
Chronologiczny log zmian technicznych (co i dlaczego). Najnowsze na gorze.
+## 2026-05-20 — Dekompozycja `PolkurierShipmentService` (776 -> 299 lin., fasada + 3 wspolpracownikow)
+
+### Co
+- `src/Modules/Shipments/PolkurierShipmentService.php` zredukowany z 776 do 299 lin. (61% redukcji) — slim fasada implementujaca `ShipmentProviderInterface`, delegujaca do wspolpracownikow. `createShipment()` skrocony z ~150 do 34 lin.
+- Wydzielono 3 nowe klasy w `src/Modules/Shipments/`:
+ - `PolkurierCarrierCatalog` (118) — `getDeliveryServices()` (z memoizacja `carriersCache`), `resolveCarrierLabel()` + prywatne heurystyki `detectPickupPointSupport`, `resolvePointCourierKey`.
+ - `PolkurierShipmentPayloadBuilder` (393) — `buildShipmentRequest()` (montaz `apiPayload` + rekordu `shipment_packages` w jednym przebiegu), `validateSender`, `buildSenderPayload`, `buildRecipient`, `buildPickup`, `buildCodPayload`, `normalizeShipmentType` + prywatne helpery adresowe (`splitStreetAndNumber`, `composeStreet`, `firstNonEmpty`, `normalizePhone`, `nextBusinessDay`). Zalezy od `CompanySettingsRepository` (numer konta COD) i `PolkurierCarrierCatalog` (etykieta przewoznika w rekordzie paczki — wstrzykniety, by zachowac pierwotna kolejnosc rozwiazania labela).
+ - `PolkurierResponseParser` (113) — bezstanowy: `extractLabelBase64`, `extractOrderNumber`, `extractTrackingNumber`.
+- Fasada komponuje wspolpracownikow w konstruktorze (poor man's DI, bez nowych zaleznosci zewnetrznych). Orkiestracja `createShipment` rozbita na prywatne `sendCreateRequest()` (wywolanie API + obsluga bledu) i `persistCreationResult()` (parsowanie odpowiedzi + update paczki + synchroniczne pobranie etykiety).
+- `PolkurierShipmentService::buildCodPayload()` pozostaje jako PRYWATNY forwarder do `PolkurierShipmentPayloadBuilder::buildCodPayload()` — `tests/Unit/PolkurierShipmentServiceTest.php` siega po nia przez `ReflectionMethod(PolkurierShipmentService::class, 'buildCodPayload')`.
+
+### Dlaczego
+- `PolkurierShipmentService` byl najwiekszym nieruszonym serwisem kurierskim (`quality_risks.md`: 776 lin., kandydat do podzialu), laczacym 4 odpowiedzialnosci: orkiestracja, katalog uslug, budowa payloadu, parsowanie odpowiedzi. Naruszal regule `CLAUDE.md`.
+- Wzorzec sprawdzony w projekcie (InvoiceService 762->118, DeliveryStatus 657->170): slim fasada + wspolpracownicy, zero zmian publicznego kontraktu.
+
+### Zachowane gwarancje
+- Konstruktor `PolkurierShipmentService(5 zaleznosci readonly)` bez zmian — `ShipmentsModule` (factory `shipments.polkurier.service`) i `tests/Unit/PolkurierShipmentServiceTest.php::setUp()` dzialaja bez modyfikacji.
+- Implementacja `ShipmentProviderInterface` (`code`, `getDeliveryServices`, `createShipment`, `checkCreationStatus`, `downloadLabel`) — sygnatury i ksztalt zwrotow niezmienione.
+- `ShopproIntegrationsController::loadPolkurierServices()` korzysta tylko z publicznego `getDeliveryServices()` — bez zmian.
+- Komunikaty bledow, statusy paczki, zapis `payload_json`, synchroniczne pobranie etykiety i memoizacja katalogu — przeniesione 1:1.
+
+### Obserwacja (pre-existing, NIE adresowane w tym planie)
+- `testBuildCodPayloadRequiresBankAccount` (`tests/Unit/PolkurierShipmentServiceTest.php:76`) asercja `expectExceptionMessage('Przesylka COD wymaga numeru konta bankowego.')` (litera `l`), a rzucany komunikat (HEAD i po refaktorze) to `'Przesyłka COD wymaga numeru konta bankowego. ...'` (litera `ł`). Substring nie pasuje -> ten scenariusz padal juz na baseline (HEAD). Komunikat przeniesiono BAJT-W-BAJT, wiec wynik testu jest identyczny jak przed refaktorem. Naprawa (ujednolicenie `ł`/`l`) zmienia komunikat uzytkownika -> wymaga osobnej zgody/planu.
+
+### Deviation (rozszerzenie zakresu zatwierdzone przez uzytkownika w trakcie APPLY)
+- Pierwotnie fasada wyszla 342 lin. (createShipment ~150). Na zyczenie uzytkownika dolozono ekstrakcje montazu zadania do `PolkurierShipmentPayloadBuilder::buildShipmentRequest()` -> fasada 299 lin., `createShipment` 34 lin. (cel < 50 osiagniety).
+- Tradeoff: `buildShipmentRequest()` ma 86 lin. (zdominowane dwoma deklaratywnymi literalami tablic: `apiPayload` + rekord paczki; zero zagniezdzen, niska zlozonosc). Nie rozbijano na `buildApiPayload` + `buildPackageRecord`, bo wspoldziela ~10 wartosci posrednich (dim/typ/sourceOrderId/insurance/cod) — rozbicie wymusiloby albo rekalkulacje, albo metode z ~10 parametrami (gorszy zapach niz dlugi, ale plaski literal).
+- Spelniony twardy prog AC-5 (< 50% oryginalu: 299 < 388).
+
+### Weryfikacja w sesji
+- `php -l` zielone dla 4 plikow (fasada + 3 nowe klasy) — dwukrotnie (po pierwszej dekompozycji i po ekstrakcji `buildShipmentRequest`).
+- `vendor/phpunit` niedostepny -> uruchomienie `PolkurierShipmentServiceTest` do recznej weryfikacji.
+- Kontrola statyczna: forwarder `buildCodPayload` prywatny na fasadzie; konstruktor 5-arg; guard `cod<=0` przed `getSettings()`.
+
+### Plan
+- `.paul/plans/20260520-1615-refactor-polkurier-shipment-service/`
+
## 2026-05-20 — Dekompozycja `InvoiceService` (762 -> 118 lin., fasada + 5 wspolpracownikow)
### Co
diff --git a/.paul/codebase/tooling_status.md b/.paul/codebase/tooling_status.md
index 2fe2faa..a6a7fba 100644
--- a/.paul/codebase/tooling_status.md
+++ b/.paul/codebase/tooling_status.md
@@ -1,7 +1,31 @@
# Tooling Status
-**Timestamp:** 2026-05-20 (last post-apply: refactor InvoiceService)
-**Tryb skanu:** full (`/paul:map-codebase` 2026-05-19) + post-apply (2026-05-20)
+**Timestamp:** 2026-05-20 (last post-apply: refactor PolkurierShipmentService)
+**Tryb skanu:** full (`/paul:map-codebase` 2026-05-19) + post-apply + plan (2026-05-20)
+
+## Post-apply 2026-05-20 — refactor `PolkurierShipmentService`
+
+- Plan: `.paul/plans/20260520-1615-refactor-polkurier-shipment-service/`
+- Tryb: post-apply (radar refresh po zmianach).
+- codebase-memory-mcp: `index_repository(mode=moderate)` -> **3 518 wezlow / 10 535 krawedzi** (przed apply: 3 481 / 10 297; +3 klasy: `PolkurierCarrierCatalog`, `PolkurierShipmentPayloadBuilder`, `PolkurierResponseParser`; po ekstrakcji `buildShipmentRequest`/`sendCreateRequest`/`persistCreationResult`).
+- `php -l` (XAMPP `C:\xampp\php\php.exe`) na 4 plikach modulu `Shipments/` -> No syntax errors (2x: po dekompozycji i po ekstrakcji montazu zadania).
+- Wynik koncowy: fasada 299, `PolkurierCarrierCatalog` 118, `PolkurierShipmentPayloadBuilder` 393, `PolkurierResponseParser` 113; `createShipment` 34 lin.
+- `vendor/phpunit` NIEDOSTEPNY -> `PolkurierShipmentServiceTest` przelozony na reczna weryfikacje.
+- Obserwacja: `testBuildCodPayloadRequiresBankAccount` ma pre-existing rozjazd `ł`/`l` w asercji komunikatu (baseline HEAD) — komunikat przeniesiony 1:1, wynik niezmieniony.
+- jscpd / ast-grep: nadal disabled by policy.
+
+---
+
+## Plan scan 2026-05-20 — refactor `PolkurierShipmentService`
+
+- Plan: `.paul/plans/20260520-1615-refactor-polkurier-shipment-service/`
+- Tryb: targeted (`$paul-plan` impact scan).
+- codebase-memory-mcp: re-index sesyjny `index_repository(mode=moderate)` -> projekt `C-visual-studio-code-orderPRO`, **3 481 wezlow / 10 297 krawedzi** (artifact_present=true). Rozbieznosc vs poprzedni snapshot (4 217/11 649) wynika z trybu `moderate` vs `full` — przy nastepnym `$paul-map-codebase` odswiezyc pelny indeks.
+- Zapytania grafu: `search_graph` (PolkurierShipmentService, ShipmentProviderInterface, *ShipmentService), `get_code_snippet` (klasa + test + interfejs).
+- Zweryfikowane sprzezenia: `ShipmentsModule` (factory 5-arg), `ShopproIntegrationsController::loadPolkurierServices` (tylko publiczne `getDeliveryServices()`, linia 963), `PolkurierShipmentServiceTest` (refleksja na prywatnym `buildCodPayload`).
+- jscpd / ast-grep: nadal disabled by policy.
+
+---
## Post-apply 2026-05-20 — refactor `InvoiceService`
diff --git a/.paul/plans/20260520-1615-refactor-polkurier-shipment-service/PLAN.md b/.paul/plans/20260520-1615-refactor-polkurier-shipment-service/PLAN.md
new file mode 100644
index 0000000..4b4c4aa
--- /dev/null
+++ b/.paul/plans/20260520-1615-refactor-polkurier-shipment-service/PLAN.md
@@ -0,0 +1,286 @@
+---
+plan_id: 20260520-1615-refactor-polkurier-shipment-service
+title: Refaktoring PolkurierShipmentService (776 -> fasada + 3 wspolpracownikow)
+storage: plan-first
+legacy_phase: null
+created: 2026-05-20T16:15:00
+status: planned
+type: execute
+autonomous: true
+delegation: auto
+files_modified:
+ - src/Modules/Shipments/PolkurierShipmentService.php
+ - src/Modules/Shipments/PolkurierCarrierCatalog.php
+ - src/Modules/Shipments/PolkurierShipmentPayloadBuilder.php
+ - src/Modules/Shipments/PolkurierResponseParser.php
+quality_radar: ok
+---
+
+
+## Goal
+Rozbic `src/Modules/Shipments/PolkurierShipmentService.php` (776 lin.) na slim fasade zachowujaca pelny
+publiczny kontrakt (`ShipmentProviderInterface`) + 3 wspolpracownikow o jasno rozdzielonej odpowiedzialnosci,
+zgodnie z `quality_risks.md` (plik > 500 lin.) i konwencja `CLAUDE.md` (klasa/metoda 30-50 lin., max 3 poziomy zagniezdzen).
+
+## Purpose
+Plik to najwiekszy nieruszony serwis kurierski po refaktorach z 2026-05-19/20 (InvoiceService, DeliveryStatus,
+Allegro, Statistics). Laczy 5 odpowiedzialnosci w jednej klasie: orkiestracja przesylki, katalog uslug
+przewozniczych, budowa payloadu API, parsowanie odpowiedzi, helpery adresowe. Rozdzielenie poprawia czytelnosc,
+testowalnosc i lokalnosc zmian bez ryzyka dla konsumentow.
+
+## Output
+- `PolkurierShipmentService` jako slim fasada (~200-220 lin.) implementujaca `ShipmentProviderInterface`.
+- `PolkurierCarrierCatalog` (~135 lin.) — katalog uslug przewozniczych + heurystyki punktow odbioru.
+- `PolkurierShipmentPayloadBuilder` (~290 lin.) — budowa payloadu nadawcy/odbiorcy/odbioru/COD + helpery adresowe.
+- `PolkurierResponseParser` (~115 lin.) — ekstrakcja numeru zamowienia / trackingu / etykiety base64.
+- Zero zmian publicznego kontraktu i konsumentow; test jednostkowy zielony bez modyfikacji.
+
+
+
+## Project Docs
+@.paul/PROJECT.md
+@.paul/STATE.md
+@.paul/codebase/architecture.md
+@.paul/codebase/db_schema.md
+@.paul/codebase/impact_map.md
+@.paul/codebase/quality_risks.md
+
+## Source Files
+@src/Modules/Shipments/PolkurierShipmentService.php
+@src/Modules/Shipments/ShipmentProviderInterface.php
+@src/Modules/Shipments/ShipmentsModule.php
+@tests/Unit/PolkurierShipmentServiceTest.php
+@src/Modules/Settings/ShopproIntegrationsController.php
+
+## Wzorce referencyjne (analogiczne refaktory)
+- `.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md` (fasada + 5 klas, forwarder statyczny, zero zmian kontraktu).
+- `.paul/plans/20260519-1730-refactor-delivery-status/SUMMARY.md` (fasada + 3 klasy, kontrakt nietkniety).
+
+
+
+- Bez pytan. Wzorzec "slim fasada + wspolpracownicy, zero zmian kontraktu" jest ustalony w projekcie i potwierdzony 5 poprzednimi refaktorami.
+- Decyzja techniczna potwierdzona: `buildCodPayload` MUSI pozostac prywatna metoda na klasie `PolkurierShipmentService` (forwarder), poniewaz `PolkurierShipmentServiceTest` siega po nia przez `ReflectionMethod(PolkurierShipmentService::class, 'buildCodPayload')`.
+
+
+
+## Quality Radar
+
+**Status:** ok
+**Tools:** codebase-memory-mcp (3481 nodes / 10297 edges, reindex sesyjny 2026-05-20); jscpd/ast-grep — disabled by policy (`.paul/config.md`).
+
+## Affected Areas
+
+- Shipments — `src/Modules/Shipments/PolkurierShipmentService.php` (refaktor) + 3 nowe pliki w tym samym katalogu.
+- Shipments DI — `src/Modules/Shipments/ShipmentsModule.php` (`shipments.polkurier.service`): factory MUSI pozostac bez zmian (konstruktor 5-arg).
+- Konsument publiczny (interfejs) — `ShipmentController` przez `ShipmentProviderInterface` (`createShipment`, `checkCreationStatus`, `downloadLabel`).
+- Konsument publiczny (dropdown uslug) — `src/Modules/Settings/ShopproIntegrationsController::loadPolkurierServices()` uzywa WYLACZNIE publicznego `getDeliveryServices()` (zweryfikowane: linia 963).
+- Test (primary gate) — `tests/Unit/PolkurierShipmentServiceTest.php` siega po prywatny `buildCodPayload` przez refleksje.
+
+## Duplicate / Hardcoded Risks
+
+- Heurystyki punktow odbioru (`paczkomat/inpost/orlen/pocztex/kurier48`) i mapowanie `point_courier` — przeniesione do `PolkurierCarrierCatalog`, jedno zrodlo prawdy. Handled w Task 1.
+- Lista dozwolonych typow przesylki polkurier (`box/envelope/palette/small_parcel/parcel_size_20`) + aliasy — przeniesione do `PolkurierShipmentPayloadBuilder::normalizeShipmentType`, jedno zrodlo prawdy. Handled w Task 2.
+- Lista dozwolonych formatow etykiety (`PDF/ZPL/EPL`) wystepuje 2x (w `createShipment` i `downloadLabel`) — pozostaje w fasadzie (oba miejsca to orkiestracja); deferral, patrz nizej (niski zysk, ryzyko zmiany zachowania).
+
+## Explicit Deferrals
+
+- Walidacja formatu etykiety (`PDF/ZPL/EPL`) zduplikowana w dwoch metodach fasady — NIE konsolidujemy w tym planie (out of scope: refaktor ma byc bezpieczna ekstrakcja, nie zmiana logiki orkiestracji). Do rozwazenia osobno.
+- Luka testowa InPost/Shoppro z `quality_risks.md` — poza zakresem (dotyczy innych plikow).
+
+
+
+Brak `.paul/SPECIAL-FLOWS.md` — sekcja skills pominieta.
+
+
+
+
+## AC-1: Publiczny kontrakt fasady niezmieniony
+```gherkin
+Given PolkurierShipmentService implementuje ShipmentProviderInterface
+When refaktor jest zakonczony
+Then klasa nadal implementuje ShipmentProviderInterface z metodami code(), getDeliveryServices(), createShipment(int, array), checkCreationStatus(int), downloadLabel(int, string)
+And konstruktor ma niezmienione 5 argumentow readonly (PolkurierIntegrationRepository, PolkurierApiClient, ShipmentPackageRepository, CompanySettingsRepository, OrdersRepository)
+And ShipmentsModule::register dla 'shipments.polkurier.service' nie wymaga zmiany
+```
+
+## AC-2: Test jednostkowy zielony bez modyfikacji (primary gate)
+```gherkin
+Given tests/Unit/PolkurierShipmentServiceTest.php uzywa ReflectionMethod(PolkurierShipmentService::class, 'buildCodPayload')
+When uruchomie vendor/bin/phpunit tests/Unit/PolkurierShipmentServiceTest.php
+Then 3/3 scenariusze przechodza bez jakiejkolwiek modyfikacji pliku testu
+And buildCodPayload pozostaje prywatna metoda na klasie PolkurierShipmentService
+```
+
+## AC-3: buildCodPayload — kolejnosc i kontrakt zachowane
+```gherkin
+Given formData z cod_amount <= 0
+When wywolam buildCodPayload
+Then zwraca null BEZ wywolania companySettings->getSettings() (testBuildCodPayloadReturnsNullWithoutCodAmount: expects never())
+
+Given formData z cod_amount > 0 oraz pusty bank_account w companySettings
+When wywolam buildCodPayload
+Then rzuca ShipmentException z komunikatem 'Przesylka COD wymaga numeru konta bankowego.'
+
+Given formData z cod_amount = '123.456' oraz bank_account = '12 3456-7890 1234 5678 9012 3456'
+When wywolam buildCodPayload
+Then zwraca codtype='S', codamount=123.46, codbankaccount='12345678901234567890123456', return_cod='BA'
+```
+
+## AC-4: Zachowanie createShipment/downloadLabel/getDeliveryServices identyczne
+```gherkin
+Given dotychczasowe sciezki (sukces, brak order number, blad API, COD, etykieta synchroniczna, cache uslug)
+When fasada deleguje do wspolpracownikow
+Then ksztalt zwracanych tablic, statusy paczki (pending/created/error/label_ready), komunikaty bledow i zapis payload_json sa niezmienione
+And carriersCache (memoizacja) dziala tak samo (przeniesiony do PolkurierCarrierCatalog)
+```
+
+## AC-5: Higiena rozmiaru i skladni
+```gherkin
+Given 4 pliki po refaktorze
+When uruchomie php -l na kazdym
+Then wszystkie przechodza bez bledow skladni
+And PolkurierShipmentService jest istotnie mniejszy (cel ~200-220 lin., < 50% oryginalu)
+And zadna nowa klasa nie wprowadza zewnetrznych zaleznosci spoza obecnych 5 (kompozycja wewnatrz fasady)
+```
+
+
+
+
+
+
+ Task 1: Wydzielic PolkurierCarrierCatalog (katalog uslug przewozniczych)
+ src/Modules/Shipments/PolkurierCarrierCatalog.php (nowy), src/Modules/Shipments/PolkurierShipmentService.php
+
+ Utworz `final class PolkurierCarrierCatalog` w namespace `App\Modules\Shipments`.
+ Konstruktor: `(private readonly PolkurierIntegrationRepository $integrationRepository, private readonly PolkurierApiClient $apiClient)`.
+ Przenies z fasady (bez zmiany logiki):
+ - pole `private ?array $carriersCache = null` (memoizacja w katalogu),
+ - public `getDeliveryServices(): array` (cala normalizacja + usort + cache; klucze id/name/supports_pickup_point/point_courier/foreign_shipments/raw bez zmian),
+ - public `resolveCarrierLabel(string $courierCode): string` (iteruje po getDeliveryServices()),
+ - private `detectPickupPointSupport(string $code, array $carrier): bool`,
+ - private `resolvePointCourierKey(string $code): ?string`.
+ W fasadzie: zbuduj `$this->carrierCatalog = new PolkurierCarrierCatalog($integrationRepository, $apiClient)` w konstruktorze; `getDeliveryServices()` deleguje `return $this->carrierCatalog->getDeliveryServices();`. Usun z fasady pole `carriersCache` oraz metody detectPickupPointSupport/resolvePointCourierKey/resolveCarrierLabel (przeniesione). W `createShipment` zamien `$this->resolveCarrierLabel(...)` na `$this->carrierCatalog->resolveCarrierLabel(...)`.
+
+ php -l src/Modules/Shipments/PolkurierCarrierCatalog.php; php -l src/Modules/Shipments/PolkurierShipmentService.php
+ AC-1 (getDeliveryServices dziala przez delegacje), AC-4 (cache + ksztalt uslug identyczne), AC-5.
+
+
+
+ Task 2: Wydzielic PolkurierShipmentPayloadBuilder (budowa payloadu + helpery adresowe)
+ src/Modules/Shipments/PolkurierShipmentPayloadBuilder.php (nowy), src/Modules/Shipments/PolkurierShipmentService.php
+
+ Utworz `final class PolkurierShipmentPayloadBuilder` w namespace `App\Modules\Shipments`.
+ Konstruktor: `(private readonly CompanySettingsRepository $companySettings)` — potrzebny wylacznie dla COD (bank_account).
+ Przenies z fasady BEZ zmiany logiki:
+ - public `validateSender(array $sender): void`,
+ - public `buildSenderPayload(array $sender): array`,
+ - public `buildRecipient(array $order, array $formData, string $receiverPointId): array`,
+ - public `buildPickup(array $formData): array`,
+ - public `buildCodPayload(array $formData): ?array` (kolejnosc: najpierw guard cod<=0 -> null, dopiero potem getSettings()),
+ - public `normalizeShipmentType(string $input): string`,
+ - private helpery: `splitStreetAndNumber`, `composeStreet`, `firstNonEmpty`, `normalizePhone`, `nextBusinessDay`.
+ W fasadzie: zbuduj `$this->payloadBuilder = new PolkurierShipmentPayloadBuilder($companySettings)` w konstruktorze.
+ W `createShipment` przekieruj wywolania: `$this->payloadBuilder->validateSender($sender)`, `->buildSenderPayload($sender)`, `->buildRecipient(...)`, `->buildPickup($formData)`, `->normalizeShipmentType(...)`.
+ Zachowaj w fasadzie PRYWATNY forwarder (wymog testu refleksji):
+ `private function buildCodPayload(array $formData): ?array { /* pinned by PolkurierShipmentServiceTest reflection */ return $this->payloadBuilder->buildCodPayload($formData); }`.
+ `createShipment` woła COD przez `$this->buildCodPayload($formData)` (forwarder), aby istniala jedna sciezka.
+ Usun z fasady oryginalne implementacje przeniesionych metod (poza forwarderem buildCodPayload).
+
+ php -l src/Modules/Shipments/PolkurierShipmentPayloadBuilder.php; php -l src/Modules/Shipments/PolkurierShipmentService.php
+ AC-2 (forwarder zachowany), AC-3 (kolejnosc COD + komunikat), AC-4 (payload identyczny), AC-5.
+
+
+
+ Task 3: Wydzielic PolkurierResponseParser (parsowanie odpowiedzi API)
+ src/Modules/Shipments/PolkurierResponseParser.php (nowy), src/Modules/Shipments/PolkurierShipmentService.php
+
+ Utworz `final class PolkurierResponseParser` w namespace `App\Modules\Shipments` (bezstanowy, bez konstruktora/zaleznosci).
+ Przenies z fasady BEZ zmiany logiki (lacznie z rekurencja po 'order'/[0] i listami fallbackow):
+ - public `extractLabelBase64(mixed $response): string`,
+ - public `extractOrderNumber(array $response): string`,
+ - public `extractTrackingNumber(array $response, string $orderno): string`.
+ W fasadzie: zbuduj `$this->responseParser = new PolkurierResponseParser()` w konstruktorze.
+ W `createShipment` zamien `$this->extractOrderNumber($response)` -> `$this->responseParser->extractOrderNumber($response)` oraz `$this->extractTrackingNumber($response, $orderno)` -> `$this->responseParser->extractTrackingNumber(...)`.
+ W `downloadLabel` zamien `$this->extractLabelBase64($response)` -> `$this->responseParser->extractLabelBase64($response)`.
+ Usun z fasady przeniesione metody.
+
+ php -l src/Modules/Shipments/PolkurierResponseParser.php; php -l src/Modules/Shipments/PolkurierShipmentService.php
+ AC-4 (parsowanie order/tracking/label identyczne), AC-5.
+
+
+
+ Task 4: Domkniecie fasady + dokumentacja techniczna
+ src/Modules/Shipments/PolkurierShipmentService.php, .paul/codebase/architecture.md, .paul/codebase/quality_risks.md, .paul/codebase/tech_changelog.md
+
+ Fasada po Task 1-3 zawiera: konstruktor 5-arg (niezmieniony) budujacy 3 wspolpracownikow, `code()`,
+ `getDeliveryServices()` (delegacja), `createShipment()` (orkiestracja), `checkCreationStatus()`,
+ `downloadLabel()` (orkiestracja), private `requireCredentials()`, private `resolveStorageRoot()` (ZOSTAJE w fasadzie —
+ zalezy od `dirname(__DIR__, 3)`), oraz private forwarder `buildCodPayload()`.
+ Zweryfikuj brak martwych `use` po przeniesieniu (np. jesli ktoras stala/wyjatek nie jest juz uzywana w fasadzie)
+ oraz dodaj wymagane `use` w nowych plikach (IntegrationConfigException, ShipmentException, repozytoria, OrdersRepository, CompanySettingsRepository wg potrzeb klasy).
+ Zaktualizuj dokumentacje:
+ - `architecture.md` (wiersz modulu Shipments): dopisz fasade `PolkurierShipmentService` + `PolkurierCarrierCatalog` + `PolkurierShipmentPayloadBuilder` + `PolkurierResponseParser` (analogicznie do opisu DeliveryStatus).
+ - `quality_risks.md`: oznacz wiersz `PolkurierShipmentService.php` jako zrefaktorowany (przekreslenie + nowe liczby + odnosnik do tego planu), wzorem InvoiceService/DeliveryStatus.
+ - `tech_changelog.md`: wpis chronologiczny (co + dlaczego).
+
+ php -l src/Modules/Shipments/PolkurierShipmentService.php; (Measure-Object -Line) na pliku fasady — cel ~200-220 lin.
+ AC-1, AC-5; dokumentacja zsynchronizowana zgodnie z CLAUDE.md.
+
+
+
+ Task 5: Weryfikacja kontraktu i testu (primary gate)
+ tests/Unit/PolkurierShipmentServiceTest.php (tylko odczyt/uruchomienie — bez modyfikacji)
+
+ Uruchom `php -l` na 4 plikach (fasada + 3 nowe). Uruchom `vendor/bin/phpunit tests/Unit/PolkurierShipmentServiceTest.php`
+ i potwierdz 3/3 zielone BEZ modyfikacji testu. Jezeli vendor/phpunit niedostepny w sesji — udokumentuj jako
+ wymaganą weryfikacje reczna w SUMMARY/STATE (jak w poprzednich planach) i wykonaj statyczna kontrole, ze:
+ (a) `buildCodPayload` istnieje jako prywatna metoda na PolkurierShipmentService,
+ (b) konstruktor 5-arg niezmieniony,
+ (c) `companySettings->getSettings()` nie jest wolane gdy cod<=0.
+
+ vendor/bin/phpunit tests/Unit/PolkurierShipmentServiceTest.php (lub udokumentowany fallback offline)
+ AC-2, AC-3.
+
+
+
+
+
+## Do Not Change
+- Sygnatura konstruktora `PolkurierShipmentService` — 5 argumentow readonly w tej samej kolejnosci. Brak nowych zewnetrznych zaleznosci (wspolpracownicy budowani wewnatrz fasady, kompozycja jak w InvoiceService).
+- Implementacja `ShipmentProviderInterface` — fasada nadal implementuje interfejs; sygnatury i ksztalt zwrotow 5 metod niezmienione.
+- `ShipmentsModule` factory `shipments.polkurier.service` — bez zmian.
+- `tests/Unit/PolkurierShipmentServiceTest.php` — ZAKAZ modyfikacji (primary gate).
+- `buildCodPayload` — pozostaje PRYWATNA metoda na klasie `PolkurierShipmentService` (forwarder dozwolony); kolejnosc guard cod<=0 PRZED getSettings() i dokladny komunikat wyjatku 'Przesylka COD wymaga numeru konta bankowego.'.
+- `resolveStorageRoot()` — zostaje w fasadzie (sciezko-wrazliwe `dirname(__DIR__, 3)`).
+- Zachowanie runtime: statusy paczki, komunikaty bledow, zapis `payload_json`, synchroniczne pobranie etykiety po utworzeniu, memoizacja katalogu uslug — identyczne.
+
+## Scope Limits
+- Bez zmian w `PolkurierApiClient`, `PolkurierIntegrationRepository`, `ShipmentPackageRepository`, `ShipmentController`, `ShopproIntegrationsController`.
+- Bez zmian schematu DB, migracji, widokow, routingu.
+- Bez konsolidacji walidacji formatu etykiety (PDF/ZPL/EPL) — jawnie odroczone w ``.
+- Czysty refaktor: zero zmian funkcjonalnych/poprawek bugow (jesli wykryto pre-existing bug, zaraportowac w SUMMARY, nie naprawiac w tym planie bez zgody).
+
+
+
+- [ ] `php -l` zielony dla: PolkurierShipmentService.php, PolkurierCarrierCatalog.php, PolkurierShipmentPayloadBuilder.php, PolkurierResponseParser.php.
+- [ ] `vendor/bin/phpunit tests/Unit/PolkurierShipmentServiceTest.php` — 3/3 bez modyfikacji testu (lub udokumentowany fallback offline + kontrola statyczna).
+- [ ] `PolkurierShipmentService` implementuje `ShipmentProviderInterface`; konstruktor 5-arg niezmieniony; `buildCodPayload` prywatna na klasie.
+- [ ] Fasada ~200-220 lin. (< 50% oryginalu 776).
+- [ ] Smoke (reczny, jesli DB dostepna): `POST /orders/{id}/shipment/prepare` dla zamowienia z mapowaniem polkurier; dropdown uslug w `/settings/integrations/shoppro` (publiczne getDeliveryServices).
+- [ ] Dokumentacja zaktualizowana: architecture.md, quality_risks.md (wiersz oznaczony ✅), tech_changelog.md, tooling_status.md (wpis radaru).
+- [ ] Quality Radar relevant risks handled or deferred (patrz ``).
+
+
+
+- [ ] Wszystkie AC (AC-1..AC-5) spelnione.
+- [ ] Verification kompletna.
+- [ ] Docs/radar reports zaktualizowane (architecture.md, quality_risks.md, tech_changelog.md, tooling_status.md).
+- [ ] Zero zmian publicznego kontraktu i konsumentow.
+
+
+
diff --git a/.paul/plans/20260520-1615-refactor-polkurier-shipment-service/SUMMARY.md b/.paul/plans/20260520-1615-refactor-polkurier-shipment-service/SUMMARY.md
new file mode 100644
index 0000000..ebc71d9
--- /dev/null
+++ b/.paul/plans/20260520-1615-refactor-polkurier-shipment-service/SUMMARY.md
@@ -0,0 +1,83 @@
+---
+plan_id: 20260520-1615-refactor-polkurier-shipment-service
+title: Refaktoring PolkurierShipmentService (776 -> fasada + 3 wspolpracownikow)
+completed: 2026-05-20T17:10:00
+storage: plan-first
+quality_radar: ok
+---
+
+# Summary: Refaktoring `PolkurierShipmentService`
+
+## Objective
+
+Rozbic `src/Modules/Shipments/PolkurierShipmentService.php` (776 lin.) na slim fasade zachowujaca pelny
+publiczny kontrakt (`ShipmentProviderInterface`) + wspolpracownikow o jednej odpowiedzialnosci, zgodnie z
+`quality_risks.md` (plik > 500 lin.) i konwencja `CLAUDE.md`. Zero zmian kontraktu i konsumentow.
+
+## What Was Built
+
+| Area | Result |
+|------|--------|
+| Fasada | `PolkurierShipmentService` 776 -> **299** lin. (61% redukcji); `createShipment` ~150 -> **34** lin.; orkiestracja create rozbita na prywatne `sendCreateRequest` + `persistCreationResult` |
+| Katalog uslug | `PolkurierCarrierCatalog` (118) — `getDeliveryServices` (memoizacja), `resolveCarrierLabel` + heurystyki punktow odbioru |
+| Builder payloadu | `PolkurierShipmentPayloadBuilder` (393) — `buildShipmentRequest` (montaz apiPayload + rekordu paczki), sender/recipient/pickup/COD/typ + helpery adresowe |
+| Parser odpowiedzi | `PolkurierResponseParser` (113) — bezstanowy `extractLabelBase64`/`extractOrderNumber`/`extractTrackingNumber` |
+| Kontrakt | Konstruktor 5-arg readonly i interfejs niezmienione; `buildCodPayload` prywatny forwarder (pinned przez test refleksji) |
+
+## Files Modified
+
+- `src/Modules/Shipments/PolkurierShipmentService.php` — slim fasada (orkiestracja + delegacja + forwarder COD).
+- `src/Modules/Shipments/PolkurierCarrierCatalog.php` — NOWY (katalog uslug przewozniczych).
+- `src/Modules/Shipments/PolkurierShipmentPayloadBuilder.php` — NOWY (montaz zadania + budowa sekcji payloadu).
+- `src/Modules/Shipments/PolkurierResponseParser.php` — NOWY (parsowanie odpowiedzi API).
+- `.paul/codebase/architecture.md`, `quality_risks.md`, `tech_changelog.md`, `tooling_status.md`, `.paul/STATE.md` — dokumentacja.
+
+## Acceptance Criteria Results
+
+| Criterion | Status | Evidence |
+|-----------|--------|----------|
+| AC-1 Kontrakt fasady niezmieniony | Pass | Implementuje `ShipmentProviderInterface` (5 metod); konstruktor 5-arg readonly bez zmian; `ShipmentsModule` factory `shipments.polkurier.service` nietkniety |
+| AC-2 Test bez modyfikacji (primary gate) | Pass (offline) | Plik testu NIE modyfikowany; `buildCodPayload` pozostaje prywatna metoda na `PolkurierShipmentService` (forwarder). **Korekta wzgledem brzmienia AC:** baseline HEAD to 2/3, NIE 3/3 — `testBuildCodPayloadRequiresBankAccount` pada na pre-existing rozjezdzie `ł`/`l` w komunikacie. Komunikat przeniesiono 1:1, wiec wynik = baseline (brak regresji). `vendor/phpunit` niedostepny w sesji -> uruchomienie reczne |
+| AC-3 COD kolejnosc + komunikat | Pass (static) | `buildCodPayload`: guard `cod<=0` -> `null` PRZED `getSettings()`; komunikat `'Przesyłka COD wymaga numeru konta bankowego. ...'` 1:1; `codtype=S`, `codamount=round(2)`, `codbankaccount` digits-only, `return_cod=BA` |
+| AC-4 Zachowanie create/download/getDeliveryServices | Pass (static) | Kod przeniesiony 1:1; kolejnosc rozwiazania `resolveCarrierLabel` zachowana (katalog wstrzykniety do buildera, label liczony po COD, przed rekordem paczki); chain `label_format` rownowazny dla wszystkich przypadkow wejscia |
+| AC-5 Higiena rozmiaru/skladni | Pass | `php -l` 4/4 czyste; fasada 299 < 388 (50% z 776); brak nowych zaleznosci zewnetrznych (kompozycja w konstruktorze) |
+
+## Verification Results
+
+| Check | Result | Notes |
+|-------|--------|-------|
+| `php -l` (4 pliki) | Pass | XAMPP `C:\xampp\php\php.exe` — No syntax errors; uruchomione 2x (po dekompozycji i po ekstrakcji `buildShipmentRequest`) |
+| `vendor/bin/phpunit tests/Unit/PolkurierShipmentServiceTest.php` | Skipped | `vendor/` niedostepny w sesji -> reczna weryfikacja (primary gate) |
+| Kontrola statyczna kontraktu | Pass | konstruktor 5-arg; `buildCodPayload` prywatny na fasadzie; konsumenci uzywaja tylko publicznego API |
+| Line counts | Pass | fasada 299, catalog 118, builder 393, parser 113; `createShipment` 34; `buildShipmentRequest` 86 |
+| `git diff --stat` | Info | fasada -535/+~ (Write x2); 3 nowe pliki untracked |
+
+## Quality Radar Results
+
+**Status:** ok
+
+- New risks: brak.
+- Resolved risks: `PolkurierShipmentService.php` (776 lin.) skreslony z listy plikow > 500 lin. w `quality_risks.md` (oznaczony ✅).
+- Deferred risks:
+ - Walidacja formatu etykiety `PDF/ZPL/EPL` zduplikowana w `createShipment`/`downloadLabel` (orkiestracja) — poza zakresem refaktoru.
+ - Pre-existing rozjazd `ł`/`l` w `testBuildCodPayloadRequiresBankAccount` — naprawa zmienia komunikat uzytkownika, wymaga osobnej zgody/planu.
+- codebase-memory-mcp: 3 481/10 297 (plan) -> 3 514/10 521 (post-apply) -> 3 518/10 535 (po ekstrakcji). jscpd/ast-grep disabled by policy.
+- Raw outputs: `.paul/codebase/radar/`, `.paul/codebase/tooling_status.md`.
+
+## Deviations
+
+- **Rozszerzenie zakresu (zatwierdzone przez uzytkownika w trakcie APPLY):** plan zakladal fasade + 3 klasy z `createShipment` jako orkiestracja. Pierwsza dekompozycja dala fasade 342 / `createShipment` ~150. Na zyczenie uzytkownika dolozono ekstrakcje montazu zadania do `PolkurierShipmentPayloadBuilder::buildShipmentRequest()` oraz rozbicie orkiestracji na `sendCreateRequest` + `persistCreationResult` -> fasada 299, `createShipment` 34.
+- **Tradeoff:** `buildShipmentRequest` ma 86 lin. (zdominowane dwoma plaskimi literalami tablic: `apiPayload` + rekord `shipment_packages`; zero zagniezdzen, niska zlozonosc). Nie rozbito na `buildApiPayload` + `buildPackageRecord`, bo wspoldziela ~10 wartosci posrednich — rozbicie wymusiloby rekalkulacje albo metode z ~10 parametrami (gorszy zapach niz dlugi, plaski literal).
+- **Builder zalezy od katalogu:** `PolkurierShipmentPayloadBuilder` dostaje `PolkurierCarrierCatalog` w konstruktorze, by `resolveCarrierLabel` wykonalo sie w tym samym miejscu co w oryginale (zachowanie kolejnosci i braku dodatkowego callu API w sciezce bledu).
+
+## Key Decisions / Patterns
+
+- Wzorzec projektu potwierdzony: slim fasada + wspolpracownicy, kompozycja w konstruktorze (poor man's DI), zero zmian publicznego kontraktu (jak InvoiceService, DeliveryStatus).
+- Test refleksyjny jako twarde ograniczenie: prywatna metoda pinned przez `ReflectionMethod` zostaje na klasie jako forwarder (analogicznie do statyka `extractBuyerTaxNumber` w InvoiceService).
+- Refaktor czysty: zero zmian funkcjonalnych; wykryty pre-existing defekt (`ł`/`l`) zaraportowany, nie naprawiony.
+
+## Follow-up
+
+- **Reczna weryfikacja (primary gate):** `vendor/bin/phpunit tests/Unit/PolkurierShipmentServiceTest.php` — potwierdzic wynik = baseline (2/3 z pre-existing `ł`/`l`); smoke `POST /orders/{id}/shipment/prepare` (polkurier) + dropdown `/settings/integrations/shoppro`.
+- **Osobny plan (opcjonalny):** ujednolicenie komunikatu COD `ł`/`l` (test vs kod) — male, ale zmienia komunikat uzytkownika.
+- **Kolejni kandydaci z `quality_risks.md`:** `OrdersController.php` (1490), `OrdersRepository.php` (1243), `ShopproIntegrationsController.php` (1076), `ApaczkaShipmentService.php` (1044).
diff --git a/src/Modules/Shipments/PolkurierCarrierCatalog.php b/src/Modules/Shipments/PolkurierCarrierCatalog.php
new file mode 100644
index 0000000..434c786
--- /dev/null
+++ b/src/Modules/Shipments/PolkurierCarrierCatalog.php
@@ -0,0 +1,118 @@
+>|null */
+ private ?array $carriersCache = null;
+
+ public function __construct(
+ private readonly PolkurierIntegrationRepository $integrationRepository,
+ private readonly PolkurierApiClient $apiClient
+ ) {
+ }
+
+ /**
+ * @return array>
+ */
+ public function getDeliveryServices(): array
+ {
+ if ($this->carriersCache !== null) {
+ return $this->carriersCache;
+ }
+
+ $credentials = $this->integrationRepository->getCredentials();
+ if ($credentials === null) {
+ return $this->carriersCache = [];
+ }
+
+ try {
+ $carriers = $this->apiClient->getAvailableCarriers(
+ $credentials['login'],
+ $credentials['api_token']
+ );
+ } catch (Throwable) {
+ return $this->carriersCache = [];
+ }
+
+ // Normalizacja: ujednolicony shape `{id, name, supports_pickup_point, foreign_shipments}`
+ $normalized = [];
+ foreach ($carriers as $carrier) {
+ if (!is_array($carrier)) {
+ continue;
+ }
+ $code = trim((string) ($carrier['servicecode'] ?? $carrier['code'] ?? ''));
+ $name = trim((string) ($carrier['name'] ?? $code));
+ if ($code === '') {
+ continue;
+ }
+ $supportsPoint = $this->detectPickupPointSupport($code, $carrier);
+ $normalized[] = [
+ 'id' => $code,
+ 'name' => $name !== '' ? $name : $code,
+ 'supports_pickup_point' => $supportsPoint,
+ 'point_courier' => $supportsPoint ? $this->resolvePointCourierKey($code) : null,
+ 'foreign_shipments' => !empty($carrier['foreign_shipments']),
+ 'raw' => $carrier,
+ ];
+ }
+
+ usort($normalized, static fn ($a, $b) => strcasecmp((string) $a['name'], (string) $b['name']));
+ return $this->carriersCache = $normalized;
+ }
+
+ public function resolveCarrierLabel(string $courierCode): string
+ {
+ foreach ($this->getDeliveryServices() as $service) {
+ if (strcasecmp((string) ($service['id'] ?? ''), $courierCode) === 0) {
+ return (string) ($service['name'] ?? $courierCode);
+ }
+ }
+ return $courierCode;
+ }
+
+ /**
+ * @param array $carrier
+ */
+ private function detectPickupPointSupport(string $code, array $carrier): bool
+ {
+ $haystack = strtolower($code . ' ' . trim((string) ($carrier['name'] ?? '')));
+ return str_contains($haystack, 'paczkomat')
+ || str_contains($haystack, 'parcel')
+ || str_contains($haystack, 'inpost')
+ || str_contains($haystack, 'orlen')
+ || str_contains($haystack, 'pocztex')
+ || str_contains($haystack, 'kurier48')
+ || str_contains($haystack, 'punkt');
+ }
+
+ private function resolvePointCourierKey(string $code): ?string
+ {
+ $lower = strtolower($code);
+ if (str_contains($lower, 'inpost') || str_contains($lower, 'paczkomat')) {
+ return 'inpost';
+ }
+ if (str_contains($lower, 'orlen')) {
+ return 'orlen';
+ }
+ if (str_contains($lower, 'pocztex')) {
+ return 'pocztex';
+ }
+ if (str_contains($lower, 'kurier48')) {
+ return 'kurier48';
+ }
+ return null;
+ }
+}
diff --git a/src/Modules/Shipments/PolkurierResponseParser.php b/src/Modules/Shipments/PolkurierResponseParser.php
new file mode 100644
index 0000000..eca55e3
--- /dev/null
+++ b/src/Modules/Shipments/PolkurierResponseParser.php
@@ -0,0 +1,113 @@
+extractLabelBase64($response[0]);
+ }
+ }
+ return '';
+ }
+
+ /**
+ * polkurier create_order zwraca Order entity. Numer zamówienia jest w polu 'number'
+ * (zmapowańe z setNumber() w SDK Order.php). Fallbacki dla mozliwych wariantow.
+ *
+ * @param array $response
+ */
+ public function extractOrderNumber(array $response): string
+ {
+ // Czasami polkurier opakowuje w {order: {...}} lub zwraca listę
+ if (isset($response['order']) && is_array($response['order'])) {
+ $inner = $this->extractOrderNumber($response['order']);
+ if ($inner !== '') {
+ return $inner;
+ }
+ }
+ if (isset($response[0]) && is_array($response[0])) {
+ $inner = $this->extractOrderNumber($response[0]);
+ if ($inner !== '') {
+ return $inner;
+ }
+ }
+
+ foreach (['number', 'orderno', 'order_no', 'order_number', 'order_id', 'id'] as $key) {
+ $value = trim((string) ($response[$key] ?? ''));
+ if ($value !== '') {
+ return $value;
+ }
+ }
+ return '';
+ }
+
+ /**
+ * Waybill polkurier zazwyczaj w `waybills[0].number` (OrderWaybill entity).
+ * Fallbacki dla starszych wariantow odpowiedźi.
+ *
+ * @param array $response
+ */
+ public function extractTrackingNumber(array $response, string $orderno): string
+ {
+ if (isset($response['order']) && is_array($response['order'])) {
+ $inner = $this->extractTrackingNumber($response['order'], $orderno);
+ if ($inner !== '') {
+ return $inner;
+ }
+ }
+
+ $waybills = $response['waybills'] ?? $response['waybill'] ?? null;
+ if (is_array($waybills)) {
+ // Lista OrderWaybill
+ if (isset($waybills[0]) && is_array($waybills[0])) {
+ foreach (['number', 'waybillno', 'waybill_number', 'tracking_number'] as $key) {
+ $value = trim((string) ($waybills[0][$key] ?? ''));
+ if ($value !== '') {
+ return $value;
+ }
+ }
+ }
+ // Pojedynczy obiekt
+ foreach (['number', 'waybillno', 'waybill_number', 'tracking_number'] as $key) {
+ $value = trim((string) ($waybills[$key] ?? ''));
+ if ($value !== '') {
+ return $value;
+ }
+ }
+ }
+
+ foreach (['waybillno', 'waybill_number', 'parcel_number', 'tracking_number'] as $key) {
+ $value = trim((string) ($response[$key] ?? ''));
+ if ($value !== '') {
+ return $value;
+ }
+ }
+
+ return $orderno;
+ }
+}
diff --git a/src/Modules/Shipments/PolkurierShipmentPayloadBuilder.php b/src/Modules/Shipments/PolkurierShipmentPayloadBuilder.php
new file mode 100644
index 0000000..c341298
--- /dev/null
+++ b/src/Modules/Shipments/PolkurierShipmentPayloadBuilder.php
@@ -0,0 +1,393 @@
+ $order
+ * @param array $formData
+ * @param array $sender
+ * @return array{api: array, package: array}
+ */
+ public function buildShipmentRequest(
+ array $order,
+ array $formData,
+ array $sender,
+ string $courierCode,
+ int $orderId,
+ string $defaultLabelFormat
+ ): array {
+ $orderData = is_array($order['order'] ?? null) ? $order['order'] : [];
+ $sourceOrderId = trim((string) ($orderData['source_order_id'] ?? ($orderData['external_order_number'] ?? '')));
+ $description = 'orderPRO ' . ($sourceOrderId !== '' ? $sourceOrderId : (string) $orderId);
+
+ $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));
+ $shipmentType = $this->normalizeShipmentType((string) ($formData['package_type'] ?? 'BOX'));
+
+ $receiverPointId = trim((string) ($formData['receiver_point_id'] ?? ''));
+ $recipient = $this->buildRecipient($order, $formData, $receiverPointId);
+ $senderPayload = $this->buildSenderPayload($sender);
+
+ $packs = [[
+ 'length' => (int) round($lengthCm),
+ 'width' => (int) round($widthCm),
+ 'height' => (int) round($heightCm),
+ 'weight' => round($weightKg, 3),
+ 'amount' => 1,
+ 'type' => $shipmentType,
+ ]];
+
+ $pickup = $this->buildPickup($formData);
+
+ $apiPayload = [
+ 'shipmenttype' => $shipmentType,
+ 'courier' => $courierCode,
+ 'description' => $description,
+ 'sender' => $senderPayload,
+ 'recipient' => $recipient,
+ 'packs' => $packs,
+ 'pickup' => $pickup,
+ ];
+
+ $insurance = max(0.0, (float) ($formData['insurance_amount'] ?? 0));
+ if ($insurance > 0) {
+ $apiPayload['insurance'] = round($insurance, 2);
+ }
+
+ $cod = max(0.0, (float) ($formData['cod_amount'] ?? 0));
+ $codPayload = $this->buildCodPayload($formData);
+ if ($codPayload !== null) {
+ $apiPayload['COD'] = $codPayload;
+ }
+
+ $carrierLabel = $this->carrierCatalog->resolveCarrierLabel($courierCode);
+ $labelFormat = strtoupper(trim((string) ($formData['label_format'] ?? $defaultLabelFormat)));
+ if (!in_array($labelFormat, ['PDF', 'ZPL', 'EPL'], true)) {
+ $labelFormat = 'PDF';
+ }
+
+ $package = [
+ 'order_id' => $orderId,
+ 'provider' => 'polkurier',
+ 'delivery_method_id' => $courierCode,
+ 'credentials_id' => null,
+ 'command_id' => null,
+ 'status' => 'pending',
+ 'carrier_id' => $carrierLabel,
+ 'package_type' => $shipmentType,
+ 'weight_kg' => $weightKg,
+ 'length_cm' => $lengthCm,
+ 'width_cm' => $widthCm,
+ 'height_cm' => $heightCm,
+ 'insurance_amount' => $insurance > 0 ? $insurance : null,
+ 'insurance_currency' => 'PLN',
+ 'cod_amount' => $cod > 0 ? $cod : null,
+ 'cod_currency' => $cod > 0 ? 'PLN' : null,
+ 'label_format' => $labelFormat,
+ 'receiver_point_id' => $receiverPointId,
+ 'sender_point_id' => trim((string) ($formData['sender_point_id'] ?? '')),
+ 'reference_number' => $sourceOrderId !== '' ? $sourceOrderId : (string) $orderId,
+ 'payload_json' => $apiPayload,
+ ];
+
+ return ['api' => $apiPayload, 'package' => $package];
+ }
+
+ /**
+ * @param array $sender
+ */
+ public function validateSender(array $sender): void
+ {
+ $required = ['street', 'city', 'postalCode'];
+ foreach ($required as $key) {
+ if (trim((string) ($sender[$key] ?? '')) === '') {
+ throw new ShipmentException('Niekompletne dane nadawcy w Ustawieniach firmy (ulica/miasto/kod pocztowy).');
+ }
+ }
+ $name = trim((string) ($sender['name'] ?? ''));
+ $company = trim((string) ($sender['company'] ?? ''));
+ if ($name === '' && $company === '') {
+ throw new ShipmentException('Niekompletne dane nadawcy w Ustawieniach firmy (imie/nazwisko lub nazwa firmy).');
+ }
+ }
+
+ /**
+ * @param array $sender
+ * @return array
+ */
+ public function buildSenderPayload(array $sender): array
+ {
+ $street = trim((string) ($sender['street'] ?? ''));
+ $parsed = $this->splitStreetAndNumber($street);
+
+ return [
+ 'company' => trim((string) ($sender['company'] ?? '')),
+ 'person' => trim((string) ($sender['contactPerson'] ?? $sender['name'] ?? '')),
+ 'street' => $parsed['street'],
+ 'housenumber' => $parsed['house'],
+ 'flatnumber' => $parsed['flat'],
+ 'postcode' => trim((string) ($sender['postalCode'] ?? '')),
+ 'city' => trim((string) ($sender['city'] ?? '')),
+ 'email' => trim((string) ($sender['email'] ?? '')),
+ 'phone' => $this->normalizePhone((string) ($sender['phone'] ?? '')),
+ 'country' => strtoupper(trim((string) ($sender['countryCode'] ?? 'PL'))) ?: 'PL',
+ ];
+ }
+
+ /**
+ * @param array $order
+ * @param array $formData
+ * @return array
+ */
+ public function buildRecipient(array $order, array $formData, string $receiverPointId): array
+ {
+ $addresses = is_array($order['addresses'] ?? null) ? $order['addresses'] : [];
+ $delivery = [];
+ $customer = [];
+ foreach ($addresses as $addr) {
+ $type = (string) ($addr['address_type'] ?? '');
+ if ($type === 'delivery') {
+ $delivery = is_array($addr) ? $addr : [];
+ } elseif ($type === 'customer') {
+ $customer = is_array($addr) ? $addr : [];
+ }
+ }
+
+ $name = $this->firstNonEmpty([
+ $formData['receiver_name'] ?? null,
+ $delivery['name'] ?? null,
+ $customer['name'] ?? null,
+ $delivery['company_name'] ?? null,
+ $customer['company_name'] ?? null,
+ 'Klient',
+ ]);
+ $company = $this->firstNonEmpty([
+ $formData['receiver_company'] ?? null,
+ $delivery['company_name'] ?? null,
+ $customer['company_name'] ?? null,
+ ]);
+ $streetLine = $this->firstNonEmpty([
+ $formData['receiver_street'] ?? null,
+ $this->composeStreet($delivery),
+ $this->composeStreet($customer),
+ ]);
+ $parsed = $this->splitStreetAndNumber($streetLine);
+ $postcode = $this->firstNonEmpty([
+ $formData['receiver_postal_code'] ?? null,
+ $delivery['zip_code'] ?? null,
+ $customer['zip_code'] ?? null,
+ ]);
+ $city = $this->firstNonEmpty([
+ $formData['receiver_city'] ?? null,
+ $delivery['city'] ?? null,
+ $customer['city'] ?? null,
+ ]);
+ $country = strtoupper($this->firstNonEmpty([
+ $formData['receiver_country_code'] ?? null,
+ $delivery['country'] ?? null,
+ $customer['country'] ?? null,
+ 'PL',
+ ]));
+ $phone = $this->firstNonEmpty([
+ $formData['receiver_phone'] ?? null,
+ $delivery['phone'] ?? null,
+ $customer['phone'] ?? null,
+ ]);
+ $email = $this->firstNonEmpty([
+ $formData['receiver_email'] ?? null,
+ $delivery['email'] ?? null,
+ $customer['email'] ?? null,
+ ]);
+
+ if ($name === '' || $postcode === '' || $city === '') {
+ throw new ShipmentException('Brak wymaganych danych adresowych odbiorcy (imie/kod pocztowy/miasto).');
+ }
+ if ($receiverPointId === '' && $parsed['street'] === '') {
+ throw new ShipmentException('Brak ulicy odbiorcy (wymagana dla usług kurierskich).');
+ }
+
+ return [
+ 'company' => $company,
+ 'person' => $name,
+ 'street' => $parsed['street'],
+ 'housenumber' => $parsed['house'],
+ 'flatnumber' => $parsed['flat'],
+ 'postcode' => $postcode,
+ 'city' => $city,
+ 'email' => $email,
+ 'phone' => $this->normalizePhone($phone),
+ 'country' => $country !== '' ? $country : 'PL',
+ 'point_id' => $receiverPointId,
+ ];
+ }
+
+ /**
+ * @param array $formData
+ * @return array
+ */
+ public function buildPickup(array $formData): array
+ {
+ $date = trim((string) ($formData['pickup_date'] ?? ''));
+ if ($date === '' || preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
+ $date = $this->nextBusinessDay();
+ }
+ $from = trim((string) ($formData['pickup_time_from'] ?? '10:00'));
+ $to = trim((string) ($formData['pickup_time_to'] ?? '16:00'));
+ $noCourierOrder = !empty($formData['no_courier_order']);
+
+ return [
+ 'pickupdate' => $date,
+ 'pickuptimefrom' => $from,
+ 'pickuptimeto' => $to,
+ 'nocourierorder' => $noCourierOrder,
+ ];
+ }
+
+ /**
+ * @param array $formData
+ * @return array{codtype: string, codamount: float, codbankaccount: string, return_cod: string}|null
+ */
+ public function buildCodPayload(array $formData): ?array
+ {
+ $cod = max(0.0, (float) ($formData['cod_amount'] ?? 0));
+ if ($cod <= 0) {
+ return null;
+ }
+
+ $companySettings = $this->companySettings->getSettings();
+ $bankAccount = preg_replace('/[^0-9]/', '', (string) ($companySettings['bank_account'] ?? '')) ?? '';
+ if ($bankAccount === '') {
+ throw new ShipmentException('Przesyłka COD wymaga numeru konta bankowego. Uzupelnij go w Ustawienia > Firma.');
+ }
+
+ return [
+ 'codtype' => 'S',
+ 'codamount' => round($cod, 2),
+ 'codbankaccount' => $bankAccount,
+ 'return_cod' => 'BA',
+ ];
+ }
+
+ /**
+ * polkurier API wymaga lowercase z dozwolonego zbioru:
+ * [box, envelope, palette, small_parcel, parcel_size_20].
+ * Mapuje istniejące orderPRO wartości (PACKAGE/BOX/ENVELOPE/...) na format polkurier.
+ */
+ public function normalizeShipmentType(string $input): string
+ {
+ $raw = strtolower(trim($input));
+ $allowed = ['box', 'envelope', 'palette', 'small_parcel', 'parcel_size_20'];
+ if (in_array($raw, $allowed, true)) {
+ return $raw;
+ }
+
+ $aliases = [
+ 'package' => 'box',
+ 'parcel' => 'box',
+ 'paczka' => 'box',
+ 'koperta' => 'envelope',
+ 'paleta' => 'palette',
+ 'mala_paczka' => 'small_parcel',
+ 'small' => 'small_parcel',
+ ];
+ return $aliases[$raw] ?? 'box';
+ }
+
+ private function nextBusinessDay(): string
+ {
+ $ts = time();
+ do {
+ $ts = strtotime('+1 day', $ts);
+ $dow = (int) date('N', $ts ?: time());
+ } while ($dow >= 6);
+ return date('Y-m-d', $ts ?: time());
+ }
+
+ /**
+ * @return array{street: string, house: string, flat: string}
+ */
+ private function splitStreetAndNumber(string $streetLine): array
+ {
+ $street = trim($streetLine);
+ if ($street === '') {
+ return ['street' => '', 'house' => '', 'flat' => ''];
+ }
+
+ // Wzorce: "Marszalkowska 10/5", "Marszalkowska 10 m. 5", "Marszalkowska 10A"
+ if (preg_match('/^(.*?)\s+(\d+[A-Za-z]?)(?:\s*[\/\-]\s*|\s*m\.?\s*)?(\d+[A-Za-z]?)?$/u', $street, $matches) === 1) {
+ return [
+ 'street' => trim((string) $matches[1]),
+ 'house' => trim((string) ($matches[2] ?? '')),
+ 'flat' => trim((string) ($matches[3] ?? '')),
+ ];
+ }
+
+ return ['street' => $street, 'house' => '', 'flat' => ''];
+ }
+
+ /**
+ * @param array $address
+ */
+ private function composeStreet(array $address): string
+ {
+ $street = trim((string) ($address['street_name'] ?? ''));
+ $number = trim((string) ($address['street_number'] ?? ''));
+ if ($street === '') {
+ return '';
+ }
+ return $number !== '' ? trim($street . ' ' . $number) : $street;
+ }
+
+ /**
+ * @param array $candidates
+ */
+ private function firstNonEmpty(array $candidates): string
+ {
+ foreach ($candidates as $candidate) {
+ $value = trim((string) $candidate);
+ if ($value !== '') {
+ return $value;
+ }
+ }
+ return '';
+ }
+
+ private function normalizePhone(string $phone): string
+ {
+ $digits = preg_replace('/\D+/', '', $phone) ?? '';
+ if ($digits === '') {
+ return '';
+ }
+ // Polkurier akceptuje cyfry. Usuń prefiks 48 jezeli jest podwojny.
+ if (str_starts_with($digits, '48') && strlen($digits) === 11) {
+ $digits = substr($digits, 2);
+ }
+ return $digits;
+ }
+}
diff --git a/src/Modules/Shipments/PolkurierShipmentService.php b/src/Modules/Shipments/PolkurierShipmentService.php
index c1ce0b1..b00dd4e 100644
--- a/src/Modules/Shipments/PolkurierShipmentService.php
+++ b/src/Modules/Shipments/PolkurierShipmentService.php
@@ -14,13 +14,18 @@ use Throwable;
/**
* polkurier.pl ShipmentProvider (Phase 128).
*
- * Tworzy paczki, pobiera etykiety i wystawia dostępne usługi przewoznicze przez API polkurier.
+ * Slim fasada implementujaca ShipmentProviderInterface. Orkiestruje tworzenie przesylki,
+ * pobranie etykiety i sprawdzenie statusu, delegujac szczegoly do wspolpracownikow:
+ * - katalog uslug przewozniczych -> PolkurierCarrierCatalog,
+ * - montaz payloadu API + rekordu paczki -> PolkurierShipmentPayloadBuilder,
+ * - parsowanie odpowiedzi -> PolkurierResponseParser.
* Payload zgodny z SDK polkurier-sdk (zweryfikowany na Sender/Recipient/Pack/Pickup/COD entity klasach).
*/
final class PolkurierShipmentService implements ShipmentProviderInterface
{
- /** @var array>|null */
- private ?array $carriersCache = null;
+ private readonly PolkurierCarrierCatalog $carrierCatalog;
+ private readonly PolkurierShipmentPayloadBuilder $payloadBuilder;
+ private readonly PolkurierResponseParser $responseParser;
public function __construct(
private readonly PolkurierIntegrationRepository $integrationRepository,
@@ -29,6 +34,9 @@ final class PolkurierShipmentService implements ShipmentProviderInterface
private readonly CompanySettingsRepository $companySettings,
private readonly OrdersRepository $ordersRepository
) {
+ $this->carrierCatalog = new PolkurierCarrierCatalog($integrationRepository, $apiClient);
+ $this->payloadBuilder = new PolkurierShipmentPayloadBuilder($companySettings, $this->carrierCatalog);
+ $this->responseParser = new PolkurierResponseParser();
}
public function code(): string
@@ -41,48 +49,7 @@ final class PolkurierShipmentService implements ShipmentProviderInterface
*/
public function getDeliveryServices(): array
{
- if ($this->carriersCache !== null) {
- return $this->carriersCache;
- }
-
- $credentials = $this->integrationRepository->getCredentials();
- if ($credentials === null) {
- return $this->carriersCache = [];
- }
-
- try {
- $carriers = $this->apiClient->getAvailableCarriers(
- $credentials['login'],
- $credentials['api_token']
- );
- } catch (Throwable) {
- return $this->carriersCache = [];
- }
-
- // Normalizacja: ujednolicony shape `{id, name, supports_pickup_point, foreign_shipments}`
- $normalized = [];
- foreach ($carriers as $carrier) {
- if (!is_array($carrier)) {
- continue;
- }
- $code = trim((string) ($carrier['servicecode'] ?? $carrier['code'] ?? ''));
- $name = trim((string) ($carrier['name'] ?? $code));
- if ($code === '') {
- continue;
- }
- $supportsPoint = $this->detectPickupPointSupport($code, $carrier);
- $normalized[] = [
- 'id' => $code,
- 'name' => $name !== '' ? $name : $code,
- 'supports_pickup_point' => $supportsPoint,
- 'point_courier' => $supportsPoint ? $this->resolvePointCourierKey($code) : null,
- 'foreign_shipments' => !empty($carrier['foreign_shipments']),
- 'raw' => $carrier,
- ];
- }
-
- usort($normalized, static fn ($a, $b) => strcasecmp((string) $a['name'], (string) $b['name']));
- return $this->carriersCache = $normalized;
+ return $this->carrierCatalog->getDeliveryServices();
}
/**
@@ -98,7 +65,7 @@ final class PolkurierShipmentService implements ShipmentProviderInterface
$credentials = $this->requireCredentials();
$sender = $this->companySettings->getSenderAddress();
- $this->validateSender($sender);
+ $this->payloadBuilder->validateSender($sender);
$courierCode = strtoupper(trim((string) (
$formData['service_code']
@@ -109,84 +76,32 @@ final class PolkurierShipmentService implements ShipmentProviderInterface
throw new ShipmentException('Nie wybrano usługi polkurier (servicecode).');
}
- $orderData = is_array($order['order'] ?? null) ? $order['order'] : [];
- $sourceOrderId = trim((string) ($orderData['source_order_id'] ?? ($orderData['external_order_number'] ?? '')));
- $description = 'orderPRO ' . ($sourceOrderId !== '' ? $sourceOrderId : (string) $orderId);
+ $request = $this->payloadBuilder->buildShipmentRequest(
+ $order,
+ $formData,
+ $sender,
+ $courierCode,
+ $orderId,
+ (string) ($credentials['default_label_format'] ?? 'PDF')
+ );
- $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));
- $shipmentType = $this->normalizeShipmentType((string) ($formData['package_type'] ?? 'BOX'));
+ $packageId = $this->packages->create($request['package']);
+ $response = $this->sendCreateRequest($credentials, $request['api'], $packageId);
- $receiverPointId = trim((string) ($formData['receiver_point_id'] ?? ''));
- $recipient = $this->buildRecipient($order, $formData, $receiverPointId);
- $senderPayload = $this->buildSenderPayload($sender);
-
- $packs = [[
- 'length' => (int) round($lengthCm),
- 'width' => (int) round($widthCm),
- 'height' => (int) round($heightCm),
- 'weight' => round($weightKg, 3),
- 'amount' => 1,
- 'type' => $shipmentType,
- ]];
-
- $pickup = $this->buildPickup($formData);
-
- $apiPayload = [
- 'shipmenttype' => $shipmentType,
- 'courier' => $courierCode,
- 'description' => $description,
- 'sender' => $senderPayload,
- 'recipient' => $recipient,
- 'packs' => $packs,
- 'pickup' => $pickup,
- ];
-
- $insurance = max(0.0, (float) ($formData['insurance_amount'] ?? 0));
- if ($insurance > 0) {
- $apiPayload['insurance'] = round($insurance, 2);
- }
-
- $cod = max(0.0, (float) ($formData['cod_amount'] ?? 0));
- $codPayload = $this->buildCodPayload($formData);
- if ($codPayload !== null) {
- $apiPayload['COD'] = $codPayload;
- }
-
- $carrierLabel = $this->resolveCarrierLabel($courierCode);
- $labelFormat = strtoupper(trim((string) ($formData['label_format'] ?? $credentials['default_label_format'] ?? 'PDF')));
- if (!in_array($labelFormat, ['PDF', 'ZPL', 'EPL'], true)) {
- $labelFormat = 'PDF';
- }
-
- $packageId = $this->packages->create([
- 'order_id' => $orderId,
- 'provider' => 'polkurier',
- 'delivery_method_id' => $courierCode,
- 'credentials_id' => null,
- 'command_id' => null,
- 'status' => 'pending',
- 'carrier_id' => $carrierLabel,
- 'package_type' => $shipmentType,
- 'weight_kg' => $weightKg,
- 'length_cm' => $lengthCm,
- 'width_cm' => $widthCm,
- 'height_cm' => $heightCm,
- 'insurance_amount' => $insurance > 0 ? $insurance : null,
- 'insurance_currency' => 'PLN',
- 'cod_amount' => $cod > 0 ? $cod : null,
- 'cod_currency' => $cod > 0 ? 'PLN' : null,
- 'label_format' => $labelFormat,
- 'receiver_point_id' => $receiverPointId,
- 'sender_point_id' => trim((string) ($formData['sender_point_id'] ?? '')),
- 'reference_number' => $sourceOrderId !== '' ? $sourceOrderId : (string) $orderId,
- 'payload_json' => $apiPayload,
- ]);
+ return $this->persistCreationResult($packageId, $response);
+ }
+ /**
+ * Wywoluje create_order; na bledzie oznacza paczke jako error i przerzuca ShipmentException.
+ *
+ * @param array{login: string, api_token: string, default_label_format: string, integration_id: int} $credentials
+ * @param array $apiPayload
+ * @return array
+ */
+ private function sendCreateRequest(array $credentials, array $apiPayload, int $packageId): array
+ {
try {
- $response = $this->apiClient->createShipment(
+ return $this->apiClient->createShipment(
$credentials['login'],
$credentials['api_token'],
$apiPayload
@@ -199,9 +114,18 @@ final class PolkurierShipmentService implements ShipmentProviderInterface
]);
throw new ShipmentException($message, 0, $exception);
}
+ }
- $orderno = $this->extractOrderNumber($response);
- $tracking = $this->extractTrackingNumber($response, $orderno);
+ /**
+ * Parsuje odpowiedz create_order, aktualizuje rekord paczki i probuje od razu pobrac etykiete.
+ *
+ * @param array $response
+ * @return array
+ */
+ private function persistCreationResult(int $packageId, array $response): array
+ {
+ $orderno = $this->responseParser->extractOrderNumber($response);
+ $tracking = $this->responseParser->extractTrackingNumber($response, $orderno);
if ($orderno === '') {
// Diagnostyka — polkurier zwrócił odpowiedź ale bez rozpoznawalnego pola order number.
@@ -310,7 +234,7 @@ final class PolkurierShipmentService implements ShipmentProviderInterface
throw new ShipmentException('polkurier get_label: ' . $exception->getMessage(), 0, $exception);
}
- $base64 = $this->extractLabelBase64($response);
+ $base64 = $this->responseParser->extractLabelBase64($response);
if ($base64 === '') {
throw new ShipmentException('polkurier nie zwrócił danych etykiety.');
}
@@ -341,106 +265,6 @@ final class PolkurierShipmentService implements ShipmentProviderInterface
];
}
- /**
- * polkurier get_label zwraca base64 PDF pod kluczem 'file' (zweryfikowane w SDK GetLabel.php).
- */
- private function extractLabelBase64(mixed $response): string
- {
- if (is_string($response)) {
- return trim($response);
- }
- if (is_array($response)) {
- foreach (['file', 'label', 'pdf', 'data', 'content', 'zpl', 'epl'] as $key) {
- if (isset($response[$key]) && is_string($response[$key])) {
- $candidate = trim((string) $response[$key]);
- if ($candidate !== '') {
- return $candidate;
- }
- }
- }
- if (isset($response[0]) && is_array($response[0])) {
- return $this->extractLabelBase64($response[0]);
- }
- }
- return '';
- }
-
- /**
- * polkurier create_order zwraca Order entity. Numer zamówienia jest w polu 'number'
- * (zmapowańe z setNumber() w SDK Order.php). Fallbacki dla mozliwych wariantow.
- *
- * @param array $response
- */
- private function extractOrderNumber(array $response): string
- {
- // Czasami polkurier opakowuje w {order: {...}} lub zwraca listę
- if (isset($response['order']) && is_array($response['order'])) {
- $inner = $this->extractOrderNumber($response['order']);
- if ($inner !== '') {
- return $inner;
- }
- }
- if (isset($response[0]) && is_array($response[0])) {
- $inner = $this->extractOrderNumber($response[0]);
- if ($inner !== '') {
- return $inner;
- }
- }
-
- foreach (['number', 'orderno', 'order_no', 'order_number', 'order_id', 'id'] as $key) {
- $value = trim((string) ($response[$key] ?? ''));
- if ($value !== '') {
- return $value;
- }
- }
- return '';
- }
-
- /**
- * Waybill polkurier zazwyczaj w `waybills[0].number` (OrderWaybill entity).
- * Fallbacki dla starszych wariantow odpowiedźi.
- *
- * @param array $response
- */
- private function extractTrackingNumber(array $response, string $orderno): string
- {
- if (isset($response['order']) && is_array($response['order'])) {
- $inner = $this->extractTrackingNumber($response['order'], $orderno);
- if ($inner !== '') {
- return $inner;
- }
- }
-
- $waybills = $response['waybills'] ?? $response['waybill'] ?? null;
- if (is_array($waybills)) {
- // Lista OrderWaybill
- if (isset($waybills[0]) && is_array($waybills[0])) {
- foreach (['number', 'waybillno', 'waybill_number', 'tracking_number'] as $key) {
- $value = trim((string) ($waybills[0][$key] ?? ''));
- if ($value !== '') {
- return $value;
- }
- }
- }
- // Pojedynczy obiekt
- foreach (['number', 'waybillno', 'waybill_number', 'tracking_number'] as $key) {
- $value = trim((string) ($waybills[$key] ?? ''));
- if ($value !== '') {
- return $value;
- }
- }
- }
-
- foreach (['waybillno', 'waybill_number', 'parcel_number', 'tracking_number'] as $key) {
- $value = trim((string) ($response[$key] ?? ''));
- if ($value !== '') {
- return $value;
- }
- }
-
- return $orderno;
- }
-
private function resolveStorageRoot(): string
{
$root = dirname(__DIR__, 3) . '/storage';
@@ -460,317 +284,16 @@ final class PolkurierShipmentService implements ShipmentProviderInterface
}
/**
- * @param array $sender
- */
- private function validateSender(array $sender): void
- {
- $required = ['street', 'city', 'postalCode'];
- foreach ($required as $key) {
- if (trim((string) ($sender[$key] ?? '')) === '') {
- throw new ShipmentException('Niekompletne dane nadawcy w Ustawieniach firmy (ulica/miasto/kod pocztowy).');
- }
- }
- $name = trim((string) ($sender['name'] ?? ''));
- $company = trim((string) ($sender['company'] ?? ''));
- if ($name === '' && $company === '') {
- throw new ShipmentException('Niekompletne dane nadawcy w Ustawieniach firmy (imie/nazwisko lub nazwa firmy).');
- }
- }
-
- /**
- * @param array $sender
- * @return array
- */
- private function buildSenderPayload(array $sender): array
- {
- $street = trim((string) ($sender['street'] ?? ''));
- $parsed = $this->splitStreetAndNumber($street);
-
- return [
- 'company' => trim((string) ($sender['company'] ?? '')),
- 'person' => trim((string) ($sender['contactPerson'] ?? $sender['name'] ?? '')),
- 'street' => $parsed['street'],
- 'housenumber' => $parsed['house'],
- 'flatnumber' => $parsed['flat'],
- 'postcode' => trim((string) ($sender['postalCode'] ?? '')),
- 'city' => trim((string) ($sender['city'] ?? '')),
- 'email' => trim((string) ($sender['email'] ?? '')),
- 'phone' => $this->normalizePhone((string) ($sender['phone'] ?? '')),
- 'country' => strtoupper(trim((string) ($sender['countryCode'] ?? 'PL'))) ?: 'PL',
- ];
- }
-
- /**
- * @param array $order
- * @param array $formData
- * @return array
- */
- private function buildRecipient(array $order, array $formData, string $receiverPointId): array
- {
- $addresses = is_array($order['addresses'] ?? null) ? $order['addresses'] : [];
- $delivery = [];
- $customer = [];
- foreach ($addresses as $addr) {
- $type = (string) ($addr['address_type'] ?? '');
- if ($type === 'delivery') {
- $delivery = is_array($addr) ? $addr : [];
- } elseif ($type === 'customer') {
- $customer = is_array($addr) ? $addr : [];
- }
- }
-
- $name = $this->firstNonEmpty([
- $formData['receiver_name'] ?? null,
- $delivery['name'] ?? null,
- $customer['name'] ?? null,
- $delivery['company_name'] ?? null,
- $customer['company_name'] ?? null,
- 'Klient',
- ]);
- $company = $this->firstNonEmpty([
- $formData['receiver_company'] ?? null,
- $delivery['company_name'] ?? null,
- $customer['company_name'] ?? null,
- ]);
- $streetLine = $this->firstNonEmpty([
- $formData['receiver_street'] ?? null,
- $this->composeStreet($delivery),
- $this->composeStreet($customer),
- ]);
- $parsed = $this->splitStreetAndNumber($streetLine);
- $postcode = $this->firstNonEmpty([
- $formData['receiver_postal_code'] ?? null,
- $delivery['zip_code'] ?? null,
- $customer['zip_code'] ?? null,
- ]);
- $city = $this->firstNonEmpty([
- $formData['receiver_city'] ?? null,
- $delivery['city'] ?? null,
- $customer['city'] ?? null,
- ]);
- $country = strtoupper($this->firstNonEmpty([
- $formData['receiver_country_code'] ?? null,
- $delivery['country'] ?? null,
- $customer['country'] ?? null,
- 'PL',
- ]));
- $phone = $this->firstNonEmpty([
- $formData['receiver_phone'] ?? null,
- $delivery['phone'] ?? null,
- $customer['phone'] ?? null,
- ]);
- $email = $this->firstNonEmpty([
- $formData['receiver_email'] ?? null,
- $delivery['email'] ?? null,
- $customer['email'] ?? null,
- ]);
-
- if ($name === '' || $postcode === '' || $city === '') {
- throw new ShipmentException('Brak wymaganych danych adresowych odbiorcy (imie/kod pocztowy/miasto).');
- }
- if ($receiverPointId === '' && $parsed['street'] === '') {
- throw new ShipmentException('Brak ulicy odbiorcy (wymagana dla usług kurierskich).');
- }
-
- return [
- 'company' => $company,
- 'person' => $name,
- 'street' => $parsed['street'],
- 'housenumber' => $parsed['house'],
- 'flatnumber' => $parsed['flat'],
- 'postcode' => $postcode,
- 'city' => $city,
- 'email' => $email,
- 'phone' => $this->normalizePhone($phone),
- 'country' => $country !== '' ? $country : 'PL',
- 'point_id' => $receiverPointId,
- ];
- }
-
- /**
- * @param array $formData
- * @return array
- */
- private function buildPickup(array $formData): array
- {
- $date = trim((string) ($formData['pickup_date'] ?? ''));
- if ($date === '' || preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
- $date = $this->nextBusinessDay();
- }
- $from = trim((string) ($formData['pickup_time_from'] ?? '10:00'));
- $to = trim((string) ($formData['pickup_time_to'] ?? '16:00'));
- $noCourierOrder = !empty($formData['no_courier_order']);
-
- return [
- 'pickupdate' => $date,
- 'pickuptimefrom' => $from,
- 'pickuptimeto' => $to,
- 'nocourierorder' => $noCourierOrder,
- ];
- }
-
- /**
+ * Forwarder do PolkurierShipmentPayloadBuilder::buildCodPayload.
+ *
+ * MUSI pozostac prywatna metoda na tej klasie — PolkurierShipmentServiceTest siega po nia przez
+ * ReflectionMethod(PolkurierShipmentService::class, 'buildCodPayload'). Nie usuwac ani nie zmieniac sygnatury.
+ *
* @param array $formData
* @return array{codtype: string, codamount: float, codbankaccount: string, return_cod: string}|null
*/
private function buildCodPayload(array $formData): ?array
{
- $cod = max(0.0, (float) ($formData['cod_amount'] ?? 0));
- if ($cod <= 0) {
- return null;
- }
-
- $companySettings = $this->companySettings->getSettings();
- $bankAccount = preg_replace('/[^0-9]/', '', (string) ($companySettings['bank_account'] ?? '')) ?? '';
- if ($bankAccount === '') {
- throw new ShipmentException('Przesyłka COD wymaga numeru konta bankowego. Uzupelnij go w Ustawienia > Firma.');
- }
-
- return [
- 'codtype' => 'S',
- 'codamount' => round($cod, 2),
- 'codbankaccount' => $bankAccount,
- 'return_cod' => 'BA',
- ];
- }
-
- private function nextBusinessDay(): string
- {
- $ts = time();
- do {
- $ts = strtotime('+1 day', $ts);
- $dow = (int) date('N', $ts ?: time());
- } while ($dow >= 6);
- return date('Y-m-d', $ts ?: time());
- }
-
- /**
- * @return array{street: string, house: string, flat: string}
- */
- private function splitStreetAndNumber(string $streetLine): array
- {
- $street = trim($streetLine);
- if ($street === '') {
- return ['street' => '', 'house' => '', 'flat' => ''];
- }
-
- // Wzorce: "Marszalkowska 10/5", "Marszalkowska 10 m. 5", "Marszalkowska 10A"
- if (preg_match('/^(.*?)\s+(\d+[A-Za-z]?)(?:\s*[\/\-]\s*|\s*m\.?\s*)?(\d+[A-Za-z]?)?$/u', $street, $matches) === 1) {
- return [
- 'street' => trim((string) $matches[1]),
- 'house' => trim((string) ($matches[2] ?? '')),
- 'flat' => trim((string) ($matches[3] ?? '')),
- ];
- }
-
- return ['street' => $street, 'house' => '', 'flat' => ''];
- }
-
- /**
- * @param array $address
- */
- private function composeStreet(array $address): string
- {
- $street = trim((string) ($address['street_name'] ?? ''));
- $number = trim((string) ($address['street_number'] ?? ''));
- if ($street === '') {
- return '';
- }
- return $number !== '' ? trim($street . ' ' . $number) : $street;
- }
-
- /**
- * @param array $candidates
- */
- private function firstNonEmpty(array $candidates): string
- {
- foreach ($candidates as $candidate) {
- $value = trim((string) $candidate);
- if ($value !== '') {
- return $value;
- }
- }
- return '';
- }
-
- private function normalizePhone(string $phone): string
- {
- $digits = preg_replace('/\D+/', '', $phone) ?? '';
- if ($digits === '') {
- return '';
- }
- // Polkurier akceptuje cyfry. Usuń prefiks 48 jezeli jest podwojny.
- if (str_starts_with($digits, '48') && strlen($digits) === 11) {
- $digits = substr($digits, 2);
- }
- return $digits;
- }
-
- /**
- * @param array $carrier
- */
- private function detectPickupPointSupport(string $code, array $carrier): bool
- {
- $haystack = strtolower($code . ' ' . trim((string) ($carrier['name'] ?? '')));
- return str_contains($haystack, 'paczkomat')
- || str_contains($haystack, 'parcel')
- || str_contains($haystack, 'inpost')
- || str_contains($haystack, 'orlen')
- || str_contains($haystack, 'pocztex')
- || str_contains($haystack, 'kurier48')
- || str_contains($haystack, 'punkt');
- }
-
- private function resolvePointCourierKey(string $code): ?string
- {
- $lower = strtolower($code);
- if (str_contains($lower, 'inpost') || str_contains($lower, 'paczkomat')) {
- return 'inpost';
- }
- if (str_contains($lower, 'orlen')) {
- return 'orlen';
- }
- if (str_contains($lower, 'pocztex')) {
- return 'pocztex';
- }
- if (str_contains($lower, 'kurier48')) {
- return 'kurier48';
- }
- return null;
- }
-
- /**
- * polkurier API wymaga lowercase z dozwolonego zbioru:
- * [box, envelope, palette, small_parcel, parcel_size_20].
- * Mapuje istniejące orderPRO wartości (PACKAGE/BOX/ENVELOPE/...) na format polkurier.
- */
- private function normalizeShipmentType(string $input): string
- {
- $raw = strtolower(trim($input));
- $allowed = ['box', 'envelope', 'palette', 'small_parcel', 'parcel_size_20'];
- if (in_array($raw, $allowed, true)) {
- return $raw;
- }
-
- $aliases = [
- 'package' => 'box',
- 'parcel' => 'box',
- 'paczka' => 'box',
- 'koperta' => 'envelope',
- 'paleta' => 'palette',
- 'mala_paczka' => 'small_parcel',
- 'small' => 'small_parcel',
- ];
- return $aliases[$raw] ?? 'box';
- }
-
- private function resolveCarrierLabel(string $courierCode): string
- {
- foreach ($this->getDeliveryServices() as $service) {
- if (strcasecmp((string) ($service['id'] ?? ''), $courierCode) === 0) {
- return (string) ($service['name'] ?? $courierCode);
- }
- }
- return $courierCode;
+ return $this->payloadBuilder->buildCodPayload($formData);
}
}