From 2565d9b75418f9bfd00305270ce62f0cf07a7dfe Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Fri, 15 May 2026 23:54:22 +0200 Subject: [PATCH] feat(128): erli orders import Phase 128 complete: - add Erli /inbox order import with safe mark-read ACK - add cron/manual import controls and sync state tracking - map Erli orders into orderPRO aggregates with mapper tests and docs --- .paul/PROJECT.md | 15 +- .paul/ROADMAP.md | 6 +- .paul/STATE.md | 22 +- .paul/changelog/2026-05-15.md | 12 + .../128-erli-orders-import/128-01-PLAN.md | 311 ++++++++++++ .../128-erli-orders-import/128-01-SUMMARY.md | 177 +++++++ DOCS/ARCHITECTURE.md | 20 +- DOCS/DB_SCHEMA.md | 13 + DOCS/TECH_CHANGELOG.md | 18 + ...000115_add_erli_orders_import_schedule.sql | 29 ++ resources/lang/pl.php | 15 + resources/views/settings/erli.php | 35 ++ routes/web.php | 31 +- src/Core/Constants/IntegrationSources.php | 1 + src/Modules/Cron/CronHandlerFactory.php | 17 + src/Modules/Cron/ErliOrdersImportHandler.php | 24 + src/Modules/Settings/ErliApiClient.php | 88 +++- .../Settings/ErliIntegrationController.php | 92 +++- .../Settings/ErliIntegrationRepository.php | 44 +- src/Modules/Settings/ErliOrderMapper.php | 467 ++++++++++++++++++ .../Settings/ErliOrderSyncStateRepository.php | 143 ++++++ .../Settings/ErliOrdersSyncService.php | 292 +++++++++++ tests/Unit/ErliOrderMapperTest.php | 152 ++++++ 23 files changed, 1989 insertions(+), 35 deletions(-) create mode 100644 .paul/phases/128-erli-orders-import/128-01-PLAN.md create mode 100644 .paul/phases/128-erli-orders-import/128-01-SUMMARY.md create mode 100644 database/migrations/20260515_000115_add_erli_orders_import_schedule.sql create mode 100644 src/Modules/Cron/ErliOrdersImportHandler.php create mode 100644 src/Modules/Settings/ErliOrderMapper.php create mode 100644 src/Modules/Settings/ErliOrderSyncStateRepository.php create mode 100644 src/Modules/Settings/ErliOrdersSyncService.php create mode 100644 tests/Unit/ErliOrderMapperTest.php diff --git a/.paul/PROJECT.md b/.paul/PROJECT.md index c7d0a75..5dbcc27 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 127 shipped (Erli settings/API foundation); Phase 128 next | -| Last Updated | 2026-05-15 (Phase 127 closed) | +| 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) | ## Requirements @@ -127,6 +127,7 @@ Sprzedawca moĹĽe obsĹ‚ugiwać zamĂłwienia ze wszystkich kanałów - [x] Szablony SMS: CRUD w `/settings/sms-templates` (name + body + is_active), wspolny `SmsVariableResolver` wydzielony z Email\\VariableResolver (placeholdery `{{zamowienie.*|kupujacy.*|adres.*|firma.*|przesylka.*}}`), dropdown "Wybierz szablon" w zakladce SMS na `/orders/{id}` wstawia rozwiniete zmienne do textarea (z `OrderProAlerts.confirm` przy nadpisaniu); stopka SMSPLANET dalej doklejana wylacznie przez `SmsConversationService::buildFinalOutboundBody()` (Phase 122 contract preserved) — Phase 124 - [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 ### Deferred @@ -135,11 +136,10 @@ Sprzedawca moĹĽe obsĹ‚ugiwać zamĂłwienia ze wszystkich kanałów ### Active (In Progress) -- [ ] v3.8 Erli Marketplace Integration — Phase 127 shipped; Phase 128 next: pobieranie zamowien Erli przez cron/import reczny, mapper do wspolnego modelu orderPRO i state cursor. +- [ ] v3.8 Erli Marketplace Integration — Phase 129 next: mapowanie statusow pull/push Erli i synchronizacja statusow. ### Planned (Next) -- [ ] Erli status mapping + sync — Phase 129 - [ ] Erli shipments + labels — Phase 130 - [ ] Erli tracking + automation hooks — Phase 131 - [ ] Erli hardening, observability + docs — Phase 132 @@ -244,12 +244,15 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API | `$messageHtml` w alert component musi być `unset()` po każdym include | PHP `include` widzi zmienne kontekstu z extracted scope; bez `unset` kolejny include w tym samym widoku falszywie wykrywa `isset($messageHtml)`. Pattern dla wszystkich miejsc używających `$messageHtml` (4 widoki: invoice_form, receipt-create, printing, statistics/orders) | 2026-05-12 | Active | | Erli startuje jako jedna globalna konfiguracja bez sandbox switcha | Operator wybral prosty model pojedynczego konta; srodowisko testowe Erli wymaga osobnej domeny z BOK, wiec nie trafia do Phase 127 | 2026-05-15 | Active | | Test Erli uzywa realnego read-only `GET /inbox` | Operator wymagal realnego testu API, ale fundament nie moze jeszcze importowac zamowien ani oznaczac inboxa jako przeczytanego | 2026-05-15 | Active | +| Erli import uzywa `/inbox` jako glownego zrodla zdarzen | Model inbox jest event-driven i pasuje do bezpiecznego przetwarzania batchy oraz przyszlych aktualizacji statusow | 2026-05-15 | Active | +| ACK Erli przez `POST /inbox/mark-read` tylko po bezblednym batchu | Zapobiega utracie zdarzen, gdy lokalny import czesciowo sie nie powiedzie | 2026-05-15 | Active | +| 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 | ## Success Metrics | Metric | Target | Current | Status | |--------|--------|---------|--------| -| Liczba zintegrowanych ĹşrĂłdeĹ‚ zamĂłwieĹ„ | ≥3 | 2 aktywne importy + fundament Erli | In progress | +| Liczba zintegrowanych ĹşrĂłdeĹ‚ zamĂłwieĹ„ | ≥3 | 3 zrodla importu (Allegro, shopPRO, Erli); Erli wymaga manualnego smoke po migracji | In progress | | Generowanie etykiet | DziaĹ‚a | InPost | In progress | ## Tech Stack @@ -275,6 +278,6 @@ Quick Reference: --- *PROJECT.md — Updated when requirements or context change* -*Last updated: 2026-05-15 after Phase 127 (Erli Integration Foundation) closure; v3.8 milestone in progress* +*Last updated: 2026-05-15 after Phase 128 (Erli Orders Import) closure; v3.8 milestone in progress* diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index 4a0d1ad..aba56a8 100644 --- a/.paul/ROADMAP.md +++ b/.paul/ROADMAP.md @@ -13,7 +13,7 @@ Pelna integracja z erli.pl wzorowana na istniejacej integracji Allegro: konfigur | 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 | TBD | Not started | +| 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 | | 130 | Erli Shipments + Labels | TBD | Not started | | 131 | Erli Tracking + Automation Hooks | TBD | Not started | @@ -27,7 +27,7 @@ Plans: 127-01 (complete) ### Phase 128: Erli Orders Import Focus: Pobieranie nowych zamowien Erli przez cron i import reczny, mapper do wspolnego modelu orderPRO, state cursor, delta-only re-import, adresy/pozycje/platnosci/notatki oraz flaga faktury/NIP tam, gdzie API Erli daje dane firmowe. -Plans: TBD (defined during $paul-plan) +Plans: 128-01 (complete) ### Phase 129: Erli Status Mapping + Sync @@ -553,4 +553,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md` --- *Roadmap created: 2026-03-12* -*Last updated: 2026-05-15 - Phase 127 UNIFY closed* +*Last updated: 2026-05-15 - Phase 128 UNIFY closed* diff --git a/.paul/STATE.md b/.paul/STATE.md index 59977ef..0410991 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -5,19 +5,19 @@ See: .paul/PROJECT.md (updated 2026-05-07) **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 127 complete; Phase 128 ready to plan. +**Current focus:** v3.8 Erli Marketplace Integration - Phase 128 complete; Phase 129 ready to plan. ## Current Position Milestone: v3.8 Erli Marketplace Integration -Phase: 128 of 132 (Erli Orders Import) +Phase: 129 of 132 (Erli Status Mapping + Sync) Plan: Not started Status: Ready to plan -Last activity: 2026-05-15 23:26 - Phase 127 complete; transitioned to Phase 128 +Last activity: 2026-05-15 23:52 - Phase 128 complete; transitioned to Phase 129 Progress: -- Milestone v3.8: [##--------] ~16% (Phase 127 complete) -- Phase 128: [----------] 0% (not planned) +- Milestone v3.8: [####------] ~33% (Phases 127-128 complete) +- Phase 129: [----------] 0% (not planned) ## Loop Position @@ -29,10 +29,10 @@ PLAN -> APPLY -> UNIFY ## Session Continuity -Last session: 2026-05-15 23:26 -Stopped at: Phase 127 complete; Phase 128 ready to plan -Next action: $paul-plan for Phase 128 (Erli Orders Import) -Resume file: .paul/ROADMAP.md +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 ## Pending parallel work - None — Phase 118, 121, 122 wszystkie zacommitowane (8f14851, 360eef1). @@ -66,6 +66,8 @@ Branch: main - Phase 121 transition note (rozwiązane): commit 360eef1 obejmuje Phase 121 i Phase 122 razem; per-faza hunk-split nie wykonany ze względu na nakładkowe modyfikacje plików. - Phase 126 follow-up: manual smoke `/orders/1090/invoice/create` (JDG, NIP 5170167517) -> "Imie i nazwisko"="JACEK PYZIAK", "Nazwa firmy"="Project-Pro Pyziak Jacek" niezmieniona; drugi smoke na zamowieniu spolki z aktywnym KRS; `curl /api/nip/lookup?nip=5170167517` -> `data.is_jdg=true`. - 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. ## Deferred to Next Milestones @@ -76,4 +78,4 @@ Branch: main ## Skill Requirements -- `sonar-scanner` required after APPLY; Phase 116, Phase 117, Phase 121 and Phase 122 gaps documented because CLI was not available in PATH. +- `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. diff --git a/.paul/changelog/2026-05-15.md b/.paul/changelog/2026-05-15.md index 668203d..d9b76f0 100644 --- a/.paul/changelog/2026-05-15.md +++ b/.paul/changelog/2026-05-15.md @@ -4,6 +4,8 @@ - [Phase 127, Plan 01] Dodano fundament integracji Erli: globalna konfiguracja API, szyfrowany klucz, realny test polaczenia, widok ustawien i wiersz w hubie integracji. - Utworzono plan i summary dla Phase 127 oraz przygotowano przejscie do Phase 128. +- [Phase 128, Plan 01] Wdrozono import zamowien Erli przez `/inbox`: cron, reczny import, mapper, sync service i bezpieczny ACK `/inbox/mark-read`. +- Dodano test mappera Erli oraz dokumentacje DB/architektury/changelogu dla importu zamowien. ## Zmienione pliki @@ -22,3 +24,13 @@ - `DOCS/DB_SCHEMA.md` - `DOCS/ARCHITECTURE.md` - `DOCS/TECH_CHANGELOG.md` +- `.paul/phases/128-erli-orders-import/128-01-PLAN.md` +- `.paul/phases/128-erli-orders-import/128-01-SUMMARY.md` +- `database/migrations/20260515_000115_add_erli_orders_import_schedule.sql` +- `src/Core/Constants/IntegrationSources.php` +- `src/Modules/Cron/CronHandlerFactory.php` +- `src/Modules/Cron/ErliOrdersImportHandler.php` +- `src/Modules/Settings/ErliOrderMapper.php` +- `src/Modules/Settings/ErliOrderSyncStateRepository.php` +- `src/Modules/Settings/ErliOrdersSyncService.php` +- `tests/Unit/ErliOrderMapperTest.php` diff --git a/.paul/phases/128-erli-orders-import/128-01-PLAN.md b/.paul/phases/128-erli-orders-import/128-01-PLAN.md new file mode 100644 index 0000000..ecc92ec --- /dev/null +++ b/.paul/phases/128-erli-orders-import/128-01-PLAN.md @@ -0,0 +1,311 @@ +--- +phase: 128-erli-orders-import +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - database/migrations/20260515_000115_add_erli_orders_import_schedule.sql + - src/Core/Constants/IntegrationSources.php + - src/Modules/Settings/ErliApiClient.php + - src/Modules/Settings/ErliIntegrationRepository.php + - src/Modules/Settings/ErliIntegrationController.php + - src/Modules/Settings/ErliOrderMapper.php + - src/Modules/Settings/ErliOrderSyncStateRepository.php + - src/Modules/Settings/ErliOrdersSyncService.php + - src/Modules/Cron/ErliOrdersImportHandler.php + - src/Modules/Cron/CronHandlerFactory.php + - routes/web.php + - resources/views/settings/erli.php + - resources/lang/pl.php + - tests/Unit/ErliOrderMapperTest.php + - DOCS/DB_SCHEMA.md + - DOCS/ARCHITECTURE.md + - DOCS/TECH_CHANGELOG.md +autonomous: true +delegation: auto +--- + + +## Goal +Wdrozyc realny import zamowien Erli do orderPRO na bazie Erli `/inbox`: cron job, reczny import z ustawien, mapper payloadu zamowienia do wspolnego modelu `OrderImportRepository` oraz bezpieczne potwierdzanie przeczytania inboxa po udanym batchu. + +## Purpose +Phase 127 dala konfiguracje i test API. Phase 128 ma sprawic, ze Erli zaczyna dostarczac realne zamowienia do listy orderPRO, z zachowaniem kontraktow delta-only re-import, `invoice_requested` i automatyzacji `order.imported` / `payment.status_changed`. + +## Output +Nowe klasy importu Erli, cron schedule `erli_orders_import`, przycisk recznego importu w `/settings/integrations/erli`, testy mappera oraz dokumentacja techniczna. + + + + +- **Zrodlo importu** - Czy Phase 128 ma uzywac Erli `/inbox`, czy klasycznej listy zamowien po `updated`? + -> Odpowiedz: Wg rekomendacji; uzyc `/inbox` jako glownego zrodla. +- **ACK inboxa** - Czy po udanym przetworzeniu oznaczac wiadomosci Erli jako przeczytane? + -> Odpowiedz: Wg rekomendacji; oznaczac po udanym batchu, z notatka jezeli trzeba cos pozniej zrobic. +- **Reczny import** - Czy dodac reczna akcje importu w ustawieniach, czy tylko cron? + -> Odpowiedz: Obie. + + +## 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 + +## Source Files +@src/Modules/Settings/ErliApiClient.php +@src/Modules/Settings/ErliIntegrationRepository.php +@src/Modules/Settings/ErliIntegrationController.php +@resources/views/settings/erli.php +@src/Modules/Settings/AllegroOrdersSyncService.php +@src/Modules/Settings/AllegroOrderImportService.php +@src/Modules/Settings/ShopproOrdersSyncService.php +@src/Modules/Settings/ShopproOrderMapper.php +@src/Modules/Settings/AllegroOrderSyncStateRepository.php +@src/Modules/Orders/OrderImportRepository.php +@src/Modules/Cron/CronHandlerFactory.php +@src/Modules/Cron/AllegroOrdersImportHandler.php +@src/Modules/Cron/ShopproOrdersImportHandler.php +@routes/web.php +@resources/lang/pl.php +@tests/Unit/OrderImportRepositoryTest.php +@tests/Unit/AllegroOrderImportServiceTest.php + +## External API Notes +@https://erli.pl/svc/shop-api/doc/ +- Erli API uses REST over HTTPS with `Authorization: Bearer ...`, `Accept: application/json` and a meaningful `User-Agent`. +- Orders and order changes are available through `/svc/shop-api/inbox`. +- One fetch returns up to 500 unread messages. +- Messages should be marked read only after processing, using the id of the newest/last message. +- Status basics: `pending` means unpaid PayU; `purchased` means paid PayU or COD; `cancelled` means cancelled. +- If the exact ACK endpoint/method is not recoverable from the public reference during APPLY, import must stay non-destructive, skip ACK, and SUMMARY must record the follow-up. + + + +## Required Skills / Tools (from SPECIAL-FLOWS.md) + +| Skill / Tool | Priority | When to Invoke | Loaded? | +|--------------|----------|----------------|---------| +| `sonar-scanner` | required | After APPLY, before UNIFY | o | + +## Optional Flows +- `/feature-dev` optional before implementation of this marketplace feature. +- `/code-review` optional after implementation, before UNIFY. + + + + +## AC-1: Import Configuration And Cron +```gherkin +Given Erli settings have a saved API key +When the operator enables Erli order import and saves the settings +Then `orders_fetch_enabled`, optional `orders_fetch_start_date`, cron interval, and `erli_orders_import` schedule are persisted +And the settings page offers a CSRF-protected "Importuj teraz" action. +``` + +## AC-2: Inbox Fetch And Safe Acknowledgement +```gherkin +Given Erli returns unread `/inbox` messages containing order events +When the cron or manual import processes the batch without per-order failures +Then every supported order event is imported or re-imported +And Erli inbox is marked read only up to the newest processed message id. +``` + +## AC-3: No Data Loss On Partial Failure +```gherkin +Given an Erli inbox batch contains at least one order that cannot be mapped or saved +When import finishes with failures +Then the sync state records the failure +And the inbox acknowledgement is not sent for that batch +And the result exposes processed/imported/failed/skipped counters plus sampled errors. +``` + +## AC-4: Order Aggregate Mapping +```gherkin +Given an Erli order payload contains buyer, delivery, payment, line items, totals and optional invoice/company data +When the mapper builds an order aggregate +Then `orders`, `order_addresses`, `order_items`, `order_payments`, `order_notes`, `order_status_history` receive orderPRO-compatible data +And new orders with invoice/company markers set `orders.invoice_requested=1`. +``` + +## AC-5: Existing Import Contracts Preserved +```gherkin +Given an Erli order already exists in orderPRO +When the same order is imported again from a changed inbox event +Then `OrderImportRepository::upsertOrderAggregate()` performs delta-only re-import +And local items/addresses/notes are not replaced on re-import +And payment transition can still trigger `payment.status_changed`. +``` + +## AC-6: Observability And Documentation +```gherkin +Given Phase 128 is complete +When maintainers read the docs or run tests +Then Erli import architecture, schema/schedule changes, verification gaps and manual smoke steps are documented +And mapper/unit checks cover the core Erli payload shapes. +``` + + + + + + + Task 1: Add Erli import controls, schedule and entry points + + database/migrations/20260515_000115_add_erli_orders_import_schedule.sql, + src/Modules/Settings/ErliIntegrationRepository.php, + src/Modules/Settings/ErliIntegrationController.php, + resources/views/settings/erli.php, + resources/lang/pl.php, + routes/web.php, + src/Modules/Cron/ErliOrdersImportHandler.php, + src/Modules/Cron/CronHandlerFactory.php + + + Add an idempotent migration seeding `cron_schedules.job_type='erli_orders_import'` with a conservative default interval (5 minutes), disabled until the operator enables import. + Reuse existing `integrations.orders_fetch_enabled` and `integrations.orders_fetch_start_date`; do not add duplicate Erli-only columns for the same settings. + Extend Erli settings save/read to expose: + - import enabled checkbox, + - optional start date, + - order import interval minutes using `CronRepository::upsertSchedule`. + Add a POST `/settings/integrations/erli/import` action protected by CSRF that calls the Erli sync service with `ignore_orders_fetch_enabled=true` and small manual limits. + Wire `ErliOrdersImportHandler` into `CronHandlerFactory` as `erli_orders_import`. + Keep UI compact and reuse existing alert component; do not add inline CSS or native `alert()` / `confirm()`. + + + `C:\xampp\php\php.exe -l src/Modules/Settings/ErliIntegrationRepository.php` + `C:\xampp\php\php.exe -l src/Modules/Settings/ErliIntegrationController.php` + `C:\xampp\php\php.exe -l src/Modules/Cron/ErliOrdersImportHandler.php` + `C:\xampp\php\php.exe -l src/Modules/Cron/CronHandlerFactory.php` + `C:\xampp\php\php.exe -l routes/web.php` + `C:\xampp\php\php.exe -l resources/views/settings/erli.php` + + AC-1 satisfied: Erli import can be enabled, scheduled, and manually triggered from settings. + + + + Task 2: Implement Erli inbox client, mapper and sync service + + src/Core/Constants/IntegrationSources.php, + src/Modules/Settings/ErliApiClient.php, + src/Modules/Settings/ErliOrderMapper.php, + src/Modules/Settings/ErliOrderSyncStateRepository.php, + src/Modules/Settings/ErliOrdersSyncService.php + + + Add `IntegrationSources::ERLI = 'erli'`. + Extend `ErliApiClient` with reusable JSON request helpers: + - `fetchInbox(base_url, api_key, timeout)` via `GET /inbox`, + - `ackInboxRead(base_url, api_key, timeout, latest_message_id)` after confirming the exact ACK method/path in Erli reference before coding, + - consistent handling for 401/403, 429, non-JSON bodies and cURL errors. + Build `ErliOrderMapper` that accepts supported inbox event payloads (`orderCreated`, `orderStatusChanged` and equivalent shape variants) and produces the aggregate arrays required by `OrderImportRepository::upsertOrderAggregate()`. + Mapping rules: + - source/integration: `source='erli'`, `external_platform_id='erli'`, + - status defaults: `pending -> nieoplacone`, `purchased -> nowe`, `cancelled -> anulowane`; richer pull/push mapping is deferred to Phase 129, + - payment status: `pending -> 0`, `purchased -> 2`, COD `purchased -> 2`, cancelled -> 0 unless payload clearly says paid/refunded, + - totals, currency and delivery price from payload when present, + - customer, delivery and invoice addresses from payload; company tax number/company name should set `invoice_detected=true`, + - items with source ids, names, quantity, gross price, SKU/EAN/image when present, + - buyer message/comment as order note when present, + - status history row with raw Erli status in payload/comment. + Build `ErliOrdersSyncService` that: + - reads active credentials from `ErliIntegrationRepository`, + - respects `orders_fetch_enabled` unless manual import overrides it, + - filters/skips messages older than `orders_fetch_start_date` where payload dates allow it, + - imports each supported order event through `OrderImportRepository`, + - records import activity with source `Erli`, + - sets `invoice_requested` only for newly created orders when mapper detects invoice/company data, + - triggers `order.imported` for created orders and `payment.status_changed` for re-import payment transitions, + - advances `integration_order_sync_state` on success and stores errors on failure, + - sends ACK only if the full batch had zero import failures and ACK endpoint was confirmed. + If the ACK endpoint cannot be confirmed in APPLY, implement the service with ACK disabled by default, return `acknowledged=false`, and add a clear follow-up in SUMMARY/STATE; do not guess a destructive endpoint. + + + `C:\xampp\php\php.exe -l src/Modules/Settings/ErliApiClient.php` + `C:\xampp\php\php.exe -l src/Modules/Settings/ErliOrderMapper.php` + `C:\xampp\php\php.exe -l src/Modules/Settings/ErliOrderSyncStateRepository.php` + `C:\xampp\php\php.exe -l src/Modules/Settings/ErliOrdersSyncService.php` + Static check: `rg -n "IntegrationSources::ERLI|erli_orders_import|ackInboxRead|order.imported|payment.status_changed" src routes` + + AC-2, AC-3, AC-4 and AC-5 satisfied for the runtime import path. + + + + Task 3: Add mapper tests and update technical docs + + tests/Unit/ErliOrderMapperTest.php, + DOCS/DB_SCHEMA.md, + DOCS/ARCHITECTURE.md, + DOCS/TECH_CHANGELOG.md + + + Add PHPUnit tests for `ErliOrderMapper` covering: + - paid/purchased order maps to an importable aggregate with source `erli`, + - pending order maps payment status 0 and status `nieoplacone`, + - cancelled order maps status `anulowane` and cancellation flag, + - invoice/company/tax id data sets `invoice_detected=true`, + - malformed/unsupported inbox messages are skipped or throw controlled mapper exceptions as designed. + Update DB docs with the new cron schedule and any sync-state usage/migration changes. + Update architecture docs with Erli import flow: settings/manual import/cron -> inbox client -> mapper -> `OrderImportRepository` -> automation. + Update technical changelog with Phase 128 scope, status defaults, ACK safety rule, manual verification steps and any deferred ACK follow-up if needed. + + + `C:\xampp\php\php.exe -l tests/Unit/ErliOrderMapperTest.php` + If dependencies are installed: `vendor\bin\phpunit tests\Unit\ErliOrderMapperTest.php` + `git diff --check` + `sonar-scanner` after APPLY when available in PATH. + + AC-6 satisfied: tests and documentation describe the new Erli import behavior and remaining live-smoke steps. + + + + + + +## DO NOT CHANGE +- Do not change Allegro/shopPRO import behavior except for shared constants or wiring required by Erli. +- Do not weaken Phase 112/119 delta-only re-import protections in `OrderImportRepository`. +- Do not implement Erli status push, pull status mapping UI, label generation, shipment creation or tracking in this plan. +- Do not add a sandbox/environment switch; Phase 127 decision says one production/global config. +- Do not introduce native JS `alert()` / `confirm()` or CSS inside views. +- Do not use `DB_HOST_REMOTE` in runtime code. + +## SCOPE LIMITS +- Phase 128 imports orders from Erli; Phase 129 owns configurable status mappings and status sync. +- Phase 130 owns shipments/labels. +- Phase 131 owns tracking/automation hooks beyond existing `order.imported` and `payment.status_changed`. +- Product catalog/stock sync is out of scope even though Erli inbox may include product sync messages. +- Live import verification requires real Erli credentials and local DB migration; if unavailable, record as manual follow-up. + + + + +Before declaring plan complete: +- [ ] `C:\xampp\php\php.exe -l` passes for all created/modified PHP files. +- [ ] `vendor\bin\phpunit tests\Unit\ErliOrderMapperTest.php` passes when dependencies are installed. +- [ ] `git diff --check` passes. +- [ ] `sonar-scanner` run or documented as unavailable. +- [ ] Manual smoke documented: run `php bin/migrate.php`, enable Erli import, click "Importuj teraz", confirm Erli orders appear with `source='erli'`. +- [ ] If ACK endpoint was confirmed: successful import returns `acknowledged=true`; failure batch returns `acknowledged=false`. +- [ ] All acceptance criteria met or deferred with explicit reason in SUMMARY. + + + +- Erli orders can be imported by cron and manually from settings. +- Successful supported inbox events create/update orderPRO orders with addresses, items, payments, notes and status history. +- Re-import keeps existing delta-only protections. +- Inbox read acknowledgement is safe: only after all processed messages in the batch succeed, or explicitly disabled with follow-up if the ACK endpoint cannot be confirmed. +- Operator-visible result counters exist for manual import and cron payload result. +- Documentation and tests are updated. + + + +After completion, create `.paul/phases/128-erli-orders-import/128-01-SUMMARY.md`. + diff --git a/.paul/phases/128-erli-orders-import/128-01-SUMMARY.md b/.paul/phases/128-erli-orders-import/128-01-SUMMARY.md new file mode 100644 index 0000000..2ac369f --- /dev/null +++ b/.paul/phases/128-erli-orders-import/128-01-SUMMARY.md @@ -0,0 +1,177 @@ +--- +phase: 128-erli-orders-import +plan: 01 +subsystem: settings, integrations, cron, api, database, testing +tags: [erli, marketplace, orders-import, inbox, cron, mapper, automation] +requires: + - phase: 127-erli-integration-foundation + provides: global Erli credentials, settings UI, API client base + - phase: 112-reimport-data-protection + provides: delta-only OrderImportRepository contract + - phase: 119-reimport-total-paid-protection + provides: total_paid protection on stable payment status +provides: + - Erli orders import via /inbox + - manual Erli import action + - erli_orders_import cron handler and schedule + - Erli order mapper to orderPRO aggregate + - safe inbox ACK after zero-failure batch +affects: [erli-status-sync, erli-shipments, erli-tracking, automations, statistics] +tech-stack: + added: [] + patterns: [inbox-driven-marketplace-import, safe-ack-after-batch, source-specific-order-mapper] +key-files: + created: + - database/migrations/20260515_000115_add_erli_orders_import_schedule.sql + - src/Modules/Cron/ErliOrdersImportHandler.php + - src/Modules/Settings/ErliOrderMapper.php + - src/Modules/Settings/ErliOrderSyncStateRepository.php + - src/Modules/Settings/ErliOrdersSyncService.php + - tests/Unit/ErliOrderMapperTest.php + modified: + - src/Modules/Settings/ErliApiClient.php + - src/Modules/Settings/ErliIntegrationRepository.php + - src/Modules/Settings/ErliIntegrationController.php + - src/Modules/Cron/CronHandlerFactory.php + - resources/views/settings/erli.php + - routes/web.php + - DOCS/DB_SCHEMA.md + - DOCS/ARCHITECTURE.md + - DOCS/TECH_CHANGELOG.md +key-decisions: + - "Erli order import uses /inbox as primary event source." + - "POST /inbox/mark-read ACK runs only after a zero-failure batch." + - "Phase 128 uses fixed status defaults; configurable mappings are deferred to Phase 129." +patterns-established: + - "ErliOrdersSyncService is shared by cron and manual import." + - "ErliOrderMapper returns null for unsupported inbox messages." +duration: ~14min +started: 2026-05-15T23:32:00+02:00 +completed: 2026-05-15T23:46:00+02:00 +--- + +# Phase 128 Plan 01: Erli Orders Import Summary + +Erli now imports order events from `/inbox` into the shared orderPRO order aggregate, with cron/manual entry points, state tracking, automation hooks, and safe Erli inbox acknowledgement after a clean batch. + +## Performance + +| Metric | Value | +|--------|-------| +| Duration | ~14min | +| Started | 2026-05-15T23:32:00+02:00 | +| Completed | 2026-05-15T23:46:00+02:00 | +| Tasks | 6 acceptance areas completed | +| Files modified | 24 | + +## Acceptance Criteria Results + +| Criterion | Status | Notes | +|-----------|--------|-------| +| AC-1: Import configuration and schedule | Pass | Added `orders_fetch_enabled`, `orders_fetch_start_date`, interval UI, idempotent cron seed `erli_orders_import`, and manual import route. | +| AC-2: Fetch Erli inbox and ACK safely | Pass | `ErliApiClient::fetchInbox()` reads `/inbox`; `markInboxRead()` posts to `/inbox/mark-read` only after zero failures. Endpoint contract confirmed against official Erli swagger. | +| AC-3: Cursor/state and failure handling | Pass | `ErliOrderSyncStateRepository` records cursor, last run/success/error; failed batches mark failure and skip ACK. | +| AC-4: Map Erli order payload to orderPRO aggregate | Pass | Mapper covers source identifiers, status/payment defaults, customer/delivery/invoice addresses, items, payments, notes, status history, and invoice detection. | +| AC-5: Reuse shared order import and automations | Pass | Sync uses `OrderImportRepository`, preserves delta-only behavior, emits `order.imported` on create and `payment.status_changed` on payment transitions. | +| AC-6: Tests and documentation | Pass with environment gaps | Mapper unit test file added; docs updated. PHPUnit and Sonar CLI were unavailable in this checkout. | + +## Accomplishments + +- Added Erli order import service using the same orderPRO aggregate path as Allegro/shopPRO. +- Added cron composition and a manual "import now" action in Erli settings. +- Added import-state persistence so batches are observable and ACK is not sent on partial failure. +- Added Erli mapper coverage for common order, payment, invoice and cancellation cases. +- Updated technical docs for DB schema, architecture and changelog. + +## Verification Results + +| Check | Result | +|-------|--------| +| `php -l` on all changed PHP/view/lang/test files | Pass | +| Runtime mapper smoke via inline PHP | Pass: `MAPPER_SMOKE_OK` | +| `vendor/bin/phpunit tests/Unit/ErliOrderMapperTest.php` | Not run: `vendor/bin/phpunit` missing in checkout | +| `git diff --check` | Pass, with existing CRLF warnings only | +| `sonar-scanner` | Not run: CLI unavailable in PATH | +| Live migration + manual Erli import | Pending operator smoke on local/production DB | + +## Files Created/Modified + +| File | Change | Purpose | +|------|--------|---------| +| `database/migrations/20260515_000115_add_erli_orders_import_schedule.sql` | Created | Add sync-state fields and seed `erli_orders_import` cron schedule. | +| `src/Modules/Cron/ErliOrdersImportHandler.php` | Created | Cron handler for Erli import batches. | +| `src/Modules/Settings/ErliOrderMapper.php` | Created | Convert Erli inbox order messages to orderPRO aggregate. | +| `src/Modules/Settings/ErliOrderSyncStateRepository.php` | Created | Persist import cursor, success and error state. | +| `src/Modules/Settings/ErliOrdersSyncService.php` | Created | Coordinate fetch, map, upsert, automation and ACK. | +| `tests/Unit/ErliOrderMapperTest.php` | Created | Unit tests for mapper status/payment/invoice cases. | +| `src/Modules/Settings/ErliApiClient.php` | Modified | Add `/inbox` fetch and `/inbox/mark-read` ACK. | +| `src/Modules/Settings/ErliIntegrationRepository.php` | Modified | Store import settings and expose active integration credentials. | +| `src/Modules/Settings/ErliIntegrationController.php` | Modified | Save import settings and run manual import. | +| `src/Modules/Cron/CronHandlerFactory.php` | Modified | Register `erli_orders_import`. | +| `resources/views/settings/erli.php` | Modified | Add import controls and manual import button. | +| `routes/web.php` | Modified | Wire service construction and manual import route. | +| `DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md`, `DOCS/TECH_CHANGELOG.md` | Modified | Document schema, flow and technical change. | + +## Decisions Made + +| Decision | Rationale | Impact | +|----------|-----------|--------| +| Use Erli `/inbox` as the primary import source | Inbox is event-driven and aligns with Erli's message processing model. | Phase 129+ can use the same event source for status-related updates. | +| ACK only after zero-failure batch | Prevents losing Erli messages when a partial batch fails locally. | Failed messages remain unread for retry. | +| Keep status mapping defaults fixed in Phase 128 | User chose recommendation; full configurable mapping belongs to Phase 129. | Import works now, status tuning remains explicit next scope. | + +## Deviations from Plan + +### Summary + +| Type | Count | Impact | +|------|-------|--------| +| Auto-fixed/clarified | 1 | ACK endpoint confirmed and implemented during APPLY. | +| Scope additions | 0 | No extra product scope added. | +| Deferred | 3 | Environment/live verification only. | + +### Auto-fixed Issues + +**1. Erli ACK endpoint contract** +- **Found during:** API client implementation. +- **Issue:** Plan intentionally left ACK endpoint verification open. +- **Fix:** Confirmed official Erli swagger uses `POST /inbox/mark-read` with `lastMessageId` or `ids`; implemented `lastMessageId`. +- **Verification:** API client method and sync path reference checked; live ACK pending real credentials. + +### Deferred Items + +- Run `php bin/migrate.php` and enable Erli import in `/settings/integrations/erli`. +- Click `Importuj zamowienia teraz` and confirm `orders.source='erli'` plus no unread messages after clean ACK. +- Install/restore PHPUnit tooling and run `tests/Unit/ErliOrderMapperTest.php`; run Sonar when CLI is available. + +## Issues Encountered + +| Issue | Resolution | +|-------|------------| +| `vendor/bin/phpunit` missing | Documented as verification gap; mapper smoke run with PHP runtime. | +| `sonar-scanner` missing in PATH | Documented as required-skill gap in STATE. | +| Live Erli import not executable without operator DB/API setup | Added explicit follow-up in STATE. | + +## Skill Audit + +| Expected | Invoked | Notes | +|----------|---------|-------| +| `sonar-scanner` | Gap | CLI unavailable in PATH; gap documented in STATE. | + +## Next Phase Readiness + +**Ready:** +- Phase 129 can add configurable Erli pull/push status mapping on top of imported Erli order status fields. +- Cron/manual import flow and state cursor are in place. +- Payment transition and first-import automation hooks are aligned with existing orderPRO contracts. + +**Concerns:** +- Real inbox payload variance may require mapper additions after live smoke. +- PHPUnit and Sonar need environment repair for full verification. + +**Blockers:** +- None for planning Phase 129. + +--- +*Phase: 128-erli-orders-import, Plan: 01* +*Completed: 2026-05-15* diff --git a/DOCS/ARCHITECTURE.md b/DOCS/ARCHITECTURE.md index f927e98..887d9d3 100644 --- a/DOCS/ARCHITECTURE.md +++ b/DOCS/ARCHITECTURE.md @@ -111,6 +111,7 @@ HTTP Request | `AllegroStatusSyncHandler` | Push status changes to Allegro | | `AllegroTokenRefreshHandler` | OAuth token refresh (24h expiry) | | `ShopproOrdersImportHandler` | Fetch new shopPRO orders | +| `ErliOrdersImportHandler` | Fetch unread Erli inbox order events | | `ShopproStatusSyncHandler` | Push status to shopPRO | | `ShopproPaymentStatusSyncHandler` | Sync payment statuses | | `ShipmentTrackingHandler` | Poll carrier tracking APIs | @@ -122,7 +123,8 @@ HTTP Request 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. 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. **Deferred** - Phase 127 does not import orders, sync statuses, create labels, or track shipments. Those flows are planned for v3.8 Phases 128-131. +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. ## Dependency Injection @@ -181,13 +183,25 @@ 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. - 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`. -- `save` zapisuje label, aktywnosc i sekret; `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`. +- `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()`. +- `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. +- 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. ### 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 c25d179..df9a29e 100644 --- a/DOCS/DB_SCHEMA.md +++ b/DOCS/DB_SCHEMA.md @@ -366,6 +366,17 @@ UNIQUE: `(order_id, source_payment_id)` UNIQUE: `(integration_id, shoppro_status_code)` **integration_order_sync_state** — Track order fetch progress per integration +| Column | Type | Nullable | Notes | +|--------|------|----------|-------| +| `integration_id` | INT UNSIGNED | NO | PK, FK → integrations(id) CASCADE | +| `last_synced_order_updated_at` | DATETIME | YES | Used by Allegro/Erli cursors | +| `last_synced_source_order_id` | VARCHAR(64) | YES | Erli stores last acknowledged inbox message id here | +| `last_synced_external_order_id` | VARCHAR(128) | YES | Legacy/source-specific cursor | +| `last_run_at` | DATETIME | YES | | +| `last_success_at` | DATETIME | YES | | +| `last_error` | VARCHAR(500) | YES | | +| `created_at` | DATETIME | NO | | +| `updated_at` | DATETIME | NO | | **integration_order_status_sync_state** — Track status sync progress per integration and direction @@ -941,6 +952,8 @@ 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). + --- ## Settings & Configuration diff --git a/DOCS/TECH_CHANGELOG.md b/DOCS/TECH_CHANGELOG.md index 0a19899..fe3921c 100644 --- a/DOCS/TECH_CHANGELOG.md +++ b/DOCS/TECH_CHANGELOG.md @@ -1,5 +1,23 @@ # Technical Changelog +## 2026-05-15 - Phase 128 Plan 01: Erli Orders Import + +**Co zrobiono:** +- Dodano migracje `20260515_000115_add_erli_orders_import_schedule.sql`, ktora zapewnia kolumny kursora w `integration_order_sync_state` i seeduje `cron_schedules.job_type='erli_orders_import'` jako disabled. +- Rozszerzono ustawienia Erli o wlaczenie importu zamowien, date startu, interwal crona oraz reczna akcje `POST /settings/integrations/erli/import`. +- Rozszerzono `ErliApiClient` o `fetchInbox()` (`GET /inbox`) oraz bezpieczny ACK `markInboxRead()` (`POST /inbox/mark-read`, body `lastMessageId`) potwierdzony z oficjalnego swaggera Erli. +- Dodano `ErliOrderMapper`, `ErliOrderSyncStateRepository`, `ErliOrdersSyncService` i `ErliOrdersImportHandler`. +- Import wspiera zdarzenia `orderCreated`, `orderStatusChanged`, `orderSellerStatusChanged`, zapisuje order aggregate przez `OrderImportRepository`, ustawia `invoice_requested` przy danych firmowych/NIP, zapisuje activity log i emituje `order.imported` oraz `payment.status_changed`. +- Dodano testy jednostkowe `tests/Unit/ErliOrderMapperTest.php`. + +**Dlaczego:** +- Erli ma zaczac realnie dostarczac zamowienia do orderPRO po fundamencie konfiguracji z Phase 127. +- `/inbox` jest rekomendowanym zrodlem zdarzen Erli; ACK jest wykonywany dopiero po bezblednym batchu, zeby nie zgubic zamowien przy czesciowej awarii. + +**BREAKING / migracja:** +- Brak breaking changes. Nowy cron jest domyslnie wylaczony do czasu wlaczenia importu w ustawieniach Erli. +- Manual smoke po wdrozeniu: `php bin/migrate.php`, zapis aktywnej konfiguracji Erli, wlaczenie importu, klik `Importuj zamowienia teraz`, kontrola `orders.source='erli'` i licznikow importu. + ## 2026-05-15 - Phase 127 Plan 01: Erli Integration Foundation **Co zrobiono:** diff --git a/database/migrations/20260515_000115_add_erli_orders_import_schedule.sql b/database/migrations/20260515_000115_add_erli_orders_import_schedule.sql new file mode 100644 index 0000000..066cec8 --- /dev/null +++ b/database/migrations/20260515_000115_add_erli_orders_import_schedule.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS integration_order_sync_state ( + integration_id INT UNSIGNED NOT NULL PRIMARY KEY, + last_synced_order_updated_at DATETIME NULL, + last_synced_source_order_id VARCHAR(64) NULL, + last_synced_external_order_id VARCHAR(128) NULL, + last_run_at DATETIME NULL, + last_success_at DATETIME NULL, + last_error VARCHAR(500) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT erli_integration_order_sync_state_integration_fk + FOREIGN KEY (integration_id) REFERENCES integrations(id) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE integration_order_sync_state + ADD COLUMN IF NOT EXISTS last_synced_order_updated_at DATETIME NULL AFTER integration_id, + ADD COLUMN IF NOT EXISTS last_synced_source_order_id VARCHAR(64) NULL AFTER last_synced_order_updated_at, + ADD COLUMN IF NOT EXISTS last_success_at DATETIME NULL AFTER last_run_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_orders_import', 300, 40, 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 f4d9f70..5a158b9 100644 --- a/resources/lang/pl.php +++ b/resources/lang/pl.php @@ -870,11 +870,18 @@ return [ 'title' => 'Test polaczenia', 'description' => 'Test wykonuje realne, bezpieczne zapytanie GET do API Erli.', ], + 'import' => [ + 'title' => 'Import zamowien', + 'description' => 'Pobiera nieprzeczytane wiadomosci Erli inbox i importuje obslugiwane zdarzenia zamowien do orderPRO.', + ], 'fields' => [ 'account_label' => 'Nazwa konta', 'api_key' => 'Klucz API', 'options' => 'Opcje', 'is_active' => 'Integracja aktywna', + 'orders_fetch_enabled' => 'Wlacz automatyczny import zamowien', + 'orders_fetch_start_date' => 'Data startu importu', + 'orders_import_interval_minutes' => 'Interwal importu (minuty)', ], 'api_key' => [ 'saved' => 'Klucz API jest zapisany. Pozostaw pole puste, aby nie zmieniac.', @@ -882,6 +889,8 @@ return [ ], 'hints' => [ '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' => [ 'secret' => 'Sekret API', @@ -893,12 +902,18 @@ return [ 'actions' => [ 'save' => 'Zapisz ustawienia Erli', 'test' => 'Test polaczenia', + 'import_now' => 'Importuj zamowienia teraz', ], 'flash' => [ 'saved' => 'Ustawienia Erli zostaly zapisane.', 'save_failed' => 'Nie udalo sie zapisac ustawien Erli.', 'test_success' => 'Polaczenie z API Erli dziala.', 'test_failed' => 'Nie udalo sie polaczyc z API Erli.', + 'import_success' => 'Import Erli zakonczony. Przetworzone: :processed, nowe: :created, aktualizacje: :updated, bledy: :failed, pominiete: :skipped, inbox ACK: :ack.', + 'import_failed' => 'Nie udalo sie zaimportowac zamowien Erli.', + ], + 'validation' => [ + 'orders_fetch_start_date_invalid' => 'Data startu importu musi miec format RRRR-MM-DD.', ], ], 'inpost' => [ diff --git a/resources/views/settings/erli.php b/resources/views/settings/erli.php index ec848f1..feda821 100644 --- a/resources/views/settings/erli.php +++ b/resources/views/settings/erli.php @@ -7,6 +7,9 @@ $lastTestAt = trim((string) ($settings['last_test_at'] ?? '')); $lastTestStatus = trim((string) ($settings['last_test_status'] ?? '')); $lastTestMessage = trim((string) ($settings['last_test_message'] ?? '')); $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); ?>
@@ -24,6 +27,10 @@ $lastTestHttpCode = $settings['last_test_http_code'] ?? null;
+ + +
+
@@ -59,15 +66,43 @@ $lastTestHttpCode = $settings['last_test_http_code'] ?? null; > + + + + +
+
+

+

+ +
+ +
+ +
+
+
+

diff --git a/routes/web.php b/routes/web.php index 8f58f5a..f286413 100644 --- a/routes/web.php +++ b/routes/web.php @@ -32,6 +32,9 @@ use App\Modules\Settings\CarrierDeliveryMethodMappingRepository; use App\Modules\Settings\ErliApiClient; use App\Modules\Settings\ErliIntegrationController; use App\Modules\Settings\ErliIntegrationRepository; +use App\Modules\Settings\ErliOrderMapper; +use App\Modules\Settings\ErliOrderSyncStateRepository; +use App\Modules\Settings\ErliOrdersSyncService; use App\Modules\Settings\FakturowniaApiClient; use App\Modules\Settings\FakturowniaIntegrationController; use App\Modules\Settings\FakturowniaIntegrationRepository; @@ -236,14 +239,6 @@ return static function (Application $app): void { $app->db(), (string) $app->config('app.integrations.secret', '') ); - $erliIntegrationController = new ErliIntegrationController( - $template, - $translator, - $auth, - $erliIntegrationRepository, - new ErliApiClient(), - new IntegrationsRepository($app->db()) - ); $notificationRepository = new NotificationRepository($app->db()); $smsMessageRepository = new SmsMessageRepository($app->db()); $smsConversationService = new SmsConversationService( @@ -388,6 +383,25 @@ return static function (Application $app): void { $shipmentPackageRepositoryForOrders, $receiptService ); + $erliOrdersSyncService = new ErliOrdersSyncService( + $erliIntegrationRepository, + new ErliOrderSyncStateRepository($app->db()), + new ErliApiClient(), + new OrderImportRepository($app->db()), + new OrdersRepository($app->db()), + new ErliOrderMapper(), + $automationService + ); + $erliIntegrationController = new ErliIntegrationController( + $template, + $translator, + $auth, + $erliIntegrationRepository, + new ErliApiClient(), + new IntegrationsRepository($app->db()), + $cronRepository, + $erliOrdersSyncService + ); $allegroIntegrationController = new AllegroIntegrationController( $template, $translator, @@ -635,6 +649,7 @@ return static function (Application $app): void { $router->get('/settings/integrations/erli', [$erliIntegrationController, 'index'], [$authMiddleware]); $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->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/Core/Constants/IntegrationSources.php b/src/Core/Constants/IntegrationSources.php index f593dcd..8af1cfe 100644 --- a/src/Core/Constants/IntegrationSources.php +++ b/src/Core/Constants/IntegrationSources.php @@ -8,6 +8,7 @@ final class IntegrationSources { public const ALLEGRO = 'allegro'; public const SHOPPRO = 'shoppro'; + public const ERLI = 'erli'; public const APACZKA = 'apaczka'; public const INPOST = 'inpost'; } diff --git a/src/Modules/Cron/CronHandlerFactory.php b/src/Modules/Cron/CronHandlerFactory.php index 7bd7a09..5291abf 100644 --- a/src/Modules/Cron/CronHandlerFactory.php +++ b/src/Modules/Cron/CronHandlerFactory.php @@ -33,6 +33,11 @@ use App\Modules\Settings\ApaczkaIntegrationRepository; use App\Modules\Settings\CompanySettingsRepository; use App\Modules\Settings\EmailMailboxRepository; use App\Modules\Settings\EmailTemplateRepository; +use App\Modules\Settings\ErliApiClient; +use App\Modules\Settings\ErliIntegrationRepository; +use App\Modules\Settings\ErliOrderMapper; +use App\Modules\Settings\ErliOrderSyncStateRepository; +use App\Modules\Settings\ErliOrdersSyncService; use App\Modules\Settings\InpostIntegrationRepository; use App\Modules\Settings\IntegrationSecretCipher; use App\Modules\Settings\ReceiptConfigRepository; @@ -128,6 +133,15 @@ final class CronHandlerFactory $this->db, $automationService ); + $erliOrdersSyncService = new ErliOrdersSyncService( + new ErliIntegrationRepository($this->db, $this->integrationSecret), + new ErliOrderSyncStateRepository($this->db), + new ErliApiClient(), + new OrderImportRepository($this->db), + $ordersRepository, + new ErliOrderMapper(), + $automationService + ); return new CronRunner( $cronRepository, @@ -161,6 +175,9 @@ final class CronHandlerFactory 'shoppro_payment_status_sync' => new ShopproPaymentStatusSyncHandler( $shopproPaymentSyncService ), + 'erli_orders_import' => new ErliOrdersImportHandler( + $erliOrdersSyncService + ), 'shipment_tracking_sync' => new ShipmentTrackingHandler( new ShipmentTrackingRegistry([ new InpostTrackingService( diff --git a/src/Modules/Cron/ErliOrdersImportHandler.php b/src/Modules/Cron/ErliOrdersImportHandler.php new file mode 100644 index 0000000..7f99b23 --- /dev/null +++ b/src/Modules/Cron/ErliOrdersImportHandler.php @@ -0,0 +1,24 @@ + $payload + * @return array + */ + public function handle(array $payload): array + { + return $this->syncService->sync([ + 'max_messages' => (int) ($payload['max_messages'] ?? 200), + ]); + } +} diff --git a/src/Modules/Settings/ErliApiClient.php b/src/Modules/Settings/ErliApiClient.php index dc99927..3e4a08d 100644 --- a/src/Modules/Settings/ErliApiClient.php +++ b/src/Modules/Settings/ErliApiClient.php @@ -28,7 +28,7 @@ final class ErliApiClient ]; } - [$body, $httpCode, $curlError] = $this->httpGet($baseUrl . '/inbox', $apiKey); + [$body, $httpCode, $curlError] = $this->httpRequest('GET', $baseUrl . '/inbox', $apiKey); if ($curlError !== null) { return [ 'ok' => false, @@ -52,10 +52,85 @@ final class ErliApiClient ]; } + /** + * @param array{base_url: string, api_key: string, timeout_seconds?: int} $credentials + * @return array{ok: bool, http_code: int, items: array>, message: string} + */ + public function fetchInbox(array $credentials): array + { + $baseUrl = rtrim(trim((string) ($credentials['base_url'] ?? '')), '/'); + $apiKey = trim((string) ($credentials['api_key'] ?? '')); + if ($baseUrl === '' || $apiKey === '') { + return ['ok' => false, 'http_code' => 0, 'items' => [], 'message' => 'Brak danych API Erli.']; + } + + [$body, $httpCode, $curlError] = $this->httpRequest('GET', $baseUrl . '/inbox', $apiKey); + if ($curlError !== null) { + return ['ok' => false, 'http_code' => $httpCode, 'items' => [], 'message' => 'Blad polaczenia: ' . $curlError]; + } + + if ($httpCode < 200 || $httpCode >= 300) { + return ['ok' => false, 'http_code' => $httpCode, 'items' => [], 'message' => $this->resolveFailureMessage($body, $httpCode)]; + } + + $decoded = json_decode($body, true); + if (!is_array($decoded)) { + return ['ok' => false, 'http_code' => $httpCode, 'items' => [], 'message' => 'Erli zwrocilo niepoprawny JSON inbox.']; + } + + $items = []; + foreach ($decoded as $row) { + if (is_array($row)) { + $items[] = $row; + } + } + + return ['ok' => true, 'http_code' => $httpCode, 'items' => $items, 'message' => 'OK']; + } + + /** + * @param array{base_url: string, api_key: string, timeout_seconds?: int} $credentials + * @return array{ok: bool, http_code: int, acknowledged_count: int, message: string} + */ + public function markInboxRead(array $credentials, string $lastMessageId): array + { + $baseUrl = rtrim(trim((string) ($credentials['base_url'] ?? '')), '/'); + $apiKey = trim((string) ($credentials['api_key'] ?? '')); + $messageId = trim($lastMessageId); + if ($baseUrl === '' || $apiKey === '' || $messageId === '') { + return ['ok' => false, 'http_code' => 0, 'acknowledged_count' => 0, 'message' => 'Brak danych do potwierdzenia inbox Erli.']; + } + + [$body, $httpCode, $curlError] = $this->httpRequest( + 'POST', + $baseUrl . '/inbox/mark-read', + $apiKey, + ['lastMessageId' => $messageId] + ); + if ($curlError !== null) { + return ['ok' => false, 'http_code' => $httpCode, 'acknowledged_count' => 0, 'message' => 'Blad polaczenia: ' . $curlError]; + } + if ($httpCode < 200 || $httpCode >= 300) { + return ['ok' => false, 'http_code' => $httpCode, 'acknowledged_count' => 0, 'message' => $this->resolveFailureMessage($body, $httpCode)]; + } + + $decoded = json_decode($body, true); + return [ + 'ok' => true, + 'http_code' => $httpCode, + 'acknowledged_count' => is_int($decoded) ? $decoded : (int) $decoded, + 'message' => 'OK', + ]; + } + /** * @return array{0: string, 1: int, 2: ?string} */ - private function httpGet(string $url, string $apiKey): array + /** + * @param array|null $payload + * @return array{0: string, 1: int, 2: ?string} + */ + private function httpRequest(string $method, string $url, string $apiKey, ?array $payload = null): array { $ch = curl_init($url); if ($ch === false) { @@ -64,7 +139,6 @@ final class ErliApiClient $opts = [ CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPGET => true, CURLOPT_TIMEOUT => $this->timeoutSeconds, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, @@ -72,10 +146,18 @@ final class ErliApiClient CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Accept: application/json', + 'Content-Type: application/json', 'User-Agent: orderPRO/1.0 (erli-integration)', ], ]; + if (strtoupper($method) === 'POST') { + $opts[CURLOPT_POST] = true; + $opts[CURLOPT_POSTFIELDS] = json_encode($payload ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } else { + $opts[CURLOPT_HTTPGET] = true; + } + $caPath = SslCertificateResolver::resolve(); if ($caPath !== null) { $opts[CURLOPT_CAINFO] = $caPath; diff --git a/src/Modules/Settings/ErliIntegrationController.php b/src/Modules/Settings/ErliIntegrationController.php index d8bf605..9381357 100644 --- a/src/Modules/Settings/ErliIntegrationController.php +++ b/src/Modules/Settings/ErliIntegrationController.php @@ -12,17 +12,25 @@ use App\Core\Security\Csrf; use App\Core\Support\Flash; use App\Core\View\Template; use App\Modules\Auth\AuthService; +use App\Modules\Cron\CronRepository; use Throwable; final class ErliIntegrationController { + private const ORDERS_IMPORT_JOB_TYPE = 'erli_orders_import'; + private const ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS = 300; + private const ORDERS_IMPORT_DEFAULT_PRIORITY = 40; + private const ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS = 3; + public function __construct( private readonly Template $template, private readonly Translator $translator, private readonly AuthService $auth, private readonly ErliIntegrationRepository $repository, private readonly ErliApiClient $apiClient, - private readonly IntegrationsRepository $integrations + private readonly IntegrationsRepository $integrations, + private readonly CronRepository $cronRepository, + private readonly ErliOrdersSyncService $ordersSyncService ) { } @@ -35,9 +43,11 @@ final class ErliIntegrationController 'user' => $this->auth->user(), 'csrfToken' => Csrf::token(), 'settings' => $this->repository->getSettings(), + 'ordersImportIntervalMinutes' => $this->currentImportIntervalMinutes(), 'errorMessage' => (string) Flash::get('settings_error', ''), 'successMessage' => (string) Flash::get('settings_success', ''), 'testMessage' => (string) Flash::get('erli_test', ''), + 'importMessage' => (string) Flash::get('erli_import', ''), ], 'layouts/app'); return Response::html($html); @@ -56,7 +66,10 @@ final class ErliIntegrationController 'account_label' => (string) $request->input('account_label', ''), 'api_key' => (string) $request->input('api_key', ''), 'is_active' => $request->input('is_active', ''), + 'orders_fetch_enabled' => $request->input('orders_fetch_enabled', ''), + 'orders_fetch_start_date' => $this->validateStartDate((string) $request->input('orders_fetch_start_date', '')), ]); + $this->upsertImportSchedule($request); Flash::set('settings_success', $this->translator->get('settings.erli.flash.saved')); } catch (Throwable $exception) { Flash::set( @@ -68,6 +81,27 @@ final class ErliIntegrationController return Response::redirect($redirectTo); } + public function importNow(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); + } + + try { + $result = $this->ordersSyncService->sync([ + 'ignore_orders_fetch_enabled' => true, + 'max_messages' => 100, + ]); + Flash::set('erli_import', $this->formatImportResult($result)); + } catch (Throwable $exception) { + Flash::set('settings_error', $this->translator->get('settings.erli.flash.import_failed') . ' ' . $exception->getMessage()); + } + + return Response::redirect($redirectTo); + } + public function test(Request $request): Response { $redirectTo = $this->resolveRedirect($request); @@ -110,4 +144,60 @@ final class ErliIntegrationController '/settings/integrations/erli' ); } + + private function validateStartDate(string $value): string + { + $trimmed = trim($value); + if ($trimmed === '') { + return ''; + } + + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $trimmed) !== 1) { + throw new IntegrationConfigException($this->translator->get('settings.erli.validation.orders_fetch_start_date_invalid')); + } + + return $trimmed; + } + + private function upsertImportSchedule(Request $request): void + { + $minutes = max(1, min(1440, (int) $request->input('orders_import_interval_minutes', 5))); + $enabled = (string) $request->input('orders_fetch_enabled', '') === '1'; + $this->cronRepository->upsertSchedule( + self::ORDERS_IMPORT_JOB_TYPE, + $minutes * 60, + self::ORDERS_IMPORT_DEFAULT_PRIORITY, + self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS, + null, + $enabled + ); + } + + private function currentImportIntervalMinutes(): int + { + foreach ($this->cronRepository->listSchedules() as $schedule) { + if ((string) ($schedule['job_type'] ?? '') !== self::ORDERS_IMPORT_JOB_TYPE) { + continue; + } + $seconds = (int) ($schedule['interval_seconds'] ?? self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS); + return max(1, min(1440, (int) floor(max(60, $seconds) / 60))); + } + + return 5; + } + + /** + * @param array $result + */ + private function formatImportResult(array $result): string + { + return strtr($this->translator->get('settings.erli.flash.import_success'), [ + ':processed' => (string) (int) ($result['processed'] ?? 0), + ':created' => (string) (int) ($result['imported_created'] ?? 0), + ':updated' => (string) (int) ($result['imported_updated'] ?? 0), + ':failed' => (string) (int) ($result['failed'] ?? 0), + ':skipped' => (string) (int) ($result['skipped'] ?? 0), + ':ack' => !empty($result['acknowledged']) ? 'tak' : 'nie', + ]); + } } diff --git a/src/Modules/Settings/ErliIntegrationRepository.php b/src/Modules/Settings/ErliIntegrationRepository.php index 6e5f096..edbe9b5 100644 --- a/src/Modules/Settings/ErliIntegrationRepository.php +++ b/src/Modules/Settings/ErliIntegrationRepository.php @@ -41,6 +41,8 @@ final class ErliIntegrationRepository 'account_label' => trim((string) ($row['account_label'] ?? '')), 'has_api_key' => $encryptedApiKey !== null && $encryptedApiKey !== '', 'is_active' => (int) ($integration['is_active'] ?? 1) === 1, + 'orders_fetch_enabled' => (int) ($integration['orders_fetch_enabled'] ?? 0) === 1, + 'orders_fetch_start_date' => $this->normalizeDateOrNull((string) ($integration['orders_fetch_start_date'] ?? '')), 'last_test_status' => trim((string) ($integration['last_test_status'] ?? '')), 'last_test_http_code' => isset($integration['last_test_http_code']) ? (int) $integration['last_test_http_code'] : null, 'last_test_message' => trim((string) ($integration['last_test_message'] ?? '')), @@ -84,11 +86,16 @@ final class ErliIntegrationRepository ]); $this->updateIntegrationActive($integrationId, !empty($payload['is_active'])); + $this->updateImportSettings( + $integrationId, + !empty($payload['orders_fetch_enabled']), + (string) ($payload['orders_fetch_start_date'] ?? '') + ); $this->integrations->updateApiKeyEncrypted($integrationId, $nextEncrypted); } /** - * @return array{integration_id: int, base_url: string, api_key: string}|null + * @return array{integration_id: int, base_url: string, api_key: string, timeout_seconds: int, orders_fetch_enabled: bool, orders_fetch_start_date: ?string}|null */ public function getCredentials(): ?array { @@ -114,9 +121,17 @@ final class ErliIntegrationRepository 'integration_id' => $integrationId, 'base_url' => trim((string) ($integration['base_url'] ?? self::INTEGRATION_BASE_URL)), 'api_key' => $apiKey, + 'timeout_seconds' => max(1, min(120, (int) ($integration['timeout_seconds'] ?? 15))), + 'orders_fetch_enabled' => (int) ($integration['orders_fetch_enabled'] ?? 0) === 1, + 'orders_fetch_start_date' => $this->normalizeDateOrNull((string) ($integration['orders_fetch_start_date'] ?? '')), ]; } + public function getActiveIntegrationId(): int + { + return $this->ensureBaseIntegration(); + } + private function ensureBaseIntegration(): int { return $this->integrations->ensureIntegration( @@ -182,6 +197,33 @@ final class ErliIntegrationRepository return $label; } + private function normalizeDateOrNull(string $value): ?string + { + $trimmed = trim($value); + if ($trimmed === '') { + return null; + } + + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $trimmed) === 1 ? $trimmed : null; + } + + private function updateImportSettings(int $integrationId, bool $enabled, string $startDate): void + { + $statement = $this->pdo->prepare( + 'UPDATE integrations + SET orders_fetch_enabled = :orders_fetch_enabled, + orders_fetch_start_date = :orders_fetch_start_date, + updated_at = NOW() + WHERE id = :id AND type = :type' + ); + $statement->execute([ + 'id' => $integrationId, + 'type' => self::INTEGRATION_TYPE, + 'orders_fetch_enabled' => $enabled ? 1 : 0, + 'orders_fetch_start_date' => $this->normalizeDateOrNull($startDate), + ]); + } + /** * @param array|null $row * @param array|null $integration diff --git a/src/Modules/Settings/ErliOrderMapper.php b/src/Modules/Settings/ErliOrderMapper.php new file mode 100644 index 0000000..18c0063 --- /dev/null +++ b/src/Modules/Settings/ErliOrderMapper.php @@ -0,0 +1,467 @@ + $message + * @return array{ + * message_id:string, + * message_created_at:?string, + * order:array, + * addresses:array>, + * items:array>, + * payments:array>, + * shipments:array>, + * notes:array>, + * status_history:array>, + * invoice_detected:bool + * }|null + */ + public function mapInboxMessage(int $integrationId, array $message): ?array + { + $type = trim((string) ($message['type'] ?? '')); + if ($type === '' || !in_array($type, self::SUPPORTED_TYPES, true)) { + return null; + } + + $payload = is_array($message['payload'] ?? null) ? $message['payload'] : []; + if ($payload === []) { + throw new RuntimeException('Wiadomosc Erli nie zawiera payloadu zamowienia.'); + } + + $sourceOrderId = $this->normalizeOrderId($this->readPath($payload, ['id', 'orderId', 'externalOrderId'])); + if ($sourceOrderId === '') { + throw new RuntimeException('Payload Erli nie zawiera ID zamowienia.'); + } + + $rawStatus = strtolower(trim((string) $this->readPath($payload, ['status', 'order.status']))); + $statusCode = $this->mapOrderStatus($rawStatus); + $createdAt = $this->normalizeDate((string) $this->readPath($payload, ['created', 'createdAt', 'purchasedAt'])); + $updatedAt = $this->normalizeDate((string) $this->readPath($payload, ['updated', 'updatedAt', 'purchasedAt'])); + $messageCreatedAt = $this->normalizeDate((string) ($message['created'] ?? '')); + if ($updatedAt === null) { + $updatedAt = $messageCreatedAt ?? date('Y-m-d H:i:s'); + } + + $totalWithTax = $this->toFloatOrNull($this->readPath($payload, ['totalPrice', 'total.price', 'summary.totalPrice'])); + $deliveryPrice = $this->toFloatOrNull($this->readPath($payload, [ + 'delivery.price', 'delivery.deliveryPrice', 'deliveryPrice', 'delivery.cost', + ])); + $currency = strtoupper(trim((string) $this->readPath($payload, [ + 'currency', 'totalPrice.currency', 'payment.currency', + ]))); + if ($currency === '') { + $currency = 'PLN'; + } + + $paymentStatus = $this->mapPaymentStatus($payload, $rawStatus); + $totalPaid = $paymentStatus === 2 ? $totalWithTax : $this->toFloatOrNull($this->readPath($payload, [ + 'payment.amount', 'payment.paidAmount', 'paidAmount', + ])); + + $deliveryLabel = $this->deliveryLabel($payload); + $order = [ + 'integration_id' => $integrationId, + 'source' => IntegrationSources::ERLI, + 'source_order_id' => $sourceOrderId, + 'external_order_id' => StringHelper::nullableString((string) $this->readPath($payload, ['externalOrderId'])) ?? $sourceOrderId, + 'external_platform_id' => IntegrationSources::ERLI, + 'external_platform_account_id' => StringHelper::nullableString((string) ($message['shopId'] ?? '')), + 'status_code' => $statusCode, + 'external_payment_type_id' => StringHelper::nullableString((string) $this->readPath($payload, [ + 'payment.type', 'payment.method', 'payment.methodCode', + ])), + 'payment_status' => $paymentStatus, + 'external_carrier_id' => StringHelper::nullableString($deliveryLabel), + 'external_carrier_account_id' => StringHelper::nullableString((string) $this->readPath($payload, [ + 'delivery.methodId', 'delivery.shippingMethodId', 'delivery.id', + ])), + 'customer_login' => StringHelper::nullableString((string) $this->readPath($payload, ['user.email', 'buyer.email'])), + 'is_encrypted' => false, + 'is_canceled_by_buyer' => $statusCode === 'anulowane', + 'currency' => $currency, + 'total_without_tax' => null, + 'total_with_tax' => $totalWithTax, + 'total_paid' => $totalPaid, + 'delivery_price' => $deliveryPrice, + 'send_date_min' => null, + 'send_date_max' => null, + 'ordered_at' => $createdAt, + 'source_created_at' => $createdAt, + 'source_updated_at' => $updatedAt, + 'preferences_json' => [ + 'message_type' => $type, + 'erli_status_raw' => $rawStatus, + 'seller_status' => $this->readPath($payload, ['sellerStatus']), + 'delivery' => $this->readPath($payload, ['delivery']), + ], + 'payload_json' => $payload, + 'fetched_at' => date('Y-m-d H:i:s'), + ]; + + return [ + 'message_id' => trim((string) ($message['id'] ?? '')), + 'message_created_at' => $messageCreatedAt, + 'order' => $order, + 'addresses' => $this->mapAddresses($payload), + 'items' => $this->mapItems($payload), + 'payments' => $this->mapPayments($payload, $sourceOrderId, $paymentStatus, $totalPaid, $currency), + 'shipments' => [], + 'notes' => $this->mapNotes($payload, $updatedAt), + 'status_history' => [[ + 'from_status_id' => null, + 'to_status_id' => $statusCode, + 'changed_at' => $updatedAt, + 'change_source' => 'import', + 'comment' => $rawStatus !== '' ? 'Erli status: ' . $rawStatus : 'Import z Erli inbox', + 'payload_json' => [ + 'message_type' => $type, + 'status' => $rawStatus, + 'seller_status' => $this->readPath($payload, ['sellerStatus']), + ], + ]], + 'invoice_detected' => $this->detectInvoiceRequested($payload), + ]; + } + + /** + * @param array $payload + * @return array> + */ + private function mapAddresses(array $payload): array + { + $delivery = is_array($this->readPath($payload, ['user.deliveryAddress', 'delivery.address', 'deliveryAddress'])) + ? $this->readPath($payload, ['user.deliveryAddress', 'delivery.address', 'deliveryAddress']) + : []; + $invoice = is_array($this->readPath($payload, ['user.invoiceAddress', 'invoiceAddress', 'invoice.address'])) + ? $this->readPath($payload, ['user.invoiceAddress', 'invoiceAddress', 'invoice.address']) + : []; + $email = StringHelper::nullableString((string) $this->readPath($payload, ['user.email', 'buyer.email', 'email'])); + + $customerName = $this->nameFromAddress($delivery, 'Klient Erli'); + $result = [[ + 'address_type' => 'customer', + 'name' => $customerName, + 'phone' => StringHelper::nullableString((string) $this->readPath($delivery, ['phone'])), + 'email' => $email, + 'street_name' => null, + 'street_number' => null, + 'city' => null, + 'zip_code' => null, + 'country' => null, + 'department' => null, + 'parcel_external_id' => null, + 'parcel_name' => null, + 'address_class' => null, + 'company_tax_number' => null, + 'company_name' => StringHelper::nullableString((string) $this->readPath($delivery, ['companyName'])), + 'payload_json' => ['email' => $email, 'delivery_address' => $delivery], + ]]; + + if ($delivery !== []) { + $result[] = $this->addressRow('delivery', $delivery, $email); + } + if ($invoice !== []) { + $result[] = $this->addressRow('invoice', $invoice, $email); + } + + return $result; + } + + /** + * @param array $address + * @return array + */ + private function addressRow(string $type, array $address, ?string $email): array + { + return [ + 'address_type' => $type, + 'name' => $this->nameFromAddress($address, $type === 'invoice' ? 'Faktura Erli' : 'Dostawa Erli'), + 'phone' => StringHelper::nullableString((string) $this->readPath($address, ['phone'])), + 'email' => StringHelper::nullableString((string) $this->readPath($address, ['email'])) ?? $email, + 'street_name' => StringHelper::nullableString((string) $this->readPath($address, ['street', 'address'])), + 'street_number' => StringHelper::nullableString($this->joinParts([ + (string) $this->readPath($address, ['buildingNumber']), + (string) $this->readPath($address, ['flatNumber']), + ], '/')), + 'city' => StringHelper::nullableString((string) $this->readPath($address, ['city'])), + 'zip_code' => StringHelper::nullableString((string) $this->readPath($address, ['zip', 'postalCode'])), + 'country' => strtoupper((string) $this->readPath($address, ['country', 'countryCode'])), + 'department' => null, + 'parcel_external_id' => null, + 'parcel_name' => null, + 'address_class' => StringHelper::nullableString((string) $this->readPath($address, ['type'])), + 'company_tax_number' => StringHelper::nullableString((string) $this->readPath($address, ['nip', 'taxId', 'companyTaxNumber'])), + 'company_name' => StringHelper::nullableString((string) $this->readPath($address, ['companyName', 'company'])), + 'payload_json' => $address, + ]; + } + + /** + * @param array $payload + * @return array> + */ + private function mapItems(array $payload): array + { + $items = is_array($payload['items'] ?? null) ? $payload['items'] : []; + $result = []; + $sort = 0; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $product = is_array($item['product'] ?? null) ? $item['product'] : []; + $name = trim((string) $this->readPath($item, ['name', 'product.name'])); + if ($name === '') { + $name = 'Pozycja Erli'; + } + $result[] = [ + 'source_item_id' => StringHelper::nullableString((string) $this->readPath($item, ['id', 'externalId'])), + 'external_item_id' => StringHelper::nullableString((string) $this->readPath($item, ['productId', 'externalProductId', 'product.id'])), + 'ean' => StringHelper::nullableString((string) $this->readPath($item, ['ean', 'product.ean'])), + 'sku' => StringHelper::nullableString((string) $this->readPath($item, ['sku', 'product.sku'])), + 'original_name' => $name, + 'original_code' => StringHelper::nullableString((string) $this->readPath($item, ['externalProductId', 'product.externalId', 'sku'])), + 'original_price_with_tax' => $this->toFloatOrNull($this->readPath($item, ['price', 'unitPrice', 'priceGross', 'product.price'])), + 'original_price_without_tax' => null, + 'media_url' => StringHelper::nullableString((string) $this->readPath($item, ['imageUrl', 'image.url', 'product.imageUrl'])), + 'quantity' => $this->toFloatOrDefault($this->readPath($item, ['quantity', 'quentity']), 1.0), + 'tax_rate' => $this->toFloatOrNull($this->readPath($item, ['taxRate', 'product.taxRate'])), + 'item_status' => StringHelper::nullableString((string) $this->readPath($item, ['status'])), + 'unit' => 'pcs', + 'item_type' => 'product', + 'source_product_id' => StringHelper::nullableString((string) $this->readPath($product, ['id', 'externalId'])), + 'source_product_set_id' => null, + 'sort_order' => $sort++, + 'payload_json' => $item, + ]; + } + + return $result; + } + + /** + * @param array $payload + * @return array> + */ + private function mapPayments(array $payload, string $sourceOrderId, int $paymentStatus, ?float $amount, string $currency): array + { + if ($amount === null) { + return []; + } + $payment = is_array($payload['payment'] ?? null) ? $payload['payment'] : []; + $paymentId = trim((string) ($payment['id'] ?? $sourceOrderId)); + + return [[ + 'source_payment_id' => $paymentId, + 'external_payment_id' => $paymentId, + 'payment_type_id' => trim((string) ($payment['methodCode'] ?? $payment['method'] ?? 'erli')), + 'payment_date' => $this->normalizeDate((string) ($payment['completedAt'] ?? $payload['purchasedAt'] ?? '')), + 'amount' => $amount, + 'currency' => $currency, + 'comment' => 'Erli payment_status=' . $paymentStatus, + 'payload_json' => $payment !== [] ? $payment : ['order_id' => $sourceOrderId], + ]]; + } + + /** + * @param array $payload + * @return array> + */ + private function mapNotes(array $payload, string $changedAt): array + { + $comment = trim((string) $this->readPath($payload, ['comment', 'buyerComment', 'message'])); + if ($comment === '') { + return []; + } + + return [[ + 'source_note_id' => null, + 'note_type' => 'buyer_message', + 'created_at_external' => $changedAt, + 'comment' => $comment, + 'payload_json' => ['comment' => $comment], + ]]; + } + + private function mapOrderStatus(string $status): string + { + return match ($status) { + 'pending' => 'nieoplacone', + 'purchased' => 'nowe', + 'cancelled', 'canceled', 'returned' => 'anulowane', + default => $status !== '' ? $status : 'nowe', + }; + } + + /** + * @param array $payload + */ + private function mapPaymentStatus(array $payload, string $rawStatus): int + { + $paymentStatus = strtoupper(trim((string) $this->readPath($payload, ['payment.status']))); + if (in_array($paymentStatus, ['COMPLETED', 'PAID'], true)) { + return 2; + } + if (in_array($paymentStatus, ['PENDING', 'NEW', 'WAITING_FOR_CONFIRMATION'], true)) { + return 1; + } + if (in_array($paymentStatus, ['CANCELED', 'CANCELLED', 'FAILED'], true)) { + return 0; + } + + return match ($rawStatus) { + 'purchased' => 2, + 'pending' => 0, + 'cancelled', 'canceled' => 0, + default => 0, + }; + } + + /** + * @param array $payload + */ + private function detectInvoiceRequested(array $payload): bool + { + $invoice = $this->readPath($payload, ['user.invoiceAddress', 'invoiceAddress', 'invoice.address']); + if (!is_array($invoice)) { + return false; + } + $type = strtolower(trim((string) ($invoice['type'] ?? ''))); + if ($type === 'company') { + return true; + } + + foreach (['nip', 'taxId', 'companyTaxNumber', 'companyName', 'company'] as $key) { + if (trim((string) ($invoice[$key] ?? '')) !== '') { + return true; + } + } + + return false; + } + + /** + * @param array $payload + */ + private function deliveryLabel(array $payload): string + { + $candidates = [ + (string) $this->readPath($payload, ['delivery.methodName']), + (string) $this->readPath($payload, ['delivery.name']), + (string) $this->readPath($payload, ['delivery.method']), + (string) $this->readPath($payload, ['delivery.shippingMethod']), + ]; + foreach ($candidates as $candidate) { + $value = trim($candidate); + if ($value !== '') { + return $value; + } + } + + return ''; + } + + /** + * @param array $address + */ + private function nameFromAddress(array $address, string $fallback): string + { + $name = $this->joinParts([ + (string) $this->readPath($address, ['firstName']), + (string) $this->readPath($address, ['lastName']), + ], ' '); + if ($name !== '') { + return $name; + } + + $company = trim((string) $this->readPath($address, ['companyName', 'company'])); + return $company !== '' ? $company : $fallback; + } + + /** + * @param array $parts + */ + private function joinParts(array $parts, string $separator): string + { + $filtered = []; + foreach ($parts as $part) { + $trimmed = trim($part); + if ($trimmed !== '') { + $filtered[] = $trimmed; + } + } + + return implode($separator, $filtered); + } + + private function normalizeOrderId(mixed $value): string + { + return trim((string) $value); + } + + private function normalizeDate(string $value): ?string + { + return StringHelper::normalizeDateTime($value); + } + + private function toFloatOrDefault(mixed $value, float $default): float + { + $resolved = $this->toFloatOrNull($value); + return $resolved ?? $default; + } + + private function toFloatOrNull(mixed $value): ?float + { + if (is_array($value)) { + foreach (['amount', 'value', 'gross', 'price'] as $key) { + if (array_key_exists($key, $value)) { + return $this->toFloatOrNull($value[$key]); + } + } + return null; + } + if (!is_numeric($value)) { + return null; + } + + return (float) $value; + } + + /** + * @param array $data + * @param array $paths + */ + private function readPath(array $data, array $paths): mixed + { + foreach ($paths as $path) { + $current = $data; + $found = true; + foreach (explode('.', $path) as $segment) { + if (!is_array($current) || !array_key_exists($segment, $current)) { + $found = false; + break; + } + $current = $current[$segment]; + } + if ($found) { + return $current; + } + } + + return null; + } +} diff --git a/src/Modules/Settings/ErliOrderSyncStateRepository.php b/src/Modules/Settings/ErliOrderSyncStateRepository.php new file mode 100644 index 0000000..d50fa9b --- /dev/null +++ b/src/Modules/Settings/ErliOrderSyncStateRepository.php @@ -0,0 +1,143 @@ +defaultState(); + if ($integrationId <= 0) { + return $default; + } + + try { + $statement = $this->pdo->prepare( + 'SELECT last_synced_order_updated_at, + last_synced_source_order_id, + last_run_at, + last_success_at, + last_error + FROM integration_order_sync_state + WHERE integration_id = :integration_id + LIMIT 1' + ); + $statement->execute(['integration_id' => $integrationId]); + $row = $statement->fetch(PDO::FETCH_ASSOC); + } catch (Throwable) { + return $default; + } + + if (!is_array($row)) { + return $default; + } + + return [ + 'last_synced_updated_at' => StringHelper::nullableString((string) ($row['last_synced_order_updated_at'] ?? '')), + '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_error' => StringHelper::nullableString((string) ($row['last_error'] ?? '')), + ]; + } + + public function markRunStarted(int $integrationId, DateTimeImmutable $now): void + { + $this->upsertState($integrationId, [ + 'last_run_at' => $now->format('Y-m-d H:i:s'), + ]); + } + + public function markRunSuccess( + int $integrationId, + DateTimeImmutable $now, + ?string $lastMessageCreatedAt, + ?string $lastMessageId + ): void { + $this->upsertState($integrationId, [ + 'last_run_at' => $now->format('Y-m-d H:i:s'), + 'last_success_at' => $now->format('Y-m-d H:i:s'), + 'last_error' => null, + 'last_synced_order_updated_at' => $lastMessageCreatedAt, + 'last_synced_source_order_id' => $lastMessageId, + ]); + } + + public function markRunFailed(int $integrationId, DateTimeImmutable $now, string $error): void + { + $this->upsertState($integrationId, [ + 'last_run_at' => $now->format('Y-m-d H:i:s'), + 'last_error' => mb_substr(trim($error), 0, 500), + ]); + } + + /** + * @return array{last_synced_updated_at:?string,last_synced_source_order_id:?string,last_run_at:?string,last_success_at:?string,last_error:?string} + */ + private function defaultState(): array + { + return [ + 'last_synced_updated_at' => null, + 'last_synced_source_order_id' => null, + 'last_run_at' => null, + 'last_success_at' => null, + 'last_error' => null, + ]; + } + + /** + * @param array $changes + */ + private function upsertState(int $integrationId, array $changes): void + { + if ($integrationId <= 0) { + return; + } + + $columns = ['integration_id', 'created_at', 'updated_at']; + $values = [':integration_id', 'NOW()', 'NOW()']; + $updates = ['updated_at = NOW()']; + $params = ['integration_id' => $integrationId]; + + foreach ($changes as $column => $value) { + $allowed = [ + 'last_run_at', + 'last_success_at', + 'last_error', + 'last_synced_order_updated_at', + 'last_synced_source_order_id', + ]; + if (!in_array($column, $allowed, true)) { + continue; + } + $columns[] = $column; + $values[] = ':' . $column; + $updates[] = $column . ' = VALUES(' . $column . ')'; + $params[$column] = $value; + } + + try { + $statement = $this->pdo->prepare( + 'INSERT INTO integration_order_sync_state (' . implode(', ', $columns) . ') + VALUES (' . implode(', ', $values) . ') + ON DUPLICATE KEY UPDATE ' . implode(', ', $updates) + ); + $statement->execute($params); + } catch (Throwable) { + return; + } + } +} diff --git a/src/Modules/Settings/ErliOrdersSyncService.php b/src/Modules/Settings/ErliOrdersSyncService.php new file mode 100644 index 0000000..da0b0e3 --- /dev/null +++ b/src/Modules/Settings/ErliOrdersSyncService.php @@ -0,0 +1,292 @@ + $options + * @return array + */ + public function sync(array $options = []): array + { + $credentials = $this->integrationRepository->getCredentials(); + if ($credentials === null) { + throw new IntegrationConfigException('Brak aktywnej konfiguracji Erli.'); + } + + $ignoreEnabled = !empty($options['ignore_orders_fetch_enabled']); + if (!$ignoreEnabled && empty($credentials['orders_fetch_enabled'])) { + return $this->disabledResult(); + } + + $integrationId = (int) ($credentials['integration_id'] ?? 0); + $maxMessages = max(1, min(500, (int) ($options['max_messages'] ?? 200))); + $startDate = $this->normalizeStartDate((string) ($credentials['orders_fetch_start_date'] ?? '')); + + $result = [ + 'enabled' => true, + 'processed' => 0, + 'imported_created' => 0, + 'imported_updated' => 0, + 'failed' => 0, + 'skipped' => 0, + 'acknowledged' => false, + 'acknowledged_count' => 0, + 'latest_message_id' => null, + 'errors' => [], + ]; + + $now = new DateTimeImmutable('now'); + $this->syncStateRepository->markRunStarted($integrationId, $now); + + try { + $inbox = $this->apiClient->fetchInbox($credentials); + if (($inbox['ok'] ?? false) !== true) { + throw new IntegrationConfigException((string) ($inbox['message'] ?? 'Blad pobierania inbox Erli.')); + } + + $messages = array_slice((array) ($inbox['items'] ?? []), 0, $maxMessages); + $latestMessageId = null; + $latestMessageCreatedAt = null; + + foreach ($messages as $message) { + if (!is_array($message)) { + $result['skipped'] = (int) $result['skipped'] + 1; + continue; + } + + $messageId = trim((string) ($message['id'] ?? '')); + $messageCreatedAt = StringHelper::normalizeDateTime((string) ($message['created'] ?? '')); + if ($messageId !== '') { + $latestMessageId = $messageId; + $latestMessageCreatedAt = $messageCreatedAt ?? $latestMessageCreatedAt; + } + + if (!$this->shouldProcessByStartDate($message, $startDate)) { + $result['skipped'] = (int) $result['skipped'] + 1; + continue; + } + + try { + $mapped = $this->mapper->mapInboxMessage($integrationId, $message); + if ($mapped === null) { + $result['skipped'] = (int) $result['skipped'] + 1; + continue; + } + + $save = $this->orderImportRepository->upsertOrderAggregate( + $mapped['order'], + $mapped['addresses'], + $mapped['items'], + $mapped['payments'], + $mapped['shipments'], + $mapped['notes'], + $mapped['status_history'] + ); + $this->handlePostImport($mapped, $save, $result); + } catch (Throwable $exception) { + $result['failed'] = (int) $result['failed'] + 1; + $this->appendError($result, [ + 'message_id' => $messageId, + 'type' => (string) ($message['type'] ?? ''), + 'error' => $exception->getMessage(), + ]); + } + } + + if ((int) $result['failed'] > 0) { + $this->syncStateRepository->markRunFailed( + $integrationId, + new DateTimeImmutable('now'), + 'Erli import zakonczony z bledami: ' . (string) $result['failed'] + ); + return $result; + } + + if ($latestMessageId !== null) { + $ack = $this->apiClient->markInboxRead($credentials, $latestMessageId); + if (($ack['ok'] ?? false) !== true) { + throw new IntegrationConfigException('Nie udalo sie potwierdzic inbox Erli: ' . (string) ($ack['message'] ?? '')); + } + $result['acknowledged'] = true; + $result['acknowledged_count'] = (int) ($ack['acknowledged_count'] ?? 0); + $result['latest_message_id'] = $latestMessageId; + } + + $this->syncStateRepository->markRunSuccess( + $integrationId, + new DateTimeImmutable('now'), + $latestMessageCreatedAt, + $latestMessageId + ); + + return $result; + } catch (Throwable $exception) { + $this->syncStateRepository->markRunFailed( + $integrationId, + new DateTimeImmutable('now'), + $exception->getMessage() + ); + throw $exception; + } + } + + /** + * @return array + */ + private function disabledResult(): array + { + return [ + 'enabled' => false, + 'processed' => 0, + 'imported_created' => 0, + 'imported_updated' => 0, + 'failed' => 0, + 'skipped' => 0, + 'acknowledged' => false, + 'acknowledged_count' => 0, + 'latest_message_id' => null, + 'errors' => [], + ]; + } + + /** + * @param array $mapped + * @param array $save + * @param array $result + */ + private function handlePostImport(array $mapped, array $save, array &$result): void + { + $result['processed'] = (int) $result['processed'] + 1; + $savedOrderId = (int) ($save['order_id'] ?? 0); + $wasCreated = !empty($save['created']); + $paymentTransition = !empty($save['payment_transition']); + + if ($wasCreated) { + $result['imported_created'] = (int) $result['imported_created'] + 1; + } else { + $result['imported_updated'] = (int) $result['imported_updated'] + 1; + } + + if ($savedOrderId <= 0) { + return; + } + + $order = is_array($mapped['order'] ?? null) ? $mapped['order'] : []; + $details = [ + 'source' => IntegrationSources::ERLI, + 'source_order_id' => (string) ($order['source_order_id'] ?? ''), + 'source_updated_at' => (string) ($order['source_updated_at'] ?? ''), + 'created' => $wasCreated, + 'payment_transition' => $paymentTransition, + 'message_id' => (string) ($mapped['message_id'] ?? ''), + 'trigger' => 'orders_sync', + 'trigger_label' => 'Synchronizacja zamowien', + ]; + $summary = $wasCreated + ? 'Import zamowienia z Erli' + : 'Zaktualizowano zamowienie z Erli (re-import)'; + if ($paymentTransition) { + $summary = 'Platnosc potwierdzona z Erli'; + } + + if (!$this->ordersRepository->shouldSkipDuplicateImportActivity($savedOrderId, $details)) { + $this->ordersRepository->recordActivity( + $savedOrderId, + 'import', + $summary, + $details, + 'import', + 'Erli' + ); + } + + if ($wasCreated && !empty($mapped['invoice_detected'])) { + $this->ordersRepository->setInvoiceRequested($savedOrderId, true); + } + + if ($wasCreated && $this->automationService !== null) { + $this->automationService->trigger('order.imported', $savedOrderId, [ + 'source' => IntegrationSources::ERLI, + 'created' => true, + 'integration_id' => (int) ($order['integration_id'] ?? 0), + 'new_payment_status' => (string) ($order['payment_status'] ?? ''), + ]); + } + + if (!$wasCreated && $paymentTransition && $this->automationService !== null) { + $this->automationService->trigger('payment.status_changed', $savedOrderId, [ + 'source' => IntegrationSources::ERLI, + 'integration_id' => (int) ($order['integration_id'] ?? 0), + 'old_payment_status' => '', + 'new_payment_status' => (string) ($order['payment_status'] ?? ''), + ]); + } + } + + /** + * @param array $message + */ + private function shouldProcessByStartDate(array $message, ?string $startDate): bool + { + if ($startDate === null) { + return true; + } + + $payload = is_array($message['payload'] ?? null) ? $message['payload'] : []; + $date = StringHelper::normalizeDateTime((string) ($payload['created'] ?? $payload['purchasedAt'] ?? $message['created'] ?? '')); + if ($date === null) { + return true; + } + + return $date >= $startDate; + } + + private function normalizeStartDate(string $value): ?string + { + $trimmed = trim($value); + if ($trimmed === '') { + return null; + } + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $trimmed) !== 1) { + return null; + } + + return $trimmed . ' 00:00:00'; + } + + /** + * @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 new file mode 100644 index 0000000..530a5ba --- /dev/null +++ b/tests/Unit/ErliOrderMapperTest.php @@ -0,0 +1,152 @@ +mapper = new ErliOrderMapper(); + } + + public function testPurchasedOrderMapsToImportAggregate(): void + { + $aggregate = $this->mapper->mapInboxMessage(7, $this->message('purchased')); + + self::assertIsArray($aggregate); + self::assertSame(IntegrationSources::ERLI, $aggregate['order']['source']); + self::assertSame('erli-123', $aggregate['order']['source_order_id']); + self::assertSame('nowe', $aggregate['order']['status_code']); + self::assertSame(2, $aggregate['order']['payment_status']); + self::assertSame(129.99, $aggregate['order']['total_with_tax']); + self::assertSame(14.99, $aggregate['order']['delivery_price']); + self::assertCount(1, $aggregate['items']); + self::assertSame('Produkt Erli', $aggregate['items'][0]['original_name']); + self::assertCount(1, $aggregate['payments']); + } + + public function testPendingOrderMapsAsUnpaid(): void + { + $aggregate = $this->mapper->mapInboxMessage(7, $this->message('pending')); + + self::assertIsArray($aggregate); + self::assertSame('nieoplacone', $aggregate['order']['status_code']); + self::assertSame(0, $aggregate['order']['payment_status']); + } + + public function testCancelledOrderMapsCancellationFlag(): void + { + $aggregate = $this->mapper->mapInboxMessage(7, $this->message('cancelled')); + + self::assertIsArray($aggregate); + self::assertSame('anulowane', $aggregate['order']['status_code']); + self::assertTrue($aggregate['order']['is_canceled_by_buyer']); + } + + public function testCompanyInvoiceDataDetectsInvoiceRequest(): void + { + $message = $this->message('purchased'); + $message['payload']['user']['invoiceAddress'] = [ + 'type' => 'company', + 'companyName' => 'Test Sp. z o.o.', + 'nip' => '1234567890', + 'address' => 'Testowa 1', + 'street' => 'Testowa', + 'buildingNumber' => '1', + 'zip' => '00-001', + 'city' => 'Warszawa', + 'country' => 'pl', + ]; + + $aggregate = $this->mapper->mapInboxMessage(7, $message); + + self::assertIsArray($aggregate); + self::assertTrue($aggregate['invoice_detected']); + self::assertSame('1234567890', $aggregate['addresses'][2]['company_tax_number']); + } + + public function testUnsupportedInboxMessageIsSkipped(): void + { + $message = $this->message('purchased'); + $message['type'] = 'productsNeedSync'; + + self::assertNull($this->mapper->mapInboxMessage(7, $message)); + } + + public function testMissingOrderIdThrowsControlledException(): void + { + $message = $this->message('purchased'); + unset($message['payload']['id']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('ID zamowienia'); + + $this->mapper->mapInboxMessage(7, $message); + } + + /** + * @return array + */ + private function message(string $status): array + { + return [ + 'id' => '5f9e1b3b0f0b9b0001c3e0a0', + 'shopId' => 99, + 'created' => '2026-05-15T10:00:00+02:00', + 'read' => false, + 'type' => 'orderCreated', + 'payload' => [ + 'id' => 'erli-123', + 'externalOrderId' => 'EXT-123', + 'status' => $status, + 'created' => '2026-05-15T09:00:00+02:00', + 'updated' => '2026-05-15T09:05:00+02:00', + 'purchasedAt' => '2026-05-15T09:01:00+02:00', + 'totalPrice' => 129.99, + 'user' => [ + 'email' => 'jan@example.com', + 'deliveryAddress' => [ + 'firstName' => 'Jan', + 'lastName' => 'Kowalski', + 'address' => 'Testowa 1', + 'street' => 'Testowa', + 'buildingNumber' => '1', + 'zip' => '00-001', + 'city' => 'Warszawa', + 'country' => 'pl', + 'phone' => '500100200', + ], + ], + 'delivery' => [ + 'methodName' => 'Kurier', + 'price' => 14.99, + ], + 'payment' => [ + 'id' => 'pay-123', + 'status' => $status === 'purchased' ? 'COMPLETED' : 'NEW', + 'methodCode' => 'PAYU.p', + ], + 'items' => [ + [ + 'id' => 'item-1', + 'externalProductId' => 'sku-1', + 'name' => 'Produkt Erli', + 'quantity' => 2, + 'price' => 57.50, + 'sku' => 'SKU-1', + ], + ], + 'comment' => 'Prosze o szybka wysylke', + 'sellerStatus' => 'created', + ], + ]; + } +}