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
This commit is contained in:
@@ -13,8 +13,8 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
|
|||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
|-----------|-------|
|
||||||
| Version | 3.8.0-dev |
|
| Version | 3.8.0-dev |
|
||||||
| Status | v3.8 Erli Marketplace Integration in progress — Phase 127 shipped (Erli settings/API foundation); Phase 128 next |
|
| Status | v3.8 Erli Marketplace Integration in progress — Phase 128 shipped (Erli orders import); Phase 129 next |
|
||||||
| Last Updated | 2026-05-15 (Phase 127 closed) |
|
| Last Updated | 2026-05-15 (Phase 128 closed) |
|
||||||
|
|
||||||
## Requirements
|
## 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] 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] Bugfix detekcji faktury przy imporcie: shopPRO order z `firm_nip` ustawia `invoice_requested=1` (mapper jako jedyne zrodlo heurystyki, sync service propaguje `aggregate['invoice_detected']`); Allegro rozszerzony o `naturalPerson=false`/`address.taxId`/`companyName` (wczesniej tylko `invoice.required`); usunieta legacy kolumna `orders.is_invoice` (Phase 115 dryft) + backfill 7 zamowien — Phase 125
|
||||||
- [x] Fundament integracji Erli: pojedyncza globalna konfiguracja `/settings/integrations/erli`, szyfrowany Bearer API key, realny test `GET /svc/shop-api/inbox`, karta w hubie integracji oraz dokumentacja schematu/architektury — Phase 127
|
- [x] Fundament integracji Erli: pojedyncza globalna konfiguracja `/settings/integrations/erli`, szyfrowany Bearer API key, realny test `GET /svc/shop-api/inbox`, karta w hubie integracji oraz dokumentacja schematu/architektury — Phase 127
|
||||||
|
- [x] Import zamowien Erli: pobieranie `/inbox` przez cron i recznie, mapper do orderPRO, delta-only re-import, `invoice_requested` z danych firmowych/NIP, bezpieczny ACK `/inbox/mark-read` po bezblednym batchu — Phase 128
|
||||||
|
|
||||||
### Deferred
|
### Deferred
|
||||||
|
|
||||||
@@ -135,11 +136,10 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
|
|||||||
|
|
||||||
### Active (In Progress)
|
### 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)
|
### Planned (Next)
|
||||||
|
|
||||||
- [ ] Erli status mapping + sync — Phase 129
|
|
||||||
- [ ] Erli shipments + labels — Phase 130
|
- [ ] Erli shipments + labels — Phase 130
|
||||||
- [ ] Erli tracking + automation hooks — Phase 131
|
- [ ] Erli tracking + automation hooks — Phase 131
|
||||||
- [ ] Erli hardening, observability + docs — Phase 132
|
- [ ] Erli hardening, observability + docs — Phase 132
|
||||||
@@ -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 |
|
| `$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 |
|
| 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 |
|
| 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
|
## Success Metrics
|
||||||
|
|
||||||
| Metric | Target | Current | Status |
|
| 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 |
|
| Generowanie etykiet | Działa | InPost | In progress |
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
@@ -275,6 +278,6 @@ Quick Reference:
|
|||||||
|
|
||||||
---
|
---
|
||||||
*PROJECT.md — Updated when requirements or context change*
|
*PROJECT.md — Updated when requirements or context change*
|
||||||
*Last updated: 2026-05-15 after Phase 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*
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Pelna integracja z erli.pl wzorowana na istniejacej integracji Allegro: konfigur
|
|||||||
| Phase | Name | Plans | Status |
|
| Phase | Name | Plans | Status |
|
||||||
|-------|------|-------|--------|
|
|-------|------|-------|--------|
|
||||||
| 127 | Erli Integration Foundation | 1/1 | Complete (2026-05-15; migration/manual Erli API smoke pending operator) |
|
| 127 | Erli Integration Foundation | 1/1 | Complete (2026-05-15; migration/manual Erli API smoke pending operator) |
|
||||||
| 128 | Erli Orders Import | 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 |
|
| 129 | Erli Status Mapping + Sync | TBD | Not started |
|
||||||
| 130 | Erli Shipments + Labels | TBD | Not started |
|
| 130 | Erli Shipments + Labels | TBD | Not started |
|
||||||
| 131 | Erli Tracking + Automation Hooks | TBD | Not started |
|
| 131 | Erli Tracking + Automation Hooks | TBD | Not started |
|
||||||
@@ -27,7 +27,7 @@ Plans: 127-01 (complete)
|
|||||||
### Phase 128: Erli Orders Import
|
### 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.
|
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
|
### Phase 129: Erli Status Mapping + Sync
|
||||||
|
|
||||||
@@ -553,4 +553,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
|
|||||||
|
|
||||||
---
|
---
|
||||||
*Roadmap created: 2026-03-12*
|
*Roadmap created: 2026-03-12*
|
||||||
*Last updated: 2026-05-15 - Phase 127 UNIFY closed*
|
*Last updated: 2026-05-15 - Phase 128 UNIFY closed*
|
||||||
|
|||||||
@@ -5,19 +5,19 @@
|
|||||||
See: .paul/PROJECT.md (updated 2026-05-07)
|
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.
|
**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
|
## Current Position
|
||||||
|
|
||||||
Milestone: v3.8 Erli Marketplace Integration
|
Milestone: v3.8 Erli Marketplace Integration
|
||||||
Phase: 128 of 132 (Erli Orders Import)
|
Phase: 129 of 132 (Erli Status Mapping + Sync)
|
||||||
Plan: Not started
|
Plan: Not started
|
||||||
Status: Ready to plan
|
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:
|
Progress:
|
||||||
- Milestone v3.8: [##--------] ~16% (Phase 127 complete)
|
- Milestone v3.8: [####------] ~33% (Phases 127-128 complete)
|
||||||
- Phase 128: [----------] 0% (not planned)
|
- Phase 129: [----------] 0% (not planned)
|
||||||
|
|
||||||
## Loop Position
|
## Loop Position
|
||||||
|
|
||||||
@@ -29,10 +29,10 @@ PLAN -> APPLY -> UNIFY
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-05-15 23:26
|
Last session: 2026-05-15 23:52
|
||||||
Stopped at: Phase 127 complete; Phase 128 ready to plan
|
Stopped at: Phase 128 complete
|
||||||
Next action: $paul-plan for Phase 128 (Erli Orders Import)
|
Next action: $paul-plan for Phase 129 (Erli Status Mapping + Sync)
|
||||||
Resume file: .paul/ROADMAP.md
|
Resume file: .paul/phases/128-erli-orders-import/128-01-SUMMARY.md
|
||||||
|
|
||||||
## Pending parallel work
|
## Pending parallel work
|
||||||
- None — Phase 118, 121, 122 wszystkie zacommitowane (8f14851, 360eef1).
|
- None — Phase 118, 121, 122 wszystkie zacommitowane (8f14851, 360eef1).
|
||||||
@@ -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 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 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 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
|
## Deferred to Next Milestones
|
||||||
|
|
||||||
@@ -76,4 +78,4 @@ Branch: main
|
|||||||
|
|
||||||
## Skill Requirements
|
## 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.
|
||||||
|
|||||||
@@ -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.
|
- [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.
|
- 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
|
## Zmienione pliki
|
||||||
|
|
||||||
@@ -22,3 +24,13 @@
|
|||||||
- `DOCS/DB_SCHEMA.md`
|
- `DOCS/DB_SCHEMA.md`
|
||||||
- `DOCS/ARCHITECTURE.md`
|
- `DOCS/ARCHITECTURE.md`
|
||||||
- `DOCS/TECH_CHANGELOG.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`
|
||||||
|
|||||||
311
.paul/phases/128-erli-orders-import/128-01-PLAN.md
Normal file
311
.paul/phases/128-erli-orders-import/128-01-PLAN.md
Normal file
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
## 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.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
<clarifications>
|
||||||
|
- **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.
|
||||||
|
</clarifications>
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
@.paul/PROJECT.md
|
||||||
|
@.paul/ROADMAP.md
|
||||||
|
@.paul/STATE.md
|
||||||
|
@.paul/codebase/architecture.md
|
||||||
|
@.paul/codebase/db_schema.md
|
||||||
|
@DOCS/DB_SCHEMA.md
|
||||||
|
@DOCS/ARCHITECTURE.md
|
||||||
|
|
||||||
|
## Prior Work
|
||||||
|
@.paul/phases/127-erli-integration-foundation/127-01-SUMMARY.md
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<skills>
|
||||||
|
## 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.
|
||||||
|
</skills>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
```
|
||||||
|
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add Erli import controls, schedule and entry points</name>
|
||||||
|
<files>
|
||||||
|
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
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
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()`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
`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`
|
||||||
|
</verify>
|
||||||
|
<done>AC-1 satisfied: Erli import can be enabled, scheduled, and manually triggered from settings.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Implement Erli inbox client, mapper and sync service</name>
|
||||||
|
<files>
|
||||||
|
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
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
`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`
|
||||||
|
</verify>
|
||||||
|
<done>AC-2, AC-3, AC-4 and AC-5 satisfied for the runtime import path.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Add mapper tests and update technical docs</name>
|
||||||
|
<files>
|
||||||
|
tests/Unit/ErliOrderMapperTest.php,
|
||||||
|
DOCS/DB_SCHEMA.md,
|
||||||
|
DOCS/ARCHITECTURE.md,
|
||||||
|
DOCS/TECH_CHANGELOG.md
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
`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.
|
||||||
|
</verify>
|
||||||
|
<done>AC-6 satisfied: tests and documentation describe the new Erli import behavior and remaining live-smoke steps.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<boundaries>
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
</boundaries>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
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.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- 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.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.paul/phases/128-erli-orders-import/128-01-SUMMARY.md`.
|
||||||
|
</output>
|
||||||
177
.paul/phases/128-erli-orders-import/128-01-SUMMARY.md
Normal file
177
.paul/phases/128-erli-orders-import/128-01-SUMMARY.md
Normal file
@@ -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*
|
||||||
@@ -111,6 +111,7 @@ HTTP Request
|
|||||||
| `AllegroStatusSyncHandler` | Push status changes to Allegro |
|
| `AllegroStatusSyncHandler` | Push status changes to Allegro |
|
||||||
| `AllegroTokenRefreshHandler` | OAuth token refresh (24h expiry) |
|
| `AllegroTokenRefreshHandler` | OAuth token refresh (24h expiry) |
|
||||||
| `ShopproOrdersImportHandler` | Fetch new shopPRO orders |
|
| `ShopproOrdersImportHandler` | Fetch new shopPRO orders |
|
||||||
|
| `ErliOrdersImportHandler` | Fetch unread Erli inbox order events |
|
||||||
| `ShopproStatusSyncHandler` | Push status to shopPRO |
|
| `ShopproStatusSyncHandler` | Push status to shopPRO |
|
||||||
| `ShopproPaymentStatusSyncHandler` | Sync payment statuses |
|
| `ShopproPaymentStatusSyncHandler` | Sync payment statuses |
|
||||||
| `ShipmentTrackingHandler` | Poll carrier tracking APIs |
|
| `ShipmentTrackingHandler` | Poll carrier tracking APIs |
|
||||||
@@ -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.
|
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_*`.
|
2. **Connection test** - `ErliIntegrationController::test()` loads active credentials, calls `ErliApiClient::testConnection()`, performs a real authenticated `GET https://erli.pl/svc/shop-api/inbox`, and stores the result in `integrations.last_test_*`.
|
||||||
3. **Hub** - `IntegrationsHubController::buildErliRow()` adds Erli to `/settings/integrations` with configured/missing secret status, active status, last test timestamp, and configure URL.
|
3. **Hub** - `IntegrationsHubController::buildErliRow()` adds Erli to `/settings/integrations` with configured/missing secret status, active status, last test timestamp, and configure URL.
|
||||||
4. **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
|
## Dependency Injection
|
||||||
|
|
||||||
@@ -181,13 +183,25 @@ tests/
|
|||||||
|
|
||||||
### ErliApiClient (`src/Modules/Settings/ErliApiClient.php`)
|
### ErliApiClient (`src/Modules/Settings/ErliApiClient.php`)
|
||||||
- `testConnection()` wykonuje realny `GET /inbox` do Erli z naglowkiem `Authorization: Bearer ...`.
|
- `testConnection()` wykonuje realny `GET /inbox` do Erli z naglowkiem `Authorization: Bearer ...`.
|
||||||
|
- Phase 128: `fetchInbox()` pobiera do 500 nieprzeczytanych wiadomosci; `markInboxRead()` potwierdza `POST /inbox/mark-read` z `lastMessageId` dopiero po udanym batchu.
|
||||||
- Wysyla `Accept: application/json` i `User-Agent: orderPRO/1.0 (erli-integration)`.
|
- Wysyla `Accept: application/json` i `User-Agent: orderPRO/1.0 (erli-integration)`.
|
||||||
- Traktuje HTTP 2xx jako sukces; 401/403 jako blad autoryzacji, 429 jako limit zapytan, pozostale bledy jako czytelny komunikat z odpowiedzi.
|
- Traktuje HTTP 2xx jako sukces; 401/403 jako blad autoryzacji, 429 jako limit zapytan, pozostale bledy jako czytelny komunikat z odpowiedzi.
|
||||||
- Uzywa `SslCertificateResolver` i nie wywoluje `curl_close()` (PHP 8.5 compatible).
|
- Uzywa `SslCertificateResolver` i nie wywoluje `curl_close()` (PHP 8.5 compatible).
|
||||||
|
|
||||||
### ErliIntegrationController (`src/Modules/Settings/ErliIntegrationController.php`)
|
### ErliIntegrationController (`src/Modules/Settings/ErliIntegrationController.php`)
|
||||||
- Endpointy: `GET /settings/integrations/erli`, `POST /settings/integrations/erli/save`, `POST /settings/integrations/erli/test`.
|
- Endpointy: `GET /settings/integrations/erli`, `POST /settings/integrations/erli/save`, `POST /settings/integrations/erli/test`, `POST /settings/integrations/erli/import`.
|
||||||
- `save` zapisuje label, aktywnosc i sekret; `test` wykonuje realny test API i zapisuje wynik przez `IntegrationsRepository::updateTestResult()`.
|
- `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
|
### IntegrationsHubController
|
||||||
- Dodaje wiersz Erli do `/settings/integrations` ze statusem konfiguracji, sekretu, aktywnosci i ostatniego testu.
|
- Dodaje wiersz Erli do `/settings/integrations` ze statusem konfiguracji, sekretu, aktywnosci i ostatniego testu.
|
||||||
|
|||||||
@@ -366,6 +366,17 @@ UNIQUE: `(order_id, source_payment_id)`
|
|||||||
UNIQUE: `(integration_id, shoppro_status_code)`
|
UNIQUE: `(integration_id, shoppro_status_code)`
|
||||||
|
|
||||||
**integration_order_sync_state** — Track order fetch progress per integration
|
**integration_order_sync_state** — Track order fetch progress per integration
|
||||||
|
| 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
|
**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 | |
|
| `created_at` | DATETIME | NO | |
|
||||||
| `updated_at` | DATETIME | NO | |
|
| `updated_at` | DATETIME | NO | |
|
||||||
|
|
||||||
|
Seeded recurring jobs include `shoppro_orders_import`, `allegro_orders_import`, `shoppro_order_status_sync`, `shoppro_payment_status_sync`, `allegro_status_sync`, `shipment_tracking_sync`, `automation_history_cleanup`, `order_status_aged`, and `erli_orders_import` (Phase 128; default disabled until Erli order import is enabled).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Settings & Configuration
|
## Settings & Configuration
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# Technical Changelog
|
# 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
|
## 2026-05-15 - Phase 127 Plan 01: Erli Integration Foundation
|
||||||
|
|
||||||
**Co zrobiono:**
|
**Co zrobiono:**
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -870,11 +870,18 @@ return [
|
|||||||
'title' => 'Test polaczenia',
|
'title' => 'Test polaczenia',
|
||||||
'description' => 'Test wykonuje realne, bezpieczne zapytanie GET do API Erli.',
|
'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' => [
|
'fields' => [
|
||||||
'account_label' => 'Nazwa konta',
|
'account_label' => 'Nazwa konta',
|
||||||
'api_key' => 'Klucz API',
|
'api_key' => 'Klucz API',
|
||||||
'options' => 'Opcje',
|
'options' => 'Opcje',
|
||||||
'is_active' => 'Integracja aktywna',
|
'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' => [
|
'api_key' => [
|
||||||
'saved' => 'Klucz API jest zapisany. Pozostaw pole puste, aby nie zmieniac.',
|
'saved' => 'Klucz API jest zapisany. Pozostaw pole puste, aby nie zmieniac.',
|
||||||
@@ -882,6 +889,8 @@ return [
|
|||||||
],
|
],
|
||||||
'hints' => [
|
'hints' => [
|
||||||
'account_label' => 'Opcjonalna nazwa widoczna w hubie integracji.',
|
'account_label' => 'Opcjonalna nazwa widoczna w hubie integracji.',
|
||||||
|
'orders_fetch_start_date' => 'Opcjonalnie pominie zdarzenia starsze niz podana data, jesli payload Erli zawiera date zamowienia.',
|
||||||
|
'orders_import_interval_minutes' => 'Dotyczy zadania cron `erli_orders_import`. Zakres: 1-1440 minut.',
|
||||||
],
|
],
|
||||||
'status' => [
|
'status' => [
|
||||||
'secret' => 'Sekret API',
|
'secret' => 'Sekret API',
|
||||||
@@ -893,12 +902,18 @@ return [
|
|||||||
'actions' => [
|
'actions' => [
|
||||||
'save' => 'Zapisz ustawienia Erli',
|
'save' => 'Zapisz ustawienia Erli',
|
||||||
'test' => 'Test polaczenia',
|
'test' => 'Test polaczenia',
|
||||||
|
'import_now' => 'Importuj zamowienia teraz',
|
||||||
],
|
],
|
||||||
'flash' => [
|
'flash' => [
|
||||||
'saved' => 'Ustawienia Erli zostaly zapisane.',
|
'saved' => 'Ustawienia Erli zostaly zapisane.',
|
||||||
'save_failed' => 'Nie udalo sie zapisac ustawien Erli.',
|
'save_failed' => 'Nie udalo sie zapisac ustawien Erli.',
|
||||||
'test_success' => 'Polaczenie z API Erli dziala.',
|
'test_success' => 'Polaczenie z API Erli dziala.',
|
||||||
'test_failed' => 'Nie udalo sie polaczyc z API Erli.',
|
'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' => [
|
'inpost' => [
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ $lastTestAt = trim((string) ($settings['last_test_at'] ?? ''));
|
|||||||
$lastTestStatus = trim((string) ($settings['last_test_status'] ?? ''));
|
$lastTestStatus = trim((string) ($settings['last_test_status'] ?? ''));
|
||||||
$lastTestMessage = trim((string) ($settings['last_test_message'] ?? ''));
|
$lastTestMessage = trim((string) ($settings['last_test_message'] ?? ''));
|
||||||
$lastTestHttpCode = $settings['last_test_http_code'] ?? null;
|
$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);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -24,6 +27,10 @@ $lastTestHttpCode = $settings['last_test_http_code'] ?? null;
|
|||||||
<?php if (!empty($testMessage)): ?>
|
<?php if (!empty($testMessage)): ?>
|
||||||
<div class="mt-12"><?php $type='info'; $message=(string) $testMessage; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
|
<div class="mt-12"><?php $type='info'; $message=(string) $testMessage; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($importMessage)): ?>
|
||||||
|
<div class="mt-12"><?php $type='info'; $message=(string) $importMessage; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card mt-16">
|
<section class="card mt-16">
|
||||||
@@ -59,15 +66,43 @@ $lastTestHttpCode = $settings['last_test_http_code'] ?? null;
|
|||||||
<input type="checkbox" name="is_active" value="1"<?= $isActive ? ' checked' : '' ?>>
|
<input type="checkbox" name="is_active" value="1"<?= $isActive ? ' checked' : '' ?>>
|
||||||
<span><?= $e($t('settings.erli.fields.is_active')) ?></span>
|
<span><?= $e($t('settings.erli.fields.is_active')) ?></span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="integration-settings-checkboxes__item">
|
||||||
|
<input type="checkbox" name="orders_fetch_enabled" value="1"<?= $ordersFetchEnabled ? ' checked' : '' ?>>
|
||||||
|
<span><?= $e($t('settings.erli.fields.orders_fetch_enabled')) ?></span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('settings.erli.fields.orders_fetch_start_date')) ?></span>
|
||||||
|
<input class="form-control" type="date" name="orders_fetch_start_date" value="<?= $e($ordersFetchStartDate) ?>">
|
||||||
|
<span class="muted"><?= $e($t('settings.erli.hints.orders_fetch_start_date')) ?></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('settings.erli.fields.orders_import_interval_minutes')) ?></span>
|
||||||
|
<input class="form-control" type="number" min="1" max="1440" name="orders_import_interval_minutes" value="<?= $e((string) $ordersImportIntervalMinutes) ?>">
|
||||||
|
<span class="muted"><?= $e($t('settings.erli.hints.orders_import_interval_minutes')) ?></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="form-actions mt-16">
|
<div class="form-actions mt-16">
|
||||||
<button type="submit" class="btn btn--primary"><?= $e($t('settings.erli.actions.save')) ?></button>
|
<button type="submit" class="btn btn--primary"><?= $e($t('settings.erli.actions.save')) ?></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<h3 class="section-title"><?= $e($t('settings.erli.import.title')) ?></h3>
|
||||||
|
<p class="muted mt-12"><?= $e($t('settings.erli.import.description')) ?></p>
|
||||||
|
|
||||||
|
<form class="statuses-form mt-16" action="/settings/integrations/erli/import" method="post">
|
||||||
|
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn--secondary"><?= $e($t('settings.erli.actions.import_now')) ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card mt-16">
|
<section class="card mt-16">
|
||||||
<h3 class="section-title"><?= $e($t('settings.erli.test.title')) ?></h3>
|
<h3 class="section-title"><?= $e($t('settings.erli.test.title')) ?></h3>
|
||||||
<p class="muted mt-12"><?= $e($t('settings.erli.test.description')) ?></p>
|
<p class="muted mt-12"><?= $e($t('settings.erli.test.description')) ?></p>
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ use App\Modules\Settings\CarrierDeliveryMethodMappingRepository;
|
|||||||
use App\Modules\Settings\ErliApiClient;
|
use App\Modules\Settings\ErliApiClient;
|
||||||
use App\Modules\Settings\ErliIntegrationController;
|
use App\Modules\Settings\ErliIntegrationController;
|
||||||
use App\Modules\Settings\ErliIntegrationRepository;
|
use App\Modules\Settings\ErliIntegrationRepository;
|
||||||
|
use App\Modules\Settings\ErliOrderMapper;
|
||||||
|
use App\Modules\Settings\ErliOrderSyncStateRepository;
|
||||||
|
use App\Modules\Settings\ErliOrdersSyncService;
|
||||||
use App\Modules\Settings\FakturowniaApiClient;
|
use App\Modules\Settings\FakturowniaApiClient;
|
||||||
use App\Modules\Settings\FakturowniaIntegrationController;
|
use App\Modules\Settings\FakturowniaIntegrationController;
|
||||||
use App\Modules\Settings\FakturowniaIntegrationRepository;
|
use App\Modules\Settings\FakturowniaIntegrationRepository;
|
||||||
@@ -236,14 +239,6 @@ return static function (Application $app): void {
|
|||||||
$app->db(),
|
$app->db(),
|
||||||
(string) $app->config('app.integrations.secret', '')
|
(string) $app->config('app.integrations.secret', '')
|
||||||
);
|
);
|
||||||
$erliIntegrationController = new ErliIntegrationController(
|
|
||||||
$template,
|
|
||||||
$translator,
|
|
||||||
$auth,
|
|
||||||
$erliIntegrationRepository,
|
|
||||||
new ErliApiClient(),
|
|
||||||
new IntegrationsRepository($app->db())
|
|
||||||
);
|
|
||||||
$notificationRepository = new NotificationRepository($app->db());
|
$notificationRepository = new NotificationRepository($app->db());
|
||||||
$smsMessageRepository = new SmsMessageRepository($app->db());
|
$smsMessageRepository = new SmsMessageRepository($app->db());
|
||||||
$smsConversationService = new SmsConversationService(
|
$smsConversationService = new SmsConversationService(
|
||||||
@@ -388,6 +383,25 @@ return static function (Application $app): void {
|
|||||||
$shipmentPackageRepositoryForOrders,
|
$shipmentPackageRepositoryForOrders,
|
||||||
$receiptService
|
$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(
|
$allegroIntegrationController = new AllegroIntegrationController(
|
||||||
$template,
|
$template,
|
||||||
$translator,
|
$translator,
|
||||||
@@ -635,6 +649,7 @@ return static function (Application $app): void {
|
|||||||
$router->get('/settings/integrations/erli', [$erliIntegrationController, 'index'], [$authMiddleware]);
|
$router->get('/settings/integrations/erli', [$erliIntegrationController, 'index'], [$authMiddleware]);
|
||||||
$router->post('/settings/integrations/erli/save', [$erliIntegrationController, 'save'], [$authMiddleware]);
|
$router->post('/settings/integrations/erli/save', [$erliIntegrationController, 'save'], [$authMiddleware]);
|
||||||
$router->post('/settings/integrations/erli/test', [$erliIntegrationController, 'test'], [$authMiddleware]);
|
$router->post('/settings/integrations/erli/test', [$erliIntegrationController, 'test'], [$authMiddleware]);
|
||||||
|
$router->post('/settings/integrations/erli/import', [$erliIntegrationController, 'importNow'], [$authMiddleware]);
|
||||||
$router->get('/settings/integrations/shoppro', [$shopproIntegrationsController, 'index'], [$authMiddleware]);
|
$router->get('/settings/integrations/shoppro', [$shopproIntegrationsController, 'index'], [$authMiddleware]);
|
||||||
$router->post('/settings/integrations/shoppro/save', [$shopproIntegrationsController, 'save'], [$authMiddleware]);
|
$router->post('/settings/integrations/shoppro/save', [$shopproIntegrationsController, 'save'], [$authMiddleware]);
|
||||||
$router->post('/settings/integrations/shoppro/test', [$shopproIntegrationsController, 'test'], [$authMiddleware]);
|
$router->post('/settings/integrations/shoppro/test', [$shopproIntegrationsController, 'test'], [$authMiddleware]);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ final class IntegrationSources
|
|||||||
{
|
{
|
||||||
public const ALLEGRO = 'allegro';
|
public const ALLEGRO = 'allegro';
|
||||||
public const SHOPPRO = 'shoppro';
|
public const SHOPPRO = 'shoppro';
|
||||||
|
public const ERLI = 'erli';
|
||||||
public const APACZKA = 'apaczka';
|
public const APACZKA = 'apaczka';
|
||||||
public const INPOST = 'inpost';
|
public const INPOST = 'inpost';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ use App\Modules\Settings\ApaczkaIntegrationRepository;
|
|||||||
use App\Modules\Settings\CompanySettingsRepository;
|
use App\Modules\Settings\CompanySettingsRepository;
|
||||||
use App\Modules\Settings\EmailMailboxRepository;
|
use App\Modules\Settings\EmailMailboxRepository;
|
||||||
use App\Modules\Settings\EmailTemplateRepository;
|
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\InpostIntegrationRepository;
|
||||||
use App\Modules\Settings\IntegrationSecretCipher;
|
use App\Modules\Settings\IntegrationSecretCipher;
|
||||||
use App\Modules\Settings\ReceiptConfigRepository;
|
use App\Modules\Settings\ReceiptConfigRepository;
|
||||||
@@ -128,6 +133,15 @@ final class CronHandlerFactory
|
|||||||
$this->db,
|
$this->db,
|
||||||
$automationService
|
$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(
|
return new CronRunner(
|
||||||
$cronRepository,
|
$cronRepository,
|
||||||
@@ -161,6 +175,9 @@ final class CronHandlerFactory
|
|||||||
'shoppro_payment_status_sync' => new ShopproPaymentStatusSyncHandler(
|
'shoppro_payment_status_sync' => new ShopproPaymentStatusSyncHandler(
|
||||||
$shopproPaymentSyncService
|
$shopproPaymentSyncService
|
||||||
),
|
),
|
||||||
|
'erli_orders_import' => new ErliOrdersImportHandler(
|
||||||
|
$erliOrdersSyncService
|
||||||
|
),
|
||||||
'shipment_tracking_sync' => new ShipmentTrackingHandler(
|
'shipment_tracking_sync' => new ShipmentTrackingHandler(
|
||||||
new ShipmentTrackingRegistry([
|
new ShipmentTrackingRegistry([
|
||||||
new InpostTrackingService(
|
new InpostTrackingService(
|
||||||
|
|||||||
24
src/Modules/Cron/ErliOrdersImportHandler.php
Normal file
24
src/Modules/Cron/ErliOrdersImportHandler.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Cron;
|
||||||
|
|
||||||
|
use App\Modules\Settings\ErliOrdersSyncService;
|
||||||
|
|
||||||
|
final class ErliOrdersImportHandler
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ErliOrdersSyncService $syncService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function handle(array $payload): array
|
||||||
|
{
|
||||||
|
return $this->syncService->sync([
|
||||||
|
'max_messages' => (int) ($payload['max_messages'] ?? 200),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
if ($curlError !== null) {
|
||||||
return [
|
return [
|
||||||
'ok' => false,
|
'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<int, array<string, mixed>>, 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}
|
* @return array{0: string, 1: int, 2: ?string}
|
||||||
*/
|
*/
|
||||||
private function httpGet(string $url, string $apiKey): array
|
/**
|
||||||
|
* @param array<string, mixed>|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);
|
$ch = curl_init($url);
|
||||||
if ($ch === false) {
|
if ($ch === false) {
|
||||||
@@ -64,7 +139,6 @@ final class ErliApiClient
|
|||||||
|
|
||||||
$opts = [
|
$opts = [
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_HTTPGET => true,
|
|
||||||
CURLOPT_TIMEOUT => $this->timeoutSeconds,
|
CURLOPT_TIMEOUT => $this->timeoutSeconds,
|
||||||
CURLOPT_CONNECTTIMEOUT => 10,
|
CURLOPT_CONNECTTIMEOUT => 10,
|
||||||
CURLOPT_SSL_VERIFYPEER => true,
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
@@ -72,10 +146,18 @@ final class ErliApiClient
|
|||||||
CURLOPT_HTTPHEADER => [
|
CURLOPT_HTTPHEADER => [
|
||||||
'Authorization: Bearer ' . $apiKey,
|
'Authorization: Bearer ' . $apiKey,
|
||||||
'Accept: application/json',
|
'Accept: application/json',
|
||||||
|
'Content-Type: application/json',
|
||||||
'User-Agent: orderPRO/1.0 (erli-integration)',
|
'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();
|
$caPath = SslCertificateResolver::resolve();
|
||||||
if ($caPath !== null) {
|
if ($caPath !== null) {
|
||||||
$opts[CURLOPT_CAINFO] = $caPath;
|
$opts[CURLOPT_CAINFO] = $caPath;
|
||||||
|
|||||||
@@ -12,17 +12,25 @@ use App\Core\Security\Csrf;
|
|||||||
use App\Core\Support\Flash;
|
use App\Core\Support\Flash;
|
||||||
use App\Core\View\Template;
|
use App\Core\View\Template;
|
||||||
use App\Modules\Auth\AuthService;
|
use App\Modules\Auth\AuthService;
|
||||||
|
use App\Modules\Cron\CronRepository;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
final class ErliIntegrationController
|
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(
|
public function __construct(
|
||||||
private readonly Template $template,
|
private readonly Template $template,
|
||||||
private readonly Translator $translator,
|
private readonly Translator $translator,
|
||||||
private readonly AuthService $auth,
|
private readonly AuthService $auth,
|
||||||
private readonly ErliIntegrationRepository $repository,
|
private readonly ErliIntegrationRepository $repository,
|
||||||
private readonly ErliApiClient $apiClient,
|
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(),
|
'user' => $this->auth->user(),
|
||||||
'csrfToken' => Csrf::token(),
|
'csrfToken' => Csrf::token(),
|
||||||
'settings' => $this->repository->getSettings(),
|
'settings' => $this->repository->getSettings(),
|
||||||
|
'ordersImportIntervalMinutes' => $this->currentImportIntervalMinutes(),
|
||||||
'errorMessage' => (string) Flash::get('settings_error', ''),
|
'errorMessage' => (string) Flash::get('settings_error', ''),
|
||||||
'successMessage' => (string) Flash::get('settings_success', ''),
|
'successMessage' => (string) Flash::get('settings_success', ''),
|
||||||
'testMessage' => (string) Flash::get('erli_test', ''),
|
'testMessage' => (string) Flash::get('erli_test', ''),
|
||||||
|
'importMessage' => (string) Flash::get('erli_import', ''),
|
||||||
], 'layouts/app');
|
], 'layouts/app');
|
||||||
|
|
||||||
return Response::html($html);
|
return Response::html($html);
|
||||||
@@ -56,7 +66,10 @@ final class ErliIntegrationController
|
|||||||
'account_label' => (string) $request->input('account_label', ''),
|
'account_label' => (string) $request->input('account_label', ''),
|
||||||
'api_key' => (string) $request->input('api_key', ''),
|
'api_key' => (string) $request->input('api_key', ''),
|
||||||
'is_active' => $request->input('is_active', ''),
|
'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'));
|
Flash::set('settings_success', $this->translator->get('settings.erli.flash.saved'));
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
Flash::set(
|
Flash::set(
|
||||||
@@ -68,6 +81,27 @@ final class ErliIntegrationController
|
|||||||
return Response::redirect($redirectTo);
|
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
|
public function test(Request $request): Response
|
||||||
{
|
{
|
||||||
$redirectTo = $this->resolveRedirect($request);
|
$redirectTo = $this->resolveRedirect($request);
|
||||||
@@ -110,4 +144,60 @@ final class ErliIntegrationController
|
|||||||
'/settings/integrations/erli'
|
'/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<string, mixed> $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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ final class ErliIntegrationRepository
|
|||||||
'account_label' => trim((string) ($row['account_label'] ?? '')),
|
'account_label' => trim((string) ($row['account_label'] ?? '')),
|
||||||
'has_api_key' => $encryptedApiKey !== null && $encryptedApiKey !== '',
|
'has_api_key' => $encryptedApiKey !== null && $encryptedApiKey !== '',
|
||||||
'is_active' => (int) ($integration['is_active'] ?? 1) === 1,
|
'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_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_http_code' => isset($integration['last_test_http_code']) ? (int) $integration['last_test_http_code'] : null,
|
||||||
'last_test_message' => trim((string) ($integration['last_test_message'] ?? '')),
|
'last_test_message' => trim((string) ($integration['last_test_message'] ?? '')),
|
||||||
@@ -84,11 +86,16 @@ final class ErliIntegrationRepository
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->updateIntegrationActive($integrationId, !empty($payload['is_active']));
|
$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);
|
$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
|
public function getCredentials(): ?array
|
||||||
{
|
{
|
||||||
@@ -114,9 +121,17 @@ final class ErliIntegrationRepository
|
|||||||
'integration_id' => $integrationId,
|
'integration_id' => $integrationId,
|
||||||
'base_url' => trim((string) ($integration['base_url'] ?? self::INTEGRATION_BASE_URL)),
|
'base_url' => trim((string) ($integration['base_url'] ?? self::INTEGRATION_BASE_URL)),
|
||||||
'api_key' => $apiKey,
|
'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
|
private function ensureBaseIntegration(): int
|
||||||
{
|
{
|
||||||
return $this->integrations->ensureIntegration(
|
return $this->integrations->ensureIntegration(
|
||||||
@@ -182,6 +197,33 @@ final class ErliIntegrationRepository
|
|||||||
return $label;
|
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<string, mixed>|null $row
|
* @param array<string, mixed>|null $row
|
||||||
* @param array<string, mixed>|null $integration
|
* @param array<string, mixed>|null $integration
|
||||||
|
|||||||
467
src/Modules/Settings/ErliOrderMapper.php
Normal file
467
src/Modules/Settings/ErliOrderMapper.php
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Settings;
|
||||||
|
|
||||||
|
use App\Core\Constants\IntegrationSources;
|
||||||
|
use App\Core\Support\StringHelper;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class ErliOrderMapper
|
||||||
|
{
|
||||||
|
private const SUPPORTED_TYPES = [
|
||||||
|
'orderCreated',
|
||||||
|
'orderStatusChanged',
|
||||||
|
'orderSellerStatusChanged',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $message
|
||||||
|
* @return array{
|
||||||
|
* message_id:string,
|
||||||
|
* message_created_at:?string,
|
||||||
|
* order:array<string,mixed>,
|
||||||
|
* addresses:array<int,array<string,mixed>>,
|
||||||
|
* items:array<int,array<string,mixed>>,
|
||||||
|
* payments:array<int,array<string,mixed>>,
|
||||||
|
* shipments:array<int,array<string,mixed>>,
|
||||||
|
* notes:array<int,array<string,mixed>>,
|
||||||
|
* status_history:array<int,array<string,mixed>>,
|
||||||
|
* 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<string, mixed> $payload
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $address
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $payload
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $payload
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $payload
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<int, string> $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<string, mixed> $data
|
||||||
|
* @param array<int, string> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/Modules/Settings/ErliOrderSyncStateRepository.php
Normal file
143
src/Modules/Settings/ErliOrderSyncStateRepository.php
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Settings;
|
||||||
|
|
||||||
|
use App\Core\Support\StringHelper;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PDO;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class ErliOrderSyncStateRepository
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PDO $pdo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{last_synced_updated_at:?string,last_synced_source_order_id:?string,last_run_at:?string,last_success_at:?string,last_error:?string}
|
||||||
|
*/
|
||||||
|
public function getState(int $integrationId): array
|
||||||
|
{
|
||||||
|
$default = $this->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<string, mixed> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
292
src/Modules/Settings/ErliOrdersSyncService.php
Normal file
292
src/Modules/Settings/ErliOrdersSyncService.php
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Settings;
|
||||||
|
|
||||||
|
use App\Core\Constants\IntegrationSources;
|
||||||
|
use App\Core\Exceptions\IntegrationConfigException;
|
||||||
|
use App\Core\Support\StringHelper;
|
||||||
|
use App\Modules\Automation\AutomationService;
|
||||||
|
use App\Modules\Orders\OrderImportRepository;
|
||||||
|
use App\Modules\Orders\OrdersRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class ErliOrdersSyncService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ErliIntegrationRepository $integrationRepository,
|
||||||
|
private readonly ErliOrderSyncStateRepository $syncStateRepository,
|
||||||
|
private readonly ErliApiClient $apiClient,
|
||||||
|
private readonly OrderImportRepository $orderImportRepository,
|
||||||
|
private readonly OrdersRepository $ordersRepository,
|
||||||
|
private readonly ErliOrderMapper $mapper,
|
||||||
|
private readonly ?AutomationService $automationService = null
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $options
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $mapped
|
||||||
|
* @param array<string, mixed> $save
|
||||||
|
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $result
|
||||||
|
* @param array<string, mixed> $error
|
||||||
|
*/
|
||||||
|
private function appendError(array &$result, array $error): void
|
||||||
|
{
|
||||||
|
$errors = is_array($result['errors'] ?? null) ? $result['errors'] : [];
|
||||||
|
if (count($errors) < 20) {
|
||||||
|
$errors[] = $error;
|
||||||
|
}
|
||||||
|
$result['errors'] = $errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
152
tests/Unit/ErliOrderMapperTest.php
Normal file
152
tests/Unit/ErliOrderMapperTest.php
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use App\Core\Constants\IntegrationSources;
|
||||||
|
use App\Modules\Settings\ErliOrderMapper;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class ErliOrderMapperTest extends TestCase
|
||||||
|
{
|
||||||
|
private ErliOrderMapper $mapper;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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<string, mixed>
|
||||||
|
*/
|
||||||
|
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',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user