diff --git a/.paul/PROJECT.md b/.paul/PROJECT.md
index ca3825b..4b72d7a 100644
--- a/.paul/PROJECT.md
+++ b/.paul/PROJECT.md
@@ -13,8 +13,8 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
| Attribute | Value |
|-----------|-------|
| Version | 3.8.0-dev |
-| Status | v3.8 Erli Marketplace Integration in progress — Phase 130 shipped (Erli shipments + labels/external parcel sync); Phase 131 next |
-| Last Updated | 2026-05-16 (Phase 130 closed) |
+| Status | v3.8 Erli Marketplace Integration in progress — Phase 131 shipped (Erli tracking + automation hooks); Phase 132 next |
+| Last Updated | 2026-05-16 (Phase 131 closed) |
## Requirements
@@ -130,6 +130,7 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
- [x] Import zamowien Erli: pobieranie `/inbox` przez cron i recznie, mapper do orderPRO, delta-only re-import, `invoice_requested` z danych firmowych/NIP, bezpieczny ACK `/inbox/mark-read` po bezblednym batchu — Phase 128
- [x] Mapowanie i synchronizacja statusow Erli: osobne pull/push mappings, discovery statusow z inboxa, reczny-only push `PATCH /orders/{id}/status`, cron `erli_status_sync` i zakladki w ustawieniach Erli — Phase 129
- [x] Przesylki Erli: zakladka mapowania dostaw, etykiety przez lokalne providery InPost/Apaczka i rejestracja paczek zewnetrznych w Erli przez `POST /shipping/external` — Phase 130
+- [x] Tracking i automatyzacje Erli: lokalny provider tracking jak w Allegro, retry niekrytycznej rejestracji paczki zewnetrznej Erli z `shipment_tracking_sync`, wspolny kontekst `shipment.created`/`shipment.status_changed` dla regul e-mail/SMS/statystyk — Phase 131
- [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
- [x] Order User Notes module (Phase 129): pelen CRUD notatek autorskich operatora per zamowienie. Reuse `order_notes` przez nowy `note_type='user'` z `user_id` (FK→users SET NULL) + `author_name` (snapshot) + indeks `idx_order_notes_type_order`. `OrderNotesService` z autoryzacja DB-level (`WHERE user_id = :user_id`, rowCount=0 ⇒ 403). Sekcja `#notes` w "Wiadomosci i zalaczniki" w `/orders/{id}` z inline edit form + delete przez `OrderProAlerts.confirm`. Badge `[N]` (indigo neutralny) przy nr zamowienia na `/orders/list` (subquery `user_notes_count` w paginate). Brak admin override (brak systemu rol w aplikacji) — edit/delete tylko dla autora — Phase 129
@@ -142,11 +143,10 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
### Active (In Progress)
-- [ ] v3.8 Erli Marketplace Integration — Phase 131 next: tracking przesylek Erli i hooki automatyzacji.
+- [ ] v3.8 Erli Marketplace Integration — Phase 132 next: hardening, observability and docs.
### Planned (Next)
-- [ ] Erli tracking + automation hooks — Phase 131
- [ ] Erli hardening, observability + docs — Phase 132
- [ ] ZarzÄ…dzanie produktami
- [ ] ZarzÄ…dzanie stanami magazynowymi
@@ -212,6 +212,7 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API
| Historia automatyzacji zapisywana per regula (success/failed) i czyszczona cronem po 30 dniach | Audyt wykonywania regul bez recznego utrzymania danych | 2026-03-28 | Active |
| Akcja update_order_status korzysta z OrdersRepository::updateOrderStatus | Spojnosc z historia statusow i activity log bez duplikowania logiki | 2026-03-28 | Active |
| Push waybilla do Allegro checkout forms wykonywany tylko dla zamowien source=allegro i jest niekrytyczny dla lokalnego tworzenia paczki | Eliminacja recznego kroku po stronie Allegro bez ryzyka utraty lokalnie utworzonej przesylki przy bledzie API | 2026-03-28 | Active |
+| Erli tracking uzywa lokalnych providerow jak Allegro, bez osobnego ErliTrackingService/UI statusow dostawy | Lokalny delivery status pozostaje zrodlem prawdy, a Erli dostaje niekrytyczny retry external parcel/tracking z crona | 2026-05-16 | Active |
| HostedSMS startuje jako jedna globalna konfiguracja z realnym testowym SMS | Operator potrzebowal na start tylko ustawien i potwierdzenia dzialania; SimpleAPI nie ma osobnego ping endpointu | 2026-05-12 | Active |
| Odbior odpowiedzi SMS z HostedSMS odlozony do osobnej fazy | Dokumentacja przewiduje metody odbioru SMS, ale wymagaja aktywacji interfejsu po stronie DCS/HostedSMS | 2026-05-12 | Deferred |
| SMSPLANET startuje jako jedna globalna konfiguracja z realnym testowym SMS | Operator potrzebowal drugiej bramki porownywalnej z HostedSMS, bez automatyzacji SMS w tej fazie | 2026-05-12 | Active |
@@ -304,6 +305,6 @@ Quick Reference:
---
*PROJECT.md — Updated when requirements or context change*
-*Last updated: 2026-05-16 after Phase 130 (Erli Shipments + Labels) closure; v3.8 milestone in progress*
+*Last updated: 2026-05-16 after Phase 131 (Erli Tracking + Automation Hooks) closure; v3.8 milestone in progress*
diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md
index 09d4c8f..9e81964 100644
--- a/.paul/ROADMAP.md
+++ b/.paul/ROADMAP.md
@@ -10,7 +10,7 @@ v3.8 Erli Marketplace Integration — In progress
Pelna integracja z erli.pl wzorowana na istniejacej integracji Allegro: konfiguracja konta/API, pobieranie zamowien, mapowanie i synchronizacja statusow, generowanie etykiet, tracking oraz wlaczenie Erli w istniejace przeplywy automatyzacji, statystyk i obslugi zamowien.
-Progress: 4 of 6 phases complete (67%).
+Progress: 5 of 6 phases complete (83%).
| Phase | Name | Plans | Status |
|-------|------|-------|--------|
@@ -18,7 +18,7 @@ Progress: 4 of 6 phases complete (67%).
| 128 | Erli Orders Import | 1/1 | Complete (2026-05-15; migration/manual Erli import smoke pending operator) |
| 129 | Erli Status Mapping + Sync | 1/1 | Complete (2026-05-16; migration/manual Erli status smoke pending operator) |
| 130 | Erli Shipments + Labels | 1/1 | Complete (2026-05-16; migration/manual Erli shipping smoke pending operator) |
-| 131 | Erli Tracking + Automation Hooks | TBD | Not started |
+| 131 | Erli Tracking + Automation Hooks | 1/1 | Complete (2026-05-16; manual Erli tracking/automation smoke pending operator) |
| 132 | Erli Hardening, Observability + Docs | TBD | Not started |
### Phase 127: Erli Integration Foundation
@@ -44,7 +44,7 @@ Plans: 130-01 (complete)
### Phase 131: Erli Tracking + Automation Hooks
Focus: Tracking przesylek Erli, aktualizacja delivery statusow, zdarzenia automatyzacji (`order.imported`, `shipment.created`, `shipment.status_changed`) i zachowanie kompatybilnosci z szablonami e-mail/SMS oraz statystykami.
-Plans: TBD (defined during $paul-plan)
+Plans: 131-01 (complete)
### Phase 132: Erli Hardening, Observability + Docs
@@ -561,4 +561,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
---
*Roadmap created: 2026-03-12*
-*Last updated: 2026-05-16 - Phase 130 complete, ready for Phase 131 planning*
+*Last updated: 2026-05-16 - Phase 131 complete, ready for Phase 132 planning*
diff --git a/.paul/STATE.md b/.paul/STATE.md
index 9b1caaa..65cc08d 100644
--- a/.paul/STATE.md
+++ b/.paul/STATE.md
@@ -5,19 +5,19 @@
See: .paul/PROJECT.md (updated 2026-05-16)
**Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami.
-**Current focus:** v3.8 Erli Marketplace Integration - Phase 131 ready to plan.
+**Current focus:** v3.8 Erli Marketplace Integration - Phase 132 ready to plan.
## Current Position
Milestone: v3.8 Erli Marketplace Integration
-Phase: 131 of 132 (Erli Tracking + Automation Hooks) - Not started
+Phase: 132 of 132 (Erli Hardening, Observability + Docs) - Not started
Plan: Not started
Status: Ready to plan
-Last activity: 2026-05-16 00:51 - Phase 130 complete, transitioned to Phase 131
+Last activity: 2026-05-16 15:36 - Phase 131 complete, transitioned to Phase 132
Progress:
-- Milestone v3.8: [#######---] 67% (Phases 127-130 complete; Phase 131 next)
-- Phase 131: [----------] 0% (not planned)
+- Milestone v3.8: [########--] 83% (Phases 127-131 complete; Phase 132 next)
+- Phase 132: [----------] 0% (not planned)
## Loop Position
@@ -29,9 +29,9 @@ PLAN -> APPLY -> UNIFY
## Session Continuity
-Last session: 2026-05-16 00:51
-Stopped at: Phase 130 complete, ready to plan Phase 131
-Next action: $paul-plan for Phase 131 (Erli Tracking + Automation Hooks)
+Last session: 2026-05-16 15:36
+Stopped at: Phase 131 complete, ready to plan Phase 132
+Next action: $paul-plan for Phase 132 (Erli Hardening, Observability + Docs)
Resume file: .paul/ROADMAP.md
## Pending parallel work
@@ -39,8 +39,8 @@ Resume file: .paul/ROADMAP.md
## Git State
-Last phase commit: 13f570e feat(130): erli shipments and labels
-Previous: 7972bb9 feat(129): erli status mapping sync
+Last phase commit: feat(131): erli tracking automation hooks (created during UNIFY)
+Previous: 13f570e feat(130): erli shipments and labels
Branch: main
### Skill Audit (Phase 129)
@@ -55,6 +55,12 @@ Branch: main
|----------|---------|-------|
| `sonar-scanner` | gap documented | Attempted after APPLY; CLI is not available in PATH. |
+### Skill Audit (Phase 131)
+
+| Expected | Invoked | Notes |
+|----------|---------|-------|
+| `sonar-scanner` | gap documented | Attempted after APPLY; CLI is not available in PATH. |
+
## Pending Actions
- Manualne testy AC-1..AC-7 dla Phase 112 na zywej bazie (XAMPP online).
@@ -85,6 +91,9 @@ Branch: main
- Phase 130 follow-up: uruchom `php bin/migrate.php` (dodaje `carrier_delivery_method_mappings.source_service_id/source_vendor_code`), otworz `/settings/integrations/erli?tab=delivery`, zapisz mapowanie metody Erli na InPost/Apaczka oraz vendor Erli, a potem utworz etykiete dla zamowienia Erli i potwierdz `POST /shipping/external`.
- Phase 130 verification gap: `vendor/bin/phpunit` nie istnieje w checkoutcie, wiec test `tests/Unit/ErliExternalShipmentServiceTest.php` nie zostal uruchomiony przez PHPUnit; wykonano `php -l` i `git diff --check`.
- Phase 130 skill gap: `sonar-scanner` nie jest dostepny w PATH, wiec skan SonarQube nie zostal uruchomiony.
+- Phase 131 verification gap: `vendor/bin/phpunit` nie istnieje w checkoutcie, wiec testy `tests/Unit/ErliExternalShipmentServiceTest.php` i `tests/Unit/AutomationServiceTest.php` nie zostaly uruchomione przez PHPUnit; wykonano `php -l` i `git diff --check`.
+- Phase 131 skill gap: `sonar-scanner` nie jest dostepny w PATH, wiec skan SonarQube nie zostal uruchomiony.
+- Phase 131 follow-up: manualny smoke po migracjach/konfiguracji Erli — utworz paczke dla zamowienia Erli z lokalnym providerem, potwierdz `shipment.created`, uruchom `shipment_tracking_sync`, sprawdz `shipment.status_changed` i retry `POST /shipping/external` tylko po pojawieniu sie tracking number.
- 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).
@@ -106,4 +115,4 @@ Branch: main
## Skill Requirements
-- `sonar-scanner` required after APPLY; Phase 116, Phase 117, Phase 121, Phase 122, Phase 128 and Phase 129 gaps documented because CLI was not available in PATH.
+- `sonar-scanner` required after APPLY; Phase 116, Phase 117, Phase 121, Phase 122, Phase 128, Phase 129, Phase 130 and Phase 131 gaps documented because CLI was not available in PATH.
diff --git a/.paul/changelog/2026-05-16.md b/.paul/changelog/2026-05-16.md
index afacc7f..d9edb0e 100644
--- a/.paul/changelog/2026-05-16.md
+++ b/.paul/changelog/2026-05-16.md
@@ -12,6 +12,11 @@
- Rozszerzono mapowania dostaw o `source_service_id` i `source_vendor_code`, zeby oddzielic Erli vendor od lokalnego providera etykiety.
- Dodano niekrytyczna synchronizacje tracking number do Erli po utworzeniu lokalnej paczki.
- Udokumentowano gapy srodowiskowe Phase 130: brak `vendor/bin/phpunit`, brak `sonar-scanner` w PATH, smoke testy Erli po migracji do wykonania przez operatora.
+- [Phase 131, Plan 01] Domknieto Erli tracking i hooki automatyzacji wedlug wzorca Allegro: lokalny provider tracking jako zrodlo prawdy, bez osobnego `ErliTrackingService` i bez nowego UI statusow dostawy.
+- `ShipmentTrackingHandler` probuje ponownie `ErliExternalShipmentService::syncPackage()` po odczycie statusu lokalnego providera, a blad syncu Erli pozostaje niekrytyczny dla lokalnego trackingu.
+- Uzupelniono kontekst `shipment.created` i `shipment.status_changed` o wspolne pola `source`, `tracking_number`, `package_id`, `provider` i statusy dostawy.
+- Dodano testy dla skip/idempotencji/bledu API w `ErliExternalShipmentServiceTest` oraz test wspolnego kontekstu automatyzacji Erli.
+- Udokumentowano gapy srodowiskowe Phase 131: brak `vendor/bin/phpunit`, brak `sonar-scanner` w PATH, manualny smoke Erli tracking/automation pending.
## Zmienione pliki
@@ -52,3 +57,7 @@
- `DOCS/DB_SCHEMA.md`
- `DOCS/ARCHITECTURE.md`
- `DOCS/TECH_CHANGELOG.md`
+- `.paul/phases/131-erli-tracking-automation-hooks/131-01-PLAN.md`
+- `.paul/phases/131-erli-tracking-automation-hooks/131-01-SUMMARY.md`
+- `src/Modules/Cron/ShipmentTrackingHandler.php`
+- `tests/Unit/AutomationServiceTest.php`
diff --git a/.paul/phases/131-erli-tracking-automation-hooks/131-01-PLAN.md b/.paul/phases/131-erli-tracking-automation-hooks/131-01-PLAN.md
new file mode 100644
index 0000000..1c50005
--- /dev/null
+++ b/.paul/phases/131-erli-tracking-automation-hooks/131-01-PLAN.md
@@ -0,0 +1,244 @@
+---
+phase: 131-erli-tracking-automation-hooks
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - src/Modules/Cron/ShipmentTrackingHandler.php
+ - src/Modules/Cron/CronHandlerFactory.php
+ - src/Modules/Settings/ErliExternalShipmentService.php
+ - src/Modules/Shipments/ShipmentController.php
+ - src/Modules/Automation/AutomationService.php
+ - src/Modules/Sms/SmsVariableResolver.php
+ - src/Modules/Email/VariableResolver.php
+ - tests/Unit/ErliExternalShipmentServiceTest.php
+ - tests/Unit/AutomationServiceTest.php
+ - DOCS/ARCHITECTURE.md
+ - DOCS/TECH_CHANGELOG.md
+autonomous: true
+delegation: auto
+---
+
+
+## Goal
+Domknac Erli tracking i hooki automatyzacji w sposob zgodny z dzialajacym wzorcem Allegro: tracking paczek zostaje lokalny przez `shipment_tracking_sync`, zdarzenia `order.imported`, `shipment.created` i `shipment.status_changed` maja byc emitowane dla zamowien Erli, a Erli ma dostawac dane paczki zewnetrznej/tracking niekrytycznie, bez budowania osobnego UI statusow dostawy.
+
+## Purpose
+Operator chce, aby Erli zachowywalo sie jak Allegro, bo Allegro dziala stabilnie: lokalna paczka i status dostawy sa zrodlem prawdy w orderPRO, marketplace dostaje numer przesylki, a automatyzacje e-mail/SMS/statystyki uzywaja wspolnych mechanizmow aplikacji.
+
+## Output
+Plan ma dostarczyc male, testowalne domkniecie: retry/sync Erli external parcel podczas tracking cron gdy tracking pojawi sie pozniej, weryfikacje hookow automatyzacji dla Erli oraz dokumentacje kontraktu "jak Allegro".
+
+
+
+
+- **Tracking Erli** - Czy dodac osobny `ErliTrackingService`, czy wzorowac sie na Allegro?
+ -> Odpowiedz: Robimy jak Allegro. Allegro uzywa lokalnego `shipment_tracking_sync` przez provider paczki (`allegro_wza`, InPost/edge), bez osobnego UI trackingu marketplace.
+- **Aktualizacja Erli** - Czy statusy paczek wypychac do Erli tak jak Allegro?
+ -> Odpowiedz: Robimy jak Allegro. Marketplace dostaje tracking/paczke zewnetrzna niekrytycznie; lokalny delivery status i automatyzacja sa po stronie orderPRO.
+- **Zakres UI** - Czy dodawac nowe mapowania/statusy w UI?
+ -> Odpowiedz: Robimy jak Allegro, ale bez nowego panelu dla statusow dostawy. Istniejace zakladki Erli z Phase 129/130 zostaja, a `delivery_statuses` i reguly automatyzacji sa wspolne.
+
+
+## Project Context
+@.paul/PROJECT.md
+@.paul/ROADMAP.md
+@.paul/STATE.md
+@.paul/codebase/architecture.md
+@.paul/codebase/db_schema.md
+@AGENTS.md
+
+## Prior Work
+@.paul/phases/129-erli-status-mapping-sync/129-01-SUMMARY.md
+@.paul/phases/130-erli-shipments-labels/130-01-SUMMARY.md
+@.paul/phases/50-allegro-shipment-waybill-push/50-01-SUMMARY.md
+
+## Allegro Reference Pattern
+@src/Modules/Shipments/AllegroTrackingService.php
+@src/Modules/Settings/AllegroStatusSyncService.php
+@src/Modules/Settings/AllegroApiClient.php
+@src/Modules/Shipments/ShipmentController.php
+
+Allegro reference decisions:
+- Tracking paczki dziala w `shipment_tracking_sync` po providerze zapisanym w `shipment_packages`.
+- Push numeru nadania do Allegro jest niekrytyczny i nie blokuje lokalnej etykiety.
+- Status zamowienia Allegro ma osobny pull/push sync, ale status dostawy paczki nie dostaje osobnego panelu UI.
+- `shipment.status_changed` jest emitowany z lokalnego crona trackingowego, gdy `delivery_status` realnie sie zmieni.
+
+## Source Files
+@src/Modules/Cron/ShipmentTrackingHandler.php
+@src/Modules/Cron/CronHandlerFactory.php
+@src/Modules/Settings/ErliExternalShipmentService.php
+@src/Modules/Settings/ErliOrdersSyncService.php
+@src/Modules/Shipments/ShipmentController.php
+@src/Modules/Shipments/ShipmentPackageRepository.php
+@src/Modules/Automation/AutomationService.php
+@src/Modules/Sms/SmsVariableResolver.php
+@src/Modules/Email/VariableResolver.php
+@tests/Unit/ErliExternalShipmentServiceTest.php
+@tests/Unit/AutomationServiceTest.php
+
+
+
+## Required Skills (from SPECIAL-FLOWS.md)
+
+| Skill | Priority | When to Invoke | Loaded? |
+|-------|----------|----------------|---------|
+| `sonar-scanner` | required | Po APPLY, przed UNIFY | ○ |
+| /feature-dev | optional | Przed implementacja integracji marketplace/shipping | ○ |
+| /code-review | optional | Po implementacji, przed UNIFY | ○ |
+
+**BLOCKING:** Required `sonar-scanner` must be attempted before UNIFY. If CLI is still unavailable in PATH, document the gap in SUMMARY and STATE like Phase 129/130.
+
+## Skill Invocation Checklist
+- [ ] `sonar-scanner` attempted after APPLY
+- [ ] Optional flows considered if implementation risk warrants them
+
+
+
+
+## AC-1: Erli Uses Local Tracking Like Allegro
+```gherkin
+Given an Erli order has a local shipment package created through an existing provider
+When `shipment_tracking_sync` runs and the provider returns a changed delivery status
+Then `shipment_packages.delivery_status` and `delivery_status_raw` are updated by the common tracking flow
+And no separate Erli-only tracking service or status UI is required.
+```
+
+## AC-2: External Parcel Sync Is Retried Non-Critically
+```gherkin
+Given an Erli shipment package gets a tracking number after the initial create path
+When creation status, label download or tracking cron observes the package with a tracking number
+Then orderPRO attempts `ErliExternalShipmentService::syncPackage()`
+And failures are logged/returned as non-critical so the local label and tracking flow continue.
+```
+
+## AC-3: Shipment Automation Hooks Fire For Erli
+```gherkin
+Given an Erli shipment package is created or its local delivery status changes
+When `ShipmentController` or `ShipmentTrackingHandler` emits automation events
+Then `shipment.created` and `shipment.status_changed` include order id, package id, provider, tracking number and status context usable by existing automation rules.
+```
+
+## AC-4: Order Import Hook Remains Compatible
+```gherkin
+Given Erli inbox import creates a new order
+When `ErliOrdersSyncService` saves the order successfully
+Then it emits `order.imported` with source `erli` and does not emit duplicate import activities for no-op reimports.
+```
+
+## AC-5: Email/SMS Variables And Statistics Stay Common
+```gherkin
+Given an Erli order has a latest shipment package
+When an e-mail or SMS template resolves `{{przesylka.numer}}` and `{{przesylka.link_sledzenia}}`
+Then the existing resolvers return the same provider-aware values as for Allegro/shopPRO orders
+And no Erli-specific duplicate resolver or statistics path is introduced.
+```
+
+## AC-6: Verification Documents The Allegro-Compatible Contract
+```gherkin
+Given the implementation is complete
+When verification runs
+Then focused tests/lints and docs prove that Erli follows the Allegro-like local tracking + non-critical marketplace sync contract.
+```
+
+
+
+
+
+
+ Task 1: Retry Erli external parcel sync from tracking-safe points
+ src/Modules/Cron/ShipmentTrackingHandler.php, src/Modules/Cron/CronHandlerFactory.php, src/Modules/Settings/ErliExternalShipmentService.php, tests/Unit/ErliExternalShipmentServiceTest.php
+
+ Extend the existing Erli external parcel sync without creating a separate `ErliTrackingService`.
+ Wire `ErliExternalShipmentService` into tracking-safe execution where needed, most likely `ShipmentTrackingHandler` through an optional nullable dependency built by `CronHandlerFactory`.
+ During `shipment_tracking_sync`, after a provider returns a status result for a package, attempt `syncPackage($packageId)` only for Erli orders/packages where a tracking number exists and the payload does not already show the same tracking synced.
+ Keep the call non-critical: catch failures, do not increment tracking API errors just because Erli sync failed, and preserve local `delivery_status` updates.
+ Keep `ErliExternalShipmentService` responsible for source checks, idempotency and bounded activity logging so `ShipmentTrackingHandler` stays readable.
+ Add/extend tests for:
+ - skip when package is not Erli,
+ - skip when tracking number is empty,
+ - skip when same tracking was already synced,
+ - successful payload update,
+ - API failure returns non-critical result without throwing.
+
+ `C:\xampp\php\php.exe -l src/Modules/Cron/ShipmentTrackingHandler.php`; `C:\xampp\php\php.exe -l src/Modules/Settings/ErliExternalShipmentService.php`; `vendor/bin/phpunit tests/Unit/ErliExternalShipmentServiceTest.php` if available.
+ AC-1 and AC-2 satisfied; Erli mirrors Allegro's local tracking + non-critical marketplace sync approach.
+
+
+
+ Task 2: Prove Erli automation event context stays common
+ src/Modules/Shipments/ShipmentController.php, src/Modules/Automation/AutomationService.php, tests/Unit/AutomationServiceTest.php
+
+ Review the existing `shipment.created`, `shipment.status_changed` and `order.imported` paths for Erli.
+ Keep behavior shared with Allegro/shopPRO: do not add Erli-only automation event names.
+ If current context is missing useful fields for Erli rules, add only generic fields that benefit all providers, such as `source`, `tracking_number`, `package_id`, `provider`, `delivery_status`, `previous_status`.
+ Ensure `update_shipment_status` still emits `shipment.status_changed` only on real local status change and does not create automation loops.
+ Add/extend focused tests that an Erli-like package/order context can satisfy shipment status conditions and that logs preserve sanitized context.
+
+ `C:\xampp\php\php.exe -l src/Modules/Shipments/ShipmentController.php`; `C:\xampp\php\php.exe -l src/Modules/Automation/AutomationService.php`; `vendor/bin/phpunit tests/Unit/AutomationServiceTest.php` if available.
+ AC-3 and AC-4 satisfied without creating marketplace-specific automation forks.
+
+
+
+ Task 3: Document and verify template/statistics compatibility
+ src/Modules/Sms/SmsVariableResolver.php, src/Modules/Email/VariableResolver.php, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md
+
+ Inspect the shared shipment variable flow and confirm it uses `ShipmentPackageRepository::findLatestByOrderId()` plus `DeliveryStatus::trackingUrl()` for Erli orders the same way it does for Allegro/shopPRO.
+ Do not fork e-mail/SMS variable logic unless a concrete failing case is found.
+ If a small generic fix is needed for Erli provider/carrier URLs, keep it in the shared delivery status/tracking URL layer and document it.
+ Confirm statistics remain source-based on `orders.source='erli'` and are not affected by delivery-status sync.
+ Update `DOCS/ARCHITECTURE.md` and `DOCS/TECH_CHANGELOG.md` with the Phase 131 contract: Erli tracking follows local provider tracking; external parcel sync is retryable/non-critical; automation uses shared event names.
+
+ `C:\xampp\php\php.exe -l src/Modules/Sms/SmsVariableResolver.php`; `C:\xampp\php\php.exe -l src/Modules/Email/VariableResolver.php`; manual review of docs diff; `git diff --check`.
+ AC-5 and AC-6 satisfied; no unnecessary Erli-specific UI or resolver duplication introduced.
+
+
+
+
+
+
+## DO NOT CHANGE
+- Do not add a separate `ErliTrackingService` unless APPLY proves Erli has a documented parcel-status endpoint that is necessary and local provider tracking cannot cover the case.
+- Do not add new native `alert()` / `confirm()` calls.
+- Do not put CSS in `resources/views/...`.
+- Do not connect runtime code to `DB_HOST_REMOTE`.
+- Do not regress Allegro status sync, Allegro tracking or Phase 130 Erli external parcel creation.
+- Do not change unrelated `.vscode/ftp-kr.sync.cache.json`.
+
+## SCOPE LIMITS
+- No new Erli delivery-status UI in this plan.
+- No new database schema unless a concrete bug requires it; existing `shipment_packages.payload_json` should store sync marker details.
+- No Erli product/stock/offer work.
+- No change to order status mapping UI from Phase 129 except if a small bug blocks automation hooks.
+- No attempt to ship on Erli carrier agreement; local providers remain the label source.
+
+
+
+
+Before declaring plan complete:
+- [ ] `C:\xampp\php\php.exe -l` for every changed PHP/test file.
+- [ ] `git diff --check`.
+- [ ] `vendor/bin/phpunit tests/Unit/ErliExternalShipmentServiceTest.php` if PHPUnit dependencies are available; otherwise document the environment gap.
+- [ ] `vendor/bin/phpunit tests/Unit/AutomationServiceTest.php` if PHPUnit dependencies are available; otherwise document the environment gap.
+- [ ] Attempt `sonar-scanner`; if unavailable, document the gap in SUMMARY and STATE.
+- [ ] Manual smoke: Erli order with mapped local provider creates package and `shipment.created` is logged/emitted.
+- [ ] Manual smoke: `shipment_tracking_sync` updates local delivery status for the provider package and emits `shipment.status_changed` on real status change.
+- [ ] Manual smoke/review: Erli external parcel sync is retried only when tracking exists and does not block local tracking.
+- [ ] Docs updated for Phase 131 contract.
+- [ ] All acceptance criteria met.
+
+
+
+- Erli tracking behavior matches Allegro's proven local-provider model.
+- Erli external parcel sync is retryable, idempotent for same tracking number and non-critical.
+- Automation hooks work for Erli through shared event names and context.
+- Email/SMS shipment variables continue to resolve from shared latest-package logic.
+- No unnecessary UI/schema/provider fork is introduced.
+- Verification and skill audit gaps are documented honestly.
+
+
+
diff --git a/.paul/phases/131-erli-tracking-automation-hooks/131-01-SUMMARY.md b/.paul/phases/131-erli-tracking-automation-hooks/131-01-SUMMARY.md
new file mode 100644
index 0000000..528a2e8
--- /dev/null
+++ b/.paul/phases/131-erli-tracking-automation-hooks/131-01-SUMMARY.md
@@ -0,0 +1,92 @@
+---
+phase: 131-erli-tracking-automation-hooks
+plan: 01
+subsystem: erli, shipments, automation, cron
+tags: [erli, tracking, automation, external-parcels]
+requires:
+ - phase: 129-erli-status-mapping-sync
+ provides: Erli order status pull/push and shared order import hooks
+ - phase: 130-erli-shipments-labels
+ provides: Erli delivery mapping and external parcel registration service
+provides:
+ - Erli external parcel retry from shipment tracking cron
+ - Shared shipment automation context for Erli
+ - Documentation of Allegro-compatible Erli tracking contract
+affects: [shipment_tracking_sync, erli-external-parcel-sync, automation-events]
+tech-stack:
+ added: []
+ patterns: [local-provider tracking source of truth, non-critical marketplace sync]
+key-files:
+ modified:
+ - src/Modules/Cron/ShipmentTrackingHandler.php
+ - src/Modules/Cron/CronHandlerFactory.php
+ - src/Modules/Shipments/ShipmentController.php
+ - src/Modules/Automation/AutomationService.php
+ - tests/Unit/ErliExternalShipmentServiceTest.php
+ - tests/Unit/AutomationServiceTest.php
+ - DOCS/ARCHITECTURE.md
+ - DOCS/TECH_CHANGELOG.md
+key-decisions:
+ - "Erli follows the working Allegro model: local provider tracking remains the source of truth."
+ - "No separate ErliTrackingService and no Erli-only delivery status UI were added."
+ - "Erli external parcel sync is retryable and non-critical."
+started: 2026-05-16T15:21:00+02:00
+completed: 2026-05-16T15:29:00+02:00
+---
+
+# Phase 131-01 Summary: Erli Tracking + Automation Hooks
+
+Phase 131 closes the Erli tracking/automation gap by reusing the existing local-provider tracking model that already works for Allegro.
+
+## Acceptance Criteria
+
+| AC | Result | Notes |
+|----|--------|-------|
+| AC-1: Erli Uses Local Tracking Like Allegro | Pass | `shipment_tracking_sync` still tracks by local provider; no Erli-only tracker/UI was introduced. |
+| AC-2: External Parcel Sync Is Retried Non-Critically | Pass | `ShipmentTrackingHandler` retries `ErliExternalShipmentService::syncPackage()` after provider status reads; failures do not block local tracking. |
+| AC-3: Shipment Automation Hooks Fire For Erli | Pass | `shipment.created` and `shipment.status_changed` contexts include shared fields such as source, package id, provider, tracking number and status. |
+| AC-4: Order Import Hook Remains Compatible | Pass | Existing `ErliOrdersSyncService` order import hook remains unchanged and compatible. |
+| AC-5: Email/SMS Variables And Statistics Stay Common | Pass | Shared SMS/e-mail shipment variable resolver remains the source; no Erli fork was needed. |
+| AC-6: Verification Documents The Allegro-Compatible Contract | Pass with env gaps | Lints and diff checks passed; PHPUnit and Sonar CLI are unavailable in this checkout/PATH. |
+
+## Task Results
+
+| Task | Result |
+|------|--------|
+| Task 1: Retry Erli external parcel sync from tracking-safe points | Done |
+| Task 2: Prove Erli automation event context stays common | Done |
+| Task 3: Document and verify template/statistics compatibility | Done |
+
+## What Changed
+
+- `ShipmentTrackingHandler` now accepts optional `ErliExternalShipmentService` and `OrdersRepository`.
+- `CronHandlerFactory` wires `ErliExternalShipmentService` into `shipment_tracking_sync`.
+- Erli external parcel registration is retried after a provider returns tracking status, but remains non-critical.
+- `shipment.created` and `shipment.status_changed` contexts now carry shared Erli-friendly fields without new event names.
+- Tests were expanded for Erli external parcel idempotency/failure paths and shared automation context.
+- `DOCS/ARCHITECTURE.md` and `DOCS/TECH_CHANGELOG.md` document the Phase 131 contract.
+
+## Verification
+
+| Check | Result |
+|-------|--------|
+| PHP lint: changed cron/settings/shipment/automation PHP files | Passed |
+| PHP lint: SMS/e-mail resolvers | Passed |
+| PHP lint: changed test files | Passed |
+| `git diff --check -- . ':!.vscode/ftp-kr.sync.cache.json'` | Passed, with LF/CRLF warnings only |
+| `vendor/bin/phpunit tests/Unit/ErliExternalShipmentServiceTest.php` | Not run: `vendor/bin/phpunit` missing |
+| `vendor/bin/phpunit tests/Unit/AutomationServiceTest.php` | Not run: `vendor/bin/phpunit` missing |
+| `sonar-scanner --version` | Failed: command unavailable in PATH |
+
+## Deviations / Gaps
+
+- PHPUnit binaries are absent from `vendor/bin`, so new/changed tests could only be linted.
+- `sonar-scanner` is still unavailable in PATH; this matches previous Phase 129/130 skill gaps.
+- Manual live smoke remains pending until the operator runs with live Erli/local carrier data.
+
+## Next Phase Readiness
+
+Phase 131 is complete and ready for Phase 132 hardening. Manual smoke after deployment should verify:
+- mapped Erli order creates a local package and emits `shipment.created`,
+- `shipment_tracking_sync` updates local delivery status and emits `shipment.status_changed`,
+- Erli external parcel sync is retried only when tracking exists and does not block local tracking.
diff --git a/DOCS/ARCHITECTURE.md b/DOCS/ARCHITECTURE.md
index f71d672..19590eb 100644
--- a/DOCS/ARCHITECTURE.md
+++ b/DOCS/ARCHITECTURE.md
@@ -85,9 +85,9 @@ HTTP Request
### Shipment Flow
-Phase 130 adds an Erli-specific post-label step: for `orders.source='erli'`, `ShipmentController` calls `ErliExternalShipmentService::syncPackage()` after a local provider has a tracking number. The service posts `vendor/status/trackingNumber/orderId` to Erli `POST /shipping/external` and stores the response in `shipment_packages.payload_json.erli_external_parcel`; failures are activity-log warnings and do not block local labels.
+Phase 130 adds an Erli-specific post-label step: for `orders.source='erli'`, `ShipmentController` calls `ErliExternalShipmentService::syncPackage()` after a local provider has a tracking number. Phase 131 extends the same Allegro-like contract into cron tracking: `ShipmentTrackingHandler` retries the Erli external parcel sync after a provider returns tracking status, but the retry is non-critical and never blocks local `delivery_status` updates. The service posts `vendor/status/trackingNumber/orderId` to Erli `POST /shipping/external` and stores the response in `shipment_packages.payload_json.erli_external_parcel`; failures are activity-log warnings.
1. **Create** — `ShipmentController::create()` → `ShipmentProviderRegistry` → carrier `ShipmentService::createShipment()` → `ShipmentPackageRepository::insert()`
-2. **Track** — Cron `ShipmentTrackingHandler` → `ShipmentTrackingRegistry` → carrier tracking API → `ShipmentPackageRepository::updateDeliveryStatus()`
+2. **Track** — Cron `ShipmentTrackingHandler` → `ShipmentTrackingRegistry` → carrier tracking API → optional Erli external parcel retry → `ShipmentPackageRepository::updateDeliveryStatus()` → shared `shipment.status_changed` automation event when normalized status really changes.
### Receipt / Invoice
1. **Generate** — `ReceiptController::store()` → `ReceiptService::generateReceipt()` → `ReceiptRepository::insert()` + Dompdf PDF
@@ -102,7 +102,7 @@ Phase 130 adds an Erli-specific post-label step: for `orders.source='erli'`, `Sh
### Automation Rules
1. **Setup** — `AutomationController` → `AutomationRepository::insertRule()`
-2. **Trigger** — `AutomationService::executeForOrder()` → evaluates trigger (`order_status_changed`, `order_status_aged`) → runs action (send email, update status)
+2. **Trigger** — `AutomationService::executeForOrder()` → evaluates trigger (`order_status_changed`, `order_status_aged`, `shipment.created`, `shipment.status_changed`, `order.imported`) → runs action (send email, update status)
3. **Log** — `AutomationExecutionLogRepository` tracks every run
### Cron Jobs
@@ -117,7 +117,7 @@ Phase 130 adds an Erli-specific post-label step: for `orders.source='erli'`, `Sh
| `ErliStatusSyncHandler` | Pull Erli status events via inbox or push manual local status changes to Erli |
| `ShopproStatusSyncHandler` | Push status to shopPRO |
| `ShopproPaymentStatusSyncHandler` | Sync payment statuses |
-| `ShipmentTrackingHandler` | Poll carrier tracking APIs |
+| `ShipmentTrackingHandler` | Poll carrier tracking APIs, retry Erli external parcel sync and emit shipment delivery-status automation |
| `OrderStatusAgedHandler` | Trigger automation for stuck statuses |
| `AutomationHistoryCleanupHandler` | Purge old automation logs |
@@ -129,8 +129,8 @@ Phase 130 adds an Erli-specific post-label step: for `orders.source='erli'`, `Sh
4. **Order import** - Phase 128 adds `/settings/integrations/erli/import` and cron `erli_orders_import`. Both call `ErliOrdersSyncService`, which fetches unread `/inbox` messages, maps supported order events through `ErliOrderMapper`, persists via `OrderImportRepository::upsertOrderAggregate()`, emits existing automation events, and acknowledges `POST /inbox/mark-read` only after a zero-failure batch.
5. **Status mapping/sync** - Phase 129 adds pull/push status mapping tables, status controls in Erli settings, and cron `erli_status_sync`. Pull reuses inbox import; push sends manual orderPRO status changes to `PATCH /orders/{id}/status`.
6. **Delivery mapping and labels** - Phase 130 adds `ErliDeliveryMappingController` and a Delivery tab. It maps imported Erli delivery method labels to local shipment providers (`inpost`/`apaczka`) and stores Erli `source_vendor_code` for external parcel registration. Label files are still produced by local providers, not by Erli carrier-contract parcels.
-7. **External shipment sync** - Phase 130 extends `ErliApiClient` with shipping dictionary calls and `createExternalParcel()` (`POST /shipping/external`). `ErliExternalShipmentService` registers a local package in Erli only after `shipment_packages.tracking_number` exists; failures are activity-log warnings and do not block local labels.
-8. **Deferred** - Carrier tracking automation and broader delivery-status hooks are planned for v3.8 Phase 131.
+7. **External shipment sync** - Phase 130 extends `ErliApiClient` with shipping dictionary calls and `createExternalParcel()` (`POST /shipping/external`). `ErliExternalShipmentService` registers a local package in Erli only after `shipment_packages.tracking_number` exists; failures are activity-log warnings and do not block local labels. Phase 131 wires the same service into `shipment_tracking_sync` as a retry path when tracking appears after the initial create/label flow.
+8. **Tracking + automation hooks** - Phase 131 deliberately follows the working Allegro model: no separate `ErliTrackingService` and no Erli-only delivery-status UI. Local provider tracking remains source of truth, `ShipmentTrackingHandler` emits shared `shipment.status_changed` context (`source`, `package_id`, `provider`, `tracking_number`, status fields), `ShipmentController` emits `shipment.created` with the same generic context, and e-mail/SMS variables continue to use `ShipmentPackageRepository::findLatestByOrderId()` plus `DeliveryStatus::trackingUrl()`.
## Dependency Injection
diff --git a/DOCS/TECH_CHANGELOG.md b/DOCS/TECH_CHANGELOG.md
index 1139de0..8e99b0a 100644
--- a/DOCS/TECH_CHANGELOG.md
+++ b/DOCS/TECH_CHANGELOG.md
@@ -1,5 +1,22 @@
# Technical Changelog
+## 2026-05-16 - Phase 131 Plan 01: Erli Tracking + Automation Hooks
+
+**Co zrobiono:**
+- `ShipmentTrackingHandler` po udanym odczycie statusu lokalnego providera probuje ponownie zarejestrowac paczke Erli przez `ErliExternalShipmentService::syncPackage()`, jesli tracking pojawil sie dopiero po pierwszym flow tworzenia etykiety.
+- Retry synchronizacji Erli jest niekrytyczny: bledy `POST /shipping/external` trafiaja do logiki serwisu/activity log, ale nie blokuja lokalnego `delivery_status`, crona trackingowego ani automatyzacji.
+- `CronHandlerFactory` wstrzykuje `ErliExternalShipmentService` do `shipment_tracking_sync` razem ze wspolnym `ShipmentPackageRepository` i `OrdersRepository`.
+- Kontekst zdarzen `shipment.created` i `shipment.status_changed` zostal uzupelniony o wspolne pola `source`, `tracking_number`, `package_id`, `provider` oraz statusy dostawy, bez tworzenia eventow specyficznych dla Erli.
+- Rozszerzono testy `ErliExternalShipmentServiceTest` o skip dla nie-Erli, pustego trackingu, idempotencje po tym samym tracking number i niekrytyczny blad API; dodano test automatyzacji dla Erli na wspolnym `shipment.status_changed`.
+- Potwierdzono, ze e-mail/SMS uzywaja wspolnego resolvera przesylki (`ShipmentPackageRepository::findLatestByOrderId()` + `DeliveryStatus::trackingUrl()`), wiec nie dodano osobnej logiki Erli.
+
+**Dlaczego:**
+- Operator wskazal integracje Allegro jako dzialajacy wzorzec. Erli ma wiec korzystac z lokalnego trackingu providerow i wspolnych automatyzacji, a marketplace dostaje tylko niekrytyczna informacje o zewnetrznej paczce/tracking number.
+- Brak osobnego `ErliTrackingService` i brak nowego UI statusow dostawy zmniejsza ryzyko rozjazdu kontraktow oraz utrzymuje jeden model regul automatyzacji dla Allegro/shopPRO/Erli.
+
+**BREAKING / migracja:**
+- Brak migracji i brak breaking changes. Zmiana uzywa istniejacych pol `shipment_packages.payload_json`, `delivery_status`, `delivery_status_raw` oraz istniejacych eventow automatyzacji.
+
## 2026-05-16 - Phase 130 Plan 01: Erli Shipments + Labels
**Co zrobiono:**
diff --git a/src/Modules/Automation/AutomationService.php b/src/Modules/Automation/AutomationService.php
index 360d9ef..f9aa923 100644
--- a/src/Modules/Automation/AutomationService.php
+++ b/src/Modules/Automation/AutomationService.php
@@ -538,7 +538,9 @@ final class AutomationService
$context,
[
'package_id' => $packageId,
+ 'source' => (string) ($context['source'] ?? ''),
'provider' => (string) ($targetPackage['provider'] ?? ''),
+ 'tracking_number' => (string) ($targetPackage['tracking_number'] ?? ''),
'delivery_status' => $targetStatus,
'delivery_status_raw' => '',
'previous_status' => $previousStatus,
diff --git a/src/Modules/Cron/CronHandlerFactory.php b/src/Modules/Cron/CronHandlerFactory.php
index 8bf370f..92b9f76 100644
--- a/src/Modules/Cron/CronHandlerFactory.php
+++ b/src/Modules/Cron/CronHandlerFactory.php
@@ -30,10 +30,12 @@ use App\Modules\Settings\AllegroStatusSyncService;
use App\Modules\Settings\AllegroTokenManager;
use App\Modules\Settings\ApaczkaApiClient;
use App\Modules\Settings\ApaczkaIntegrationRepository;
+use App\Modules\Settings\CarrierDeliveryMethodMappingRepository;
use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\EmailMailboxRepository;
use App\Modules\Settings\EmailTemplateRepository;
use App\Modules\Settings\ErliApiClient;
+use App\Modules\Settings\ErliExternalShipmentService;
use App\Modules\Settings\ErliIntegrationRepository;
use App\Modules\Settings\ErliOrderMapper;
use App\Modules\Settings\ErliOrderSyncStateRepository;
@@ -144,6 +146,8 @@ final class CronHandlerFactory
$erliApiClient = new ErliApiClient();
$erliPullStatusMappingRepository = new ErliPullStatusMappingRepository($this->db);
$erliStatusMappingRepository = new ErliStatusMappingRepository($this->db);
+ $carrierDeliveryMappings = new CarrierDeliveryMethodMappingRepository($this->db);
+ $shipmentPackageRepository = new ShipmentPackageRepository($this->db);
$erliOrdersSyncService = new ErliOrdersSyncService(
$erliIntegrationRepository,
$erliSyncStateRepository,
@@ -219,9 +223,17 @@ final class CronHandlerFactory
new DeliveryStatusMappingRepository($this->db)
),
]),
- new ShipmentPackageRepository($this->db),
+ $shipmentPackageRepository,
$automationService,
- new DeliveryStatusMappingRepository($this->db)
+ new DeliveryStatusMappingRepository($this->db),
+ new ErliExternalShipmentService(
+ $erliIntegrationRepository,
+ $erliApiClient,
+ $carrierDeliveryMappings,
+ $shipmentPackageRepository,
+ $ordersRepository
+ ),
+ $ordersRepository
),
'automation_history_cleanup' => new AutomationHistoryCleanupHandler(
new AutomationExecutionLogRepository($this->db)
diff --git a/src/Modules/Cron/ShipmentTrackingHandler.php b/src/Modules/Cron/ShipmentTrackingHandler.php
index d1e53f2..cf94bac 100644
--- a/src/Modules/Cron/ShipmentTrackingHandler.php
+++ b/src/Modules/Cron/ShipmentTrackingHandler.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\Automation\AutomationService;
+use App\Modules\Orders\OrdersRepository;
+use App\Modules\Settings\ErliExternalShipmentService;
use App\Modules\Shipments\DeliveryStatus;
use App\Modules\Shipments\DeliveryStatusMappingRepository;
use App\Modules\Shipments\ShipmentPackageRepository;
@@ -18,7 +20,9 @@ final class ShipmentTrackingHandler
private readonly ShipmentTrackingRegistry $registry,
private readonly ShipmentPackageRepository $repository,
private readonly AutomationService $automationService,
- private readonly ?DeliveryStatusMappingRepository $mappingRepository = null
+ private readonly ?DeliveryStatusMappingRepository $mappingRepository = null,
+ private readonly ?ErliExternalShipmentService $erliExternalShipmentService = null,
+ private readonly ?OrdersRepository $ordersRepository = null
) {
}
@@ -65,6 +69,8 @@ final class ShipmentTrackingHandler
$result = $service->getDeliveryStatus($package);
if ($result !== null) {
+ $this->syncErliExternalShipment($packageId);
+
$previousStatus = trim((string) ($package['delivery_status'] ?? 'unknown'));
if ($previousStatus === '') {
$previousStatus = 'unknown';
@@ -99,7 +105,9 @@ final class ShipmentTrackingHandler
if ($orderId > 0) {
$this->automationService->trigger('shipment.status_changed', $orderId, [
'package_id' => $packageId,
+ 'source' => $this->resolveOrderSource($orderId),
'provider' => $provider,
+ 'tracking_number' => (string) ($package['tracking_number'] ?? ''),
'delivery_status' => $newStatus,
'delivery_status_raw' => $newStatusRaw,
'previous_status' => $previousStatus,
@@ -119,4 +127,33 @@ final class ShipmentTrackingHandler
'errors' => $errors,
];
}
+
+ private function syncErliExternalShipment(int $packageId): void
+ {
+ if ($this->erliExternalShipmentService === null || $packageId <= 0) {
+ return;
+ }
+
+ try {
+ $this->erliExternalShipmentService->syncPackage($packageId);
+ } catch (Throwable) {
+ // Synchronizacja marketplace nie moze blokowac lokalnego trackingu.
+ }
+ }
+
+ private function resolveOrderSource(int $orderId): string
+ {
+ if ($this->ordersRepository === null || $orderId <= 0) {
+ return '';
+ }
+
+ try {
+ $details = $this->ordersRepository->findDetails($orderId);
+ } catch (Throwable) {
+ return '';
+ }
+
+ $order = is_array($details['order'] ?? null) ? $details['order'] : [];
+ return strtolower(trim((string) ($order['source'] ?? '')));
+ }
}
diff --git a/src/Modules/Shipments/ShipmentController.php b/src/Modules/Shipments/ShipmentController.php
index 85e1215..93507cf 100644
--- a/src/Modules/Shipments/ShipmentController.php
+++ b/src/Modules/Shipments/ShipmentController.php
@@ -494,14 +494,18 @@ final class ShipmentController
}
$package = $this->packageRepository->findById($packageId);
+ $details = $this->ordersRepository->findDetails($orderId);
+ $order = is_array($details['order'] ?? null) ? $details['order'] : [];
$context = [
'package_id' => $packageId,
+ 'source' => strtolower(trim((string) ($order['source'] ?? ''))),
'provider' => $providerCode,
'package_status' => is_array($package) ? (string) ($package['status'] ?? '') : '',
'tracking_number' => is_array($package) ? (string) ($package['tracking_number'] ?? '') : '',
'delivery_status' => is_array($package)
? (string) ($package['delivery_status'] ?? DeliveryStatus::UNKNOWN)
: DeliveryStatus::UNKNOWN,
+ 'delivery_status_raw' => is_array($package) ? (string) ($package['delivery_status_raw'] ?? '') : '',
];
try {
diff --git a/tests/Unit/AutomationServiceTest.php b/tests/Unit/AutomationServiceTest.php
index 0da613a..534cee7 100644
--- a/tests/Unit/AutomationServiceTest.php
+++ b/tests/Unit/AutomationServiceTest.php
@@ -213,4 +213,71 @@ final class AutomationServiceTest extends TestCase
'current_status' => 'w_realizacji',
]);
}
+
+ public function testShipmentStatusChangedForErliUsesSharedContext(): void
+ {
+ $rule = [
+ 'id' => 10,
+ 'name' => 'Regula Erli',
+ 'conditions' => [[
+ 'id' => 31,
+ 'condition_type' => 'shipment_status',
+ 'condition_value' => [
+ 'status_keys' => ['delivered'],
+ ],
+ ]],
+ 'actions' => [[
+ 'id' => 14,
+ 'action_type' => 'send_email',
+ 'action_config' => [
+ 'template_id' => 6,
+ 'recipient' => 'client',
+ 'send_once_per_order' => 0,
+ ],
+ ]],
+ ];
+
+ $this->automationRepository
+ ->expects($this->once())
+ ->method('findActiveByEvent')
+ ->with('shipment.status_changed')
+ ->willReturn([$rule]);
+
+ $this->ordersRepository
+ ->expects($this->once())
+ ->method('findDetails')
+ ->with(123)
+ ->willReturn(['order' => ['id' => 123, 'integration_id' => 1, 'source' => 'erli']]);
+
+ $this->emailService
+ ->expects($this->once())
+ ->method('send')
+ ->with(123, 6, null, 'Automatyzacja: Regula Erli')
+ ->willReturn(['success' => true, 'error' => null, 'log_id' => 4]);
+
+ $this->executionLogRepository
+ ->expects($this->once())
+ ->method('create')
+ ->with($this->callback(static function (array $payload): bool {
+ $context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
+ return ($payload['event_type'] ?? '') === 'shipment.status_changed'
+ && ($context['source'] ?? '') === 'erli'
+ && ($context['package_id'] ?? 0) === 77
+ && ($context['provider'] ?? '') === 'inpost'
+ && ($context['tracking_number'] ?? '') === '1234567890'
+ && ($context['delivery_status'] ?? '') === 'delivered'
+ && ($context['previous_status'] ?? '') === 'in_transit';
+ }));
+
+ $this->service->trigger('shipment.status_changed', 123, [
+ 'source' => 'erli',
+ 'package_id' => 77,
+ 'provider' => 'inpost',
+ 'tracking_number' => '1234567890',
+ 'delivery_status' => 'delivered',
+ 'delivery_status_raw' => 'DELIVERED',
+ 'previous_status' => 'in_transit',
+ 'previous_status_raw' => 'IN_TRANSIT',
+ ]);
+ }
}
diff --git a/tests/Unit/ErliExternalShipmentServiceTest.php b/tests/Unit/ErliExternalShipmentServiceTest.php
index 3ed95be..745ed06 100644
--- a/tests/Unit/ErliExternalShipmentServiceTest.php
+++ b/tests/Unit/ErliExternalShipmentServiceTest.php
@@ -117,6 +117,106 @@ final class ErliExternalShipmentServiceTest extends TestCase
self::assertTrue($result['skipped']);
}
+ public function testSyncPackageSkipsNonErliOrders(): void
+ {
+ $this->packageRepository
+ ->method('findById')
+ ->with(55)
+ ->willReturn([
+ 'id' => 55,
+ 'order_id' => 1001,
+ 'tracking_number' => '1234567890',
+ ]);
+ $this->ordersRepository
+ ->method('findDetails')
+ ->with(1001)
+ ->willReturn(['order' => ['source' => 'allegro']]);
+ $this->apiClient
+ ->expects($this->never())
+ ->method('createExternalParcel');
+
+ $result = $this->createService()->syncPackage(55);
+
+ self::assertTrue($result['ok']);
+ self::assertTrue($result['skipped']);
+ }
+
+ public function testSyncPackageSkipsAlreadySyncedTrackingNumber(): void
+ {
+ $this->packageRepository
+ ->method('findById')
+ ->with(55)
+ ->willReturn([
+ 'id' => 55,
+ 'order_id' => 1001,
+ 'tracking_number' => '1234567890',
+ 'payload_json' => '{"erli_external_parcel":{"trackingNumber":"1234567890"}}',
+ ]);
+ $this->ordersRepository
+ ->method('findDetails')
+ ->with(1001)
+ ->willReturn(['order' => ['source' => 'erli']]);
+ $this->apiClient
+ ->expects($this->never())
+ ->method('createExternalParcel');
+
+ $result = $this->createService()->syncPackage(55);
+
+ self::assertTrue($result['ok']);
+ self::assertTrue($result['skipped']);
+ }
+
+ public function testSyncPackageReturnsNonCriticalFailureWhenApiFails(): void
+ {
+ $this->packageRepository
+ ->method('findById')
+ ->with(55)
+ ->willReturn([
+ 'id' => 55,
+ 'order_id' => 1001,
+ 'provider' => 'inpost',
+ 'tracking_number' => '1234567890',
+ 'payload_json' => '{}',
+ ]);
+ $this->ordersRepository
+ ->method('findDetails')
+ ->with(1001)
+ ->willReturn([
+ 'order' => [
+ 'source' => 'erli',
+ 'source_order_id' => '240101x12345',
+ 'external_carrier_id' => 'Erli Paczkomat',
+ ],
+ ]);
+ $this->deliveryMappings
+ ->method('findByOrderMethod')
+ ->with('erli', 0, 'Erli Paczkomat')
+ ->willReturn(['source_vendor_code' => 'inpost']);
+ $this->erliRepository
+ ->method('getCredentials')
+ ->willReturn([
+ 'integration_id' => 12,
+ 'base_url' => 'https://erli.test',
+ 'api_key' => 'token',
+ ]);
+ $this->apiClient
+ ->expects($this->once())
+ ->method('createExternalParcel')
+ ->willReturn(['ok' => false, 'http_code' => 500, 'items' => [], 'message' => 'API down']);
+ $this->packageRepository
+ ->expects($this->never())
+ ->method('update');
+ $this->ordersRepository
+ ->expects($this->once())
+ ->method('recordActivity');
+
+ $result = $this->createService()->syncPackage(55);
+
+ self::assertFalse($result['ok']);
+ self::assertFalse($result['skipped']);
+ self::assertSame('API down', $result['message']);
+ }
+
private function createService(): ErliExternalShipmentService
{
return new ErliExternalShipmentService(