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:
2026-05-15 23:54:22 +02:00
parent 3ea8cdc941
commit 2565d9b754
23 changed files with 1989 additions and 35 deletions

View File

@@ -13,8 +13,8 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
| Attribute | Value | | Attribute | Value |
|-----------|-------| |-----------|-------|
| Version | 3.8.0-dev | | Version | 3.8.0-dev |
| Status | v3.8 Erli Marketplace Integration in progress — Phase 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*

View File

@@ -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*

View File

@@ -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.

View File

@@ -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`

View 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>

View 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*

View File

@@ -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.

View File

@@ -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

View File

@@ -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:**

View File

@@ -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();

View File

@@ -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' => [

View File

@@ -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>

View File

@@ -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]);

View File

@@ -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';
} }

View File

@@ -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(

View 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),
]);
}
}

View File

@@ -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;

View File

@@ -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',
]);
}
} }

View File

@@ -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

View 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;
}
}

View 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;
}
}
}

View 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;
}
}

View 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',
],
];
}
}