This commit is contained in:
2026-05-20 23:35:20 +02:00
parent aeb6fb6df2
commit a7965f1aab
12 changed files with 1135 additions and 535 deletions

View File

@@ -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).

View File

@@ -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`

View File

@@ -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 |

View File

@@ -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`. |

View File

@@ -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

View File

@@ -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`

View File

@@ -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
---
<objective>
## 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.
</objective>
<context>
## 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).
</context>
<clarifications>
- 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')`.
</clarifications>
<impact_scan>
## 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).
</impact_scan>
<skills>
Brak `.paul/SPECIAL-FLOWS.md` — sekcja skills pominieta.
</skills>
<acceptance_criteria>
## 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)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Wydzielic PolkurierCarrierCatalog (katalog uslug przewozniczych)</name>
<files>src/Modules/Shipments/PolkurierCarrierCatalog.php (nowy), src/Modules/Shipments/PolkurierShipmentService.php</files>
<action>
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(...)`.
</action>
<verify>php -l src/Modules/Shipments/PolkurierCarrierCatalog.php; php -l src/Modules/Shipments/PolkurierShipmentService.php</verify>
<done>AC-1 (getDeliveryServices dziala przez delegacje), AC-4 (cache + ksztalt uslug identyczne), AC-5.</done>
</task>
<task type="auto">
<name>Task 2: Wydzielic PolkurierShipmentPayloadBuilder (budowa payloadu + helpery adresowe)</name>
<files>src/Modules/Shipments/PolkurierShipmentPayloadBuilder.php (nowy), src/Modules/Shipments/PolkurierShipmentService.php</files>
<action>
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).
</action>
<verify>php -l src/Modules/Shipments/PolkurierShipmentPayloadBuilder.php; php -l src/Modules/Shipments/PolkurierShipmentService.php</verify>
<done>AC-2 (forwarder zachowany), AC-3 (kolejnosc COD + komunikat), AC-4 (payload identyczny), AC-5.</done>
</task>
<task type="auto">
<name>Task 3: Wydzielic PolkurierResponseParser (parsowanie odpowiedzi API)</name>
<files>src/Modules/Shipments/PolkurierResponseParser.php (nowy), src/Modules/Shipments/PolkurierShipmentService.php</files>
<action>
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.
</action>
<verify>php -l src/Modules/Shipments/PolkurierResponseParser.php; php -l src/Modules/Shipments/PolkurierShipmentService.php</verify>
<done>AC-4 (parsowanie order/tracking/label identyczne), AC-5.</done>
</task>
<task type="auto">
<name>Task 4: Domkniecie fasady + dokumentacja techniczna</name>
<files>src/Modules/Shipments/PolkurierShipmentService.php, .paul/codebase/architecture.md, .paul/codebase/quality_risks.md, .paul/codebase/tech_changelog.md</files>
<action>
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).
</action>
<verify>php -l src/Modules/Shipments/PolkurierShipmentService.php; (Measure-Object -Line) na pliku fasady — cel ~200-220 lin.</verify>
<done>AC-1, AC-5; dokumentacja zsynchronizowana zgodnie z CLAUDE.md.</done>
</task>
<task type="auto">
<name>Task 5: Weryfikacja kontraktu i testu (primary gate)</name>
<files>tests/Unit/PolkurierShipmentServiceTest.php (tylko odczyt/uruchomienie — bez modyfikacji)</files>
<action>
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.
</action>
<verify>vendor/bin/phpunit tests/Unit/PolkurierShipmentServiceTest.php (lub udokumentowany fallback offline)</verify>
<done>AC-2, AC-3.</done>
</task>
</tasks>
<boundaries>
## 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 `<impact_scan>`.
- Czysty refaktor: zero zmian funkcjonalnych/poprawek bugow (jesli wykryto pre-existing bug, zaraportowac w SUMMARY, nie naprawiac w tym planie bez zgody).
</boundaries>
<verification>
- [ ] `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 `<impact_scan>`).
</verification>
<success_criteria>
- [ ] 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.
</success_criteria>
<output>
SUMMARY.md path: `.paul/plans/20260520-1615-refactor-polkurier-shipment-service/SUMMARY.md`
Notatka do UNIFY: oznaczyc wiersz `PolkurierShipmentService.php` w `.paul/codebase/quality_risks.md` jako ✅
(776 -> liczba_fasady) z odnosnikiem do tego planu; dopisac delty radaru i tooling_status.md.
</output>

View File

@@ -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).

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Modules\Shipments;
use App\Modules\Settings\PolkurierApiClient;
use App\Modules\Settings\PolkurierIntegrationRepository;
use Throwable;
/**
* Katalog uslug przewozniczych polkurier.
*
* Pobiera i normalizuje liste przewoznikow (z memoizacja per instancja), wykrywa wsparcie
* punktow odbioru oraz rozwiazuje czytelna etykiete przewoznika po jego kodzie uslugi.
*/
final class PolkurierCarrierCatalog
{
/** @var array<int, array<string, mixed>>|null */
private ?array $carriersCache = null;
public function __construct(
private readonly PolkurierIntegrationRepository $integrationRepository,
private readonly PolkurierApiClient $apiClient
) {
}
/**
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $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;
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Modules\Shipments;
/**
* Parser odpowiedzi API polkurier.
*
* Bezstanowa ekstrakcja numeru zamowienia, numeru trackingu i etykiety base64
* z roznych wariantow ksztaltu odpowiedzi (opakowanie w {order:...}, listy, fallbacki kluczy).
*/
final class PolkurierResponseParser
{
/**
* polkurier get_label zwraca base64 PDF pod kluczem 'file' (zweryfikowane w SDK GetLabel.php).
*/
public 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<string, mixed> $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<string, mixed> $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;
}
}

View File

@@ -0,0 +1,393 @@
<?php
declare(strict_types=1);
namespace App\Modules\Shipments;
use App\Core\Exceptions\ShipmentException;
use App\Modules\Settings\CompanySettingsRepository;
/**
* Budowniczy payloadu przesylki polkurier.
*
* Skupia walidacje nadawcy, montaz pelnego zadania przesylki (payload API + rekord shipment_packages),
* budowe poszczegolnych sekcji payloadu (sender/recipient/pickup/COD), normalizacje typu przesylki
* oraz pomocnicze parsowanie danych adresowych.
* CompanySettingsRepository jest potrzebny wylacznie dla numeru konta bankowego (COD);
* PolkurierCarrierCatalog sluzy do rozwiazania czytelnej etykiety przewoznika do rekordu paczki.
*/
final class PolkurierShipmentPayloadBuilder
{
public function __construct(
private readonly CompanySettingsRepository $companySettings,
private readonly PolkurierCarrierCatalog $carrierCatalog
) {
}
/**
* Buduje komplet danych przesylki: payload do API oraz rekord do zapisu w shipment_packages.
* Kolejnosc obliczen (w tym moment rozwiazania etykiety przewoznika) zachowana 1:1 z pierwotnym createShipment.
*
* @param array<string, mixed> $order
* @param array<string, mixed> $formData
* @param array<string, mixed> $sender
* @return array{api: array<string, mixed>, package: array<string, mixed>}
*/
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<string, mixed> $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<string, mixed> $sender
* @return array<string, mixed>
*/
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<string, mixed> $order
* @param array<string, mixed> $formData
* @return array<string, mixed>
*/
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<string, mixed> $formData
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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<int, mixed> $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;
}
}

View File

@@ -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<int, array<string, mixed>>|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);
return $this->persistCreationResult($packageId, $response);
}
$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,
]);
/**
* 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<string, mixed> $apiPayload
* @return array<string, mixed>
*/
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<string, mixed> $response
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $sender
* @return array<string, mixed>
*/
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<string, mixed> $order
* @param array<string, mixed> $formData
* @return array<string, mixed>
*/
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<string, mixed> $formData
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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<int, mixed> $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<string, mixed> $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);
}
}