feat(130): erli shipments and labels
This commit is contained in:
@@ -13,8 +13,8 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| Version | 3.8.0-dev |
|
||||
| Status | v3.8 Erli Marketplace Integration in progress — Phase 129 shipped (Erli status mappings/sync); Phase 130 next |
|
||||
| Last Updated | 2026-05-16 (Phase 129 closed) |
|
||||
| Status | v3.8 Erli Marketplace Integration in progress — Phase 130 shipped (Erli shipments + labels/external parcel sync); Phase 131 next |
|
||||
| Last Updated | 2026-05-16 (Phase 130 closed) |
|
||||
|
||||
## 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] Import zamowien Erli: pobieranie `/inbox` przez cron i recznie, mapper do orderPRO, delta-only re-import, `invoice_requested` z danych firmowych/NIP, bezpieczny ACK `/inbox/mark-read` po bezblednym batchu — Phase 128
|
||||
- [x] Mapowanie i synchronizacja statusow Erli: osobne pull/push mappings, discovery statusow z inboxa, reczny-only push `PATCH /orders/{id}/status`, cron `erli_status_sync` i zakladki w ustawieniach Erli — Phase 129
|
||||
- [x] Przesylki Erli: zakladka mapowania dostaw, etykiety przez lokalne providery InPost/Apaczka i rejestracja paczek zewnetrznych w Erli przez `POST /shipping/external` — Phase 130
|
||||
|
||||
### Deferred
|
||||
|
||||
@@ -137,11 +138,10 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
|
||||
|
||||
### 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)
|
||||
|
||||
- [ ] Erli shipments + labels — Phase 130
|
||||
- [ ] Erli tracking + automation hooks — Phase 131
|
||||
- [ ] Erli hardening, observability + docs — Phase 132
|
||||
- [ ] 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 |
|
||||
| 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 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
|
||||
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -282,6 +284,6 @@ Quick Reference:
|
||||
|
||||
---
|
||||
*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*
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Progress: 3 of 6 phases complete (50%).
|
||||
Progress: 4 of 6 phases complete (67%).
|
||||
|
||||
| Phase | Name | Plans | Status |
|
||||
|-------|------|-------|--------|
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 132 | Erli Hardening, Observability + Docs | TBD | Not started |
|
||||
|
||||
@@ -39,7 +39,7 @@ Plans: 129-01 (complete)
|
||||
### 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.
|
||||
Plans: TBD (defined during $paul-plan)
|
||||
Plans: 130-01 (complete)
|
||||
|
||||
### Phase 131: Erli Tracking + Automation Hooks
|
||||
|
||||
@@ -555,4 +555,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
|
||||
|
||||
---
|
||||
*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*
|
||||
|
||||
@@ -5,33 +5,33 @@
|
||||
See: .paul/PROJECT.md (updated 2026-05-16)
|
||||
|
||||
**Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami.
|
||||
**Current focus:** v3.8 Erli Marketplace Integration - Phase 130 ready to plan.
|
||||
**Current focus:** v3.8 Erli Marketplace Integration - Phase 131 ready to plan.
|
||||
|
||||
## Current Position
|
||||
|
||||
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
|
||||
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:
|
||||
- Milestone v3.8: [#####-----] 50% (Phases 127-129 complete; Phase 130 next)
|
||||
- Phase 130: [----------] 0% (not planned)
|
||||
- Milestone v3.8: [#######---] 67% (Phases 127-130 complete; Phase 131 next)
|
||||
- Phase 131: [----------] 0% (not planned)
|
||||
|
||||
## Loop Position
|
||||
|
||||
Current loop state:
|
||||
```
|
||||
PLAN -> APPLY -> UNIFY
|
||||
done done done [Loop complete - ready for next PLAN]
|
||||
done done done [Loop complete, ready for next PLAN]
|
||||
```
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-05-16 00:23
|
||||
Stopped at: Phase 129 complete, ready to plan Phase 130
|
||||
Next action: $paul-plan for Phase 130 (Erli Shipments + Labels)
|
||||
Last session: 2026-05-16 00:51
|
||||
Stopped at: Phase 130 complete, ready to plan Phase 131
|
||||
Next action: $paul-plan for Phase 131 (Erli Tracking + Automation Hooks)
|
||||
Resume file: .paul/ROADMAP.md
|
||||
|
||||
## Pending parallel work
|
||||
@@ -39,8 +39,8 @@ Resume file: .paul/ROADMAP.md
|
||||
|
||||
## Git State
|
||||
|
||||
Last phase commit: 7972bb9 feat(129): erli status mapping sync
|
||||
Previous: 2565d9b feat(128): erli orders import
|
||||
Last phase commit: a73bd7f feat(130): erli shipments and labels
|
||||
Previous: 7972bb9 feat(129): erli status mapping sync
|
||||
Branch: main
|
||||
|
||||
### Skill Audit (Phase 129)
|
||||
@@ -49,6 +49,12 @@ Branch: main
|
||||
|----------|---------|-------|
|
||||
| `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
|
||||
|
||||
- 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 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 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
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -35,6 +40,15 @@
|
||||
- `resources/lang/pl.php`
|
||||
- `tests/Unit/ErliOrderMapperTest.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/ARCHITECTURE.md`
|
||||
- `DOCS/TECH_CHANGELOG.md`
|
||||
|
||||
255
.paul/phases/130-erli-shipments-labels/130-01-PLAN.md
Normal file
255
.paul/phases/130-erli-shipments-labels/130-01-PLAN.md
Normal 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>
|
||||
165
.paul/phases/130-erli-shipments-labels/130-01-SUMMARY.md
Normal file
165
.paul/phases/130-erli-shipments-labels/130-01-SUMMARY.md
Normal 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.
|
||||
@@ -84,6 +84,8 @@ HTTP Request
|
||||
5. **Render** — `resources/views/statistics/summary.php` renders filters, chart JSON, two canvas targets, and table fallbacks.
|
||||
|
||||
### 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()`
|
||||
2. **Track** — Cron `ShipmentTrackingHandler` → `ShipmentTrackingRegistry` → carrier tracking API → `ShipmentPackageRepository::updateDeliveryStatus()`
|
||||
|
||||
@@ -121,12 +123,14 @@ HTTP Request
|
||||
|
||||
### 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_*`.
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -187,15 +191,25 @@ tests/
|
||||
- `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 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)`.
|
||||
- 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).
|
||||
|
||||
### 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()`.
|
||||
- `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::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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
**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
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
@@ -414,7 +434,7 @@ UNIQUE: `(integration_id, shoppro_status_code)`
|
||||
| `sender_point_id` | VARCHAR(64) | YES | |
|
||||
| `reference_number` | VARCHAR(128) | 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 | |
|
||||
| `updated_at` | DATETIME | NO | |
|
||||
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 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
|
||||
|
||||
**Co zrobiono:**
|
||||
|
||||
@@ -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;
|
||||
@@ -867,6 +867,7 @@ return [
|
||||
'label' => 'Zakladki integracji Erli',
|
||||
'integration' => 'Integracja',
|
||||
'statuses' => 'Statusy',
|
||||
'delivery' => 'Dostawy',
|
||||
'settings' => 'Ustawienia',
|
||||
],
|
||||
'config' => [
|
||||
@@ -925,6 +926,29 @@ return [
|
||||
'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' => [
|
||||
'secret' => 'Sekret API',
|
||||
'active' => 'Aktywna',
|
||||
|
||||
@@ -15,6 +15,20 @@ $statusSyncIntervalMinutes = (int) ($statusSyncIntervalMinutes ?? 15);
|
||||
$orderproStatuses = is_array($orderproStatuses ?? null) ? $orderproStatuses : [];
|
||||
$erliStatusMappings = is_array($erliStatusMappings ?? null) ? $erliStatusMappings : [];
|
||||
$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');
|
||||
?>
|
||||
|
||||
@@ -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">
|
||||
<?= $e($t('settings.erli.tabs.statuses')) ?>
|
||||
</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">
|
||||
<?= $e($t('settings.erli.tabs.settings')) ?>
|
||||
</button>
|
||||
@@ -261,6 +278,149 @@ $activeTab = (string) ($activeTab ?? 'integration');
|
||||
|
||||
</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">
|
||||
<section class="mt-16">
|
||||
<h3 class="section-title"><?= $e($t('settings.erli.import.title')) ?></h3>
|
||||
@@ -315,6 +475,7 @@ $activeTab = (string) ($activeTab ?? 'integration');
|
||||
var tabNameMap = {
|
||||
'erli-tab-integration': 'integration',
|
||||
'erli-tab-statuses': 'statuses',
|
||||
'erli-tab-delivery': 'delivery',
|
||||
'erli-tab-settings': 'settings'
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,11 @@ $mappedCredentialsId = trim((string) ($mapping['provider_account_id'] ?? ''));
|
||||
$mappedCarrierId = trim((string) ($mapping['provider_carrier_id'] ?? ''));
|
||||
$mappedProvider = trim((string) ($mapping['provider'] ?? ''));
|
||||
$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) {
|
||||
$mappedCarrier = 'inpost';
|
||||
}
|
||||
@@ -144,16 +148,16 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
|
||||
|
||||
<div id="shipment-inpost-panel" style="<?= $preselectedCarrier !== 'inpost' ? 'display:none' : '' ?>">
|
||||
<?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: ?>
|
||||
<select class="form-control" id="shipment-inpost-select">
|
||||
<option value="">-- Wybierz usluge InPost --</option>
|
||||
<?php foreach ($inpostSvcList as $inSvc): ?>
|
||||
<?php
|
||||
$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'] ?? ''));
|
||||
$inSvcCarrierId = trim((string) ($inSvc['carrierId'] ?? ''));
|
||||
$inSvcCarrierId = trim((string) ($inSvc['carrierId'] ?? 'inpost'));
|
||||
$inSvcName = trim((string) ($inSvc['name'] ?? ''));
|
||||
$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="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">
|
||||
<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 (apaczkaPanel) apaczkaPanel.style.display = carrier === 'apaczka' ? '' : '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');
|
||||
@@ -655,7 +659,7 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
|
||||
hiddenInput.value = inpostSelect.value;
|
||||
credentialsInput.value = opt ? (opt.getAttribute('data-credentials-id') || '') : '';
|
||||
carrierInput.value = opt ? (opt.getAttribute('data-carrier-id') || '') : '';
|
||||
if (providerInput) providerInput.value = 'allegro_wza';
|
||||
if (providerInput) providerInput.value = 'inpost';
|
||||
}
|
||||
inpostSelect.addEventListener('change', syncInpostFields);
|
||||
if (carrierSelect.value === 'inpost' && inpostSelect.value !== '') {
|
||||
|
||||
@@ -30,6 +30,8 @@ use App\Modules\Settings\ApaczkaIntegrationController;
|
||||
use App\Modules\Settings\ApaczkaIntegrationRepository;
|
||||
use App\Modules\Settings\CarrierDeliveryMethodMappingRepository;
|
||||
use App\Modules\Settings\ErliApiClient;
|
||||
use App\Modules\Settings\ErliDeliveryMappingController;
|
||||
use App\Modules\Settings\ErliExternalShipmentService;
|
||||
use App\Modules\Settings\ErliIntegrationController;
|
||||
use App\Modules\Settings\ErliIntegrationRepository;
|
||||
use App\Modules\Settings\ErliOrderMapper;
|
||||
@@ -397,19 +399,6 @@ return static function (Application $app): void {
|
||||
$automationService,
|
||||
$erliPullStatusMappingRepository
|
||||
);
|
||||
$erliIntegrationController = new ErliIntegrationController(
|
||||
$template,
|
||||
$translator,
|
||||
$auth,
|
||||
$erliIntegrationRepository,
|
||||
new ErliApiClient(),
|
||||
new IntegrationsRepository($app->db()),
|
||||
$cronRepository,
|
||||
$erliOrdersSyncService,
|
||||
$app->orderStatuses(),
|
||||
$erliStatusMappingRepository,
|
||||
$erliPullStatusMappingRepository
|
||||
);
|
||||
$allegroIntegrationController = new AllegroIntegrationController(
|
||||
$template,
|
||||
$translator,
|
||||
@@ -498,6 +487,35 @@ return static function (Application $app): void {
|
||||
$apaczkaShipmentService,
|
||||
$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(
|
||||
$template,
|
||||
$translator,
|
||||
@@ -509,7 +527,8 @@ return static function (Application $app): void {
|
||||
$automationService,
|
||||
$app->basePath('storage'),
|
||||
$carrierDeliveryMappings,
|
||||
$printJobRepository
|
||||
$printJobRepository,
|
||||
$erliExternalShipmentService
|
||||
);
|
||||
$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/statuses/save-pull', [$erliIntegrationController, 'savePullStatusMappings'], [$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->post('/settings/integrations/shoppro/save', [$shopproIntegrationsController, 'save'], [$authMiddleware]);
|
||||
$router->post('/settings/integrations/shoppro/test', [$shopproIntegrationsController, 'test'], [$authMiddleware]);
|
||||
|
||||
@@ -105,6 +105,8 @@ final class CarrierDeliveryMethodMappingRepository
|
||||
source_system,
|
||||
source_integration_id,
|
||||
order_delivery_method,
|
||||
source_service_id,
|
||||
source_vendor_code,
|
||||
provider,
|
||||
provider_service_id,
|
||||
provider_account_id,
|
||||
@@ -114,6 +116,8 @@ final class CarrierDeliveryMethodMappingRepository
|
||||
:source_system,
|
||||
:source_integration_id,
|
||||
:order_delivery_method,
|
||||
:source_service_id,
|
||||
:source_vendor_code,
|
||||
:provider,
|
||||
:provider_service_id,
|
||||
:provider_account_id,
|
||||
@@ -135,6 +139,8 @@ final class CarrierDeliveryMethodMappingRepository
|
||||
'source_system' => $normalizedSource,
|
||||
'source_integration_id' => $normalizedIntegrationId,
|
||||
'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_service_id' => $providerServiceId,
|
||||
'provider_account_id' => $this->nullableLimited((string) ($mapping['provider_account_id'] ?? ''), 128),
|
||||
@@ -174,6 +180,20 @@ final class CarrierDeliveryMethodMappingRepository
|
||||
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(
|
||||
"SELECT DISTINCT external_carrier_id
|
||||
FROM orders
|
||||
@@ -191,7 +211,7 @@ final class CarrierDeliveryMethodMappingRepository
|
||||
private function normalizeSourceSystem(string $value): string
|
||||
{
|
||||
$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
|
||||
|
||||
@@ -82,6 +82,11 @@ final class ErliApiClient
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +158,135 @@ final class ErliApiClient
|
||||
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
|
||||
* @return array{0: string, 1: int, 2: ?string}
|
||||
|
||||
194
src/Modules/Settings/ErliDeliveryMappingController.php
Normal file
194
src/Modules/Settings/ErliDeliveryMappingController.php
Normal 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] ?? '')),
|
||||
];
|
||||
}
|
||||
}
|
||||
169
src/Modules/Settings/ErliExternalShipmentService.php
Normal file
169
src/Modules/Settings/ErliExternalShipmentService.php
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,8 @@ final class ErliIntegrationController
|
||||
private readonly ErliOrdersSyncService $ordersSyncService,
|
||||
private readonly OrderStatusRepository $orderStatuses,
|
||||
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'));
|
||||
|
||||
$html = $this->template->render('settings/erli', [
|
||||
$viewData = [
|
||||
'title' => $this->translator->get('settings.erli.title'),
|
||||
'activeMenu' => 'settings',
|
||||
'activeSettings' => 'integrations',
|
||||
@@ -65,7 +66,12 @@ final class ErliIntegrationController
|
||||
'successMessage' => (string) Flash::get('settings_success', ''),
|
||||
'testMessage' => (string) Flash::get('erli_test', ''),
|
||||
'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);
|
||||
}
|
||||
@@ -244,7 +250,7 @@ final class ErliIntegrationController
|
||||
private function resolveTab(string $tab): string
|
||||
{
|
||||
$normalized = trim($tab);
|
||||
if (in_array($normalized, ['integration', 'statuses', 'settings'], true)) {
|
||||
if (in_array($normalized, ['integration', 'statuses', 'delivery', 'settings'], true)) {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Modules\Auth\AuthService;
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\CarrierDeliveryMethodMappingRepository;
|
||||
use App\Modules\Settings\CompanySettingsRepository;
|
||||
use App\Modules\Settings\ErliExternalShipmentService;
|
||||
use App\Core\Exceptions\ShipmentException;
|
||||
use Throwable;
|
||||
|
||||
@@ -30,7 +31,8 @@ final class ShipmentController
|
||||
private readonly AutomationService $automationService,
|
||||
private readonly string $storagePath,
|
||||
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 = array_values(array_filter(
|
||||
$deliveryServices,
|
||||
static fn(array $svc) => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false
|
||||
));
|
||||
$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(
|
||||
$deliveryServices,
|
||||
static fn(array $svc) => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false
|
||||
));
|
||||
}
|
||||
|
||||
$flashSuccess = (string) Flash::get('shipment.success', '');
|
||||
$flashError = (string) Flash::get('shipment.error', '');
|
||||
@@ -104,7 +119,7 @@ final class ShipmentController
|
||||
$sourceIntegrationId = $source === 'shoppro'
|
||||
? max(0, (int) ($order['integration_id'] ?? 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);
|
||||
if ($deliveryMapping === null) {
|
||||
$hasMappingsForSource = $this->deliveryMappings->hasMappingsForSource($source, $sourceIntegrationId);
|
||||
@@ -218,6 +233,7 @@ final class ShipmentController
|
||||
);
|
||||
|
||||
$this->triggerShipmentCreatedAutomation($orderId, $packageId, $providerCode);
|
||||
$this->syncErliExternalShipment($packageId);
|
||||
Flash::set('order.success', 'Przesylka utworzona. Sprawdz status w zakladce Przesylki.');
|
||||
return Response::redirect('/orders/' . $orderId . '?printLast=1');
|
||||
} catch (Throwable $exception) {
|
||||
@@ -264,6 +280,9 @@ final class ShipmentController
|
||||
// label generation failed, user can retry manually
|
||||
}
|
||||
}
|
||||
if (in_array((string) ($result['status'] ?? ''), ['created', 'label_ready'], true)) {
|
||||
$this->syncErliExternalShipment($packageId);
|
||||
}
|
||||
|
||||
return Response::json($result);
|
||||
} catch (Throwable $exception) {
|
||||
@@ -302,6 +321,7 @@ final class ShipmentController
|
||||
}
|
||||
|
||||
$result = $provider->downloadLabel($packageId, $this->storagePath);
|
||||
$this->syncErliExternalShipment($packageId);
|
||||
$fullPath = (string) ($result['full_path'] ?? '');
|
||||
if ($fullPath !== '' && file_exists($fullPath)) {
|
||||
$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 $customerAddr
|
||||
|
||||
130
tests/Unit/ErliExternalShipmentServiceTest.php
Normal file
130
tests/Unit/ErliExternalShipmentServiceTest.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user