refactor(shipments): rozbij DeliveryStatus na fasade + 3 wspolpracownikow

DeliveryStatus (657 lin.) byl god-klasa statusow uzywana globalnie
(56 wywolan w 20 plikach). ~400 linii to tablice const map dostawcow.

Wydzielono zachowujac 100% kontraktu publicznego (fasada deleguje):
- DeliveryStatusProviderMap  - mapy surowych statusow + normalize/description/getDefaultMappings/overrides
- AllegroDescriptionGuesser  - slugify + guessStatusFromDescription + wzorce
- DeliveryTrackingUrlBuilder - trackingUrl + URL kurierow/dostawcow

DeliveryStatus.php 657 -> 170 lin. Zero zmian w plikach konsumentow.
tests/Unit/DeliveryStatusTest.php przechodzi bez modyfikacji (4/4).

Plan/SUMMARY: .paul/plans/20260519-1730-refactor-delivery-status/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 23:45:48 +02:00
parent e12ebe3a6f
commit 7ca6f4e462
12 changed files with 996 additions and 525 deletions

View File

@@ -4,30 +4,33 @@
**Ostatnia aktualizacja:** 2026-05-19
## Aktywna praca
UNIFY zakonczony dla `.paul/plans/20260519-1600-refactor-allegro-integration-controller/`. Petla zamknieta, brak aktywnego planu.
UNIFY zakonczony dla `.paul/plans/20260519-1730-refactor-delivery-status/`. Petla zamknieta, brak aktywnego planu.
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Loop complete — gotowe na nastepny PLAN]
```
Rezultat: `AllegroIntegrationController` 653 -> 223 lin. (66% redukcji). Wydzielono `AllegroIntegrationViewModel` (61), `AllegroOAuthFlowService` (94), `AllegroImportScheduleService` (179), `AllegroImportImageWarningFormatter` (69), `AllegroSaveSettingsValidator` (48). Deviation: kontroler 223 vs cel < 200 (akceptowalne, 66% redukcji).
Rezultat: `DeliveryStatus.php` 657 -> 170 lin. (fasada zachowujaca pelny kontrakt). Wydzielono `DeliveryStatusProviderMap` (~330), `AllegroDescriptionGuesser` (~150), `DeliveryTrackingUrlBuilder` (~85). Zero zmian w 20 plikach konsumentow; `tests/Unit/DeliveryStatusTest.php` 4/4 bez modyfikacji (primary gate). Pelny suite 3/15 identyczny z baseline (pre-existing). SUMMARY: `.paul/plans/20260519-1730-refactor-delivery-status/SUMMARY.md`.
## Poprzednia praca
UNIFY zakonczony dla `.paul/plans/20260519-1600-refactor-allegro-integration-controller/`. Rezultat: `AllegroIntegrationController` 653 -> 223 lin. (66% redukcji). Wydzielono `AllegroIntegrationViewModel` (61), `AllegroOAuthFlowService` (94), `AllegroImportScheduleService` (179), `AllegroImportImageWarningFormatter` (69), `AllegroSaveSettingsValidator` (48). Deviation: kontroler 223 vs cel < 200 (akceptowalne, 66% redukcji).
Zamkniete SUMMARY (chronologicznie 2026-05-19):
- `.paul/plans/20260519-1200-refactor-routes-web/SUMMARY.md` (routes/web.php: 859 -> 78).
- `.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md` (Statistics: 640 -> 110).
- `.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md` (Allegro: 653 -> 223).
- `.paul/plans/20260519-1730-refactor-delivery-status/SUMMARY.md` (DeliveryStatus: 657 -> 170, fasada + 3 klasy).
## Kontekst sesji
- Galaz: `main`.
- Niezakomitowane: dekompozycja Statistics + Allegro (8 nowych plikow modulu + 4 zmodyfikowane + dokumenty PAUL + STATE.md + governance log + changelog).
- Ostatnie commity: `3809340 update`, `e77b0f1 refactor(routing): module providers + lazy ServiceRegistry`, `2df4638 update`.
- Niezakomitowane: dekompozycja Statistics + Allegro + DeliveryStatus (11 nowych plikow modulow + 5 zmodyfikowanych + dokumenty PAUL + STATE.md + governance log + changelog).
- Ostatnie commity: `e12ebe3 update`, `3809340 update`, `e77b0f1 refactor(routing): module providers + lazy ServiceRegistry`.
## Sugerowana nastepna akcja
1. Smoke-test reczny zgodnie z sekcja "Smoke-test" w obu SUMMARY (Statistics + Allegro) przed commitem.
2. Commit: refaktor Statistics + Allegro (8 nowych plikow + 4 zmodyfikowane + docs PAUL + plany).
3. Kolejny kandydat z `quality_risks.md`: `OrdersController.php` (1490 lin.), `OrdersRepository.php` (1243 lin.), `ShopproIntegrationsController.php` (1076 lin.), albo bazowy `BaseIntegrationController` dla 9 integracji.
4. `$paul-plan` aby uruchomic nowa petle.
1. Smoke-test reczny przed commitem: widoki `/orders/show` + `/shipments/prepare` (label/description/getColor/trackingUrl/ALL_STATUSES) oraz tracking Apaczka/InPost/Polkurier (DB byla niedostepna w sesji APPLY).
2. Commit zaleglej dekompozycji: Statistics + Allegro + DeliveryStatus.
3. `$paul-plan` na kolejnego kandydata z `quality_risks.md`: `OrdersController.php` (1490 lin.), `OrdersRepository.php` (1243 lin.), `ShopproIntegrationsController.php` (1076 lin.).
## Legacy
Pliki `ROADMAP.md` / `MILESTONES.md` sa opcjonalne i obecnie nie sa wymagane.

View File

@@ -16,6 +16,12 @@
- Task 4: utworzony `AllegroIntegrationViewModel` (61 lin.) — payload widoku `settings/allegro`.
- Task 5: slim kontroler (223 lin.) + dodatkowo `AllegroSaveSettingsValidator` (48 lin., poza pierwotnym planem); `AllegroIntegrationModule` rejestruje 5 nowych kluczy w `ServiceRegistry`.
- Task 6: dokumentacja PAUL zaktualizowana (`architecture.md`, `quality_risks.md`, `tech_changelog.md`).
- [Plan 20260519-1730-refactor-delivery-status] Dekompozycja `DeliveryStatus` (657 -> 170 lin.) na fasade + 3 wspolpracownikow; zero zmian w 20 plikach konsumentow.
- Task 1: utworzony `DeliveryStatusProviderMap` (~330 lin.) — mapy surowych statusow per dostawca + `normalize`/`description`/`getDefaultMappings`/`*WithOverrides`.
- Task 2: utworzony `AllegroDescriptionGuesser` (~150 lin.) — `slugifyAllegroDescription` + `guessStatusFromDescription` + wzorce.
- Task 3: utworzony `DeliveryTrackingUrlBuilder` (~85 lin.) — `trackingUrl` + URL kurierow/dostawcow.
- Task 4: dokumentacja PAUL zaktualizowana (`architecture.md`, `tech_changelog.md`, `quality_risks.md`).
- Weryfikacja: `DeliveryStatusTest` 4/4 bez zmiany pliku testu; pelny suite identyczny z baseline (3/15 pre-existing).
## Zmienione pliki
@@ -39,3 +45,9 @@
- `.paul/STATE.md`
- `.paul/plans/20260519-1430-refactor-orders-statistics-controller/PLAN.md`
- `.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md`
- `src/Modules/Shipments/DeliveryStatus.php`
- `src/Modules/Shipments/DeliveryStatusProviderMap.php`
- `src/Modules/Shipments/AllegroDescriptionGuesser.php`
- `src/Modules/Shipments/DeliveryTrackingUrlBuilder.php`
- `.paul/plans/20260519-1730-refactor-delivery-status/PLAN.md`
- `.paul/plans/20260519-1730-refactor-delivery-status/SUMMARY.md`

View File

@@ -67,7 +67,7 @@ Korzysci wzgledem poprzedniego monolitycznego `routes/web.php` (859 lin.):
| Statistics | `src/Modules/Statistics/` | raporty zamowien (slim Controller + `OrdersStatisticsFilters` + `OrdersStatisticsTableBuilder` + `OrdersStatisticsSummaryBuilder` + Repository) |
| Settings/Allegro | `src/Modules/Settings/Allegro*.php` | integracja Allegro: slim `AllegroIntegrationController` + `AllegroIntegrationViewModel` + `AllegroOAuthFlowService` + `AllegroImportScheduleService` + `AllegroImportImageWarningFormatter` + `AllegroSaveSettingsValidator` + `AllegroOrderImportService` + `AllegroStatusDiscoveryService` + `AllegroStatusMappingController` + `AllegroDeliveryMappingController` + repozytoria (`AllegroIntegrationRepository`, `AllegroStatusMappingRepository`, `AllegroPullStatusMappingRepository`) + klienci (`AllegroApiClient`, `AllegroOAuthClient`, `AllegroTokenManager`). |
| Accounting | `src/Modules/Accounting/` | paragony, faktury, eksport ksiegowy |
| Shipments | `src/Modules/Shipments/` | etykiety, tracking, presets paczek |
| Shipments | `src/Modules/Shipments/` | etykiety, tracking, presets paczek; slownik statusow dostawy jako fasada `DeliveryStatus` + `DeliveryStatusProviderMap` (mapy dostawcow) + `AllegroDescriptionGuesser` (heurystyki opisow) + `DeliveryTrackingUrlBuilder` (URL sledzenia) |
| Printing | `src/Modules/Printing/` | API drukowania etykiet (klient Windows) |
| Email | `src/Modules/Email/` | SMTP, wysylka maili |
| Sms | `src/Modules/Sms/` | wysylka SMS, webhook SmsPlanet, konwersacje |

View File

@@ -21,7 +21,7 @@ Konwencja (`CLAUDE.md`): funkcja/klasa zwykle do 30-50 linii, max 3 poziomy zagn
| `src/Modules/Shipments/PolkurierShipmentService.php` | 776 | |
| `src/Modules/Accounting/InvoiceService.php` | 762 | |
| `src/Modules/Automation/AutomationController.php` | 677 | |
| `src/Modules/Shipments/DeliveryStatus.php` | 657 | encja statusow uzywana globalnie — zmiany dotykaja wszystkich integracji. |
| ~~`src/Modules/Shipments/DeliveryStatus.php`~~ | ~~657~~ -> 170 | ✅ Zrefaktorowane 2026-05-19 — fasada zachowujaca pelny kontrakt + wydzielono `DeliveryStatusProviderMap` (~330), `AllegroDescriptionGuesser` (~150), `DeliveryTrackingUrlBuilder` (~85). Zero zmian w 20 plikach konsumentow. Patrz `.paul/plans/20260519-1730-refactor-delivery-status/SUMMARY.md`. |
| ~~`src/Modules/Settings/AllegroIntegrationController.php`~~ | ~~653~~ -> 223 | ✅ Zrefaktorowane 2026-05-19 — wydzielono `AllegroIntegrationViewModel` (61), `AllegroOAuthFlowService` (94), `AllegroImportScheduleService` (179), `AllegroImportImageWarningFormatter` (69), `AllegroSaveSettingsValidator` (48). Patrz `.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md`. |
| ~~`src/Modules/Statistics/OrdersStatisticsController.php`~~ | ~~640~~ -> 110 | ✅ Zrefaktorowane 2026-05-19 — wydzielono `OrdersStatisticsFilters` (258), `OrdersStatisticsTableBuilder` (101), `OrdersStatisticsSummaryBuilder` (195). Patrz `.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md`. |
| ~~`routes/web.php`~~ | ~~859~~ -> 78 | ✅ Zrefaktorowane 2026-05-19 — `ServiceRegistry` + 24 klasy `<Modul>Module.php`. Patrz `.paul/plans/20260519-1200-refactor-routes-web/SUMMARY.md`. |

View File

@@ -2,6 +2,28 @@
Chronologiczny log zmian technicznych (co i dlaczego). Najnowsze na gorze.
## 2026-05-19 — Dekompozycja DeliveryStatus (fasada + 3 wspolpracownikow)
### Co
- Rozbicie `src/Modules/Shipments/DeliveryStatus.php` z 657 do 170 lin.
- `DeliveryStatus` pozostaje **fasada** zachowujaca pelny kontrakt publiczny (stale statusow `UNKNOWN..PROBLEM`, `TERMINAL_STATUSES`, `ALL_STATUSES`, `LABEL_PL`, most do DB `setRepository`/`getAllStatuses`/`getAllOptions`/`getColor`/`label`, `isTerminal` oraz metody-delegaty).
- Wydzielono trzy klasy w namespace `App\Modules\Shipments`:
- `DeliveryStatusProviderMap` (~330 lin.) — mapy surowych statusow per dostawca (`INPOST_*`, `APACZKA_*`, `ALLEGRO_*`, `ALLEGRO_EDGE_*`, `POLKURIER_*`) + `normalize`, `description`, `getDefaultMappings`, `normalizeWithOverrides`, `descriptionWithOverrides`. Prywatne stale opisow `DESC_*` przeniesione tutaj.
- `AllegroDescriptionGuesser` (~150 lin.) — `slugifyAllegroDescription`, `guessStatusFromDescription`, `DESCRIPTION_STATUS_PATTERNS` + helper `containsAny`.
- `DeliveryTrackingUrlBuilder` (~85 lin.) — `trackingUrl` + `PROVIDER_TRACKING_URLS`, `CARRIER_TRACKING_URLS`, stale `TRACKING_INPOST_URL`/`TRACKING_ALLEGRO_URL` + helpery matchowania kuriera.
### Dlaczego
- `quality_risks.md` wskazywal `DeliveryStatus` (657 lin.) jako kandydata do podzialu; ~400 z 657 linii to tablice `const` map statusow — naruszenie limitu z `CLAUDE.md`.
- Encja jest uzywana globalnie (56 wywolan w 20 plikach: serwisy trackingu wszystkich kurierow, cron, kontrolery, widoki, test) — `impact_map.md` zaznacza "dotykac ostroznie".
- Wybrano wariant **fasady** (a nie pelnej ekstrakcji): stale `DeliveryStatus::DELIVERED` itd. uzywane bezposrednio w serwisach, widokach i tescie nie da sie oddelegowac — musialyby zostac fizycznie przeniesione, co wymusiloby edycje kazdego call-site. Fasada = zerowe ryzyko dla integracji.
### Wplyw
- **Zero zmian w 20 plikach konsumentow** — kontrakt publiczny (sygnatury, stale, wartosci) niezmieniony.
- `tests/Unit/DeliveryStatusTest.php` przechodzi BEZ modyfikacji (primary gate; 4 testy / 7 asercji) — dowod zachowanego kontraktu.
- Pelny suite: 3 errors / 15 failures identyczne jak na HEAD przed zmiana (pre-existing, encoding/mocki — niezwiazane z refaktorem).
- Laczna liczba linii rosnie nieznacznie (boilerplate 3 klas) — oczekiwane.
- Plan: `.paul/plans/20260519-1730-refactor-delivery-status/PLAN.md`.
## 2026-05-19 — Dekompozycja AllegroIntegrationController
### Co

View File

@@ -276,3 +276,23 @@
{"ts":"2026-05-19T21:14:09Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-05-19.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:14:17Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-05-19.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:22:01Z","tool":"Write","file":"c:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:34:36Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\plans\\\\20260519-1730-refactor-delivery-status\\\\PLAN.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:34:45Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:34:51Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:35:42Z","tool":"Bash","cmd":"php -v 2>&1 | head -1; echo \"---\"; ls vendor/bin/phpunit 2>&1; echo \"---baseline test---\"; php vendor/bin/phpunit tests/Unit/DeliveryStatusTest.php 2>&1 | tail -15\",\"description\":\"Check php ve","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:36:31Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Shipments\\\\DeliveryStatusProviderMap.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:36:57Z","tool":"Bash","cmd":"php -l src/Modules/Shipments/DeliveryStatusProviderMap.php 2>&1\",\"description\":\"Syntax-check DeliveryStatusProviderMap\"},\"tool_response\":{\"stdout\":\"No syntax errors detected in src/Modules/S","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:37:06Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Shipments\\\\AllegroDescriptionGuesser.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:37:14Z","tool":"Bash","cmd":"php -l src/Modules/Shipments/AllegroDescriptionGuesser.php 2>&1\",\"description\":\"Syntax-check AllegroDescriptionGuesser\"},\"tool_response\":{\"stdout\":\"No syntax errors detected in src/Modules/S","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:37:20Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Shipments\\\\DeliveryTrackingUrlBuilder.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:37:38Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Shipments\\\\DeliveryStatus.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:38:30Z","tool":"Bash","cmd":"git stash push src/Modules/Shipments/DeliveryStatus.php 2>&1\\necho \"=== BASELINE (my facade reverted to HEAD) ===\"; php vendor/bin/phpunit 2>&1 | tail -3\\ngit stash pop 2>&1 | tail -2\",\"descript","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:38:55Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\tech_changelog.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:39:01Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\architecture.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:39:13Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:43:54Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\plans\\\\20260519-1730-refactor-delivery-status\\\\SUMMARY.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:44:00Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\quality_risks.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:44:14Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-05-19.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:44:19Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-05-19.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:44:27Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:44:40Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}

View File

@@ -0,0 +1,236 @@
---
plan_id: 20260519-1730-refactor-delivery-status
title: Refaktoryzacja DeliveryStatus — fasada + 3 wspolpracownikow
storage: plan-first
legacy_phase: null
created: 2026-05-19T17:30:00
status: planned
type: execute
autonomous: true
delegation: auto
files_modified:
- src/Modules/Shipments/DeliveryStatus.php
- src/Modules/Shipments/DeliveryStatusProviderMap.php
- src/Modules/Shipments/AllegroDescriptionGuesser.php
- src/Modules/Shipments/DeliveryTrackingUrlBuilder.php
- .paul/codebase/architecture.md
- .paul/codebase/tech_changelog.md
quality_radar: ok
---
<objective>
## Goal
Rozbic `src/Modules/Shipments/DeliveryStatus.php` (657 lin.) na czytelna **fasade** + trzech wyspecjalizowanych wspolpracownikow, bez zmiany ani jednego miejsca wywolania w aplikacji.
## Purpose
`DeliveryStatus` to encja statusow uzywana globalnie — 56 wywolan w 20 plikach (serwisy trackingu wszystkich kurierow, cron, kontrolery, widoki, testy). Plik laczy cztery niezalezne odpowiedzialnosci, a ~400 z 657 linii to tablice `const` mapujace surowe statusy dostawcow. Konwencja `CLAUDE.md` (jedna odpowiedzialnosc na klase, czytelnosc "dla obcego") jest naruszona. Cel: rozdzielic odpowiedzialnosci, zachowujac 100% kontraktu publicznego, by ryzyko dla integracji bylo zerowe.
## Output
- Fasada `DeliveryStatus` (~150-180 lin.): stale statusow, etykiety, most do DB (`$repository`), publiczne metody-delegaty (jednolinijkowce).
- `DeliveryStatusProviderMap` (~350 lin.): mapy `*_MAP` / `*_DESCRIPTIONS` per dostawca + `normalize` / `description` / `getDefaultMappings` / `*WithOverrides`.
- `AllegroDescriptionGuesser` (~110-130 lin.): `slugifyAllegroDescription`, `guessStatusFromDescription`, `DESCRIPTION_STATUS_PATTERNS`.
- `DeliveryTrackingUrlBuilder` (~80-100 lin.): `trackingUrl` + tablice URL kurierow/dostawcow + matchowanie.
</objective>
<context>
## Project Docs
@.paul/PROJECT.md
@.paul/STATE.md
@.paul/codebase/architecture.md
@.paul/codebase/db_schema.md
@.paul/codebase/impact_map.md
@.paul/codebase/quality_risks.md
## Source Files
@src/Modules/Shipments/DeliveryStatus.php
@src/Modules/Shipments/DeliveryStatusRepository.php
@tests/Unit/DeliveryStatusTest.php
</context>
<clarifications>
- Wariant "pelnej ekstrakcji" (przeniesienie metod do nowych klas + edycja 20 plikow wywolan) zostal odrzucony: stale `DeliveryStatus::UNKNOWN`, `::DELIVERED`, `::READY_FOR_PICKUP`, `::OUT_FOR_DELIVERY`, `::ALL_STATUSES` sa uzywane bezposrednio w serwisach, widokach **i tescie** — stalych nie da sie "oddelegowac", musialyby zostac fizycznie przeniesione, co wymusza edycje kazdego odwolania (w tym testu). To inny, wiekszy refaktor niz wymaga `quality_risks.md`. Wybrano fasade zachowujaca pelny kontrakt.
</clarifications>
<impact_scan>
## Quality Radar
**Status:** ok
**Tryb:** plan (targeted)
**Tools:** codebase-memory-mcp (graf wczytany w STATE: 4217 nodes / 11649 edges); jscpd/ast-grep wylaczone polityka (`.paul/config.md`). Zakres potwierdzony przez `rg` (grep) na `DeliveryStatus::` — 56 trafien / 20 plikow.
## Affected Areas
- **Shipments (rdzen zmiany)** — `DeliveryStatus.php` + 3 nowe klasy w `src/Modules/Shipments/`.
- **Konsumenci kontraktu (NIE modyfikowani — weryfikacja braku zmian):**
- Serwisy trackingu: `AllegroTrackingService` (normalize, description, slugifyAllegroDescription, guessStatusFromDescription, UNKNOWN), `InpostTrackingService` (normalize, description), `ApaczkaTrackingService` (normalize, description), `PolkurierTrackingService` (normalizeWithOverrides).
- Cron: `ShipmentTrackingHandler` (normalizeWithOverrides).
- Kontrolery: `Automation*` (getAllStatuses, getAllOptions, UNKNOWN), `DeliveryStatusesController` + `DeliveryStatusMappingController` (getDefaultMappings), `ShipmentController` (UNKNOWN).
- Repozytoria: `DeliveryStatusMappingRepository` (getDefaultMappings).
- Bootstrap: `ShipmentsModule` + `bin/smoke_routes.php` (setRepository).
- Widoki: `resources/views/orders/show.php`, `resources/views/shipments/prepare.php` (label, description, getColor, trackingUrl, ALL_STATUSES).
- SMS: `SmsVariableResolver` (trackingUrl).
- Test: `tests/Unit/DeliveryStatusTest.php` (slugify, normalize, guess + stale).
- **Migracje:** `20260422_000101_backfill_delivery_status_unknowns.sql` — odwoluje sie do `DeliveryStatus::normalize()` wylacznie w komentarzu SQL (zweryfikowane). Brak wykonywalnego PHP. Bez zmian.
## Duplicate / Hardcoded Risks
- **Stale `self::CREATED` itd. wewnatrz przenoszonych map** — po przeniesieniu `*_MAP` do `DeliveryStatusProviderMap` musza wskazywac `DeliveryStatus::CREATED` (ten sam namespace). Obsluzone w Task 1 `<action>`.
- **Prywatne stale opisow `DESC_*`** (`DESC_DELIVERED`, `DESC_PICKED_UP_BY_COURIER`, ...) uzywane wylacznie przez tablice opisow → przeniesc do `DeliveryStatusProviderMap` (Task 1).
- **Stale `TRACKING_INPOST_URL` / `TRACKING_ALLEGRO_URL`** uzywane w `PROVIDER_TRACKING_URLS` i `CARRIER_TRACKING_URLS` → przeniesc do `DeliveryTrackingUrlBuilder` (Task 3).
- **Helpery prywatne `containsAny` / `carrierMatches`** (po ~5 lin.) — wedruja ze swoimi wywolujacymi (`containsAny` → guesser, `carrierMatches` → URL builder). NIE tworzyc wspolnej klasy util dla dwoch helperow.
- **Brak drugiego zrodla prawdy:** mapy statusow pozostaja w JEDNYM miejscu (przeniesione, nie skopiowane). Fasada deleguje, nie duplikuje.
## Explicit Deferrals
- **Mapowania statusow per integracja jako wzorzec do abstrakcji** (`quality_risks.md``*StatusMappingRepository` + `*PullStatusMappingRepository`) — poza zakresem; dotyczy repozytoriow DB, nie tej encji.
- **Przejscie na `enum` PHP 8.1+ dla statusow** — kuszace, ale zlamaloby kontrakt `string` uzywany w sygnaturach 20 plikow i w DB. Odlozone.
- **Zamiana statycznego API na instancyjne / DI** — poza zakresem; caly kontrakt jest statyczny i tak uzywany globalnie.
</impact_scan>
<skills>
Brak `.paul/SPECIAL-FLOWS.md` — sekcja skills pominieta.
</skills>
<acceptance_criteria>
## AC-1: Kontrakt publiczny niezmieniony
```gherkin
Given 20 plikow wywoluje DeliveryStatus:: (56 wywolan)
When refaktor jest zakonczony
Then zaden plik konsumenta (serwisy, cron, kontrolery, widoki, test) nie jest zmodyfikowany
And wszystkie publiczne metody i stale DeliveryStatus zachowuja te sama sygnature i zwracane wartosci
```
## AC-2: Testy jednostkowe przechodza bez zmian
```gherkin
Given tests/Unit/DeliveryStatusTest.php (slugify, normalize allegro_edge, guess, stale)
When uruchomie phpunit po refaktorze
Then wszystkie testy przechodza
And plik testu nie wymagal zadnej modyfikacji
```
## AC-3: Rozdzielone odpowiedzialnosci
```gherkin
Given monolit DeliveryStatus.php (657 lin.)
When refaktor jest zakonczony
Then powstaja 3 nowe klasy: DeliveryStatusProviderMap, AllegroDescriptionGuesser, DeliveryTrackingUrlBuilder
And kazda ma jedna odpowiedzialnosc
And fasada DeliveryStatus.php ma <= 200 lin. (cel ~150-180)
And zadna mapa statusow nie istnieje w wiecej niz jednym miejscu
```
## AC-4: Kod sie laduje (brak bledow skladni / autoload)
```gherkin
Given nowe klasy w namespace App\Modules\Shipments
When uruchomie php -l na kazdym pliku i smoke check autoloadu
Then brak bledow skladni
And klasy sa znajdowane przez PSR-4 (App\ -> src/)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Wydzielic DeliveryStatusProviderMap (mapowanie statusow dostawcow)</name>
<files>src/Modules/Shipments/DeliveryStatusProviderMap.php, src/Modules/Shipments/DeliveryStatus.php</files>
<action>
Utworz `src/Modules/Shipments/DeliveryStatusProviderMap.php` (namespace `App\Modules\Shipments`, `final class`).
Przenies z DeliveryStatus do nowej klasy:
- prywatne stale map: `INPOST_MAP`, `INPOST_DESCRIPTIONS`, `APACZKA_MAP`, `APACZKA_DESCRIPTIONS`, `ALLEGRO_MAP`, `ALLEGRO_DESCRIPTIONS`, `ALLEGRO_EDGE_MAP`, `ALLEGRO_EDGE_DESCRIPTIONS`, `POLKURIER_MAP`, `POLKURIER_DESCRIPTIONS`, `PROVIDER_MAPS`, `PROVIDER_DESCRIPTIONS`,
- prywatne stale opisow uzywane przez te tablice: `DESC_PICKED_UP_BY_COURIER`, `DESC_DISPATCHED`, `DESC_OUT_FOR_DELIVERY`, `DESC_DELIVERED`, `DESC_RETURNED_TO_SENDER`, `DESC_AWAITING_PICKUP`,
- metody: `getDefaultMappings`, `normalizeWithOverrides`, `descriptionWithOverrides`, `normalize`, `description`.
WAZNE: wewnatrz przeniesionych map odwolania `self::CREATED`/`self::DELIVERED`/... zamien na `DeliveryStatus::CREATED` itd. (ten sam namespace, bez importu). `self::UNKNOWN` w `normalize` -> `DeliveryStatus::UNKNOWN`.
W DeliveryStatus zostaw publiczne metody-delegaty (jednolinijkowce), zachowujac identyczne sygnatury i PHPDoc:
`public static function getDefaultMappings(string $provider): array { return DeliveryStatusProviderMap::getDefaultMappings($provider); }` — analogicznie dla `normalize`, `description`, `normalizeWithOverrides`, `descriptionWithOverrides`.
Usun z DeliveryStatus przeniesione stale prywatne (map i `DESC_*`).
</action>
<verify>php -l src/Modules/Shipments/DeliveryStatusProviderMap.php; php -l src/Modules/Shipments/DeliveryStatus.php; php vendor/bin/phpunit tests/Unit/DeliveryStatusTest.php</verify>
<done>AC-1, AC-2, AC-3, AC-4 — mapowanie dostawcow w jednej dedykowanej klasie, fasada deleguje, testy przechodza.</done>
</task>
<task type="auto">
<name>Task 2: Wydzielic AllegroDescriptionGuesser (heurystyki opisow Allegro)</name>
<files>src/Modules/Shipments/AllegroDescriptionGuesser.php, src/Modules/Shipments/DeliveryStatus.php</files>
<action>
Utworz `src/Modules/Shipments/AllegroDescriptionGuesser.php` (namespace `App\Modules\Shipments`, `final class`).
Przenies z DeliveryStatus:
- prywatna stala `DESCRIPTION_STATUS_PATTERNS`,
- metody publiczne `slugifyAllegroDescription`, `guessStatusFromDescription`,
- prywatny helper `containsAny` (uzywany wylacznie przez `guessStatusFromDescription`).
Odwolania `self::READY_FOR_PICKUP`/`self::UNKNOWN`/... w przeniesionym kodzie zamien na `DeliveryStatus::READY_FOR_PICKUP` itd.
W DeliveryStatus zostaw delegaty:
`public static function slugifyAllegroDescription(string $description): string { return AllegroDescriptionGuesser::slugifyAllegroDescription($description); }` oraz analogiczny dla `guessStatusFromDescription`.
Usun z DeliveryStatus przeniesione skladowe (w tym `containsAny`).
</action>
<verify>php -l src/Modules/Shipments/AllegroDescriptionGuesser.php; php -l src/Modules/Shipments/DeliveryStatus.php; php vendor/bin/phpunit tests/Unit/DeliveryStatusTest.php</verify>
<done>AC-1, AC-2, AC-3, AC-4 — heurystyki opisow w osobnej klasie; testy slugify/guess przechodza bez zmiany pliku testu.</done>
</task>
<task type="auto">
<name>Task 3: Wydzielic DeliveryTrackingUrlBuilder (budowanie URL sledzenia)</name>
<files>src/Modules/Shipments/DeliveryTrackingUrlBuilder.php, src/Modules/Shipments/DeliveryStatus.php</files>
<action>
Utworz `src/Modules/Shipments/DeliveryTrackingUrlBuilder.php` (namespace `App\Modules\Shipments`, `final class`).
Przenies z DeliveryStatus:
- stale `TRACKING_INPOST_URL`, `TRACKING_ALLEGRO_URL`, `PROVIDER_TRACKING_URLS`, `CARRIER_TRACKING_URLS`,
- metode publiczna `trackingUrl`,
- prywatne helpery `carrierTrackingUrl`, `providerTrackingUrl`, `fallbackTrackingUrl`, `matchCarrierByName`, `carrierMatches`.
W DeliveryStatus zostaw delegat:
`public static function trackingUrl(string $provider, string $trackingNumber, string $carrierId = ''): ?string { return DeliveryTrackingUrlBuilder::trackingUrl($provider, $trackingNumber, $carrierId); }`.
Usun z DeliveryStatus przeniesione stale i helpery.
Po tym tasku w DeliveryStatus zostaja TYLKO: stale statusow (UNKNOWN..PROBLEM), `TERMINAL_STATUSES`, `ALL_STATUSES`, `LABEL_PL`, most do DB (`$repository`, `setRepository`, `getAllStatuses`, `getAllOptions`, `getColor`, `label`), `isTerminal` oraz metody-delegaty z Task 1-3.
</action>
<verify>php -l src/Modules/Shipments/DeliveryTrackingUrlBuilder.php; php -l src/Modules/Shipments/DeliveryStatus.php; php vendor/bin/phpunit tests/Unit/DeliveryStatusTest.php; (Get-Content src/Modules/Shipments/DeliveryStatus.php | Measure-Object -Line).Lines</verify>
<done>AC-1, AC-2, AC-3, AC-4 — budowanie URL w osobnej klasie; fasada DeliveryStatus.php <= 200 lin.</done>
</task>
<task type="auto">
<name>Task 4: Aktualizacja dokumentacji technicznej</name>
<files>.paul/codebase/architecture.md, .paul/codebase/tech_changelog.md</files>
<action>
W `architecture.md` (sekcja modulu Shipments / warstwy) dopisz nowy podzial: `DeliveryStatus` jako fasada + `DeliveryStatusProviderMap` + `AllegroDescriptionGuesser` + `DeliveryTrackingUrlBuilder`.
W `tech_changelog.md` dopisz wpis chronologiczny (data 2026-05-19): co i dlaczego — dekompozycja god-klasy statusow na fasade + 3 wspolpracownikow, kontrakt publiczny zachowany, zero zmian w call-site, redukcja DeliveryStatus.php z 657 do ~N lin.
Nie ruszaj `db_schema.md` (brak zmian schematu).
</action>
<verify>Reczny przeglad obu plikow — wpisy obecne i spojne z faktycznym stanem kodu.</verify>
<done>AC-3 — dokumentacja odzwierciedla nowy podzial klas.</done>
</task>
</tasks>
<boundaries>
## Do Not Change
- Zadnego z 20 plikow konsumentow `DeliveryStatus::` (serwisy trackingu, cron, kontrolery, widoki, `SmsVariableResolver`, `ShipmentsModule`, `bin/smoke_routes.php`).
- `tests/Unit/DeliveryStatusTest.php` — musi przejsc BEZ modyfikacji (to dowod, ze kontrakt jest zachowany).
- Sygnatury, nazwy i zwracane wartosci publicznych metod oraz stale `DeliveryStatus`.
- Migracje SQL (`20260422_000101_*` i pozostale).
- `routes/web.php`, `ServiceRegistry`, providery modulow (refaktor jest wewnatrz Shipments, brak nowych route'ow/serwisow w DI).
## Scope Limits
- Brak zmian schematu DB i migracji.
- Brak zamiany statycznego API na instancyjne/DI.
- Brak `enum` PHP zamiast stalych string.
- Brak abstrakcji bazowej dla `*StatusMappingRepository` (osobny, przyszly plan).
- Brak nowych testow poza utrzymaniem zielonego `DeliveryStatusTest` (mozna dopisac testy charakteryzujace trackingUrl/getDefaultMappings tylko jesli nie wymaga to zmiany istniejacych plikow — opcjonalne, nie blokujace).
</boundaries>
<verification>
- [ ] `php vendor/bin/phpunit tests/Unit/DeliveryStatusTest.php` — wszystkie testy zielone, plik testu niezmieniony (PRIMARY GATE — kontrakt fasady).
- [ ] `php -l` bez bledow dla: `DeliveryStatus.php`, `DeliveryStatusProviderMap.php`, `AllegroDescriptionGuesser.php`, `DeliveryTrackingUrlBuilder.php`.
- [ ] `git diff --name-only` pokazuje TYLKO 4 pliki kodu (1 zmodyfikowany + 3 nowe) + 2 pliki docs — zaden plik konsumenta.
- [ ] `(Get-Content src/Modules/Shipments/DeliveryStatus.php | Measure-Object -Line).Lines` <= 200.
- [ ] `php bin/smoke_routes.php` (jesli dostepny) — bootstrap przechodzi (weryfikuje `setRepository` + autoload).
- [ ] Quality Radar: ryzyko "drugie zrodlo prawdy" obsluzone (mapy przeniesione, nie skopiowane).
</verification>
<success_criteria>
- [ ] Wszystkie AC (AC-1..AC-4) spelnione.
- [ ] Weryfikacja kompletna (phpunit + php -l + diff scope + rozmiar fasady).
- [ ] `architecture.md` i `tech_changelog.md` zaktualizowane.
- [ ] `quality_risks.md` zaktualizowane w UNIFY (wpis `DeliveryStatus.php` oznaczony jako zrefaktorowany, analogicznie do Allegro/Statistics).
</success_criteria>
<output>
SUMMARY.md path: `.paul/plans/20260519-1730-refactor-delivery-status/SUMMARY.md`
</output>

View File

@@ -0,0 +1,77 @@
---
plan_id: 20260519-1730-refactor-delivery-status
title: Refaktoryzacja DeliveryStatus — fasada + 3 wspolpracownikow
completed: 2026-05-19T18:10:00
storage: plan-first
quality_radar: ok
---
# Summary: Refaktoryzacja DeliveryStatus — fasada + 3 wspolpracownikow
## Objective
Rozbicie `src/Modules/Shipments/DeliveryStatus.php` (657 lin., god-klasa statusow uzywana globalnie — 56 wywolan w 20 plikach) na czytelna fasade + trzech wyspecjalizowanych wspolpracownikow, przy 100% zachowaniu kontraktu publicznego (zero zmian w plikach konsumentow).
## What Was Built
| Area | Result |
|------|--------|
| Fasada `DeliveryStatus` | 657 -> **170 lin.**; stale statusow, `LABEL_PL`, `ALL_STATUSES`, `TERMINAL_STATUSES`, most do DB, `isTerminal` + metody-delegaty |
| Mapowanie dostawcow | nowy `DeliveryStatusProviderMap` (~330 lin.) — `INPOST_*`/`APACZKA_*`/`ALLEGRO_*`/`ALLEGRO_EDGE_*`/`POLKURIER_*` + `normalize`/`description`/`getDefaultMappings`/`*WithOverrides` + prywatne `DESC_*` |
| Heurystyki opisow Allegro | nowy `AllegroDescriptionGuesser` (~150 lin.) — `slugifyAllegroDescription`, `guessStatusFromDescription`, `DESCRIPTION_STATUS_PATTERNS`, `containsAny` |
| URL sledzenia | nowy `DeliveryTrackingUrlBuilder` (~85 lin.) — `trackingUrl` + `PROVIDER_TRACKING_URLS`/`CARRIER_TRACKING_URLS` + helpery |
## Files Modified
- `src/Modules/Shipments/DeliveryStatus.php` — zredukowany do fasady delegujacej (657 -> 170).
- `src/Modules/Shipments/DeliveryStatusProviderMap.php` — NOWY; mapy surowych statusow per dostawca.
- `src/Modules/Shipments/AllegroDescriptionGuesser.php` — NOWY; heurystyki opisow Allegro edge.
- `src/Modules/Shipments/DeliveryTrackingUrlBuilder.php` — NOWY; budowanie URL-i sledzenia.
- `.paul/codebase/architecture.md` — opis nowego podzialu w wierszu modulu Shipments.
- `.paul/codebase/tech_changelog.md` — wpis chronologiczny 2026-05-19.
## Acceptance Criteria Results
| Criterion | Status | Evidence |
|-----------|--------|----------|
| AC-1: Kontrakt publiczny niezmieniony | Pass | `git status --porcelain` — zaden z 20 plikow konsumentow niemodyfikowany; zmieniony tylko `DeliveryStatus.php` |
| AC-2: Testy przechodza bez zmian | Pass | `DeliveryStatusTest` 4/4 (7 asercji); `git diff --stat tests/Unit/DeliveryStatusTest.php` pusty |
| AC-3: Rozdzielone odpowiedzialnosci | Pass | 3 nowe klasy, fasada 170 lin. (<= 200); mapy w jednym miejscu (przeniesione, nie skopiowane) |
| AC-4: Kod sie laduje | Pass | `php -l` clean x4; test przechodzacy przez `slugify`/`normalize` potwierdza autoload PSR-4 nowych klas |
## Verification Results
| Check | Result | Notes |
|-------|--------|-------|
| `php vendor/bin/phpunit tests/Unit/DeliveryStatusTest.php` | Pass | 4 testy / 7 asercji (PRIMARY GATE), plik testu niezmieniony |
| `php -l` (4 pliki) | Pass | brak bledow skladni |
| rozmiar fasady | Pass | 170 lin. (cel ~150-180, limit <= 200) |
| `git status --porcelain` (scope) | Pass | tylko 4 pliki kodu + 2 docs + STATE.md (workflow) |
| `php vendor/bin/phpunit` (full) | Pass (neutralnie) | 3 errors / 15 failures **identyczne z baseline na HEAD** (stash `DeliveryStatus.php` -> ten sam wynik) — pre-existing, niezwiazane |
| `php bin/cron` / `bin/smoke_routes.php` | Skipped | DB niedostepne — `setRepository` nie zweryfikowany runtime (plan dopuszczal "jesli dostepny") |
## Quality Radar Results
**Status:** ok
- New risks: brak. Powstaly 3 nowe klasy, kazda < 350 lin., jedna odpowiedzialnosc.
- Resolved risks: `DeliveryStatus.php` (657 lin.) zdjety z listy kandydatow > 500 lin. w `quality_risks.md`.
- Deferred risks: enum PHP zamiast stalych string; abstrakcja bazowa dla `*StatusMappingRepository`; zamiana statycznego API na instancyjne/DI — wszystkie poza zakresem (osobne plany).
- Tools: codebase-memory-mcp (graf bez zmian strukturalnych poza +3 klasy); jscpd/ast-grep disabled by policy. Raw outputs: `.paul/codebase/radar/`.
## Deviations
- **Tryb wykonania inline zamiast delegated (auto):** wszystkie 4 zadania edytuja wspolny `DeliveryStatus.php` — delegacja rownolegla niemozliwa (konflikt plikow), a precyzyjne zachowanie kontraktu wymagalo pelnej kontroli. Ekstrakcja wykonana atomowo: 3 nowe pliki + jeden czysty zapis fasady, nastepnie pelna weryfikacja. Rownowazne wynikowo zadaniom T1-T3, nizsze ryzyko niz 3 przeplatane edycje.
- **Laczna liczba linii rosnie** (boilerplate 3 klas + PHPDoc) — oczekiwane i zapowiedziane w planie; nie jest regresja.
## Key Decisions / Patterns
- **Fasada zamiast pelnej ekstrakcji** — wymuszone przez kontrakt: stale `DeliveryStatus::DELIVERED` itd. uzywane wprost w serwisach, widokach i tescie nie da sie oddelegowac. Fasada = zerowy blast radius dla integracji.
- **Helpery prywatne wedruja z wywolujacymi** (`containsAny` -> guesser, `carrierMatches` -> URL builder) — bez tworzenia sztucznej klasy util dla 2 metod.
- **Stale-klucze map** (`DeliveryStatus::CREATED` jako klucz w `DESCRIPTION_STATUS_PATTERNS`) dzialaja jako constant-expression w `const` array (PHP 8.2).
## Follow-up
- **Luka testowa (znana):** test pokrywa 4 z ~13 metod fasady; pozostale delegaty (`trackingUrl`, `getDefaultMappings`, `*WithOverrides`, most do DB, `label`/`getColor`/`getAllStatuses`/`getAllOptions`) niesprawdzone runtime — mechanicznie bezpieczne (jednolinijkowe passthrough). Kandydat: contract test fasady.
- **Smoke-test reczny przed commitem:** widoki `/orders/show` + `/shipments/prepare` (label/description/getColor/trackingUrl/ALL_STATUSES) oraz tracking Apaczka/InPost/Polkurier (DB niedostepne w sesji).
- **Kolejni kandydaci** z `quality_risks.md`: `OrdersController.php` (1490), `OrdersRepository.php` (1243), `ShopproIntegrationsController.php` (1076).

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Modules\Shipments;
/**
* Heurystyki rozpoznawania znormalizowanego statusu z opisow tekstowych Allegro
* (edge API): slugifikacja opisu oraz fallback po slowach kluczowych.
*/
final class AllegroDescriptionGuesser
{
private const DESCRIPTION_STATUS_PATTERNS = [
DeliveryStatus::DELIVERED => [
'delivered',
'picked up by recipient',
'doręczon',
'dostarczono',
'odebrana przez odbiorc',
],
DeliveryStatus::PICKED_UP => [
'picked up by courier',
'picked up from point',
'podjęta',
'podjeta',
'odebrana przez kuriera',
],
DeliveryStatus::RETURNED => [
'returned',
'zwrócon',
'zwrocona',
],
DeliveryStatus::CANCELLED => [
'cancelled',
'canceled',
'anulowan',
],
DeliveryStatus::OUT_FOR_DELIVERY => [
'out for delivery',
'released for delivery',
'doręczeni',
'doreczenia',
'wydana do',
],
DeliveryStatus::READY_FOR_PICKUP => [
'awaiting pick-up',
'awaiting pickup',
'ready for pickup',
'ready for pick-up',
'oczekuje na odb',
'gotowa do odb',
],
DeliveryStatus::IN_TRANSIT => [
'courier',
'warehouse',
'branch',
'in transit',
'sortowni',
'magazyn',
'w drodze',
'tranzyt',
'kurier',
'wyjechał',
'wyjechala',
],
DeliveryStatus::CONFIRMED => [
'dispatched',
'nadana',
'nadano',
],
DeliveryStatus::CREATED => [
'prepared',
'created',
'przygotowan',
'utworzon',
],
DeliveryStatus::PROBLEM => [
'damaged',
'problem',
'lost',
'uszkodzon',
'zagubion',
],
];
public static function slugifyAllegroDescription(string $description): string
{
$text = trim($description);
if ($text === '') {
return 'unknown';
}
// Usuń typowe prefiksy
$text = preg_replace('/^Przesy[łl]ka zosta[łl]a\s+/ui', '', $text);
$text = preg_replace('/^Kurier\s+/ui', '', $text);
$text = preg_replace('/^Paczka zosta[łl]a\s+/ui', '', $text);
$text = preg_replace('/^Parcel has been\s+/i', '', $text);
$text = preg_replace('/^Parcel is\s+/i', '', $text);
$text = preg_replace('/^Courier has\s+/i', '', $text);
// Polskie znaki na ASCII
$polish = ['ą','ć','ę','ł','ń','ó','ś','ź','ż','Ą','Ć','Ę','Ł','Ń','Ó','Ś','Ź','Ż'];
$ascii = ['a','c','e','l','n','o','s','z','z','A','C','E','L','N','O','S','Z','Z'];
$text = str_replace($polish, $ascii, $text);
// Lowercase, zamień nie-alfanumeryczne na podkreślenia
$text = strtolower($text);
$text = preg_replace('/[^a-z0-9]+/', '_', $text);
$text = trim($text, '_');
return $text !== '' ? $text : 'unknown';
}
/**
* Keyword-based fallback for unknown Allegro edge descriptions.
* Used when slugified description is not in ALLEGRO_EDGE_MAP.
*/
public static function guessStatusFromDescription(string $description): string
{
$lower = mb_strtolower($description, 'UTF-8');
foreach (self::DESCRIPTION_STATUS_PATTERNS as $status => $patterns) {
if (self::containsAny($lower, $patterns)) {
return $status;
}
}
return str_contains($lower, "odbi\u{00F3}r") && !str_contains($lower, 'w drodze')
? DeliveryStatus::READY_FOR_PICKUP
: DeliveryStatus::UNKNOWN;
}
/**
* @param array<int, string> $needles
*/
private static function containsAny(string $haystack, array $needles): bool
{
foreach ($needles as $needle) {
if (str_contains($haystack, $needle)) {
return true;
}
}
return false;
}
}

View File

@@ -3,6 +3,16 @@ declare(strict_types=1);
namespace App\Modules\Shipments;
/**
* Slownik znormalizowanych statusow dostawy (zrodlo prawdy dla calej aplikacji)
* oraz fasada delegujaca do wyspecjalizowanych wspolpracownikow:
* - {@see DeliveryStatusProviderMap} — mapowanie surowych statusow dostawcow,
* - {@see AllegroDescriptionGuesser} — heurystyki opisow Allegro,
* - {@see DeliveryTrackingUrlBuilder} — budowanie URL-i sledzenia.
*
* Stale i metody publiczne stanowia stabilny kontrakt uzywany przez wszystkie
* integracje kurierskie, cron, kontrolery i widoki — nie zmieniac sygnatur.
*/
final class DeliveryStatus
{
private static ?DeliveryStatusRepository $repository = null;
@@ -25,272 +35,20 @@ final class DeliveryStatus
self::CANCELLED,
];
private const DESC_PICKED_UP_BY_COURIER = 'Odebrana przez kuriera';
private const DESC_DISPATCHED = "Przesy\u{0142}ka nadana";
private const DESC_OUT_FOR_DELIVERY = "W dor\u{0119}czeniu";
private const DESC_DELIVERED = "Dor\u{0119}czona";
private const DESC_RETURNED_TO_SENDER = "Zwr\u{00F3}cona do nadawcy";
private const DESC_AWAITING_PICKUP = "Oczekuje na odbi\u{00F3}r";
private const TRACKING_INPOST_URL = 'https://inpost.pl/sledzenie-przesylek?number=';
private const TRACKING_ALLEGRO_URL = 'https://allegro.pl/allegrodelivery/sledzenie-paczki?numer=';
public const LABEL_PL = [
self::UNKNOWN => 'Nieznany',
self::CREATED => 'Utworzona',
self::CONFIRMED => 'Potwierdzona',
self::PICKED_UP => self::DESC_PICKED_UP_BY_COURIER,
self::PICKED_UP => 'Odebrana przez kuriera',
self::IN_TRANSIT => 'W tranzycie',
self::OUT_FOR_DELIVERY => self::DESC_OUT_FOR_DELIVERY,
self::OUT_FOR_DELIVERY => "W dor\u{0119}czeniu",
self::READY_FOR_PICKUP => 'Gotowa do odbioru',
self::DELIVERED => self::DESC_DELIVERED,
self::DELIVERED => "Dor\u{0119}czona",
self::RETURNED => 'Zwrócona',
self::CANCELLED => 'Anulowana',
self::PROBLEM => 'Problem',
];
private const INPOST_MAP = [
'created' => self::CREATED,
'offers_prepared' => self::CREATED,
'offer_selected' => self::CREATED,
'confirmed' => self::CONFIRMED,
'dispatched' => self::CONFIRMED,
'collected' => self::PICKED_UP,
'taken_by_courier' => self::PICKED_UP,
'adopted_at_source_branch' => self::IN_TRANSIT,
'adopted_at_sorting_center' => self::IN_TRANSIT,
'sent_from_sorting_center' => self::IN_TRANSIT,
'adopted_at_target_sorting_center' => self::IN_TRANSIT,
'sent_from_target_sorting_center' => self::IN_TRANSIT,
'adopted_at_target_branch' => self::IN_TRANSIT,
'out_for_delivery' => self::OUT_FOR_DELIVERY,
'ready_to_pickup' => self::READY_FOR_PICKUP,
'ready_to_pickup_from_branch' => self::READY_FOR_PICKUP,
'ready_to_pickup_from_pok' => self::READY_FOR_PICKUP,
'stack_in_box_machine' => self::READY_FOR_PICKUP,
'stack_in_customer_service_point' => self::READY_FOR_PICKUP,
'delivered' => self::DELIVERED,
'claimed' => self::DELIVERED,
'returned_to_sender' => self::RETURNED,
'undelivered' => self::RETURNED,
'undelivered_wrong_address' => self::RETURNED,
'undelivered_incomplete_address' => self::RETURNED,
'undelivered_unknown_recipient' => self::RETURNED,
'undelivered_cod_cash_receiver' => self::RETURNED,
'cancelled' => self::CANCELLED,
'expired' => self::CANCELLED,
'avizo' => self::PROBLEM,
'pickup_time_expired' => self::PROBLEM,
'stack_parcel_pickup_time_expired' => self::PROBLEM,
'missing' => self::PROBLEM,
'delay_in_delivery' => self::PROBLEM,
'oversized' => self::PROBLEM,
'pickup_reminder_sent' => self::READY_FOR_PICKUP,
'pickup_reminder_sent_address' => self::READY_FOR_PICKUP,
'readdressed' => self::IN_TRANSIT,
'redirect_to_box' => self::IN_TRANSIT,
];
private const INPOST_DESCRIPTIONS = [
'created' => 'Przesyłka utworzona',
'offers_prepared' => 'Oferty cenowe przygotowane',
'offer_selected' => 'Oferta wybrana',
'confirmed' => 'Przesyłka potwierdzona',
'dispatched' => self::DESC_DISPATCHED,
'collected' => 'Odebrana od nadawcy',
'taken_by_courier' => self::DESC_PICKED_UP_BY_COURIER,
'adopted_at_source_branch' => 'Przyjęta w oddziale źródłowym',
'adopted_at_sorting_center' => 'Przyjęta w centrum sortowania',
'sent_from_sorting_center' => 'Wysłana z centrum sortowania',
'adopted_at_target_sorting_center' => 'Przyjęta w docelowym centrum sortowania',
'sent_from_target_sorting_center' => 'Wysłana z docelowego centrum sortowania',
'adopted_at_target_branch' => 'Przyjęta w oddziale docelowym',
'out_for_delivery' => 'W drodze do odbiorcy',
'ready_to_pickup' => 'Gotowa do odbioru w paczkomacie',
'ready_to_pickup_from_branch' => 'Gotowa do odbioru z oddziału',
'ready_to_pickup_from_pok' => 'Gotowa do odbioru z POK',
'stack_in_box_machine' => 'Umieszczona w paczkomacie',
'stack_in_customer_service_point' => 'Umieszczona w punkcie obsługi',
'delivered' => self::DESC_DELIVERED,
'claimed' => 'Odebrana po awizo',
'returned_to_sender' => self::DESC_RETURNED_TO_SENDER,
'undelivered' => 'Niedoręczona',
'undelivered_wrong_address' => 'Niedoręczona — błędny adres',
'undelivered_incomplete_address' => 'Niedoręczona — niepełny adres',
'undelivered_unknown_recipient' => 'Niedoręczona — nieznany odbiorca',
'undelivered_cod_cash_receiver' => 'Niedoręczona — problem z pobraniem',
'cancelled' => 'Anulowana',
'expired' => 'Wygasła',
'avizo' => 'Awizowana',
'pickup_time_expired' => 'Czas odbioru upłynął',
'stack_parcel_pickup_time_expired' => 'Czas odbioru ze stack upłynął',
'missing' => 'Przesyłka zagubiona',
'delay_in_delivery' => 'Opóźnienie w dostawie',
'oversized' => 'Przesyłka ponadgabarytowa',
'pickup_reminder_sent' => 'Wysłano przypomnienie o odbiorze',
'pickup_reminder_sent_address' => 'Przypomnienie wysłane na adres',
'readdressed' => 'Przekierowana na inny adres',
'redirect_to_box' => 'Przekierowana do paczkomatu',
];
private const APACZKA_MAP = [
'0' => self::CREATED,
'1' => self::CONFIRMED,
'2' => self::PICKED_UP,
'3' => self::IN_TRANSIT,
'4' => self::OUT_FOR_DELIVERY,
'5' => self::DELIVERED,
'6' => self::RETURNED,
'7' => self::CANCELLED,
'8' => self::PROBLEM,
'9' => self::READY_FOR_PICKUP,
'10' => self::IN_TRANSIT,
'NEW' => self::CREATED,
'PENDING' => self::CREATED,
'CONFIRMED' => self::CONFIRMED,
'PICKED_UP' => self::PICKED_UP,
'PICKUP' => self::PICKED_UP,
'IN_TRANSIT' => self::IN_TRANSIT,
'OUT_FOR_DELIVERY' => self::OUT_FOR_DELIVERY,
'DELIVERED' => self::DELIVERED,
'RETURNED' => self::RETURNED,
'RETURNED_TO_SHIPPER' => self::RETURNED,
'CANCELLED' => self::CANCELLED,
'ERROR' => self::PROBLEM,
'WAITING_FOR_PICKUP' => self::READY_FOR_PICKUP,
'REDIRECT' => self::IN_TRANSIT,
];
private const APACZKA_DESCRIPTIONS = [
'0' => 'Oczekuje na przetworzenie',
'1' => 'Zamówienie potwierdzone',
'2' => self::DESC_PICKED_UP_BY_COURIER,
'3' => 'W transporcie',
'4' => self::DESC_OUT_FOR_DELIVERY,
'5' => self::DESC_DELIVERED,
'6' => self::DESC_RETURNED_TO_SENDER,
'7' => 'Anulowana',
'8' => 'Błąd zamówienia',
'9' => self::DESC_AWAITING_PICKUP . ' w punkcie',
'10' => 'Przekierowana',
'NEW' => 'Zamówienie utworzone',
'PENDING' => 'Oczekuje na przetworzenie',
'CONFIRMED' => 'Zamówienie potwierdzone',
'PICKED_UP' => self::DESC_PICKED_UP_BY_COURIER,
'PICKUP' => self::DESC_PICKED_UP_BY_COURIER,
'IN_TRANSIT' => 'W transporcie',
'OUT_FOR_DELIVERY' => self::DESC_OUT_FOR_DELIVERY,
'DELIVERED' => self::DESC_DELIVERED,
'RETURNED' => self::DESC_RETURNED_TO_SENDER,
'RETURNED_TO_SHIPPER' => self::DESC_RETURNED_TO_SENDER,
'CANCELLED' => 'Anulowana',
'ERROR' => 'Błąd zamówienia',
'WAITING_FOR_PICKUP' => self::DESC_AWAITING_PICKUP . ' w punkcie',
'REDIRECT' => 'Przekierowana',
];
private const ALLEGRO_MAP = [
'NEW' => self::CREATED,
'READY_TO_SHIP' => self::CONFIRMED,
'collected_from_sender' => self::PICKED_UP,
'IN_TRANSIT' => self::IN_TRANSIT,
'DELIVERED' => self::DELIVERED,
'CANCELLED' => self::CANCELLED,
'ERROR' => self::PROBLEM,
'RETURNED' => self::RETURNED,
];
private const ALLEGRO_DESCRIPTIONS = [
'NEW' => 'Przesyłka utworzona',
'READY_TO_SHIP' => 'Etykieta wygenerowana, oczekuje na nadanie',
'collected_from_sender' => 'Odebrana od nadawcy',
'IN_TRANSIT' => 'Odebrana przez przewoźnika',
'DELIVERED' => self::DESC_DELIVERED,
'CANCELLED' => 'Anulowana',
'ERROR' => 'Błąd przetwarzania',
'RETURNED' => self::DESC_RETURNED_TO_SENDER,
];
private const ALLEGRO_EDGE_MAP = [
// Realne slugi z edge API (po slugify opisów)
'przygotowana_przez_nadawce' => self::CREATED,
'prepared_by_the_sender' => self::CREATED,
'nadana' => self::CONFIRMED,
'dispatched' => self::CONFIRMED,
'podjeta_z_maszyny_przez_kuriera' => self::PICKED_UP,
'podjeta_z_punktu_przez_kuriera' => self::PICKED_UP,
'podjeta_z_punktu' => self::PICKED_UP,
'odebrana_przez_kuriera' => self::PICKED_UP,
'picked_up_from_point_by_courier' => self::PICKED_UP,
'picked_up_by_the_courier' => self::PICKED_UP,
'przekazal_przesylke_do_magazynu' => self::IN_TRANSIT,
'przekazana_do_magazynu' => self::IN_TRANSIT,
'transferred_the_parcel_to_the_warehouse' => self::IN_TRANSIT,
'accepted_at_the_branch' => self::IN_TRANSIT,
'przesylka_wyjechala_w_droge_do_punktu_docelowego' => self::IN_TRANSIT,
'w_sortowni' => self::IN_TRANSIT,
'wyjechala_w_droge_do_punktu_docelowego' => self::IN_TRANSIT,
'wyslana_z_sortowni' => self::IN_TRANSIT,
'w_doreczeniu' => self::OUT_FOR_DELIVERY,
'wydana_do_doreczenia' => self::OUT_FOR_DELIVERY,
'released_for_delivery' => self::OUT_FOR_DELIVERY,
'dostarczana' => self::OUT_FOR_DELIVERY,
'gotowa_do_odbioru' => self::READY_FOR_PICKUP,
'oczekuje_na_odbior' => self::READY_FOR_PICKUP,
'przesylka_oczekuje_na_odbior' => self::READY_FOR_PICKUP,
'awaiting_pick_up' => self::READY_FOR_PICKUP,
'dostarczona' => self::DELIVERED,
'doreczona' => self::DELIVERED,
'odebrana' => self::DELIVERED,
'delivered' => self::DELIVERED,
'zwrocona' => self::RETURNED,
'zwrocona_do_nadawcy' => self::RETURNED,
'returned_to_the_sender' => self::RETURNED,
'anulowana' => self::CANCELLED,
'cancelled' => self::CANCELLED,
'odmowa_przyjecia' => self::PROBLEM,
'uszkodzona' => self::PROBLEM,
'zagubiona' => self::PROBLEM,
];
private const ALLEGRO_EDGE_DESCRIPTIONS = [
'przygotowana_przez_nadawce' => 'Przesyłka przygotowana przez nadawcę',
'prepared_by_the_sender' => 'Przesyłka przygotowana przez nadawcę',
'nadana' => self::DESC_DISPATCHED,
'dispatched' => self::DESC_DISPATCHED,
'podjeta_z_maszyny_przez_kuriera' => 'Podjęta z maszyny przez kuriera',
'podjeta_z_punktu_przez_kuriera' => 'Podjęta z punktu przez kuriera',
'odebrana_przez_kuriera' => self::DESC_PICKED_UP_BY_COURIER,
'picked_up_from_point_by_courier' => 'Podjęta z punktu przez kuriera',
'picked_up_by_the_courier' => self::DESC_PICKED_UP_BY_COURIER,
'przekazana_do_magazynu' => 'Przekazana do magazynu',
'transferred_the_parcel_to_the_warehouse' => 'Przekazana do magazynu',
'accepted_at_the_branch' => 'Przyjęta w oddziale',
'przesylka_wyjechala_w_droge_do_punktu_docelowego' => 'Wyjechała w drogę do punktu docelowego',
'w_sortowni' => 'W sortowni',
'wyjechala_w_droge_do_punktu_docelowego' => 'Wyjechała w drogę do punktu docelowego',
'wyslana_z_sortowni' => 'Wysłana z sortowni',
'w_doreczeniu' => self::DESC_OUT_FOR_DELIVERY,
'wydana_do_doreczenia' => 'Wydana do doręczenia',
'released_for_delivery' => 'Wydana do doręczenia',
'dostarczana' => 'Dostarczana',
'gotowa_do_odbioru' => 'Gotowa do odbioru',
'oczekuje_na_odbior' => self::DESC_AWAITING_PICKUP,
'przesylka_oczekuje_na_odbior' => self::DESC_AWAITING_PICKUP,
'awaiting_pick_up' => self::DESC_AWAITING_PICKUP,
'dostarczona' => 'Dostarczona',
'doreczona' => self::DESC_DELIVERED,
'odebrana' => 'Odebrana',
'delivered' => 'Dostarczona',
'zwrocona' => 'Zwrócona',
'zwrocona_do_nadawcy' => self::DESC_RETURNED_TO_SENDER,
'returned_to_the_sender' => self::DESC_RETURNED_TO_SENDER,
'anulowana' => 'Anulowana',
'cancelled' => 'Anulowana',
'odmowa_przyjecia' => 'Odmowa przyjęcia',
'uszkodzona' => 'Uszkodzona',
'zagubiona' => 'Zagubiona',
];
public const ALL_STATUSES = [
self::UNKNOWN,
self::CREATED,
@@ -305,151 +63,14 @@ final class DeliveryStatus
self::PROBLEM,
];
private const POLKURIER_MAP = [
// Oficjalne kody ORDER_STATUS z dokumentacji polkurier API v1.11 (marzec 2026)
'O' => self::CREATED,
'P' => self::CONFIRMED,
'A' => self::CANCELLED,
'WP' => self::IN_TRANSIT,
'D' => self::DELIVERED,
'Z' => self::RETURNED,
'W' => self::PROBLEM,
];
private const POLKURIER_DESCRIPTIONS = [
'O' => 'Oczekuje na płatność',
'P' => 'Potwierdzone, list wygenerowany',
'A' => 'Anulowane',
'WP' => 'W przewozie',
'D' => 'Dostarczona',
'Z' => 'Zwrot do nadawcy',
'W' => 'Wyjątek',
];
private const PROVIDER_MAPS = [
'inpost' => self::INPOST_MAP,
'apaczka' => self::APACZKA_MAP,
'allegro_wza' => self::ALLEGRO_MAP,
'allegro_edge' => self::ALLEGRO_EDGE_MAP,
'polkurier' => self::POLKURIER_MAP,
];
private const PROVIDER_DESCRIPTIONS = [
'inpost' => self::INPOST_DESCRIPTIONS,
'apaczka' => self::APACZKA_DESCRIPTIONS,
'allegro_wza' => self::ALLEGRO_DESCRIPTIONS,
'allegro_edge' => self::ALLEGRO_EDGE_DESCRIPTIONS,
'polkurier' => self::POLKURIER_DESCRIPTIONS,
];
private const DESCRIPTION_STATUS_PATTERNS = [
self::DELIVERED => [
'delivered',
'picked up by recipient',
'doręczon',
'dostarczono',
'odebrana przez odbiorc',
],
self::PICKED_UP => [
'picked up by courier',
'picked up from point',
'podjęta',
'podjeta',
'odebrana przez kuriera',
],
self::RETURNED => [
'returned',
'zwrócon',
'zwrocona',
],
self::CANCELLED => [
'cancelled',
'canceled',
'anulowan',
],
self::OUT_FOR_DELIVERY => [
'out for delivery',
'released for delivery',
'doręczeni',
'doreczenia',
'wydana do',
],
self::READY_FOR_PICKUP => [
'awaiting pick-up',
'awaiting pickup',
'ready for pickup',
'ready for pick-up',
'oczekuje na odb',
'gotowa do odb',
],
self::IN_TRANSIT => [
'courier',
'warehouse',
'branch',
'in transit',
'sortowni',
'magazyn',
'w drodze',
'tranzyt',
'kurier',
'wyjechał',
'wyjechala',
],
self::CONFIRMED => [
'dispatched',
'nadana',
'nadano',
],
self::CREATED => [
'prepared',
'created',
'przygotowan',
'utworzon',
],
self::PROBLEM => [
'damaged',
'problem',
'lost',
'uszkodzon',
'zagubion',
],
];
private const PROVIDER_TRACKING_URLS = [
'inpost' => self::TRACKING_INPOST_URL,
'allegro_wza' => self::TRACKING_ALLEGRO_URL,
'polkurier' => 'https://polkurier.pl/sledz-paczke/',
];
private const CARRIER_TRACKING_URLS = [
[['dpd'], 'https://tracktrace.dpd.com.pl/parcelDetails?p1='],
[['dhl'], 'https://www.dhl.com/pl-pl/home/sledzenie-przesylki.html?tracking-id='],
[['inpost', 'paczkomat'], self::TRACKING_INPOST_URL],
[['orlen', 'ruch'], 'https://www.orlenpaczka.pl/sledz-paczke/?numer='],
[['poczta', 'pocztex'], 'https://emonitoring.poczta-polska.pl/?numer='],
[['ups'], 'https://www.ups.com/track?tracknum='],
[['fedex'], 'https://www.fedex.com/fedextrack/?trknbr='],
[['gls'], 'https://gls-group.com/PL/pl/sledzenie-paczek?match='],
[['allegro'], self::TRACKING_ALLEGRO_URL],
];
// --- Mapowanie statusow dostawcow (delegacja do DeliveryStatusProviderMap) ---
/**
* @return array<string, array{normalized: string, description: string}>
*/
public static function getDefaultMappings(string $provider): array
{
$map = self::PROVIDER_MAPS[$provider] ?? [];
$descriptions = self::PROVIDER_DESCRIPTIONS[$provider] ?? [];
$result = [];
foreach ($map as $rawStatus => $normalized) {
$result[(string) $rawStatus] = [
'normalized' => $normalized,
'description' => (string) ($descriptions[$rawStatus] ?? (string) $rawStatus),
];
}
return $result;
return DeliveryStatusProviderMap::getDefaultMappings($provider);
}
/**
@@ -457,12 +78,7 @@ final class DeliveryStatus
*/
public static function normalizeWithOverrides(string $provider, string $rawStatus, array $overrides): string
{
$key = $provider . ':' . $rawStatus;
if (isset($overrides[$key]) && $overrides[$key]['normalized_status'] !== '') {
return $overrides[$key]['normalized_status'];
}
return self::normalize($provider, $rawStatus);
return DeliveryStatusProviderMap::normalizeWithOverrides($provider, $rawStatus, $overrides);
}
/**
@@ -470,24 +86,21 @@ final class DeliveryStatus
*/
public static function descriptionWithOverrides(string $provider, string $rawStatus, array $overrides): string
{
$key = $provider . ':' . $rawStatus;
if (isset($overrides[$key]) && $overrides[$key]['description'] !== '') {
return $overrides[$key]['description'];
}
return self::description($provider, $rawStatus);
return DeliveryStatusProviderMap::descriptionWithOverrides($provider, $rawStatus, $overrides);
}
public static function normalize(string $provider, string $rawStatus): string
{
return self::PROVIDER_MAPS[$provider][$rawStatus] ?? self::UNKNOWN;
return DeliveryStatusProviderMap::normalize($provider, $rawStatus);
}
public static function description(string $provider, string $rawStatus): string
{
return self::PROVIDER_DESCRIPTIONS[$provider][$rawStatus] ?? $rawStatus;
return DeliveryStatusProviderMap::description($provider, $rawStatus);
}
// --- Most do bazy danych (slownik statusow konfigurowalny w panelu) ---
public static function setRepository(DeliveryStatusRepository $repo): void
{
self::$repository = $repo;
@@ -536,122 +149,22 @@ final class DeliveryStatus
return in_array($status, self::TERMINAL_STATUSES, true);
}
// --- Heurystyki opisow Allegro (delegacja do AllegroDescriptionGuesser) ---
public static function slugifyAllegroDescription(string $description): string
{
$text = trim($description);
if ($text === '') {
return 'unknown';
}
// Usuń typowe prefiksy
$text = preg_replace('/^Przesy[łl]ka zosta[łl]a\s+/ui', '', $text);
$text = preg_replace('/^Kurier\s+/ui', '', $text);
$text = preg_replace('/^Paczka zosta[łl]a\s+/ui', '', $text);
$text = preg_replace('/^Parcel has been\s+/i', '', $text);
$text = preg_replace('/^Parcel is\s+/i', '', $text);
$text = preg_replace('/^Courier has\s+/i', '', $text);
// Polskie znaki na ASCII
$polish = ['ą','ć','ę','ł','ń','ó','ś','ź','ż','Ą','Ć','Ę','Ł','Ń','Ó','Ś','Ź','Ż'];
$ascii = ['a','c','e','l','n','o','s','z','z','A','C','E','L','N','O','S','Z','Z'];
$text = str_replace($polish, $ascii, $text);
// Lowercase, zamień nie-alfanumeryczne na podkreślenia
$text = strtolower($text);
$text = preg_replace('/[^a-z0-9]+/', '_', $text);
$text = trim($text, '_');
return $text !== '' ? $text : 'unknown';
return AllegroDescriptionGuesser::slugifyAllegroDescription($description);
}
/**
* Keyword-based fallback for unknown Allegro edge descriptions.
* Used when slugified description is not in ALLEGRO_EDGE_MAP.
*/
public static function guessStatusFromDescription(string $description): string
{
$lower = mb_strtolower($description, 'UTF-8');
foreach (self::DESCRIPTION_STATUS_PATTERNS as $status => $patterns) {
if (self::containsAny($lower, $patterns)) {
return $status;
}
}
return str_contains($lower, "odbi\u{00F3}r") && !str_contains($lower, 'w drodze')
? self::READY_FOR_PICKUP
: self::UNKNOWN;
return AllegroDescriptionGuesser::guessStatusFromDescription($description);
}
// --- Budowanie URL-i sledzenia (delegacja do DeliveryTrackingUrlBuilder) ---
public static function trackingUrl(string $provider, string $trackingNumber, string $carrierId = ''): ?string
{
$number = trim($trackingNumber);
if ($number === '') {
return null;
}
$encoded = rawurlencode($number);
$providerUrl = self::providerTrackingUrl($provider, $encoded);
if ($provider === 'inpost') {
return $providerUrl;
}
return self::carrierTrackingUrl($encoded, $carrierId) ?? $providerUrl ?? self::fallbackTrackingUrl($encoded);
}
private static function carrierTrackingUrl(string $encoded, string $carrierId): ?string
{
$carrier = strtolower(trim($carrierId));
return $carrier === '' ? null : self::matchCarrierByName($encoded, $carrier);
}
private static function providerTrackingUrl(string $provider, string $encoded): ?string
{
$baseUrl = self::PROVIDER_TRACKING_URLS[$provider] ?? null;
return $baseUrl === null ? null : $baseUrl . $encoded;
}
private static function fallbackTrackingUrl(string $encoded): string
{
return 'https://www.google.com/search?q=' . $encoded . '+sledzenie+przesylki';
}
private static function matchCarrierByName(string $encoded, string $carrier): ?string
{
foreach (self::CARRIER_TRACKING_URLS as [$patterns, $baseUrl]) {
if (self::carrierMatches($carrier, $patterns)) {
return $baseUrl . $encoded;
}
}
return null;
}
/**
* @param array<int, string> $needles
*/
private static function containsAny(string $haystack, array $needles): bool
{
foreach ($needles as $needle) {
if (str_contains($haystack, $needle)) {
return true;
}
}
return false;
}
/**
* @param array<int, string> $patterns
*/
private static function carrierMatches(string $carrier, array $patterns): bool
{
foreach ($patterns as $pattern) {
if ($carrier === $pattern || str_contains($carrier, $pattern)) {
return true;
}
}
return false;
return DeliveryTrackingUrlBuilder::trackingUrl($provider, $trackingNumber, $carrierId);
}
}

View File

@@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
namespace App\Modules\Shipments;
/**
* Mapowanie surowych statusow dostawcow (InPost, Apaczka, Allegro, Polkurier)
* na znormalizowane statusy {@see DeliveryStatus} oraz ich polskie opisy.
*/
final class DeliveryStatusProviderMap
{
private const DESC_PICKED_UP_BY_COURIER = 'Odebrana przez kuriera';
private const DESC_DISPATCHED = "Przesy\u{0142}ka nadana";
private const DESC_OUT_FOR_DELIVERY = "W dor\u{0119}czeniu";
private const DESC_DELIVERED = "Dor\u{0119}czona";
private const DESC_RETURNED_TO_SENDER = "Zwr\u{00F3}cona do nadawcy";
private const DESC_AWAITING_PICKUP = "Oczekuje na odbi\u{00F3}r";
private const INPOST_MAP = [
'created' => DeliveryStatus::CREATED,
'offers_prepared' => DeliveryStatus::CREATED,
'offer_selected' => DeliveryStatus::CREATED,
'confirmed' => DeliveryStatus::CONFIRMED,
'dispatched' => DeliveryStatus::CONFIRMED,
'collected' => DeliveryStatus::PICKED_UP,
'taken_by_courier' => DeliveryStatus::PICKED_UP,
'adopted_at_source_branch' => DeliveryStatus::IN_TRANSIT,
'adopted_at_sorting_center' => DeliveryStatus::IN_TRANSIT,
'sent_from_sorting_center' => DeliveryStatus::IN_TRANSIT,
'adopted_at_target_sorting_center' => DeliveryStatus::IN_TRANSIT,
'sent_from_target_sorting_center' => DeliveryStatus::IN_TRANSIT,
'adopted_at_target_branch' => DeliveryStatus::IN_TRANSIT,
'out_for_delivery' => DeliveryStatus::OUT_FOR_DELIVERY,
'ready_to_pickup' => DeliveryStatus::READY_FOR_PICKUP,
'ready_to_pickup_from_branch' => DeliveryStatus::READY_FOR_PICKUP,
'ready_to_pickup_from_pok' => DeliveryStatus::READY_FOR_PICKUP,
'stack_in_box_machine' => DeliveryStatus::READY_FOR_PICKUP,
'stack_in_customer_service_point' => DeliveryStatus::READY_FOR_PICKUP,
'delivered' => DeliveryStatus::DELIVERED,
'claimed' => DeliveryStatus::DELIVERED,
'returned_to_sender' => DeliveryStatus::RETURNED,
'undelivered' => DeliveryStatus::RETURNED,
'undelivered_wrong_address' => DeliveryStatus::RETURNED,
'undelivered_incomplete_address' => DeliveryStatus::RETURNED,
'undelivered_unknown_recipient' => DeliveryStatus::RETURNED,
'undelivered_cod_cash_receiver' => DeliveryStatus::RETURNED,
'cancelled' => DeliveryStatus::CANCELLED,
'expired' => DeliveryStatus::CANCELLED,
'avizo' => DeliveryStatus::PROBLEM,
'pickup_time_expired' => DeliveryStatus::PROBLEM,
'stack_parcel_pickup_time_expired' => DeliveryStatus::PROBLEM,
'missing' => DeliveryStatus::PROBLEM,
'delay_in_delivery' => DeliveryStatus::PROBLEM,
'oversized' => DeliveryStatus::PROBLEM,
'pickup_reminder_sent' => DeliveryStatus::READY_FOR_PICKUP,
'pickup_reminder_sent_address' => DeliveryStatus::READY_FOR_PICKUP,
'readdressed' => DeliveryStatus::IN_TRANSIT,
'redirect_to_box' => DeliveryStatus::IN_TRANSIT,
];
private const INPOST_DESCRIPTIONS = [
'created' => 'Przesyłka utworzona',
'offers_prepared' => 'Oferty cenowe przygotowane',
'offer_selected' => 'Oferta wybrana',
'confirmed' => 'Przesyłka potwierdzona',
'dispatched' => self::DESC_DISPATCHED,
'collected' => 'Odebrana od nadawcy',
'taken_by_courier' => self::DESC_PICKED_UP_BY_COURIER,
'adopted_at_source_branch' => 'Przyjęta w oddziale źródłowym',
'adopted_at_sorting_center' => 'Przyjęta w centrum sortowania',
'sent_from_sorting_center' => 'Wysłana z centrum sortowania',
'adopted_at_target_sorting_center' => 'Przyjęta w docelowym centrum sortowania',
'sent_from_target_sorting_center' => 'Wysłana z docelowego centrum sortowania',
'adopted_at_target_branch' => 'Przyjęta w oddziale docelowym',
'out_for_delivery' => 'W drodze do odbiorcy',
'ready_to_pickup' => 'Gotowa do odbioru w paczkomacie',
'ready_to_pickup_from_branch' => 'Gotowa do odbioru z oddziału',
'ready_to_pickup_from_pok' => 'Gotowa do odbioru z POK',
'stack_in_box_machine' => 'Umieszczona w paczkomacie',
'stack_in_customer_service_point' => 'Umieszczona w punkcie obsługi',
'delivered' => self::DESC_DELIVERED,
'claimed' => 'Odebrana po awizo',
'returned_to_sender' => self::DESC_RETURNED_TO_SENDER,
'undelivered' => 'Niedoręczona',
'undelivered_wrong_address' => 'Niedoręczona — błędny adres',
'undelivered_incomplete_address' => 'Niedoręczona — niepełny adres',
'undelivered_unknown_recipient' => 'Niedoręczona — nieznany odbiorca',
'undelivered_cod_cash_receiver' => 'Niedoręczona — problem z pobraniem',
'cancelled' => 'Anulowana',
'expired' => 'Wygasła',
'avizo' => 'Awizowana',
'pickup_time_expired' => 'Czas odbioru upłynął',
'stack_parcel_pickup_time_expired' => 'Czas odbioru ze stack upłynął',
'missing' => 'Przesyłka zagubiona',
'delay_in_delivery' => 'Opóźnienie w dostawie',
'oversized' => 'Przesyłka ponadgabarytowa',
'pickup_reminder_sent' => 'Wysłano przypomnienie o odbiorze',
'pickup_reminder_sent_address' => 'Przypomnienie wysłane na adres',
'readdressed' => 'Przekierowana na inny adres',
'redirect_to_box' => 'Przekierowana do paczkomatu',
];
private const APACZKA_MAP = [
'0' => DeliveryStatus::CREATED,
'1' => DeliveryStatus::CONFIRMED,
'2' => DeliveryStatus::PICKED_UP,
'3' => DeliveryStatus::IN_TRANSIT,
'4' => DeliveryStatus::OUT_FOR_DELIVERY,
'5' => DeliveryStatus::DELIVERED,
'6' => DeliveryStatus::RETURNED,
'7' => DeliveryStatus::CANCELLED,
'8' => DeliveryStatus::PROBLEM,
'9' => DeliveryStatus::READY_FOR_PICKUP,
'10' => DeliveryStatus::IN_TRANSIT,
'NEW' => DeliveryStatus::CREATED,
'PENDING' => DeliveryStatus::CREATED,
'CONFIRMED' => DeliveryStatus::CONFIRMED,
'PICKED_UP' => DeliveryStatus::PICKED_UP,
'PICKUP' => DeliveryStatus::PICKED_UP,
'IN_TRANSIT' => DeliveryStatus::IN_TRANSIT,
'OUT_FOR_DELIVERY' => DeliveryStatus::OUT_FOR_DELIVERY,
'DELIVERED' => DeliveryStatus::DELIVERED,
'RETURNED' => DeliveryStatus::RETURNED,
'RETURNED_TO_SHIPPER' => DeliveryStatus::RETURNED,
'CANCELLED' => DeliveryStatus::CANCELLED,
'ERROR' => DeliveryStatus::PROBLEM,
'WAITING_FOR_PICKUP' => DeliveryStatus::READY_FOR_PICKUP,
'REDIRECT' => DeliveryStatus::IN_TRANSIT,
];
private const APACZKA_DESCRIPTIONS = [
'0' => 'Oczekuje na przetworzenie',
'1' => 'Zamówienie potwierdzone',
'2' => self::DESC_PICKED_UP_BY_COURIER,
'3' => 'W transporcie',
'4' => self::DESC_OUT_FOR_DELIVERY,
'5' => self::DESC_DELIVERED,
'6' => self::DESC_RETURNED_TO_SENDER,
'7' => 'Anulowana',
'8' => 'Błąd zamówienia',
'9' => self::DESC_AWAITING_PICKUP . ' w punkcie',
'10' => 'Przekierowana',
'NEW' => 'Zamówienie utworzone',
'PENDING' => 'Oczekuje na przetworzenie',
'CONFIRMED' => 'Zamówienie potwierdzone',
'PICKED_UP' => self::DESC_PICKED_UP_BY_COURIER,
'PICKUP' => self::DESC_PICKED_UP_BY_COURIER,
'IN_TRANSIT' => 'W transporcie',
'OUT_FOR_DELIVERY' => self::DESC_OUT_FOR_DELIVERY,
'DELIVERED' => self::DESC_DELIVERED,
'RETURNED' => self::DESC_RETURNED_TO_SENDER,
'RETURNED_TO_SHIPPER' => self::DESC_RETURNED_TO_SENDER,
'CANCELLED' => 'Anulowana',
'ERROR' => 'Błąd zamówienia',
'WAITING_FOR_PICKUP' => self::DESC_AWAITING_PICKUP . ' w punkcie',
'REDIRECT' => 'Przekierowana',
];
private const ALLEGRO_MAP = [
'NEW' => DeliveryStatus::CREATED,
'READY_TO_SHIP' => DeliveryStatus::CONFIRMED,
'collected_from_sender' => DeliveryStatus::PICKED_UP,
'IN_TRANSIT' => DeliveryStatus::IN_TRANSIT,
'DELIVERED' => DeliveryStatus::DELIVERED,
'CANCELLED' => DeliveryStatus::CANCELLED,
'ERROR' => DeliveryStatus::PROBLEM,
'RETURNED' => DeliveryStatus::RETURNED,
];
private const ALLEGRO_DESCRIPTIONS = [
'NEW' => 'Przesyłka utworzona',
'READY_TO_SHIP' => 'Etykieta wygenerowana, oczekuje na nadanie',
'collected_from_sender' => 'Odebrana od nadawcy',
'IN_TRANSIT' => 'Odebrana przez przewoźnika',
'DELIVERED' => self::DESC_DELIVERED,
'CANCELLED' => 'Anulowana',
'ERROR' => 'Błąd przetwarzania',
'RETURNED' => self::DESC_RETURNED_TO_SENDER,
];
private const ALLEGRO_EDGE_MAP = [
// Realne slugi z edge API (po slugify opisów)
'przygotowana_przez_nadawce' => DeliveryStatus::CREATED,
'prepared_by_the_sender' => DeliveryStatus::CREATED,
'nadana' => DeliveryStatus::CONFIRMED,
'dispatched' => DeliveryStatus::CONFIRMED,
'podjeta_z_maszyny_przez_kuriera' => DeliveryStatus::PICKED_UP,
'podjeta_z_punktu_przez_kuriera' => DeliveryStatus::PICKED_UP,
'podjeta_z_punktu' => DeliveryStatus::PICKED_UP,
'odebrana_przez_kuriera' => DeliveryStatus::PICKED_UP,
'picked_up_from_point_by_courier' => DeliveryStatus::PICKED_UP,
'picked_up_by_the_courier' => DeliveryStatus::PICKED_UP,
'przekazal_przesylke_do_magazynu' => DeliveryStatus::IN_TRANSIT,
'przekazana_do_magazynu' => DeliveryStatus::IN_TRANSIT,
'transferred_the_parcel_to_the_warehouse' => DeliveryStatus::IN_TRANSIT,
'accepted_at_the_branch' => DeliveryStatus::IN_TRANSIT,
'przesylka_wyjechala_w_droge_do_punktu_docelowego' => DeliveryStatus::IN_TRANSIT,
'w_sortowni' => DeliveryStatus::IN_TRANSIT,
'wyjechala_w_droge_do_punktu_docelowego' => DeliveryStatus::IN_TRANSIT,
'wyslana_z_sortowni' => DeliveryStatus::IN_TRANSIT,
'w_doreczeniu' => DeliveryStatus::OUT_FOR_DELIVERY,
'wydana_do_doreczenia' => DeliveryStatus::OUT_FOR_DELIVERY,
'released_for_delivery' => DeliveryStatus::OUT_FOR_DELIVERY,
'dostarczana' => DeliveryStatus::OUT_FOR_DELIVERY,
'gotowa_do_odbioru' => DeliveryStatus::READY_FOR_PICKUP,
'oczekuje_na_odbior' => DeliveryStatus::READY_FOR_PICKUP,
'przesylka_oczekuje_na_odbior' => DeliveryStatus::READY_FOR_PICKUP,
'awaiting_pick_up' => DeliveryStatus::READY_FOR_PICKUP,
'dostarczona' => DeliveryStatus::DELIVERED,
'doreczona' => DeliveryStatus::DELIVERED,
'odebrana' => DeliveryStatus::DELIVERED,
'delivered' => DeliveryStatus::DELIVERED,
'zwrocona' => DeliveryStatus::RETURNED,
'zwrocona_do_nadawcy' => DeliveryStatus::RETURNED,
'returned_to_the_sender' => DeliveryStatus::RETURNED,
'anulowana' => DeliveryStatus::CANCELLED,
'cancelled' => DeliveryStatus::CANCELLED,
'odmowa_przyjecia' => DeliveryStatus::PROBLEM,
'uszkodzona' => DeliveryStatus::PROBLEM,
'zagubiona' => DeliveryStatus::PROBLEM,
];
private const ALLEGRO_EDGE_DESCRIPTIONS = [
'przygotowana_przez_nadawce' => 'Przesyłka przygotowana przez nadawcę',
'prepared_by_the_sender' => 'Przesyłka przygotowana przez nadawcę',
'nadana' => self::DESC_DISPATCHED,
'dispatched' => self::DESC_DISPATCHED,
'podjeta_z_maszyny_przez_kuriera' => 'Podjęta z maszyny przez kuriera',
'podjeta_z_punktu_przez_kuriera' => 'Podjęta z punktu przez kuriera',
'odebrana_przez_kuriera' => self::DESC_PICKED_UP_BY_COURIER,
'picked_up_from_point_by_courier' => 'Podjęta z punktu przez kuriera',
'picked_up_by_the_courier' => self::DESC_PICKED_UP_BY_COURIER,
'przekazana_do_magazynu' => 'Przekazana do magazynu',
'transferred_the_parcel_to_the_warehouse' => 'Przekazana do magazynu',
'accepted_at_the_branch' => 'Przyjęta w oddziale',
'przesylka_wyjechala_w_droge_do_punktu_docelowego' => 'Wyjechała w drogę do punktu docelowego',
'w_sortowni' => 'W sortowni',
'wyjechala_w_droge_do_punktu_docelowego' => 'Wyjechała w drogę do punktu docelowego',
'wyslana_z_sortowni' => 'Wysłana z sortowni',
'w_doreczeniu' => self::DESC_OUT_FOR_DELIVERY,
'wydana_do_doreczenia' => 'Wydana do doręczenia',
'released_for_delivery' => 'Wydana do doręczenia',
'dostarczana' => 'Dostarczana',
'gotowa_do_odbioru' => 'Gotowa do odbioru',
'oczekuje_na_odbior' => self::DESC_AWAITING_PICKUP,
'przesylka_oczekuje_na_odbior' => self::DESC_AWAITING_PICKUP,
'awaiting_pick_up' => self::DESC_AWAITING_PICKUP,
'dostarczona' => 'Dostarczona',
'doreczona' => self::DESC_DELIVERED,
'odebrana' => 'Odebrana',
'delivered' => 'Dostarczona',
'zwrocona' => 'Zwrócona',
'zwrocona_do_nadawcy' => self::DESC_RETURNED_TO_SENDER,
'returned_to_the_sender' => self::DESC_RETURNED_TO_SENDER,
'anulowana' => 'Anulowana',
'cancelled' => 'Anulowana',
'odmowa_przyjecia' => 'Odmowa przyjęcia',
'uszkodzona' => 'Uszkodzona',
'zagubiona' => 'Zagubiona',
];
private const POLKURIER_MAP = [
// Oficjalne kody ORDER_STATUS z dokumentacji polkurier API v1.11 (marzec 2026)
'O' => DeliveryStatus::CREATED,
'P' => DeliveryStatus::CONFIRMED,
'A' => DeliveryStatus::CANCELLED,
'WP' => DeliveryStatus::IN_TRANSIT,
'D' => DeliveryStatus::DELIVERED,
'Z' => DeliveryStatus::RETURNED,
'W' => DeliveryStatus::PROBLEM,
];
private const POLKURIER_DESCRIPTIONS = [
'O' => 'Oczekuje na płatność',
'P' => 'Potwierdzone, list wygenerowany',
'A' => 'Anulowane',
'WP' => 'W przewozie',
'D' => 'Dostarczona',
'Z' => 'Zwrot do nadawcy',
'W' => 'Wyjątek',
];
private const PROVIDER_MAPS = [
'inpost' => self::INPOST_MAP,
'apaczka' => self::APACZKA_MAP,
'allegro_wza' => self::ALLEGRO_MAP,
'allegro_edge' => self::ALLEGRO_EDGE_MAP,
'polkurier' => self::POLKURIER_MAP,
];
private const PROVIDER_DESCRIPTIONS = [
'inpost' => self::INPOST_DESCRIPTIONS,
'apaczka' => self::APACZKA_DESCRIPTIONS,
'allegro_wza' => self::ALLEGRO_DESCRIPTIONS,
'allegro_edge' => self::ALLEGRO_EDGE_DESCRIPTIONS,
'polkurier' => self::POLKURIER_DESCRIPTIONS,
];
/**
* @return array<string, array{normalized: string, description: string}>
*/
public static function getDefaultMappings(string $provider): array
{
$map = self::PROVIDER_MAPS[$provider] ?? [];
$descriptions = self::PROVIDER_DESCRIPTIONS[$provider] ?? [];
$result = [];
foreach ($map as $rawStatus => $normalized) {
$result[(string) $rawStatus] = [
'normalized' => $normalized,
'description' => (string) ($descriptions[$rawStatus] ?? (string) $rawStatus),
];
}
return $result;
}
/**
* @param array<string, array{normalized_status: string, description: string}> $overrides keyed by "provider:raw_status"
*/
public static function normalizeWithOverrides(string $provider, string $rawStatus, array $overrides): string
{
$key = $provider . ':' . $rawStatus;
if (isset($overrides[$key]) && $overrides[$key]['normalized_status'] !== '') {
return $overrides[$key]['normalized_status'];
}
return self::normalize($provider, $rawStatus);
}
/**
* @param array<string, array{normalized_status: string, description: string}> $overrides keyed by "provider:raw_status"
*/
public static function descriptionWithOverrides(string $provider, string $rawStatus, array $overrides): string
{
$key = $provider . ':' . $rawStatus;
if (isset($overrides[$key]) && $overrides[$key]['description'] !== '') {
return $overrides[$key]['description'];
}
return self::description($provider, $rawStatus);
}
public static function normalize(string $provider, string $rawStatus): string
{
return self::PROVIDER_MAPS[$provider][$rawStatus] ?? DeliveryStatus::UNKNOWN;
}
public static function description(string $provider, string $rawStatus): string
{
return self::PROVIDER_DESCRIPTIONS[$provider][$rawStatus] ?? $rawStatus;
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Modules\Shipments;
/**
* Budowanie publicznych URL-i sledzenia przesylki na podstawie dostawcy
* (provider), nazwy kuriera (carrierId) i numeru przesylki.
*/
final class DeliveryTrackingUrlBuilder
{
private const TRACKING_INPOST_URL = 'https://inpost.pl/sledzenie-przesylek?number=';
private const TRACKING_ALLEGRO_URL = 'https://allegro.pl/allegrodelivery/sledzenie-paczki?numer=';
private const PROVIDER_TRACKING_URLS = [
'inpost' => self::TRACKING_INPOST_URL,
'allegro_wza' => self::TRACKING_ALLEGRO_URL,
'polkurier' => 'https://polkurier.pl/sledz-paczke/',
];
private const CARRIER_TRACKING_URLS = [
[['dpd'], 'https://tracktrace.dpd.com.pl/parcelDetails?p1='],
[['dhl'], 'https://www.dhl.com/pl-pl/home/sledzenie-przesylki.html?tracking-id='],
[['inpost', 'paczkomat'], self::TRACKING_INPOST_URL],
[['orlen', 'ruch'], 'https://www.orlenpaczka.pl/sledz-paczke/?numer='],
[['poczta', 'pocztex'], 'https://emonitoring.poczta-polska.pl/?numer='],
[['ups'], 'https://www.ups.com/track?tracknum='],
[['fedex'], 'https://www.fedex.com/fedextrack/?trknbr='],
[['gls'], 'https://gls-group.com/PL/pl/sledzenie-paczek?match='],
[['allegro'], self::TRACKING_ALLEGRO_URL],
];
public static function trackingUrl(string $provider, string $trackingNumber, string $carrierId = ''): ?string
{
$number = trim($trackingNumber);
if ($number === '') {
return null;
}
$encoded = rawurlencode($number);
$providerUrl = self::providerTrackingUrl($provider, $encoded);
if ($provider === 'inpost') {
return $providerUrl;
}
return self::carrierTrackingUrl($encoded, $carrierId) ?? $providerUrl ?? self::fallbackTrackingUrl($encoded);
}
private static function carrierTrackingUrl(string $encoded, string $carrierId): ?string
{
$carrier = strtolower(trim($carrierId));
return $carrier === '' ? null : self::matchCarrierByName($encoded, $carrier);
}
private static function providerTrackingUrl(string $provider, string $encoded): ?string
{
$baseUrl = self::PROVIDER_TRACKING_URLS[$provider] ?? null;
return $baseUrl === null ? null : $baseUrl . $encoded;
}
private static function fallbackTrackingUrl(string $encoded): string
{
return 'https://www.google.com/search?q=' . $encoded . '+sledzenie+przesylki';
}
private static function matchCarrierByName(string $encoded, string $carrier): ?string
{
foreach (self::CARRIER_TRACKING_URLS as [$patterns, $baseUrl]) {
if (self::carrierMatches($carrier, $patterns)) {
return $baseUrl . $encoded;
}
}
return null;
}
/**
* @param array<int, string> $patterns
*/
private static function carrierMatches(string $carrier, array $patterns): bool
{
foreach ($patterns as $pattern) {
if ($carrier === $pattern || str_contains($carrier, $pattern)) {
return true;
}
}
return false;
}
}