feat(129): erli status mapping sync

Phase 129 complete:
- Add Erli pull/push status mapping tables, seeds and repositories
- Wire Erli status sync cron for inbox pull and manual-only push
- Add tabbed Erli settings UI, tests and documentation

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-16 00:27:08 +02:00
parent c127ebf04d
commit 7972bb9fa4
28 changed files with 2021 additions and 57 deletions

View File

@@ -13,8 +13,8 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
| Attribute | Value | | Attribute | Value |
|-----------|-------| |-----------|-------|
| Version | 3.8.0-dev | | Version | 3.8.0-dev |
| Status | v3.8 Erli Marketplace Integration in progress — Phase 128 shipped (Erli orders import); Phase 129 next | | Status | v3.8 Erli Marketplace Integration in progress — Phase 129 shipped (Erli status mappings/sync); Phase 130 next |
| Last Updated | 2026-05-15 (Phase 128 closed) | | Last Updated | 2026-05-16 (Phase 129 closed) |
## Requirements ## Requirements
@@ -128,6 +128,7 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
- [x] Bugfix detekcji faktury przy imporcie: shopPRO order z `firm_nip` ustawia `invoice_requested=1` (mapper jako jedyne zrodlo heurystyki, sync service propaguje `aggregate['invoice_detected']`); Allegro rozszerzony o `naturalPerson=false`/`address.taxId`/`companyName` (wczesniej tylko `invoice.required`); usunieta legacy kolumna `orders.is_invoice` (Phase 115 dryft) + backfill 7 zamowien — Phase 125 - [x] Bugfix detekcji faktury przy imporcie: shopPRO order z `firm_nip` ustawia `invoice_requested=1` (mapper jako jedyne zrodlo heurystyki, sync service propaguje `aggregate['invoice_detected']`); Allegro rozszerzony o `naturalPerson=false`/`address.taxId`/`companyName` (wczesniej tylko `invoice.required`); usunieta legacy kolumna `orders.is_invoice` (Phase 115 dryft) + backfill 7 zamowien — Phase 125
- [x] Fundament integracji Erli: pojedyncza globalna konfiguracja `/settings/integrations/erli`, szyfrowany Bearer API key, realny test `GET /svc/shop-api/inbox`, karta w hubie integracji oraz dokumentacja schematu/architektury — Phase 127 - [x] Fundament integracji Erli: pojedyncza globalna konfiguracja `/settings/integrations/erli`, szyfrowany Bearer API key, realny test `GET /svc/shop-api/inbox`, karta w hubie integracji oraz dokumentacja schematu/architektury — Phase 127
- [x] Import zamowien Erli: pobieranie `/inbox` przez cron i recznie, mapper do orderPRO, delta-only re-import, `invoice_requested` z danych firmowych/NIP, bezpieczny ACK `/inbox/mark-read` po bezblednym batchu — Phase 128 - [x] Import zamowien Erli: pobieranie `/inbox` przez cron i recznie, mapper do orderPRO, delta-only re-import, `invoice_requested` z danych firmowych/NIP, bezpieczny ACK `/inbox/mark-read` po bezblednym batchu — Phase 128
- [x] Mapowanie i synchronizacja statusow Erli: osobne pull/push mappings, discovery statusow z inboxa, reczny-only push `PATCH /orders/{id}/status`, cron `erli_status_sync` i zakladki w ustawieniach Erli — Phase 129
### Deferred ### Deferred
@@ -136,7 +137,7 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
### Active (In Progress) ### Active (In Progress)
- [ ] v3.8 Erli Marketplace Integration — Phase 129 next: mapowanie statusow pull/push Erli i synchronizacja statusow. - [ ] v3.8 Erli Marketplace Integration — Phase 130 next: generowanie etykiet i obsluga przesylek Erli.
### Planned (Next) ### Planned (Next)
@@ -247,6 +248,9 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API
| Erli import uzywa `/inbox` jako glownego zrodla zdarzen | Model inbox jest event-driven i pasuje do bezpiecznego przetwarzania batchy oraz przyszlych aktualizacji statusow | 2026-05-15 | Active | | Erli import uzywa `/inbox` jako glownego zrodla zdarzen | Model inbox jest event-driven i pasuje do bezpiecznego przetwarzania batchy oraz przyszlych aktualizacji statusow | 2026-05-15 | Active |
| ACK Erli przez `POST /inbox/mark-read` tylko po bezblednym batchu | Zapobiega utracie zdarzen, gdy lokalny import czesciowo sie nie powiedzie | 2026-05-15 | Active | | ACK Erli przez `POST /inbox/mark-read` tylko po bezblednym batchu | Zapobiega utracie zdarzen, gdy lokalny import czesciowo sie nie powiedzie | 2026-05-15 | Active |
| Phase 128 ma domyslne mapowania statusow, a UI mapowan dopiero Phase 129 | Import ma realnie dzialac teraz, a pelne strojenie pull/push statusow wymaga osobnej fazy | 2026-05-15 | Active | | Phase 128 ma domyslne mapowania statusow, a UI mapowan dopiero Phase 129 | Import ma realnie dzialac teraz, a pelne strojenie pull/push statusow wymaga osobnej fazy | 2026-05-15 | Active |
| Push statusow Erli obejmuje tylko reczne zmiany orderPRO (`change_source='manual'`) | Chroni przed petlami po imporcie, automatyzacjach i systemowych zmianach statusu | 2026-05-16 | Active |
| Erli -> orderPRO status pull uzywa tego samego inbox + ACK flow co import zamowien | Jedno bezpieczne zrodlo zdarzen Erli; brak osobnego status endpointu do utrzymania | 2026-05-16 | Active |
| Erli settings korzysta z zakladek Integracja/Statusy/Ustawienia | Po dodaniu mapowan strona wymagala parytetu UX z Allegro/shopPRO | 2026-05-16 | Active |
## Success Metrics ## Success Metrics
@@ -278,6 +282,6 @@ Quick Reference:
--- ---
*PROJECT.md — Updated when requirements or context change* *PROJECT.md — Updated when requirements or context change*
*Last updated: 2026-05-15 after Phase 128 (Erli Orders Import) closure; v3.8 milestone in progress* *Last updated: 2026-05-16 after Phase 129 (Erli Status Mapping + Sync) closure; v3.8 milestone in progress*

View File

@@ -10,11 +10,13 @@ v3.8 Erli Marketplace Integration — In progress
Pelna integracja z erli.pl wzorowana na istniejacej integracji Allegro: konfiguracja konta/API, pobieranie zamowien, mapowanie i synchronizacja statusow, generowanie etykiet, tracking oraz wlaczenie Erli w istniejace przeplywy automatyzacji, statystyk i obslugi zamowien. Pelna integracja z erli.pl wzorowana na istniejacej integracji Allegro: konfiguracja konta/API, pobieranie zamowien, mapowanie i synchronizacja statusow, generowanie etykiet, tracking oraz wlaczenie Erli w istniejace przeplywy automatyzacji, statystyk i obslugi zamowien.
Progress: 3 of 6 phases complete (50%).
| Phase | Name | Plans | Status | | Phase | Name | Plans | Status |
|-------|------|-------|--------| |-------|------|-------|--------|
| 127 | Erli Integration Foundation | 1/1 | Complete (2026-05-15; migration/manual Erli API smoke pending operator) | | 127 | Erli Integration Foundation | 1/1 | Complete (2026-05-15; migration/manual Erli API smoke pending operator) |
| 128 | Erli Orders Import | 1/1 | Complete (2026-05-15; migration/manual Erli import smoke pending operator) | | 128 | Erli Orders Import | 1/1 | Complete (2026-05-15; migration/manual Erli import smoke pending operator) |
| 129 | Erli Status Mapping + Sync | TBD | Not started | | 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 | TBD | Not started |
| 131 | Erli Tracking + Automation Hooks | TBD | Not started | | 131 | Erli Tracking + Automation Hooks | TBD | Not started |
| 132 | Erli Hardening, Observability + Docs | TBD | Not started | | 132 | Erli Hardening, Observability + Docs | TBD | Not started |
@@ -32,7 +34,7 @@ Plans: 128-01 (complete)
### Phase 129: Erli Status Mapping + Sync ### Phase 129: Erli Status Mapping + Sync
Focus: Osobne mapowanie pull/push statusow Erli, auto-discovery nieznanych statusow, cron synchronizacji orderPRO -> Erli i ochrona lokalnych statusow przy re-imporcie analogicznie do Allegro/shopPRO. Focus: Osobne mapowanie pull/push statusow Erli, auto-discovery nieznanych statusow, cron synchronizacji orderPRO -> Erli i ochrona lokalnych statusow przy re-imporcie analogicznie do Allegro/shopPRO.
Plans: TBD (defined during $paul-plan) Plans: 129-01 (complete)
### Phase 130: Erli Shipments + Labels ### Phase 130: Erli Shipments + Labels
@@ -553,4 +555,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
--- ---
*Roadmap created: 2026-03-12* *Roadmap created: 2026-03-12*
*Last updated: 2026-05-15 - Phase 128 UNIFY closed* *Last updated: 2026-05-16 - Phase 129 complete, ready for Phase 130 planning*

View File

@@ -2,22 +2,22 @@
## Project Reference ## Project Reference
See: .paul/PROJECT.md (updated 2026-05-07) See: .paul/PROJECT.md (updated 2026-05-16)
**Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami. **Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami.
**Current focus:** v3.8 Erli Marketplace Integration - Phase 128 complete; Phase 129 ready to plan. **Current focus:** v3.8 Erli Marketplace Integration - Phase 130 ready to plan.
## Current Position ## Current Position
Milestone: v3.8 Erli Marketplace Integration Milestone: v3.8 Erli Marketplace Integration
Phase: 129 of 132 (Erli Status Mapping + Sync) Phase: 130 of 132 (Erli Shipments + Labels) - Not started
Plan: Not started Plan: Not started
Status: Ready to plan Status: Ready to plan
Last activity: 2026-05-15 23:52 - Phase 128 complete; transitioned to Phase 129 Last activity: 2026-05-16 00:23 - Phase 129 complete, transitioned to Phase 130
Progress: Progress:
- Milestone v3.8: [####------] ~33% (Phases 127-128 complete) - Milestone v3.8: [#####-----] 50% (Phases 127-129 complete; Phase 130 next)
- Phase 129: [----------] 0% (not planned) - Phase 130: [----------] 0% (not planned)
## Loop Position ## Loop Position
@@ -29,20 +29,26 @@ PLAN -> APPLY -> UNIFY
## Session Continuity ## Session Continuity
Last session: 2026-05-15 23:52 Last session: 2026-05-16 00:23
Stopped at: Phase 128 complete Stopped at: Phase 129 complete, ready to plan Phase 130
Next action: $paul-plan for Phase 129 (Erli Status Mapping + Sync) Next action: $paul-plan for Phase 130 (Erli Shipments + Labels)
Resume file: .paul/phases/128-erli-orders-import/128-01-SUMMARY.md Resume file: .paul/ROADMAP.md
## Pending parallel work ## Pending parallel work
- None — Phase 118, 121, 122 wszystkie zacommitowane (8f14851, 360eef1). - None — Phase 118, 121, 122 wszystkie zacommitowane (8f14851, 360eef1).
## Git State ## Git State
Last phase commit: 2565d9b feat(128): erli orders import Last phase commit: b371420 feat(129): erli status mapping sync
Previous: d6b18a6 feat(127): erli integration foundation Previous: 2565d9b feat(128): erli orders import
Branch: main Branch: main
### Skill Audit (Phase 129)
| Expected | Invoked | Notes |
|----------|---------|-------|
| `sonar-scanner` | gap documented | Attempted before UNIFY; CLI is not available in PATH. |
## Pending Actions ## Pending Actions
- Manualne testy AC-1..AC-7 dla Phase 112 na zywej bazie (XAMPP online). - Manualne testy AC-1..AC-7 dla Phase 112 na zywej bazie (XAMPP online).
@@ -68,6 +74,8 @@ Branch: main
- Phase 127 follow-up: uruchom `php bin/migrate.php` gdy lokalny MySQL/XAMPP jest online, zapisz prawdziwy klucz Erli w `/settings/integrations/erli`, wykonaj realny test polaczenia i potwierdz wpis w hubie integracji. - Phase 127 follow-up: uruchom `php bin/migrate.php` gdy lokalny MySQL/XAMPP jest online, zapisz prawdziwy klucz Erli w `/settings/integrations/erli`, wykonaj realny test polaczenia i potwierdz wpis w hubie integracji.
- Phase 128 follow-up: uruchom `php bin/migrate.php`, wlacz import Erli w `/settings/integrations/erli`, kliknij `Importuj zamowienia teraz`, potwierdz `orders.source='erli'` i sprawdz, ze przy bezblednym batchu inbox ACK `POST /inbox/mark-read` nie zostawia nieprzeczytanych zdarzen. - Phase 128 follow-up: uruchom `php bin/migrate.php`, wlacz import Erli w `/settings/integrations/erli`, kliknij `Importuj zamowienia teraz`, potwierdz `orders.source='erli'` i sprawdz, ze przy bezblednym batchu inbox ACK `POST /inbox/mark-read` nie zostawia nieprzeczytanych zdarzen.
- Phase 128 verification gap: `vendor/bin/phpunit` nie istnieje w checkoutcie, wiec test `tests/Unit/ErliOrderMapperTest.php` nie zostal uruchomiony przez PHPUnit; wykonano `php -l` i runtime smoke mappera. - Phase 128 verification gap: `vendor/bin/phpunit` nie istnieje w checkoutcie, wiec test `tests/Unit/ErliOrderMapperTest.php` nie zostal uruchomiony przez PHPUnit; wykonano `php -l` i runtime smoke mappera.
- Phase 129 follow-up: uruchom `php bin/migrate.php`, sprawdz `/settings/integrations/erli` mapowania pull/push i zakladki, zapisz mapowania, ustaw `orderPRO -> Erli`, zmien recznie status zamowienia Erli i uruchom cron `erli_status_sync`.
- Phase 129 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`.
## Deferred to Next Milestones ## Deferred to Next Milestones
@@ -78,4 +86,4 @@ Branch: main
## Skill Requirements ## Skill Requirements
- `sonar-scanner` required after APPLY; Phase 116, Phase 117, Phase 121, Phase 122 and Phase 128 gaps documented because CLI was not available in PATH. - `sonar-scanner` required after APPLY; Phase 116, Phase 117, Phase 121, Phase 122, Phase 128 and Phase 129 gaps documented because CLI was not available in PATH.

View File

@@ -0,0 +1,40 @@
# 2026-05-16
## Co zrobiono
- [Phase 129, Plan 01] Wdrozono mapowanie i synchronizacje statusow Erli w obu kierunkach: Erli -> orderPRO przez inbox oraz orderPRO -> Erli przez `PATCH /orders/{id}/status`.
- Dodano tabele pull/push mapowan statusow Erli, seed statusow, kursor `last_status_pushed_at`, ustawienia `erli_status_sync_*` i cron `erli_status_sync`.
- Dodano repozytoria mapowan, `ErliStatusSyncService`, `ErliStatusSyncHandler`, discovery nieznanych statusow Erli i testy jednostkowe dla mappera/status sync.
- Ujednolicono `/settings/integrations/erli` z innymi integracjami przez zakladki Integracja, Statusy i Ustawienia.
- Udokumentowano gapy srodowiskowe: brak `vendor/bin/phpunit`, globalny XAMPP PHPUnit niekompatybilny z PHP, brak `sonar-scanner` w PATH.
## Zmienione pliki
- `.paul/phases/129-erli-status-mapping-sync/129-01-PLAN.md`
- `.paul/phases/129-erli-status-mapping-sync/129-01-SUMMARY.md`
- `.paul/ROADMAP.md`
- `.paul/STATE.md`
- `.paul/PROJECT.md`
- `.paul/codebase/architecture.md`
- `.paul/codebase/db_schema.md`
- `.paul/codebase/tech_changelog.md`
- `.paul/changelog/2026-05-16.md`
- `database/migrations/20260515_000116_add_erli_status_mapping_sync.sql`
- `src/Modules/Settings/ErliApiClient.php`
- `src/Modules/Settings/ErliIntegrationController.php`
- `src/Modules/Settings/ErliOrderMapper.php`
- `src/Modules/Settings/ErliOrderSyncStateRepository.php`
- `src/Modules/Settings/ErliOrdersSyncService.php`
- `src/Modules/Settings/ErliPullStatusMappingRepository.php`
- `src/Modules/Settings/ErliStatusMappingRepository.php`
- `src/Modules/Settings/ErliStatusSyncService.php`
- `src/Modules/Cron/ErliStatusSyncHandler.php`
- `src/Modules/Cron/CronHandlerFactory.php`
- `routes/web.php`
- `resources/views/settings/erli.php`
- `resources/lang/pl.php`
- `tests/Unit/ErliOrderMapperTest.php`
- `tests/Unit/ErliStatusSyncServiceTest.php`
- `DOCS/DB_SCHEMA.md`
- `DOCS/ARCHITECTURE.md`
- `DOCS/TECH_CHANGELOG.md`

View File

@@ -69,7 +69,7 @@ HTTP Request
1. **Import** — Cron handler → API client → `OrderImportService``OrdersRepository::insertOrder()``AutomationService::executeForNewOrder()` 1. **Import** — Cron handler → API client → `OrderImportService``OrdersRepository::insertOrder()``AutomationService::executeForNewOrder()`
2. **Re-import (Phase 111 + 112 + 119)**`OrderImportRepository::upsertOrderAggregate` wykrywa tranzycje `payment_status` z 0/1 na 2 i zwraca `payment_transition=true`. `AllegroOrderImportService` i `ShopproOrdersSyncService` na tej fladze emituja `payment.status_changed`, co przez chain reguly automatyzacji #7 zmienia `status_code` na `w_realizacji`. Logika preservacji `status_code` z Phase 62 pozostaje rozdzielona (`statusOverwriteAllowed` = `currentStatus='nieoplacone' && newPaymentStatus===2`). **Phase 112-01 (delta-only re-import):** przy `created=false` repo nie wywoluje `replaceAddresses/replaceItems/replaceNotes/replaceShipments/replaceStatusHistory``order_items.id` i flagi lokalne (np. `project_generated` z Phase 97) pozostaja stabilne. `updateOrderDelta()` aktualizuje wylacznie `status_code` (warunkowo, z propagacja anulowania), `payment_status`, `total_paid`, `is_canceled_by_buyer`, `source_updated_at`, `payload_json`, `fetched_at`, `updated_at`. Anulowanie ze zrodla (`is_canceled_by_buyer=1` lub zmapowany pull `status_code='anulowane'`) nadpisuje preservacje statusu. Identical-payload guard (`normalizePayloadJson`) pomija UPDATE gdy znormalizowany payload nie rozni sie od DB i brak innych tranzycji. **Phase 119-01 (total_paid protection):** gdy `paymentStatusUnchanged=true` (`oldPaymentStatus === newPaymentStatus`), `updateOrderDelta()` nie dolacza `total_paid` do UPDATE — chroni reczne korekty kwoty (np. zwroty czesciowe). `is_canceled_by_buyer` jest pomijane analogicznie, chyba ze `cancelledBySource=true` (cancel propagation ze zrodla zawsze wymusza wpis flagi). Pozostale pola (`status_code`, `payment_status`, `source_updated_at`, `payload_json`, `fetched_at`, `updated_at`) zachowuja niezmieniony kontrakt z Phase 112-01. 2. **Re-import (Phase 111 + 112 + 119)**`OrderImportRepository::upsertOrderAggregate` wykrywa tranzycje `payment_status` z 0/1 na 2 i zwraca `payment_transition=true`. `AllegroOrderImportService` i `ShopproOrdersSyncService` na tej fladze emituja `payment.status_changed`, co przez chain reguly automatyzacji #7 zmienia `status_code` na `w_realizacji`. Logika preservacji `status_code` z Phase 62 pozostaje rozdzielona (`statusOverwriteAllowed` = `currentStatus='nieoplacone' && newPaymentStatus===2`). **Phase 112-01 (delta-only re-import):** przy `created=false` repo nie wywoluje `replaceAddresses/replaceItems/replaceNotes/replaceShipments/replaceStatusHistory``order_items.id` i flagi lokalne (np. `project_generated` z Phase 97) pozostaja stabilne. `updateOrderDelta()` aktualizuje wylacznie `status_code` (warunkowo, z propagacja anulowania), `payment_status`, `total_paid`, `is_canceled_by_buyer`, `source_updated_at`, `payload_json`, `fetched_at`, `updated_at`. Anulowanie ze zrodla (`is_canceled_by_buyer=1` lub zmapowany pull `status_code='anulowane'`) nadpisuje preservacje statusu. Identical-payload guard (`normalizePayloadJson`) pomija UPDATE gdy znormalizowany payload nie rozni sie od DB i brak innych tranzycji. **Phase 119-01 (total_paid protection):** gdy `paymentStatusUnchanged=true` (`oldPaymentStatus === newPaymentStatus`), `updateOrderDelta()` nie dolacza `total_paid` do UPDATE — chroni reczne korekty kwoty (np. zwroty czesciowe). `is_canceled_by_buyer` jest pomijane analogicznie, chyba ze `cancelledBySource=true` (cancel propagation ze zrodla zawsze wymusza wpis flagi). Pozostale pola (`status_code`, `payment_status`, `source_updated_at`, `payload_json`, `fetched_at`, `updated_at`) zachowuja niezmieniony kontrakt z Phase 112-01.
3. **Status update**`OrdersController::updateStatus()``OrdersRepository::updateStatus()` → automation check 3. **Status update**`OrdersController::updateStatus()``OrdersRepository::updateStatus()` → automation check
4. **Status sync** — Cron → `AllegroStatusSyncService` / `ShopproStatusSyncService` → carrier API 4. **Status sync** — Cron → `AllegroStatusSyncService` / `ShopproStatusSyncService` / `ErliStatusSyncService` → marketplace API
### Statistics Summary ### Statistics Summary
1. **Request**`/statistics/summary``OrdersStatisticsController::summary()` 1. **Request**`/statistics/summary``OrdersStatisticsController::summary()`
@@ -97,6 +97,7 @@ HTTP Request
|---------|------| |---------|------|
| `AllegroOrdersImportHandler` | Fetch new Allegro orders | | `AllegroOrdersImportHandler` | Fetch new Allegro orders |
| `AllegroStatusSyncHandler` | Push status changes to Allegro | | `AllegroStatusSyncHandler` | Push status changes to Allegro |
| `ErliStatusSyncHandler` | Pull Erli status events via inbox or push manual local status changes to Erli |
| `AllegroTokenRefreshHandler` | OAuth token refresh (24h expiry) | | `AllegroTokenRefreshHandler` | OAuth token refresh (24h expiry) |
| `ShopproOrdersImportHandler` | Fetch new shopPRO orders | | `ShopproOrdersImportHandler` | Fetch new shopPRO orders |
| `ShopproStatusSyncHandler` | Push status to shopPRO | | `ShopproStatusSyncHandler` | Push status to shopPRO |

View File

@@ -370,6 +370,7 @@ UNIQUE: `(order_id, source_payment_id)`
UNIQUE: `(integration_id, shoppro_status_code)` UNIQUE: `(integration_id, shoppro_status_code)`
**integration_order_sync_state** — Track order fetch progress per integration **integration_order_sync_state** — Track order fetch progress per integration
- Phase 129 adds `last_status_pushed_at` for Erli manual status push cursor.
**integration_order_status_sync_state** — Track status sync progress per integration and direction **integration_order_status_sync_state** — Track status sync progress per integration and direction
@@ -517,6 +518,14 @@ UNIQUE: `(type, name)`
| `created_at` | DATETIME | | | `created_at` | DATETIME | |
| `updated_at` | DATETIME | | | `updated_at` | DATETIME | |
**erli_order_status_mappings** — orderPRO status → Erli status used for status push
- Seeded official Erli statuses: `created`, `canceled`, `readyToProcess`, `inProgress`, `sent`, `readyToPickup`, `received`, `returned`, `returningToSender`, `unknown`.
- `orderpro_status_code` is nullable; unmapped status rows are skipped by push sync.
**erli_order_status_pull_mappings** — Erli status → orderPRO status used during inbox import
- Seeded defaults: `pending -> nieoplacone`, `purchased -> nowe`, `cancelled -> anulowane`.
- Unknown Erli statuses are discovered from `/inbox` and inserted with nullable mapping.
**allegro_delivery_method_mappings** — Map order delivery method strings to Allegro services **allegro_delivery_method_mappings** — Map order delivery method strings to Allegro services
| Column | Type | Notes | | Column | Type | Notes |
|--------|------|-------| |--------|------|-------|

View File

@@ -1,5 +1,16 @@
# Technical Changelog # Technical Changelog
## 2026-05-16 - Phase 129 Plan 01: Erli Status Mapping + Sync
**Co zrobiono:**
- Dodano mapowania statusow Erli pull/push, `ErliStatusSyncService`, `ErliStatusSyncHandler`, endpoint `PATCH /orders/{id}/status` w kliencie API oraz UI mapowan w `/settings/integrations/erli`.
- Import Erli odkrywa surowe statusy z inboxa i uzywa `erli_order_status_pull_mappings`; push obejmuje tylko reczne zmiany statusu z `order_status_history.change_source='manual'`.
**Dlaczego:**
- Phase 128 miala domyslne statusy; Phase 129 daje operatorowi kontrolowane mapowanie i bezpieczny push bez petli automatyzacji.
---
## 2026-05-13 - Phase 126 Plan 01: Invoice GUS Field Mapping Fix (KRS heuristic) ## 2026-05-13 - Phase 126 Plan 01: Invoice GUS Field Mapping Fix (KRS heuristic)
**Co zrobiono:** **Co zrobiono:**

View File

@@ -0,0 +1,304 @@
---
phase: 129-erli-status-mapping-sync
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- database/migrations/20260515_000116_add_erli_status_mapping_sync.sql
- src/Modules/Settings/ErliApiClient.php
- src/Modules/Settings/ErliIntegrationController.php
- src/Modules/Settings/ErliIntegrationRepository.php
- src/Modules/Settings/ErliOrderMapper.php
- src/Modules/Settings/ErliOrdersSyncService.php
- src/Modules/Settings/ErliStatusMappingRepository.php
- src/Modules/Settings/ErliPullStatusMappingRepository.php
- src/Modules/Settings/ErliStatusSyncService.php
- src/Modules/Cron/ErliStatusSyncHandler.php
- src/Modules/Cron/CronHandlerFactory.php
- routes/web.php
- resources/views/settings/erli.php
- resources/lang/pl.php
- tests/Unit/ErliOrderMapperTest.php
- tests/Unit/ErliStatusSyncServiceTest.php
- DOCS/DB_SCHEMA.md
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
autonomous: true
delegation: auto
---
<objective>
## Goal
Wdrozyc mapowanie i synchronizacje statusow Erli w obu kierunkach: Erli -> orderPRO przy imporcie `/inbox` oraz orderPRO -> Erli przez cron push na `PATCH /orders/{id}/status`.
## Purpose
Phase 128 importuje zamowienia Erli, ale statusy sa jeszcze mapowane sztywnymi defaultami. Phase 129 ma dac operatorowi kontrolowane mapowania pull/push, bezpieczne odkrywanie nowych statusow z inboxa oraz automatyczny push recznych zmian statusu z orderPRO do Erli.
## Output
Nowe tabele mapowan statusow Erli, UI w ustawieniach Erli, endpointy zapisu mapowan, serwis synchronizacji statusow, handler crona `erli_status_sync`, rozszerzony mapper importu oraz testy i dokumentacja.
</objective>
<context>
<clarifications>
- **Zakres** — Czy Phase 129 ma objac od razu oba kierunki synchronizacji statusow Erli?
-> Odpowiedz: Tak, oba kierunki.
- **Statusy** — Jak traktowac liste statusow Erli w UI mapowan?
-> Odpowiedz: Seed + discovery.
- **Push** — Ktore lokalne zmiany statusu orderPRO maja byc wysylane do Erli?
-> Odpowiedz: Tylko reczne zmiany statusu (`order_status_history.change_source='manual'`).
</clarifications>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
@.paul/codebase/architecture.md
@.paul/codebase/db_schema.md
@DOCS/DB_SCHEMA.md
@DOCS/ARCHITECTURE.md
## Prior Work
@.paul/phases/127-erli-integration-foundation/127-01-SUMMARY.md
@.paul/phases/128-erli-orders-import/128-01-SUMMARY.md
## External API Context
- Official Erli docs checked 2026-05-15: `https://erli.pl/svc/shop-api/doc/`
- Relevant Erli contract:
- `/inbox` contains new orders and order status changes.
- Base order status values documented for import: `pending`, `purchased`, `cancelled`.
- Swagger exposes `PATCH /orders/{id}/status` with body `{"status": "created|canceled|readyToProcess|inProgress|sent|readyToPickup|received|returned|returningToSender|unknown"}`.
- Erli docs state that order update for Erli shipments should not send `trackingNumber`; shipment/tracking remains Phase 130-131.
## Source Files
@src/Modules/Settings/ErliApiClient.php
@src/Modules/Settings/ErliIntegrationRepository.php
@src/Modules/Settings/ErliIntegrationController.php
@src/Modules/Settings/ErliOrderMapper.php
@src/Modules/Settings/ErliOrdersSyncService.php
@src/Modules/Settings/AllegroStatusMappingRepository.php
@src/Modules/Settings/AllegroPullStatusMappingRepository.php
@src/Modules/Settings/AllegroStatusMappingController.php
@src/Modules/Settings/AllegroStatusSyncService.php
@src/Modules/Settings/ShopproStatusSyncService.php
@src/Modules/Settings/OrderStatusRepository.php
@src/Modules/Cron/CronHandlerFactory.php
@src/Modules/Cron/AllegroStatusSyncHandler.php
@resources/views/settings/erli.php
@resources/views/settings/allegro.php
@routes/web.php
@resources/lang/pl.php
@tests/Unit/AllegroStatusSyncServiceTest.php
@tests/Unit/ErliOrderMapperTest.php
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| `sonar-scanner` | required | After APPLY, before UNIFY | ○ |
| `/feature-dev` | optional | New marketplace integration feature | ○ |
| `/code-review` | optional | Before UNIFY if broad risks remain | ○ |
| `/frontend-design` | optional | If Erli settings UI needs significant redesign | ○ |
**BLOCKING:** Required skills MUST be attempted before UNIFY. If `sonar-scanner` is unavailable in PATH, document the gap in SUMMARY and STATE as in Phase 128.
## Skill Invocation Checklist
- [ ] `sonar-scanner` run or documented unavailable
</skills>
<acceptance_criteria>
## AC-1: Erli status mapping schema and seeds
```gherkin
Given Phase 129 migration is applied
When the database is inspected
Then Erli has separate pull and push mapping tables, seeded with documented Erli statuses, and `erli_status_sync` exists as a disabled-by-default or settings-controlled cron schedule
```
## AC-2: Erli settings UI exposes status mappings
```gherkin
Given an operator opens `/settings/integrations/erli`
When they review the settings page
Then they can configure Erli -> orderPRO pull mappings, orderPRO -> Erli push mappings, status sync direction, and status sync interval without editing code
```
## AC-3: Import uses configurable pull mappings with discovery
```gherkin
Given an Erli inbox message contains status `pending`, `purchased`, `cancelled`, or a new status
When `ErliOrdersSyncService` imports the message
Then `ErliOrderMapper` uses the configured pull mapping when present, falls back to safe defaults when absent, and stores unknown Erli statuses for later mapping
```
## AC-4: Cron pushes only manual orderPRO status changes to Erli
```gherkin
Given an Erli order has a manual status change in `order_status_history`
When the `erli_status_sync` cron handler runs in orderPRO -> Erli direction
Then the service maps the current orderPRO status to an Erli status and calls `PATCH /orders/{id}/status` only for mapped manual changes after the last pushed cursor
```
## AC-5: Push is safe and observable
```gherkin
Given Erli credentials are missing, mappings are incomplete, or Erli API returns an error
When status sync runs
Then the result reports pushed/skipped/failed counts, records bounded errors, and advances the push cursor only for successfully processed change timestamps
```
## AC-6: Documentation and tests cover status behavior
```gherkin
Given Phase 129 implementation is complete
When verification runs
Then mapper/status-sync unit tests, PHP lint, docs, and PAUL summary describe the new status mapping and sync behavior
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Add Erli status mapping persistence, seeds, API client method</name>
<files>
database/migrations/20260515_000116_add_erli_status_mapping_sync.sql,
src/Modules/Settings/ErliApiClient.php,
src/Modules/Settings/ErliIntegrationRepository.php,
src/Modules/Settings/ErliStatusMappingRepository.php,
src/Modules/Settings/ErliPullStatusMappingRepository.php,
DOCS/DB_SCHEMA.md
</files>
<action>
Create the persistence foundation:
- Add `erli_order_status_mappings` for push mappings (`orderpro_status_code` -> `erli_status_code`, name, timestamps).
- Add `erli_order_status_pull_mappings` for pull mappings (`erli_status_code` -> `orderpro_status_code`, name, timestamps).
- Seed documented pull statuses: `pending -> nieoplacone`, `purchased -> nowe`, `cancelled -> anulowane`.
- Seed documented push status options from official swagger: `created`, `canceled`, `readyToProcess`, `inProgress`, `sent`, `readyToPickup`, `received`, `returned`, `returningToSender`, `unknown`. Keep orderPRO mappings nullable where no safe default exists.
- Seed `app_settings` keys `erli_status_sync_direction=erli_to_orderpro` and `erli_status_sync_interval_minutes=15`.
- Seed `cron_schedules.job_type='erli_status_sync'` idempotently; prefer disabled until operator enables/import settings are confirmed, unless existing local pattern makes enabled safer.
- Add repositories mirroring Allegro style: list, replaceAll, find mapped status, upsertDiscoveredStatus, build reverse map.
- Add `ErliApiClient::updateOrderStatus(array $credentials, string $orderId, string $erliStatus): array` calling `PATCH /orders/{id}/status` with JSON `{status: ...}`.
- Extend repository credentials/settings only as needed for status sync settings; do not introduce per-account Erli complexity.
- Update DB schema docs.
</action>
<verify>
`C:\xampp\php\php.exe -l src/Modules/Settings/ErliApiClient.php`
`C:\xampp\php\php.exe -l src/Modules/Settings/ErliStatusMappingRepository.php`
`C:\xampp\php\php.exe -l src/Modules/Settings/ErliPullStatusMappingRepository.php`
Inspect migration for idempotent DDL/seed and no raw runtime use of `DB_HOST_REMOTE`.
</verify>
<done>AC-1 foundation is implemented and AC-5 has API error result shape available.</done>
</task>
<task type="auto">
<name>Task 2: Add Erli settings UI and routes for pull/push mappings</name>
<files>
src/Modules/Settings/ErliIntegrationController.php,
routes/web.php,
resources/views/settings/erli.php,
resources/lang/pl.php,
DOCS/ARCHITECTURE.md
</files>
<action>
Extend the Erli settings page without creating inline CSS or native alert/confirm:
- Inject `OrderStatusRepository`, `ErliStatusMappingRepository`, and `ErliPullStatusMappingRepository`.
- Pass orderPRO statuses, Erli push statuses, Erli pull mappings, current sync direction and interval to the view.
- Add POST handlers for saving pull mappings and push mappings with CSRF validation.
- Add status sync direction and interval fields to the existing Erli settings form, validating direction against `erli_to_orderpro` / `orderpro_to_erli` and interval 1-1440.
- Add routes under `/settings/integrations/erli/statuses/save-pull` and `/settings/integrations/erli/statuses/save-push` (or one clear equivalent route if controller shape is cleaner).
- Render compact mapping tables similar to Allegro, but keep Erli page simple; do not build a landing/marketing page.
- Add Polish translations for labels, hints, empty states, validation and flash messages.
- Preserve existing test/import flash behavior from Phase 128.
</action>
<verify>
`C:\xampp\php\php.exe -l src/Modules/Settings/ErliIntegrationController.php`
`C:\xampp\php\php.exe -l routes/web.php`
`C:\xampp\php\php.exe -l resources/views/settings/erli.php`
`C:\xampp\php\php.exe -l resources/lang/pl.php`
Manual inspect that forms include `_token` and use escaped output via `$e()`.
</verify>
<done>AC-2 is satisfied and existing Phase 127/128 settings actions remain reachable.</done>
</task>
<task type="auto">
<name>Task 3: Wire pull discovery and push cron sync</name>
<files>
src/Modules/Settings/ErliOrderMapper.php,
src/Modules/Settings/ErliOrdersSyncService.php,
src/Modules/Settings/ErliStatusSyncService.php,
src/Modules/Cron/ErliStatusSyncHandler.php,
src/Modules/Cron/CronHandlerFactory.php,
tests/Unit/ErliOrderMapperTest.php,
tests/Unit/ErliStatusSyncServiceTest.php,
DOCS/ARCHITECTURE.md,
DOCS/TECH_CHANGELOG.md
</files>
<action>
Implement runtime behavior:
- Let `ErliOrderMapper` accept an optional pull mapping dependency or mapping array while preserving the existing no-dependency unit-test usage.
- During import, discover any raw Erli order status from inbox and upsert it into the pull mapping repository before/while mapping.
- Use configured pull mapping when available; otherwise keep Phase 128 defaults (`pending`, `purchased`, `cancelled`) and safe fallback.
- Add `ErliStatusSyncService` similar to Allegro/shopPRO:
- Direction `erli_to_orderpro`: run Erli inbox import with `ignore_orders_fetch_enabled=true` so status-change messages are pulled via the same safe ACK path.
- Direction `orderpro_to_erli`: find only Erli orders with `order_status_history.change_source='manual'` newer than `last_status_pushed_at` for the active Erli integration.
- Use push mapping to call `ErliApiClient::updateOrderStatus()`.
- Count `pushed`, `skipped`, `failed`; keep bounded errors; advance cursor only to latest successfully processed change timestamp.
- Add `ErliStatusSyncHandler` and register `erli_status_sync` in `CronHandlerFactory`.
- Add/update unit tests for pull mapping override, unknown status discovery behavior, skipped unmapped push, successful push, and failed push cursor behavior.
- Update architecture/changelog docs.
</action>
<verify>
`C:\xampp\php\php.exe -l src/Modules/Settings/ErliOrderMapper.php`
`C:\xampp\php\php.exe -l src/Modules/Settings/ErliOrdersSyncService.php`
`C:\xampp\php\php.exe -l src/Modules/Settings/ErliStatusSyncService.php`
`C:\xampp\php\php.exe -l src/Modules/Cron/ErliStatusSyncHandler.php`
`C:\xampp\php\php.exe -l src/Modules/Cron/CronHandlerFactory.php`
`C:\xampp\php\php.exe -l tests/Unit/ErliStatusSyncServiceTest.php`
If available: `vendor/bin/phpunit tests/Unit/ErliOrderMapperTest.php tests/Unit/ErliStatusSyncServiceTest.php`
Required skill: `sonar-scanner` or document unavailable.
</verify>
<done>AC-3, AC-4, AC-5 and AC-6 are satisfied.</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Do not change shipment/label generation flows; Erli shipment creation belongs to Phase 130.
- Do not send Erli `trackingNumber` in status sync; official docs say shipment number is handled after shipment generation.
- Do not broaden push to automation/system/import changes; Phase 129 push covers only manual `order_status_history` rows.
- Do not alter Allegro/shopPRO mapping semantics except for reading patterns.
- Do not use `DB_HOST_REMOTE` in runtime code.
- Do not add native `alert()`/`confirm()` or inline CSS in Erli settings.
## SCOPE LIMITS
- No Erli webhook registration in this plan; inbox/cron remains the source.
- No product stock adjustment on Erli cancellation; document if needed for a later products/inventory phase.
- No manual live Erli API call is required during APPLY; live smoke is an operator follow-up unless credentials and DB are ready.
- No redesign of the whole integrations UI; keep changes scoped to Erli settings.
</boundaries>
<verification>
Before declaring plan complete:
- [ ] PHP lint passes for all changed PHP/view/lang/test files.
- [ ] Migration is idempotent and documented in `DOCS/DB_SCHEMA.md`.
- [ ] Erli settings page renders pull/push mapping forms with CSRF and escaped values.
- [ ] Mapper tests cover configured mapping fallback and unknown status handling.
- [ ] Status sync tests cover successful push, skipped unmapped push, failed push, and cursor behavior.
- [ ] `vendor/bin/phpunit tests/Unit/ErliOrderMapperTest.php tests/Unit/ErliStatusSyncServiceTest.php` run if available; otherwise gap documented.
- [ ] `sonar-scanner` attempted per SPECIAL-FLOWS; unavailable CLI documented as gap.
- [ ] `git diff --check` passes.
- [ ] All acceptance criteria met.
</verification>
<success_criteria>
- Erli status pull/push mappings can be configured from UI.
- Erli import uses configured pull mapping and discovers unknown raw statuses.
- Erli status cron can push only manual orderPRO status changes to Erli using `PATCH /orders/{id}/status`.
- Failures are observable and do not incorrectly advance push cursor.
- Docs and tests reflect the new behavior.
</success_criteria>
<output>
After completion, create `.paul/phases/129-erli-status-mapping-sync/129-01-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,198 @@
---
phase: 129-erli-status-mapping-sync
plan: 01
subsystem: settings, integrations, cron, database
tags: [erli, status-mapping, status-sync, cron, marketplace]
requires:
- phase: 127-erli-integration-foundation
provides: global Erli credentials, API client, settings page and hub row
- phase: 128-erli-orders-import
provides: Erli inbox import, order mapper, sync state and safe ACK flow
provides:
- Configurable Erli -> orderPRO pull status mappings with discovery
- Configurable orderPRO -> Erli push status mappings
- Cron-driven Erli status sync in pull or push direction
- Tabbed Erli settings UI consistent with other integrations
affects: [phase-130-erli-shipments-labels, phase-131-erli-tracking-automation, erli-settings]
tech-stack:
added: []
patterns: [separate pull/push marketplace status mappings, manual-only push cursor, tabbed integration settings]
key-files:
created:
- database/migrations/20260515_000116_add_erli_status_mapping_sync.sql
- src/Modules/Settings/ErliStatusMappingRepository.php
- src/Modules/Settings/ErliPullStatusMappingRepository.php
- src/Modules/Settings/ErliStatusSyncService.php
- src/Modules/Cron/ErliStatusSyncHandler.php
- tests/Unit/ErliStatusSyncServiceTest.php
modified:
- src/Modules/Settings/ErliApiClient.php
- src/Modules/Settings/ErliIntegrationController.php
- src/Modules/Settings/ErliOrderMapper.php
- src/Modules/Settings/ErliOrdersSyncService.php
- src/Modules/Settings/ErliOrderSyncStateRepository.php
- src/Modules/Cron/CronHandlerFactory.php
- routes/web.php
- resources/views/settings/erli.php
- resources/lang/pl.php
- DOCS/DB_SCHEMA.md
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
key-decisions:
- "Push to Erli uses only manual orderPRO status changes from order_status_history.change_source='manual'."
- "Pull status changes reuse the existing Erli inbox import and ACK flow instead of a separate status endpoint."
- "Erli settings use the same tabbed UI pattern as Allegro/shopPRO."
patterns-established:
- "Erli has separate pull and push mapping repositories/tables to avoid mixing import and outbound sync semantics."
- "Unknown Erli statuses discovered in inbox are stored for later operator mapping."
duration: 35min
started: 2026-05-16T00:00:00+02:00
completed: 2026-05-16T00:23:35+02:00
---
# Phase 129 Plan 01: Erli Status Mapping + Sync Summary
Erli now supports configurable pull/push status mapping, status discovery from inbox, and cron-based status synchronization with a tabbed settings UI.
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~35min |
| Started | 2026-05-16T00:00:00+02:00 |
| Completed | 2026-05-16T00:23:35+02:00 |
| Tasks | 3 completed |
| Files modified | 25 phase files, excluding unrelated `.vscode/ftp-kr.sync.cache.json` |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Erli status mapping schema and seeds | Pass | Migration adds pull/push mapping tables, `integration_order_sync_state.last_status_pushed_at`, app settings, seed status options, and disabled `erli_status_sync` schedule. |
| AC-2: Erli settings UI exposes status mappings | Pass | `/settings/integrations/erli` exposes tabbed panels for integration, statuses and settings; operators can save pull/push mappings, direction and interval with CSRF-protected forms. |
| AC-3: Import uses configurable pull mappings with discovery | Pass | `ErliOrderMapper` uses configured pull mappings with safe fallbacks; `ErliOrdersSyncService` stores newly seen raw Erli statuses for later mapping. |
| AC-4: Cron pushes only manual orderPRO status changes to Erli | Pass | `ErliStatusSyncService` selects only Erli orders with manual status history newer than `last_status_pushed_at` and calls `PATCH /orders/{id}/status` only when mapped. |
| AC-5: Push is safe and observable | Pass | Sync result reports pushed/skipped/failed and bounded errors; cursor advances only through successfully processed timestamps. |
| AC-6: Documentation and tests cover status behavior | Pass with env gaps | PHP lint and diff checks passed; PHPUnit and Sonar are documented gaps because local CLIs are unavailable/broken. Docs were updated. |
## Accomplishments
- Added Erli status persistence for both directions with seed statuses from the documented Erli status contract.
- Extended Erli API client with `PATCH /orders/{id}/status`.
- Added repositories and controller endpoints for saving pull and push mappings.
- Reused the existing inbox import path for Erli -> orderPRO status pulls.
- Added `ErliStatusSyncService` and cron handler for configurable pull/push sync.
- Updated Erli settings UI to match other integrations with tabs.
- Added mapper and status sync unit test coverage files.
## Task Commits
No per-task commits were created during APPLY. Phase transition creates the scoped phase commit.
| Task | Commit | Type | Description |
|------|--------|------|-------------|
| Task 1: Persistence/API foundation | pending phase commit | feat | Mapping tables, seeds, repositories and API status update method. |
| Task 2: Settings UI/routes | pending phase commit | feat | Erli settings status controls, mapping forms, routes and translations. |
| Task 3: Runtime sync/tests/docs | pending phase commit | feat/test/docs | Discovery, pull/push sync service, cron handler, tests and docs. |
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `database/migrations/20260515_000116_add_erli_status_mapping_sync.sql` | Created | Status mapping tables, sync cursor, seeds and cron/app settings. |
| `src/Modules/Settings/ErliStatusMappingRepository.php` | Created | Push mapping persistence for orderPRO -> Erli. |
| `src/Modules/Settings/ErliPullStatusMappingRepository.php` | Created | Pull mapping persistence and discovery for Erli -> orderPRO. |
| `src/Modules/Settings/ErliStatusSyncService.php` | Created | Pull/push status sync orchestration and result counters. |
| `src/Modules/Cron/ErliStatusSyncHandler.php` | Created | Cron entrypoint for `erli_status_sync`. |
| `tests/Unit/ErliStatusSyncServiceTest.php` | Created | Unit tests for push success, skipped unmapped changes and failure cursor behavior. |
| `src/Modules/Settings/ErliApiClient.php` | Modified | Added authenticated Erli status update request. |
| `src/Modules/Settings/ErliIntegrationController.php` | Modified | Added tab state, status settings and mapping save handlers. |
| `src/Modules/Settings/ErliOrderMapper.php` | Modified | Added configurable pull mapping with safe fallback. |
| `src/Modules/Settings/ErliOrdersSyncService.php` | Modified | Added raw Erli status discovery during inbox import. |
| `src/Modules/Settings/ErliOrderSyncStateRepository.php` | Modified | Added push cursor support. |
| `src/Modules/Cron/CronHandlerFactory.php` | Modified | Registered `erli_status_sync`. |
| `routes/web.php` | Modified | Wired repositories, status sync service and mapping routes. |
| `resources/views/settings/erli.php` | Modified | Added tabbed UI and pull/push mapping forms. |
| `resources/lang/pl.php` | Modified | Added Erli status mapping/sync/tabs translations. |
| `DOCS/DB_SCHEMA.md` | Modified | Documented new tables/settings/cursor. |
| `DOCS/ARCHITECTURE.md` | Modified | Documented Erli status sync flow and tabbed settings. |
| `DOCS/TECH_CHANGELOG.md` | Modified | Logged Phase 129 and Erli settings tabs fix. |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Push only manual status changes | Prevents loops from imports, automation and system updates. | Operator intent is explicit; automated changes stay local unless manually changed. |
| Pull uses inbox import | Erli status events arrive through inbox and the Phase 128 ACK flow is already safe. | One source of truth for Erli event processing. |
| Separate pull/push mapping tables | Import statuses and outbound Erli status values have different semantics. | Safer UI and easier future extension. |
| Unknown pull statuses are discovered | Erli can introduce or send statuses not present in seed data. | Operator can map new statuses without code changes. |
| Erli settings use tabs | Page grew after Phase 129 and needed parity with Allegro/shopPRO. | Better scanability, same interaction model as other integrations. |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 1 | UI parity fix, no backend contract change. |
| Scope additions | 1 | Tabbed Erli settings page added during final verification. |
| Deferred | 2 | Environment-dependent verification remains operator follow-up. |
**Total impact:** Positive UI consistency improvement; no expansion into shipments/labels/tracking.
### Auto-fixed Issues
**1. Erli settings were missing tabs**
- **Found during:** Post-APPLY manual inspection.
- **Issue:** Erli settings displayed all integration/status/import/test sections in one vertical page, unlike Allegro/shopPRO.
- **Fix:** Added active `tab` handling, `return_to` for forms and standard `content-tabs-nav` / `content-tab-panel` markup.
- **Files:** `resources/views/settings/erli.php`, `src/Modules/Settings/ErliIntegrationController.php`, `resources/lang/pl.php`, docs.
- **Verification:** PHP lint for view/controller/lang and `git diff --check`.
### Deferred Items
- Phase 129 follow-up: run `php bin/migrate.php`, verify `/settings/integrations/erli` mappings, set `orderPRO -> Erli`, manually change an Erli order status and run `erli_status_sync`.
- Phase 129 verification gap: `vendor/bin/phpunit` is absent in this checkout and global XAMPP PHPUnit is incompatible with the current PHP, so PHPUnit tests were not executed.
- Phase 129 skill gap: `sonar-scanner` is not available in PATH, so Sonar scan could not run.
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| `vendor/bin/phpunit` missing | Documented as verification gap; PHP lint and diff checks were run. |
| Global XAMPP `phpunit` crashes on removed PHP `each()` | Documented as verification gap. |
| `sonar-scanner` unavailable | Documented in SUMMARY and STATE skill audit. |
## Verification Results
| Check | Result |
|-------|--------|
| `php -l resources/views/settings/erli.php` | Pass |
| `php -l src/Modules/Settings/ErliIntegrationController.php` | Pass |
| `php -l resources/lang/pl.php` | Pass |
| Phase APPLY PHP lints for changed PHP/view/test files | Pass |
| `git diff --check` | Pass |
| `vendor/bin/phpunit tests/Unit/ErliOrderMapperTest.php tests/Unit/ErliStatusSyncServiceTest.php` | Not run: `vendor/bin/phpunit` missing |
| `phpunit --version` | Failed: old XAMPP PHPUnit uses removed `each()` |
| `sonar-scanner --version` | Failed: command not found |
Skill audit: required `sonar-scanner` was attempted and documented unavailable.
## Next Phase Readiness
**Ready:**
- Erli status mapping and sync foundation is in place for later shipment/tracking work.
- Erli settings now has the tab structure needed to host future shipment/label settings.
- Push sync cursor and manual-change filtering provide a safe outbound pattern for future Erli API writes.
**Concerns:**
- Live Erli behavior still needs migration and production credential smoke testing.
- PHPUnit dev dependencies are missing locally, so unit tests need a `composer install` or CI run.
- Sonar scan still depends on installing/configuring `sonar-scanner` in PATH.
**Blockers:**
- None for planning Phase 130. Manual smoke remains required before production confidence.
---
*Phase: 129-erli-status-mapping-sync, Plan: 01*
*Completed: 2026-05-16*

View File

@@ -39,7 +39,7 @@ HTTP Request
| **Accounting** | 5 | `AccountingController`, `ReceiptService`, `ReceiptRepository` | Receipts, invoices, PDF, Excel export | | **Accounting** | 5 | `AccountingController`, `ReceiptService`, `ReceiptRepository` | Receipts, invoices, PDF, Excel export |
| **Email** | 3 | `EmailSendingService`, `VariableResolver`, `AttachmentGenerator` | Template-based email with PDF attachments | | **Email** | 3 | `EmailSendingService`, `VariableResolver`, `AttachmentGenerator` | Template-based email with PDF attachments |
| **Automation** | 6 | `AutomationService` (834 LOC), `AutomationRepository`, `AutomationExecutionLogRepository` | Event→condition→action rules, email triggers | | **Automation** | 6 | `AutomationService` (834 LOC), `AutomationRepository`, `AutomationExecutionLogRepository` | Event→condition→action rules, email triggers |
| **Settings** | 57+ | Integration controllers, OAuth clients, API clients, mappers | Allegro/shopPRO/Erli/Apaczka/InPost config, status mappings | | **Settings** | 60+ | Integration controllers, OAuth clients, API clients, mappers | Allegro/shopPRO/Erli/Apaczka/InPost config, status mappings |
| **Sms** | 3 | `SmsMessageRepository`, `SmsConversationService`, `SmsplanetWebhookController` | SMSPLANET outbound order SMS, inbound webhook parsing, order matching | | **Sms** | 3 | `SmsMessageRepository`, `SmsConversationService`, `SmsplanetWebhookController` | SMSPLANET outbound order SMS, inbound webhook parsing, order matching |
| **Notifications** | 3 | `NotificationRepository`, `NotificationController`, `NotificationApiController` | Global notification history, unread polling API, mark-read actions | | **Notifications** | 3 | `NotificationRepository`, `NotificationController`, `NotificationApiController` | Global notification history, unread polling API, mark-read actions |
| **Cron** | 12 | `CronRepository`, `CronHandlerFactory`, handler classes | Scheduled imports, syncs, token refresh | | **Cron** | 12 | `CronRepository`, `CronHandlerFactory`, handler classes | Scheduled imports, syncs, token refresh |
@@ -74,7 +74,7 @@ HTTP Request
### Order Lifecycle ### Order Lifecycle
1. **Import** — Cron handler → API client → `OrderImportService``OrdersRepository::insertOrder()``AutomationService::executeForNewOrder()` 1. **Import** — Cron handler → API client → `OrderImportService``OrdersRepository::insertOrder()``AutomationService::executeForNewOrder()`
2. **Status update**`OrdersController::updateStatus()``OrdersRepository::updateStatus()` → automation check 2. **Status update**`OrdersController::updateStatus()``OrdersRepository::updateStatus()` → automation check
3. **Status sync** — Cron → `AllegroStatusSyncService` / `ShopproStatusSyncService` → carrier API 3. **Status sync** — Cron → `AllegroStatusSyncService` / `ShopproStatusSyncService` / `ErliStatusSyncService` → marketplace API
### Statistics Summary ### Statistics Summary
1. **Request**`/statistics/summary``OrdersStatisticsController::summary()` 1. **Request**`/statistics/summary``OrdersStatisticsController::summary()`
@@ -112,6 +112,7 @@ HTTP Request
| `AllegroTokenRefreshHandler` | OAuth token refresh (24h expiry) | | `AllegroTokenRefreshHandler` | OAuth token refresh (24h expiry) |
| `ShopproOrdersImportHandler` | Fetch new shopPRO orders | | `ShopproOrdersImportHandler` | Fetch new shopPRO orders |
| `ErliOrdersImportHandler` | Fetch unread Erli inbox order events | | `ErliOrdersImportHandler` | Fetch unread Erli inbox order events |
| `ErliStatusSyncHandler` | Pull Erli status events via inbox or push manual local status changes to Erli |
| `ShopproStatusSyncHandler` | Push status to shopPRO | | `ShopproStatusSyncHandler` | Push status to shopPRO |
| `ShopproPaymentStatusSyncHandler` | Sync payment statuses | | `ShopproPaymentStatusSyncHandler` | Sync payment statuses |
| `ShipmentTrackingHandler` | Poll carrier tracking APIs | | `ShipmentTrackingHandler` | Poll carrier tracking APIs |
@@ -120,11 +121,12 @@ HTTP Request
### Erli Integration Foundation ### Erli Integration Foundation
1. **Settings** - `/settings/integrations/erli` 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/settings panels and stores one global Erli API key encrypted via `IntegrationSecretCipher`, an optional account label, active flag, and last connection-test result.
2. **Connection test** - `ErliIntegrationController::test()` loads active credentials, calls `ErliApiClient::testConnection()`, performs a real authenticated `GET https://erli.pl/svc/shop-api/inbox`, and stores the result in `integrations.last_test_*`. 2. **Connection test** - `ErliIntegrationController::test()` loads active credentials, calls `ErliApiClient::testConnection()`, performs a real authenticated `GET https://erli.pl/svc/shop-api/inbox`, and stores the result in `integrations.last_test_*`.
3. **Hub** - `IntegrationsHubController::buildErliRow()` adds Erli to `/settings/integrations` with configured/missing secret status, active status, last test timestamp, and configure URL. 3. **Hub** - `IntegrationsHubController::buildErliRow()` adds Erli to `/settings/integrations` with configured/missing secret status, active status, last test timestamp, and configure URL.
4. **Order import** - Phase 128 adds `/settings/integrations/erli/import` and cron `erli_orders_import`. Both call `ErliOrdersSyncService`, which fetches unread `/inbox` messages, maps supported order events through `ErliOrderMapper`, persists via `OrderImportRepository::upsertOrderAggregate()`, emits existing automation events, and acknowledges `POST /inbox/mark-read` only after a zero-failure batch. 4. **Order import** - Phase 128 adds `/settings/integrations/erli/import` and cron `erli_orders_import`. Both call `ErliOrdersSyncService`, which fetches unread `/inbox` messages, maps supported order events through `ErliOrderMapper`, persists via `OrderImportRepository::upsertOrderAggregate()`, emits existing automation events, and acknowledges `POST /inbox/mark-read` only after a zero-failure batch.
5. **Deferred** - Phase 128 does not implement status push mappings, label generation, shipment creation, or tracking. Those flows are planned for v3.8 Phases 129-131. 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.
## Dependency Injection ## Dependency Injection
@@ -184,25 +186,33 @@ tests/
### ErliApiClient (`src/Modules/Settings/ErliApiClient.php`) ### ErliApiClient (`src/Modules/Settings/ErliApiClient.php`)
- `testConnection()` wykonuje realny `GET /inbox` do Erli z naglowkiem `Authorization: Bearer ...`. - `testConnection()` wykonuje realny `GET /inbox` do Erli z naglowkiem `Authorization: Bearer ...`.
- Phase 128: `fetchInbox()` pobiera do 500 nieprzeczytanych wiadomosci; `markInboxRead()` potwierdza `POST /inbox/mark-read` z `lastMessageId` dopiero po udanym batchu. - Phase 128: `fetchInbox()` pobiera do 500 nieprzeczytanych wiadomosci; `markInboxRead()` potwierdza `POST /inbox/mark-read` z `lastMessageId` dopiero po udanym batchu.
- Phase 129: `updateOrderStatus()` wysyla `PATCH /orders/{id}/status` z body `{"status": "..."}` dla recznych zmian statusu orderPRO mapowanych na status Erli.
- Wysyla `Accept: application/json` i `User-Agent: orderPRO/1.0 (erli-integration)`. - Wysyla `Accept: application/json` i `User-Agent: orderPRO/1.0 (erli-integration)`.
- Traktuje HTTP 2xx jako sukces; 401/403 jako blad autoryzacji, 429 jako limit zapytan, pozostale bledy jako czytelny komunikat z odpowiedzi. - Traktuje HTTP 2xx jako sukces; 401/403 jako blad autoryzacji, 429 jako limit zapytan, pozostale bledy jako czytelny komunikat z odpowiedzi.
- Uzywa `SslCertificateResolver` i nie wywoluje `curl_close()` (PHP 8.5 compatible). - Uzywa `SslCertificateResolver` i nie wywoluje `curl_close()` (PHP 8.5 compatible).
### ErliIntegrationController (`src/Modules/Settings/ErliIntegrationController.php`) ### ErliIntegrationController (`src/Modules/Settings/ErliIntegrationController.php`)
- Endpointy: `GET /settings/integrations/erli`, `POST /settings/integrations/erli/save`, `POST /settings/integrations/erli/test`, `POST /settings/integrations/erli/import`. - 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`.
- `save` zapisuje label, aktywnosc, sekret i ustawienia importu (`orders_fetch_enabled`, `orders_fetch_start_date`, interwal crona); `test` wykonuje realny test API i zapisuje wynik przez `IntegrationsRepository::updateTestResult()`. - `save` zapisuje label, aktywnosc, sekret, ustawienia importu (`orders_fetch_enabled`, `orders_fetch_start_date`, interwal crona) oraz kierunek/interwal `erli_status_sync`; `test` wykonuje realny test API i zapisuje wynik przez `IntegrationsRepository::updateTestResult()`.
- `importNow()` uruchamia reczny import Erli z pominieciem flagi cron enable, ale nadal wymaga aktywnych credentials. - `importNow()` uruchamia reczny import Erli z pominieciem flagi cron enable, ale nadal wymaga aktywnych credentials.
### ErliOrdersSyncService / ErliOrderMapper (`src/Modules/Settings/`) ### ErliOrdersSyncService / ErliOrderMapper (`src/Modules/Settings/`)
- `ErliOrdersSyncService::sync()` jest wspolnym entrypointem dla crona i importu recznego. Zwraca liczniki `processed`, `imported_created`, `imported_updated`, `failed`, `skipped`, `acknowledged`. - `ErliOrdersSyncService::sync()` jest wspolnym entrypointem dla crona i importu recznego. Zwraca liczniki `processed`, `imported_created`, `imported_updated`, `failed`, `skipped`, `acknowledged`.
- Obsluguje tylko zdarzenia order inbox (`orderCreated`, `orderStatusChanged`, `orderSellerStatusChanged`); wiadomosci produktowe sa pomijane do przyszlych faz. - Obsluguje tylko zdarzenia order inbox (`orderCreated`, `orderStatusChanged`, `orderSellerStatusChanged`); wiadomosci produktowe sa pomijane do przyszlych faz.
- `ErliOrderMapper` mapuje statusy bazowe: `pending -> nieoplacone`, `purchased -> nowe`, `cancelled/returned -> anulowane`. Konfigurowalne pull/push status mappings sa odlozone do Phase 129. - `ErliOrderMapper` mapuje statusy przez `ErliPullStatusMappingRepository` gdy istnieje konfiguracja; w przeciwnym razie zachowuje fallbacki `pending -> nieoplacone`, `purchased -> nowe`, `cancelled/returned -> anulowane`.
- `ErliOrdersSyncService` odkrywa surowe statusy Erli z inboxa i dopisuje je do `erli_order_status_pull_mappings`, zeby operator mogl je zmapowac w UI.
- Nowe zamowienia z invoice/company/tax id ustawiają `orders.invoice_requested=1`; re-import korzysta z istniejacego delta-only kontraktu `OrderImportRepository`. - Nowe zamowienia z invoice/company/tax id ustawiają `orders.invoice_requested=1`; re-import korzysta z istniejacego delta-only kontraktu `OrderImportRepository`.
- Automatyzacje: `order.imported` dla nowych zamowien i `payment.status_changed` przy tranzycji platnosci na re-imporcie. - Automatyzacje: `order.imported` dla nowych zamowien i `payment.status_changed` przy tranzycji platnosci na re-imporcie.
### ErliOrdersImportHandler (`src/Modules/Cron/ErliOrdersImportHandler.php`) ### ErliOrdersImportHandler (`src/Modules/Cron/ErliOrdersImportHandler.php`)
- Handler crona `erli_orders_import`, domyslnie seedowany jako disabled. Operator wlacza go z ustawien Erli. - Handler crona `erli_orders_import`, domyslnie seedowany jako disabled. Operator wlacza go z ustawien Erli.
### ErliStatusSyncService / ErliStatusSyncHandler (`src/Modules/Settings/`, `src/Modules/Cron/`)
- Kierunek `erli_to_orderpro` wywoluje `ErliOrdersSyncService::sync()` z `ignore_orders_fetch_enabled=true`, czyli statusy przychodzace z Erli przechodza tym samym bezpiecznym `/inbox` + ACK flow co import.
- Kierunek `orderpro_to_erli` wybiera tylko zamowienia `source='erli'` z reczna zmiana statusu (`order_status_history.change_source='manual'`) po `integration_order_sync_state.last_status_pushed_at`.
- Push korzysta z `erli_order_status_mappings` i `ErliApiClient::updateOrderStatus()`. Brak mapowania powoduje `skipped`; blad API powoduje `failed` i nie przesuwa kursora poza ostatni udany timestamp.
- `erli_status_sync` jest seedowany jako disabled; zapis ustawien Erli aktualizuje interwal, kierunek i wlaczenie harmonogramu zgodnie z aktywnoscia integracji.
### IntegrationsHubController ### IntegrationsHubController
- Dodaje wiersz Erli do `/settings/integrations` ze statusem konfiguracji, sekretu, aktywnosci i ostatniego testu. - Dodaje wiersz Erli do `/settings/integrations` ze statusem konfiguracji, sekretu, aktywnosci i ostatniego testu.

View File

@@ -374,6 +374,7 @@ UNIQUE: `(integration_id, shoppro_status_code)`
| `last_synced_external_order_id` | VARCHAR(128) | YES | Legacy/source-specific cursor | | `last_synced_external_order_id` | VARCHAR(128) | YES | Legacy/source-specific cursor |
| `last_run_at` | DATETIME | YES | | | `last_run_at` | DATETIME | YES | |
| `last_success_at` | DATETIME | YES | | | `last_success_at` | DATETIME | YES | |
| `last_status_pushed_at` | DATETIME | YES | Erli status push cursor for manual local status changes |
| `last_error` | VARCHAR(500) | YES | | | `last_error` | VARCHAR(500) | YES | |
| `created_at` | DATETIME | NO | | | `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | | | `updated_at` | DATETIME | NO | |
@@ -524,6 +525,26 @@ UNIQUE: `(type, name)`
| `created_at` | DATETIME | | | `created_at` | DATETIME | |
| `updated_at` | DATETIME | | | `updated_at` | DATETIME | |
**erli_order_status_mappings** — orderPRO status → Erli status used for status push
| Column | Type | Notes |
|--------|------|-------|
| `id` | INT UNSIGNED | PK |
| `erli_status_code` | VARCHAR(64) | UNIQUE; official values include `created`, `canceled`, `readyToProcess`, `inProgress`, `sent`, `readyToPickup`, `received`, `returned`, `returningToSender`, `unknown` |
| `erli_status_name` | VARCHAR(120) | Optional display label |
| `orderpro_status_code` | VARCHAR(64) | Nullable; empty mapping means push skips that orderPRO status |
| `created_at` | DATETIME | |
| `updated_at` | DATETIME | |
**erli_order_status_pull_mappings** — Erli status → orderPRO status used during `/inbox` import
| Column | Type | Notes |
|--------|------|-------|
| `id` | INT UNSIGNED | PK |
| `erli_status_code` | VARCHAR(64) | UNIQUE; seeded with `pending`, `purchased`, `cancelled` and extended by discovery |
| `erli_status_name` | VARCHAR(120) | Optional display label |
| `orderpro_status_code` | VARCHAR(64) | Nullable; if missing, mapper uses safe Phase 128 defaults |
| `created_at` | DATETIME | |
| `updated_at` | DATETIME | |
**allegro_delivery_method_mappings** — Map order delivery method strings to Allegro services **allegro_delivery_method_mappings** — Map order delivery method strings to Allegro services
| Column | Type | Notes | | Column | Type | Notes |
|--------|------|-------| |--------|------|-------|
@@ -952,7 +973,7 @@ Index: `(status, priority, scheduled_at)`
| `created_at` | DATETIME | NO | | | `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | | | `updated_at` | DATETIME | NO | |
Seeded recurring jobs include `shoppro_orders_import`, `allegro_orders_import`, `shoppro_order_status_sync`, `shoppro_payment_status_sync`, `allegro_status_sync`, `shipment_tracking_sync`, `automation_history_cleanup`, `order_status_aged`, and `erli_orders_import` (Phase 128; default disabled until Erli order import is enabled). Seeded recurring jobs include `shoppro_orders_import`, `allegro_orders_import`, `shoppro_order_status_sync`, `shoppro_payment_status_sync`, `allegro_status_sync`, `shipment_tracking_sync`, `automation_history_cleanup`, `order_status_aged`, `erli_orders_import` (Phase 128; default disabled until Erli order import is enabled), and `erli_status_sync` (Phase 129; default disabled until Erli settings save enables it).
--- ---

View File

@@ -1,5 +1,32 @@
# Technical Changelog # Technical Changelog
## 2026-05-16 - Erli Settings Tabs UI Fix
**Co zrobiono:**
- Ujednolicono `/settings/integrations/erli` z innymi integracjami przez dodanie zakladek Integracja, Statusy i Ustawienia.
- Dodano obsluge aktywnej zakladki przez parametr `tab` oraz `return_to` w formularzach Erli, zeby po zapisie wracac do wlasciwego panelu.
**Dlaczego:**
- Ekran Erli po Phase 129 mial wszystkie sekcje jedna pod druga, przez co odstawal od Allegro/shopPRO i byl trudniejszy do skanowania.
## 2026-05-16 - Phase 129 Plan 01: Erli Status Mapping + Sync
**Co zrobiono:**
- Dodano migracje `20260515_000116_add_erli_status_mapping_sync.sql` z tabelami `erli_order_status_mappings`, `erli_order_status_pull_mappings`, kolumna `integration_order_sync_state.last_status_pushed_at`, seedami statusow Erli oraz cronem `erli_status_sync`.
- Rozszerzono ustawienia Erli o mapowania Erli -> orderPRO, orderPRO -> Erli, kierunek synchronizacji statusow i interwal crona.
- Dodano `ErliStatusMappingRepository`, `ErliPullStatusMappingRepository`, `ErliStatusSyncService` oraz `ErliStatusSyncHandler`.
- Rozszerzono `ErliApiClient` o `PATCH /orders/{id}/status` oraz `ErliOrdersSyncService` o discovery surowych statusow z inboxa.
- `ErliOrderMapper` korzysta teraz z konfigurowalnych pull mappings, zachowujac fallbacki z Phase 128.
- Dodano testy jednostkowe `tests/Unit/ErliStatusSyncServiceTest.php` i rozszerzono `ErliOrderMapperTest`.
**Dlaczego:**
- Phase 128 importowala zamowienia Erli, ale statusy byly mapowane domyslnie. Operator potrzebuje kontrolowac pull/push statusow analogicznie do Allegro/shopPRO.
- Push jest ograniczony do recznych zmian statusu (`order_status_history.change_source='manual'`), zeby uniknac petli po automatyzacjach i reimportach.
**BREAKING / migracja:**
- Brak breaking changes. Nowy cron jest domyslnie wylaczony w migracji; zapis ustawien Erli wlacza go zgodnie z aktywnoscia integracji.
- Manual smoke po wdrozeniu: `php bin/migrate.php`, sprawdz widok `/settings/integrations/erli`, zapisz mapowania, ustaw `orderPRO -> Erli`, zmien recznie status zamowienia Erli i uruchom cron `erli_status_sync`.
## 2026-05-15 - Phase 128 Plan 01: Erli Orders Import ## 2026-05-15 - Phase 128 Plan 01: Erli Orders Import
**Co zrobiono:** **Co zrobiono:**

View File

@@ -0,0 +1,71 @@
CREATE TABLE IF NOT EXISTS erli_order_status_mappings (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
erli_status_code VARCHAR(64) NOT NULL,
erli_status_name VARCHAR(120) NULL,
orderpro_status_code VARCHAR(64) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY erli_order_status_mappings_code_unique (erli_status_code),
KEY erli_order_status_mappings_orderpro_code_idx (orderpro_status_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS erli_order_status_pull_mappings (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
erli_status_code VARCHAR(64) NOT NULL,
erli_status_name VARCHAR(120) NULL,
orderpro_status_code VARCHAR(64) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY erli_order_status_pull_mappings_code_unique (erli_status_code),
KEY erli_order_status_pull_mappings_orderpro_code_idx (orderpro_status_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE integration_order_sync_state
ADD COLUMN IF NOT EXISTS last_status_pushed_at DATETIME NULL AFTER last_success_at;
INSERT INTO erli_order_status_pull_mappings (
erli_status_code, erli_status_name, orderpro_status_code, created_at, updated_at
) VALUES
('pending', 'Oczekuje', 'nieoplacone', NOW(), NOW()),
('purchased', 'Kupione', 'nowe', NOW(), NOW()),
('cancelled', 'Anulowane', 'anulowane', NOW(), NOW())
ON DUPLICATE KEY UPDATE
erli_status_name = VALUES(erli_status_name),
orderpro_status_code = COALESCE(erli_order_status_pull_mappings.orderpro_status_code, VALUES(orderpro_status_code)),
updated_at = NOW();
INSERT INTO erli_order_status_mappings (
erli_status_code, erli_status_name, orderpro_status_code, created_at, updated_at
) VALUES
('created', 'Utworzone', NULL, NOW(), NOW()),
('canceled', 'Anulowane', 'anulowane', NOW(), NOW()),
('readyToProcess', 'Gotowe do realizacji', 'nowe', NOW(), NOW()),
('inProgress', 'W realizacji', 'w_realizacji', NOW(), NOW()),
('sent', 'Wyslane', 'wyslane', NOW(), NOW()),
('readyToPickup', 'Gotowe do odbioru', NULL, NOW(), NOW()),
('received', 'Odebrane', NULL, NOW(), NOW()),
('returned', 'Zwrocone', 'anulowane', NOW(), NOW()),
('returningToSender', 'Wraca do nadawcy', NULL, NOW(), NOW()),
('unknown', 'Nieznane', NULL, NOW(), NOW())
ON DUPLICATE KEY UPDATE
erli_status_name = VALUES(erli_status_name),
orderpro_status_code = COALESCE(erli_order_status_mappings.orderpro_status_code, VALUES(orderpro_status_code)),
updated_at = NOW();
INSERT INTO app_settings (setting_key, setting_value, created_at, updated_at)
VALUES
('erli_status_sync_direction', 'erli_to_orderpro', NOW(), NOW()),
('erli_status_sync_interval_minutes', '15', NOW(), NOW())
ON DUPLICATE KEY UPDATE
setting_value = setting_value,
updated_at = updated_at;
INSERT INTO cron_schedules (
job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at, created_at, updated_at
) VALUES (
'erli_status_sync', 900, 45, 3, NULL, 0, NULL, NOW(), NOW(), NOW()
) ON DUPLICATE KEY UPDATE
interval_seconds = VALUES(interval_seconds),
priority = VALUES(priority),
max_attempts = VALUES(max_attempts),
updated_at = NOW();

View File

@@ -863,6 +863,12 @@ return [
'erli' => [ 'erli' => [
'title' => 'Integracja Erli', 'title' => 'Integracja Erli',
'description' => 'Konfiguracja globalnego polaczenia z marketplace Erli.', 'description' => 'Konfiguracja globalnego polaczenia z marketplace Erli.',
'tabs' => [
'label' => 'Zakladki integracji Erli',
'integration' => 'Integracja',
'statuses' => 'Statusy',
'settings' => 'Ustawienia',
],
'config' => [ 'config' => [
'title' => 'Konfiguracja API', 'title' => 'Konfiguracja API',
], ],
@@ -882,6 +888,10 @@ return [
'orders_fetch_enabled' => 'Wlacz automatyczny import zamowien', 'orders_fetch_enabled' => 'Wlacz automatyczny import zamowien',
'orders_fetch_start_date' => 'Data startu importu', 'orders_fetch_start_date' => 'Data startu importu',
'orders_import_interval_minutes' => 'Interwal importu (minuty)', 'orders_import_interval_minutes' => 'Interwal importu (minuty)',
'status_sync_direction' => 'Kierunek synchronizacji statusow',
'status_sync_direction_pull' => 'Erli -> orderPRO',
'status_sync_direction_push' => 'orderPRO -> Erli',
'status_sync_interval_minutes' => 'Interwal synchronizacji statusow (minuty)',
], ],
'api_key' => [ 'api_key' => [
'saved' => 'Klucz API jest zapisany. Pozostaw pole puste, aby nie zmieniac.', 'saved' => 'Klucz API jest zapisany. Pozostaw pole puste, aby nie zmieniac.',
@@ -891,6 +901,29 @@ return [
'account_label' => 'Opcjonalna nazwa widoczna w hubie integracji.', 'account_label' => 'Opcjonalna nazwa widoczna w hubie integracji.',
'orders_fetch_start_date' => 'Opcjonalnie pominie zdarzenia starsze niz podana data, jesli payload Erli zawiera date zamowienia.', 'orders_fetch_start_date' => 'Opcjonalnie pominie zdarzenia starsze niz podana data, jesli payload Erli zawiera date zamowienia.',
'orders_import_interval_minutes' => 'Dotyczy zadania cron `erli_orders_import`. Zakres: 1-1440 minut.', 'orders_import_interval_minutes' => 'Dotyczy zadania cron `erli_orders_import`. Zakres: 1-1440 minut.',
'status_sync_direction' => 'Pull pobiera statusy przez inbox. Push wysyla reczne zmiany statusu z orderPRO do Erli.',
'status_sync_interval_minutes' => 'Dotyczy zadania cron `erli_status_sync`. Zakres: 1-1440 minut.',
],
'statuses' => [
'pull_title' => 'Mapowanie przy imporcie (Erli -> orderPRO)',
'pull_description' => 'Przypisz surowe statusy Erli do statusow orderPRO. Nieznane statusy z inboxa beda dopisywane automatycznie po imporcie.',
'push_title' => 'Mapowanie przy wysylce (orderPRO -> Erli)',
'push_description' => 'Przypisz statusy orderPRO do statusow Erli uzywanych przy recznej zmianie statusu i cron push.',
'empty_pull' => 'Brak statusow Erli do mapowania. Uruchom migracje lub import, aby zasilic liste.',
'empty_push' => 'Brak statusow Erli do mapowania. Uruchom migracje, aby zasilic liste.',
'fields' => [
'erli_status' => 'Status Erli',
'orderpro_status' => 'Status orderPRO',
],
'actions' => [
'save_pull' => 'Zapisz mapowanie importu',
'save_push' => 'Zapisz mapowanie wysylki',
],
'flash' => [
'saved_pull' => 'Mapowanie importu statusow Erli zostalo zapisane.',
'saved_push' => 'Mapowanie wysylki statusow Erli zostalo zapisane.',
'save_failed' => 'Nie udalo sie zapisac mapowan statusow Erli.',
],
], ],
'status' => [ 'status' => [
'secret' => 'Sekret API', 'secret' => 'Sekret API',
@@ -914,6 +947,7 @@ return [
], ],
'validation' => [ 'validation' => [
'orders_fetch_start_date_invalid' => 'Data startu importu musi miec format RRRR-MM-DD.', 'orders_fetch_start_date_invalid' => 'Data startu importu musi miec format RRRR-MM-DD.',
'status_sync_direction_invalid' => 'Wybierz poprawny kierunek synchronizacji statusow Erli.',
], ],
], ],
'inpost' => [ 'inpost' => [

View File

@@ -10,6 +10,12 @@ $lastTestHttpCode = $settings['last_test_http_code'] ?? null;
$ordersFetchEnabled = (bool) ($settings['orders_fetch_enabled'] ?? false); $ordersFetchEnabled = (bool) ($settings['orders_fetch_enabled'] ?? false);
$ordersFetchStartDate = trim((string) ($settings['orders_fetch_start_date'] ?? '')); $ordersFetchStartDate = trim((string) ($settings['orders_fetch_start_date'] ?? ''));
$ordersImportIntervalMinutes = (int) ($ordersImportIntervalMinutes ?? 5); $ordersImportIntervalMinutes = (int) ($ordersImportIntervalMinutes ?? 5);
$statusSyncDirection = (string) ($statusSyncDirection ?? 'erli_to_orderpro');
$statusSyncIntervalMinutes = (int) ($statusSyncIntervalMinutes ?? 15);
$orderproStatuses = is_array($orderproStatuses ?? null) ? $orderproStatuses : [];
$erliStatusMappings = is_array($erliStatusMappings ?? null) ? $erliStatusMappings : [];
$erliPullStatusMappings = is_array($erliPullStatusMappings ?? null) ? $erliPullStatusMappings : [];
$activeTab = (string) ($activeTab ?? 'integration');
?> ?>
<section class="card"> <section class="card">
@@ -34,6 +40,20 @@ $ordersImportIntervalMinutes = (int) ($ordersImportIntervalMinutes ?? 5);
</section> </section>
<section class="card mt-16"> <section class="card mt-16">
<nav class="content-tabs-nav" aria-label="<?= $e($t('settings.erli.tabs.label')) ?>">
<button type="button" class="content-tab-btn<?= $activeTab === 'integration' ? ' is-active' : '' ?>" data-tab-target="erli-tab-integration">
<?= $e($t('settings.erli.tabs.integration')) ?>
</button>
<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 === 'settings' ? ' is-active' : '' ?>" data-tab-target="erli-tab-settings">
<?= $e($t('settings.erli.tabs.settings')) ?>
</button>
</nav>
<div class="content-tab-panel<?= $activeTab === 'integration' ? ' is-active' : '' ?>" data-tab-panel="erli-tab-integration">
<section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.erli.config.title')) ?></h3> <h3 class="section-title"><?= $e($t('settings.erli.config.title')) ?></h3>
<div class="muted mt-12"> <div class="muted mt-12">
@@ -46,6 +66,7 @@ $ordersImportIntervalMinutes = (int) ($ordersImportIntervalMinutes ?? 5);
<form class="statuses-form mt-16" action="/settings/integrations/erli/save" method="post" novalidate> <form class="statuses-form mt-16" action="/settings/integrations/erli/save" method="post" novalidate>
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>"> <input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="return_to" value="/settings/integrations/erli?tab=integration">
<label class="form-field"> <label class="form-field">
<span class="field-label"><?= $e($t('settings.erli.fields.account_label')) ?></span> <span class="field-label"><?= $e($t('settings.erli.fields.account_label')) ?></span>
@@ -85,30 +106,182 @@ $ordersImportIntervalMinutes = (int) ($ordersImportIntervalMinutes ?? 5);
<span class="muted"><?= $e($t('settings.erli.hints.orders_import_interval_minutes')) ?></span> <span class="muted"><?= $e($t('settings.erli.hints.orders_import_interval_minutes')) ?></span>
</label> </label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.erli.fields.status_sync_direction')) ?></span>
<select class="form-control" name="status_sync_direction">
<option value="erli_to_orderpro"<?= $statusSyncDirection === 'erli_to_orderpro' ? ' selected' : '' ?>>
<?= $e($t('settings.erli.fields.status_sync_direction_pull')) ?>
</option>
<option value="orderpro_to_erli"<?= $statusSyncDirection === 'orderpro_to_erli' ? ' selected' : '' ?>>
<?= $e($t('settings.erli.fields.status_sync_direction_push')) ?>
</option>
</select>
<span class="muted"><?= $e($t('settings.erli.hints.status_sync_direction')) ?></span>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.erli.fields.status_sync_interval_minutes')) ?></span>
<input class="form-control" type="number" min="1" max="1440" name="status_sync_interval_minutes" value="<?= $e((string) $statusSyncIntervalMinutes) ?>">
<span class="muted"><?= $e($t('settings.erli.hints.status_sync_interval_minutes')) ?></span>
</label>
<div class="form-actions mt-16"> <div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.erli.actions.save')) ?></button> <button type="submit" class="btn btn--primary"><?= $e($t('settings.erli.actions.save')) ?></button>
</div> </div>
</form> </form>
</section> </section>
</div>
<section class="card mt-16"> <div class="content-tab-panel<?= $activeTab === 'statuses' ? ' is-active' : '' ?>" data-tab-panel="erli-tab-statuses">
<section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.erli.statuses.pull_title')) ?></h3>
<p class="muted mt-12"><?= $e($t('settings.erli.statuses.pull_description')) ?></p>
<form action="/settings/integrations/erli/statuses/save-pull" method="post" class="mt-12">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="return_to" value="/settings/integrations/erli?tab=statuses">
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.erli.statuses.fields.erli_status')) ?></th>
<th><?= $e($t('settings.erli.statuses.fields.orderpro_status')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($erliPullStatusMappings === []): ?>
<tr>
<td colspan="2" class="muted"><?= $e($t('settings.erli.statuses.empty_pull')) ?></td>
</tr>
<?php else: ?>
<?php foreach ($erliPullStatusMappings as $mapping): ?>
<?php
$erliCode = strtolower(trim((string) ($mapping['erli_status_code'] ?? '')));
if ($erliCode === '') continue;
$erliName = trim((string) ($mapping['erli_status_name'] ?? ''));
$selectedOrderproCode = strtolower(trim((string) ($mapping['orderpro_status_code'] ?? '')));
?>
<tr>
<td>
<?= $e($erliName !== '' ? $erliName : $erliCode) ?> <code class="muted"><?= $e($erliCode) ?></code>
<input type="hidden" name="erli_status_code[]" value="<?= $e($erliCode) ?>">
<input type="hidden" name="erli_status_name[]" value="<?= $e($erliName) ?>">
</td>
<td>
<select class="form-control" name="orderpro_status_code[]">
<option value=""><?= $e($t('settings.order_statuses.fields.no_mapping')) ?></option>
<?php foreach ($orderproStatuses as $status): ?>
<?php
$opCode = strtolower(trim((string) ($status['code'] ?? '')));
if ($opCode === '') continue;
$opName = (string) ($status['name'] ?? $opCode);
?>
<option value="<?= $e($opCode) ?>"<?= $selectedOrderproCode === $opCode ? ' selected' : '' ?>>
<?= $e($opName) ?> (<?= $e($opCode) ?>)
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($erliPullStatusMappings !== []): ?>
<div class="form-actions mt-12">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.erli.statuses.actions.save_pull')) ?></button>
</div>
<?php endif; ?>
</form>
</section>
<section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.erli.statuses.push_title')) ?></h3>
<p class="muted mt-12"><?= $e($t('settings.erli.statuses.push_description')) ?></p>
<form action="/settings/integrations/erli/statuses/save-push" method="post" class="mt-12">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="return_to" value="/settings/integrations/erli?tab=statuses">
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.erli.statuses.fields.erli_status')) ?></th>
<th><?= $e($t('settings.erli.statuses.fields.orderpro_status')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($erliStatusMappings === []): ?>
<tr>
<td colspan="2" class="muted"><?= $e($t('settings.erli.statuses.empty_push')) ?></td>
</tr>
<?php else: ?>
<?php foreach ($erliStatusMappings as $mapping): ?>
<?php
$erliCode = trim((string) ($mapping['erli_status_code'] ?? ''));
if ($erliCode === '') continue;
$erliName = trim((string) ($mapping['erli_status_name'] ?? ''));
$selectedOrderproCode = strtolower(trim((string) ($mapping['orderpro_status_code'] ?? '')));
?>
<tr>
<td>
<?= $e($erliName !== '' ? $erliName : $erliCode) ?> <code class="muted"><?= $e($erliCode) ?></code>
<input type="hidden" name="erli_status_code[]" value="<?= $e($erliCode) ?>">
<input type="hidden" name="erli_status_name[]" value="<?= $e($erliName) ?>">
</td>
<td>
<select class="form-control" name="orderpro_status_code[]">
<option value=""><?= $e($t('settings.order_statuses.fields.no_mapping')) ?></option>
<?php foreach ($orderproStatuses as $status): ?>
<?php
$opCode = strtolower(trim((string) ($status['code'] ?? '')));
if ($opCode === '') continue;
$opName = (string) ($status['name'] ?? $opCode);
?>
<option value="<?= $e($opCode) ?>"<?= $selectedOrderproCode === $opCode ? ' selected' : '' ?>>
<?= $e($opName) ?> (<?= $e($opCode) ?>)
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($erliStatusMappings !== []): ?>
<div class="form-actions mt-12">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.erli.statuses.actions.save_push')) ?></button>
</div>
<?php endif; ?>
</form>
</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> <h3 class="section-title"><?= $e($t('settings.erli.import.title')) ?></h3>
<p class="muted mt-12"><?= $e($t('settings.erli.import.description')) ?></p> <p class="muted mt-12"><?= $e($t('settings.erli.import.description')) ?></p>
<form class="statuses-form mt-16" action="/settings/integrations/erli/import" method="post"> <form class="statuses-form mt-16" action="/settings/integrations/erli/import" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>"> <input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="return_to" value="/settings/integrations/erli?tab=settings">
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn--secondary"><?= $e($t('settings.erli.actions.import_now')) ?></button> <button type="submit" class="btn btn--secondary"><?= $e($t('settings.erli.actions.import_now')) ?></button>
</div> </div>
</form> </form>
</section> </section>
<section class="card mt-16"> <section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.erli.test.title')) ?></h3> <h3 class="section-title"><?= $e($t('settings.erli.test.title')) ?></h3>
<p class="muted mt-12"><?= $e($t('settings.erli.test.description')) ?></p> <p class="muted mt-12"><?= $e($t('settings.erli.test.description')) ?></p>
<form class="statuses-form mt-16" action="/settings/integrations/erli/test" method="post"> <form class="statuses-form mt-16" action="/settings/integrations/erli/test" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>"> <input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="return_to" value="/settings/integrations/erli?tab=settings">
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn--secondary"><?= $e($t('settings.erli.actions.test')) ?></button> <button type="submit" class="btn btn--secondary"><?= $e($t('settings.erli.actions.test')) ?></button>
</div> </div>
@@ -128,3 +301,40 @@ $ordersImportIntervalMinutes = (int) ($ordersImportIntervalMinutes ?? 5);
?></div> ?></div>
<?php endif; ?> <?php endif; ?>
</section> </section>
</div>
</section>
<script>
(function () {
var tabs = document.querySelectorAll('[data-tab-target]');
var panels = document.querySelectorAll('[data-tab-panel]');
if (tabs.length === 0 || panels.length === 0) {
return;
}
var tabNameMap = {
'erli-tab-integration': 'integration',
'erli-tab-statuses': 'statuses',
'erli-tab-settings': 'settings'
};
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
var target = tab.getAttribute('data-tab-target');
var tabName = tabNameMap[target] || 'integration';
var url = new URL(window.location.href);
url.searchParams.set('tab', tabName);
window.history.replaceState(null, '', url.toString());
tabs.forEach(function (node) { node.classList.remove('is-active'); });
panels.forEach(function (panel) { panel.classList.remove('is-active'); });
tab.classList.add('is-active');
var panel = document.querySelector('[data-tab-panel="' + target + '"]');
if (panel) {
panel.classList.add('is-active');
}
});
});
})();
</script>

View File

@@ -35,6 +35,8 @@ use App\Modules\Settings\ErliIntegrationRepository;
use App\Modules\Settings\ErliOrderMapper; use App\Modules\Settings\ErliOrderMapper;
use App\Modules\Settings\ErliOrderSyncStateRepository; use App\Modules\Settings\ErliOrderSyncStateRepository;
use App\Modules\Settings\ErliOrdersSyncService; use App\Modules\Settings\ErliOrdersSyncService;
use App\Modules\Settings\ErliPullStatusMappingRepository;
use App\Modules\Settings\ErliStatusMappingRepository;
use App\Modules\Settings\FakturowniaApiClient; use App\Modules\Settings\FakturowniaApiClient;
use App\Modules\Settings\FakturowniaIntegrationController; use App\Modules\Settings\FakturowniaIntegrationController;
use App\Modules\Settings\FakturowniaIntegrationRepository; use App\Modules\Settings\FakturowniaIntegrationRepository;
@@ -239,6 +241,8 @@ return static function (Application $app): void {
$app->db(), $app->db(),
(string) $app->config('app.integrations.secret', '') (string) $app->config('app.integrations.secret', '')
); );
$erliPullStatusMappingRepository = new ErliPullStatusMappingRepository($app->db());
$erliStatusMappingRepository = new ErliStatusMappingRepository($app->db());
$notificationRepository = new NotificationRepository($app->db()); $notificationRepository = new NotificationRepository($app->db());
$smsMessageRepository = new SmsMessageRepository($app->db()); $smsMessageRepository = new SmsMessageRepository($app->db());
$smsConversationService = new SmsConversationService( $smsConversationService = new SmsConversationService(
@@ -389,8 +393,9 @@ return static function (Application $app): void {
new ErliApiClient(), new ErliApiClient(),
new OrderImportRepository($app->db()), new OrderImportRepository($app->db()),
new OrdersRepository($app->db()), new OrdersRepository($app->db()),
new ErliOrderMapper(), new ErliOrderMapper($erliPullStatusMappingRepository),
$automationService $automationService,
$erliPullStatusMappingRepository
); );
$erliIntegrationController = new ErliIntegrationController( $erliIntegrationController = new ErliIntegrationController(
$template, $template,
@@ -400,7 +405,10 @@ return static function (Application $app): void {
new ErliApiClient(), new ErliApiClient(),
new IntegrationsRepository($app->db()), new IntegrationsRepository($app->db()),
$cronRepository, $cronRepository,
$erliOrdersSyncService $erliOrdersSyncService,
$app->orderStatuses(),
$erliStatusMappingRepository,
$erliPullStatusMappingRepository
); );
$allegroIntegrationController = new AllegroIntegrationController( $allegroIntegrationController = new AllegroIntegrationController(
$template, $template,
@@ -650,6 +658,8 @@ return static function (Application $app): void {
$router->post('/settings/integrations/erli/save', [$erliIntegrationController, 'save'], [$authMiddleware]); $router->post('/settings/integrations/erli/save', [$erliIntegrationController, 'save'], [$authMiddleware]);
$router->post('/settings/integrations/erli/test', [$erliIntegrationController, 'test'], [$authMiddleware]); $router->post('/settings/integrations/erli/test', [$erliIntegrationController, 'test'], [$authMiddleware]);
$router->post('/settings/integrations/erli/import', [$erliIntegrationController, 'importNow'], [$authMiddleware]); $router->post('/settings/integrations/erli/import', [$erliIntegrationController, 'importNow'], [$authMiddleware]);
$router->post('/settings/integrations/erli/statuses/save-pull', [$erliIntegrationController, 'savePullStatusMappings'], [$authMiddleware]);
$router->post('/settings/integrations/erli/statuses/save-push', [$erliIntegrationController, 'savePushStatusMappings'], [$authMiddleware]);
$router->get('/settings/integrations/shoppro', [$shopproIntegrationsController, 'index'], [$authMiddleware]); $router->get('/settings/integrations/shoppro', [$shopproIntegrationsController, 'index'], [$authMiddleware]);
$router->post('/settings/integrations/shoppro/save', [$shopproIntegrationsController, 'save'], [$authMiddleware]); $router->post('/settings/integrations/shoppro/save', [$shopproIntegrationsController, 'save'], [$authMiddleware]);
$router->post('/settings/integrations/shoppro/test', [$shopproIntegrationsController, 'test'], [$authMiddleware]); $router->post('/settings/integrations/shoppro/test', [$shopproIntegrationsController, 'test'], [$authMiddleware]);

View File

@@ -38,6 +38,9 @@ use App\Modules\Settings\ErliIntegrationRepository;
use App\Modules\Settings\ErliOrderMapper; use App\Modules\Settings\ErliOrderMapper;
use App\Modules\Settings\ErliOrderSyncStateRepository; use App\Modules\Settings\ErliOrderSyncStateRepository;
use App\Modules\Settings\ErliOrdersSyncService; use App\Modules\Settings\ErliOrdersSyncService;
use App\Modules\Settings\ErliPullStatusMappingRepository;
use App\Modules\Settings\ErliStatusMappingRepository;
use App\Modules\Settings\ErliStatusSyncService;
use App\Modules\Settings\InpostIntegrationRepository; use App\Modules\Settings\InpostIntegrationRepository;
use App\Modules\Settings\IntegrationSecretCipher; use App\Modules\Settings\IntegrationSecretCipher;
use App\Modules\Settings\ReceiptConfigRepository; use App\Modules\Settings\ReceiptConfigRepository;
@@ -133,14 +136,20 @@ final class CronHandlerFactory
$this->db, $this->db,
$automationService $automationService
); );
$erliIntegrationRepository = new ErliIntegrationRepository($this->db, $this->integrationSecret);
$erliSyncStateRepository = new ErliOrderSyncStateRepository($this->db);
$erliApiClient = new ErliApiClient();
$erliPullStatusMappingRepository = new ErliPullStatusMappingRepository($this->db);
$erliStatusMappingRepository = new ErliStatusMappingRepository($this->db);
$erliOrdersSyncService = new ErliOrdersSyncService( $erliOrdersSyncService = new ErliOrdersSyncService(
new ErliIntegrationRepository($this->db, $this->integrationSecret), $erliIntegrationRepository,
new ErliOrderSyncStateRepository($this->db), $erliSyncStateRepository,
new ErliApiClient(), $erliApiClient,
new OrderImportRepository($this->db), new OrderImportRepository($this->db),
$ordersRepository, $ordersRepository,
new ErliOrderMapper(), new ErliOrderMapper($erliPullStatusMappingRepository),
$automationService $automationService,
$erliPullStatusMappingRepository
); );
return new CronRunner( return new CronRunner(
@@ -178,6 +187,17 @@ final class CronHandlerFactory
'erli_orders_import' => new ErliOrdersImportHandler( 'erli_orders_import' => new ErliOrdersImportHandler(
$erliOrdersSyncService $erliOrdersSyncService
), ),
'erli_status_sync' => new ErliStatusSyncHandler(
new ErliStatusSyncService(
$cronRepository,
$erliIntegrationRepository,
$erliOrdersSyncService,
$erliApiClient,
$erliSyncStateRepository,
$erliStatusMappingRepository,
$this->db
)
),
'shipment_tracking_sync' => new ShipmentTrackingHandler( 'shipment_tracking_sync' => new ShipmentTrackingHandler(
new ShipmentTrackingRegistry([ new ShipmentTrackingRegistry([
new InpostTrackingService( new InpostTrackingService(

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\Settings\ErliStatusSyncService;
final class ErliStatusSyncHandler
{
public function __construct(private readonly ErliStatusSyncService $syncService)
{
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function handle(array $payload): array
{
return $this->syncService->sync();
}
}

View File

@@ -124,8 +124,35 @@ final class ErliApiClient
} }
/** /**
* @return array{0: string, 1: int, 2: ?string} * @param array{base_url: string, api_key: string, timeout_seconds?: int} $credentials
* @return array{ok: bool, http_code: int, message: string}
*/ */
public function updateOrderStatus(array $credentials, string $orderId, string $erliStatus): array
{
$baseUrl = rtrim(trim((string) ($credentials['base_url'] ?? '')), '/');
$apiKey = trim((string) ($credentials['api_key'] ?? ''));
$sourceOrderId = trim($orderId);
$status = trim($erliStatus);
if ($baseUrl === '' || $apiKey === '' || $sourceOrderId === '' || $status === '') {
return ['ok' => false, 'http_code' => 0, 'message' => 'Brak danych do aktualizacji statusu Erli.'];
}
[$body, $httpCode, $curlError] = $this->httpRequest(
'PATCH',
$baseUrl . '/orders/' . rawurlencode($sourceOrderId) . '/status',
$apiKey,
['status' => $status]
);
if ($curlError !== null) {
return ['ok' => false, 'http_code' => $httpCode, 'message' => 'Blad polaczenia: ' . $curlError];
}
if ($httpCode < 200 || $httpCode >= 300) {
return ['ok' => false, 'http_code' => $httpCode, 'message' => $this->resolveFailureMessage($body, $httpCode)];
}
return ['ok' => true, 'http_code' => $httpCode, 'message' => 'OK'];
}
/** /**
* @param array<string, mixed>|null $payload * @param array<string, mixed>|null $payload
* @return array{0: string, 1: int, 2: ?string} * @return array{0: string, 1: int, 2: ?string}
@@ -151,9 +178,13 @@ final class ErliApiClient
], ],
]; ];
if (strtoupper($method) === 'POST') { $normalizedMethod = strtoupper($method);
if ($normalizedMethod === 'POST') {
$opts[CURLOPT_POST] = true; $opts[CURLOPT_POST] = true;
$opts[CURLOPT_POSTFIELDS] = json_encode($payload ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $opts[CURLOPT_POSTFIELDS] = json_encode($payload ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} elseif ($normalizedMethod === 'PATCH') {
$opts[CURLOPT_CUSTOMREQUEST] = 'PATCH';
$opts[CURLOPT_POSTFIELDS] = json_encode($payload ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} else { } else {
$opts[CURLOPT_HTTPGET] = true; $opts[CURLOPT_HTTPGET] = true;
} }

View File

@@ -21,6 +21,12 @@ final class ErliIntegrationController
private const ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS = 300; private const ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS = 300;
private const ORDERS_IMPORT_DEFAULT_PRIORITY = 40; private const ORDERS_IMPORT_DEFAULT_PRIORITY = 40;
private const ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS = 3; private const ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS = 3;
private const STATUS_SYNC_JOB_TYPE = 'erli_status_sync';
private const STATUS_SYNC_DEFAULT_INTERVAL_SECONDS = 900;
private const STATUS_SYNC_DEFAULT_PRIORITY = 45;
private const STATUS_SYNC_DEFAULT_MAX_ATTEMPTS = 3;
private const STATUS_SYNC_DIRECTION_PULL = 'erli_to_orderpro';
private const STATUS_SYNC_DIRECTION_PUSH = 'orderpro_to_erli';
public function __construct( public function __construct(
private readonly Template $template, private readonly Template $template,
@@ -30,20 +36,31 @@ final class ErliIntegrationController
private readonly ErliApiClient $apiClient, private readonly ErliApiClient $apiClient,
private readonly IntegrationsRepository $integrations, private readonly IntegrationsRepository $integrations,
private readonly CronRepository $cronRepository, private readonly CronRepository $cronRepository,
private readonly ErliOrdersSyncService $ordersSyncService private readonly ErliOrdersSyncService $ordersSyncService,
private readonly OrderStatusRepository $orderStatuses,
private readonly ErliStatusMappingRepository $statusMappings,
private readonly ErliPullStatusMappingRepository $pullStatusMappings
) { ) {
} }
public function index(Request $request): Response public function index(Request $request): Response
{ {
$activeTab = $this->resolveTab((string) $request->input('tab', 'integration'));
$html = $this->template->render('settings/erli', [ $html = $this->template->render('settings/erli', [
'title' => $this->translator->get('settings.erli.title'), 'title' => $this->translator->get('settings.erli.title'),
'activeMenu' => 'settings', 'activeMenu' => 'settings',
'activeSettings' => 'integrations', 'activeSettings' => 'integrations',
'user' => $this->auth->user(), 'user' => $this->auth->user(),
'csrfToken' => Csrf::token(), 'csrfToken' => Csrf::token(),
'activeTab' => $activeTab,
'settings' => $this->repository->getSettings(), 'settings' => $this->repository->getSettings(),
'ordersImportIntervalMinutes' => $this->currentImportIntervalMinutes(), 'ordersImportIntervalMinutes' => $this->currentImportIntervalMinutes(),
'statusSyncDirection' => $this->currentStatusSyncDirection(),
'statusSyncIntervalMinutes' => $this->currentStatusSyncIntervalMinutes(),
'orderproStatuses' => $this->orderStatuses->listStatuses(),
'erliStatusMappings' => $this->statusMappings->listAll(),
'erliPullStatusMappings' => $this->pullStatusMappings->listAll(),
'errorMessage' => (string) Flash::get('settings_error', ''), 'errorMessage' => (string) Flash::get('settings_error', ''),
'successMessage' => (string) Flash::get('settings_success', ''), 'successMessage' => (string) Flash::get('settings_success', ''),
'testMessage' => (string) Flash::get('erli_test', ''), 'testMessage' => (string) Flash::get('erli_test', ''),
@@ -70,6 +87,7 @@ final class ErliIntegrationController
'orders_fetch_start_date' => $this->validateStartDate((string) $request->input('orders_fetch_start_date', '')), 'orders_fetch_start_date' => $this->validateStartDate((string) $request->input('orders_fetch_start_date', '')),
]); ]);
$this->upsertImportSchedule($request); $this->upsertImportSchedule($request);
$this->saveStatusSyncSettings($request);
Flash::set('settings_success', $this->translator->get('settings.erli.flash.saved')); Flash::set('settings_success', $this->translator->get('settings.erli.flash.saved'));
} catch (Throwable $exception) { } catch (Throwable $exception) {
Flash::set( Flash::set(
@@ -81,6 +99,84 @@ final class ErliIntegrationController
return Response::redirect($redirectTo); return Response::redirect($redirectTo);
} }
public function savePullStatusMappings(Request $request): Response
{
$redirectTo = $this->resolveRedirect($request);
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
$erliCodes = $request->input('erli_status_code', []);
$erliNames = $request->input('erli_status_name', []);
$orderproCodes = $request->input('orderpro_status_code', []);
if (!is_array($erliCodes) || !is_array($erliNames) || !is_array($orderproCodes)) {
Flash::set('settings_error', $this->translator->get('settings.erli.statuses.flash.save_failed'));
return Response::redirect($redirectTo);
}
$mappings = [];
foreach ($erliCodes as $index => $rawErliCode) {
$erliCode = strtolower(trim((string) $rawErliCode));
if ($erliCode === '') {
continue;
}
$mappings[] = [
'erli_status_code' => $erliCode,
'erli_status_name' => trim((string) ($erliNames[$index] ?? '')),
'orderpro_status_code' => strtolower(trim((string) ($orderproCodes[$index] ?? ''))),
];
}
try {
$this->pullStatusMappings->replaceAll($mappings);
Flash::set('settings_success', $this->translator->get('settings.erli.statuses.flash.saved_pull'));
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.erli.statuses.flash.save_failed') . ' ' . $exception->getMessage());
}
return Response::redirect($redirectTo);
}
public function savePushStatusMappings(Request $request): Response
{
$redirectTo = $this->resolveRedirect($request);
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
$erliCodes = $request->input('erli_status_code', []);
$erliNames = $request->input('erli_status_name', []);
$orderproCodes = $request->input('orderpro_status_code', []);
if (!is_array($erliCodes) || !is_array($erliNames) || !is_array($orderproCodes)) {
Flash::set('settings_error', $this->translator->get('settings.erli.statuses.flash.save_failed'));
return Response::redirect($redirectTo);
}
$mappings = [];
foreach ($erliCodes as $index => $rawErliCode) {
$erliCode = trim((string) $rawErliCode);
if ($erliCode === '') {
continue;
}
$mappings[] = [
'erli_status_code' => $erliCode,
'erli_status_name' => trim((string) ($erliNames[$index] ?? '')),
'orderpro_status_code' => strtolower(trim((string) ($orderproCodes[$index] ?? ''))),
];
}
try {
$this->statusMappings->replaceAll($mappings);
Flash::set('settings_success', $this->translator->get('settings.erli.statuses.flash.saved_push'));
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.erli.statuses.flash.save_failed') . ' ' . $exception->getMessage());
}
return Response::redirect($redirectTo);
}
public function importNow(Request $request): Response public function importNow(Request $request): Response
{ {
$redirectTo = $this->resolveRedirect($request); $redirectTo = $this->resolveRedirect($request);
@@ -145,6 +241,16 @@ final class ErliIntegrationController
); );
} }
private function resolveTab(string $tab): string
{
$normalized = trim($tab);
if (in_array($normalized, ['integration', 'statuses', 'settings'], true)) {
return $normalized;
}
return 'integration';
}
private function validateStartDate(string $value): string private function validateStartDate(string $value): string
{ {
$trimmed = trim($value); $trimmed = trim($value);
@@ -173,6 +279,27 @@ final class ErliIntegrationController
); );
} }
private function saveStatusSyncSettings(Request $request): void
{
$direction = trim((string) $request->input('status_sync_direction', self::STATUS_SYNC_DIRECTION_PULL));
if (!in_array($direction, [self::STATUS_SYNC_DIRECTION_PULL, self::STATUS_SYNC_DIRECTION_PUSH], true)) {
throw new IntegrationConfigException($this->translator->get('settings.erli.validation.status_sync_direction_invalid'));
}
$minutes = max(1, min(1440, (int) $request->input('status_sync_interval_minutes', 15)));
$enabled = (string) $request->input('is_active', '') === '1';
$this->cronRepository->upsertSetting('erli_status_sync_direction', $direction);
$this->cronRepository->upsertSetting('erli_status_sync_interval_minutes', (string) $minutes);
$this->cronRepository->upsertSchedule(
self::STATUS_SYNC_JOB_TYPE,
$minutes * 60,
self::STATUS_SYNC_DEFAULT_PRIORITY,
self::STATUS_SYNC_DEFAULT_MAX_ATTEMPTS,
null,
$enabled
);
}
private function currentImportIntervalMinutes(): int private function currentImportIntervalMinutes(): int
{ {
foreach ($this->cronRepository->listSchedules() as $schedule) { foreach ($this->cronRepository->listSchedules() as $schedule) {
@@ -186,6 +313,27 @@ final class ErliIntegrationController
return 5; return 5;
} }
private function currentStatusSyncDirection(): string
{
$direction = trim($this->cronRepository->getStringSetting('erli_status_sync_direction', self::STATUS_SYNC_DIRECTION_PULL));
return in_array($direction, [self::STATUS_SYNC_DIRECTION_PULL, self::STATUS_SYNC_DIRECTION_PUSH], true)
? $direction
: self::STATUS_SYNC_DIRECTION_PULL;
}
private function currentStatusSyncIntervalMinutes(): int
{
foreach ($this->cronRepository->listSchedules() as $schedule) {
if ((string) ($schedule['job_type'] ?? '') !== self::STATUS_SYNC_JOB_TYPE) {
continue;
}
$seconds = (int) ($schedule['interval_seconds'] ?? self::STATUS_SYNC_DEFAULT_INTERVAL_SECONDS);
return max(1, min(1440, (int) floor(max(60, $seconds) / 60)));
}
return $this->cronRepository->getIntSetting('erli_status_sync_interval_minutes', 15, 1, 1440);
}
/** /**
* @param array<string, mixed> $result * @param array<string, mixed> $result
*/ */

View File

@@ -15,6 +15,10 @@ final class ErliOrderMapper
'orderSellerStatusChanged', 'orderSellerStatusChanged',
]; ];
public function __construct(private readonly ?ErliPullStatusMappingRepository $pullStatusMappings = null)
{
}
/** /**
* @param array<string, mixed> $message * @param array<string, mixed> $message
* @return array{ * @return array{
@@ -299,6 +303,13 @@ final class ErliOrderMapper
private function mapOrderStatus(string $status): string private function mapOrderStatus(string $status): string
{ {
if ($this->pullStatusMappings !== null) {
$mapped = $this->pullStatusMappings->findMappedStatusCode($status);
if ($mapped !== null) {
return $mapped;
}
}
return match ($status) { return match ($status) {
'pending' => 'nieoplacone', 'pending' => 'nieoplacone',
'purchased' => 'nowe', 'purchased' => 'nowe',

View File

@@ -15,7 +15,7 @@ final class ErliOrderSyncStateRepository
} }
/** /**
* @return array{last_synced_updated_at:?string,last_synced_source_order_id:?string,last_run_at:?string,last_success_at:?string,last_error:?string} * @return array{last_synced_updated_at:?string,last_synced_source_order_id:?string,last_run_at:?string,last_success_at:?string,last_status_pushed_at:?string,last_error:?string}
*/ */
public function getState(int $integrationId): array public function getState(int $integrationId): array
{ {
@@ -30,6 +30,7 @@ final class ErliOrderSyncStateRepository
last_synced_source_order_id, last_synced_source_order_id,
last_run_at, last_run_at,
last_success_at, last_success_at,
last_status_pushed_at,
last_error last_error
FROM integration_order_sync_state FROM integration_order_sync_state
WHERE integration_id = :integration_id WHERE integration_id = :integration_id
@@ -50,6 +51,7 @@ final class ErliOrderSyncStateRepository
'last_synced_source_order_id' => StringHelper::nullableString((string) ($row['last_synced_source_order_id'] ?? '')), 'last_synced_source_order_id' => StringHelper::nullableString((string) ($row['last_synced_source_order_id'] ?? '')),
'last_run_at' => StringHelper::nullableString((string) ($row['last_run_at'] ?? '')), 'last_run_at' => StringHelper::nullableString((string) ($row['last_run_at'] ?? '')),
'last_success_at' => StringHelper::nullableString((string) ($row['last_success_at'] ?? '')), 'last_success_at' => StringHelper::nullableString((string) ($row['last_success_at'] ?? '')),
'last_status_pushed_at' => StringHelper::nullableString((string) ($row['last_status_pushed_at'] ?? '')),
'last_error' => StringHelper::nullableString((string) ($row['last_error'] ?? '')), 'last_error' => StringHelper::nullableString((string) ($row['last_error'] ?? '')),
]; ];
} }
@@ -84,8 +86,42 @@ final class ErliOrderSyncStateRepository
]); ]);
} }
public function getLastStatusPushedAt(int $integrationId): ?string
{
if ($integrationId <= 0) {
return null;
}
try {
$statement = $this->pdo->prepare(
'SELECT last_status_pushed_at
FROM integration_order_sync_state
WHERE integration_id = :integration_id
LIMIT 1'
);
$statement->execute(['integration_id' => $integrationId]);
$value = $statement->fetchColumn();
} catch (Throwable) {
return null;
}
return is_string($value) ? StringHelper::nullableString($value) : null;
}
public function updateLastStatusPushedAt(int $integrationId, string $datetime): void
{
$normalized = StringHelper::normalizeDateTime($datetime);
if ($integrationId <= 0 || $normalized === null) {
return;
}
$this->upsertState($integrationId, [
'last_status_pushed_at' => $normalized,
]);
}
/** /**
* @return array{last_synced_updated_at:?string,last_synced_source_order_id:?string,last_run_at:?string,last_success_at:?string,last_error:?string} * @return array{last_synced_updated_at:?string,last_synced_source_order_id:?string,last_run_at:?string,last_success_at:?string,last_status_pushed_at:?string,last_error:?string}
*/ */
private function defaultState(): array private function defaultState(): array
{ {
@@ -94,6 +130,7 @@ final class ErliOrderSyncStateRepository
'last_synced_source_order_id' => null, 'last_synced_source_order_id' => null,
'last_run_at' => null, 'last_run_at' => null,
'last_success_at' => null, 'last_success_at' => null,
'last_status_pushed_at' => null,
'last_error' => null, 'last_error' => null,
]; ];
} }
@@ -119,6 +156,7 @@ final class ErliOrderSyncStateRepository
'last_error', 'last_error',
'last_synced_order_updated_at', 'last_synced_order_updated_at',
'last_synced_source_order_id', 'last_synced_source_order_id',
'last_status_pushed_at',
]; ];
if (!in_array($column, $allowed, true)) { if (!in_array($column, $allowed, true)) {
continue; continue;

View File

@@ -21,7 +21,8 @@ final class ErliOrdersSyncService
private readonly OrderImportRepository $orderImportRepository, private readonly OrderImportRepository $orderImportRepository,
private readonly OrdersRepository $ordersRepository, private readonly OrdersRepository $ordersRepository,
private readonly ErliOrderMapper $mapper, private readonly ErliOrderMapper $mapper,
private readonly ?AutomationService $automationService = null private readonly ?AutomationService $automationService = null,
private readonly ?ErliPullStatusMappingRepository $pullStatusMappings = null
) { ) {
} }
@@ -90,6 +91,7 @@ final class ErliOrdersSyncService
} }
try { try {
$this->discoverStatus($message);
$mapped = $this->mapper->mapInboxMessage($integrationId, $message); $mapped = $this->mapper->mapInboxMessage($integrationId, $message);
if ($mapped === null) { if ($mapped === null) {
$result['skipped'] = (int) $result['skipped'] + 1; $result['skipped'] = (int) $result['skipped'] + 1;
@@ -172,6 +174,25 @@ final class ErliOrdersSyncService
]; ];
} }
/**
* @param array<string, mixed> $message
*/
private function discoverStatus(array $message): void
{
if ($this->pullStatusMappings === null) {
return;
}
$payload = is_array($message['payload'] ?? null) ? $message['payload'] : [];
$rawStatus = strtolower(trim((string) ($payload['status'] ?? '')));
if ($rawStatus === '') {
return;
}
$statusName = trim((string) ($payload['statusName'] ?? $payload['statusLabel'] ?? $rawStatus));
$this->pullStatusMappings->upsertDiscoveredStatus($rawStatus, $statusName);
}
/** /**
* @param array<string, mixed> $mapped * @param array<string, mixed> $mapped
* @param array<string, mixed> $save * @param array<string, mixed> $save

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use PDO;
final class ErliPullStatusMappingRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array{erli_status_code:string,erli_status_name:string,orderpro_status_code:string}>
*/
public function listAll(): array
{
$statement = $this->pdo->query(
'SELECT erli_status_code, erli_status_name, orderpro_status_code
FROM erli_order_status_pull_mappings
ORDER BY erli_status_code ASC'
);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$erliCode = strtolower(trim((string) ($row['erli_status_code'] ?? '')));
if ($erliCode === '') {
continue;
}
$result[] = [
'erli_status_code' => $erliCode,
'erli_status_name' => trim((string) ($row['erli_status_name'] ?? '')),
'orderpro_status_code' => strtolower(trim((string) ($row['orderpro_status_code'] ?? ''))),
];
}
return $result;
}
public function findMappedStatusCode(string $erliStatusCode): ?string
{
$code = strtolower(trim($erliStatusCode));
if ($code === '') {
return null;
}
$statement = $this->pdo->prepare(
'SELECT orderpro_status_code
FROM erli_order_status_pull_mappings
WHERE erli_status_code = :erli_status_code
AND orderpro_status_code IS NOT NULL
AND orderpro_status_code <> ""
LIMIT 1'
);
$statement->execute(['erli_status_code' => $code]);
$value = $statement->fetchColumn();
if (!is_string($value)) {
return null;
}
$mapped = strtolower(trim($value));
return $mapped !== '' ? $mapped : null;
}
public function upsertDiscoveredStatus(string $erliStatusCode, ?string $erliStatusName = null): void
{
$code = strtolower(trim($erliStatusCode));
if ($code === '') {
return;
}
$existing = $this->pdo->prepare(
'SELECT id FROM erli_order_status_pull_mappings
WHERE erli_status_code = :erli_status_code
LIMIT 1'
);
$existing->execute(['erli_status_code' => $code]);
if ($existing->fetchColumn() !== false) {
if ($erliStatusName !== null && trim($erliStatusName) !== '') {
$update = $this->pdo->prepare(
'UPDATE erli_order_status_pull_mappings
SET erli_status_name = :erli_status_name,
updated_at = NOW()
WHERE erli_status_code = :erli_status_code
AND (erli_status_name IS NULL OR erli_status_name = "")'
);
$update->execute([
'erli_status_name' => trim($erliStatusName),
'erli_status_code' => $code,
]);
}
return;
}
$insert = $this->pdo->prepare(
'INSERT INTO erli_order_status_pull_mappings (
erli_status_code, erli_status_name, orderpro_status_code, created_at, updated_at
) VALUES (
:erli_status_code, :erli_status_name, NULL, NOW(), NOW()
)'
);
$insert->execute([
'erli_status_code' => $code,
'erli_status_name' => StringHelper::nullableString((string) $erliStatusName),
]);
}
/**
* @param array<int, array{erli_status_code:string,erli_status_name:string,orderpro_status_code:string}> $mappings
*/
public function replaceAll(array $mappings): void
{
$this->pdo->exec('DELETE FROM erli_order_status_pull_mappings');
if ($mappings === []) {
return;
}
$statement = $this->pdo->prepare(
'INSERT INTO erli_order_status_pull_mappings (
erli_status_code, erli_status_name, orderpro_status_code, created_at, updated_at
) VALUES (
:erli_status_code, :erli_status_name, :orderpro_status_code, NOW(), NOW()
)'
);
foreach ($mappings as $mapping) {
$erliCode = strtolower(trim((string) ($mapping['erli_status_code'] ?? '')));
if ($erliCode === '') {
continue;
}
$statement->execute([
'erli_status_code' => $erliCode,
'erli_status_name' => StringHelper::nullableString((string) ($mapping['erli_status_name'] ?? '')),
'orderpro_status_code' => StringHelper::nullableString(strtolower(trim((string) ($mapping['orderpro_status_code'] ?? '')))),
]);
}
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use PDO;
final class ErliStatusMappingRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array{erli_status_code:string,erli_status_name:string,orderpro_status_code:string}>
*/
public function listAll(): array
{
$statement = $this->pdo->query(
'SELECT erli_status_code, erli_status_name, orderpro_status_code
FROM erli_order_status_mappings
ORDER BY erli_status_code ASC'
);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$code = trim((string) ($row['erli_status_code'] ?? ''));
if ($code === '') {
continue;
}
$result[] = [
'erli_status_code' => $code,
'erli_status_name' => trim((string) ($row['erli_status_name'] ?? '')),
'orderpro_status_code' => strtolower(trim((string) ($row['orderpro_status_code'] ?? ''))),
];
}
return $result;
}
/**
* @return array<string, string> orderpro_status_code => erli_status_code
*/
public function buildOrderproToErliMap(): array
{
$statement = $this->pdo->query(
'SELECT orderpro_status_code, erli_status_code
FROM erli_order_status_mappings
WHERE orderpro_status_code IS NOT NULL
AND orderpro_status_code <> ""
AND erli_status_code IS NOT NULL
AND erli_status_code <> ""
ORDER BY id ASC'
);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
$map = [];
foreach ($rows as $row) {
$orderproCode = strtolower(trim((string) ($row['orderpro_status_code'] ?? '')));
$erliCode = trim((string) ($row['erli_status_code'] ?? ''));
if ($orderproCode === '' || $erliCode === '') {
continue;
}
if (!isset($map[$orderproCode])) {
$map[$orderproCode] = $erliCode;
}
}
return $map;
}
/**
* @param array<int, array{erli_status_code:string,erli_status_name:string,orderpro_status_code:string}> $mappings
*/
public function replaceAll(array $mappings): void
{
$this->pdo->exec('UPDATE erli_order_status_mappings SET orderpro_status_code = NULL, updated_at = NOW()');
if ($mappings === []) {
return;
}
$statement = $this->pdo->prepare(
'INSERT INTO erli_order_status_mappings (
erli_status_code, erli_status_name, orderpro_status_code, created_at, updated_at
) VALUES (
:erli_status_code, :erli_status_name, :orderpro_status_code, NOW(), NOW()
)
ON DUPLICATE KEY UPDATE
erli_status_name = VALUES(erli_status_name),
orderpro_status_code = VALUES(orderpro_status_code),
updated_at = VALUES(updated_at)'
);
foreach ($mappings as $mapping) {
$erliCode = trim((string) ($mapping['erli_status_code'] ?? ''));
if ($erliCode === '') {
continue;
}
$statement->execute([
'erli_status_code' => $erliCode,
'erli_status_name' => StringHelper::nullableString((string) ($mapping['erli_status_name'] ?? '')),
'orderpro_status_code' => StringHelper::nullableString(strtolower(trim((string) ($mapping['orderpro_status_code'] ?? '')))),
]);
}
}
}

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Constants\IntegrationSources;
use App\Modules\Cron\CronRepository;
use PDO;
use Throwable;
final class ErliStatusSyncService
{
private const DIRECTION_ERLI_TO_ORDERPRO = 'erli_to_orderpro';
private const DIRECTION_ORDERPRO_TO_ERLI = 'orderpro_to_erli';
private const MAX_ORDERS_PER_RUN = 50;
public function __construct(
private readonly CronRepository $cronRepository,
private readonly ErliIntegrationRepository $integrationRepository,
private readonly ErliOrdersSyncService $ordersSyncService,
private readonly ErliApiClient $apiClient,
private readonly ErliOrderSyncStateRepository $syncStateRepository,
private readonly ErliStatusMappingRepository $statusMappings,
private readonly PDO $pdo
) {
}
/**
* @return array<string, mixed>
*/
public function sync(): array
{
$direction = $this->resolveDirection();
if ($direction === self::DIRECTION_ORDERPRO_TO_ERLI) {
return $this->syncPushDirection();
}
return $this->syncPullDirection();
}
/**
* @return array<string, mixed>
*/
private function syncPullDirection(): array
{
$result = $this->ordersSyncService->sync([
'ignore_orders_fetch_enabled' => true,
'max_messages' => 200,
]);
$result['ok'] = ((int) ($result['failed'] ?? 0)) === 0;
$result['direction'] = self::DIRECTION_ERLI_TO_ORDERPRO;
return $result;
}
/**
* @return array<string, mixed>
*/
private function syncPushDirection(): array
{
$credentials = $this->integrationRepository->getCredentials();
if ($credentials === null) {
return $this->emptyPushResult(false, 'Brak aktywnej konfiguracji Erli.');
}
$integrationId = (int) ($credentials['integration_id'] ?? 0);
if ($integrationId <= 0) {
return $this->emptyPushResult(false, 'Brak aktywnego ID integracji Erli.');
}
$reverseMap = $this->statusMappings->buildOrderproToErliMap();
if ($reverseMap === []) {
return $this->emptyPushResult(true, 'Brak mapowan statusow orderPRO -> Erli.');
}
$lastStatusPushedAt = $this->syncStateRepository->getLastStatusPushedAt($integrationId);
$orders = $this->findOrdersForPush($integrationId, $lastStatusPushedAt);
if ($orders === []) {
return $this->emptyPushResult(true, 'Brak zamowien do synchronizacji statusow.');
}
$result = [
'ok' => true,
'direction' => self::DIRECTION_ORDERPRO_TO_ERLI,
'pushed' => 0,
'skipped' => 0,
'failed' => 0,
'errors' => [],
];
$latestPushedChangeAt = null;
foreach ($orders as $order) {
$sourceOrderId = trim((string) ($order['source_order_id'] ?? ''));
$orderproStatusCode = strtolower(trim((string) ($order['orderpro_status_code'] ?? '')));
if ($sourceOrderId === '' || $orderproStatusCode === '') {
$result['skipped']++;
continue;
}
$erliStatusCode = $reverseMap[$orderproStatusCode] ?? null;
if ($erliStatusCode === null || trim($erliStatusCode) === '') {
$result['skipped']++;
continue;
}
try {
$apiResult = $this->apiClient->updateOrderStatus($credentials, $sourceOrderId, $erliStatusCode);
if (($apiResult['ok'] ?? false) !== true) {
$result['failed']++;
$this->appendError($result, [
'source_order_id' => $sourceOrderId,
'orderpro_status_code' => $orderproStatusCode,
'erli_status_code' => $erliStatusCode,
'error' => (string) ($apiResult['message'] ?? 'Blad API Erli.'),
]);
continue;
}
$result['pushed']++;
$changeAt = trim((string) ($order['latest_change'] ?? ''));
if ($changeAt !== '' && ($latestPushedChangeAt === null || $changeAt > $latestPushedChangeAt)) {
$latestPushedChangeAt = $changeAt;
}
} catch (Throwable $exception) {
$result['failed']++;
$this->appendError($result, [
'source_order_id' => $sourceOrderId,
'orderpro_status_code' => $orderproStatusCode,
'erli_status_code' => $erliStatusCode,
'error' => $exception->getMessage(),
]);
}
}
if ($latestPushedChangeAt !== null) {
$this->syncStateRepository->updateLastStatusPushedAt($integrationId, $latestPushedChangeAt);
}
$result['ok'] = (int) $result['failed'] === 0;
return $result;
}
/**
* @return array<string, mixed>
*/
private function emptyPushResult(bool $ok, string $message): array
{
return [
'ok' => $ok,
'direction' => self::DIRECTION_ORDERPRO_TO_ERLI,
'pushed' => 0,
'skipped' => 0,
'failed' => 0,
'message' => $message,
'errors' => [],
];
}
/**
* @return array<int, array{order_id:int,source_order_id:string,orderpro_status_code:string,latest_change:string}>
*/
private function findOrdersForPush(int $integrationId, ?string $lastStatusPushedAt): array
{
$sinceDate = $lastStatusPushedAt !== null && trim($lastStatusPushedAt) !== ''
? $lastStatusPushedAt
: date('Y-m-d H:i:s', strtotime('-24 hours'));
try {
$statement = $this->pdo->prepare(
'SELECT
o.id AS order_id,
o.source_order_id,
o.status_code AS orderpro_status_code,
MAX(h.changed_at) AS latest_change
FROM order_status_history h
INNER JOIN orders o ON o.id = h.order_id
WHERE o.source = :source
AND o.integration_id = :integration_id
AND h.change_source = :change_source
AND h.changed_at > :since_date
GROUP BY o.id, o.source_order_id, o.status_code
ORDER BY latest_change ASC
LIMIT ' . self::MAX_ORDERS_PER_RUN
);
$statement->execute([
'source' => IntegrationSources::ERLI,
'integration_id' => $integrationId,
'change_source' => 'manual',
'since_date' => $sinceDate,
]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
return is_array($rows) ? $rows : [];
}
private function resolveDirection(): string
{
$direction = trim($this->cronRepository->getStringSetting(
'erli_status_sync_direction',
self::DIRECTION_ERLI_TO_ORDERPRO
));
return in_array($direction, [self::DIRECTION_ERLI_TO_ORDERPRO, self::DIRECTION_ORDERPRO_TO_ERLI], true)
? $direction
: self::DIRECTION_ERLI_TO_ORDERPRO;
}
/**
* @param array<string, mixed> $result
* @param array<string, mixed> $error
*/
private function appendError(array &$result, array $error): void
{
$errors = is_array($result['errors'] ?? null) ? $result['errors'] : [];
if (count($errors) < 20) {
$errors[] = $error;
}
$result['errors'] = $errors;
}
}

View File

@@ -5,6 +5,7 @@ namespace Tests\Unit;
use App\Core\Constants\IntegrationSources; use App\Core\Constants\IntegrationSources;
use App\Modules\Settings\ErliOrderMapper; use App\Modules\Settings\ErliOrderMapper;
use App\Modules\Settings\ErliPullStatusMappingRepository;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use RuntimeException; use RuntimeException;
@@ -51,6 +52,30 @@ final class ErliOrderMapperTest extends TestCase
self::assertTrue($aggregate['order']['is_canceled_by_buyer']); self::assertTrue($aggregate['order']['is_canceled_by_buyer']);
} }
public function testConfiguredPullMappingOverridesDefaultStatus(): void
{
$pullMappings = $this->createMock(ErliPullStatusMappingRepository::class);
$pullMappings
->method('findMappedStatusCode')
->with('purchased')
->willReturn('w_realizacji');
$mapper = new ErliOrderMapper($pullMappings);
$aggregate = $mapper->mapInboxMessage(7, $this->message('purchased'));
self::assertIsArray($aggregate);
self::assertSame('w_realizacji', $aggregate['order']['status_code']);
}
public function testUnknownStatusFallsBackToRawCodeForDiscoveryMappingLater(): void
{
$aggregate = $this->mapper->mapInboxMessage(7, $this->message('readyToProcess'));
self::assertIsArray($aggregate);
self::assertSame('readytoprocess', $aggregate['order']['status_code']);
self::assertSame('readytoprocess', $aggregate['order']['preferences_json']['erli_status_raw']);
}
public function testCompanyInvoiceDataDetectsInvoiceRequest(): void public function testCompanyInvoiceDataDetectsInvoiceRequest(): void
{ {
$message = $this->message('purchased'); $message = $this->message('purchased');

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Modules\Cron\CronRepository;
use App\Modules\Settings\ErliApiClient;
use App\Modules\Settings\ErliIntegrationRepository;
use App\Modules\Settings\ErliOrdersSyncService;
use App\Modules\Settings\ErliOrderSyncStateRepository;
use App\Modules\Settings\ErliStatusMappingRepository;
use App\Modules\Settings\ErliStatusSyncService;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class ErliStatusSyncServiceTest extends TestCase
{
private CronRepository&MockObject $cronRepository;
private ErliIntegrationRepository&MockObject $integrationRepository;
private ErliOrdersSyncService&MockObject $ordersSyncService;
private ErliApiClient&MockObject $apiClient;
private ErliOrderSyncStateRepository&MockObject $syncStateRepository;
private ErliStatusMappingRepository&MockObject $statusMappings;
protected function setUp(): void
{
$this->cronRepository = $this->createMock(CronRepository::class);
$this->integrationRepository = $this->createMock(ErliIntegrationRepository::class);
$this->ordersSyncService = $this->createMock(ErliOrdersSyncService::class);
$this->apiClient = $this->createMock(ErliApiClient::class);
$this->syncStateRepository = $this->createMock(ErliOrderSyncStateRepository::class);
$this->statusMappings = $this->createMock(ErliStatusMappingRepository::class);
}
public function testPullDirectionDelegatesToOrdersInboxImport(): void
{
$this->cronRepository
->method('getStringSetting')
->willReturn('erli_to_orderpro');
$this->ordersSyncService
->expects($this->once())
->method('sync')
->with([
'ignore_orders_fetch_enabled' => true,
'max_messages' => 200,
])
->willReturn([
'failed' => 0,
'processed' => 2,
]);
$result = $this->createServiceWithRows([])->sync();
self::assertTrue($result['ok']);
self::assertSame('erli_to_orderpro', $result['direction']);
self::assertSame(2, $result['processed']);
}
public function testPushDirectionProcessesMappedManualOrdersAndSkipsUnmapped(): void
{
$this->preparePushDefaults([
'w_realizacji' => 'inProgress',
]);
$this->apiClient
->expects($this->exactly(2))
->method('updateOrderStatus')
->willReturn(['ok' => true, 'http_code' => 204, 'message' => 'OK']);
$this->syncStateRepository
->expects($this->once())
->method('updateLastStatusPushedAt')
->with(12, '2026-05-16 10:10:00');
$service = $this->createServiceWithRows([
['source_order_id' => 'E1', 'orderpro_status_code' => 'w_realizacji', 'latest_change' => '2026-05-16 10:00:00'],
['source_order_id' => 'E2', 'orderpro_status_code' => 'nowe', 'latest_change' => '2026-05-16 10:05:00'],
['source_order_id' => 'E3', 'orderpro_status_code' => 'w_realizacji', 'latest_change' => '2026-05-16 10:10:00'],
]);
$result = $service->sync();
self::assertTrue($result['ok']);
self::assertSame('orderpro_to_erli', $result['direction']);
self::assertSame(2, $result['pushed']);
self::assertSame(1, $result['skipped']);
self::assertSame(0, $result['failed']);
}
public function testPushFailureDoesNotAdvanceCursorPastFailedOrder(): void
{
$this->preparePushDefaults([
'w_realizacji' => 'inProgress',
]);
$this->apiClient
->expects($this->exactly(2))
->method('updateOrderStatus')
->willReturnOnConsecutiveCalls(
['ok' => true, 'http_code' => 204, 'message' => 'OK'],
['ok' => false, 'http_code' => 500, 'message' => 'Erli error']
);
$this->syncStateRepository
->expects($this->once())
->method('updateLastStatusPushedAt')
->with(12, '2026-05-16 10:00:00');
$service = $this->createServiceWithRows([
['source_order_id' => 'E1', 'orderpro_status_code' => 'w_realizacji', 'latest_change' => '2026-05-16 10:00:00'],
['source_order_id' => 'E2', 'orderpro_status_code' => 'w_realizacji', 'latest_change' => '2026-05-16 10:10:00'],
]);
$result = $service->sync();
self::assertFalse($result['ok']);
self::assertSame(1, $result['pushed']);
self::assertSame(1, $result['failed']);
self::assertCount(1, $result['errors']);
}
public function testPushDirectionReturnsEarlyWithoutCredentials(): void
{
$this->cronRepository
->method('getStringSetting')
->willReturn('orderpro_to_erli');
$this->integrationRepository
->method('getCredentials')
->willReturn(null);
$this->apiClient
->expects($this->never())
->method('updateOrderStatus');
$result = $this->createServiceWithRows([])->sync();
self::assertFalse($result['ok']);
self::assertSame(0, $result['pushed']);
self::assertSame('orderpro_to_erli', $result['direction']);
}
/**
* @param array<string, string> $map
*/
private function preparePushDefaults(array $map): void
{
$this->cronRepository
->method('getStringSetting')
->willReturn('orderpro_to_erli');
$this->integrationRepository
->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->syncStateRepository
->method('getLastStatusPushedAt')
->willReturn(null);
$this->statusMappings
->method('buildOrderproToErliMap')
->willReturn($map);
}
/**
* @param array<int, array<string, mixed>> $rows
*/
private function createServiceWithRows(array $rows): ErliStatusSyncService
{
$statement = $this->createMock(PDOStatement::class);
$statement
->method('execute')
->willReturn(true);
$statement
->method('fetchAll')
->willReturn($rows);
$pdo = $this->createMock(PDO::class);
$pdo
->method('prepare')
->willReturn($statement);
return new ErliStatusSyncService(
$this->cronRepository,
$this->integrationRepository,
$this->ordersSyncService,
$this->apiClient,
$this->syncStateRepository,
$this->statusMappings,
$pdo
);
}
}