feat(128): polkurier shipment service + tracking + UI prepare

PolkurierApiClient rozszerzony do pelnego kontraktu (7 metod):
createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers/
getInpostParcelMachines/getCourierPoints. Wspolny call() parsuje
envelope {status, response}. Kontrakt zweryfikowany na oficjalnej
dokumentacji PDF v1.11.

PolkurierShipmentService (implements ShipmentProviderInterface)
orchestruje pelen flow: normalizeShipmentType (lowercase), split
ulicy, build recipient/sender/pickup, COD z bank account z
company_settings, extractOrderNumber/extractTrackingNumber
priorytetujace SDK Order entity (number, waybills[0].number).

PolkurierTrackingService (implements ShipmentTrackingInterface)
mapuje statusy O/P/A/WP/D/Z/W przez delivery_status_mappings.

UI panel polkurier w prepare.php z dynamiczna lista uslug z
available_carriers. Bez dedykowanego selektora punktu — operator
wpisuje receiver_point_id w istniejace pole w sekcji Adres odbiorcy.

Migracja 20260514_000115 seedujaca 7 wpisow delivery_status_mappings
z oficjalnej tabeli ORDER_STATUS (O/P/A/WP/D/Z/W).

Live test #114/#115 zakonczony sukcesem po 4 iteracjach
(ReferenceError -> uppercase shipmenttype -> orderno parsing ->
A4/A6 etykieta). Rozmiar etykiety A4/A6 sterowany w panelu klienta
polkurier.pl, NIE przez API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:56:36 +02:00
parent 3443879f59
commit c78ac335ee
19 changed files with 5011 additions and 102 deletions

View File

@@ -378,6 +378,80 @@ tests/
- `apaczka_integration_settings`, `ApaczkaShipmentService`, `ApaczkaTrackingService` — Apaczka netknieta, dziala rownolegle.
- `delivery_status_mappings` — brak nowych wpisow `provider='polkurier'` (dolozone razem z tracking service w kolejnej fazie).
## Phase 128 — polkurier ShipmentService + Tracking + UI prepare
### PolkurierApiClient (Phase 128 extension)
- 6 nowych metod publicznych obok zachowanego `testConnection()`:
- `getAvailableCarriers($login, $token)``apimetod=available_carriers`. Zwraca tablice przewoznikow z polami `servicecode`, `name`, `additional_data`, `foreign_shipments`. Konsumowane przez `PolkurierShipmentService::getDeliveryServices()`.
- `createShipment($login, $token, $payload)``apimetod=create_order`. Payload zgodny z oficjalna doca PDF v1.11 (zweryfikowany): `shipmenttype` (lowercase: box/envelope/palette/small_parcel/parcel_size_20), `courier` (servicecode), `description`, `sender`/`recipient` (company/person/street/housenumber/flatnumber/postcode/city/email/phone/country/point_id), `packs[]` (length/width/height/weight/amount/type), `pickup` (pickupdate/pickuptimefrom/pickuptimeto/nocourierorder), opcjonalnie `COD` i `insurance`.
- `getLabel($login, $token, $orderno)``apimetod=get_label`. **API przyjmuje WYLACZNIE `orderno: Array<String>`** (zweryfikowane w dokumentacji PDF). Rozmiar etykiety A4 vs A6 sterowany jest w panelu klienta polkurier.pl (Ustawienia konta → Preferencje etykiet), NIE przez API. Odpowiedz: `{file: <base64 PDF>}`.
- `getStatus($login, $token, $orderno)``apimetod=get_status`. Zwraca `{url, status_date, status, status_code, delivered_date}`. Kody w tabeli ORDER_STATUS (O/P/A/WP/D/Z/W).
- `cancelOrder($login, $token, $orderno)``apimetod=cancel_order`. Zwraca `{cancellation: true}`. Nie wywolywane przez nasz kod w Phase 128 (operator anuluje w panelu polkuriera).
- `getInpostParcelMachines` + `getCourierPoints` — stuby na przyszle rozszerzenie UI (panel paczkomatow). Aktualnie nie wykorzystywane w UI (operator wpisuje `receiver_point_id` recznie w sekcji Adres odbiorcy).
- Wspolny prywatny `call($apimetod, $data, $login, $token): mixed` parsuje envelope `{status, response}`. Sukces -> zwraca tresc `response`. Blad -> rzuca `RuntimeException` z trescia z `response` (string albo zserializowany JSON dla tablic).
### PolkurierShipmentService (`src/Modules/Shipments/PolkurierShipmentService.php`)
- `final class implements ShipmentProviderInterface` (`code()='polkurier'`). DI: `PolkurierIntegrationRepository`, `PolkurierApiClient`, `ShipmentPackageRepository`, `CompanySettingsRepository`, `OrdersRepository`.
- `getDeliveryServices()` cache'uje per-request liste z `available_carriers`, normalizuje do `[{id, name, supports_pickup_point, point_courier, foreign_shipments, raw}]`. Pole `supports_pickup_point` to heurystyka po `servicecode`/`name` (paczkomat/parcel/inpost/orlen/pocztex/kurier48/punkt).
- `createShipment($orderId, $formData)` orchestruje pelny flow:
1. Walidacja: order istnieje, `service_code`/`delivery_method_id` niepusty, credentials aktywne, sender ma street/city/postcode + name|company.
2. Mapowanie `package_type` przez `normalizeShipmentType()` na zbior `[box, envelope, palette, small_parcel, parcel_size_20]` (lowercase, aliases dla PACKAGE/PARCEL/PACZKA/...).
3. Buduje `recipient` z `order_addresses` (delivery → fallback customer) + override z formularza. Splituje ulice na `street`/`housenumber`/`flatnumber` regexem (`Marszalkowska 10/5` → street="Marszalkowska", house="10", flat="5").
4. `pickup` default: `nextBusinessDay()` + 10:00-16:00, `nocourierorder=false` (override mozliwy przez formularz: `pickup_date`, `pickup_time_from`, `pickup_time_to`, `no_courier_order`).
5. `COD` jezeli `cod_amount > 0` (codtype='transfer', codbankaccount z `company_settings.bank_account` po stripowaniu nie-cyfr; throw `ShipmentException` jezeli pusty).
6. INSERT do `shipment_packages` (provider='polkurier', status='pending', payload_json z pelnym requestem).
7. Wywolanie `apiClient->createShipment()`. On success: parsing przez `extractOrderNumber()` (priorytet `number` → Order entity z SDK, fallback `orderno`/`order_no`/`order_number`/`order_id`/`id`, obsluga wrappera `{order:{...}}` i list) i `extractTrackingNumber()` (priorytet `waybills[0].number` → OrderWaybill entity, fallback top-level klucze).
8. UPDATE `shipment_packages`: `status='created'`, `shipment_id=command_id=orderno`, `tracking_number`.
9. Synchroniczna proba `downloadLabel()` (niekrytyczna — przy bledzie ignoruje, operator klikni "Pobierz" pozniej).
10. Diagnostyka: gdy `orderno=''` mimo `status=success`, zapisuje fragment surowej odpowiedzi (400 znakow) do `shipment_packages.error_message` dla debug.
- `downloadLabel($packageId, $storagePath)`: wywoluje `get_label`, parsuje `extractLabelBase64()` (priorytet klucz `file` — zweryfikowane w SDK GetLabel.php, fallback `label`/`pdf`/`data`/`content`/`zpl`/`epl`), `base64_decode`, zapis do `storage/labels/polkurier_{packageId}_{orderno}.pdf`, UPDATE `label_path`.
- `checkCreationStatus($packageId)`: graceful — przy bledzie API zwraca zachowany `tracking_number` z DB (cron sam zaktualizuje przez TrackingService).
### PolkurierTrackingService (`src/Modules/Shipments/PolkurierTrackingService.php`)
- `final class implements ShipmentTrackingInterface`. DI: `PolkurierApiClient`, `PolkurierIntegrationRepository`, `DeliveryStatusMappingRepository`.
- `supports('polkurier')`. `getDeliveryStatus($package)` woła `get_status` po `shipment_id`/`command_id` (orderno), parsuje `status_code` z `response` (z obsluga listy w response[0]).
- Mapowanie surowego `status_code` (O/P/A/WP/D/Z/W) → znormalizowany przez `DeliveryStatus::normalizeWithOverrides('polkurier', $rawStatus, $overrides)` z DB. Seed mapowan w migracji `20260514_000115`.
- Odporny na bledy: brak credentials → `null` (skip), wyjatek API → `null`, brak `status_code``null`. Cron nie crashuje.
### DeliveryStatus::trackingUrl (Phase 128 patch)
- Carrier_id routing (DPD/UPS/GLS/InPost/Pocztex/...) dziala dla polkuriera automatycznie przez istniejacy `matchCarrierByName($encoded, $carrier)` (carrier_id ustawiany na servicecode z polkuriera, np. "INPOST", "DPD" — pasuje do substring matchu).
- Fallback dla provider='polkurier' bez carrier matchu: `https://polkurier.pl/sledz-paczke/<tracking>`.
### Wiring
- `routes/web.php`: `new PolkurierShipmentService(...)` zarejestrowany w `ShipmentProviderRegistry` obok Apaczki/InPost/AllegroWZA.
- `src/Modules/Cron/CronHandlerFactory.php`: `new PolkurierTrackingService(...)` w `ShipmentTrackingRegistry` w `shipment_tracking_sync` handler.
- `src/Modules/Shipments/ShipmentController.php`: `prepare()` fetchuje `polkurierServices` przez registry i przekazuje do widoku. `create()` rozszerzony o `service_code`/`pickup_date`/`pickup_time_from`/`pickup_time_to` przekazywane do `createShipment()`.
### UI prepare panel (`resources/views/shipments/prepare.php`)
- Opcja "polkurier" w dropdownie `#shipment-carrier-select` (obok Allegro/InPost/Apaczka).
- `<div id="shipment-polkurier-panel">` z dynamicznym `<select id="shipment-polkurier-select">` (lista uslug z `available_carriers`).
- `<input type="hidden" name="service_code">` synchronizowany z polkurier select przez `syncPolkurierFields()`.
- Brak dedykowanego selektora punktu odbioru — operator wpisuje `receiver_point_id` w istniejacy text input w sekcji Adres odbiorcy (np. `POP-RZE54`). Format string `POP-RZE54 | Lukasiewicza 78, 35-604 Rzeszow` z importu zamowienia nie jest parsowany — operator skraca recznie.
- JS toggle widocznosci paneli rozszerzony o polkurier; `clearHiddenFields()` czysci `service_code`; `showPanel('polkurier')` ustawia `provider_code='polkurier'`.
### Rozmiar etykiety A4 vs A6
- API polkurier nie udostepnia parametru sterowania rozmiarem etykiety w `get_label` ani `create_order` (zweryfikowane w PDF v1.11).
- Domyslny rozmiar ustawiany jest w **panelu klienta polkurier.pl → Ustawienia konta → Preferencje etykiet** (per-konto, globalnie dla wszystkich `get_label` calli).
- `polkurier_integration_settings.default_label_format` (PDF/ZPL/EPL) sluzy tylko typowi pliku, NIE rozmiarowi.
### Seed delivery_status_mappings (`20260514_000115_seed_polkurier_delivery_status_mappings.sql`)
- 7 wpisow `provider='polkurier'` (kody z oficjalnej tabeli ORDER_STATUS w PDF v1.11):
- `O``created` (Oczekuje na platnosc)
- `P``confirmed` (Potwierdzone, list wygenerowany)
- `A``cancelled` (Anulowane)
- `WP``in_transit` (W przewozie)
- `D``delivered` (Dostarczona)
- `Z``returned` (Zwrot do nadawcy)
- `W``problem` (Wyjatek)
- Idempotentne: `ON DUPLICATE KEY UPDATE normalized_status / description / updated_at`.
### Boundaries / co NIE zostalo zmienione
- Apaczka (`ApaczkaShipmentService`, `ApaczkaTrackingService`, `apaczka_integration_settings`) niezalezna, dziala obok polkuriera.
- `ShipmentProviderInterface` i `ShipmentTrackingInterface` kontrakty niezmienione.
- `getInpostParcelMachines`/`getCourierPoints` w API client zaimplementowane ale nieuzywane przez UI w Phase 128 (operator wpisuje punkt recznie).
- `cancelOrder` zaimplementowane w API client ale nie wywolywane z UI/cron — operator anuluje w panelu polkuriera.
- Brak presetow przesylek dla polkuriera (`shipment_presets.provider_code='polkurier'`) — kolejna faza.
## Phase 121 - SMSPLANET Conversation + Notifications
### SmsConversationService (`src/Modules/Sms/SmsConversationService.php`)

View File

@@ -459,6 +459,18 @@ Indexes: `shipment_packages_order_idx`, `shipment_packages_status_idx`, `shipmen
UNIQUE: `(provider, raw_status)`
**Seedowane mapowania:**
- `provider='inpost'`, `provider='apaczka'`, `provider='allegro_wza'` — w `DeliveryStatus.php` (hardcoded fallback przez `DeliveryStatus::normalize($provider, $rawStatus)`).
- `provider='polkurier'` (Phase 128, migracja `20260514_000115_seed_polkurier_delivery_status_mappings.sql`):
- `O``created` (Oczekuje na platnosc)
- `P``confirmed` (Potwierdzone, list wygenerowany)
- `A``cancelled` (Anulowane)
- `WP``in_transit` (W przewozie)
- `D``delivered` (Dostarczona)
- `Z``returned` (Zwrot do nadawcy)
- `W``problem` (Wyjatek)
- Kody z oficjalnej tabeli `ORDER_STATUS` w dokumentacji API polkurier v1.11 (marzec 2026).
---
## Integrations

View File

@@ -1,5 +1,41 @@
# Technical Changelog
## 2026-05-14 - Phase 128 Plan 01: polkurier ShipmentService + Tracking + UI prepare
**Co zrobiono:**
- `src/Modules/Settings/PolkurierApiClient.php` — pelen kontrakt API: `createShipment` (`apimetod=create_order`), `getLabel` (`get_label`), `getStatus` (`get_status`), `cancelOrder` (`cancel_order`), `getAvailableCarriers` (`available_carriers`), `getInpostParcelMachines` (`inpost_parcel_machines`), `getCourierPoints` (`get_courier_point`). Wspolny prywatny `call($apimetod, $data, $login, $token): mixed` parsuje envelope `{status, response}`; sukces -> zwraca `response`, blad -> rzuca `RuntimeException` z trescia z `response` (string albo zserializowany JSON dla tablic). Kontrakt zweryfikowany na oficjalnej dokumentacji PDF v1.11 (marzec 2026) — pobrana z `https://www.polkurier.pl/files/download/api_documentation_pdf`, zachowana w `.paul/phases/128-polkurier-shipment-service/polkurier-api-docs.txt`.
- `src/Modules/Shipments/PolkurierShipmentService.php``final class implements ShipmentProviderInterface` (`code()='polkurier'`). Pelen flow `createShipment($orderId, $formData)`: walidacja credentials/sender, `normalizeShipmentType()` mapuje `package_type` (PACKAGE/BOX/...) na zbior polkuriera `[box,envelope,palette,small_parcel,parcel_size_20]` (lowercase wymagane przez API — odkryte podczas live testu), `splitStreetAndNumber()` rozdziela ulice regexem na `street`/`housenumber`/`flatnumber`, `buildRecipient` z payload `order_addresses` + override z formularza, `buildPickup` z domyslnym `nextBusinessDay()` + 10:00-16:00, `COD` z bank account z `company_settings`. Po sukcesie API: `extractOrderNumber` (priorytet `number` z SDK Order entity), `extractTrackingNumber` (priorytet `waybills[0].number` z OrderWaybill entity), synchroniczna proba `downloadLabel`. Diagnostyka: gdy `orderno=''`, zapisuje fragment surowej odpowiedzi do `shipment_packages.error_message`.
- `src/Modules/Shipments/PolkurierTrackingService.php``final class implements ShipmentTrackingInterface`. `getDeliveryStatus($package)` woła `get_status`, parsuje `status_code`, mapuje przez `DeliveryStatus::normalizeWithOverrides('polkurier', ...)` z `delivery_status_mappings`. Graceful: null przy braku credentials/wyjatku API/braku `status_code`.
- `src/Modules/Shipments/DeliveryStatus.php` — fallback URL sledzenia dla `provider='polkurier'`: `https://polkurier.pl/sledz-paczke/<tracking>`. Carrier_id routing (DPD/UPS/GLS/InPost/Pocztex) dziala automatycznie przez istniejacy `matchCarrierByName()` (carrier_id ustawiany na servicecode z polkuriera, np. "INPOST" lub "DPD" — substring match).
- `routes/web.php``new PolkurierShipmentService(...)` w `ShipmentProviderRegistry`. Import `App\Modules\Settings\PolkurierApiClient` + `App\Modules\Shipments\PolkurierShipmentService`.
- `src/Modules/Cron/CronHandlerFactory.php``new PolkurierTrackingService(new PolkurierApiClient(), new PolkurierIntegrationRepository($this->db, $this->integrationSecret), new DeliveryStatusMappingRepository($this->db))` w `ShipmentTrackingRegistry` w handlerze `shipment_tracking_sync`.
- `src/Modules/Shipments/ShipmentController.php``prepare()` fetchuje `polkurierServices` przez registry i przekazuje do widoku. `create()` rozszerzony o pola `service_code`/`pickup_date`/`pickup_time_from`/`pickup_time_to` przekazywane do `createShipment()`.
- `resources/views/shipments/prepare.php` — opcja "polkurier" w `#shipment-carrier-select`, panel `#shipment-polkurier-panel` z `<select id="shipment-polkurier-select">` (lista uslug z `available_carriers`), hidden `name="service_code"`, JS toggle (`showPanel`, `syncPolkurierFields`). Brak dedykowanego selektora punktu odbioru — operator wpisuje `receiver_point_id` w istniejacym text inpucie w sekcji Adres odbiorcy (np. `POP-RZE54`).
- Nowa migracja `database/migrations/20260514_000115_seed_polkurier_delivery_status_mappings.sql` — 7 wpisow `provider='polkurier'`: `O``created`, `P``confirmed`, `A``cancelled`, `WP``in_transit`, `D``delivered`, `Z``returned`, `W``problem`. Idempotentne (`ON DUPLICATE KEY UPDATE`). Kody z oficjalnej tabeli ORDER_STATUS PDF v1.11.
- `.paul/codebase/architecture.md` + `db_schema.md` — opisy fazy 128.
**Dlaczego:**
- Phase 127 dostarczyl fundament (settings + test_auth_api). Bez ShipmentService polkurier byl tylko `wystawiony w hubie integracji ale niedzialajacy`. Operator chcial realnie nadawac paczki przez polkurier obok Apaczki — szczegolnie dla DPD/UPS/GLS/InPost gdzie polkurier oferuje lepsze ceny.
- Pelny zakres (ShipmentService + TrackingService + UI prepare + delivery_status_mappings) w jednej fazie zgodnie z decyzja operatora z planu (clarifications, `delegation: off`, `autonomous: false` z checkpointem live testu na #114/#115).
**Live test iteracje (zarejestrowane podczas APPLY):**
1. Pierwszy submit polkurier → "Blad tworzenia przesylki: Nie podano uslugi Apaczka." Przyczyna: `ReferenceError` na zmiennej `polkurierPointIdInput` (pozostalej po usunieciu duplikatu selektora punktu) w `clearHiddenFields()` → handler `carrierSelect.change` crashowal przed `showPanel()`, `provider_code` zostawal na PHP-renderowanej wartosci `apaczka`. Fix: usuniecie martwej referencji.
2. Drugi submit → "Blad tworzenia przesylki: polkurier create_order: Typ paczki musi przyjmowac jeden z parametrow ze zbioru [box, envelope, palette, small_parcel, parcel_size_20]". Przyczyna: wysylanie `BOX` uppercase. Fix: `normalizeShipmentType()` z lowercase + aliasami.
3. Trzeci submit → utworzona w polkurier, ale w orderPRO `status=pending` (brak orderno w parsing). Przyczyna: shape odpowiedzi `create_order` zwraca `Order` entity z polem `number` (nie `orderno`). Fix: `extractOrderNumber` z priorytetem `number` + fallback list + obsluga wrappera `{order:{...}}`. Etykieta poprawnie parsowana z pola `file` (response GetLabel.php).
4. Czwarty test → etykieta A4 zamiast A6. Iteracja w bogus parametry (`format`/`label_size`/`paper_size`) wyslane do `get_label` — bez efektu, bo API ignoruje. Pobranie oficjalnej dokumentacji PDF potwierdzilo: `get_label` przyjmuje WYLACZNIE `orderno`. Rozmiar A4 vs A6 sterowany jest w panelu klienta polkurier.pl (Ustawienia konta → Preferencje etykiet), NIE przez API. Operator zmienil w panelu — etykieta A6 OK.
**Deviation vs PLAN:**
- Plan deklarowal `delegation: off` — wykonane inline. Tasks 1-3 napisane przez orchestrator, checkpoint i Task 5/6 inline. Bez sub-agentow ze wzgledu na potrzebe szybkich iteracji po live test feedback (4 iteracje przed sukcesem) — spawn agentow zdublowalby koszt research z PDF.
- Plan deklarowal AJAX endpoint `/shipments/polkurier/points` + UI selector punktow paczkomatowych (Task 3 action). USUNIETY po feedback operatora — istnieje juz pole "Punkt odbioru" w sekcji Adres odbiorcy, operator wpisuje ID recznie (np. `POP-RZE54`). Usuniety: `PolkurierShipmentService::lookupPickupPoints()`, `ShipmentController::polkurierPoints()`, route, JS handler.
- AC-3 (TrackingService cron) dostarczony, ale niezweryfikowany na zywej bazie podczas APPLY (operator anulowal paczki w panelu polkurier po teście — cron tracking nie mial co pingowac). Dziala defensywnie (graceful null przy bledach), pierwszy realny passthrough nastapi przy nastepnej zywej paczce.
- Plan zakladal seedowanie mapowan po zaobserwowaniu realnych statusow w live tescie. Seed wykonany bazujac na oficjalnej tabeli ORDER_STATUS z PDF v1.11 (kody O/P/A/WP/D/Z/W) zamiast obserwacji — bezpieczniejsze i wyczerpujace.
- AC-1 wzmiankowal getAvailableCarriers/getParcelMachines/getPostOffices. `getInpostParcelMachines` i `getCourierPoints` zaimplementowane jako stuby na przyszle rozszerzenie UI, ale nie uzywane przez aktualny UI (operator wpisuje punkt recznie). `getPostOffices` POMINIETY — brak dedykowanej metody w SDK (jest tylko `inpost_parcel_machines` per courier, `pocztex_post_offices`, `kurier48_post_offices` — zlozenie tego w UI panel paczkomatow odlozone na kolejna faze).
**Follow-up:**
- Operator musi uruchomic migracje gdy XAMPP MySQL online: `php bin/migrate.php` (utworzy 7 wpisow `provider='polkurier'`).
- Po pierwszej zywej paczce w `in_transit` — weryfikacja crona `shipment_tracking_sync` (1x ping API polkuriera, zapis `delivery_status` + `delivery_status_raw` w `shipment_packages`).
- Kolejne fazy v3.7: paczkomaty UI panel (`InpostParcelMachines`/`PocztexPostOffices`/`Kurier48PostOffices`), presety przesylek z `provider_code='polkurier'`, `OrderValuationV2` (wycena przed nadaniem).
## 2026-05-14 - Phase 127 Plan 01: polkurier Integration Foundation
**Co zrobiono:**