diff --git a/.paul/PROJECT.md b/.paul/PROJECT.md index 5dbcc27..c45c16f 100644 --- a/.paul/PROJECT.md +++ b/.paul/PROJECT.md @@ -13,8 +13,8 @@ Sprzedawca moĹĽe obsĹ‚ugiwać zamĂłwienia ze wszystkich kanałów | Attribute | Value | |-----------|-------| | Version | 3.8.0-dev | -| Status | v3.8 Erli Marketplace Integration in progress — Phase 128 shipped (Erli orders import); Phase 129 next | -| Last Updated | 2026-05-15 (Phase 128 closed) | +| Status | v3.8 Erli Marketplace Integration in progress — Phase 129 shipped (Erli status mappings/sync); Phase 130 next | +| Last Updated | 2026-05-16 (Phase 129 closed) | ## 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] Fundament integracji Erli: pojedyncza globalna konfiguracja `/settings/integrations/erli`, szyfrowany Bearer API key, realny test `GET /svc/shop-api/inbox`, karta w hubie integracji oraz dokumentacja schematu/architektury — Phase 127 - [x] Import zamowien Erli: pobieranie `/inbox` przez cron i recznie, mapper do orderPRO, delta-only re-import, `invoice_requested` z danych firmowych/NIP, bezpieczny ACK `/inbox/mark-read` po bezblednym batchu — Phase 128 +- [x] Mapowanie i synchronizacja statusow Erli: osobne pull/push mappings, discovery statusow z inboxa, reczny-only push `PATCH /orders/{id}/status`, cron `erli_status_sync` i zakladki w ustawieniach Erli — Phase 129 ### Deferred @@ -136,7 +137,7 @@ Sprzedawca moĹĽe obsĹ‚ugiwać zamĂłwienia ze wszystkich kanałów ### 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) @@ -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 | | 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 | +| 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 @@ -278,6 +282,6 @@ Quick Reference: --- *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* diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index aba56a8..5252d22 100644 --- a/.paul/ROADMAP.md +++ b/.paul/ROADMAP.md @@ -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. +Progress: 3 of 6 phases complete (50%). + | Phase | Name | Plans | Status | |-------|------|-------|--------| | 127 | Erli Integration Foundation | 1/1 | Complete (2026-05-15; migration/manual Erli API smoke pending operator) | | 128 | Erli Orders Import | 1/1 | Complete (2026-05-15; migration/manual Erli import smoke pending operator) | -| 129 | Erli Status Mapping + Sync | 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 | | 131 | Erli Tracking + Automation Hooks | 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 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 @@ -553,4 +555,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md` --- *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* diff --git a/.paul/STATE.md b/.paul/STATE.md index 8591816..427dc08 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -2,22 +2,22 @@ ## 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. -**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 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 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: -- Milestone v3.8: [####------] ~33% (Phases 127-128 complete) -- Phase 129: [----------] 0% (not planned) +- Milestone v3.8: [#####-----] 50% (Phases 127-129 complete; Phase 130 next) +- Phase 130: [----------] 0% (not planned) ## Loop Position @@ -29,20 +29,26 @@ PLAN -> APPLY -> UNIFY ## Session Continuity -Last session: 2026-05-15 23:52 -Stopped at: Phase 128 complete -Next action: $paul-plan for Phase 129 (Erli Status Mapping + Sync) -Resume file: .paul/phases/128-erli-orders-import/128-01-SUMMARY.md +Last session: 2026-05-16 00:23 +Stopped at: Phase 129 complete, ready to plan Phase 130 +Next action: $paul-plan for Phase 130 (Erli Shipments + Labels) +Resume file: .paul/ROADMAP.md ## Pending parallel work - None — Phase 118, 121, 122 wszystkie zacommitowane (8f14851, 360eef1). ## Git State -Last phase commit: 2565d9b feat(128): erli orders import -Previous: d6b18a6 feat(127): erli integration foundation +Last phase commit: b371420 feat(129): erli status mapping sync +Previous: 2565d9b feat(128): erli orders import Branch: main +### Skill Audit (Phase 129) + +| Expected | Invoked | Notes | +|----------|---------|-------| +| `sonar-scanner` | gap documented | Attempted before UNIFY; CLI is not available in PATH. | + ## Pending Actions - 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 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 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 @@ -78,4 +86,4 @@ Branch: main ## 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. diff --git a/.paul/changelog/2026-05-16.md b/.paul/changelog/2026-05-16.md new file mode 100644 index 0000000..dfcffa3 --- /dev/null +++ b/.paul/changelog/2026-05-16.md @@ -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` diff --git a/.paul/codebase/architecture.md b/.paul/codebase/architecture.md index e067b59..e9a5275 100644 --- a/.paul/codebase/architecture.md +++ b/.paul/codebase/architecture.md @@ -69,7 +69,7 @@ HTTP Request 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. 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 1. **Request** — `/statistics/summary` → `OrdersStatisticsController::summary()` @@ -97,6 +97,7 @@ HTTP Request |---------|------| | `AllegroOrdersImportHandler` | Fetch new Allegro orders | | `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) | | `ShopproOrdersImportHandler` | Fetch new shopPRO orders | | `ShopproStatusSyncHandler` | Push status to shopPRO | diff --git a/.paul/codebase/db_schema.md b/.paul/codebase/db_schema.md index 6f26f18..5de3c6b 100644 --- a/.paul/codebase/db_schema.md +++ b/.paul/codebase/db_schema.md @@ -370,6 +370,7 @@ UNIQUE: `(order_id, source_payment_id)` UNIQUE: `(integration_id, shoppro_status_code)` **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 @@ -517,6 +518,14 @@ UNIQUE: `(type, name)` | `created_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 | Column | Type | Notes | |--------|------|-------| diff --git a/.paul/codebase/tech_changelog.md b/.paul/codebase/tech_changelog.md index 4c8dfc6..5cb8377 100644 --- a/.paul/codebase/tech_changelog.md +++ b/.paul/codebase/tech_changelog.md @@ -1,5 +1,16 @@ # 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) **Co zrobiono:** diff --git a/.paul/phases/129-erli-status-mapping-sync/129-01-PLAN.md b/.paul/phases/129-erli-status-mapping-sync/129-01-PLAN.md new file mode 100644 index 0000000..7c55bdf --- /dev/null +++ b/.paul/phases/129-erli-status-mapping-sync/129-01-PLAN.md @@ -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 +--- + + +## 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. + + + + +- **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'`). + + +## 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 + + + +## 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 + + + + +## 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 +``` + + + + + + + Task 1: Add Erli status mapping persistence, seeds, API client method + + 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 + + + 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. + + + `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`. + + AC-1 foundation is implemented and AC-5 has API error result shape available. + + + + Task 2: Add Erli settings UI and routes for pull/push mappings + + src/Modules/Settings/ErliIntegrationController.php, + routes/web.php, + resources/views/settings/erli.php, + resources/lang/pl.php, + DOCS/ARCHITECTURE.md + + + 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. + + + `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()`. + + AC-2 is satisfied and existing Phase 127/128 settings actions remain reachable. + + + + Task 3: Wire pull discovery and push cron sync + + 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 + + + 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. + + + `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. + + AC-3, AC-4, AC-5 and AC-6 are satisfied. + + + + + + +## 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. + + + + +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. + + + +- 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. + + + +After completion, create `.paul/phases/129-erli-status-mapping-sync/129-01-SUMMARY.md`. + diff --git a/.paul/phases/129-erli-status-mapping-sync/129-01-SUMMARY.md b/.paul/phases/129-erli-status-mapping-sync/129-01-SUMMARY.md new file mode 100644 index 0000000..722761a --- /dev/null +++ b/.paul/phases/129-erli-status-mapping-sync/129-01-SUMMARY.md @@ -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* diff --git a/DOCS/ARCHITECTURE.md b/DOCS/ARCHITECTURE.md index 887d9d3..9b19645 100644 --- a/DOCS/ARCHITECTURE.md +++ b/DOCS/ARCHITECTURE.md @@ -39,7 +39,7 @@ HTTP Request | **Accounting** | 5 | `AccountingController`, `ReceiptService`, `ReceiptRepository` | Receipts, invoices, PDF, Excel export | | **Email** | 3 | `EmailSendingService`, `VariableResolver`, `AttachmentGenerator` | Template-based email with PDF attachments | | **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 | | **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 | @@ -74,7 +74,7 @@ HTTP Request ### Order Lifecycle 1. **Import** — Cron handler → API client → `OrderImportService` → `OrdersRepository::insertOrder()` → `AutomationService::executeForNewOrder()` 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 1. **Request** — `/statistics/summary` → `OrdersStatisticsController::summary()` @@ -112,6 +112,7 @@ HTTP Request | `AllegroTokenRefreshHandler` | OAuth token refresh (24h expiry) | | `ShopproOrdersImportHandler` | Fetch new shopPRO orders | | `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 | | `ShopproPaymentStatusSyncHandler` | Sync payment statuses | | `ShipmentTrackingHandler` | Poll carrier tracking APIs | @@ -120,11 +121,12 @@ HTTP Request ### 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_*`. 3. **Hub** - `IntegrationsHubController::buildErliRow()` adds Erli to `/settings/integrations` with configured/missing secret status, active status, last test timestamp, and configure URL. 4. **Order import** - Phase 128 adds `/settings/integrations/erli/import` and cron `erli_orders_import`. Both call `ErliOrdersSyncService`, which fetches unread `/inbox` messages, maps supported order events through `ErliOrderMapper`, persists via `OrderImportRepository::upsertOrderAggregate()`, emits existing automation events, and acknowledges `POST /inbox/mark-read` only after a zero-failure batch. -5. **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 @@ -184,25 +186,33 @@ tests/ ### ErliApiClient (`src/Modules/Settings/ErliApiClient.php`) - `testConnection()` wykonuje realny `GET /inbox` do Erli z naglowkiem `Authorization: Bearer ...`. - Phase 128: `fetchInbox()` pobiera do 500 nieprzeczytanych wiadomosci; `markInboxRead()` potwierdza `POST /inbox/mark-read` z `lastMessageId` dopiero po udanym batchu. +- Phase 129: `updateOrderStatus()` wysyla `PATCH /orders/{id}/status` z body `{"status": "..."}` dla recznych zmian statusu orderPRO mapowanych na status Erli. - Wysyla `Accept: application/json` i `User-Agent: orderPRO/1.0 (erli-integration)`. - Traktuje HTTP 2xx jako sukces; 401/403 jako blad autoryzacji, 429 jako limit zapytan, pozostale bledy jako czytelny komunikat z odpowiedzi. - Uzywa `SslCertificateResolver` i nie wywoluje `curl_close()` (PHP 8.5 compatible). ### ErliIntegrationController (`src/Modules/Settings/ErliIntegrationController.php`) -- Endpointy: `GET /settings/integrations/erli`, `POST /settings/integrations/erli/save`, `POST /settings/integrations/erli/test`, `POST /settings/integrations/erli/import`. -- `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()`. +- 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, 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. ### ErliOrdersSyncService / ErliOrderMapper (`src/Modules/Settings/`) - `ErliOrdersSyncService::sync()` jest wspolnym entrypointem dla crona i importu recznego. Zwraca liczniki `processed`, `imported_created`, `imported_updated`, `failed`, `skipped`, `acknowledged`. - Obsluguje tylko zdarzenia order inbox (`orderCreated`, `orderStatusChanged`, `orderSellerStatusChanged`); wiadomosci produktowe sa pomijane do przyszlych faz. -- `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`. - Automatyzacje: `order.imported` dla nowych zamowien i `payment.status_changed` przy tranzycji platnosci na re-imporcie. ### ErliOrdersImportHandler (`src/Modules/Cron/ErliOrdersImportHandler.php`) - 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 - Dodaje wiersz Erli do `/settings/integrations` ze statusem konfiguracji, sekretu, aktywnosci i ostatniego testu. diff --git a/DOCS/DB_SCHEMA.md b/DOCS/DB_SCHEMA.md index df9a29e..f7272dc 100644 --- a/DOCS/DB_SCHEMA.md +++ b/DOCS/DB_SCHEMA.md @@ -374,6 +374,7 @@ UNIQUE: `(integration_id, shoppro_status_code)` | `last_synced_external_order_id` | VARCHAR(128) | YES | Legacy/source-specific cursor | | `last_run_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 | | | `created_at` | DATETIME | NO | | | `updated_at` | DATETIME | NO | | @@ -524,6 +525,26 @@ UNIQUE: `(type, name)` | `created_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 | Column | Type | Notes | |--------|------|-------| @@ -952,7 +973,7 @@ Index: `(status, priority, scheduled_at)` | `created_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). --- diff --git a/DOCS/TECH_CHANGELOG.md b/DOCS/TECH_CHANGELOG.md index fe3921c..7a567db 100644 --- a/DOCS/TECH_CHANGELOG.md +++ b/DOCS/TECH_CHANGELOG.md @@ -1,5 +1,32 @@ # 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 **Co zrobiono:** diff --git a/database/migrations/20260515_000116_add_erli_status_mapping_sync.sql b/database/migrations/20260515_000116_add_erli_status_mapping_sync.sql new file mode 100644 index 0000000..761c384 --- /dev/null +++ b/database/migrations/20260515_000116_add_erli_status_mapping_sync.sql @@ -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(); diff --git a/resources/lang/pl.php b/resources/lang/pl.php index 5a158b9..4e7e41f 100644 --- a/resources/lang/pl.php +++ b/resources/lang/pl.php @@ -863,6 +863,12 @@ return [ 'erli' => [ 'title' => 'Integracja Erli', 'description' => 'Konfiguracja globalnego polaczenia z marketplace Erli.', + 'tabs' => [ + 'label' => 'Zakladki integracji Erli', + 'integration' => 'Integracja', + 'statuses' => 'Statusy', + 'settings' => 'Ustawienia', + ], 'config' => [ 'title' => 'Konfiguracja API', ], @@ -882,6 +888,10 @@ return [ 'orders_fetch_enabled' => 'Wlacz automatyczny import zamowien', 'orders_fetch_start_date' => 'Data startu importu', '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' => [ 'saved' => 'Klucz API jest zapisany. Pozostaw pole puste, aby nie zmieniac.', @@ -891,6 +901,29 @@ return [ '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_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' => [ 'secret' => 'Sekret API', @@ -914,6 +947,7 @@ return [ ], 'validation' => [ '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' => [ diff --git a/resources/views/settings/erli.php b/resources/views/settings/erli.php index feda821..cab6de7 100644 --- a/resources/views/settings/erli.php +++ b/resources/views/settings/erli.php @@ -10,6 +10,12 @@ $lastTestHttpCode = $settings['last_test_http_code'] ?? null; $ordersFetchEnabled = (bool) ($settings['orders_fetch_enabled'] ?? false); $ordersFetchStartDate = trim((string) ($settings['orders_fetch_start_date'] ?? '')); $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'); ?>
@@ -34,18 +40,33 @@ $ordersImportIntervalMinutes = (int) ($ordersImportIntervalMinutes ?? 5);
-

+ -
- : - - | - : - -
+
+
+

-
- +
+ : + + | + : + +
+ + + + -
- + + + + +
+ +
+ +
+
+ +
+
+

+

+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ +
+ +
+
-
+
+

+

+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+
+ +
+ +
+ +
+
+ +
+ +
+

+
-
+

+
@@ -128,3 +301,40 @@ $ordersImportIntervalMinutes = (int) ($ordersImportIntervalMinutes ?? 5); ?>
+ + + + diff --git a/routes/web.php b/routes/web.php index f286413..b11f351 100644 --- a/routes/web.php +++ b/routes/web.php @@ -35,6 +35,8 @@ use App\Modules\Settings\ErliIntegrationRepository; use App\Modules\Settings\ErliOrderMapper; use App\Modules\Settings\ErliOrderSyncStateRepository; use App\Modules\Settings\ErliOrdersSyncService; +use App\Modules\Settings\ErliPullStatusMappingRepository; +use App\Modules\Settings\ErliStatusMappingRepository; use App\Modules\Settings\FakturowniaApiClient; use App\Modules\Settings\FakturowniaIntegrationController; use App\Modules\Settings\FakturowniaIntegrationRepository; @@ -239,6 +241,8 @@ return static function (Application $app): void { $app->db(), (string) $app->config('app.integrations.secret', '') ); + $erliPullStatusMappingRepository = new ErliPullStatusMappingRepository($app->db()); + $erliStatusMappingRepository = new ErliStatusMappingRepository($app->db()); $notificationRepository = new NotificationRepository($app->db()); $smsMessageRepository = new SmsMessageRepository($app->db()); $smsConversationService = new SmsConversationService( @@ -389,8 +393,9 @@ return static function (Application $app): void { new ErliApiClient(), new OrderImportRepository($app->db()), new OrdersRepository($app->db()), - new ErliOrderMapper(), - $automationService + new ErliOrderMapper($erliPullStatusMappingRepository), + $automationService, + $erliPullStatusMappingRepository ); $erliIntegrationController = new ErliIntegrationController( $template, @@ -400,7 +405,10 @@ return static function (Application $app): void { new ErliApiClient(), new IntegrationsRepository($app->db()), $cronRepository, - $erliOrdersSyncService + $erliOrdersSyncService, + $app->orderStatuses(), + $erliStatusMappingRepository, + $erliPullStatusMappingRepository ); $allegroIntegrationController = new AllegroIntegrationController( $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/test', [$erliIntegrationController, 'test'], [$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->post('/settings/integrations/shoppro/save', [$shopproIntegrationsController, 'save'], [$authMiddleware]); $router->post('/settings/integrations/shoppro/test', [$shopproIntegrationsController, 'test'], [$authMiddleware]); diff --git a/src/Modules/Cron/CronHandlerFactory.php b/src/Modules/Cron/CronHandlerFactory.php index 5291abf..1015bea 100644 --- a/src/Modules/Cron/CronHandlerFactory.php +++ b/src/Modules/Cron/CronHandlerFactory.php @@ -38,6 +38,9 @@ use App\Modules\Settings\ErliIntegrationRepository; use App\Modules\Settings\ErliOrderMapper; use App\Modules\Settings\ErliOrderSyncStateRepository; 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\IntegrationSecretCipher; use App\Modules\Settings\ReceiptConfigRepository; @@ -133,14 +136,20 @@ final class CronHandlerFactory $this->db, $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( - new ErliIntegrationRepository($this->db, $this->integrationSecret), - new ErliOrderSyncStateRepository($this->db), - new ErliApiClient(), + $erliIntegrationRepository, + $erliSyncStateRepository, + $erliApiClient, new OrderImportRepository($this->db), $ordersRepository, - new ErliOrderMapper(), - $automationService + new ErliOrderMapper($erliPullStatusMappingRepository), + $automationService, + $erliPullStatusMappingRepository ); return new CronRunner( @@ -178,6 +187,17 @@ final class CronHandlerFactory 'erli_orders_import' => new ErliOrdersImportHandler( $erliOrdersSyncService ), + 'erli_status_sync' => new ErliStatusSyncHandler( + new ErliStatusSyncService( + $cronRepository, + $erliIntegrationRepository, + $erliOrdersSyncService, + $erliApiClient, + $erliSyncStateRepository, + $erliStatusMappingRepository, + $this->db + ) + ), 'shipment_tracking_sync' => new ShipmentTrackingHandler( new ShipmentTrackingRegistry([ new InpostTrackingService( diff --git a/src/Modules/Cron/ErliStatusSyncHandler.php b/src/Modules/Cron/ErliStatusSyncHandler.php new file mode 100644 index 0000000..beecb55 --- /dev/null +++ b/src/Modules/Cron/ErliStatusSyncHandler.php @@ -0,0 +1,22 @@ + $payload + * @return array + */ + public function handle(array $payload): array + { + return $this->syncService->sync(); + } +} diff --git a/src/Modules/Settings/ErliApiClient.php b/src/Modules/Settings/ErliApiClient.php index 3e4a08d..7adee79 100644 --- a/src/Modules/Settings/ErliApiClient.php +++ b/src/Modules/Settings/ErliApiClient.php @@ -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|null $payload * @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_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 { $opts[CURLOPT_HTTPGET] = true; } diff --git a/src/Modules/Settings/ErliIntegrationController.php b/src/Modules/Settings/ErliIntegrationController.php index 9381357..7eb3405 100644 --- a/src/Modules/Settings/ErliIntegrationController.php +++ b/src/Modules/Settings/ErliIntegrationController.php @@ -21,6 +21,12 @@ final class ErliIntegrationController private const ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS = 300; private const ORDERS_IMPORT_DEFAULT_PRIORITY = 40; 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( private readonly Template $template, @@ -30,20 +36,31 @@ final class ErliIntegrationController private readonly ErliApiClient $apiClient, private readonly IntegrationsRepository $integrations, 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 { + $activeTab = $this->resolveTab((string) $request->input('tab', 'integration')); + $html = $this->template->render('settings/erli', [ 'title' => $this->translator->get('settings.erli.title'), 'activeMenu' => 'settings', 'activeSettings' => 'integrations', 'user' => $this->auth->user(), 'csrfToken' => Csrf::token(), + 'activeTab' => $activeTab, 'settings' => $this->repository->getSettings(), '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', ''), 'successMessage' => (string) Flash::get('settings_success', ''), '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', '')), ]); $this->upsertImportSchedule($request); + $this->saveStatusSyncSettings($request); Flash::set('settings_success', $this->translator->get('settings.erli.flash.saved')); } catch (Throwable $exception) { Flash::set( @@ -81,6 +99,84 @@ final class ErliIntegrationController 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 { $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 { $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 { foreach ($this->cronRepository->listSchedules() as $schedule) { @@ -186,6 +313,27 @@ final class ErliIntegrationController 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 $result */ diff --git a/src/Modules/Settings/ErliOrderMapper.php b/src/Modules/Settings/ErliOrderMapper.php index 18c0063..6b132d8 100644 --- a/src/Modules/Settings/ErliOrderMapper.php +++ b/src/Modules/Settings/ErliOrderMapper.php @@ -15,6 +15,10 @@ final class ErliOrderMapper 'orderSellerStatusChanged', ]; + public function __construct(private readonly ?ErliPullStatusMappingRepository $pullStatusMappings = null) + { + } + /** * @param array $message * @return array{ @@ -299,6 +303,13 @@ final class ErliOrderMapper private function mapOrderStatus(string $status): string { + if ($this->pullStatusMappings !== null) { + $mapped = $this->pullStatusMappings->findMappedStatusCode($status); + if ($mapped !== null) { + return $mapped; + } + } + return match ($status) { 'pending' => 'nieoplacone', 'purchased' => 'nowe', diff --git a/src/Modules/Settings/ErliOrderSyncStateRepository.php b/src/Modules/Settings/ErliOrderSyncStateRepository.php index d50fa9b..fde39e2 100644 --- a/src/Modules/Settings/ErliOrderSyncStateRepository.php +++ b/src/Modules/Settings/ErliOrderSyncStateRepository.php @@ -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 { @@ -30,6 +30,7 @@ final class ErliOrderSyncStateRepository last_synced_source_order_id, last_run_at, last_success_at, + last_status_pushed_at, last_error FROM integration_order_sync_state 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_run_at' => StringHelper::nullableString((string) ($row['last_run_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'] ?? '')), ]; } @@ -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 { @@ -94,6 +130,7 @@ final class ErliOrderSyncStateRepository 'last_synced_source_order_id' => null, 'last_run_at' => null, 'last_success_at' => null, + 'last_status_pushed_at' => null, 'last_error' => null, ]; } @@ -119,6 +156,7 @@ final class ErliOrderSyncStateRepository 'last_error', 'last_synced_order_updated_at', 'last_synced_source_order_id', + 'last_status_pushed_at', ]; if (!in_array($column, $allowed, true)) { continue; diff --git a/src/Modules/Settings/ErliOrdersSyncService.php b/src/Modules/Settings/ErliOrdersSyncService.php index da0b0e3..d676ecd 100644 --- a/src/Modules/Settings/ErliOrdersSyncService.php +++ b/src/Modules/Settings/ErliOrdersSyncService.php @@ -21,7 +21,8 @@ final class ErliOrdersSyncService private readonly OrderImportRepository $orderImportRepository, private readonly OrdersRepository $ordersRepository, 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 { + $this->discoverStatus($message); $mapped = $this->mapper->mapInboxMessage($integrationId, $message); if ($mapped === null) { $result['skipped'] = (int) $result['skipped'] + 1; @@ -172,6 +174,25 @@ final class ErliOrdersSyncService ]; } + /** + * @param array $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 $mapped * @param array $save diff --git a/src/Modules/Settings/ErliPullStatusMappingRepository.php b/src/Modules/Settings/ErliPullStatusMappingRepository.php new file mode 100644 index 0000000..661d5ee --- /dev/null +++ b/src/Modules/Settings/ErliPullStatusMappingRepository.php @@ -0,0 +1,148 @@ + + */ + 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 $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'] ?? '')))), + ]); + } + } +} diff --git a/src/Modules/Settings/ErliStatusMappingRepository.php b/src/Modules/Settings/ErliStatusMappingRepository.php new file mode 100644 index 0000000..6323837 --- /dev/null +++ b/src/Modules/Settings/ErliStatusMappingRepository.php @@ -0,0 +1,117 @@ + + */ + 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 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 $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'] ?? '')))), + ]); + } + } +} diff --git a/src/Modules/Settings/ErliStatusSyncService.php b/src/Modules/Settings/ErliStatusSyncService.php new file mode 100644 index 0000000..9a8fa8f --- /dev/null +++ b/src/Modules/Settings/ErliStatusSyncService.php @@ -0,0 +1,223 @@ + + */ + public function sync(): array + { + $direction = $this->resolveDirection(); + if ($direction === self::DIRECTION_ORDERPRO_TO_ERLI) { + return $this->syncPushDirection(); + } + + return $this->syncPullDirection(); + } + + /** + * @return array + */ + 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 + */ + 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 + */ + 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 + */ + 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 $result + * @param array $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; + } +} diff --git a/tests/Unit/ErliOrderMapperTest.php b/tests/Unit/ErliOrderMapperTest.php index 530a5ba..acfb766 100644 --- a/tests/Unit/ErliOrderMapperTest.php +++ b/tests/Unit/ErliOrderMapperTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit; use App\Core\Constants\IntegrationSources; use App\Modules\Settings\ErliOrderMapper; +use App\Modules\Settings\ErliPullStatusMappingRepository; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -51,6 +52,30 @@ final class ErliOrderMapperTest extends TestCase 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 { $message = $this->message('purchased'); diff --git a/tests/Unit/ErliStatusSyncServiceTest.php b/tests/Unit/ErliStatusSyncServiceTest.php new file mode 100644 index 0000000..75dc0ac --- /dev/null +++ b/tests/Unit/ErliStatusSyncServiceTest.php @@ -0,0 +1,200 @@ +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 $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> $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 + ); + } +}