feat(130): erli shipments and labels

This commit is contained in:
2026-05-16 00:58:14 +02:00
parent 4258751e80
commit 13f570e5af
21 changed files with 1451 additions and 58 deletions

View File

@@ -13,8 +13,8 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
| Attribute | Value | | Attribute | Value |
|-----------|-------| |-----------|-------|
| Version | 3.8.0-dev | | Version | 3.8.0-dev |
| Status | v3.8 Erli Marketplace Integration in progress — Phase 129 shipped (Erli status mappings/sync); Phase 130 next | | 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 129 closed) | | Last Updated | 2026-05-16 (Phase 130 closed) |
## Requirements ## Requirements
@@ -129,6 +129,7 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
- [x] Fundament integracji Erli: pojedyncza globalna konfiguracja `/settings/integrations/erli`, szyfrowany Bearer API key, realny test `GET /svc/shop-api/inbox`, karta w hubie integracji oraz dokumentacja schematu/architektury — Phase 127 - [x] Fundament integracji Erli: pojedyncza globalna konfiguracja `/settings/integrations/erli`, szyfrowany Bearer API key, realny test `GET /svc/shop-api/inbox`, karta w hubie integracji oraz dokumentacja schematu/architektury — Phase 127
- [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] 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] 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
### Deferred ### Deferred
@@ -137,11 +138,10 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
### Active (In Progress) ### Active (In Progress)
- [ ] v3.8 Erli Marketplace Integration — Phase 130 next: generowanie etykiet i obsluga przesylek Erli. - [ ] v3.8 Erli Marketplace Integration — Phase 131 next: tracking przesylek Erli i hooki automatyzacji.
### Planned (Next) ### Planned (Next)
- [ ] Erli shipments + labels — Phase 130
- [ ] Erli tracking + automation hooks — Phase 131 - [ ] Erli tracking + automation hooks — Phase 131
- [ ] Erli hardening, observability + docs — Phase 132 - [ ] Erli hardening, observability + docs — Phase 132
- [ ] ZarzÄ…dzanie produktami - [ ] ZarzÄ…dzanie produktami
@@ -251,13 +251,15 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API
| Push statusow Erli obejmuje tylko reczne zmiany orderPRO (`change_source='manual'`) | Chroni przed petlami po imporcie, automatyzacjach i systemowych zmianach statusu | 2026-05-16 | Active | | Push statusow Erli obejmuje tylko reczne zmiany orderPRO (`change_source='manual'`) | Chroni przed petlami po imporcie, automatyzacjach i systemowych zmianach statusu | 2026-05-16 | Active |
| Erli -> orderPRO status pull uzywa tego samego inbox + ACK flow co import zamowien | Jedno bezpieczne zrodlo zdarzen Erli; brak osobnego status endpointu do utrzymania | 2026-05-16 | Active | | Erli -> orderPRO status pull uzywa tego samego inbox + ACK flow co import zamowien | Jedno bezpieczne zrodlo zdarzen Erli; brak osobnego status endpointu do utrzymania | 2026-05-16 | Active |
| Erli settings korzysta z zakladek Integracja/Statusy/Ustawienia | Po dodaniu mapowan strona wymagala parytetu UX z Allegro/shopPRO | 2026-05-16 | Active | | Erli settings korzysta z zakladek Integracja/Statusy/Ustawienia | Po dodaniu mapowan strona wymagala parytetu UX z Allegro/shopPRO | 2026-05-16 | Active |
| Erli etykiety uzywaja lokalnych providerow, a Erli dostaje paczke zewnetrzna przez `POST /shipping/external` | Operator nie chce nadawac na umowie Erli; API wspiera zewnetrzne paczki/tracking | 2026-05-16 | Active |
| `carrier_delivery_method_mappings` przechowuje `source_vendor_code`/`source_service_id` dla Erli | Vendor Erli i lokalny provider to osobne kontrakty, nie nalezy ich mieszac w polach Apaczki/InPost | 2026-05-16 | Active |
## Success Metrics ## Success Metrics
| Metric | Target | Current | Status | | Metric | Target | Current | Status |
|--------|--------|---------|--------| |--------|--------|---------|--------|
| Liczba zintegrowanych źródeŠzamówień | ≥3 | 3 zrodla importu (Allegro, shopPRO, Erli); Erli wymaga manualnego smoke po migracji | In progress | | Liczba zintegrowanych źródeŠzamówień | ≥3 | 3 zrodla importu (Allegro, shopPRO, Erli); Erli wymaga manualnego smoke po migracji | In progress |
| Generowanie etykiet | DziaĹa | InPost | In progress | | Generowanie etykiet | DziaĹa | InPost + Erli przez lokalne providery po mapowaniu; Erli wymaga manualnego smoke po migracji | In progress |
## Tech Stack ## Tech Stack
@@ -282,6 +284,6 @@ Quick Reference:
--- ---
*PROJECT.md — Updated when requirements or context change* *PROJECT.md — Updated when requirements or context change*
*Last updated: 2026-05-16 after Phase 129 (Erli Status Mapping + Sync) closure; v3.8 milestone in progress* *Last updated: 2026-05-16 after Phase 130 (Erli Shipments + Labels) closure; v3.8 milestone in progress*

View File

@@ -10,14 +10,14 @@ 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. 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: 3 of 6 phases complete (50%). Progress: 4 of 6 phases complete (67%).
| Phase | Name | Plans | Status | | Phase | Name | Plans | Status |
|-------|------|-------|--------| |-------|------|-------|--------|
| 127 | Erli Integration Foundation | 1/1 | Complete (2026-05-15; migration/manual Erli API smoke pending operator) | | 127 | Erli Integration Foundation | 1/1 | Complete (2026-05-15; migration/manual Erli API smoke pending operator) |
| 128 | Erli Orders Import | 1/1 | Complete (2026-05-15; migration/manual Erli import smoke pending operator) | | 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) | | 129 | Erli Status Mapping + Sync | 1/1 | Complete (2026-05-16; migration/manual Erli status smoke pending operator) |
| 130 | Erli Shipments + Labels | TBD | Not started | | 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 | TBD | Not started |
| 132 | Erli Hardening, Observability + Docs | TBD | Not started | | 132 | Erli Hardening, Observability + Docs | TBD | Not started |
@@ -39,7 +39,7 @@ Plans: 129-01 (complete)
### Phase 130: Erli Shipments + Labels ### Phase 130: Erli Shipments + Labels
Focus: Generowanie etykiet dla zamowien Erli, mapowanie metod dostawy Erli na dostepne providery, zapis paczek w `shipment_packages`, pobieranie labeli i integracja z kolejka zdalnego druku. Focus: Generowanie etykiet dla zamowien Erli, mapowanie metod dostawy Erli na dostepne providery, zapis paczek w `shipment_packages`, pobieranie labeli i integracja z kolejka zdalnego druku.
Plans: TBD (defined during $paul-plan) Plans: 130-01 (complete)
### Phase 131: Erli Tracking + Automation Hooks ### Phase 131: Erli Tracking + Automation Hooks
@@ -555,4 +555,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
--- ---
*Roadmap created: 2026-03-12* *Roadmap created: 2026-03-12*
*Last updated: 2026-05-16 - Phase 129 complete, ready for Phase 130 planning* *Last updated: 2026-05-16 - Phase 130 complete, ready for Phase 131 planning*

View File

@@ -5,33 +5,33 @@
See: .paul/PROJECT.md (updated 2026-05-16) 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. **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 130 ready to plan. **Current focus:** v3.8 Erli Marketplace Integration - Phase 131 ready to plan.
## Current Position ## Current Position
Milestone: v3.8 Erli Marketplace Integration Milestone: v3.8 Erli Marketplace Integration
Phase: 130 of 132 (Erli Shipments + Labels) - Not started Phase: 131 of 132 (Erli Tracking + Automation Hooks) - Not started
Plan: Not started Plan: Not started
Status: Ready to plan Status: Ready to plan
Last activity: 2026-05-16 00:23 - Phase 129 complete, transitioned to Phase 130 Last activity: 2026-05-16 00:51 - Phase 130 complete, transitioned to Phase 131
Progress: Progress:
- Milestone v3.8: [#####-----] 50% (Phases 127-129 complete; Phase 130 next) - Milestone v3.8: [#######---] 67% (Phases 127-130 complete; Phase 131 next)
- Phase 130: [----------] 0% (not planned) - Phase 131: [----------] 0% (not planned)
## Loop Position ## Loop Position
Current loop state: Current loop state:
``` ```
PLAN -> APPLY -> UNIFY PLAN -> APPLY -> UNIFY
done done done [Loop complete - ready for next PLAN] done done done [Loop complete, ready for next PLAN]
``` ```
## Session Continuity ## Session Continuity
Last session: 2026-05-16 00:23 Last session: 2026-05-16 00:51
Stopped at: Phase 129 complete, ready to plan Phase 130 Stopped at: Phase 130 complete, ready to plan Phase 131
Next action: $paul-plan for Phase 130 (Erli Shipments + Labels) Next action: $paul-plan for Phase 131 (Erli Tracking + Automation Hooks)
Resume file: .paul/ROADMAP.md Resume file: .paul/ROADMAP.md
## Pending parallel work ## Pending parallel work
@@ -39,8 +39,8 @@ Resume file: .paul/ROADMAP.md
## Git State ## Git State
Last phase commit: 7972bb9 feat(129): erli status mapping sync Last phase commit: a73bd7f feat(130): erli shipments and labels
Previous: 2565d9b feat(128): erli orders import Previous: 7972bb9 feat(129): erli status mapping sync
Branch: main Branch: main
### Skill Audit (Phase 129) ### Skill Audit (Phase 129)
@@ -49,6 +49,12 @@ Branch: main
|----------|---------|-------| |----------|---------|-------|
| `sonar-scanner` | gap documented | Attempted before UNIFY; CLI is not available in PATH. | | `sonar-scanner` | gap documented | Attempted before UNIFY; CLI is not available in PATH. |
### Skill Audit (Phase 130)
| Expected | Invoked | Notes |
|----------|---------|-------|
| `sonar-scanner` | gap documented | Attempted after APPLY; CLI is not available in PATH. |
## Pending Actions ## Pending Actions
- Manualne testy AC-1..AC-7 dla Phase 112 na zywej bazie (XAMPP online). - Manualne testy AC-1..AC-7 dla Phase 112 na zywej bazie (XAMPP online).
@@ -76,6 +82,9 @@ Branch: main
- Phase 128 verification gap: `vendor/bin/phpunit` nie istnieje w checkoutcie, wiec test `tests/Unit/ErliOrderMapperTest.php` nie zostal uruchomiony przez PHPUnit; wykonano `php -l` i runtime smoke mappera. - Phase 128 verification gap: `vendor/bin/phpunit` nie istnieje w checkoutcie, wiec test `tests/Unit/ErliOrderMapperTest.php` nie zostal uruchomiony przez PHPUnit; wykonano `php -l` i runtime smoke mappera.
- Phase 129 follow-up: uruchom `php bin/migrate.php`, sprawdz `/settings/integrations/erli` mapowania pull/push i zakladki, zapisz mapowania, ustaw `orderPRO -> Erli`, zmien recznie status zamowienia Erli i uruchom cron `erli_status_sync`. - Phase 129 follow-up: uruchom `php bin/migrate.php`, sprawdz `/settings/integrations/erli` mapowania pull/push i zakladki, zapisz mapowania, ustaw `orderPRO -> Erli`, zmien recznie status zamowienia Erli i uruchom cron `erli_status_sync`.
- Phase 129 verification gap: `vendor/bin/phpunit` nie istnieje w checkoutcie, a globalny XAMPP PHPUnit jest niekompatybilny z PHP (`each()` removed), wiec testy `ErliOrderMapperTest` i `ErliStatusSyncServiceTest` nie zostaly uruchomione przez PHPUnit; wykonano `php -l`, runtime smoke mappera i `git diff --check`. - Phase 129 verification gap: `vendor/bin/phpunit` nie istnieje w checkoutcie, a globalny XAMPP PHPUnit jest niekompatybilny z PHP (`each()` removed), wiec testy `ErliOrderMapperTest` i `ErliStatusSyncServiceTest` nie zostaly uruchomione przez PHPUnit; wykonano `php -l`, runtime smoke mappera i `git diff --check`.
- 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.
## Deferred to Next Milestones ## Deferred to Next Milestones

View File

@@ -7,6 +7,11 @@
- Dodano repozytoria mapowan, `ErliStatusSyncService`, `ErliStatusSyncHandler`, discovery nieznanych statusow Erli i testy jednostkowe dla mappera/status sync. - Dodano repozytoria mapowan, `ErliStatusSyncService`, `ErliStatusSyncHandler`, discovery nieznanych statusow Erli i testy jednostkowe dla mappera/status sync.
- Ujednolicono `/settings/integrations/erli` z innymi integracjami przez zakladki Integracja, Statusy i Ustawienia. - Ujednolicono `/settings/integrations/erli` z innymi integracjami przez zakladki Integracja, Statusy i Ustawienia.
- Udokumentowano gapy srodowiskowe: brak `vendor/bin/phpunit`, globalny XAMPP PHPUnit niekompatybilny z PHP, brak `sonar-scanner` w PATH. - Udokumentowano gapy srodowiskowe: brak `vendor/bin/phpunit`, globalny XAMPP PHPUnit niekompatybilny z PHP, brak `sonar-scanner` w PATH.
- [Phase 130, Plan 01] Wdrozono obsluge przesylek Erli: zakladke mapowan dostaw, lokalne generowanie etykiet przez zmapowanych providerow i rejestracje paczki zewnetrznej w Erli.
- Rozszerzono klienta Erli o slowniki shipping/delivery, vendorow, cenniki oraz `POST /shipping/external`.
- 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.
## Zmienione pliki ## Zmienione pliki
@@ -35,6 +40,15 @@
- `resources/lang/pl.php` - `resources/lang/pl.php`
- `tests/Unit/ErliOrderMapperTest.php` - `tests/Unit/ErliOrderMapperTest.php`
- `tests/Unit/ErliStatusSyncServiceTest.php` - `tests/Unit/ErliStatusSyncServiceTest.php`
- `.paul/phases/130-erli-shipments-labels/130-01-PLAN.md`
- `.paul/phases/130-erli-shipments-labels/130-01-SUMMARY.md`
- `database/migrations/20260516_000117_extend_delivery_mappings_for_erli_shipping.sql`
- `src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php`
- `src/Modules/Settings/ErliDeliveryMappingController.php`
- `src/Modules/Settings/ErliExternalShipmentService.php`
- `src/Modules/Shipments/ShipmentController.php`
- `resources/views/shipments/prepare.php`
- `tests/Unit/ErliExternalShipmentServiceTest.php`
- `DOCS/DB_SCHEMA.md` - `DOCS/DB_SCHEMA.md`
- `DOCS/ARCHITECTURE.md` - `DOCS/ARCHITECTURE.md`
- `DOCS/TECH_CHANGELOG.md` - `DOCS/TECH_CHANGELOG.md`

View File

@@ -0,0 +1,255 @@
---
phase: 130-erli-shipments-labels
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/Core/Constants/IntegrationSources.php
- src/Modules/Settings/ErliApiClient.php
- src/Modules/Settings/ErliDeliveryMappingController.php
- src/Modules/Settings/ErliIntegrationController.php
- src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php
- src/Modules/Settings/ErliExternalShipmentService.php
- src/Modules/Shipments/ShipmentController.php
- routes/web.php
- resources/views/settings/erli.php
- resources/views/shipments/prepare.php
- resources/lang/pl.php
- tests/Unit/ErliExternalShipmentServiceTest.php
- DOCS/DB_SCHEMA.md
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
autonomous: true
delegation: auto
---
<objective>
## Goal
Dodac obsluge przesylek dla zamowien Erli: mapowanie metod dostawy w osobnej zakladce ustawien, uzycie istniejacych providerow etykiet (InPost/Apaczka tam, gdzie sa dostepne), zapis paczek w `shipment_packages` oraz rejestracje numeru nadania w Erli przez natywny endpoint przesylek zewnetrznych.
## Purpose
Sprzedawca ma nadawac zamowienia Erli z orderPRO bez przelaczania sie do panelu marketplace, ale bez wymuszania nadawania "na umowie Erli". Natywne Erli w tym planie oznacza wykorzystanie oficjalnych slownikow/cennikow/shipping API tam, gdzie pasuja do wlasnych umow operatora.
## Output
Nowa zakladka dostaw w `/settings/integrations/erli`, rozszerzony Erli API client, flow tworzenia etykiety dla zmapowanych zamowien Erli oraz synchronizacja paczki zewnetrznej do Erli po uzyskaniu numeru przesylki.
</objective>
<context>
<clarifications>
- **Natywne Erli** - Czy probujemy najpierw API shipping Erli, czy od razu tylko istniejacy provider etykiet?
-> Odpowiedz: Trzeba sprobowac natywnego z erli.
- **Konfiguracja** - Gdzie operator ma mapowac metody dostawy Erli?
-> Odpowiedz: zakladka.
- **Umowa/cenniki** - Czy nadawac przez Erli na ich umowie, czy tylko to, co da sie spiac z wlasnymi providerami/cennikami?
-> Odpowiedz: Nie chce nadawac przez Erli na ich umowie, to co sie da. Tak jak allegro ma swoje cenniki.
</clarifications>
## 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/127-erli-integration-foundation/127-01-SUMMARY.md
@.paul/phases/128-erli-orders-import/128-01-SUMMARY.md
@.paul/phases/129-erli-status-mapping-sync/129-01-SUMMARY.md
## External Contract
@https://erli.pl/svc/shop-api/doc/swagger.json
Notes from official swagger checked during planning:
- `GET /dictionaries/shippingMethods`, `GET /dictionaries/deliveryMethods`, `GET /dictionaries/deliveryVendors`, `GET /delivery/priceLists`, `GET /delivery/priceListsDetails` expose Erli shipping/delivery dictionaries and cenniki.
- `POST /shipping/external` creates external parcels with `orderId`, `vendor`, `status`, `trackingNumber`.
- `POST /shipping/parcels/` creates Erli parcels, but this path is treated as optional/discovery only because the user does not want to ship on Erli's carrier agreement.
- No label-download endpoint was found in the swagger path list; local label generation must continue through existing provider services unless APPLY finds a documented endpoint with compatible contract.
## Source Files
@src/Modules/Settings/ErliApiClient.php
@src/Modules/Settings/ErliIntegrationController.php
@src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php
@src/Modules/Settings/AllegroDeliveryMappingController.php
@src/Modules/Shipments/ShipmentController.php
@src/Modules/Shipments/ShipmentProviderInterface.php
@src/Modules/Shipments/InpostShipmentService.php
@src/Modules/Shipments/ApaczkaShipmentService.php
@resources/views/settings/erli.php
@resources/views/shipments/prepare.php
@routes/web.php
@DOCS/DB_SCHEMA.md
@DOCS/ARCHITECTURE.md
</context>
<skills>
## 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 | ○ |
| /frontend-design | optional | Przy dodaniu zakladki UI w ustawieniach Erli | ○ |
| /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 128/129.
## Skill Invocation Checklist
- [ ] `sonar-scanner` attempted after APPLY
- [ ] Optional flows considered if implementation risk warrants them
</skills>
<acceptance_criteria>
## AC-1: Erli Shipping Contract Is Used Where Safe
```gherkin
Given Erli API credentials are configured
When orderPRO loads shipment configuration for Erli
Then it can fetch Erli shipping/delivery dictionaries, delivery vendors and price list data from the official Erli API
And it does not assume native Erli label download exists unless a documented endpoint is confirmed during implementation.
```
## AC-2: Erli Delivery Mapping Tab Exists
```gherkin
Given the operator opens /settings/integrations/erli
When they select the delivery/shipping tab
Then they see distinct Erli delivery methods from imported orders plus Erli dictionary context
And they can map each method to an available local label provider/service and Erli vendor with a CSRF-protected save form.
```
## AC-3: Erli Orders Preselect Shipment Provider
```gherkin
Given an Erli order has a saved delivery mapping
When the operator opens the shipment prepare page for that order
Then orderPRO preselects the mapped provider/service and shows a clear unmapped diagnostic if no mapping exists.
```
## AC-4: Labels Are Generated Without Erli Carrier Agreement
```gherkin
Given an Erli order is mapped to an existing local provider such as InPost or Apaczka
When the operator creates a shipment
Then the existing provider creates the local shipment package, stores it in shipment_packages and makes the label available for download/print queue
And the flow does not create Erli-contract parcels unless explicitly supported and selected in future work.
```
## AC-5: External Parcel Is Registered In Erli
```gherkin
Given a local Erli shipment package has a tracking number and a mapped Erli vendor
When the package creation/status check reaches a label-ready or sent state
Then orderPRO calls POST /shipping/external with order id, vendor, status and tracking number
And API failures are logged as non-critical local shipment warnings rather than losing the label.
```
## AC-6: Documentation And Verification Cover The Flow
```gherkin
Given the implementation is complete
When verification runs
Then PHP syntax checks, focused tests or documented PHPUnit gap, diff checks and documentation updates cover the new Erli shipment behavior.
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Extend Erli API and mapping repository for shipping</name>
<files>src/Core/Constants/IntegrationSources.php, src/Modules/Settings/ErliApiClient.php, src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php, DOCS/DB_SCHEMA.md, DOCS/ARCHITECTURE.md</files>
<action>
Add `erli` as an explicit integration source constant/path wherever current generic delivery mappings normalize only `allegro` and `shoppro`.
Extend `CarrierDeliveryMethodMappingRepository` so `listMappings`, `findByOrderMethod`, `saveMappings`, `hasMappingsForSource` and `getDistinctOrderDeliveryMethods` work for `source_system='erli'` and global `source_integration_id=0`.
Extend `ErliApiClient` with small focused methods for shipping dictionaries and external parcels:
- `getShippingMethods()`
- `getDeliveryMethods()`
- `getDeliveryVendors()`
- `getPriceLists()` / `getPriceListsDetails()` if the response is needed for operator context
- `createExternalParcel(array $payload)`
Keep request/response handling consistent with existing Erli client methods and keep runtime on `DB_HOST`, never `DB_HOST_REMOTE`.
Do not add a new table unless APPLY proves existing `carrier_delivery_method_mappings` cannot represent the mapping. If a schema change becomes unavoidable, add a migration and update DB docs in the same task.
</action>
<verify>`C:\xampp\php\php.exe -l src/Modules/Settings/ErliApiClient.php` and `C:\xampp\php\php.exe -l src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php`; repository can list/save/find Erli mappings without falling back to Allegro.</verify>
<done>AC-1 foundation complete and AC-2 persistence ready.</done>
</task>
<task type="auto">
<name>Task 2: Add Erli delivery mapping tab</name>
<files>src/Modules/Settings/ErliDeliveryMappingController.php, src/Modules/Settings/ErliIntegrationController.php, routes/web.php, resources/views/settings/erli.php, resources/lang/pl.php, DOCS/ARCHITECTURE.md</files>
<action>
Create or wire a focused Erli delivery mapping controller, following the clarity of `AllegroDeliveryMappingController` but without Allegro OAuth assumptions.
Load:
- distinct Erli delivery methods from imported orders,
- current `carrier_delivery_method_mappings` rows for `erli`,
- available local label provider services already supported by the shipment flow,
- Erli vendors/dictionaries for choosing the vendor sent to `/shipping/external`.
Add a new Erli settings tab (for example `delivery`) next to existing integration/status/settings tabs.
Saving must use POST + `_token`, `Flash`, bounded validation and redirect back to `/settings/integrations/erli?tab=delivery`.
Escape all view output with `$e()`, avoid inline CSS in the view, and do not add native `alert()`/`confirm()`.
</action>
<verify>`C:\xampp\php\php.exe -l src/Modules/Settings/ErliDeliveryMappingController.php`, `C:\xampp\php\php.exe -l resources/views/settings/erli.php`, and manual route smoke: opening `/settings/integrations/erli?tab=delivery` renders the tab even when Erli API metadata fetch fails.</verify>
<done>AC-2 satisfied; UI is consistent with Phase 129 tab pattern.</done>
</task>
<task type="auto">
<name>Task 3: Use mappings during shipment creation and sync external parcel to Erli</name>
<files>src/Modules/Shipments/ShipmentController.php, src/Modules/Settings/ErliExternalShipmentService.php, resources/views/shipments/prepare.php, routes/web.php, tests/Unit/ErliExternalShipmentServiceTest.php, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md</files>
<action>
Update shipment prepare flow so Erli orders are eligible for `CarrierDeliveryMethodMappingRepository::findByOrderMethod('erli', 0, ...)` just like Allegro/shopPRO.
Ensure the mapped provider/service can preselect existing local provider forms. Preserve existing Allegro/shopPRO behavior.
Add an `ErliExternalShipmentService` that reads the local package/order context and calls `ErliApiClient::createExternalParcel()` with:
- Erli external order id,
- mapped Erli vendor,
- tracking number from `shipment_packages`,
- status matching Erli's accepted external parcel status contract.
Trigger this service after a local provider has produced a tracking number/label-ready package (for example from `checkStatus()` and/or label-ready creation path), and make the call idempotent enough for retries by treating duplicate/already-existing responses as non-fatal where Erli exposes that signal.
Log or flash bounded warnings for Erli sync failure but never discard the local label.
Add focused unit coverage for payload building and failure behavior. If PHPUnit remains unavailable, leave the test file and document the run gap.
</action>
<verify>`C:\xampp\php\php.exe -l src/Modules/Shipments/ShipmentController.php`; `C:\xampp\php\php.exe -l src/Modules/Settings/ErliExternalShipmentService.php`; if dependencies exist, run `vendor/bin/phpunit tests/Unit/ErliExternalShipmentServiceTest.php`; manual smoke on a mapped Erli order reaches local `shipment_packages` and attempts `/shipping/external` only after tracking exists.</verify>
<done>AC-3, AC-4, AC-5 and AC-6 satisfied.</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Do not change unrelated `.vscode/ftp-kr.sync.cache.json`.
- Do not regress Allegro, shopPRO, InPost or Apaczka shipment behavior.
- Do not connect runtime application code to `DB_HOST_REMOTE`.
- Do not add inline CSS to `resources/views/...`.
- Do not add native `alert()` / `confirm()` in views.
## SCOPE LIMITS
- This plan does not implement Phase 131 carrier tracking polling, automation expansion or delivery-status cron beyond the immediate external parcel registration.
- This plan does not force creating shipments through Erli's own carrier agreement. `POST /shipping/parcels/` may be inspected during APPLY, but shipping via Erli-contract parcels is out of scope unless it is clearly just external/seller-contract compatible.
- This plan does not build product/stock sync or Erli offer management.
- This plan keeps Erli as one global integration unless a future phase explicitly introduces multi-account support.
- This plan should avoid new schema if existing generic mapping storage is enough.
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `C:\xampp\php\php.exe -l` for every changed PHP/view/test file.
- [ ] `git diff --check`.
- [ ] `vendor/bin/phpunit tests/Unit/ErliExternalShipmentServiceTest.php` if PHPUnit dependencies are available; otherwise document the environment gap.
- [ ] Attempt `sonar-scanner` after APPLY; if unavailable, document the gap in SUMMARY and STATE.
- [ ] Manual smoke: `/settings/integrations/erli?tab=delivery` renders, saves mapping with `_token`, and survives Erli API metadata errors.
- [ ] Manual smoke: a mapped Erli order preselects shipment provider/service on prepare page.
- [ ] Manual smoke: local provider label creation still stores `shipment_packages` and the Erli external parcel sync is attempted only with a tracking number.
- [ ] `DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md`, `DOCS/TECH_CHANGELOG.md` updated for changed behavior and any schema decision.
- [ ] All acceptance criteria met.
</verification>
<success_criteria>
- Erli settings has a working delivery/shipping mapping tab.
- Erli delivery mappings are stored and used by shipment prepare.
- Erli orders can generate local labels through existing providers when mapped.
- Erli receives external parcel/tracking data through native shipping API when tracking exists.
- Native Erli-contract parcel creation is either explicitly not used or documented as future/manual decision.
- Tests/lints/docs and required skill audit are completed or gaps documented.
</success_criteria>
<output>
After completion, create `.paul/phases/130-erli-shipments-labels/130-01-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,165 @@
---
phase: 130-erli-shipments-labels
plan: 01
subsystem: settings, integrations, shipments, database
tags: [erli, shipping, labels, delivery-mapping, external-parcels]
requires:
- phase: 127-erli-integration-foundation
provides: global Erli credentials and API client
- phase: 128-erli-orders-import
provides: Erli orders in common order model
- phase: 129-erli-status-mapping-sync
provides: tabbed Erli settings UI and outbound API pattern
provides:
- Erli delivery mapping tab
- Erli shipping dictionary and external parcel API methods
- Local label provider preselection for Erli orders
- External parcel registration in Erli after tracking number exists
affects: [phase-131-erli-tracking-automation, erli-settings, shipment-flow]
tech-stack:
added: []
patterns: [marketplace delivery mapping with source vendor, non-critical external parcel sync]
key-files:
created:
- database/migrations/20260516_000117_extend_delivery_mappings_for_erli_shipping.sql
- src/Modules/Settings/ErliDeliveryMappingController.php
- src/Modules/Settings/ErliExternalShipmentService.php
- tests/Unit/ErliExternalShipmentServiceTest.php
modified:
- src/Modules/Settings/ErliApiClient.php
- src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php
- src/Modules/Settings/ErliIntegrationController.php
- src/Modules/Shipments/ShipmentController.php
- routes/web.php
- resources/views/settings/erli.php
- resources/views/shipments/prepare.php
- resources/lang/pl.php
- DOCS/DB_SCHEMA.md
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
key-decisions:
- "Erli labels stay on local providers; Erli receives external parcel/tracking through POST /shipping/external."
- "Erli vendor code is stored separately from local provider service in carrier_delivery_method_mappings.source_vendor_code."
patterns-established:
- "External marketplace shipment sync is non-critical and must not block local labels."
duration: ~20min
started: 2026-05-16T00:37:00+02:00
completed: 2026-05-16T00:51:00+02:00
---
# Phase 130-01 Summary: Erli Shipments + Labels
Phase 130 adds Erli delivery mappings, local label provider preselection and external parcel registration through the native Erli shipping API without using Erli carrier-contract label flow.
## Performance
| Metric | Result |
|--------|--------|
| Duration | ~20min |
| Started | 2026-05-16T00:37:00+02:00 |
| Completed | 2026-05-16T00:51:00+02:00 |
| Tasks | 3/3 completed |
| Files changed | 18 phase files, excluding unrelated `.vscode/ftp-kr.sync.cache.json` |
## Acceptance Criteria
| AC | Result | Notes |
|----|--------|-------|
| AC-1: Erli Shipping Contract Is Used Where Safe | Pass | `ErliApiClient` now supports shipping/delivery dictionaries, vendors, price lists and `POST /shipping/external`; no native label download endpoint is assumed. |
| AC-2: Erli Delivery Mapping Tab Exists | Pass | `/settings/integrations/erli?tab=delivery` has a CSRF-protected mapping tab with imported delivery methods, Erli vendor context and local provider service choices. |
| AC-3: Erli Orders Preselect Shipment Provider | Pass | Shipment prepare includes Erli in delivery mapping lookup and preselects mapped local provider/service. |
| AC-4: Labels Are Generated Without Erli Carrier Agreement | Pass | Labels remain provider-driven through local InPost/Apaczka/Allegro WZA flow and keep writing `shipment_packages`. |
| AC-5: External Parcel Is Registered In Erli | Pass with live smoke pending | `ErliExternalShipmentService` registers external parcels only after tracking exists and logs non-critical errors. |
| AC-6: Documentation And Verification Cover The Flow | Pass with env gaps | PHP lint and diff checks passed; PHPUnit and Sonar are unavailable in this environment. |
## Accomplishments
- Extended generic carrier delivery mappings with Erli-specific source service/vendor metadata.
- Added an Erli delivery tab that maps Erli delivery methods to local label providers and Erli vendor codes.
- Added native Erli shipping dictionary and external parcel client methods.
- Wired Erli shipment preparation into the existing local label flow.
- Added non-blocking external parcel sync to Erli after local tracking numbers become available.
- Updated database, architecture and technical changelog documentation.
## Task Results
| Task | Result | Commit |
|------|--------|--------|
| Task 1: Extend Erli API and mapping repository for shipping | Done | Phase commit |
| Task 2: Add Erli delivery mapping tab | Done | Phase commit |
| Task 3: Use mappings during shipment creation and sync external parcel to Erli | Done | Phase commit |
## Files Created
| File | Purpose |
|------|---------|
| `database/migrations/20260516_000117_extend_delivery_mappings_for_erli_shipping.sql` | Adds Erli source service/vendor metadata to delivery mappings. |
| `src/Modules/Settings/ErliDeliveryMappingController.php` | Loads and saves Erli delivery mapping tab data. |
| `src/Modules/Settings/ErliExternalShipmentService.php` | Registers local tracking numbers as Erli external parcels. |
| `tests/Unit/ErliExternalShipmentServiceTest.php` | Focused unit coverage for Erli external parcel sync behavior. |
## Files Modified
| File | Purpose |
|------|---------|
| `src/Modules/Settings/ErliApiClient.php` | Added shipping dictionaries, price lists and external parcel API calls. |
| `src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php` | Added Erli source support and source vendor/service persistence. |
| `src/Modules/Settings/ErliIntegrationController.php` | Added delivery tab data and tab routing. |
| `src/Modules/Shipments/ShipmentController.php` | Uses Erli delivery mappings and triggers external parcel sync after tracking exists. |
| `routes/web.php` | Wires Erli delivery save route and external shipment service dependencies. |
| `resources/views/settings/erli.php` | Adds Erli delivery tab UI. |
| `resources/views/shipments/prepare.php` | Preselects mapped local provider/service for Erli shipments. |
| `resources/lang/pl.php` | Adds Polish labels for Erli delivery mapping UI. |
| `DOCS/DB_SCHEMA.md` | Documents mapping columns. |
| `DOCS/ARCHITECTURE.md` | Documents Erli shipment flow. |
| `DOCS/TECH_CHANGELOG.md` | Records Phase 130 technical changes. |
## Decisions Made
| Decision | Rationale |
|----------|-----------|
| Use local label providers for Erli labels | Operator does not want to ship on Erli's carrier agreement; existing provider labels are the source of truth. |
| Register Erli parcels through `POST /shipping/external` | Official Erli API supports external parcels with order id, vendor, status and tracking number. |
| Store Erli vendor separately from local provider service | Erli vendor and local provider/service are different contracts and should not overload the same field. |
| Make Erli external parcel sync non-critical | Local label generation must survive Erli API failures. |
## Deviations
| Type | Description | Impact |
|------|-------------|--------|
| Scope addition | Added a small migration because the existing mapping schema could not cleanly store Erli vendor separately from the local provider service. | Low risk, improves contract clarity. |
| Implementation detail | Native InPost service data is preferred for Erli/InPost mappings where available, with existing Allegro WZA filtered service list as fallback. | Keeps Erli local-provider flow independent from Allegro where possible. |
| Verification gap | PHPUnit binary is missing and `sonar-scanner` is not available in PATH. | Tests exist but could not be executed locally. |
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Erli `deliveryVendors` dictionary can return scalar rows | Normalization now preserves scalar values as both id and name. |
| MySQL `CREATE INDEX IF NOT EXISTS` support is environment-sensitive | Migration avoids that syntax and only adds required columns. |
| External parcel duplicate behavior could not be live-tested | Service stores successful sync payload and treats sync failures as non-critical activity entries. |
## Verification
| Check | Result |
|-------|--------|
| PHP syntax lint for changed PHP/view/test files | Passed |
| `git diff --check -- . ':!.vscode/ftp-kr.sync.cache.json'` | Passed |
| `vendor/bin/phpunit tests/Unit/ErliExternalShipmentServiceTest.php` | Not run: `vendor/bin/phpunit` missing |
| `sonar-scanner --version` | Not run: command unavailable in PATH |
| Manual Erli delivery tab smoke | Pending operator after migration/live configuration |
| Manual Erli label + external parcel smoke | Pending operator after migration/live configuration |
## Next Phase Readiness
Phase 131 can build on:
- Erli delivery mappings with local provider and Erli vendor metadata.
- Local shipment packages with tracking numbers.
- Non-critical Erli external parcel sync payloads stored in `shipment_packages.payload_json`.
Remaining concerns for Phase 131:
- Live Erli credentials, migration execution and browser smoke are still pending operator environment.
- Delivery tracking automation should decide how to poll/update delivery status after the external parcel exists.
- Duplicate external parcel semantics should be confirmed against live Erli responses.
No blocker prevents planning Phase 131.

View File

@@ -84,6 +84,8 @@ HTTP Request
5. **Render**`resources/views/statistics/summary.php` renders filters, chart JSON, two canvas targets, and table fallbacks. 5. **Render**`resources/views/statistics/summary.php` renders filters, chart JSON, two canvas targets, and table fallbacks.
### Shipment Flow ### 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.
1. **Create**`ShipmentController::create()``ShipmentProviderRegistry` → carrier `ShipmentService::createShipment()``ShipmentPackageRepository::insert()` 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 → `ShipmentPackageRepository::updateDeliveryStatus()`
@@ -121,12 +123,14 @@ HTTP Request
### Erli Integration Foundation ### Erli Integration Foundation
1. **Settings** - `/settings/integrations/erli` renders tabbed integration/status/settings panels and stores one global Erli API key encrypted via `IntegrationSecretCipher`, an optional account label, active flag, and last connection-test result. 1. **Settings** - `/settings/integrations/erli` renders tabbed integration/status/delivery/settings panels and stores one global Erli API key encrypted via `IntegrationSecretCipher`, an optional account label, active flag, and last connection-test result.
2. **Connection test** - `ErliIntegrationController::test()` loads active credentials, calls `ErliApiClient::testConnection()`, performs a real authenticated `GET https://erli.pl/svc/shop-api/inbox`, and stores the result in `integrations.last_test_*`. 2. **Connection test** - `ErliIntegrationController::test()` loads active credentials, calls `ErliApiClient::testConnection()`, performs a real authenticated `GET https://erli.pl/svc/shop-api/inbox`, and stores the result in `integrations.last_test_*`.
3. **Hub** - `IntegrationsHubController::buildErliRow()` adds Erli to `/settings/integrations` with configured/missing secret status, active status, last test timestamp, and configure URL. 3. **Hub** - `IntegrationsHubController::buildErliRow()` adds Erli to `/settings/integrations` with configured/missing secret status, active status, last test timestamp, and configure URL.
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. 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`. 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. **Deferred** - Label generation, shipment creation, and tracking are planned for v3.8 Phases 130-131. 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.
## Dependency Injection ## Dependency Injection
@@ -187,15 +191,25 @@ tests/
- `testConnection()` wykonuje realny `GET /inbox` do Erli z naglowkiem `Authorization: Bearer ...`. - `testConnection()` wykonuje realny `GET /inbox` do Erli z naglowkiem `Authorization: Bearer ...`.
- Phase 128: `fetchInbox()` pobiera do 500 nieprzeczytanych wiadomosci; `markInboxRead()` potwierdza `POST /inbox/mark-read` z `lastMessageId` dopiero po udanym batchu. - Phase 128: `fetchInbox()` pobiera do 500 nieprzeczytanych wiadomosci; `markInboxRead()` potwierdza `POST /inbox/mark-read` z `lastMessageId` dopiero po udanym batchu.
- Phase 129: `updateOrderStatus()` wysyla `PATCH /orders/{id}/status` z body `{"status": "..."}` dla recznych zmian statusu orderPRO mapowanych na status Erli. - Phase 129: `updateOrderStatus()` wysyla `PATCH /orders/{id}/status` z body `{"status": "..."}` dla recznych zmian statusu orderPRO mapowanych na status Erli.
- Phase 130: `getShippingMethods()`, `getDeliveryMethods()`, `getDeliveryVendors()`, `getPriceLists()`, `getPriceListsDetails()` i `createExternalParcel()` obsluguja natywne shipping API Erli bez wymuszania nadawania przez umowe Erli.
- Wysyla `Accept: application/json` i `User-Agent: orderPRO/1.0 (erli-integration)`. - Wysyla `Accept: application/json` i `User-Agent: orderPRO/1.0 (erli-integration)`.
- Traktuje HTTP 2xx jako sukces; 401/403 jako blad autoryzacji, 429 jako limit zapytan, pozostale bledy jako czytelny komunikat z odpowiedzi. - Traktuje HTTP 2xx jako sukces; 401/403 jako blad autoryzacji, 429 jako limit zapytan, pozostale bledy jako czytelny komunikat z odpowiedzi.
- Uzywa `SslCertificateResolver` i nie wywoluje `curl_close()` (PHP 8.5 compatible). - Uzywa `SslCertificateResolver` i nie wywoluje `curl_close()` (PHP 8.5 compatible).
### ErliIntegrationController (`src/Modules/Settings/ErliIntegrationController.php`) ### ErliIntegrationController (`src/Modules/Settings/ErliIntegrationController.php`)
- Endpointy: `GET /settings/integrations/erli`, `POST /settings/integrations/erli/save`, `POST /settings/integrations/erli/test`, `POST /settings/integrations/erli/import`, `POST /settings/integrations/erli/statuses/save-pull`, `POST /settings/integrations/erli/statuses/save-push`. - Endpointy: `GET /settings/integrations/erli`, `POST /settings/integrations/erli/save`, `POST /settings/integrations/erli/test`, `POST /settings/integrations/erli/import`, `POST /settings/integrations/erli/statuses/save-pull`, `POST /settings/integrations/erli/statuses/save-push`, `POST /settings/integrations/erli/delivery/save`.
- `save` zapisuje label, aktywnosc, sekret, ustawienia importu (`orders_fetch_enabled`, `orders_fetch_start_date`, interwal crona) oraz kierunek/interwal `erli_status_sync`; `test` wykonuje realny test API i zapisuje wynik przez `IntegrationsRepository::updateTestResult()`. - `save` zapisuje label, aktywnosc, sekret, ustawienia importu (`orders_fetch_enabled`, `orders_fetch_start_date`, interwal crona) oraz kierunek/interwal `erli_status_sync`; `test` wykonuje realny test API i zapisuje wynik przez `IntegrationsRepository::updateTestResult()`.
- `importNow()` uruchamia reczny import Erli z pominieciem flagi cron enable, ale nadal wymaga aktywnych credentials. - `importNow()` uruchamia reczny import Erli z pominieciem flagi cron enable, ale nadal wymaga aktywnych credentials.
### ErliDeliveryMappingController (`src/Modules/Settings/ErliDeliveryMappingController.php`)
- Buduje dane zakladki Dostawy: metody z `orders.external_carrier_id` dla `source='erli'`, aktualne `carrier_delivery_method_mappings`, slowniki vendorow/metod z Erli oraz lokalne uslugi InPost/Apaczka.
- `saveDeliveryMappings()` zapisuje mapowanie globalne `source_system='erli'`, `source_integration_id=0` z lokalnym providerem oraz `source_vendor_code` wymaganym przez Erli `POST /shipping/external`.
### ErliExternalShipmentService (`src/Modules/Settings/ErliExternalShipmentService.php`)
- `syncPackage(int $packageId)` sprawdza, czy paczka nalezy do zamowienia Erli i ma tracking number.
- Pobiera vendor Erli z mapowania dostawy albo proboje go wywnioskowac z lokalnego providera/carrier id.
- Wysyla `POST /shipping/external` z `orderId`, `vendor`, `status='sent'`, `trackingNumber`; sukces zapisuje w `shipment_packages.payload_json.erli_external_parcel`, blad trafia do activity log jako niekrytyczny.
### ErliOrdersSyncService / ErliOrderMapper (`src/Modules/Settings/`) ### ErliOrdersSyncService / ErliOrderMapper (`src/Modules/Settings/`)
- `ErliOrdersSyncService::sync()` jest wspolnym entrypointem dla crona i importu recznego. Zwraca liczniki `processed`, `imported_created`, `imported_updated`, `failed`, `skipped`, `acknowledged`. - `ErliOrdersSyncService::sync()` jest wspolnym entrypointem dla crona i importu recznego. Zwraca liczniki `processed`, `imported_created`, `imported_updated`, `failed`, `skipped`, `acknowledged`.
- Obsluguje tylko zdarzenia order inbox (`orderCreated`, `orderStatusChanged`, `orderSellerStatusChanged`); wiadomosci produktowe sa pomijane do przyszlych faz. - Obsluguje tylko zdarzenia order inbox (`orderCreated`, `orderStatusChanged`, `orderSellerStatusChanged`); wiadomosci produktowe sa pomijane do przyszlych faz.

View File

@@ -1,6 +1,6 @@
# Database Schema # Database Schema
**Updated:** 2026-05-15 | **Total tables:** 63 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci **Updated:** 2026-05-16 | **Total tables:** 63 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci
--- ---
@@ -385,6 +385,26 @@ UNIQUE: `(integration_id, shoppro_status_code)`
## Shipments & Delivery ## Shipments & Delivery
**carrier_delivery_method_mappings** — Map marketplace delivery methods to local shipment providers
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `source_system` | VARCHAR(32) | NO | `allegro`, `shoppro`, `erli` |
| `source_integration_id` | INT UNSIGNED | NO | `0` for global Allegro/Erli, shopPRO integration id for shopPRO |
| `order_delivery_method` | VARCHAR(200) | NO | Delivery method label/code from imported order |
| `source_service_id` | VARCHAR(128) | YES | Source delivery/shipping method id when available; used by Erli shipping dictionaries |
| `source_vendor_code` | VARCHAR(64) | YES | Source carrier/vendor code; Erli uses this for `POST /shipping/external.vendor` |
| `provider` | VARCHAR(50) | NO | Local shipment provider, e.g. `allegro_wza`, `inpost`, `apaczka` |
| `provider_service_id` | VARCHAR(128) | NO | Local provider service id |
| `provider_account_id` | VARCHAR(128) | YES | Provider account/credentials id when required |
| `provider_carrier_id` | VARCHAR(128) | YES | Provider carrier code/id when required |
| `provider_service_name` | VARCHAR(255) | YES | Display snapshot of provider service name |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
UNIQUE: `(source_system, source_integration_id, order_delivery_method)`.
Phase 130 adds `source_service_id` and `source_vendor_code`; no new table is required for Erli delivery mappings.
**shipment_packages** — Prepared shipments with tracking **shipment_packages** — Prepared shipments with tracking
| Column | Type | Nullable | Notes | | Column | Type | Nullable | Notes |
|--------|------|----------|-------| |--------|------|----------|-------|
@@ -414,7 +434,7 @@ UNIQUE: `(integration_id, shoppro_status_code)`
| `sender_point_id` | VARCHAR(64) | YES | | | `sender_point_id` | VARCHAR(64) | YES | |
| `reference_number` | VARCHAR(128) | YES | | | `reference_number` | VARCHAR(128) | YES | |
| `error_message` | TEXT | YES | | | `error_message` | TEXT | YES | |
| `payload_json` | JSON | YES | | | `payload_json` | JSON | YES | Provider request/response snapshot; Phase 130 may add `erli_external_parcel` after successful Erli external shipment registration |
| `created_at` | DATETIME | NO | | | `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | | | `updated_at` | DATETIME | NO | |

View File

@@ -1,5 +1,21 @@
# Technical Changelog # Technical Changelog
## 2026-05-16 - Phase 130 Plan 01: Erli Shipments + Labels
**Co zrobiono:**
- Rozszerzono `carrier_delivery_method_mappings` o `source_service_id` i `source_vendor_code`, zeby mapowanie Erli przechowywalo osobno metode marketplace i vendora wymaganego przez `POST /shipping/external`.
- Dodano zakladke Dostawy w `/settings/integrations/erli` oraz `ErliDeliveryMappingController` do mapowania metod z zamowien Erli na lokalne uslugi InPost/Apaczka.
- Rozszerzono `ErliApiClient` o slowniki shipping/delivery/vendor/cenniki i tworzenie paczek zewnetrznych przez `POST /shipping/external`.
- `ShipmentController` uzywa mapowan Erli przy przygotowaniu przesylki i po uzyskaniu tracking number wywoluje `ErliExternalShipmentService`.
- `ErliExternalShipmentService` rejestruje paczke zewnetrzna w Erli, zapisuje odpowiedz w `shipment_packages.payload_json.erli_external_parcel` i loguje bledy jako niekrytyczne.
**Dlaczego:**
- Operator chce nadawac zamowienia Erli z orderPRO, ale bez wymuszania nadawania na umowie Erli. Etykiety pozostaja u lokalnych providerow, a Erli dostaje informację o zewnetrznej paczce/tracking number.
**BREAKING / migracja:**
- Wymagana migracja `20260516_000117_extend_delivery_mappings_for_erli_shipping.sql`.
- Brak breaking changes w istniejacych mapowaniach Allegro/shopPRO; nowe kolumny sa opcjonalne.
## 2026-05-16 - Erli Settings Tabs UI Fix ## 2026-05-16 - Erli Settings Tabs UI Fix
**Co zrobiono:** **Co zrobiono:**

View File

@@ -0,0 +1,3 @@
ALTER TABLE carrier_delivery_method_mappings
ADD COLUMN IF NOT EXISTS source_service_id VARCHAR(128) NULL AFTER order_delivery_method,
ADD COLUMN IF NOT EXISTS source_vendor_code VARCHAR(64) NULL AFTER source_service_id;

View File

@@ -867,6 +867,7 @@ return [
'label' => 'Zakladki integracji Erli', 'label' => 'Zakladki integracji Erli',
'integration' => 'Integracja', 'integration' => 'Integracja',
'statuses' => 'Statusy', 'statuses' => 'Statusy',
'delivery' => 'Dostawy',
'settings' => 'Ustawienia', 'settings' => 'Ustawienia',
], ],
'config' => [ 'config' => [
@@ -925,6 +926,29 @@ return [
'save_failed' => 'Nie udalo sie zapisac mapowan statusow Erli.', 'save_failed' => 'Nie udalo sie zapisac mapowan statusow Erli.',
], ],
], ],
'delivery' => [
'title' => 'Mapowanie dostaw Erli',
'description' => 'Przypisz metody dostawy z zamowien Erli do lokalnych providerow etykiet oraz vendora Erli uzywanego przy rejestracji paczki zewnetrznej.',
'metadata_not_connected' => 'Zapisz aktywna konfiguracje Erli, aby pobrac slowniki dostaw i vendorow.',
'empty_methods' => 'Brak metod dostawy Erli z zaimportowanych zamowien.',
'shipping_methods_title' => 'Slownik metod wysylki Erli',
'fields' => [
'order_method' => 'Metoda z zamowienia',
'erli_vendor' => 'Vendor Erli',
'provider_service' => 'Lokalna usluga etykiety',
'no_vendor' => '-- wybierz vendora --',
'no_provider' => '-- nie generuj etykiety --',
'shipping_method' => 'Metoda Erli',
'shipping_vendor' => 'Vendor',
],
'actions' => [
'save' => 'Zapisz mapowanie dostaw',
],
'flash' => [
'saved' => 'Mapowanie dostaw Erli zostalo zapisane.',
'save_failed' => 'Nie udalo sie zapisac mapowania dostaw Erli.',
],
],
'status' => [ 'status' => [
'secret' => 'Sekret API', 'secret' => 'Sekret API',
'active' => 'Aktywna', 'active' => 'Aktywna',

View File

@@ -15,6 +15,20 @@ $statusSyncIntervalMinutes = (int) ($statusSyncIntervalMinutes ?? 15);
$orderproStatuses = is_array($orderproStatuses ?? null) ? $orderproStatuses : []; $orderproStatuses = is_array($orderproStatuses ?? null) ? $orderproStatuses : [];
$erliStatusMappings = is_array($erliStatusMappings ?? null) ? $erliStatusMappings : []; $erliStatusMappings = is_array($erliStatusMappings ?? null) ? $erliStatusMappings : [];
$erliPullStatusMappings = is_array($erliPullStatusMappings ?? null) ? $erliPullStatusMappings : []; $erliPullStatusMappings = is_array($erliPullStatusMappings ?? null) ? $erliPullStatusMappings : [];
$erliDeliveryOrderMethods = is_array($erliDeliveryOrderMethods ?? null) ? $erliDeliveryOrderMethods : [];
$erliDeliveryMappings = is_array($erliDeliveryMappings ?? null) ? $erliDeliveryMappings : [];
$erliShippingMethods = is_array($erliShippingMethods ?? null) ? $erliShippingMethods : [];
$erliDeliveryVendors = is_array($erliDeliveryVendors ?? null) ? $erliDeliveryVendors : [];
$inpostDeliveryServices = is_array($inpostDeliveryServices ?? null) ? $inpostDeliveryServices : [];
$apaczkaDeliveryServices = is_array($apaczkaDeliveryServices ?? null) ? $apaczkaDeliveryServices : [];
$erliDeliveryMetadataError = trim((string) ($erliDeliveryMetadataError ?? ''));
$erliDeliveryMappingsByMethod = [];
foreach ($erliDeliveryMappings as $mappingRow) {
$methodKey = trim((string) ($mappingRow['order_delivery_method'] ?? ''));
if ($methodKey !== '') {
$erliDeliveryMappingsByMethod[$methodKey] = $mappingRow;
}
}
$activeTab = (string) ($activeTab ?? 'integration'); $activeTab = (string) ($activeTab ?? 'integration');
?> ?>
@@ -47,6 +61,9 @@ $activeTab = (string) ($activeTab ?? 'integration');
<button type="button" class="content-tab-btn<?= $activeTab === 'statuses' ? ' is-active' : '' ?>" data-tab-target="erli-tab-statuses"> <button type="button" class="content-tab-btn<?= $activeTab === 'statuses' ? ' is-active' : '' ?>" data-tab-target="erli-tab-statuses">
<?= $e($t('settings.erli.tabs.statuses')) ?> <?= $e($t('settings.erli.tabs.statuses')) ?>
</button> </button>
<button type="button" class="content-tab-btn<?= $activeTab === 'delivery' ? ' is-active' : '' ?>" data-tab-target="erli-tab-delivery">
<?= $e($t('settings.erli.tabs.delivery')) ?>
</button>
<button type="button" class="content-tab-btn<?= $activeTab === 'settings' ? ' is-active' : '' ?>" data-tab-target="erli-tab-settings"> <button type="button" class="content-tab-btn<?= $activeTab === 'settings' ? ' is-active' : '' ?>" data-tab-target="erli-tab-settings">
<?= $e($t('settings.erli.tabs.settings')) ?> <?= $e($t('settings.erli.tabs.settings')) ?>
</button> </button>
@@ -261,6 +278,149 @@ $activeTab = (string) ($activeTab ?? 'integration');
</div> </div>
<div class="content-tab-panel<?= $activeTab === 'delivery' ? ' is-active' : '' ?>" data-tab-panel="erli-tab-delivery">
<section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.erli.delivery.title')) ?></h3>
<p class="muted mt-12"><?= $e($t('settings.erli.delivery.description')) ?></p>
<?php if ($erliDeliveryMetadataError !== ''): ?>
<div class="mt-12"><?php $type='warning'; $message=(string) $erliDeliveryMetadataError; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
<?php endif; ?>
<form action="/settings/integrations/erli/delivery/save" method="post" class="mt-12">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.erli.delivery.fields.order_method')) ?></th>
<th><?= $e($t('settings.erli.delivery.fields.erli_vendor')) ?></th>
<th><?= $e($t('settings.erli.delivery.fields.provider_service')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($erliDeliveryOrderMethods === []): ?>
<tr>
<td colspan="3" class="muted"><?= $e($t('settings.erli.delivery.empty_methods')) ?></td>
</tr>
<?php else: ?>
<?php foreach ($erliDeliveryOrderMethods as $orderMethod): ?>
<?php
$orderMethod = trim((string) $orderMethod);
if ($orderMethod === '') continue;
$mappingRow = is_array($erliDeliveryMappingsByMethod[$orderMethod] ?? null) ? $erliDeliveryMappingsByMethod[$orderMethod] : [];
$selectedVendor = trim((string) ($mappingRow['source_vendor_code'] ?? ''));
$selectedProvider = trim((string) ($mappingRow['provider'] ?? ''));
$selectedProviderServiceId = trim((string) ($mappingRow['provider_service_id'] ?? ''));
$selectedProviderCarrierId = trim((string) ($mappingRow['provider_carrier_id'] ?? ''));
?>
<tr>
<td>
<strong><?= $e($orderMethod) ?></strong>
<input type="hidden" name="order_delivery_method[]" value="<?= $e($orderMethod) ?>">
<input type="hidden" name="source_service_id[]" value="<?= $e((string) ($mappingRow['source_service_id'] ?? '')) ?>">
</td>
<td>
<select class="form-control" name="source_vendor_code[]">
<option value=""><?= $e($t('settings.erli.delivery.fields.no_vendor')) ?></option>
<?php foreach ($erliDeliveryVendors as $vendor): ?>
<?php
$vendorCode = trim((string) ($vendor['id'] ?? $vendor['code'] ?? $vendor['vendor'] ?? ''));
if ($vendorCode === '') continue;
$vendorName = trim((string) ($vendor['name'] ?? $vendorCode));
?>
<option value="<?= $e($vendorCode) ?>"<?= $selectedVendor === $vendorCode ? ' selected' : '' ?>>
<?= $e($vendorName) ?> (<?= $e($vendorCode) ?>)
</option>
<?php endforeach; ?>
</select>
</td>
<td>
<select class="form-control" name="provider_service_choice[]">
<option value=""><?= $e($t('settings.erli.delivery.fields.no_provider')) ?></option>
<?php if ($inpostDeliveryServices !== []): ?>
<optgroup label="InPost">
<?php foreach ($inpostDeliveryServices as $service): ?>
<?php
$serviceId = trim((string) ($service['id'] ?? ''));
if ($serviceId === '') continue;
$serviceName = trim((string) ($service['name'] ?? $serviceId));
$carrierId = 'inpost';
$choice = 'inpost|' . $serviceId . '|' . $carrierId . '|' . $serviceName;
$isSelected = $selectedProvider === 'inpost' && $selectedProviderServiceId === $serviceId;
?>
<option value="<?= $e($choice) ?>"<?= $isSelected ? ' selected' : '' ?>>
<?= $e($serviceName) ?>
</option>
<?php endforeach; ?>
</optgroup>
<?php endif; ?>
<?php if ($apaczkaDeliveryServices !== []): ?>
<optgroup label="Apaczka">
<?php foreach ($apaczkaDeliveryServices as $service): ?>
<?php
if (!is_array($service)) continue;
$serviceId = trim((string) ($service['service_id'] ?? $service['id'] ?? ''));
if ($serviceId === '') continue;
$serviceName = trim((string) ($service['name'] ?? ('ID ' . $serviceId)));
$carrierId = trim((string) ($service['carrier_code'] ?? ''));
$choice = 'apaczka|' . $serviceId . '|' . $carrierId . '|' . $serviceName;
$isSelected = $selectedProvider === 'apaczka'
&& $selectedProviderServiceId === $serviceId
&& ($selectedProviderCarrierId === '' || $selectedProviderCarrierId === $carrierId);
?>
<option value="<?= $e($choice) ?>"<?= $isSelected ? ' selected' : '' ?>>
<?= $e($serviceName) ?><?= $carrierId !== '' ? ' [' . $e($carrierId) . ']' : '' ?>
</option>
<?php endforeach; ?>
</optgroup>
<?php endif; ?>
</select>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($erliDeliveryOrderMethods !== []): ?>
<div class="form-actions mt-12">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.erli.delivery.actions.save')) ?></button>
</div>
<?php endif; ?>
</form>
<?php if ($erliShippingMethods !== []): ?>
<h3 class="section-title mt-16"><?= $e($t('settings.erli.delivery.shipping_methods_title')) ?></h3>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.erli.delivery.fields.shipping_method')) ?></th>
<th><?= $e($t('settings.erli.delivery.fields.shipping_vendor')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($erliShippingMethods as $method): ?>
<?php
$methodId = trim((string) ($method['id'] ?? $method['typeId'] ?? ''));
$methodName = trim((string) ($method['name'] ?? $methodId));
$methodVendor = trim((string) ($method['vendor'] ?? ''));
if ($methodId === '' && $methodName === '') continue;
?>
<tr>
<td><?= $e($methodName) ?><?= $methodId !== '' ? ' <code class="muted">' . $e($methodId) . '</code>' : '' ?></td>
<td><?= $e($methodVendor !== '' ? $methodVendor : '-') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
</div>
<div class="content-tab-panel<?= $activeTab === 'settings' ? ' is-active' : '' ?>" data-tab-panel="erli-tab-settings"> <div class="content-tab-panel<?= $activeTab === 'settings' ? ' is-active' : '' ?>" data-tab-panel="erli-tab-settings">
<section class="mt-16"> <section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.erli.import.title')) ?></h3> <h3 class="section-title"><?= $e($t('settings.erli.import.title')) ?></h3>
@@ -315,6 +475,7 @@ $activeTab = (string) ($activeTab ?? 'integration');
var tabNameMap = { var tabNameMap = {
'erli-tab-integration': 'integration', 'erli-tab-integration': 'integration',
'erli-tab-statuses': 'statuses', 'erli-tab-statuses': 'statuses',
'erli-tab-delivery': 'delivery',
'erli-tab-settings': 'settings' 'erli-tab-settings': 'settings'
}; };

View File

@@ -19,7 +19,11 @@ $mappedCredentialsId = trim((string) ($mapping['provider_account_id'] ?? ''));
$mappedCarrierId = trim((string) ($mapping['provider_carrier_id'] ?? '')); $mappedCarrierId = trim((string) ($mapping['provider_carrier_id'] ?? ''));
$mappedProvider = trim((string) ($mapping['provider'] ?? '')); $mappedProvider = trim((string) ($mapping['provider'] ?? ''));
$mappedServiceName = trim((string) ($mapping['provider_service_name'] ?? '')); $mappedServiceName = trim((string) ($mapping['provider_service_name'] ?? ''));
$mappedCarrier = $mappedProvider === 'apaczka' ? 'apaczka' : 'allegro'; $mappedCarrier = match ($mappedProvider) {
'apaczka' => 'apaczka',
'inpost' => 'inpost',
default => 'allegro',
};
if ($mappedCarrier !== 'apaczka' && stripos($mappedCarrierId, 'inpost') !== false) { if ($mappedCarrier !== 'apaczka' && stripos($mappedCarrierId, 'inpost') !== false) {
$mappedCarrier = 'inpost'; $mappedCarrier = 'inpost';
} }
@@ -144,16 +148,16 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
<div id="shipment-inpost-panel" style="<?= $preselectedCarrier !== 'inpost' ? 'display:none' : '' ?>"> <div id="shipment-inpost-panel" style="<?= $preselectedCarrier !== 'inpost' ? 'display:none' : '' ?>">
<?php if ($inpostSvcList === []): ?> <?php if ($inpostSvcList === []): ?>
<div class="muted">Brak uslug InPost (sprawdz polaczenie z Allegro).</div> <div class="muted">Brak uslug InPost (sprawdz konfiguracje InPost).</div>
<?php else: ?> <?php else: ?>
<select class="form-control" id="shipment-inpost-select"> <select class="form-control" id="shipment-inpost-select">
<option value="">-- Wybierz usluge InPost --</option> <option value="">-- Wybierz usluge InPost --</option>
<?php foreach ($inpostSvcList as $inSvc): ?> <?php foreach ($inpostSvcList as $inSvc): ?>
<?php <?php
$inSvcId = is_array($inSvc['id'] ?? null) ? $inSvc['id'] : []; $inSvcId = is_array($inSvc['id'] ?? null) ? $inSvc['id'] : [];
$inSvcMethodId = trim((string) ($inSvcId['deliveryMethodId'] ?? '')); $inSvcMethodId = trim((string) ($inSvcId['deliveryMethodId'] ?? ($inSvc['id'] ?? '')));
$inSvcCredentialsId = trim((string) ($inSvcId['credentialsId'] ?? '')); $inSvcCredentialsId = trim((string) ($inSvcId['credentialsId'] ?? ''));
$inSvcCarrierId = trim((string) ($inSvc['carrierId'] ?? '')); $inSvcCarrierId = trim((string) ($inSvc['carrierId'] ?? 'inpost'));
$inSvcName = trim((string) ($inSvc['name'] ?? '')); $inSvcName = trim((string) ($inSvc['name'] ?? ''));
$inSvcSelected = $mappedCarrier === 'inpost' && $mappedMethodId === $inSvcMethodId; $inSvcSelected = $mappedCarrier === 'inpost' && $mappedMethodId === $inSvcMethodId;
?> ?>
@@ -214,7 +218,7 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
<input type="hidden" name="credentials_id" id="shipment-credentials-id" value=""> <input type="hidden" name="credentials_id" id="shipment-credentials-id" value="">
<input type="hidden" name="carrier_id" id="shipment-carrier-id" value=""> <input type="hidden" name="carrier_id" id="shipment-carrier-id" value="">
<input type="hidden" name="provider_code" id="shipment-provider-code" value="<?= $e($preselectedCarrier === 'apaczka' ? 'apaczka' : 'allegro_wza') ?>"> <input type="hidden" name="provider_code" id="shipment-provider-code" value="<?= $e($preselectedCarrier === 'apaczka' ? 'apaczka' : ($preselectedCarrier === 'inpost' ? 'inpost' : 'allegro_wza')) ?>">
<label class="form-field"> <label class="form-field">
<span class="field-label">Typ paczki</span> <span class="field-label">Typ paczki</span>
@@ -615,7 +619,7 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
if (inpostPanel) inpostPanel.style.display = carrier === 'inpost' ? '' : 'none'; if (inpostPanel) inpostPanel.style.display = carrier === 'inpost' ? '' : 'none';
if (apaczkaPanel) apaczkaPanel.style.display = carrier === 'apaczka' ? '' : 'none'; if (apaczkaPanel) apaczkaPanel.style.display = carrier === 'apaczka' ? '' : 'none';
if (emptyPanel) emptyPanel.style.display = carrier === '' ? '' : 'none'; if (emptyPanel) emptyPanel.style.display = carrier === '' ? '' : 'none';
if (providerInput) providerInput.value = carrier === 'apaczka' ? 'apaczka' : 'allegro_wza'; if (providerInput) providerInput.value = carrier === 'apaczka' ? 'apaczka' : (carrier === 'inpost' ? 'inpost' : 'allegro_wza');
} }
var weekendWrap = document.getElementById('shipment-apaczka-weekend-wrap'); var weekendWrap = document.getElementById('shipment-apaczka-weekend-wrap');
@@ -655,7 +659,7 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
hiddenInput.value = inpostSelect.value; hiddenInput.value = inpostSelect.value;
credentialsInput.value = opt ? (opt.getAttribute('data-credentials-id') || '') : ''; credentialsInput.value = opt ? (opt.getAttribute('data-credentials-id') || '') : '';
carrierInput.value = opt ? (opt.getAttribute('data-carrier-id') || '') : ''; carrierInput.value = opt ? (opt.getAttribute('data-carrier-id') || '') : '';
if (providerInput) providerInput.value = 'allegro_wza'; if (providerInput) providerInput.value = 'inpost';
} }
inpostSelect.addEventListener('change', syncInpostFields); inpostSelect.addEventListener('change', syncInpostFields);
if (carrierSelect.value === 'inpost' && inpostSelect.value !== '') { if (carrierSelect.value === 'inpost' && inpostSelect.value !== '') {

View File

@@ -30,6 +30,8 @@ use App\Modules\Settings\ApaczkaIntegrationController;
use App\Modules\Settings\ApaczkaIntegrationRepository; use App\Modules\Settings\ApaczkaIntegrationRepository;
use App\Modules\Settings\CarrierDeliveryMethodMappingRepository; use App\Modules\Settings\CarrierDeliveryMethodMappingRepository;
use App\Modules\Settings\ErliApiClient; use App\Modules\Settings\ErliApiClient;
use App\Modules\Settings\ErliDeliveryMappingController;
use App\Modules\Settings\ErliExternalShipmentService;
use App\Modules\Settings\ErliIntegrationController; use App\Modules\Settings\ErliIntegrationController;
use App\Modules\Settings\ErliIntegrationRepository; use App\Modules\Settings\ErliIntegrationRepository;
use App\Modules\Settings\ErliOrderMapper; use App\Modules\Settings\ErliOrderMapper;
@@ -397,19 +399,6 @@ return static function (Application $app): void {
$automationService, $automationService,
$erliPullStatusMappingRepository $erliPullStatusMappingRepository
); );
$erliIntegrationController = new ErliIntegrationController(
$template,
$translator,
$auth,
$erliIntegrationRepository,
new ErliApiClient(),
new IntegrationsRepository($app->db()),
$cronRepository,
$erliOrdersSyncService,
$app->orderStatuses(),
$erliStatusMappingRepository,
$erliPullStatusMappingRepository
);
$allegroIntegrationController = new AllegroIntegrationController( $allegroIntegrationController = new AllegroIntegrationController(
$template, $template,
$translator, $translator,
@@ -498,6 +487,35 @@ return static function (Application $app): void {
$apaczkaShipmentService, $apaczkaShipmentService,
$inpostShipmentService, $inpostShipmentService,
]); ]);
$erliDeliveryMappingController = new ErliDeliveryMappingController(
$translator,
$carrierDeliveryMappings,
$erliIntegrationRepository,
new ErliApiClient(),
$inpostShipmentService,
$apaczkaShipmentService
);
$erliExternalShipmentService = new ErliExternalShipmentService(
$erliIntegrationRepository,
new ErliApiClient(),
$carrierDeliveryMappings,
$shipmentPackageRepository,
new OrdersRepository($app->db())
);
$erliIntegrationController = new ErliIntegrationController(
$template,
$translator,
$auth,
$erliIntegrationRepository,
new ErliApiClient(),
new IntegrationsRepository($app->db()),
$cronRepository,
$erliOrdersSyncService,
$app->orderStatuses(),
$erliStatusMappingRepository,
$erliPullStatusMappingRepository,
$erliDeliveryMappingController
);
$shipmentController = new ShipmentController( $shipmentController = new ShipmentController(
$template, $template,
$translator, $translator,
@@ -509,7 +527,8 @@ return static function (Application $app): void {
$automationService, $automationService,
$app->basePath('storage'), $app->basePath('storage'),
$carrierDeliveryMappings, $carrierDeliveryMappings,
$printJobRepository $printJobRepository,
$erliExternalShipmentService
); );
$authMiddleware = new AuthMiddleware($auth); $authMiddleware = new AuthMiddleware($auth);
@@ -660,6 +679,7 @@ return static function (Application $app): void {
$router->post('/settings/integrations/erli/import', [$erliIntegrationController, 'importNow'], [$authMiddleware]); $router->post('/settings/integrations/erli/import', [$erliIntegrationController, 'importNow'], [$authMiddleware]);
$router->post('/settings/integrations/erli/statuses/save-pull', [$erliIntegrationController, 'savePullStatusMappings'], [$authMiddleware]); $router->post('/settings/integrations/erli/statuses/save-pull', [$erliIntegrationController, 'savePullStatusMappings'], [$authMiddleware]);
$router->post('/settings/integrations/erli/statuses/save-push', [$erliIntegrationController, 'savePushStatusMappings'], [$authMiddleware]); $router->post('/settings/integrations/erli/statuses/save-push', [$erliIntegrationController, 'savePushStatusMappings'], [$authMiddleware]);
$router->post('/settings/integrations/erli/delivery/save', [$erliDeliveryMappingController, 'saveDeliveryMappings'], [$authMiddleware]);
$router->get('/settings/integrations/shoppro', [$shopproIntegrationsController, 'index'], [$authMiddleware]); $router->get('/settings/integrations/shoppro', [$shopproIntegrationsController, 'index'], [$authMiddleware]);
$router->post('/settings/integrations/shoppro/save', [$shopproIntegrationsController, 'save'], [$authMiddleware]); $router->post('/settings/integrations/shoppro/save', [$shopproIntegrationsController, 'save'], [$authMiddleware]);
$router->post('/settings/integrations/shoppro/test', [$shopproIntegrationsController, 'test'], [$authMiddleware]); $router->post('/settings/integrations/shoppro/test', [$shopproIntegrationsController, 'test'], [$authMiddleware]);

View File

@@ -105,6 +105,8 @@ final class CarrierDeliveryMethodMappingRepository
source_system, source_system,
source_integration_id, source_integration_id,
order_delivery_method, order_delivery_method,
source_service_id,
source_vendor_code,
provider, provider,
provider_service_id, provider_service_id,
provider_account_id, provider_account_id,
@@ -114,6 +116,8 @@ final class CarrierDeliveryMethodMappingRepository
:source_system, :source_system,
:source_integration_id, :source_integration_id,
:order_delivery_method, :order_delivery_method,
:source_service_id,
:source_vendor_code,
:provider, :provider,
:provider_service_id, :provider_service_id,
:provider_account_id, :provider_account_id,
@@ -135,6 +139,8 @@ final class CarrierDeliveryMethodMappingRepository
'source_system' => $normalizedSource, 'source_system' => $normalizedSource,
'source_integration_id' => $normalizedIntegrationId, 'source_integration_id' => $normalizedIntegrationId,
'order_delivery_method' => $orderMethod, 'order_delivery_method' => $orderMethod,
'source_service_id' => $this->nullableLimited((string) ($mapping['source_service_id'] ?? ''), 128),
'source_vendor_code' => $this->nullableLimited((string) ($mapping['source_vendor_code'] ?? ''), 64),
'provider' => $provider, 'provider' => $provider,
'provider_service_id' => $providerServiceId, 'provider_service_id' => $providerServiceId,
'provider_account_id' => $this->nullableLimited((string) ($mapping['provider_account_id'] ?? ''), 128), 'provider_account_id' => $this->nullableLimited((string) ($mapping['provider_account_id'] ?? ''), 128),
@@ -174,6 +180,20 @@ final class CarrierDeliveryMethodMappingRepository
return is_array($rows) ? $rows : []; return is_array($rows) ? $rows : [];
} }
if ($normalizedSource === 'erli') {
$stmt = $this->pdo->query(
"SELECT DISTINCT external_carrier_id
FROM orders
WHERE external_carrier_id IS NOT NULL
AND external_carrier_id <> ''
AND source = 'erli'
ORDER BY external_carrier_id ASC"
);
$rows = $stmt !== false ? $stmt->fetchAll(PDO::FETCH_COLUMN) : [];
return is_array($rows) ? $rows : [];
}
$stmt = $this->pdo->query( $stmt = $this->pdo->query(
"SELECT DISTINCT external_carrier_id "SELECT DISTINCT external_carrier_id
FROM orders FROM orders
@@ -191,7 +211,7 @@ final class CarrierDeliveryMethodMappingRepository
private function normalizeSourceSystem(string $value): string private function normalizeSourceSystem(string $value): string
{ {
$source = strtolower(trim($value)); $source = strtolower(trim($value));
return in_array($source, ['allegro', 'shoppro'], true) ? $source : 'allegro'; return in_array($source, ['allegro', 'shoppro', 'erli'], true) ? $source : 'allegro';
} }
private function nullableLimited(string $value, int $max): ?string private function nullableLimited(string $value, int $max): ?string

View File

@@ -82,6 +82,11 @@ final class ErliApiClient
foreach ($decoded as $row) { foreach ($decoded as $row) {
if (is_array($row)) { if (is_array($row)) {
$items[] = $row; $items[] = $row;
} elseif (is_scalar($row)) {
$value = trim((string) $row);
if ($value !== '') {
$items[] = ['id' => $value, 'name' => $value];
}
} }
} }
@@ -153,6 +158,135 @@ final class ErliApiClient
return ['ok' => true, 'http_code' => $httpCode, 'message' => 'OK']; return ['ok' => true, 'http_code' => $httpCode, 'message' => 'OK'];
} }
/**
* @param array{base_url: string, api_key: string, timeout_seconds?: int} $credentials
* @return array{ok: bool, http_code: int, items: array<int, array<string, mixed>>, message: string}
*/
public function getShippingMethods(array $credentials): array
{
return $this->fetchArrayEndpoint($credentials, '/dictionaries/shippingMethods');
}
/**
* @param array{base_url: string, api_key: string, timeout_seconds?: int} $credentials
* @return array{ok: bool, http_code: int, items: array<int, array<string, mixed>>, message: string}
*/
public function getDeliveryMethods(array $credentials): array
{
return $this->fetchArrayEndpoint($credentials, '/dictionaries/deliveryMethods');
}
/**
* @param array{base_url: string, api_key: string, timeout_seconds?: int} $credentials
* @return array{ok: bool, http_code: int, items: array<int, array<string, mixed>>, message: string}
*/
public function getDeliveryVendors(array $credentials): array
{
return $this->fetchArrayEndpoint($credentials, '/dictionaries/deliveryVendors');
}
/**
* @param array{base_url: string, api_key: string, timeout_seconds?: int} $credentials
* @return array{ok: bool, http_code: int, items: array<int, array<string, mixed>>, message: string}
*/
public function getPriceLists(array $credentials): array
{
return $this->fetchArrayEndpoint($credentials, '/delivery/priceLists');
}
/**
* @param array{base_url: string, api_key: string, timeout_seconds?: int} $credentials
* @return array{ok: bool, http_code: int, items: array<int, array<string, mixed>>, message: string}
*/
public function getPriceListsDetails(array $credentials): array
{
return $this->fetchArrayEndpoint($credentials, '/delivery/priceListsDetails');
}
/**
* @param array{base_url: string, api_key: string, timeout_seconds?: int} $credentials
* @param array<int, array<string, mixed>> $parcels
* @return array{ok: bool, http_code: int, items: array<int, array<string, mixed>>, message: string}
*/
public function createExternalParcel(array $credentials, array $parcels): array
{
$baseUrl = rtrim(trim((string) ($credentials['base_url'] ?? '')), '/');
$apiKey = trim((string) ($credentials['api_key'] ?? ''));
if ($baseUrl === '' || $apiKey === '' || $parcels === []) {
return ['ok' => false, 'http_code' => 0, 'items' => [], 'message' => 'Brak danych do utworzenia paczki zewnetrznej Erli.'];
}
[$body, $httpCode, $curlError] = $this->httpRequest('POST', $baseUrl . '/shipping/external', $apiKey, $parcels);
if ($curlError !== null) {
return ['ok' => false, 'http_code' => $httpCode, 'items' => [], 'message' => 'Blad polaczenia: ' . $curlError];
}
if ($httpCode < 200 || $httpCode >= 300) {
return ['ok' => false, 'http_code' => $httpCode, 'items' => [], 'message' => $this->resolveFailureMessage($body, $httpCode)];
}
$decoded = json_decode($body, true);
return [
'ok' => true,
'http_code' => $httpCode,
'items' => $this->normalizeItems($decoded),
'message' => 'OK',
];
}
/**
* @param array{base_url: string, api_key: string, timeout_seconds?: int} $credentials
* @return array{ok: bool, http_code: int, items: array<int, array<string, mixed>>, message: string}
*/
private function fetchArrayEndpoint(array $credentials, string $path): array
{
$baseUrl = rtrim(trim((string) ($credentials['base_url'] ?? '')), '/');
$apiKey = trim((string) ($credentials['api_key'] ?? ''));
if ($baseUrl === '' || $apiKey === '') {
return ['ok' => false, 'http_code' => 0, 'items' => [], 'message' => 'Brak danych API Erli.'];
}
[$body, $httpCode, $curlError] = $this->httpRequest('GET', $baseUrl . $path, $apiKey);
if ($curlError !== null) {
return ['ok' => false, 'http_code' => $httpCode, 'items' => [], 'message' => 'Blad polaczenia: ' . $curlError];
}
if ($httpCode < 200 || $httpCode >= 300) {
return ['ok' => false, 'http_code' => $httpCode, 'items' => [], 'message' => $this->resolveFailureMessage($body, $httpCode)];
}
$decoded = json_decode($body, true);
return [
'ok' => true,
'http_code' => $httpCode,
'items' => $this->normalizeItems($decoded),
'message' => 'OK',
];
}
/**
* @param mixed $decoded
* @return array<int, array<string, mixed>>
*/
private function normalizeItems(mixed $decoded): array
{
if (!is_array($decoded)) {
return [];
}
$items = [];
foreach ($decoded as $row) {
if (is_array($row)) {
$items[] = $row;
} elseif (is_scalar($row)) {
$value = trim((string) $row);
if ($value !== '') {
$items[] = ['id' => $value, 'name' => $value];
}
}
}
return $items;
}
/** /**
* @param array<string, mixed>|null $payload * @param array<string, mixed>|null $payload
* @return array{0: string, 1: int, 2: ?string} * @return array{0: string, 1: int, 2: ?string}

View File

@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Constants\IntegrationSources;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Modules\Shipments\ApaczkaShipmentService;
use App\Modules\Shipments\InpostShipmentService;
use Throwable;
final class ErliDeliveryMappingController
{
public function __construct(
private readonly Translator $translator,
private readonly CarrierDeliveryMethodMappingRepository $deliveryMappings,
private readonly ErliIntegrationRepository $erliRepository,
private readonly ErliApiClient $erliApiClient,
private readonly ?InpostShipmentService $inpostShipmentService = null,
private readonly ?ApaczkaShipmentService $apaczkaShipmentService = null
) {
}
/**
* @return array<string, mixed>
*/
public function buildViewData(): array
{
[$shippingMethods, $deliveryVendors, $metadataError] = $this->loadErliMetadata();
return [
'erliDeliveryOrderMethods' => $this->deliveryMappings->getDistinctOrderDeliveryMethods(IntegrationSources::ERLI, 0),
'erliDeliveryMappings' => $this->deliveryMappings->listMappings(IntegrationSources::ERLI, 0),
'erliShippingMethods' => $shippingMethods,
'erliDeliveryVendors' => $deliveryVendors,
'erliDeliveryMetadataError' => $metadataError,
'inpostDeliveryServices' => $this->loadInpostServices(),
'apaczkaDeliveryServices' => $this->loadApaczkaServices(),
];
}
public function saveDeliveryMappings(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/settings/integrations/erli?tab=delivery');
}
$orderMethods = (array) $request->input('order_delivery_method', []);
$sourceMethodIds = (array) $request->input('source_service_id', []);
$sourceVendorCodes = (array) $request->input('source_vendor_code', []);
$providers = (array) $request->input('provider', []);
$providerServiceIds = (array) $request->input('provider_service_id', []);
$providerCarrierIds = (array) $request->input('provider_carrier_id', []);
$providerServiceNames = (array) $request->input('provider_service_name', []);
$providerChoices = (array) $request->input('provider_service_choice', []);
$mappings = [];
foreach ($orderMethods as $index => $rawOrderMethod) {
$orderMethod = trim((string) $rawOrderMethod);
[$provider, $providerServiceId, $providerCarrierId, $providerServiceName] = $this->parseProviderChoice(
(string) ($providerChoices[$index] ?? '')
);
if ($provider === '') {
$provider = $this->normalizeProvider((string) ($providers[$index] ?? ''));
$providerServiceId = trim((string) ($providerServiceIds[$index] ?? ''));
$providerCarrierId = trim((string) ($providerCarrierIds[$index] ?? ''));
$providerServiceName = trim((string) ($providerServiceNames[$index] ?? ''));
}
if ($orderMethod === '' || $provider === '' || $providerServiceId === '') {
continue;
}
$mappings[] = [
'order_delivery_method' => $orderMethod,
'source_service_id' => trim((string) ($sourceMethodIds[$index] ?? '')),
'source_vendor_code' => trim((string) ($sourceVendorCodes[$index] ?? '')),
'provider' => $provider,
'provider_service_id' => $providerServiceId,
'provider_account_id' => '',
'provider_carrier_id' => $providerCarrierId,
'provider_service_name' => $providerServiceName,
];
}
try {
$this->deliveryMappings->saveMappings(IntegrationSources::ERLI, 0, $mappings);
Flash::set('settings_success', $this->translator->get('settings.erli.delivery.flash.saved'));
} catch (Throwable $exception) {
Flash::set(
'settings_error',
$this->translator->get('settings.erli.delivery.flash.save_failed') . ' ' . $exception->getMessage()
);
}
return Response::redirect('/settings/integrations/erli?tab=delivery');
}
/**
* @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>, 2: string}
*/
private function loadErliMetadata(): array
{
try {
$credentials = $this->erliRepository->getCredentials();
if ($credentials === null) {
return [[], [], $this->translator->get('settings.erli.delivery.metadata_not_connected')];
}
$shippingMethodsResult = $this->erliApiClient->getShippingMethods($credentials);
$vendorsResult = $this->erliApiClient->getDeliveryVendors($credentials);
$error = '';
if (!$shippingMethodsResult['ok']) {
$error = $shippingMethodsResult['message'];
} elseif (!$vendorsResult['ok']) {
$error = $vendorsResult['message'];
}
return [
$shippingMethodsResult['ok'] ? $shippingMethodsResult['items'] : [],
$vendorsResult['ok'] ? $vendorsResult['items'] : [],
$error,
];
} catch (Throwable $exception) {
return [[], [], $exception->getMessage()];
}
}
/**
* @return array<int, array<string, mixed>>
*/
private function loadInpostServices(): array
{
if ($this->inpostShipmentService === null) {
return [];
}
try {
return $this->inpostShipmentService->getDeliveryServices();
} catch (Throwable) {
return [];
}
}
/**
* @return array<int, array<string, mixed>>
*/
private function loadApaczkaServices(): array
{
if ($this->apaczkaShipmentService === null) {
return [];
}
try {
return $this->apaczkaShipmentService->getDeliveryServices();
} catch (Throwable) {
return [];
}
}
private function normalizeProvider(string $value): string
{
$provider = strtolower(trim($value));
return in_array($provider, ['inpost', 'apaczka'], true) ? $provider : '';
}
/**
* @return array{0: string, 1: string, 2: string, 3: string}
*/
private function parseProviderChoice(string $value): array
{
$parts = explode('|', $value, 4);
if (count($parts) < 2) {
return ['', '', '', ''];
}
$provider = $this->normalizeProvider((string) ($parts[0] ?? ''));
$serviceId = trim((string) ($parts[1] ?? ''));
if ($provider === '' || $serviceId === '') {
return ['', '', '', ''];
}
return [
$provider,
$serviceId,
trim((string) ($parts[2] ?? '')),
trim((string) ($parts[3] ?? '')),
];
}
}

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Constants\IntegrationSources;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Shipments\ShipmentPackageRepository;
use Throwable;
final class ErliExternalShipmentService
{
public function __construct(
private readonly ErliIntegrationRepository $erliRepository,
private readonly ErliApiClient $apiClient,
private readonly CarrierDeliveryMethodMappingRepository $deliveryMappings,
private readonly ShipmentPackageRepository $packageRepository,
private readonly OrdersRepository $ordersRepository
) {
}
/**
* @return array{ok: bool, skipped: bool, message: string}
*/
public function syncPackage(int $packageId): array
{
$package = $this->packageRepository->findById($packageId);
if ($package === null) {
return ['ok' => false, 'skipped' => true, 'message' => 'Paczka nie istnieje.'];
}
$trackingNumber = trim((string) ($package['tracking_number'] ?? ''));
if ($trackingNumber === '') {
return ['ok' => true, 'skipped' => true, 'message' => 'Paczka nie ma jeszcze numeru nadania.'];
}
$orderId = (int) ($package['order_id'] ?? 0);
$details = $orderId > 0 ? $this->ordersRepository->findDetails($orderId) : null;
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
if (strtolower(trim((string) ($order['source'] ?? ''))) !== IntegrationSources::ERLI) {
return ['ok' => true, 'skipped' => true, 'message' => 'Zamowienie nie pochodzi z Erli.'];
}
$payloadJson = $this->decodePayload((string) ($package['payload_json'] ?? ''));
if ($this->alreadySynced($payloadJson, $trackingNumber)) {
return ['ok' => true, 'skipped' => true, 'message' => 'Paczka jest juz zarejestrowana w Erli.'];
}
$sourceOrderId = trim((string) ($order['source_order_id'] ?? $order['external_order_id'] ?? ''));
$vendor = $this->resolveVendor($order, $package);
if ($sourceOrderId === '' || $vendor === '') {
return ['ok' => false, 'skipped' => true, 'message' => 'Brak ID zamowienia Erli albo vendora Erli dla paczki.'];
}
$credentials = $this->erliRepository->getCredentials();
if ($credentials === null) {
return ['ok' => false, 'skipped' => true, 'message' => 'Brak aktywnej konfiguracji Erli.'];
}
$externalParcel = [
'orderId' => $sourceOrderId,
'vendor' => $vendor,
'status' => 'sent',
'trackingNumber' => $trackingNumber,
];
$result = $this->apiClient->createExternalParcel($credentials, [$externalParcel]);
if (!$result['ok']) {
$this->recordActivity($orderId, false, $packageId, $result['message']);
return ['ok' => false, 'skipped' => false, 'message' => $result['message']];
}
$payloadJson['erli_external_parcel'] = [
'synced_at' => date('Y-m-d H:i:s'),
'request' => $externalParcel,
'response' => $result['items'],
'trackingNumber' => $trackingNumber,
];
$this->packageRepository->update($packageId, ['payload_json' => $payloadJson]);
$this->recordActivity($orderId, true, $packageId, 'Paczka zewnetrzna Erli zostala zarejestrowana.');
return ['ok' => true, 'skipped' => false, 'message' => 'OK'];
}
/**
* @param array<string, mixed> $order
* @param array<string, mixed> $package
*/
private function resolveVendor(array $order, array $package): string
{
$orderMethod = trim((string) ($order['external_carrier_id'] ?? ''));
if ($orderMethod !== '') {
$mapping = $this->deliveryMappings->findByOrderMethod(IntegrationSources::ERLI, 0, $orderMethod);
$vendor = trim((string) ($mapping['source_vendor_code'] ?? ''));
if ($vendor !== '') {
return $vendor;
}
}
return $this->inferVendorFromPackage($package);
}
/**
* @param array<string, mixed> $package
*/
private function inferVendorFromPackage(array $package): string
{
$provider = strtolower(trim((string) ($package['provider'] ?? '')));
$carrier = strtolower(trim((string) ($package['carrier_id'] ?? '')));
$deliveryMethod = strtolower(trim((string) ($package['delivery_method_id'] ?? '')));
$combined = $provider . ' ' . $carrier . ' ' . $deliveryMethod;
$known = [
'inpost' => 'inpost',
'dpd' => 'dpd',
'dhl' => 'dhl',
'ups' => 'ups',
'gls' => 'gls',
'orlen' => 'orlen',
'pocztex' => 'pocztex24',
'poczta' => 'pocztaPolska',
'fedex' => 'fedex',
];
foreach ($known as $needle => $vendor) {
if (str_contains($combined, $needle)) {
return $vendor;
}
}
return '';
}
/**
* @return array<string, mixed>
*/
private function decodePayload(string $payload): array
{
$decoded = json_decode($payload, true);
return is_array($decoded) ? $decoded : [];
}
/**
* @param array<string, mixed> $payload
*/
private function alreadySynced(array $payload, string $trackingNumber): bool
{
$erliParcel = is_array($payload['erli_external_parcel'] ?? null) ? $payload['erli_external_parcel'] : [];
return trim((string) ($erliParcel['trackingNumber'] ?? '')) === $trackingNumber;
}
private function recordActivity(int $orderId, bool $success, int $packageId, string $message): void
{
if ($orderId <= 0) {
return;
}
try {
$this->ordersRepository->recordActivity(
$orderId,
$success ? 'erli_external_shipment_synced' : 'erli_external_shipment_error',
$success ? 'Zarejestrowano paczke zewnetrzna w Erli.' : 'Nie udalo sie zarejestrowac paczki w Erli: ' . $message,
['package_id' => $packageId],
'system',
null
);
} catch (Throwable) {
// Logging nie moze blokowac lokalnej etykiety.
}
}
}

View File

@@ -39,7 +39,8 @@ final class ErliIntegrationController
private readonly ErliOrdersSyncService $ordersSyncService, private readonly ErliOrdersSyncService $ordersSyncService,
private readonly OrderStatusRepository $orderStatuses, private readonly OrderStatusRepository $orderStatuses,
private readonly ErliStatusMappingRepository $statusMappings, private readonly ErliStatusMappingRepository $statusMappings,
private readonly ErliPullStatusMappingRepository $pullStatusMappings private readonly ErliPullStatusMappingRepository $pullStatusMappings,
private readonly ?ErliDeliveryMappingController $deliveryMappingController = null
) { ) {
} }
@@ -47,7 +48,7 @@ final class ErliIntegrationController
{ {
$activeTab = $this->resolveTab((string) $request->input('tab', 'integration')); $activeTab = $this->resolveTab((string) $request->input('tab', 'integration'));
$html = $this->template->render('settings/erli', [ $viewData = [
'title' => $this->translator->get('settings.erli.title'), 'title' => $this->translator->get('settings.erli.title'),
'activeMenu' => 'settings', 'activeMenu' => 'settings',
'activeSettings' => 'integrations', 'activeSettings' => 'integrations',
@@ -65,7 +66,12 @@ final class ErliIntegrationController
'successMessage' => (string) Flash::get('settings_success', ''), 'successMessage' => (string) Flash::get('settings_success', ''),
'testMessage' => (string) Flash::get('erli_test', ''), 'testMessage' => (string) Flash::get('erli_test', ''),
'importMessage' => (string) Flash::get('erli_import', ''), 'importMessage' => (string) Flash::get('erli_import', ''),
], 'layouts/app'); ];
if ($this->deliveryMappingController !== null) {
$viewData = array_merge($viewData, $this->deliveryMappingController->buildViewData());
}
$html = $this->template->render('settings/erli', $viewData, 'layouts/app');
return Response::html($html); return Response::html($html);
} }
@@ -244,7 +250,7 @@ final class ErliIntegrationController
private function resolveTab(string $tab): string private function resolveTab(string $tab): string
{ {
$normalized = trim($tab); $normalized = trim($tab);
if (in_array($normalized, ['integration', 'statuses', 'settings'], true)) { if (in_array($normalized, ['integration', 'statuses', 'delivery', 'settings'], true)) {
return $normalized; return $normalized;
} }

View File

@@ -14,6 +14,7 @@ use App\Modules\Auth\AuthService;
use App\Modules\Orders\OrdersRepository; use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\CarrierDeliveryMethodMappingRepository; use App\Modules\Settings\CarrierDeliveryMethodMappingRepository;
use App\Modules\Settings\CompanySettingsRepository; use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\ErliExternalShipmentService;
use App\Core\Exceptions\ShipmentException; use App\Core\Exceptions\ShipmentException;
use Throwable; use Throwable;
@@ -30,7 +31,8 @@ final class ShipmentController
private readonly AutomationService $automationService, private readonly AutomationService $automationService,
private readonly string $storagePath, private readonly string $storagePath,
private readonly ?CarrierDeliveryMethodMappingRepository $deliveryMappings = null, private readonly ?CarrierDeliveryMethodMappingRepository $deliveryMappings = null,
private readonly ?\App\Modules\Printing\PrintJobRepository $printJobRepo = null private readonly ?\App\Modules\Printing\PrintJobRepository $printJobRepo = null,
private readonly ?ErliExternalShipmentService $erliExternalShipmentService = null
) { ) {
} }
@@ -89,10 +91,23 @@ final class ShipmentController
} }
} }
$inpostServices = [];
$inpostProvider = $this->providerRegistry->get('inpost');
if ($inpostProvider !== null) {
try {
$inpostServices = $inpostProvider->getDeliveryServices();
} catch (Throwable $exception) {
if ($deliveryServicesError === '') {
$deliveryServicesError = $exception->getMessage();
}
}
}
if ($inpostServices === []) {
$inpostServices = array_values(array_filter( $inpostServices = array_values(array_filter(
$deliveryServices, $deliveryServices,
static fn(array $svc) => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false static fn(array $svc) => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false
)); ));
}
$flashSuccess = (string) Flash::get('shipment.success', ''); $flashSuccess = (string) Flash::get('shipment.success', '');
$flashError = (string) Flash::get('shipment.error', ''); $flashError = (string) Flash::get('shipment.error', '');
@@ -104,7 +119,7 @@ final class ShipmentController
$sourceIntegrationId = $source === 'shoppro' $sourceIntegrationId = $source === 'shoppro'
? max(0, (int) ($order['integration_id'] ?? 0)) ? max(0, (int) ($order['integration_id'] ?? 0))
: 0; : 0;
if ($orderCarrierName !== '' && $this->deliveryMappings !== null && in_array($source, ['allegro', 'shoppro'], true)) { if ($orderCarrierName !== '' && $this->deliveryMappings !== null && in_array($source, ['allegro', 'shoppro', 'erli'], true)) {
$deliveryMapping = $this->deliveryMappings->findByOrderMethod($source, $sourceIntegrationId, $orderCarrierName); $deliveryMapping = $this->deliveryMappings->findByOrderMethod($source, $sourceIntegrationId, $orderCarrierName);
if ($deliveryMapping === null) { if ($deliveryMapping === null) {
$hasMappingsForSource = $this->deliveryMappings->hasMappingsForSource($source, $sourceIntegrationId); $hasMappingsForSource = $this->deliveryMappings->hasMappingsForSource($source, $sourceIntegrationId);
@@ -218,6 +233,7 @@ final class ShipmentController
); );
$this->triggerShipmentCreatedAutomation($orderId, $packageId, $providerCode); $this->triggerShipmentCreatedAutomation($orderId, $packageId, $providerCode);
$this->syncErliExternalShipment($packageId);
Flash::set('order.success', 'Przesylka utworzona. Sprawdz status w zakladce Przesylki.'); Flash::set('order.success', 'Przesylka utworzona. Sprawdz status w zakladce Przesylki.');
return Response::redirect('/orders/' . $orderId . '?printLast=1'); return Response::redirect('/orders/' . $orderId . '?printLast=1');
} catch (Throwable $exception) { } catch (Throwable $exception) {
@@ -264,6 +280,9 @@ final class ShipmentController
// label generation failed, user can retry manually // label generation failed, user can retry manually
} }
} }
if (in_array((string) ($result['status'] ?? ''), ['created', 'label_ready'], true)) {
$this->syncErliExternalShipment($packageId);
}
return Response::json($result); return Response::json($result);
} catch (Throwable $exception) { } catch (Throwable $exception) {
@@ -302,6 +321,7 @@ final class ShipmentController
} }
$result = $provider->downloadLabel($packageId, $this->storagePath); $result = $provider->downloadLabel($packageId, $this->storagePath);
$this->syncErliExternalShipment($packageId);
$fullPath = (string) ($result['full_path'] ?? ''); $fullPath = (string) ($result['full_path'] ?? '');
if ($fullPath !== '' && file_exists($fullPath)) { if ($fullPath !== '' && file_exists($fullPath)) {
$labelFormat = strtoupper(trim((string) ($package['label_format'] ?? 'PDF'))); $labelFormat = strtoupper(trim((string) ($package['label_format'] ?? 'PDF')));
@@ -473,6 +493,19 @@ final class ShipmentController
} }
} }
private function syncErliExternalShipment(int $packageId): void
{
if ($this->erliExternalShipmentService === null || $packageId <= 0) {
return;
}
try {
$this->erliExternalShipmentService->syncPackage($packageId);
} catch (Throwable) {
// Synchronizacja Erli nie moze blokowac lokalnej etykiety.
}
}
/** /**
* @param array<string, mixed>|null $deliveryAddr * @param array<string, mixed>|null $deliveryAddr
* @param array<string, mixed>|null $customerAddr * @param array<string, mixed>|null $customerAddr

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\CarrierDeliveryMethodMappingRepository;
use App\Modules\Settings\ErliApiClient;
use App\Modules\Settings\ErliExternalShipmentService;
use App\Modules\Settings\ErliIntegrationRepository;
use App\Modules\Shipments\ShipmentPackageRepository;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class ErliExternalShipmentServiceTest extends TestCase
{
private ErliIntegrationRepository&MockObject $erliRepository;
private ErliApiClient&MockObject $apiClient;
private CarrierDeliveryMethodMappingRepository&MockObject $deliveryMappings;
private ShipmentPackageRepository&MockObject $packageRepository;
private OrdersRepository&MockObject $ordersRepository;
protected function setUp(): void
{
$this->erliRepository = $this->createMock(ErliIntegrationRepository::class);
$this->apiClient = $this->createMock(ErliApiClient::class);
$this->deliveryMappings = $this->createMock(CarrierDeliveryMethodMappingRepository::class);
$this->packageRepository = $this->createMock(ShipmentPackageRepository::class);
$this->ordersRepository = $this->createMock(OrdersRepository::class);
}
public function testSyncPackageRegistersExternalParcelWithMappedVendor(): void
{
$this->packageRepository
->method('findById')
->with(55)
->willReturn([
'id' => 55,
'order_id' => 1001,
'provider' => 'inpost',
'carrier_id' => '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',
'timeout_seconds' => 15,
'orders_fetch_enabled' => true,
'orders_fetch_start_date' => null,
]);
$this->apiClient
->expects($this->once())
->method('createExternalParcel')
->with(
$this->anything(),
[[
'orderId' => '240101x12345',
'vendor' => 'inpost',
'status' => 'sent',
'trackingNumber' => '1234567890',
]]
)
->willReturn(['ok' => true, 'http_code' => 200, 'items' => [['id' => 777]], 'message' => 'OK']);
$this->packageRepository
->expects($this->once())
->method('update')
->with(
55,
$this->callback(static function (array $data): bool {
$payload = $data['payload_json'] ?? [];
return is_array($payload)
&& isset($payload['erli_external_parcel'])
&& ($payload['erli_external_parcel']['trackingNumber'] ?? '') === '1234567890';
})
);
$result = $this->createService()->syncPackage(55);
self::assertTrue($result['ok']);
self::assertFalse($result['skipped']);
}
public function testSyncPackageSkipsUntilTrackingNumberExists(): void
{
$this->packageRepository
->method('findById')
->willReturn([
'id' => 55,
'order_id' => 1001,
'tracking_number' => '',
]);
$this->apiClient
->expects($this->never())
->method('createExternalParcel');
$result = $this->createService()->syncPackage(55);
self::assertTrue($result['ok']);
self::assertTrue($result['skipped']);
}
private function createService(): ErliExternalShipmentService
{
return new ErliExternalShipmentService(
$this->erliRepository,
$this->apiClient,
$this->deliveryMappings,
$this->packageRepository,
$this->ordersRepository
);
}
}