---
phase: 128-polkurier-shipment-service
plan: 01
subsystem: shipments
tags: [polkurier, courier, broker, shipment, tracking, ui-prepare, delivery-status-mappings]

requires:
  - phase: 127-polkurier-integration-foundation
    provides: PolkurierIntegrationRepository (login + Token API + getCredentials), PolkurierApiClient.testConnection, integration row in `integrations` + `polkurier_integration_settings`.

provides:
  - PolkurierApiClient z pelnym kontraktem (createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers/getInpostParcelMachines/getCourierPoints).
  - PolkurierShipmentService implementujacy ShipmentProviderInterface — operator tworzy paczki polkurier z `/orders/{id}/shipment/prepare`.
  - PolkurierTrackingService implementujacy ShipmentTrackingInterface — cron `shipment_tracking_sync` pinguje get_status.
  - DeliveryStatus::trackingUrl fallback `https://polkurier.pl/sledz-paczke/<tracking>` + carrier_id routing.
  - UI panel "polkurier" w `prepare.php` z dynamiczna lista uslug z available_carriers.
  - Seed migracja `delivery_status_mappings(provider='polkurier')` z 7 wpisami O/P/A/WP/D/Z/W → znormalizowane statusy.

affects: [paczkomaty UI (InpostParcelMachines/PocztexPostOffices/Kurier48PostOffices), shipment_presets (provider_code='polkurier'), OrderValuationV2 (wycena przed nadaniem)]

tech-stack:
  added: []
  patterns:
    - "Wspolny prywatny `call($apimetod, $data, $login, $token): mixed` w API client parsuje envelope `{status, response}`; sukces -> tresc `response`, blad -> RuntimeException z trescia z `response`. Reuse dla wszystkich apimetod."
    - "polkurier SDK Order entity zwraca `number` (nie `orderno`) i `waybills[0].number` — `extractOrderNumber`/`extractTrackingNumber` priorytetuja SDK shape, fallback na top-level klucze."
    - "polkurier API nie udostepnia parametru rozmiaru etykiety (A4/A6) — sterowane wylacznie w panelu klienta polkurier.pl. `polkurier_integration_settings.default_label_format` (PDF/ZPL/EPL) odnosi sie do typu pliku, NIE rozmiaru."

key-files:
  created:
    - src/Modules/Shipments/PolkurierShipmentService.php
    - src/Modules/Shipments/PolkurierTrackingService.php
    - database/migrations/20260514_000115_seed_polkurier_delivery_status_mappings.sql
  modified:
    - src/Modules/Settings/PolkurierApiClient.php
    - src/Modules/Shipments/DeliveryStatus.php
    - src/Modules/Shipments/ShipmentController.php
    - src/Modules/Cron/CronHandlerFactory.php
    - routes/web.php
    - resources/views/shipments/prepare.php

key-decisions:
  - "polkurier `shipmenttype` wymaga lowercase z zbioru [box, envelope, palette, small_parcel, parcel_size_20] — `normalizeShipmentType()` mapuje legacy PACKAGE/BOX/PARCEL/PACZKA/KOPERTA/PALETA na format polkuriera."
  - "Rozmiar etykiety A4/A6 sterowany w panelu klienta polkurier.pl, NIE przez API (zweryfikowane na PDF v1.11) — kod nie wysyla zadnego parametru rozmiaru."
  - "Brak dedykowanego selektora punktu odbioru w UI — operator wpisuje `receiver_point_id` w istniejacy text input w sekcji Adres odbiorcy (np. `POP-RZE54`); usuniety AJAX endpoint i lookupPickupPoints."
  - "Seed `delivery_status_mappings` bazuje na oficjalnej tabeli ORDER_STATUS z PDF v1.11 (kody O/P/A/WP/D/Z/W), nie na obserwacji w live tescie — bezpieczniejsze i wyczerpujace."
  - "polkurier dziala obok Apaczki (decyzja z Phase 127 zachowana); `ShipmentProviderRegistry` rejestruje oba; brak migracji shipment_presets."

patterns-established:
  - "Pattern: dla nowych metod polkurier API uzywaj wspolnego `call($apimetod, $data, $login, $token)`. Status `success` zwraca tresc `response`. Status inny rzuca `RuntimeException` z trescia `response` (string albo zserializowany JSON dla tablic)."
  - "Pattern: dla parsowania odpowiedzi polkurier SDK entity, najpierw priorytetuj klucze entity (`number`, `waybills[].number`, `file`), potem fallback na top-level/snake_case klucze, potem obsluga wrapperow `{order:{...}}` i list."
  - "Pattern: diagnostyka silent-fail w ShipmentService — gdy parsing API odpowiedzi zwraca pusty wynik mimo `status=success`, zapisuj fragment surowej odpowiedzi do `shipment_packages.error_message` zeby operator/dev zobaczyl shape."

duration: ~120min (incl. 4 live test iteracje)
started: 2026-05-14T20:00:00Z
completed: 2026-05-14T22:00:00Z
---

# Phase 128 Plan 01: polkurier ShipmentService + Tracking + UI prepare

**polkurier zarejestrowany jako pelnoprawny przewoznik obok Apaczki — operator tworzy paczki przez UI `/orders/{id}/shipment/prepare`, etykieta A6 generowana, cron tracking gotowy do mapowania statusow O/P/A/WP/D/Z/W na znormalizowane created/confirmed/cancelled/in_transit/delivered/returned/problem.**

## Performance

| Metric | Value |
|--------|-------|
| Duration | ~120 min |
| Started | 2026-05-14T20:00:00Z |
| Completed | 2026-05-14T22:00:00Z |
| Tasks | 6/6 completed (5 auto + 1 checkpoint) |
| Files modified | 10 |
| Live test iteracje | 4 (ReferenceError → uppercase shipmenttype → orderno parsing → A6 panel) |

## Acceptance Criteria Results

| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: PolkurierApiClient pelny kontrakt API | Pass | 7 metod (createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers/getInpostParcelMachines/getCourierPoints) zweryfikowane na PDF v1.11. `call()` wspolny wrapper envelope `{status, response}`. |
| AC-2: PolkurierShipmentService implementuje ShipmentProviderInterface | Pass | `code()='polkurier'`, `getDeliveryServices` cache per-request, `createShipment` orchestruje pelny flow z normalizacja shipmenttype i splitem ulicy, `downloadLabel` z base64 decode na klucz `file`. Verified on #114/#115. |
| AC-3: PolkurierTrackingService cron tracking | Pass (kod) | Implementacja kompletna, ale niezweryfikowane na zywej bazie podczas APPLY (operator anulowal paczki w panelu polkurier po teście — cron nie mial co pingowac). Graceful null przy bledach. Pierwszy passthrough nastapi przy nastepnej zywej paczce. |
| AC-4: UI prepare.php panel polkurier | Pass | Opcja "polkurier" w dropdownie, panel z dynamiczna lista uslug, hidden `service_code`. Bez dedykowanego selektora punktu — operator wpisuje w istniejacy input w sekcji Adres odbiorcy. |
| AC-5: delivery_status_mappings + /settings/delivery-statuses | Pass (kod) | Migracja idempotentna z 7 wpisami O/P/A/WP/D/Z/W. Operator uruchomi `php bin/migrate.php` gdy MySQL online. Widocznosc w `/settings/delivery-statuses` po migracji. |
| AC-6: Live test na #114 i #115 | Pass | 4 iteracje, ostatecznie obie paczki utworzone w polkurier, etykiety pobrane (A6 po zmianie w panelu klienta), operator anulowal w panelu polkuriera po weryfikacji. |

## Accomplishments

- polkurier zarejestrowany jako 4. provider w `ShipmentProviderRegistry` (obok allegro_wza, apaczka, inpost) — operator nadaje paczki z UI bez przelaczania platform.
- Kontrakt API zweryfikowany na oficjalnej dokumentacji PDF v1.11 (pobranej i zachowanej w `.paul/phases/128-polkurier-shipment-service/polkurier-api-docs.txt` — referencyjne zrodlo dla przyszlych faz).
- Mapowanie statusow `O/P/A/WP/D/Z/W` na znormalizowane statusy `created/confirmed/cancelled/in_transit/delivered/returned/problem` z idempotentna migracja — cron tracking gotowy do dzialania.
- Diagnostyka silent-fail patternem (zapis fragmentu surowej odpowiedzi do `error_message` przy nieudanym parsingu) — uratowala 3. iteracje live testu (parsing `number` vs `orderno`).

## Task Commits

Wszystkie zmiany w jednym stanie WIP — commit zostanie wykonany w transition (`feat(128): polkurier shipment service + tracking + UI prepare`).

| Task | Status | Description |
|------|--------|-------------|
| Task 1: PolkurierApiClient pelen kontrakt API | done | 7 metod, wspolny `call()` wrapper, parsowanie envelope |
| Task 2: PolkurierShipmentService + PolkurierTrackingService | done | ~520 + ~110 LOC, oba implementuja swoje interfejsy |
| Task 3: Wiring + UI prepare.php panel | done | Registry, CronHandlerFactory, ShipmentController.prepare/create, panel + JS |
| Task 4: Live test checkpoint na #114/#115 | done | Operator approved po 4 iteracjach, etykieta A6 po zmianie w panelu klienta polkurier |
| Task 5: Migracja seed delivery_status_mappings | done (kod) | 7 wpisow z PDF v1.11, idempotentna; operator uruchomi gdy MySQL online |
| Task 6: Aktualizacja `.paul/codebase/*.md` | done | architecture.md (Phase 128 sekcja), db_schema.md (seed mappings), tech_changelog.md (Phase 128 entry z 4 deviationami i iteracjami live testu) |

## Files Created/Modified

| File | Change | Purpose |
|------|--------|---------|
| `src/Modules/Settings/PolkurierApiClient.php` | Modified | Stuby z Phase 127 zastapione 7 metodami: createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers/getInpostParcelMachines/getCourierPoints. Wspolny `call()` parser envelope. |
| `src/Modules/Shipments/PolkurierShipmentService.php` | Created | `implements ShipmentProviderInterface`, ~520 LOC. createShipment orchestracja, normalizeShipmentType, splitStreetAndNumber, buildRecipient/buildSender/buildPickup, downloadLabel z base64 decode, extractOrderNumber/extractTrackingNumber priorytetujace SDK shape. |
| `src/Modules/Shipments/PolkurierTrackingService.php` | Created | `implements ShipmentTrackingInterface`, ~110 LOC. getDeliveryStatus z graceful null + normalizacja przez DeliveryStatusMappingRepository. |
| `src/Modules/Shipments/DeliveryStatus.php` | Modified | +4 LOC: fallback URL `https://polkurier.pl/sledz-paczke/<tracking>`. Carrier_id routing przez `matchCarrierByName` automatyczny. |
| `src/Modules/Shipments/ShipmentController.php` | Modified | prepare() fetchuje polkurierServices, create() rozszerzony o service_code/pickup_date/pickup_time_from/pickup_time_to. |
| `src/Modules/Cron/CronHandlerFactory.php` | Modified | PolkurierTrackingService dodany do ShipmentTrackingRegistry. |
| `routes/web.php` | Modified | use PolkurierApiClient + PolkurierShipmentService, registry zarejestrowany. |
| `resources/views/shipments/prepare.php` | Modified | Opcja "polkurier" w carrier select, panel z select uslug, hidden service_code, JS handler. |
| `database/migrations/20260514_000115_seed_polkurier_delivery_status_mappings.sql` | Created | 7 wpisow O/P/A/WP/D/Z/W → normalized. Idempotentne. |
| `.paul/codebase/architecture.md` | Modified | Sekcja Phase 128 (PolkurierApiClient/ShipmentService/TrackingService/UI/wiring/seed/boundaries). |
| `.paul/codebase/db_schema.md` | Modified | Seedowane mapowania `provider='polkurier'` w sekcji `delivery_status_mappings`. |
| `.paul/codebase/tech_changelog.md` | Modified | Entry Phase 128 z opisem zmian + 4 iteracje live testu + deviations. |
| `.paul/phases/128-polkurier-shipment-service/polkurier-api-docs.txt` | Created | Tekst PDF v1.11 (pdftotext extract) — referencyjne zrodlo dla przyszlych faz polkuriera. |

## Decisions Made

| Decision | Rationale | Impact |
|----------|-----------|--------|
| `shipmenttype` lowercase + `normalizeShipmentType()` mapping | polkurier API odrzuca uppercase `BOX` — wymaga lowercase z zbioru `[box, envelope, palette, small_parcel, parcel_size_20]` (komunikat bledu w live tescie). Aliasy dla PACKAGE/PARCEL/PACZKA/KOPERTA/PALETA pozwalaja reuse istniejacych wartosci formularza. | Wszystkie kolejne paczki polkurier maja poprawny shipmenttype bez zmian w formularzu/preset. |
| `extractOrderNumber` priorytetuje pole `number` (SDK Order entity) nad `orderno` | polkurier `create_order` zwraca Order entity z polem `number` (zweryfikowane w SDK Order.php — setNumber/getNumber). `orderno` to nazwa parametru INPUT w innych metodach (get_label, get_status, cancel_order). | Parsing dziala dla aktualnej wersji SDK + odporne na stary shape (`orderno` fallback). |
| Brak dedykowanego selektora punktu odbioru w UI | Operator zglosil ze `Punkt odbioru` jest juz polem w sekcji Adres odbiorcy z auto-fillem `parcel_external_id` z importu zamowienia. Dodatkowy selektor byl duplikatem. | Usuniete: `lookupPickupPoints`, `ShipmentController::polkurierPoints`, route, JS handler. Operator wpisuje czysty ID (np. `POP-RZE54`) w istniejacy input. |
| Rozmiar etykiety A4/A6 sterowany w panelu klienta polkurier.pl | API polkurier nie udostepnia parametru rozmiaru w `get_label` ani `create_order` (zweryfikowane na PDF v1.11). Operator zmienia preferencje konta jednorazowo. | Brak dodatkowego pola w `polkurier_integration_settings` ani formularzu; `default_label_format` (PDF/ZPL/EPL) odnosi sie tylko do typu pliku. |
| Seed `delivery_status_mappings` z PDF v1.11 (nie z obserwacji live test) | Live test obejmowal tylko status `P` (Potwierdzone) bezposrednio po `create_order`. Seedowanie bazujace na obserwacji wymagaloby kolejnych miesiecy zywych paczek. PDF ma kompletna tabele ORDER_STATUS. | 7 wpisow O/P/A/WP/D/Z/W ready od pierwszego dnia. |
| Diagnostyka silent-fail patternem (zapis surowej odpowiedzi do `error_message`) | 3. iteracja live testu (parsing `number` vs `orderno`) byla niemozliwa do debugowania bez podgladu surowej odpowiedzi — `payload_json` w `shipment_packages.update()` jest poza whitelist. Zapis fragmentu (400 znakow) do `error_message` jest tani i widoczny operatorowi w UI. | Pattern do reuse dla nowych integracji API z nieznanym shape odpowiedzi. |

## Deviations from Plan

### Summary

| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 4 | Live test iteracje — wszystkie naprawione w tej samej sesji APPLY |
| Scope removals | 1 | UI selektor punktow paczkomatowych usuniety na zyczenie operatora |
| Scope additions | 1 | Pole `service_code` i `pickup_*` w `ShipmentController::create()` (potrzebne dla polkurier payload) |
| Deferred | 3 | Cron tracking weryfikacja, migracja MySQL, paczkomaty UI (kolejna faza) |

**Total impact:** Essential fixes (live test feedback), no scope creep — operator manual confirmation poszerzyl o jedno usuniecie (selektor punktu) i jedno dodanie (`service_code` przekazywany do service).

### Auto-fixed Issues

**1. [JS ReferenceError] `polkurierPointIdInput is not defined` w `clearHiddenFields()`**
- **Found during:** Task 4 (live test, pierwszy submit polkurier)
- **Issue:** Po usunieciu duplikatu selektora punktu odbioru (po feedback operatora w Task 3 iteracji) zostala martwa referencja do zmiennej `polkurierPointIdInput` w `clearHiddenFields()`. JS rzucal ReferenceError, handler `carrierSelect.change` przerywal przed wywolaniem `showPanel()`, `provider_code` zostawal na PHP-renderowanej wartosci `apaczka` (gdy `$preselectedCarrier === 'apaczka'`). Submit szedl do ApaczkaShipmentService → blad "Nie podano uslugi Apaczka."
- **Fix:** Usuniecie linii `if (polkurierPointIdInput) polkurierPointIdInput.value = '';` z `clearHiddenFields()`.
- **Files:** `resources/views/shipments/prepare.php`
- **Verification:** Drugi submit polkurier → routing do PolkurierShipmentService.

**2. [Polkurier API validation] `shipmenttype` musi byc lowercase**
- **Found during:** Task 4 (live test, drugi submit po napraweniu #1)
- **Issue:** Wysylanie `BOX` uppercase → API odrzucalo: "Typ paczki musi przyjmowac jeden z parametrow ze zbioru [box, envelope, palette, small_parcel, parcel_size_20]".
- **Fix:** Nowa metoda `normalizeShipmentType()` z lowercase + aliasami (PACKAGE→box, PARCEL→box, PACZKA→box, KOPERTA→envelope, PALETA→palette, MALA_PACZKA/SMALL→small_parcel). Default `box`.
- **Files:** `src/Modules/Shipments/PolkurierShipmentService.php`
- **Verification:** Trzeci submit → paczka utworzona w polkurier.

**3. [Response shape mismatch] `extractOrderNumber` nie znajdowal pola `number`**
- **Found during:** Task 4 (live test, trzeci submit — paczka utworzona w polkurier ale w orderPRO `status=pending`)
- **Issue:** Pierwotny parsing szukal kluczy `orderno`/`order_no` w odpowiedzi. polkurier zwraca SDK Order entity z polem `number` + tablica `waybills[]` z `OrderWaybill` entity (zweryfikowane w `Order.php` setterach `setNumber()`, `addWaybill()`).
- **Fix:** Nowe metody `extractOrderNumber()` (priorytet `number`, fallback `orderno`/`order_no`/`order_number`/`order_id`/`id`, obsluga wrappera `{order:{...}}` i list) + `extractTrackingNumber()` (priorytet `waybills[0].number`, fallback top-level klucze). Dodatkowo diagnostyka: gdy `orderno=''`, zapis fragmentu surowej odpowiedzi do `error_message`.
- **Files:** `src/Modules/Shipments/PolkurierShipmentService.php`
- **Verification:** Czwarty submit → `status=created`, `tracking_number` ustawiony, etykieta pobrana z pola `file`.

**4. [API misunderstanding] Bogus parametry rozmiaru etykiety**
- **Found during:** Task 4 (live test, czwarty submit — etykieta A4 zamiast A6)
- **Issue:** Iteracja w 3 bogus parametry (`format`/`label_size`/`paper_size`) wyslanych do `get_label` — bez efektu, bo API ignoruje nieznane pola. Operator zglosil ze etykieta nadal A4.
- **Fix:** Pobranie i przeczytanie oficjalnej dokumentacji PDF v1.11 potwierdzilo: `get_label` przyjmuje WYLACZNIE `orderno`. Rozmiar A4/A6 sterowany jest w panelu klienta polkurier.pl. Usuniete bogus parametry, `getLabel($login, $token, $orderno)` ma tylko 3 argumenty. Operator zmienil ustawienie w panelu polkurier — etykieta A6 OK.
- **Files:** `src/Modules/Settings/PolkurierApiClient.php`, `src/Modules/Shipments/PolkurierShipmentService.php`
- **Verification:** Operator nadal kolejna paczke → etykieta A6.

### Scope Removals

**UI selektor punktow paczkomatowych (AJAX endpoint + dropdown)**
- **Removed during:** Task 3 iteracje (po feedback operatora)
- **Reason:** Istnieje juz pole `name="receiver_point_id"` w sekcji Adres odbiorcy z auto-fillem `parcel_external_id` z importu zamowienia. Dodatkowy selektor byl duplikatem. Operator wpisuje czysty ID recznie (np. `POP-RZE54`).
- **Files removed:** `PolkurierShipmentService::lookupPickupPoints()`, `ShipmentController::polkurierPoints()`, route `/shipments/polkurier/points`, JS handler `loadPolkurierPoints/renderPolkurierPoints`.
- **Zachowane:** `PolkurierApiClient::getInpostParcelMachines()` i `getCourierPoints()` — gotowe stuby na przyszle rozszerzenie (kolejna faza paczkomatow UI).

### Scope Additions

**`service_code` + `pickup_*` w `ShipmentController::create()`**
- **Reason:** PolkurierShipmentService potrzebuje servicecode z available_carriers (osobne pole niz `delivery_method_id` zeby JS mogl wstawic czysta wartosc) + optional pickup override.
- **Impact:** Backward compatible — Apaczka/InPost/AllegroWZA ignoruja te pola w swoich createShipment.

### Deferred Items

- **Phase 128 follow-up:** Operator uruchomi `php bin/migrate.php` gdy XAMPP MySQL online (utworzy 7 wpisow `provider='polkurier'` w `delivery_status_mappings`).
- **Phase 128 follow-up:** Cron `shipment_tracking_sync` weryfikacja przy pierwszej zywej paczce polkurier w `in_transit` — pierwszy realny passthrough TrackingService dopiero przy nastepnej niezanulowanej paczce.
- **Kolejna faza:** Paczkomaty UI panel (`InpostParcelMachines`/`PocztexPostOffices`/`Kurier48PostOffices` selectory w `prepare.php`), presety przesylek z `provider_code='polkurier'`, `OrderValuationV2` (wycena przed nadaniem).

## Issues Encountered

| Issue | Resolution |
|-------|------------|
| Migracja `20260514_000115` nie uruchomiona — MySQL offline z poziomu agenta (Bash) | Operator uruchomi recznie `php bin/migrate.php` gdy XAMPP MySQL online. Migracja jest idempotentna. |
| AC-3 (cron tracking) nie zweryfikowane na zywej bazie | Operator anulowal obie paczki w panelu polkurier po teście — cron tracking nie mial co pingowac. Implementacja kompletna i defensywna (graceful null). Weryfikacja przy nastepnej zywej paczce. |
| PDF v1.11 polkurier API niedostepny przez WebFetch (binary content) | Pobrane przez WebFetch jako binarny PDF + `pdftotext.exe` (Git Bash bundle) → tekst w `.paul/phases/128-polkurier-shipment-service/polkurier-api-docs.txt`. Pattern dla przyszlych fetchy binary docs. |

## Next Phase Readiness

**Ready:**
- polkurier dziala end-to-end w UI (tworzenie + etykieta + tracking gotowy).
- Kontrakt API zweryfikowany na oficjalnej dokumentacji (PDF v1.11) — przyszle fazy maja stale referencyjne zrodlo.
- Diagnostyka silent-fail pattern do reuse dla nowych integracji.
- `getInpostParcelMachines`/`getCourierPoints` stuby gotowe dla kolejnej fazy paczkomaty UI.

**Concerns:**
- AC-3 (cron tracking) nie zweryfikowane na zywej bazie — pierwszy passthrough wymaga niezanulowanej paczki polkurier. Defensywne kodowanie (graceful null) chroni przed crashem crona, ale realne dzialanie testowalne dopiero na zywej paczce.
- `extractOrderNumber`/`extractTrackingNumber` fallback chain moze nie pokryc 100% wariantow shape odpowiedzi (np. order zlecone z dodatkowymi opcjami). Pattern z `error_message` dump pomoze w iteracji.

**Blockers:**
- None.

---
*Phase: 128-polkurier-shipment-service, Plan: 01*
*Completed: 2026-05-14*
