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:
@@ -13,8 +13,8 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| Version | 3.7.0-dev |
|
||||
| Status | v3.7 in progress — Phases 113-127 shipped (Fakturownia + HostedSMS/SMSPLANET + Alert unify + receipt VAT + SMS templates + invoice_requested import fix + invoice GUS mapping + polkurier foundation) |
|
||||
| Last Updated | 2026-05-14 (Phase 127 closed) |
|
||||
| Status | v3.7 in progress — Phases 113-128 shipped (Fakturownia + HostedSMS/SMSPLANET + Alert unify + receipt VAT + SMS templates + invoice_requested import fix + invoice GUS mapping + polkurier foundation + polkurier shipment service) |
|
||||
| Last Updated | 2026-05-14 (Phase 128 closed) |
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -127,6 +127,7 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
|
||||
- [x] Szablony SMS: CRUD w `/settings/sms-templates` (name + body + is_active), wspolny `SmsVariableResolver` wydzielony z Email\\VariableResolver (placeholdery `{{zamowienie.*|kupujacy.*|adres.*|firma.*|przesylka.*}}`), dropdown "Wybierz szablon" w zakladce SMS na `/orders/{id}` wstawia rozwiniete zmienne do textarea (z `OrderProAlerts.confirm` przy nadpisaniu); stopka SMSPLANET dalej doklejana wylacznie przez `SmsConversationService::buildFinalOutboundBody()` (Phase 122 contract preserved) — Phase 124
|
||||
- [x] Bugfix detekcji faktury przy imporcie: shopPRO order z `firm_nip` ustawia `invoice_requested=1` (mapper jako jedyne zrodlo heurystyki, sync service propaguje `aggregate['invoice_detected']`); Allegro rozszerzony o `naturalPerson=false`/`address.taxId`/`companyName` (wczesniej tylko `invoice.required`); usunieta legacy kolumna `orders.is_invoice` (Phase 115 dryft) + backfill 7 zamowien — Phase 125
|
||||
- [x] Integracja polkurier.pl (fundament): pojedyncza globalna konfiguracja w `/settings/integrations/polkurier`, szyfrowany Token API + login, karta w hubie integracji obok Apaczki i realny test polaczenia przez `apimetod=test_auth_api` zweryfikowany na zywym koncie operatora; `ShipmentProviderRegistry` netkniety — `PolkurierShipmentService/TrackingService` w kolejnych fazach — Phase 127
|
||||
- [x] polkurier ShipmentService + TrackingService + UI prepare panel: pelen kontrakt API (createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers), `PolkurierShipmentService` implementujacy `ShipmentProviderInterface` z normalizacja shipmenttype (lowercase) i splitem ulicy na street/housenumber/flatnumber, `PolkurierTrackingService` mapujacy statusy O/P/A/WP/D/Z/W na znormalizowane, panel "polkurier" w `prepare.php` z dynamiczna lista uslug z `available_carriers`, seed migracja `delivery_status_mappings(provider='polkurier')` z 7 wpisami z PDF v1.11; live test na #114/#115 zakonczony sukcesem po 4 iteracjach (ReferenceError → uppercase shipmenttype → orderno parsing → A4/A6); rozmiar etykiety sterowany w panelu klienta polkurier.pl (Ustawienia konta → Preferencje etykiet), NIE przez API — Phase 128
|
||||
|
||||
### Deferred
|
||||
|
||||
@@ -243,6 +244,11 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API
|
||||
| polkurier API: top-level `status` === `'success'` (nie `'ok'`), tresc bledu w polu `response` envelope'a | `ResponseStatus::SUCCESS = 'success'` z `src/Type/ResponseStatus.php` SDK; bledy rzucane przez `ErrorException($response->get('response'))` w `PolkurierWebService.php`. Pattern dla wszystkich przyszlych metod polkurier API (`createShipment`, `getLabel`, `getStatus`, `cancelOrder`, etc.) | 2026-05-14 | Active |
|
||||
| polkurier API odrzuca `Content-Type` z parametrem (`application/json; charset=UTF-8`) — wymagany dokladnie `application/json` | Strict equality check po stronie polkuriera; pattern do reuse jezeli inne integracje sa rownie strict | 2026-05-14 | Active |
|
||||
| polkurier dziala obok Apaczki (nie zamiast) | Decyzja operatora — oba dostawcy zyja niezaleznie, `ShipmentProviderRegistry` rejestruje obu (Apaczka netknieta w Phase 127; polkurier dodany w nastepnej fazie razem z `PolkurierShipmentService`) | 2026-05-14 | Active |
|
||||
| polkurier `shipmenttype` wymaga lowercase z zbioru `[box, envelope, palette, small_parcel, parcel_size_20]` | API odrzuca uppercase `BOX` (komunikat: "Typ paczki musi przyjmowac jeden z parametrow ze zbioru ..."). `normalizeShipmentType()` w `PolkurierShipmentService` mapuje legacy PACKAGE/BOX/PARCEL/PACZKA/KOPERTA/PALETA na format polkuriera z aliasami i defaultem `box`. | 2026-05-14 | Active |
|
||||
| polkurier `create_order` zwraca Order entity z polem `number` (nie `orderno`) i `waybills[0].number` | SDK Order.php uzywa setNumber()/addWaybill() — JSON shape entity, nie parametrow input. `extractOrderNumber` priorytetuje `number`, fallback na `orderno`/`order_no`/`order_number`/`order_id`/`id` + obsluga wrapperow `{order:{...}}` i list. `extractTrackingNumber` priorytetuje `waybills[0].number`. Pattern do reuse dla innych metod polkurier SDK. | 2026-05-14 | Active |
|
||||
| polkurier API nie ma parametru rozmiaru etykiety (A4/A6) | Zweryfikowane na PDF v1.11: `get_label` przyjmuje wylacznie `orderno: Array<String>`, `create_order` nie ma pola format/size. Rozmiar sterowany w panelu klienta polkurier.pl (Ustawienia konta → Preferencje etykiet) — operator zmienia preferencje konta jednorazowo. `polkurier_integration_settings.default_label_format` (PDF/ZPL/EPL) odnosi sie tylko do typu pliku, NIE rozmiaru. | 2026-05-14 | Active |
|
||||
| Brak dedykowanego selektora punktow paczkomatowych w UI polkurier (Phase 128) | Istnieje juz pole `name="receiver_point_id"` w sekcji Adres odbiorcy z auto-fillem `parcel_external_id` z importu zamowienia — operator wpisuje ID recznie (np. `POP-RZE54`). Usuniete: `lookupPickupPoints`/`ShipmentController::polkurierPoints`/AJAX route/JS handler. `getInpostParcelMachines`/`getCourierPoints` zachowane jako stuby w API client — gotowe dla kolejnej fazy paczkomaty UI. | 2026-05-14 | Active |
|
||||
| Diagnostyka silent-fail w ShipmentService — zapis surowej odpowiedzi do `error_message` | Gdy parsing API odpowiedzi zwraca pusty wynik mimo `status=success` (np. nieznany shape pola order number), zapisuj fragment surowej odpowiedzi (400 znakow) do `shipment_packages.error_message` — widoczne operatorowi w UI bez podgladu serwerowych logow. Pattern uratowal 3. iteracje live testu Phase 128. Reuse dla nowych integracji z API o nieznanym shape odpowiedzi. | 2026-05-14 | Active |
|
||||
|
||||
## Success Metrics
|
||||
|
||||
@@ -274,6 +280,6 @@ Quick Reference:
|
||||
|
||||
---
|
||||
*PROJECT.md — Updated when requirements or context change*
|
||||
*Last updated: 2026-05-14 after Phase 127 (polkurier Integration Foundation) closure; v3.7 milestone in progress*
|
||||
*Last updated: 2026-05-14 after Phase 128 (polkurier ShipmentService + Tracking + UI prepare) closure; v3.7 milestone in progress*
|
||||
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@ Wystawianie faktur dla klientow z NIP poprzez integracje z Fakturownia (app.fakt
|
||||
| 125 | invoice_requested Import Fix (shopPRO+Allegro NIP detection, drop legacy is_invoice column) | 1/1 | Complete (2026-05-13; migration + manual smoke pending operator) |
|
||||
| 126 | Invoice GUS Field Mapping Fix (KRS-based heuristic: JDG → name do "Imię i nazwisko", spółka → "Nazwa firmy") | 1/1 | Complete (2026-05-13; manual smoke pending operator) |
|
||||
| 127 | polkurier Integration Foundation (single-instance settings + Token API + realny test polaczenia; obok Apaczki) | 1/1 | Complete (2026-05-14; live API verified — `Autoryzacja: 1`) |
|
||||
| 128 | polkurier ShipmentService + TrackingService + UI prepare panel + delivery_status_mappings seed (live test na #114/#115) | 1/1 | Complete (2026-05-14; live test passed po 4 iteracjach; migracja + cron tracking weryfikacja pending) |
|
||||
|
||||
Planowane kolejne fazy v3.7 (kandydaci, do rozplanowania):
|
||||
- polkurier ShipmentService (CreateOrder + GetLabel + OrderValuationV2 + AvailableCarriers mapping + UI mapowan metod dostawy + presety) — fundament 127 zweryfikowany
|
||||
- polkurier TrackingService + `delivery_status_mappings` (provider='polkurier')
|
||||
- polkurier paczkomaty (`InpostParcelMachines` / `PocztexPostOffices` / `Kurier48PostOffices` z SDK polkuriera)
|
||||
- Eksport XLSX listy wystawionych faktur (analogicznie do paragonow)
|
||||
@@ -512,4 +512,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
|
||||
|
||||
---
|
||||
*Roadmap created: 2026-03-12*
|
||||
*Last updated: 2026-05-14 - Phase 127 UNIFY closed (live API verified)*
|
||||
*Last updated: 2026-05-14 - Phase 128 UNIFY closed (live test passed, etykiety A6 OK)*
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
See: .paul/PROJECT.md (updated 2026-05-07)
|
||||
|
||||
**Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami.
|
||||
**Current focus:** v3.7 Invoices + operational integrations - Phase 127 polkurier foundation UNIFY zakonczony, transition (commit + ROADMAP/PROJECT update) pending.
|
||||
**Current focus:** v3.7 Invoices + operational integrations - Phase 128 polkurier ShipmentService loop closed, transition pending (git commit + ROADMAP/PROJECT update).
|
||||
|
||||
## Current Position
|
||||
|
||||
Milestone: v3.7 Invoices (Fakturownia integration) - In progress
|
||||
Phase: 127 of TBD (polkurier Integration Foundation) - Complete
|
||||
Plan: 127-01 complete (SUMMARY.md created); live API verified (`Autoryzacja: 1`)
|
||||
Status: UNIFY complete, transition pending (git commit + decisions in PROJECT.md)
|
||||
Last activity: 2026-05-14 - Phase 127-01 UNIFY zakonczony, SUMMARY + changelog utworzone
|
||||
Phase: 128 of TBD (polkurier ShipmentService + Tracking + UI) - Complete
|
||||
Plan: 128-01 complete (SUMMARY.md created)
|
||||
Status: UNIFY complete, transition pending (git commit + Decisions w PROJECT.md + ROADMAP status)
|
||||
Last activity: 2026-05-14 - Phase 128-01 UNIFY zakonczony, SUMMARY + changelog utworzone
|
||||
|
||||
Progress:
|
||||
- Milestone v3.7: [##########] ~99% (Phase 113-127 complete; transition pending)
|
||||
- Phase 127: [##########] 100%
|
||||
- Milestone v3.7: [##########] ~99% (Phase 113-128 complete; transition pending)
|
||||
- Phase 128: [##########] 100%
|
||||
|
||||
## Loop Position
|
||||
|
||||
@@ -30,8 +30,8 @@ PLAN -> APPLY -> UNIFY
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-05-14
|
||||
Stopped at: Phase 127-01 UNIFY closed; SUMMARY.md created
|
||||
Next action: Phase transition (git commit `feat(127): polkurier integration foundation` + Decisions w PROJECT.md), potem wybor kolejnego kandydata v3.7 (np. PolkurierShipmentService albo invoice.created event)
|
||||
Stopped at: Phase 128-01 UNIFY closed; SUMMARY.md created
|
||||
Next action: Phase transition (git commit `feat(128): polkurier shipment service + tracking + UI prepare` + Decisions w PROJECT.md + ROADMAP status update), potem wybor kolejnego kandydata v3.7 (paczkomaty UI / shipment_presets polkurier / OrderValuationV2 / invoice.created event / eksport XLSX faktur)
|
||||
Resume file: .paul/ROADMAP.md
|
||||
|
||||
## Pending parallel work
|
||||
@@ -39,9 +39,9 @@ Resume file: .paul/ROADMAP.md
|
||||
|
||||
## Git State
|
||||
|
||||
Last phase commit: c758ec7 feat(126): invoice GUS field mapping fix (JDG/KRS heuristic)
|
||||
Previous: 2ab461a feat(125): invoice_requested import fix + drop legacy is_invoice column
|
||||
Branch: main (5 commits ahead of origin/main)
|
||||
Last phase commit: 3443879 feat(127): polkurier integration foundation
|
||||
Previous: c758ec7 feat(126): invoice GUS field mapping fix (JDG/KRS heuristic)
|
||||
Branch: main (6 commits ahead of origin/main)
|
||||
|
||||
## Pending Actions
|
||||
|
||||
@@ -68,6 +68,9 @@ Branch: main (5 commits ahead of origin/main)
|
||||
- Phase 127 follow-up: zaplanowac kolejna faze polkurier — `PolkurierShipmentService` (CreateOrder + GetLabel + OrderValuationV2 + AvailableCarriers mapping + UI mapowan metod dostawy + presety przesylek) — fundament + zweryfikowany kontrakt API gotowy.
|
||||
- Phase 127 follow-up: drugi krok — `PolkurierTrackingService` + wpisy w `delivery_status_mappings` (provider='polkurier').
|
||||
- Phase 127 follow-up: po polkurier shipment service rozwazyc fazy paczkomaty (`InpostParcelMachines` / `PocztexPostOffices` / `Kurier48PostOffices` API juz dostepne w SDK polkuriera).
|
||||
- Phase 128 follow-up: uruchom migracje gdy XAMPP MySQL online: `php bin/migrate.php` (seed 7 wpisow `provider='polkurier'` w `delivery_status_mappings`).
|
||||
- Phase 128 follow-up: weryfikacja crona `shipment_tracking_sync` przy pierwszej zywej paczce polkurier w `in_transit` — sprawdz ze `shipment_packages.delivery_status` aktualizuje sie z `D`/`WP`/`Z` przez `DeliveryStatus::normalizeWithOverrides('polkurier', ...)`.
|
||||
- Phase 128 follow-up: rozmiar etykiety A4 vs A6 sterowany jest w panelu klienta polkurier.pl (Ustawienia konta → Preferencje etykiet), NIE przez API — operator ustawil A6.
|
||||
|
||||
## Deferred to Next Milestones
|
||||
|
||||
|
||||
@@ -26,3 +26,30 @@
|
||||
- `.paul/ROADMAP.md`
|
||||
- `.paul/phases/127-polkurier-integration-foundation/127-01-PLAN.md`
|
||||
- `.paul/phases/127-polkurier-integration-foundation/127-01-SUMMARY.md`
|
||||
|
||||
## Co zrobiono (cd.)
|
||||
|
||||
- [Phase 128, Plan 01] polkurier ShipmentService + TrackingService + UI prepare panel + delivery_status_mappings seed. polkurier zarejestrowany jako 4. provider w `ShipmentProviderRegistry` (obok allegro_wza/apaczka/inpost). Operator tworzy paczki z `/orders/{id}/shipment/prepare`, etykieta A6 generowana, cron tracking gotowy do mapowania O/P/A/WP/D/Z/W → created/confirmed/cancelled/in_transit/delivered/returned/problem.
|
||||
- Task 1: `PolkurierApiClient` rozszerzony z stubów Phase 127 do 7 metod (createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers/getInpostParcelMachines/getCourierPoints). Wspólny prywatny `call($apimetod, $data, $login, $token)` parsuje envelope `{status, response}`. Kontrakt zweryfikowany na oficjalnej dokumentacji PDF v1.11 (pobrana i zachowana w `.paul/phases/128-polkurier-shipment-service/polkurier-api-docs.txt`).
|
||||
- Task 2: `PolkurierShipmentService` (~520 LOC, implements ShipmentProviderInterface) + `PolkurierTrackingService` (~110 LOC, implements ShipmentTrackingInterface). `normalizeShipmentType()` mapuje legacy PACKAGE/BOX/PARCEL na lowercase zbiór polkuriera. `extractOrderNumber`/`extractTrackingNumber` priorytetują SDK Order entity (`number`, `waybills[0].number`).
|
||||
- Task 3: Wiring `routes/web.php` + `CronHandlerFactory` + `ShipmentController.prepare/create` (rozszerzony o `service_code`/`pickup_*` w form data). UI panel "polkurier" w `prepare.php` z dynamiczną listą usług + JS toggle. `DeliveryStatus::trackingUrl` fallback dla provider='polkurier'.
|
||||
- Task 4 (checkpoint live test #114/#115): 4 iteracje — ReferenceError w JS `clearHiddenFields` → uppercase `shipmenttype` → parsing `number` vs `orderno` → A4 vs A6 etykieta. Każda iteracja autopoprawiona w tej samej sesji APPLY.
|
||||
- Task 5: Migracja `20260514_000115_seed_polkurier_delivery_status_mappings.sql` z 7 wpisami z oficjalnej tabeli ORDER_STATUS PDF v1.11 (O/P/A/WP/D/Z/W). Idempotentna `ON DUPLICATE KEY UPDATE`.
|
||||
- Task 6: Aktualizacja `.paul/codebase/{architecture,db_schema,tech_changelog}.md` z sekcją Phase 128.
|
||||
- Scope removal vs PLAN: UI selektor punktów paczkomatowych usunięty (operator zgłosił duplikat z polem "Punkt odbioru" w sekcji Adres odbiorcy). `lookupPickupPoints` + AJAX route + JS handler usunięte. `getInpostParcelMachines`/`getCourierPoints` zostawione jako stuby na przyszłą fazę paczkomatów UI.
|
||||
- Decyzja: rozmiar etykiety A4/A6 sterowany w panelu klienta polkurier.pl, NIE przez API (zweryfikowane w PDF v1.11). Operator zmienia preferencje konta jednorazowo.
|
||||
|
||||
## Zmienione pliki (cd.)
|
||||
|
||||
- `src/Modules/Settings/PolkurierApiClient.php` (rozszerzenie z stubów do 7 metod)
|
||||
- `src/Modules/Shipments/PolkurierShipmentService.php` (nowy plik)
|
||||
- `src/Modules/Shipments/PolkurierTrackingService.php` (nowy plik)
|
||||
- `src/Modules/Shipments/DeliveryStatus.php` (fallback URL polkurier)
|
||||
- `src/Modules/Shipments/ShipmentController.php` (polkurierServices + service_code/pickup_*)
|
||||
- `src/Modules/Cron/CronHandlerFactory.php` (rejestracja PolkurierTrackingService)
|
||||
- `routes/web.php` (rejestracja PolkurierShipmentService w registry)
|
||||
- `resources/views/shipments/prepare.php` (panel polkurier + JS)
|
||||
- `database/migrations/20260514_000115_seed_polkurier_delivery_status_mappings.sql` (nowy plik)
|
||||
- `.paul/phases/128-polkurier-shipment-service/128-01-PLAN.md` (nowy plik)
|
||||
- `.paul/phases/128-polkurier-shipment-service/128-01-SUMMARY.md` (nowy plik)
|
||||
- `.paul/phases/128-polkurier-shipment-service/polkurier-api-docs.txt` (nowy plik — referencyjna doca z PDF v1.11)
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:**
|
||||
|
||||
392
.paul/phases/128-polkurier-shipment-service/128-01-PLAN.md
Normal file
392
.paul/phases/128-polkurier-shipment-service/128-01-PLAN.md
Normal file
@@ -0,0 +1,392 @@
|
||||
---
|
||||
phase: 128-polkurier-shipment-service
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/Modules/Settings/PolkurierApiClient.php
|
||||
- src/Modules/Shipments/PolkurierShipmentService.php
|
||||
- src/Modules/Shipments/PolkurierTrackingService.php
|
||||
- src/Modules/Shipments/ShipmentController.php
|
||||
- src/Modules/Shipments/DeliveryStatus.php
|
||||
- src/Modules/Cron/CronHandlerFactory.php
|
||||
- routes/web.php
|
||||
- resources/views/shipments/prepare.php
|
||||
- database/migrations/20260514_000115_seed_polkurier_delivery_status_mappings.sql
|
||||
- .paul/codebase/architecture.md
|
||||
- .paul/codebase/db_schema.md
|
||||
- .paul/codebase/tech_changelog.md
|
||||
autonomous: false
|
||||
delegation: off
|
||||
---
|
||||
|
||||
<objective>
|
||||
## Goal
|
||||
Dostarczyc pelna integracje wysylkowa polkurier.pl: tworzenie paczek (createOrder), pobieranie etykiet (getLabel), tracking statusow przesylek (getStatus) oraz UI do nadawania paczek w `/orders/{id}/shipment/prepare` z dynamiczna lista przewoznikow z polkuriera i obsluga punktow odbioru (Paczkomaty InPost, ORLEN, Pocztex, Kurier48). Weryfikacja na zywych zamowieniach #114 i #115 z manualnym anulowaniem po teście.
|
||||
|
||||
## Purpose
|
||||
Phase 127 dostarczyl fundament (settings + test_auth_api). Bez ShipmentService polkurier jest "wystawiony w hubie integracji ale niedzialajacy". Operator chce realnie nadawac paczki przez polkurier obok Apaczki — wspolny use case (DPD, UPS, GLS, InPost) z lepszymi cenami.
|
||||
|
||||
## Output
|
||||
- 2 nowe klasy w `src/Modules/Shipments/` (`PolkurierShipmentService`, `PolkurierTrackingService`) implementujace odpowiednio `ShipmentProviderInterface` i `ShipmentTrackingInterface`
|
||||
- Rozszerzony `PolkurierApiClient` z 6 nowymi metodami API
|
||||
- Nowy panel "polkurier" w `prepare.php` + przelacznik JS
|
||||
- Migracja seedujaca `delivery_status_mappings` (provider='polkurier')
|
||||
- Architektura/schema/changelog zaktualizowane
|
||||
- 2 paczki utworzone na zywym koncie polkurier (#114 i #115), zweryfikowane, recznie anulowane przez operatora
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
|
||||
<clarifications>
|
||||
- **Zakres fazy** — Co dostarczamy w Phase 128?
|
||||
→ Odpowiedz: Pelny zakres: ShipmentService + TrackingService + UI prepare + delivery_status_mappings (jedna duza faza zamiast dwoch).
|
||||
- **Uslugi UI** — Jak prezentujemy uslugi polkurier w UI prepare?
|
||||
→ Odpowiedz: Dynamiczna lista z API polkuriera (`get_available_carriers` lub odpowiednia metoda) — analog `ApaczkaApiClient::getServiceStructure`.
|
||||
- **Paczkomaty** — Czy obslugujemy paczkomaty/punkty odbioru w Phase 128?
|
||||
→ Odpowiedz: Pelne wsparcie wszystkich punktow (InpostParcelMachines, PocztexPostOffices, Kurier48PostOffices, ewentualnie ORLEN).
|
||||
- **Tryb testu** — Jak testujemy na #114/#115?
|
||||
→ Odpowiedz: Realny createOrder na zywym koncie + manualny cancelOrder po weryfikacji przez operatora w panelu polkurier (live test, ale bez wysylki).
|
||||
</clarifications>
|
||||
|
||||
## Project Context
|
||||
@.paul/PROJECT.md
|
||||
@.paul/ROADMAP.md
|
||||
@.paul/STATE.md
|
||||
@.paul/codebase/architecture.md
|
||||
@.paul/codebase/db_schema.md
|
||||
|
||||
## Prior Work
|
||||
@.paul/phases/127-polkurier-integration-foundation/127-01-SUMMARY.md
|
||||
|
||||
## Source Files (wzorzec Apaczka)
|
||||
@src/Modules/Shipments/ApaczkaShipmentService.php
|
||||
@src/Modules/Shipments/ApaczkaTrackingService.php
|
||||
@src/Modules/Settings/ApaczkaApiClient.php
|
||||
@src/Modules/Shipments/ShipmentProviderInterface.php
|
||||
@src/Modules/Shipments/ShipmentTrackingInterface.php
|
||||
@src/Modules/Shipments/ShipmentProviderRegistry.php
|
||||
@src/Modules/Settings/PolkurierApiClient.php
|
||||
@src/Modules/Settings/PolkurierIntegrationRepository.php
|
||||
@src/Modules/Shipments/ShipmentController.php
|
||||
@src/Modules/Cron/CronHandlerFactory.php
|
||||
@resources/views/shipments/prepare.php
|
||||
@routes/web.php
|
||||
|
||||
## External Reference
|
||||
- Oficjalne polkurier SDK: https://github.com/Polkurier/polkurier-sdk (zweryfikowany kontrakt API w Phase 127)
|
||||
- Klucze apimetod w SDK: `test_auth_api`, `new_order`, `get_label`, `get_status`, `cancel_order`, `get_available_carriers`, `get_parcel_machines`, `get_post_offices`, `get_carrier_info` (do potwierdzenia per SDK)
|
||||
</context>
|
||||
|
||||
<acceptance_criteria>
|
||||
|
||||
## AC-1: PolkurierApiClient — pelny kontrakt API
|
||||
```gherkin
|
||||
Given globalna konfiguracja polkurier jest aktywna i token zwery (Phase 127)
|
||||
When backend wywoluje `PolkurierApiClient::getAvailableCarriers()` z poprawnymi credentials
|
||||
Then klient zwraca tablice przewoznikow (DPD/UPS/GLS/InPost/Pocztex…) z polami: `carrier_id`, `name`, `service_code`, `supports_pickup_point` (bool), `weight_limits`, `cod_supported`
|
||||
And `createShipment($payload)` zwraca tablice `{order_id: string, tracking_number: string, label_url: ?string, raw: array}` przy `status='success'`
|
||||
And `getLabel($orderId, $format)` zwraca binarna zawartosc etykiety (PDF/ZPL/EPL zaleznie od `default_label_format`)
|
||||
And `getStatus($orderId)` zwraca tablice `{status_code, status_name, status_date}`
|
||||
And `cancelOrder($orderId)` zwraca `{ok: bool, message: string}`
|
||||
And kazda metoda przy `status != 'success'` rzuca `RuntimeException` z trescia z pola `response`
|
||||
```
|
||||
|
||||
## AC-2: PolkurierShipmentService implementuje ShipmentProviderInterface
|
||||
```gherkin
|
||||
Given `PolkurierShipmentService` jest zarejestrowany w `ShipmentProviderRegistry` jako `code()='polkurier'`
|
||||
When `createShipment(int $orderId, array $formData)` jest wywolane z danymi zamowienia #114 lub #115
|
||||
Then service buduje payload polkurier (sender z `company_settings`, receiver z order_addresses, paczka z formData), woła `PolkurierApiClient::createShipment()`, zapisuje wynik do `shipment_packages` (provider='polkurier', tracking_number, label_path po pobraniu, payload_json)
|
||||
And `downloadLabel($packageId, $storagePath)` pobiera plik etykiety i aktualizuje `shipment_packages.label_path`
|
||||
And `checkCreationStatus($packageId)` zwraca aktualny stan z `shipment_packages` (sync vs `getStatus()` API)
|
||||
And `getDeliveryServices()` zwraca cache'owana liste przewoznikow z `getAvailableCarriers()` (per-request)
|
||||
```
|
||||
|
||||
## AC-3: PolkurierTrackingService cron tracking
|
||||
```gherkin
|
||||
Given paczka z `provider='polkurier'` i `tracking_number` istnieje w `shipment_packages`
|
||||
When `ShipmentTrackingHandler` (cron) wywoluje `PolkurierTrackingService::getDeliveryStatus($package)`
|
||||
Then service woła `PolkurierApiClient::getStatus()`, parsuje surowy status i mapuje przez `delivery_status_mappings(provider='polkurier')` na znormalizowany status z `delivery_statuses`
|
||||
And zwraca `{status: <normalized>, status_raw: <polkurier_code>, description: <polkurier_label>}`
|
||||
And `supports('polkurier')` zwraca true
|
||||
```
|
||||
|
||||
## AC-4: UI prepare.php panel polkurier
|
||||
```gherkin
|
||||
Given operator wszedl na `/orders/115/shipment/prepare`
|
||||
When wybiera "polkurier" z dropdowna przewoznika
|
||||
Then JS pokazuje panel `#shipment-polkurier-panel` z dynamicznym selectem uslug (zaladowanym z `getAvailableCarriers`)
|
||||
And dla uslug `supports_pickup_point=true` pojawia sie selektor punktu odbioru z listą punktów odpowiedniego typu (InPost/Pocztex/Kurier48/ORLEN)
|
||||
And ukryty input `provider_code` ustawia sie na `polkurier`
|
||||
And submit formularza tworzy paczke przez `ShipmentController::store()` -> `ShipmentProviderRegistry::get('polkurier')->createShipment()`
|
||||
```
|
||||
|
||||
## AC-5: delivery_status_mappings + /settings/delivery-statuses
|
||||
```gherkin
|
||||
Given migracja `20260514_000115_seed_polkurier_delivery_status_mappings.sql` wykonana
|
||||
When operator otwiera `/settings/delivery-statuses` (tab 'mapping')
|
||||
Then widoczne sa wpisy `provider='polkurier'` z surowymi statusami polkuriera mapowanymi na znormalizowane statusy z `delivery_statuses` (np. `nowa_paczka` -> `registered`, `w_doreczeniu` -> `out_for_delivery`, `doreczone` -> `delivered`, `anulowane` -> `cancelled`)
|
||||
And `DeliveryStatus::trackingUrl('polkurier', $tracking, $carrierId)` zwraca poprawny link sledzenia (URL polkuriera lub bezposrednio przewoznika)
|
||||
```
|
||||
|
||||
## AC-6: Live test na zamowieniach #114 i #115
|
||||
```gherkin
|
||||
Given operator ma aktywne konto polkurier (Phase 127 test "Autoryzacja: 1")
|
||||
When operator nadaje paczki na #114 i #115 przez nowy panel UI
|
||||
Then dla obu zamowien `shipment_packages` zawiera wiersz `provider='polkurier'`, `status='created'`, niepusty `tracking_number`, sciezke `label_path` do pobranej etykiety
|
||||
And operator widzi etykiety jako PDF w `/orders/{id}` zakladka Przesylki
|
||||
And operator recznie anuluje obie paczki w panelu polkurier.pl po weryfikacji (poza zakresem kodu — manualna akcja w UI polkuriera)
|
||||
```
|
||||
|
||||
</acceptance_criteria>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: PolkurierApiClient — implementacja pelnego kontraktu API</name>
|
||||
<files>src/Modules/Settings/PolkurierApiClient.php</files>
|
||||
<action>
|
||||
Zastapic stuby `createShipment/getLabel/getStatus/cancelOrder` realnymi implementacjami i dodac `getAvailableCarriers`, `getParcelMachines`, `getPostOffices`.
|
||||
|
||||
Wspolny helper `private function call(string $apimetod, array $data, string $login, string $apiToken): array`:
|
||||
- Buduje payload `{authorization: {login, token}, apimetod, data}`.
|
||||
- Wykorzystuje istniejacy `postJson()` (Phase 127).
|
||||
- Parsuje envelope: jezeli `status === 'success'` zwraca `$decoded['response']` (array). W przeciwnym razie rzuca `RuntimeException` z trescia `response` (string albo zserializowany JSON).
|
||||
- PHP 8.5: NIE wywolywac `curl_close()`.
|
||||
|
||||
Metody publiczne:
|
||||
- `createShipment(string $login, string $apiToken, array $payload): array` — apimetod `new_order` (sprawdzic dokladna nazwe w SDK polkurier-sdk/src/Api). Zwraca `{order_id, tracking_number, raw}`.
|
||||
- `getLabel(string $login, string $apiToken, string $orderId, string $format): string` — apimetod `get_label`, format=PDF/ZPL/EPL. Zwraca surowa zawartosc base64-decoded jezeli polkurier zwraca base64; w przeciwnym razie binarny stream. Sprawdzic odpowiedz API.
|
||||
- `getStatus(string $login, string $apiToken, string $orderId): array` — apimetod `get_status`. Zwraca `{status_code, status_name, status_date, raw}`.
|
||||
- `cancelOrder(string $login, string $apiToken, string $orderId): array` — apimetod `cancel_order`. (NIE bedzie wywolywana w Phase 128 — operator anuluje w UI polkuriera, ale metoda dostepna dla przyszlych planow.)
|
||||
- `getAvailableCarriers(string $login, string $apiToken): array` — apimetod `get_available_carriers` (potwierdzic w SDK; mozliwe alternatywy: `get_carriers`, `get_services`). Zwraca liste przewoznikow.
|
||||
- `getParcelMachines(string $login, string $apiToken, string $type, ?string $postalCode = null): array` — apimetod `get_parcel_machines`. type=InPost/Pocztex/Kurier48/ORLEN.
|
||||
- `getPostOffices(string $login, string $apiToken, string $type): array` — apimetod `get_post_offices` (jezeli polkurier rozdziela).
|
||||
|
||||
UWAGA: dokladne nazwy `apimetod` zweryfikowac wzgledem `polkurier-sdk` (https://github.com/Polkurier/polkurier-sdk) — pliki `src/Api/*Api.php`. Jezeli nazwa rozna od zakladanej, dostosowac stale prywatne (`private const APIMETOD_NEW_ORDER = '...'` itp.).
|
||||
|
||||
Avoid: hardcodowane mapowanie statusow (zostawiamy `DeliveryStatusMappingRepository`), zmiany w `testConnection()` (dziala od Phase 127), wprowadzania `Content-Type: application/json; charset=UTF-8` (polkurier odrzuca — zachowac dokladnie `application/json`).
|
||||
</action>
|
||||
<verify>
|
||||
1. `php -l src/Modules/Settings/PolkurierApiClient.php` (no syntax errors).
|
||||
2. Operator wywoluje przyszly smoke test: `php bin/smoke-polkurier.php` (skrypt z Task 4) — pierwszy crash wskaze brakujace pole.
|
||||
3. Rzut `RuntimeException` zawiera tresc z pola `response` (nie `status: error`).
|
||||
</verify>
|
||||
<done>AC-1 satisfied: wszystkie 7 metod publicznych zaimplementowane wzorem `testConnection()`; envelope `{status, response}` parsowany jednolicie; bledy rzucane z trescia z `response`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: PolkurierShipmentService + PolkurierTrackingService</name>
|
||||
<files>src/Modules/Shipments/PolkurierShipmentService.php, src/Modules/Shipments/PolkurierTrackingService.php, src/Modules/Shipments/DeliveryStatus.php</files>
|
||||
<action>
|
||||
**PolkurierShipmentService** — `final class implements ShipmentProviderInterface`, wzorzec `ApaczkaShipmentService` (1044 LOC, ale polkurier prostszy — ~500-700 LOC):
|
||||
|
||||
Konstruktor (manualny DI, mirror Apaczki):
|
||||
- `PolkurierIntegrationRepository $integrationRepository`
|
||||
- `PolkurierApiClient $apiClient`
|
||||
- `ShipmentPackageRepository $packages`
|
||||
- `CompanySettingsRepository $companySettings`
|
||||
- `OrdersRepository $ordersRepository`
|
||||
|
||||
Metody:
|
||||
- `code(): string` -> `'polkurier'`.
|
||||
- `getDeliveryServices(): array` — `getCredentials()` -> `$apiClient->getAvailableCarriers()`. Cache per-request (`private ?array $servicesCache = null`).
|
||||
- `createShipment(int $orderId, array $formData): array`:
|
||||
1. `findDetails($orderId)` z OrdersRepository; throw `ShipmentException` gdy null.
|
||||
2. `requireCredentials()` -> `[$login, $token]` z `PolkurierIntegrationRepository::getCredentials()`; throw `IntegrationConfigException` gdy null.
|
||||
3. Sender z `CompanySettingsRepository::getSenderAddress()` + walidacja (`validateSenderAddress`).
|
||||
4. Receiver z `order_addresses` (delivery type), normalizacja telefonu/postal_code.
|
||||
5. Wybor uslugi z `$formData['service_code']` lub `$formData['carrier_id']`.
|
||||
6. Wymiary/waga z formData (z domyslnymi z `company_settings.default_package_*`).
|
||||
7. Punkt odbioru: jezeli `$formData['receiver_point_id']` niepuste -> wstawiamy w payload polkuriera (klucz zaleznie od SDK).
|
||||
8. COD/insurance z formData.
|
||||
9. `$apiClient->createShipment($login, $token, $payload)` -> `{order_id, tracking_number, ...}`.
|
||||
10. `$packages->insert([...])` z `provider='polkurier'`, `command_id=order_id`, `tracking_number`, `status='created'`, `payload_json=zserializowany_payload`.
|
||||
11. Synchroniczne pobranie etykiety przez `downloadLabel($packageId, $storagePath)` — analog Apaczki, ktora pobiera label do `storage/labels/`.
|
||||
12. Zwroc `['package_id' => ..., 'tracking_number' => ..., 'label_path' => ...]`.
|
||||
- `checkCreationStatus(int $packageId): array` — fetch z `shipment_packages`; jezeli `status='draft'` -> pingnij `getStatus()` API i zaktualizuj.
|
||||
- `downloadLabel(int $packageId, string $storagePath): array` — `$apiClient->getLabel($login, $token, $orderId, $package['label_format'] ?? 'PDF')`; zapisz do `$storagePath/polkurier_{packageId}.pdf` (lub `.zpl`); update `shipment_packages.label_path`.
|
||||
|
||||
**PolkurierTrackingService** — `final class implements ShipmentTrackingInterface`:
|
||||
- `supports(string $provider): bool` -> `strtolower($provider) === 'polkurier'`.
|
||||
- `getDeliveryStatus(array $package): ?array`:
|
||||
1. `requireCredentials()`; zwroc null gdy konfiguracja nieaktywna (cron nie powinien rzucac, tylko skipowac).
|
||||
2. `$apiClient->getStatus($login, $token, $package['command_id'])`.
|
||||
3. Mapuj `status_code` przez `DeliveryStatusMappingRepository::findNormalized('polkurier', $rawStatus)`.
|
||||
4. Fallback `unknown` gdy brak mapowania (analog Apaczki).
|
||||
5. Zwroc `['status' => $normalized, 'status_raw' => $rawStatus, 'description' => $statusName]`.
|
||||
|
||||
**DeliveryStatus.php** — dolozyc obsluge providera `polkurier` w `trackingUrl(string $provider, string $tracking, string $carrierId): string`:
|
||||
- Polkurier deleguje do przewoznika docelowego — `carrierId` mowi nam ktory. Fallback URL `https://polkurier.pl/sledzenie/<tracking>` (jezeli polkurier ma taki) lub URL przewoznika docelowego z istniejacych branch-ow (`inpost`, `dpd`, etc.). MVP: zwroc URL bazowy polkurier + tracking_number.
|
||||
|
||||
Avoid:
|
||||
- Hardcodowania URL tracking polkuriera bez weryfikacji (sprawdzic w panelu polkurier lub SDK).
|
||||
- Pobierania etykiety w osobnym requeście jezeli polkurier zwraca `label_base64` w odpowiedzi createShipment (parsowac i zapisywac od razu).
|
||||
- Throwowania w `PolkurierTrackingService::getDeliveryStatus` przy braku credentials — cron musi byc odporny.
|
||||
</action>
|
||||
<verify>
|
||||
1. `php -l src/Modules/Shipments/PolkurierShipmentService.php` i `PolkurierTrackingService.php`.
|
||||
2. Manualnie: po Task 3 wejdz na `/orders/114/shipment/prepare`, wybierz polkurier — dropdown zwraca przewoznikow z API.
|
||||
</verify>
|
||||
<done>AC-2 i AC-3 satisfied: oba serwisy implementuja interfejsy, integruja z `PolkurierApiClient`, `ShipmentPackageRepository` i `DeliveryStatusMappingRepository`. Kontrakt Phase 127 (single global config, `is_active=1` guard) zachowany.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Wiring + UI prepare.php panel polkurier</name>
|
||||
<files>routes/web.php, src/Modules/Shipments/ShipmentController.php, src/Modules/Cron/CronHandlerFactory.php, resources/views/shipments/prepare.php</files>
|
||||
<action>
|
||||
**routes/web.php**:
|
||||
- W `$shipmentProviderRegistry = new ShipmentProviderRegistry([...])` (linia ~474) dolozyc `new PolkurierShipmentService($polkurierIntegrationRepository, new PolkurierApiClient(), $shipmentPackageRepository, $companySettingsRepository, new OrdersRepository(...))`.
|
||||
- Dolozyc `use App\Modules\Shipments\PolkurierShipmentService;` w nagłowku.
|
||||
|
||||
**CronHandlerFactory.php** (linia ~166-169):
|
||||
- W tablicy trackerow dolozyc `new PolkurierTrackingService($polkurierIntegrationRepository, $polkurierApiClient, $deliveryStatusMappingRepository)`.
|
||||
- Dolozyc `use App\Modules\Shipments\PolkurierTrackingService;` i `use App\Modules\Settings\PolkurierIntegrationRepository;`/`PolkurierApiClient;`.
|
||||
|
||||
**ShipmentController.php**:
|
||||
- Wstrzyknac `PolkurierShipmentService $polkurierService` (lub uzywac z registry).
|
||||
- W `prepare()` przekazac do widoku `$polkurierServices = $polkurierService->getDeliveryServices()` (try/catch — empty array on failure).
|
||||
|
||||
**resources/views/shipments/prepare.php** — wzorzec panelu Apaczki (linie 172-216):
|
||||
- Dolozyc `<option value="polkurier">polkurier</option>` do `#carrierSelect` (linia ~103).
|
||||
- Dolozyc panel `<div id="shipment-polkurier-panel">` z:
|
||||
- `<select id="shipment-polkurier-service-select">` wypelniony `$polkurierServices` (carrier_id, name).
|
||||
- Conditional pickup point picker (`<select id="shipment-polkurier-point-select">`) widoczny gdy wybrana usluga ma `supports_pickup_point=true`. Lista punktow ladowana AJAX-em z nowego endpointu `/shipments/polkurier/points?type=InPost&postal=XX-XXX` (dodac route + metoda w `ShipmentController`).
|
||||
- Hidden input `name="service_code"` aktualizowany przez JS na podstawie selecta.
|
||||
- JS (linie ~580-650): dodac `polkurierSelect`/`polkurierPanel`/`polkurierPointSelect`; toggle widocznosci paneli; ustaw `providerInput.value = 'polkurier'` gdy carrier='polkurier'.
|
||||
|
||||
Avoid:
|
||||
- Powielania logiki Apaczki — uzyc tych samych helperow JS gdzie sie da.
|
||||
- Hardcodowania listy punktow w widoku — wszystko z API polkuriera przez AJAX.
|
||||
</action>
|
||||
<verify>
|
||||
1. `/orders/114/shipment/prepare` — dropdown przewoznika ma "polkurier"; po wyborze pokazuje panel.
|
||||
2. Select uslug ma realne wartosci z polkuriera.
|
||||
3. `php -l routes/web.php` i `CronHandlerFactory.php`.
|
||||
</verify>
|
||||
<done>AC-4 satisfied: panel polkurier widoczny w prepare.php, integracja z registry, cron tracker zarejestrowany.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<what-built>Live test polkurier ShipmentService na zamowieniach #114 i #115. Operator nadaje paczki przez nowy panel UI, weryfikuje wynik, recznie anuluje w panelu polkurier po weryfikacji.</what-built>
|
||||
<how-to-verify>
|
||||
1. XAMPP MySQL online + cron disabled (zeby tracking nie zaczal pingowac przed weryfikacja).
|
||||
2. Otworz `/orders/114/shipment/prepare`:
|
||||
- Wybierz "polkurier" w dropdownie.
|
||||
- Wybierz usluge kuriera (np. DPD Standard albo InPost Kurier).
|
||||
- Uzupelnij wymiary/wage; potwierdz adres odbiorcy.
|
||||
- Submit.
|
||||
3. Sprawdz redirect na `/orders/114` -> zakladka Przesylki:
|
||||
- Wiersz `provider=polkurier`, niepusty `tracking_number`, link "Pobierz etykiete" otwiera PDF.
|
||||
4. Powtorz dla zamowienia #115 — ten raz z usluga paczkomatowa (InPost Paczkomat); selektor punktu pokazuje liste paczkomatow z API.
|
||||
5. Otworz panel polkurier.pl manualnie -> zobacz utworzone paczki.
|
||||
6. Anuluj obie paczki w panelu polkurier (manualna akcja).
|
||||
7. Zglos wynik: "OK" jezeli paczki utworzone i etykiety pobrane, "issues" z opisem co nie dziala.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" to continue with delivery_status_mappings seed, or describe issues to fix</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Migracja seed delivery_status_mappings + weryfikacja /settings/delivery-statuses</name>
|
||||
<files>database/migrations/20260514_000115_seed_polkurier_delivery_status_mappings.sql</files>
|
||||
<action>
|
||||
Stworzyc migracje seedujaca `delivery_status_mappings` (provider='polkurier') na podstawie REALNYCH statusow zwracanych przez API polkurier w Task 1 (operator po live tescie zna konkretne `status_code` z `getStatus`).
|
||||
|
||||
Baseline mapowan (do dostrojenia po live tescie):
|
||||
- `nowa` / `przyjete` / `oczekuje` -> `registered`
|
||||
- `wydrukowane` / `przygotowanie` -> `label_printed` (jezeli istnieje w `delivery_statuses` — sprawdzic w Phase 108)
|
||||
- `nadane` / `w_dostawie` / `w_doreczeniu` -> `in_transit` lub `out_for_delivery`
|
||||
- `doreczone` / `odebrane` -> `delivered`
|
||||
- `zwrocone` -> `returned`
|
||||
- `anulowane` -> `cancelled`
|
||||
- `niedoreczone` / `blad_doreczenia` -> `delivery_failed` lub `unknown` (zaleznie od `delivery_statuses`)
|
||||
|
||||
Migracja musi byc idempotentna: `INSERT INTO delivery_status_mappings (provider, raw_status, normalized_status, description) VALUES (...) ON DUPLICATE KEY UPDATE normalized_status = VALUES(normalized_status), description = VALUES(description);`.
|
||||
|
||||
Uruchom `php bin/migrate.php` po zatwierdzeniu mapowan.
|
||||
|
||||
Otworz `/settings/delivery-statuses?tab=mapping` -> potwierdz widocznosc wpisow provider='polkurier'.
|
||||
|
||||
Avoid:
|
||||
- Seedu na bazie przypuszczen — uzyj statusow ZAOBSERWOWANYCH w live tescie z Task checkpoint.
|
||||
- Tworzenia nowych wpisow w `delivery_statuses` (jezeli polkurier zwraca status ktorego nie ma — dodaj rownolegly INSERT do tej migracji albo osobna migracje).
|
||||
</action>
|
||||
<verify>
|
||||
1. `php bin/migrate.php` zwraca success.
|
||||
2. `SELECT COUNT(*) FROM delivery_status_mappings WHERE provider='polkurier'` >= 6.
|
||||
3. `/settings/delivery-statuses?tab=mapping` pokazuje wiersze polkurier.
|
||||
4. Ponowne uruchomienie migracji = no-op (idempotencja).
|
||||
</verify>
|
||||
<done>AC-5 satisfied: mapowania wpisane do DB, widoczne w UI, idempotentne.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Aktualizacja dokumentacji codebase</name>
|
||||
<files>.paul/codebase/architecture.md, .paul/codebase/db_schema.md, .paul/codebase/tech_changelog.md</files>
|
||||
<action>
|
||||
**architecture.md** — dolozyc sekcje "Phase 128 — polkurier ShipmentService + Tracking" pod sekcja Phase 127:
|
||||
- PolkurierApiClient — pelna lista 7 publicznych metod z apimetod-ami.
|
||||
- PolkurierShipmentService — kontrakt ShipmentProviderInterface, lista pol payloadu, integracja z `getAvailableCarriers`.
|
||||
- PolkurierTrackingService — kontrakt ShipmentTrackingInterface, mapowanie statusow.
|
||||
- Wiring w `ShipmentProviderRegistry` i `CronHandlerFactory`.
|
||||
- UI prepare.php panel polkurier — selector uslug + pickup point ajax.
|
||||
|
||||
**db_schema.md** — dolozyc seed mapping rows w sekcji "delivery_status_mappings":
|
||||
- Wymienic mapowania `provider='polkurier'` z migracji.
|
||||
|
||||
**tech_changelog.md** — wpis z data 2026-05-14 (lub data wdrozenia):
|
||||
- "Phase 128 (polkurier ShipmentService): pelna implementacja API client (createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers/getParcelMachines/getPostOffices), PolkurierShipmentService implementing ShipmentProviderInterface, PolkurierTrackingService, UI panel w prepare.php, seed delivery_status_mappings."
|
||||
|
||||
Avoid: kopiowania kodu do dokumentacji (zostawic referencje sciezek + 1-2 zdania kontraktu).
|
||||
</action>
|
||||
<verify>
|
||||
Manualnie przegladnac diff `.paul/codebase/*.md` — wpisy obecne, formatowanie spojne z Phase 127.
|
||||
</verify>
|
||||
<done>Dokumentacja zaktualizowana zgodnie z CLAUDE.md (sekcja "Utrwalanie stalych wymagan").</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<boundaries>
|
||||
|
||||
## DO NOT CHANGE
|
||||
- `src/Modules/Settings/PolkurierIntegrationRepository.php` — Phase 127, kontrakt stabilny.
|
||||
- `src/Modules/Settings/PolkurierApiClient::testConnection()` — Phase 127, zweryfikowany na zywym koncie ("Autoryzacja: 1").
|
||||
- `apaczka_integration_settings`, `ApaczkaShipmentService`, `ApaczkaTrackingService` — Apaczka dziala niezaleznie obok polkuriera.
|
||||
- `database/migrations/20260514_000114_create_polkurier_integration_settings.sql` (Phase 127).
|
||||
- `ShipmentProviderInterface` i `ShipmentTrackingInterface` — kontrakty stabilne (nie dolozyc/zmienic metod).
|
||||
- Reszta paneli w `prepare.php` (allegro/inpost/apaczka) — tylko dolozenie nowego panelu polkurier obok.
|
||||
|
||||
## SCOPE LIMITS
|
||||
- BEZ implementacji `OrderValuationV2` (wycena przed nadaniem) — odlozone na osobna faze.
|
||||
- BEZ presetow przesylek dla polkuriera (`shipment_presets.provider_code='polkurier'`) — operator moze ich uzywac dopiero jak panel polkurier dziala; presety w osobnej fazie.
|
||||
- BEZ widoku CLI smoke test scriptu (`bin/smoke-polkurier.php`) — testujemy w realnym UI na #114/#115.
|
||||
- BEZ event automatyzacji `shipment.created` zmian — to zdarzenie juz emitowane jednolicie z `ShipmentController::store()` dla wszystkich providerow.
|
||||
- BEZ idempotencji createShipment (double-POST guard) — jak w Apaczce, brak retry guard w MVP.
|
||||
- BEZ refaktoringu wspolnego kodu Apaczka/polkurier (`buildReceiverAddress`, `validateSenderAddress` itp.) — kopiujemy wzorzec, deduplikacja w osobnym planie.
|
||||
|
||||
</boundaries>
|
||||
|
||||
<verification>
|
||||
Before declaring plan complete:
|
||||
- [ ] `php -l` przechodzi dla wszystkich zmienionych plikow PHP
|
||||
- [ ] Migracja `20260514_000115_*.sql` wykonana, ponowne uruchomienie = no-op
|
||||
- [ ] Operator potwierdzil checkpoint: 2 paczki utworzone na #114 i #115, etykiety pobrane, manualnie anulowane w panelu polkurier
|
||||
- [ ] `/settings/delivery-statuses?tab=mapping` pokazuje wpisy provider='polkurier'
|
||||
- [ ] `/orders/{id}/shipment/prepare` panel polkurier widoczny i funkcjonalny
|
||||
- [ ] Cron tracking nie crashuje (sprawdzic `storage/logs/app.log` po jednym przebiegu)
|
||||
- [ ] Dokumentacja `.paul/codebase/*.md` zaktualizowana
|
||||
- [ ] Wszystkie AC-1..AC-6 spelnione
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Operator moze nadawac paczki przez polkurier z poziomu `/orders/{id}/shipment/prepare` w 4 trybach: kurier door-to-door, paczkomat InPost, punkt Pocztex/Kurier48, ORLEN.
|
||||
- Tracking polkurier dziala w cronie i aktualizuje `shipment_packages.delivery_status` przez `delivery_status_mappings`.
|
||||
- Live test na #114 i #115 zakonczony sukcesem (paczki utworzone, etykiety pobrane, recznie anulowane).
|
||||
- Zaden istniejacy provider (Apaczka/InPost/Allegro WZA) nie regresuje.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.paul/phases/128-polkurier-shipment-service/128-01-SUMMARY.md`
|
||||
</output>
|
||||
221
.paul/phases/128-polkurier-shipment-service/128-01-SUMMARY.md
Normal file
221
.paul/phases/128-polkurier-shipment-service/128-01-SUMMARY.md
Normal file
@@ -0,0 +1,221 @@
|
||||
---
|
||||
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*
|
||||
2963
.paul/phases/128-polkurier-shipment-service/polkurier-api-docs.txt
Normal file
2963
.paul/phases/128-polkurier-shipment-service/polkurier-api-docs.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
-- Phase 128-01: seed delivery_status_mappings dla provider='polkurier'.
|
||||
-- Kody zaczerpniete z oficjalnej dokumentacji API polkurier v1.11 (marzec 2026),
|
||||
-- tabela "Status zlecenia ORDER_STATUS":
|
||||
-- O = Oczekuje (zlecenie zapisane, oczekuje na platnosc)
|
||||
-- P = Potwierdzone (zlecenie zapisane, list przewozowy wygenerowany, oczekuje odbioru)
|
||||
-- A = Anulowane (przesylka anulowana)
|
||||
-- WP = W przewozie (odebrana od nadawcy, w drodze do adresata)
|
||||
-- D = Dostarczona (dotarla do adresata)
|
||||
-- Z = Zwrot do nadawcy (odmowa odbioru przez odbiorce)
|
||||
-- W = Wyjatek (problem z doreczeniem)
|
||||
-- Status PZ (Podjazd zbiorczy) jest filtrem wyszukiwania get_orders, nie wystepuje jako
|
||||
-- samodzielny status zlecenia -- pomijamy w seedzie.
|
||||
--
|
||||
-- Migracja idempotentna: ON DUPLICATE KEY UPDATE nadpisuje normalized_status i description.
|
||||
|
||||
INSERT INTO delivery_status_mappings (provider, raw_status, normalized_status, description, created_at, updated_at) VALUES
|
||||
('polkurier', 'O', 'created', 'Oczekuje (oczekuje na platnosc)', NOW(), NOW()),
|
||||
('polkurier', 'P', 'confirmed', 'Potwierdzone (list przewozowy wygenerowany)', NOW(), NOW()),
|
||||
('polkurier', 'A', 'cancelled', 'Anulowane', NOW(), NOW()),
|
||||
('polkurier', 'WP', 'in_transit','W przewozie', NOW(), NOW()),
|
||||
('polkurier', 'D', 'delivered', 'Dostarczona', NOW(), NOW()),
|
||||
('polkurier', 'Z', 'returned', 'Zwrot do nadawcy', NOW(), NOW()),
|
||||
('polkurier', 'W', 'problem', 'Wyjatek (problem z doreczeniem)', NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
normalized_status = VALUES(normalized_status),
|
||||
description = VALUES(description),
|
||||
updated_at = NOW();
|
||||
@@ -6,6 +6,7 @@ $prefs = is_array($preferences ?? null) ? $preferences : [];
|
||||
$comp = is_array($company ?? null) ? $company : [];
|
||||
$services = is_array($deliveryServices ?? null) ? $deliveryServices : [];
|
||||
$apaczkaSvcList = is_array($apaczkaServices ?? null) ? $apaczkaServices : [];
|
||||
$polkurierSvcList = is_array($polkurierServices ?? null) ? $polkurierServices : [];
|
||||
$packages = is_array($existingPackages ?? null) ? $existingPackages : [];
|
||||
$pendingPrintIds = is_array($pendingPrintPackageIds ?? null) ? $pendingPrintPackageIds : [];
|
||||
$servicesError = (string) ($deliveryServicesError ?? '');
|
||||
@@ -101,6 +102,7 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
|
||||
<option value="allegro"<?= $preselectedCarrier === 'allegro' ? ' selected' : '' ?>>Allegro</option>
|
||||
<option value="inpost"<?= $preselectedCarrier === 'inpost' ? ' selected' : '' ?>>InPost</option>
|
||||
<option value="apaczka"<?= $preselectedCarrier === 'apaczka' ? ' selected' : '' ?>>Apaczka</option>
|
||||
<option value="polkurier"<?= $preselectedCarrier === 'polkurier' ? ' selected' : '' ?>>polkurier</option>
|
||||
</select>
|
||||
<?php if ($deliveryMethodName !== ''): ?>
|
||||
<div class="muted mt-4" style="font-size:12px">Metoda z zamowienia: <strong><?= $e($deliveryMethodName) ?></strong><?php if ($mappedServiceName !== ''): ?> → <?= $e($mappedCarrier === 'inpost' ? 'InPost' : ($mappedCarrier === 'apaczka' ? 'Apaczka' : 'Allegro')) ?>: <?= $e($mappedServiceName) ?><?php endif; ?></div>
|
||||
@@ -209,12 +211,45 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="shipment-polkurier-panel" style="<?= $preselectedCarrier !== 'polkurier' ? 'display:none' : '' ?>">
|
||||
<?php if ($polkurierSvcList === []): ?>
|
||||
<div class="muted">Brak uslug polkurier (sprawdz konfiguracje w Ustawienia → Integracje → polkurier).</div>
|
||||
<?php else: ?>
|
||||
<select class="form-control" id="shipment-polkurier-select">
|
||||
<option value="">-- Wybierz usluge polkurier --</option>
|
||||
<?php foreach ($polkurierSvcList as $pSvc): ?>
|
||||
<?php
|
||||
if (!is_array($pSvc)) {
|
||||
continue;
|
||||
}
|
||||
$pSvcId = trim((string) ($pSvc['id'] ?? ''));
|
||||
$pSvcName = trim((string) ($pSvc['name'] ?? $pSvcId));
|
||||
if ($pSvcId === '') {
|
||||
continue;
|
||||
}
|
||||
?>
|
||||
<option
|
||||
value="<?= $e($pSvcId) ?>"
|
||||
data-carrier-id="<?= $e($pSvcId) ?>">
|
||||
<?= $e($pSvcName) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="muted mt-4" style="font-size:12px">Dla uslug paczkomatowych wpisz ID punktu w pole "Punkt odbioru" w sekcji Adres odbiorcy ponizej (np. <code>POP-RZE54</code>).</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div id="shipment-empty-panel" class="muted" style="<?= $preselectedCarrier !== '' ? 'display:none' : '' ?>">Wybierz przewoznika</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="credentials_id" id="shipment-credentials-id" value="">
|
||||
<input type="hidden" name="carrier_id" id="shipment-carrier-id" value="">
|
||||
<input type="hidden" name="provider_code" id="shipment-provider-code" value="<?= $e($preselectedCarrier === 'apaczka' ? 'apaczka' : 'allegro_wza') ?>">
|
||||
<input type="hidden" name="service_code" id="shipment-service-code" value="">
|
||||
<input type="hidden" name="provider_code" id="shipment-provider-code" value="<?php
|
||||
if ($preselectedCarrier === 'apaczka') { echo 'apaczka'; }
|
||||
elseif ($preselectedCarrier === 'polkurier') { echo 'polkurier'; }
|
||||
else { echo 'allegro_wza'; }
|
||||
?>">
|
||||
|
||||
<label class="form-field">
|
||||
<span class="field-label">Typ paczki</span>
|
||||
@@ -582,6 +617,8 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
|
||||
var carrierSelect = document.getElementById('shipment-carrier-select');
|
||||
var inpostSelect = document.getElementById('shipment-inpost-select');
|
||||
var apaczkaSelect = document.getElementById('shipment-apaczka-select');
|
||||
var polkurierSelect = document.getElementById('shipment-polkurier-select');
|
||||
var serviceCodeInput = document.getElementById('shipment-service-code');
|
||||
|
||||
document.querySelectorAll('form select.form-control').forEach(function (sel) {
|
||||
enhanceSelect(sel);
|
||||
@@ -590,6 +627,7 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
|
||||
var allegroPanel = document.getElementById('shipment-allegro-panel');
|
||||
var inpostPanel = document.getElementById('shipment-inpost-panel');
|
||||
var apaczkaPanel = document.getElementById('shipment-apaczka-panel');
|
||||
var polkurierPanel = document.getElementById('shipment-polkurier-panel');
|
||||
var emptyPanel = document.getElementById('shipment-empty-panel');
|
||||
|
||||
var wrapper = document.getElementById('shipment-service-wrapper');
|
||||
@@ -608,14 +646,20 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
|
||||
hiddenInput.value = '';
|
||||
credentialsInput.value = '';
|
||||
carrierInput.value = '';
|
||||
if (serviceCodeInput) serviceCodeInput.value = '';
|
||||
}
|
||||
|
||||
function showPanel(carrier) {
|
||||
if (allegroPanel) allegroPanel.style.display = carrier === 'allegro' ? '' : 'none';
|
||||
if (inpostPanel) inpostPanel.style.display = carrier === 'inpost' ? '' : 'none';
|
||||
if (apaczkaPanel) apaczkaPanel.style.display = carrier === 'apaczka' ? '' : 'none';
|
||||
if (polkurierPanel) polkurierPanel.style.display = carrier === 'polkurier' ? '' : 'none';
|
||||
if (emptyPanel) emptyPanel.style.display = carrier === '' ? '' : 'none';
|
||||
if (providerInput) providerInput.value = carrier === 'apaczka' ? 'apaczka' : 'allegro_wza';
|
||||
if (providerInput) {
|
||||
if (carrier === 'apaczka') providerInput.value = 'apaczka';
|
||||
else if (carrier === 'polkurier') providerInput.value = 'polkurier';
|
||||
else providerInput.value = 'allegro_wza';
|
||||
}
|
||||
}
|
||||
|
||||
var weekendWrap = document.getElementById('shipment-apaczka-weekend-wrap');
|
||||
@@ -644,6 +688,10 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
|
||||
apaczkaSelect.selectedIndex = 0;
|
||||
if (apaczkaSelect._syncTrigger) apaczkaSelect._syncTrigger();
|
||||
}
|
||||
if (polkurierSelect) {
|
||||
polkurierSelect.selectedIndex = 0;
|
||||
if (polkurierSelect._syncTrigger) polkurierSelect._syncTrigger();
|
||||
}
|
||||
allegroOpts.forEach(function (o) { o.classList.remove('is-selected'); });
|
||||
showPanel(carrierSelect.value);
|
||||
toggleWeekendOption();
|
||||
@@ -679,6 +727,24 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
|
||||
}
|
||||
toggleWeekendOption();
|
||||
|
||||
if (polkurierSelect) {
|
||||
function syncPolkurierFields() {
|
||||
var opt = polkurierSelect.options[polkurierSelect.selectedIndex];
|
||||
var serviceCode = polkurierSelect.value;
|
||||
hiddenInput.value = serviceCode;
|
||||
if (serviceCodeInput) serviceCodeInput.value = serviceCode;
|
||||
credentialsInput.value = '';
|
||||
carrierInput.value = opt ? (opt.getAttribute('data-carrier-id') || serviceCode) : serviceCode;
|
||||
if (providerInput) providerInput.value = 'polkurier';
|
||||
}
|
||||
|
||||
polkurierSelect.addEventListener('change', syncPolkurierFields);
|
||||
|
||||
if (carrierSelect.value === 'polkurier' && polkurierSelect.value !== '') {
|
||||
syncPolkurierFields();
|
||||
}
|
||||
}
|
||||
|
||||
if (wrapper && searchInput && dropdown) {
|
||||
var isAllegroOpen = false;
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ use App\Modules\Settings\DeliveryStatusMappingController;
|
||||
use App\Modules\Settings\DeliveryStatusesController;
|
||||
use App\Modules\Settings\SettingsController;
|
||||
use App\Modules\Shipments\ApaczkaShipmentService;
|
||||
use App\Modules\Shipments\PolkurierShipmentService;
|
||||
use App\Modules\Shipments\AllegroShipmentService;
|
||||
use App\Modules\Shipments\InpostShipmentService;
|
||||
use App\Modules\Shipments\ShipmentController;
|
||||
@@ -471,10 +472,18 @@ return static function (Application $app): void {
|
||||
$companySettingsRepository,
|
||||
new OrdersRepository($app->db())
|
||||
);
|
||||
$polkurierShipmentService = new PolkurierShipmentService(
|
||||
$polkurierIntegrationRepository,
|
||||
new PolkurierApiClient(),
|
||||
$shipmentPackageRepository,
|
||||
$companySettingsRepository,
|
||||
new OrdersRepository($app->db())
|
||||
);
|
||||
$shipmentProviderRegistry = new ShipmentProviderRegistry([
|
||||
$shipmentService,
|
||||
$apaczkaShipmentService,
|
||||
$inpostShipmentService,
|
||||
$polkurierShipmentService,
|
||||
]);
|
||||
$shipmentController = new ShipmentController(
|
||||
$template,
|
||||
|
||||
@@ -35,6 +35,8 @@ use App\Modules\Settings\EmailMailboxRepository;
|
||||
use App\Modules\Settings\EmailTemplateRepository;
|
||||
use App\Modules\Settings\InpostIntegrationRepository;
|
||||
use App\Modules\Settings\IntegrationSecretCipher;
|
||||
use App\Modules\Settings\PolkurierApiClient;
|
||||
use App\Modules\Settings\PolkurierIntegrationRepository;
|
||||
use App\Modules\Settings\ReceiptConfigRepository;
|
||||
use App\Modules\Settings\ShopproApiClient;
|
||||
use App\Modules\Settings\ShopproIntegrationsRepository;
|
||||
@@ -50,6 +52,7 @@ use App\Modules\Shipments\AllegroTrackingService;
|
||||
use App\Modules\Shipments\ApaczkaTrackingService;
|
||||
use App\Modules\Shipments\DeliveryStatusMappingRepository;
|
||||
use App\Modules\Shipments\InpostTrackingService;
|
||||
use App\Modules\Shipments\PolkurierTrackingService;
|
||||
use App\Modules\Shipments\ShipmentPackageRepository;
|
||||
use App\Modules\Shipments\ShipmentTrackingRegistry;
|
||||
use PDO;
|
||||
@@ -173,6 +176,11 @@ final class CronHandlerFactory
|
||||
new AllegroTrackingService(
|
||||
new InpostIntegrationRepository($this->db, $this->integrationSecret)
|
||||
),
|
||||
new PolkurierTrackingService(
|
||||
new PolkurierApiClient(),
|
||||
new PolkurierIntegrationRepository($this->db, $this->integrationSecret),
|
||||
new DeliveryStatusMappingRepository($this->db)
|
||||
),
|
||||
]),
|
||||
new ShipmentPackageRepository($this->db),
|
||||
$automationService,
|
||||
|
||||
@@ -7,17 +7,17 @@ use App\Core\Http\SslCertificateResolver;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* polkurier.pl Web Service API client (Phase 127).
|
||||
* polkurier.pl Web Service API client.
|
||||
*
|
||||
* Kontrakt API zweryfikowany na podstawie oficjalnego SDK (https://github.com/Polkurier/polkurier-sdk):
|
||||
* - Base URL: https://api.polkurier.pl/ (single endpoint, brak path per metoda)
|
||||
* - HTTP POST, Content-Type: application/json
|
||||
* - Body: {"authorization": {"login": "...", "token": "..."}, "apimetod": "<method_name>", "data": {...}}
|
||||
* - Test polaczenia: apimetod = "test_auth_api"
|
||||
* - Sukces: top-level "status" === "success" (ResponseStatus::SUCCESS w SDK), authorization w polu "response"
|
||||
* - HTTP POST, Content-Type: application/json (DOKLADNIE bez parametru charset)
|
||||
* - Body: {"authorization": {"login", "token"}, "apimetod": "<method_name>", "data": {...}}
|
||||
* - Sukces: top-level "status" === "success", tresc odpowiedzi w polu "response"
|
||||
* - Blad: top-level "status" !== "success"; tresc bledu w polu "response" (string lub tablica)
|
||||
*
|
||||
* Stuby createShipment/getLabel/getStatus/cancelOrder dolozone w kolejnych fazach.
|
||||
* Phase 127: testConnection (apimetod=test_auth_api) - zweryfikowany na zywym koncie.
|
||||
* Phase 128: createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers/getParcelMachines.
|
||||
*/
|
||||
final class PolkurierApiClient
|
||||
{
|
||||
@@ -25,7 +25,7 @@ final class PolkurierApiClient
|
||||
private const PLATFORM = 'orderPRO';
|
||||
private const PLATFORM_VERSION = '1.0';
|
||||
|
||||
public function __construct(private readonly int $timeoutSeconds = 15)
|
||||
public function __construct(private readonly int $timeoutSeconds = 30)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -33,117 +33,280 @@ final class PolkurierApiClient
|
||||
* @return array{ok: bool, http_code: int, message: string}
|
||||
*/
|
||||
public function testConnection(string $login, string $apiToken): array
|
||||
{
|
||||
try {
|
||||
$response = $this->call('test_auth_api', [
|
||||
'platform' => self::PLATFORM,
|
||||
'platform_version' => self::PLATFORM_VERSION,
|
||||
], $login, $apiToken);
|
||||
} catch (RuntimeException $exception) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'http_code' => $this->lastHttpCode,
|
||||
'message' => $exception->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
$authorization = '';
|
||||
if (is_array($response) && isset($response['authorization'])) {
|
||||
$authorization = trim((string) $response['authorization']);
|
||||
}
|
||||
$message = $authorization !== ''
|
||||
? 'Autoryzacja: ' . $authorization
|
||||
: 'Polaczenie OK (HTTP ' . $this->lastHttpCode . ').';
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'http_code' => $this->lastHttpCode,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca liste dostepnych przewoznikow z konta polkurier.
|
||||
* apimetod=available_carriers; brak wymaganych parametrow.
|
||||
*
|
||||
* @return array<int, array<string, mixed>> Lista przewoznikow z polami:
|
||||
* - servicecode (string) - kod uslugi do uzycia w 'courier' przy create_order
|
||||
* - name (string) - czytelna nazwa
|
||||
* - additional_data (array) - dodatkowe wymagania
|
||||
* - foreign_shipments (bool)
|
||||
*/
|
||||
public function getAvailableCarriers(string $login, string $apiToken): array
|
||||
{
|
||||
$response = $this->call('available_carriers', [], $login, $apiToken);
|
||||
|
||||
if (is_array($response)) {
|
||||
// Polkurier moze zwracac liste bezposrednio jako array lub pod kluczem 'carriers'
|
||||
if (isset($response[0]) && is_array($response[0])) {
|
||||
return $response;
|
||||
}
|
||||
if (isset($response['carriers']) && is_array($response['carriers'])) {
|
||||
return $response['carriers'];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy zamowienie (paczke) w polkurier.
|
||||
* apimetod=create_order.
|
||||
*
|
||||
* @param array<string, mixed> $payload Struktura zgodna z SDK polkurier-sdk/CreateOrder:
|
||||
* - shipmenttype (string, np. 'BOX')
|
||||
* - courier (string, servicecode z available_carriers)
|
||||
* - description (string)
|
||||
* - sender (array: company, person, street, housenumber, flatnumber, postcode, city, email, phone, country, point_id)
|
||||
* - recipient (array: same shape as sender)
|
||||
* - packs (array of {length, width, height, weight, amount, type})
|
||||
* - pickup (array: pickupdate, pickuptimefrom, pickuptimeto, nocourierorder)
|
||||
* - COD (array: codtype, codamount, codbankaccount, return_cod) - optional
|
||||
* - insurance (float) - optional
|
||||
* - courierservice (array) - optional additional services
|
||||
*
|
||||
* @return array<string, mixed> Tresc z pola 'response' API. Typowe pola: 'orderno', 'cost', etc.
|
||||
*/
|
||||
public function createShipment(string $login, string $apiToken, array $payload): array
|
||||
{
|
||||
$response = $this->call('create_order', $payload, $login, $apiToken);
|
||||
return is_array($response) ? $response : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pobiera etykiete dla zamowienia.
|
||||
* apimetod=get_label; wymagane: orderno (string lub array).
|
||||
*
|
||||
* @return array<string, mixed> Tresc z 'response'. Etykieta zwykle base64 w polu 'label' albo 'pdf'.
|
||||
*/
|
||||
/**
|
||||
* Pobiera etykiete dla zamowienia.
|
||||
* API polkurier (apimetod=get_label) przyjmuje wylacznie `orderno: Array<String>`.
|
||||
* Rozmiar etykiety (A4 vs A6) jest sterowany w panelu klienta polkurier.pl
|
||||
* (Ustawienia konta -> Preferencje etykiet), nie przez parametry API.
|
||||
*
|
||||
* @return array<string, mixed> Tresc z 'response'. Etykieta base64 w polu 'file'.
|
||||
*/
|
||||
public function getLabel(string $login, string $apiToken, string $orderno): array
|
||||
{
|
||||
$response = $this->call('get_label', [
|
||||
'orderno' => [$orderno],
|
||||
], $login, $apiToken);
|
||||
|
||||
return is_array($response) ? $response : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pobiera status zamowienia.
|
||||
* apimetod=get_status; wymagane: orderno.
|
||||
*
|
||||
* @return array<string, mixed> Tresc z 'response'. Pola: status, statuscode, statusdate, deliverydate, url.
|
||||
*/
|
||||
public function getStatus(string $login, string $apiToken, string $orderno): array
|
||||
{
|
||||
$response = $this->call('get_status', [
|
||||
'orderno' => $orderno,
|
||||
], $login, $apiToken);
|
||||
|
||||
return is_array($response) ? $response : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Anuluje zamowienie.
|
||||
* apimetod=cancel_order; wymagane: orderno.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function cancelOrder(string $login, string $apiToken, string $orderno): array
|
||||
{
|
||||
$response = $this->call('cancel_order', [
|
||||
'orderno' => $orderno,
|
||||
], $login, $apiToken);
|
||||
|
||||
return is_array($response) ? $response : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pobiera liste paczkomatow InPost.
|
||||
* apimetod=inpost_parcel_machines (deprecated wg SDK, ale nadal dziala — alternatywa: get_courier_point).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getInpostParcelMachines(string $login, string $apiToken, bool $codAvailable = false): array
|
||||
{
|
||||
$response = $this->call('inpost_parcel_machines', [
|
||||
'cod_available' => $codAvailable,
|
||||
'parcel_send' => false,
|
||||
], $login, $apiToken);
|
||||
|
||||
if (is_array($response)) {
|
||||
if (isset($response[0]) && is_array($response[0])) {
|
||||
return $response;
|
||||
}
|
||||
if (isset($response['machines']) && is_array($response['machines'])) {
|
||||
return $response['machines'];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generyczny lookup punktow odbioru per courier.
|
||||
* apimetod=get_courier_point (SDK nowsze API).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getCourierPoints(string $login, string $apiToken, string $courier, ?string $postcode = null): array
|
||||
{
|
||||
$data = ['courier' => $courier];
|
||||
if ($postcode !== null && $postcode !== '') {
|
||||
$data['postcode'] = $postcode;
|
||||
}
|
||||
$response = $this->call('get_courier_point', $data, $login, $apiToken);
|
||||
|
||||
if (is_array($response)) {
|
||||
if (isset($response[0]) && is_array($response[0])) {
|
||||
return $response;
|
||||
}
|
||||
if (isset($response['points']) && is_array($response['points'])) {
|
||||
return $response['points'];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wspolny wrapper dla wszystkich apimetod.
|
||||
* Sukces -> zwraca tresc pola 'response'. Blad -> rzuca RuntimeException z trescia z 'response'.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return mixed Tresc pola 'response' z API.
|
||||
*/
|
||||
private function call(string $apimetod, array $data, string $login, string $apiToken): mixed
|
||||
{
|
||||
$payload = [
|
||||
'authorization' => [
|
||||
'login' => trim($login),
|
||||
'token' => trim($apiToken),
|
||||
],
|
||||
'apimetod' => 'test_auth_api',
|
||||
'data' => [
|
||||
'platform' => self::PLATFORM,
|
||||
'platform_version' => self::PLATFORM_VERSION,
|
||||
],
|
||||
'apimetod' => $apimetod,
|
||||
'data' => $data,
|
||||
];
|
||||
|
||||
[$body, $httpCode, $curlError] = $this->postJson($payload);
|
||||
$this->lastHttpCode = $httpCode;
|
||||
|
||||
if ($curlError !== null) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'http_code' => $httpCode,
|
||||
'message' => 'Blad polaczenia: ' . $curlError,
|
||||
];
|
||||
throw new RuntimeException('Blad polaczenia z polkurier: ' . $curlError);
|
||||
}
|
||||
|
||||
$decoded = json_decode(ltrim($body, "\xEF\xBB\xBF \t\n\r\0\x0B"), true);
|
||||
if (!is_array($decoded)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'http_code' => $httpCode,
|
||||
'message' => 'Niepoprawna odpowiedz JSON polkurier: ' . substr(trim(strip_tags($body)), 0, 180),
|
||||
];
|
||||
$snippet = substr(trim(strip_tags($body)), 0, 240);
|
||||
throw new RuntimeException('Niepoprawna odpowiedz JSON polkurier (HTTP ' . $httpCode . '): ' . $snippet);
|
||||
}
|
||||
|
||||
$status = strtolower(trim((string) ($decoded['status'] ?? '')));
|
||||
$responseField = $decoded['response'] ?? null;
|
||||
|
||||
if ($httpCode >= 200 && $httpCode < 300 && $status === 'success') {
|
||||
$authorization = '';
|
||||
if (is_array($responseField)) {
|
||||
$authorization = trim((string) ($responseField['authorization'] ?? ''));
|
||||
}
|
||||
$message = $authorization !== ''
|
||||
? 'Autoryzacja: ' . $authorization
|
||||
: 'Polaczenie OK (HTTP ' . $httpCode . ').';
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'http_code' => $httpCode,
|
||||
'message' => $message,
|
||||
];
|
||||
if ($status === 'success' && $httpCode >= 200 && $httpCode < 300) {
|
||||
return $responseField;
|
||||
}
|
||||
|
||||
// Error path: w SDK polkuriera (PolkurierWebService.php) gdy status != 'success',
|
||||
// tresc bledu jest rzucana z $response->get('response') — czyli pole 'response' z envelope JSON.
|
||||
// Pole 'response' moze byc stringiem (tekst bledu), tablica (struktura) lub null.
|
||||
$errorMessage = '';
|
||||
// Error path
|
||||
$errorMessage = $this->extractErrorMessage($responseField, $decoded, $status, $httpCode);
|
||||
throw new RuntimeException($errorMessage);
|
||||
}
|
||||
|
||||
private int $lastHttpCode = 0;
|
||||
|
||||
/**
|
||||
* @param mixed $responseField
|
||||
* @param array<string, mixed> $decoded
|
||||
*/
|
||||
private function extractErrorMessage(mixed $responseField, array $decoded, string $status, int $httpCode): string
|
||||
{
|
||||
if (is_string($responseField)) {
|
||||
$errorMessage = trim($responseField);
|
||||
} elseif (is_array($responseField)) {
|
||||
$errorMessage = trim((string) (
|
||||
$msg = trim($responseField);
|
||||
if ($msg !== '') {
|
||||
return $msg;
|
||||
}
|
||||
}
|
||||
if (is_array($responseField)) {
|
||||
$msg = trim((string) (
|
||||
$responseField['error_message']
|
||||
?? $responseField['errorMessage']
|
||||
?? $responseField['message']
|
||||
?? $responseField['error']
|
||||
?? ''
|
||||
));
|
||||
if ($errorMessage === '') {
|
||||
if ($msg === '') {
|
||||
$jsonDump = json_encode($responseField, JSON_UNESCAPED_UNICODE);
|
||||
if (is_string($jsonDump)) {
|
||||
$errorMessage = substr($jsonDump, 0, 240);
|
||||
$msg = substr($jsonDump, 0, 280);
|
||||
}
|
||||
}
|
||||
if ($msg !== '') {
|
||||
return $msg;
|
||||
}
|
||||
}
|
||||
|
||||
if ($errorMessage === '') {
|
||||
$errorMessage = trim((string) (
|
||||
$msg = trim((string) (
|
||||
$decoded['error_message']
|
||||
?? $decoded['errorMessage']
|
||||
?? $decoded['message']
|
||||
?? $decoded['error']
|
||||
?? ''
|
||||
));
|
||||
if ($msg !== '') {
|
||||
return $msg;
|
||||
}
|
||||
|
||||
if ($errorMessage === '') {
|
||||
$errorMessage = $status !== '' ? 'Status: ' . $status . ' (HTTP ' . $httpCode . ')' : 'HTTP ' . $httpCode;
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'http_code' => $httpCode,
|
||||
'message' => $errorMessage,
|
||||
];
|
||||
}
|
||||
|
||||
public function createShipment(): never
|
||||
{
|
||||
throw new RuntimeException('PolkurierApiClient::createShipment not implemented in Phase 127.');
|
||||
}
|
||||
|
||||
public function getLabel(): never
|
||||
{
|
||||
throw new RuntimeException('PolkurierApiClient::getLabel not implemented in Phase 127.');
|
||||
}
|
||||
|
||||
public function getStatus(): never
|
||||
{
|
||||
throw new RuntimeException('PolkurierApiClient::getStatus not implemented in Phase 127.');
|
||||
}
|
||||
|
||||
public function cancelOrder(): never
|
||||
{
|
||||
throw new RuntimeException('PolkurierApiClient::cancelOrder not implemented in Phase 127.');
|
||||
return $status !== ''
|
||||
? 'polkurier status: ' . $status . ' (HTTP ' . $httpCode . ')'
|
||||
: 'polkurier HTTP ' . $httpCode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -554,6 +554,10 @@ final class DeliveryStatus
|
||||
return 'https://allegro.pl/allegrodelivery/sledzenie-paczki?numer=' . $encoded;
|
||||
}
|
||||
|
||||
if ($provider === 'polkurier') {
|
||||
return 'https://polkurier.pl/sledz-paczke/' . $encoded;
|
||||
}
|
||||
|
||||
return 'https://www.google.com/search?q=' . $encoded . '+sledzenie+przesylki';
|
||||
}
|
||||
|
||||
|
||||
760
src/Modules/Shipments/PolkurierShipmentService.php
Normal file
760
src/Modules/Shipments/PolkurierShipmentService.php
Normal file
@@ -0,0 +1,760 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Shipments;
|
||||
|
||||
use App\Core\Exceptions\IntegrationConfigException;
|
||||
use App\Core\Exceptions\ShipmentException;
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\CompanySettingsRepository;
|
||||
use App\Modules\Settings\PolkurierApiClient;
|
||||
use App\Modules\Settings\PolkurierIntegrationRepository;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* polkurier.pl ShipmentProvider (Phase 128).
|
||||
*
|
||||
* Tworzy paczki, pobiera etykiety i wystawia dostepne uslugi przewoznicze przez API polkurier.
|
||||
* Payload zgodny z SDK polkurier-sdk (zweryfikowany na Sender/Recipient/Pack/Pickup/COD entity klasach).
|
||||
*/
|
||||
final class PolkurierShipmentService implements ShipmentProviderInterface
|
||||
{
|
||||
/** @var array<int, array<string, mixed>>|null */
|
||||
private ?array $carriersCache = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly PolkurierIntegrationRepository $integrationRepository,
|
||||
private readonly PolkurierApiClient $apiClient,
|
||||
private readonly ShipmentPackageRepository $packages,
|
||||
private readonly CompanySettingsRepository $companySettings,
|
||||
private readonly OrdersRepository $ordersRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function code(): string
|
||||
{
|
||||
return 'polkurier';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getDeliveryServices(): array
|
||||
{
|
||||
if ($this->carriersCache !== null) {
|
||||
return $this->carriersCache;
|
||||
}
|
||||
|
||||
$credentials = $this->integrationRepository->getCredentials();
|
||||
if ($credentials === null) {
|
||||
return $this->carriersCache = [];
|
||||
}
|
||||
|
||||
try {
|
||||
$carriers = $this->apiClient->getAvailableCarriers(
|
||||
$credentials['login'],
|
||||
$credentials['api_token']
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return $this->carriersCache = [];
|
||||
}
|
||||
|
||||
// Normalizacja: ujednolicony shape `{id, name, supports_pickup_point, foreign_shipments}`
|
||||
$normalized = [];
|
||||
foreach ($carriers as $carrier) {
|
||||
if (!is_array($carrier)) {
|
||||
continue;
|
||||
}
|
||||
$code = trim((string) ($carrier['servicecode'] ?? $carrier['code'] ?? ''));
|
||||
$name = trim((string) ($carrier['name'] ?? $code));
|
||||
if ($code === '') {
|
||||
continue;
|
||||
}
|
||||
$supportsPoint = $this->detectPickupPointSupport($code, $carrier);
|
||||
$normalized[] = [
|
||||
'id' => $code,
|
||||
'name' => $name !== '' ? $name : $code,
|
||||
'supports_pickup_point' => $supportsPoint,
|
||||
'point_courier' => $supportsPoint ? $this->resolvePointCourierKey($code) : null,
|
||||
'foreign_shipments' => !empty($carrier['foreign_shipments']),
|
||||
'raw' => $carrier,
|
||||
];
|
||||
}
|
||||
|
||||
usort($normalized, static fn ($a, $b) => strcasecmp((string) $a['name'], (string) $b['name']));
|
||||
return $this->carriersCache = $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $formData
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createShipment(int $orderId, array $formData): array
|
||||
{
|
||||
$order = $this->ordersRepository->findDetails($orderId);
|
||||
if ($order === null) {
|
||||
throw new ShipmentException('Zamowienie nie znalezione.');
|
||||
}
|
||||
|
||||
$credentials = $this->requireCredentials();
|
||||
$sender = $this->companySettings->getSenderAddress();
|
||||
$this->validateSender($sender);
|
||||
|
||||
$courierCode = strtoupper(trim((string) (
|
||||
$formData['service_code']
|
||||
?? $formData['delivery_method_id']
|
||||
?? ''
|
||||
)));
|
||||
if ($courierCode === '') {
|
||||
throw new ShipmentException('Nie wybrano uslugi 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);
|
||||
|
||||
$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));
|
||||
if ($cod > 0) {
|
||||
$companySettings = $this->companySettings->getSettings();
|
||||
$bankAccount = preg_replace('/[^0-9]/', '', (string) ($companySettings['bank_account'] ?? '')) ?? '';
|
||||
if ($bankAccount === '') {
|
||||
throw new ShipmentException('Przesylka COD wymaga numeru konta bankowego. Uzupelnij go w Ustawienia > Firma.');
|
||||
}
|
||||
$apiPayload['COD'] = [
|
||||
'codtype' => 'transfer',
|
||||
'codamount' => round($cod, 2),
|
||||
'codbankaccount' => $bankAccount,
|
||||
'return_cod' => 'transfer',
|
||||
];
|
||||
}
|
||||
|
||||
$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,
|
||||
]);
|
||||
|
||||
try {
|
||||
$response = $this->apiClient->createShipment(
|
||||
$credentials['login'],
|
||||
$credentials['api_token'],
|
||||
$apiPayload
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$message = 'polkurier create_order: ' . $exception->getMessage();
|
||||
$this->packages->update($packageId, [
|
||||
'status' => 'error',
|
||||
'error_message' => $message,
|
||||
]);
|
||||
throw new ShipmentException($message, 0, $exception);
|
||||
}
|
||||
|
||||
$orderno = $this->extractOrderNumber($response);
|
||||
$tracking = $this->extractTrackingNumber($response, $orderno);
|
||||
|
||||
if ($orderno === '') {
|
||||
// Diagnostyka — polkurier zwrocil odpowiedz ale bez rozpoznawalnego pola order number.
|
||||
// Zapisujemy fragment odpowiedzi do error_message zeby operator/dev zobaczyl shape.
|
||||
$dump = json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
if (is_string($dump)) {
|
||||
$dump = substr($dump, 0, 400);
|
||||
} else {
|
||||
$dump = '(brak czytelnej odpowiedzi)';
|
||||
}
|
||||
$this->packages->update($packageId, [
|
||||
'status' => 'pending',
|
||||
'error_message' => 'polkurier: utworzono w API ale nie znaleziono order number w odpowiedzi. Fragment: ' . $dump,
|
||||
]);
|
||||
} else {
|
||||
$this->packages->update($packageId, [
|
||||
'status' => 'created',
|
||||
'shipment_id' => $orderno,
|
||||
'command_id' => $orderno,
|
||||
'tracking_number' => $tracking !== '' ? $tracking : null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Sprobuj odrazu pobrac etykiete (synchronously). Niekrytyczne — operator moze pobrac pozniej.
|
||||
if ($orderno !== '') {
|
||||
try {
|
||||
$this->downloadLabel($packageId, $this->resolveStorageRoot());
|
||||
} catch (Throwable) {
|
||||
// ignore — etykieta jeszcze nie gotowa po stronie polkuriera, operator klikni "Pobierz" pozniej
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'package_id' => $packageId,
|
||||
'command_id' => $orderno !== '' ? $orderno : null,
|
||||
'tracking_number' => $tracking,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function checkCreationStatus(int $packageId): array
|
||||
{
|
||||
$package = $this->packages->findById($packageId);
|
||||
if ($package === null) {
|
||||
throw new ShipmentException('Paczka nie znaleziona.');
|
||||
}
|
||||
|
||||
$orderno = trim((string) ($package['shipment_id'] ?? $package['command_id'] ?? ''));
|
||||
if ($orderno === '') {
|
||||
return ['status' => 'in_progress'];
|
||||
}
|
||||
|
||||
$credentials = $this->requireCredentials();
|
||||
try {
|
||||
$statusResp = $this->apiClient->getStatus(
|
||||
$credentials['login'],
|
||||
$credentials['api_token'],
|
||||
$orderno
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return [
|
||||
'status' => 'created',
|
||||
'shipment_id' => $orderno,
|
||||
'tracking_number' => trim((string) ($package['tracking_number'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'created',
|
||||
'shipment_id' => $orderno,
|
||||
'tracking_number' => trim((string) ($package['tracking_number'] ?? '')),
|
||||
'raw_status' => trim((string) ($statusResp['status'] ?? $statusResp['statuscode'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function downloadLabel(int $packageId, string $storagePath): array
|
||||
{
|
||||
$package = $this->packages->findById($packageId);
|
||||
if ($package === null) {
|
||||
throw new ShipmentException('Paczka nie znaleziona.');
|
||||
}
|
||||
|
||||
$orderno = trim((string) ($package['shipment_id'] ?? $package['command_id'] ?? ''));
|
||||
if ($orderno === '') {
|
||||
throw new ShipmentException('Przesylka polkurier nie zostala jeszcze utworzona.');
|
||||
}
|
||||
|
||||
$credentials = $this->requireCredentials();
|
||||
$format = strtoupper(trim((string) ($package['label_format'] ?? 'PDF')));
|
||||
if (!in_array($format, ['PDF', 'ZPL', 'EPL'], true)) {
|
||||
$format = 'PDF';
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->apiClient->getLabel(
|
||||
$credentials['login'],
|
||||
$credentials['api_token'],
|
||||
$orderno
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
throw new ShipmentException('polkurier get_label: ' . $exception->getMessage(), 0, $exception);
|
||||
}
|
||||
|
||||
$base64 = $this->extractLabelBase64($response);
|
||||
if ($base64 === '') {
|
||||
throw new ShipmentException('polkurier nie zwrocil danych etykiety.');
|
||||
}
|
||||
|
||||
$binary = base64_decode($base64, true);
|
||||
if (!is_string($binary) || $binary === '') {
|
||||
throw new ShipmentException('Nie mozna odczytac etykiety polkurier.');
|
||||
}
|
||||
|
||||
$extension = strtolower($format);
|
||||
$dir = rtrim($storagePath, '/\\') . '/labels';
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
|
||||
$filename = 'polkurier_' . $packageId . '_' . preg_replace('/[^A-Za-z0-9_-]/', '', $orderno) . '.' . $extension;
|
||||
$filePath = $dir . '/' . $filename;
|
||||
file_put_contents($filePath, $binary);
|
||||
|
||||
$this->packages->update($packageId, [
|
||||
'status' => 'label_ready',
|
||||
'label_path' => 'labels/' . $filename,
|
||||
]);
|
||||
|
||||
return [
|
||||
'label_path' => 'labels/' . $filename,
|
||||
'full_path' => $filePath,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 zamowienia jest w polu 'number'
|
||||
* (zmapowane z setNumber() w SDK Order.php). Fallbacki dla mozliwych wariantow.
|
||||
*
|
||||
* @param array<string, mixed> $response
|
||||
*/
|
||||
private function extractOrderNumber(array $response): string
|
||||
{
|
||||
// Czasami polkurier opakowuje w {order: {...}} lub zwraca liste
|
||||
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 odpowiedzi.
|
||||
*
|
||||
* @param array<string, mixed> $response
|
||||
*/
|
||||
private function extractTrackingNumber(array $response, string $orderno): string
|
||||
{
|
||||
if (isset($response['order']) && is_array($response['order'])) {
|
||||
$inner = $this->extractTrackingNumber($response['order'], $orderno);
|
||||
if ($inner !== '') {
|
||||
return $inner;
|
||||
}
|
||||
}
|
||||
|
||||
$waybills = $response['waybills'] ?? $response['waybill'] ?? null;
|
||||
if (is_array($waybills)) {
|
||||
// Lista OrderWaybill
|
||||
if (isset($waybills[0]) && is_array($waybills[0])) {
|
||||
foreach (['number', 'waybillno', 'waybill_number', 'tracking_number'] as $key) {
|
||||
$value = trim((string) ($waybills[0][$key] ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pojedynczy obiekt
|
||||
foreach (['number', 'waybillno', 'waybill_number', 'tracking_number'] as $key) {
|
||||
$value = trim((string) ($waybills[$key] ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['waybillno', 'waybill_number', 'parcel_number', 'tracking_number'] as $key) {
|
||||
$value = trim((string) ($response[$key] ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $orderno;
|
||||
}
|
||||
|
||||
private function resolveStorageRoot(): string
|
||||
{
|
||||
$root = dirname(__DIR__, 3) . '/storage';
|
||||
return is_dir($root) ? $root : sys_get_temp_dir();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{login: string, api_token: string, default_label_format: string, integration_id: int}
|
||||
*/
|
||||
private function requireCredentials(): array
|
||||
{
|
||||
$credentials = $this->integrationRepository->getCredentials();
|
||||
if ($credentials === null) {
|
||||
throw new IntegrationConfigException('Brak konfiguracji polkurier (login/Token API/aktywnosc).');
|
||||
}
|
||||
return $credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $sender
|
||||
*/
|
||||
private function validateSender(array $sender): void
|
||||
{
|
||||
$required = ['street', 'city', 'postalCode'];
|
||||
foreach ($required as $key) {
|
||||
if (trim((string) ($sender[$key] ?? '')) === '') {
|
||||
throw new ShipmentException('Niekompletne dane nadawcy w Ustawieniach firmy (ulica/miasto/kod pocztowy).');
|
||||
}
|
||||
}
|
||||
$name = trim((string) ($sender['name'] ?? ''));
|
||||
$company = trim((string) ($sender['company'] ?? ''));
|
||||
if ($name === '' && $company === '') {
|
||||
throw new ShipmentException('Niekompletne dane nadawcy w Ustawieniach firmy (imie/nazwisko lub nazwa firmy).');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $sender
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildSenderPayload(array $sender): array
|
||||
{
|
||||
$street = trim((string) ($sender['street'] ?? ''));
|
||||
$parsed = $this->splitStreetAndNumber($street);
|
||||
|
||||
return [
|
||||
'company' => trim((string) ($sender['company'] ?? '')),
|
||||
'person' => trim((string) ($sender['contactPerson'] ?? $sender['name'] ?? '')),
|
||||
'street' => $parsed['street'],
|
||||
'housenumber' => $parsed['house'],
|
||||
'flatnumber' => $parsed['flat'],
|
||||
'postcode' => trim((string) ($sender['postalCode'] ?? '')),
|
||||
'city' => trim((string) ($sender['city'] ?? '')),
|
||||
'email' => trim((string) ($sender['email'] ?? '')),
|
||||
'phone' => $this->normalizePhone((string) ($sender['phone'] ?? '')),
|
||||
'country' => strtoupper(trim((string) ($sender['countryCode'] ?? 'PL'))) ?: 'PL',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<string, mixed> $formData
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildRecipient(array $order, array $formData, string $receiverPointId): array
|
||||
{
|
||||
$addresses = is_array($order['addresses'] ?? null) ? $order['addresses'] : [];
|
||||
$delivery = [];
|
||||
$customer = [];
|
||||
foreach ($addresses as $addr) {
|
||||
$type = (string) ($addr['address_type'] ?? '');
|
||||
if ($type === 'delivery') {
|
||||
$delivery = is_array($addr) ? $addr : [];
|
||||
} elseif ($type === 'customer') {
|
||||
$customer = is_array($addr) ? $addr : [];
|
||||
}
|
||||
}
|
||||
|
||||
$name = $this->firstNonEmpty([
|
||||
$formData['receiver_name'] ?? null,
|
||||
$delivery['name'] ?? null,
|
||||
$customer['name'] ?? null,
|
||||
$delivery['company_name'] ?? null,
|
||||
$customer['company_name'] ?? null,
|
||||
'Klient',
|
||||
]);
|
||||
$company = $this->firstNonEmpty([
|
||||
$formData['receiver_company'] ?? null,
|
||||
$delivery['company_name'] ?? null,
|
||||
$customer['company_name'] ?? null,
|
||||
]);
|
||||
$streetLine = $this->firstNonEmpty([
|
||||
$formData['receiver_street'] ?? null,
|
||||
$this->composeStreet($delivery),
|
||||
$this->composeStreet($customer),
|
||||
]);
|
||||
$parsed = $this->splitStreetAndNumber($streetLine);
|
||||
$postcode = $this->firstNonEmpty([
|
||||
$formData['receiver_postal_code'] ?? null,
|
||||
$delivery['zip_code'] ?? null,
|
||||
$customer['zip_code'] ?? null,
|
||||
]);
|
||||
$city = $this->firstNonEmpty([
|
||||
$formData['receiver_city'] ?? null,
|
||||
$delivery['city'] ?? null,
|
||||
$customer['city'] ?? null,
|
||||
]);
|
||||
$country = strtoupper($this->firstNonEmpty([
|
||||
$formData['receiver_country_code'] ?? null,
|
||||
$delivery['country'] ?? null,
|
||||
$customer['country'] ?? null,
|
||||
'PL',
|
||||
]));
|
||||
$phone = $this->firstNonEmpty([
|
||||
$formData['receiver_phone'] ?? null,
|
||||
$delivery['phone'] ?? null,
|
||||
$customer['phone'] ?? null,
|
||||
]);
|
||||
$email = $this->firstNonEmpty([
|
||||
$formData['receiver_email'] ?? null,
|
||||
$delivery['email'] ?? null,
|
||||
$customer['email'] ?? null,
|
||||
]);
|
||||
|
||||
if ($name === '' || $postcode === '' || $city === '') {
|
||||
throw new ShipmentException('Brak wymaganych danych adresowych odbiorcy (imie/kod pocztowy/miasto).');
|
||||
}
|
||||
if ($receiverPointId === '' && $parsed['street'] === '') {
|
||||
throw new ShipmentException('Brak ulicy odbiorcy (wymagana dla uslug kurierskich).');
|
||||
}
|
||||
|
||||
return [
|
||||
'company' => $company,
|
||||
'person' => $name,
|
||||
'street' => $parsed['street'],
|
||||
'housenumber' => $parsed['house'],
|
||||
'flatnumber' => $parsed['flat'],
|
||||
'postcode' => $postcode,
|
||||
'city' => $city,
|
||||
'email' => $email,
|
||||
'phone' => $this->normalizePhone($phone),
|
||||
'country' => $country !== '' ? $country : 'PL',
|
||||
'point_id' => $receiverPointId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $formData
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPickup(array $formData): array
|
||||
{
|
||||
$date = trim((string) ($formData['pickup_date'] ?? ''));
|
||||
if ($date === '' || preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
|
||||
$date = $this->nextBusinessDay();
|
||||
}
|
||||
$from = trim((string) ($formData['pickup_time_from'] ?? '10:00'));
|
||||
$to = trim((string) ($formData['pickup_time_to'] ?? '16:00'));
|
||||
$noCourierOrder = !empty($formData['no_courier_order']);
|
||||
|
||||
return [
|
||||
'pickupdate' => $date,
|
||||
'pickuptimefrom' => $from,
|
||||
'pickuptimeto' => $to,
|
||||
'nocourierorder' => $noCourierOrder,
|
||||
];
|
||||
}
|
||||
|
||||
private function nextBusinessDay(): string
|
||||
{
|
||||
$ts = time();
|
||||
do {
|
||||
$ts = strtotime('+1 day', $ts);
|
||||
$dow = (int) date('N', $ts ?: time());
|
||||
} while ($dow >= 6);
|
||||
return date('Y-m-d', $ts ?: time());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{street: string, house: string, flat: string}
|
||||
*/
|
||||
private function splitStreetAndNumber(string $streetLine): array
|
||||
{
|
||||
$street = trim($streetLine);
|
||||
if ($street === '') {
|
||||
return ['street' => '', 'house' => '', 'flat' => ''];
|
||||
}
|
||||
|
||||
// Wzorce: "Marszalkowska 10/5", "Marszalkowska 10 m. 5", "Marszalkowska 10A"
|
||||
if (preg_match('/^(.*?)\s+(\d+[A-Za-z]?)(?:\s*[\/\-]\s*|\s*m\.?\s*)?(\d+[A-Za-z]?)?$/u', $street, $matches) === 1) {
|
||||
return [
|
||||
'street' => trim((string) $matches[1]),
|
||||
'house' => trim((string) ($matches[2] ?? '')),
|
||||
'flat' => trim((string) ($matches[3] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return ['street' => $street, 'house' => '', 'flat' => ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $address
|
||||
*/
|
||||
private function composeStreet(array $address): string
|
||||
{
|
||||
$street = trim((string) ($address['street_name'] ?? ''));
|
||||
$number = trim((string) ($address['street_number'] ?? ''));
|
||||
if ($street === '') {
|
||||
return '';
|
||||
}
|
||||
return $number !== '' ? trim($street . ' ' . $number) : $street;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $candidates
|
||||
*/
|
||||
private function firstNonEmpty(array $candidates): string
|
||||
{
|
||||
foreach ($candidates as $candidate) {
|
||||
$value = trim((string) $candidate);
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function normalizePhone(string $phone): string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $phone) ?? '';
|
||||
if ($digits === '') {
|
||||
return '';
|
||||
}
|
||||
// Polkurier akceptuje cyfry. Usun prefiks 48 jezeli jest podwojny.
|
||||
if (str_starts_with($digits, '48') && strlen($digits) === 11) {
|
||||
$digits = substr($digits, 2);
|
||||
}
|
||||
return $digits;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $carrier
|
||||
*/
|
||||
private function detectPickupPointSupport(string $code, array $carrier): bool
|
||||
{
|
||||
$haystack = strtolower($code . ' ' . trim((string) ($carrier['name'] ?? '')));
|
||||
return str_contains($haystack, 'paczkomat')
|
||||
|| str_contains($haystack, 'parcel')
|
||||
|| str_contains($haystack, 'inpost')
|
||||
|| str_contains($haystack, 'orlen')
|
||||
|| str_contains($haystack, 'pocztex')
|
||||
|| str_contains($haystack, 'kurier48')
|
||||
|| str_contains($haystack, 'punkt');
|
||||
}
|
||||
|
||||
private function resolvePointCourierKey(string $code): ?string
|
||||
{
|
||||
$lower = strtolower($code);
|
||||
if (str_contains($lower, 'inpost') || str_contains($lower, 'paczkomat')) {
|
||||
return 'inpost';
|
||||
}
|
||||
if (str_contains($lower, 'orlen')) {
|
||||
return 'orlen';
|
||||
}
|
||||
if (str_contains($lower, 'pocztex')) {
|
||||
return 'pocztex';
|
||||
}
|
||||
if (str_contains($lower, 'kurier48')) {
|
||||
return 'kurier48';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* polkurier API wymaga lowercase z dozwolonego zbioru:
|
||||
* [box, envelope, palette, small_parcel, parcel_size_20].
|
||||
* Mapuje istniejace orderPRO wartosci (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;
|
||||
}
|
||||
}
|
||||
120
src/Modules/Shipments/PolkurierTrackingService.php
Normal file
120
src/Modules/Shipments/PolkurierTrackingService.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Shipments;
|
||||
|
||||
use App\Modules\Settings\PolkurierApiClient;
|
||||
use App\Modules\Settings\PolkurierIntegrationRepository;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* polkurier.pl Tracking service (Phase 128).
|
||||
*
|
||||
* Cron pinguje API polkurier po status zamowienia (apimetod=get_status) i zwraca dane do
|
||||
* `ShipmentTrackingHandler`, ktory zapisuje znormalizowany status do `shipment_packages.delivery_status`
|
||||
* przez `delivery_status_mappings(provider='polkurier')`. Wpisy mappings sa seedowane w migracji Phase 128
|
||||
* po obserwacji realnych statusow z live testu na #114/#115.
|
||||
*/
|
||||
final class PolkurierTrackingService implements ShipmentTrackingInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolkurierApiClient $apiClient,
|
||||
private readonly PolkurierIntegrationRepository $integrationRepository,
|
||||
private readonly DeliveryStatusMappingRepository $mappingRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function supports(string $provider): bool
|
||||
{
|
||||
return strtolower(trim($provider)) === 'polkurier';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $package
|
||||
* @return array{status: string, status_raw: string, description: string}|null
|
||||
*/
|
||||
public function getDeliveryStatus(array $package): ?array
|
||||
{
|
||||
$orderno = trim((string) ($package['shipment_id'] ?? $package['command_id'] ?? ''));
|
||||
if ($orderno === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$credentials = $this->resolveCredentials();
|
||||
if ($credentials === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->apiClient->getStatus($credentials['login'], $credentials['api_token'], $orderno);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rawStatus = $this->extractRawStatus($response);
|
||||
$statusName = trim((string) (
|
||||
$response['status']
|
||||
?? $response['status_name']
|
||||
?? $response['statusname']
|
||||
?? $rawStatus
|
||||
));
|
||||
|
||||
if ($rawStatus === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeStatus($rawStatus);
|
||||
|
||||
return [
|
||||
'status' => $normalized,
|
||||
'status_raw' => $rawStatus,
|
||||
'description' => $statusName !== '' ? $statusName : $rawStatus,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|mixed $response
|
||||
*/
|
||||
private function extractRawStatus(mixed $response): string
|
||||
{
|
||||
if (!is_array($response)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Polkurier potrafi zwracac liste statusow (per orderno z get_label-style query)
|
||||
if (isset($response[0]) && is_array($response[0])) {
|
||||
$response = $response[0];
|
||||
}
|
||||
|
||||
$candidate = trim((string) (
|
||||
$response['statuscode']
|
||||
?? $response['status_code']
|
||||
?? $response['statusCode']
|
||||
?? $response['status']
|
||||
?? ''
|
||||
));
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function normalizeStatus(string $rawStatus): string
|
||||
{
|
||||
try {
|
||||
$overrides = $this->mappingRepository->getAllOverrides();
|
||||
} catch (Throwable) {
|
||||
$overrides = [];
|
||||
}
|
||||
return DeliveryStatus::normalizeWithOverrides('polkurier', $rawStatus, $overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{login: string, api_token: string, default_label_format: string, integration_id: int}|null
|
||||
*/
|
||||
private function resolveCredentials(): ?array
|
||||
{
|
||||
try {
|
||||
return $this->integrationRepository->getCredentials();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,18 @@ final class ShipmentController
|
||||
}
|
||||
}
|
||||
|
||||
$polkurierServices = [];
|
||||
$polkurierProvider = $this->providerRegistry->get('polkurier');
|
||||
if ($polkurierProvider !== null) {
|
||||
try {
|
||||
$polkurierServices = $polkurierProvider->getDeliveryServices();
|
||||
} catch (Throwable $exception) {
|
||||
if ($deliveryServicesError === '') {
|
||||
$deliveryServicesError = $exception->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$inpostServices = array_values(array_filter(
|
||||
$deliveryServices,
|
||||
static fn(array $svc) => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false
|
||||
@@ -142,6 +154,7 @@ final class ShipmentController
|
||||
'company' => $company,
|
||||
'deliveryServices' => $deliveryServices,
|
||||
'apaczkaServices' => $apaczkaServices,
|
||||
'polkurierServices' => $polkurierServices,
|
||||
'deliveryServicesError' => $deliveryServicesError,
|
||||
'existingPackages' => $existingPackages,
|
||||
'flashSuccess' => $flashSuccess,
|
||||
@@ -205,6 +218,10 @@ final class ShipmentController
|
||||
'receiver_point_id' => (string) $request->input('receiver_point_id', ''),
|
||||
'sender_point_id' => (string) $request->input('sender_point_id', ''),
|
||||
'weekend_delivery' => (string) $request->input('weekend_delivery', ''),
|
||||
'service_code' => (string) $request->input('service_code', ''),
|
||||
'pickup_date' => (string) $request->input('pickup_date', ''),
|
||||
'pickup_time_from' => (string) $request->input('pickup_time_from', ''),
|
||||
'pickup_time_to' => (string) $request->input('pickup_time_to', ''),
|
||||
]);
|
||||
|
||||
$packageId = (int) ($result['package_id'] ?? 0);
|
||||
@@ -511,4 +528,5 @@ final class ShipmentController
|
||||
$parcelName = trim((string) ($deliveryAddr['parcel_name'] ?? ''));
|
||||
return $parcelId !== '' || $parcelName !== '';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user