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. + + + +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. + 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); } }