feat(133): erli cross-surface parity

Phase 133 complete:

- Add shared order source registry for Allegro, shopPRO, and Erli

- Include Erli in order filters, statistics, automation integration filters, and integration menu state

- Document verification gaps for missing PHPUnit vendor and sonar-scanner
This commit is contained in:
2026-05-16 16:25:32 +02:00
parent 8f4745400e
commit 0c1246b522
20 changed files with 860 additions and 70 deletions

View File

@@ -13,8 +13,8 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
| Attribute | Value |
|-----------|-------|
| Version | 3.8.0-dev |
| Status | v3.8 Erli Marketplace Integration complete in code Phases 127-132 shipped; live Erli smoke/migrations remain operator follow-up |
| Last Updated | 2026-05-16 (Phase 132 closed) |
| Status | v3.8 Erli Marketplace Integration complete in code - Phases 127-133 shipped; live Erli smoke/migrations remain operator follow-up |
| Last Updated | 2026-05-16 (Phase 133 closed) |
## Requirements
@@ -132,6 +132,7 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
- [x] Przesylki Erli: zakladka mapowania dostaw, etykiety przez lokalne providery InPost/Apaczka i rejestracja paczek zewnetrznych w Erli przez `POST /shipping/external` — Phase 130
- [x] Tracking i automatyzacje Erli: lokalny provider tracking jak w Allegro, retry niekrytycznej rejestracji paczki zewnetrznej Erli z `shipment_tracking_sync`, wspolny kontekst `shipment.created`/`shipment.status_changed` dla regul e-mail/SMS/statystyk — Phase 131
- [x] Hardening Erli: spojna diagnostyka importu/ACK w `integration_order_sync_state.last_error`, brak ACK po blednym batchu, testy jednostkowe import/status sync i dokumentacja obserwowalnosci bez nowej migracji — Phase 132
- [x] Parytet Erli w powierzchniach wspolnych: filtr zrodla zamowien, kanaly statystyk dziennych/podsumowania, warunek integracji automatyzacji, menu integracji i etykiety `zrodlo` uzywaja wspolnego rejestru zrodel — Phase 133
- [x] Integracja polkurier.pl (fundament): pojedyncza globalna konfiguracja w `/settings/integrations/polkurier`, szyfrowany Token API + login, karta w hubie integracji obok Apaczki i realny test polaczenia przez `apimetod=test_auth_api` zweryfikowany na zywym koncie operatora; `ShipmentProviderRegistry` netkniety — `PolkurierShipmentService/TrackingService` w kolejnych fazach — Phase 127
- [x] polkurier ShipmentService + TrackingService + UI prepare panel: pelen kontrakt API (createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers), `PolkurierShipmentService` implementujacy `ShipmentProviderInterface` z normalizacja shipmenttype (lowercase) i splitem ulicy na street/housenumber/flatnumber, `PolkurierTrackingService` mapujacy statusy O/P/A/WP/D/Z/W na znormalizowane, panel "polkurier" w `prepare.php` z dynamiczna lista uslug z `available_carriers`, seed migracja `delivery_status_mappings(provider='polkurier')` z 7 wpisami z PDF v1.11; live test na #114/#115 zakonczony sukcesem po 4 iteracjach (ReferenceError → uppercase shipmenttype → orderno parsing → A4/A6); rozmiar etykiety sterowany w panelu klienta polkurier.pl (Ustawienia konta → Preferencje etykiet), NIE przez API — Phase 128
- [x] Order User Notes module (Phase 129): pelen CRUD notatek autorskich operatora per zamowienie. Reuse `order_notes` przez nowy `note_type='user'` z `user_id` (FK→users SET NULL) + `author_name` (snapshot) + indeks `idx_order_notes_type_order`. `OrderNotesService` z autoryzacja DB-level (`WHERE user_id = :user_id`, rowCount=0 ⇒ 403). Sekcja `#notes` w "Wiadomosci i zalaczniki" w `/orders/{id}` z inline edit form + delete przez `OrderProAlerts.confirm`. Badge `[N]` (indigo neutralny) przy nr zamowienia na `/orders/list` (subquery `user_notes_count` w paginate). Brak admin override (brak systemu rol w aplikacji) — edit/delete tylko dla autora — Phase 129
@@ -259,6 +260,7 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API
| Erli etykiety uzywaja lokalnych providerow, a Erli dostaje paczke zewnetrzna przez `POST /shipping/external` | Operator nie chce nadawac na umowie Erli; API wspiera zewnetrzne paczki/tracking | 2026-05-16 | Active |
| `carrier_delivery_method_mappings` przechowuje `source_vendor_code`/`source_service_id` dla Erli | Vendor Erli i lokalny provider to osobne kontrakty, nie nalezy ich mieszac w polach Apaczki/InPost | 2026-05-16 | Active |
| Erli hardening uzywa istniejacych powierzchni obserwowalnosci zamiast nowej tabeli logow | Operator wybral ujednolicenie istniejacych miejsc; `integration_order_sync_state.last_error`, wynik crona i activity log wystarczaja dla Phase 132 | 2026-05-16 | Active |
| Zrodla zamowien marketplace maja wspolny `OrderSourceRegistry` | Parytet Erli ma byc utrzymany wszedzie tam, gdzie kod potrzebuje listy lub etykiety zrodla; lokalne pary Allegro/shopPRO prowadzily do pominiec Erli | 2026-05-16 | Active |
| polkurier startuje jako jedna globalna konfiguracja (single-instance, mirror Apaczka/HostedSMS/SMSPLANET) z realnym testowym wywolaniem `apimetod=test_auth_api` | Operator ma jedno konto polkurier; fundament musi byc zweryfikowany na zywym API zanim dolozymy `PolkurierShipmentService` | 2026-05-14 | Active |
| polkurier wymaga `login + token` razem w body `authorization` (nie samego tokena) | Zweryfikowane w SDK polkurier-sdk (`Auth.php`/`Request.php`); kolumna `login VARCHAR(190)` w `polkurier_integration_settings` mimo ze PLAN tego nie wymagal — kontrakt API to dyktuje | 2026-05-14 | Active |
| polkurier API: top-level `status` === `'success'` (nie `'ok'`), tresc bledu w polu `response` envelope'a | `ResponseStatus::SUCCESS = 'success'` z `src/Type/ResponseStatus.php` SDK; bledy rzucane przez `ErrorException($response->get('response'))` w `PolkurierWebService.php`. Pattern dla wszystkich przyszlych metod polkurier API (`createShipment`, `getLabel`, `getStatus`, `cancelOrder`, etc.) | 2026-05-14 | Active |
@@ -306,6 +308,6 @@ Quick Reference:
---
*PROJECT.md — Updated when requirements or context change*
*Last updated: 2026-05-16 after Phase 132 (Erli Hardening, Observability + Docs) closure; v3.8 milestone complete in code*
*Last updated: 2026-05-16 after Phase 133 (Erli Cross-Surface Parity) closure; v3.8 milestone complete in code*

View File

@@ -6,11 +6,11 @@ orderPRO to narzedzie do wielokanalowego zarzadzania sprzedaza. Projekt przechod
## Current Milestone
v3.8 Erli Marketplace Integration Complete in code
v3.8 Erli Marketplace Integration - Complete in code
Pelna integracja z erli.pl wzorowana na istniejacej integracji Allegro: konfiguracja konta/API, pobieranie zamowien, mapowanie i synchronizacja statusow, generowanie etykiet, tracking oraz wlaczenie Erli w istniejace przeplywy automatyzacji, statystyk i obslugi zamowien.
Progress: 6 of 6 phases complete (100%).
Progress: 7 of 7 phases complete (100%).
| Phase | Name | Plans | Status |
|-------|------|-------|--------|
@@ -20,6 +20,7 @@ Progress: 6 of 6 phases complete (100%).
| 130 | Erli Shipments + Labels | 1/1 | Complete (2026-05-16; migration/manual Erli shipping smoke pending operator) |
| 131 | Erli Tracking + Automation Hooks | 1/1 | Complete (2026-05-16; manual Erli tracking/automation smoke pending operator) |
| 132 | Erli Hardening, Observability + Docs | 1/1 | Complete (2026-05-16; PHPUnit/Sonar env gaps documented) |
| 133 | Erli Cross-Surface Parity | 1/1 | Complete (2026-05-16; PHPUnit/Sonar env gaps documented) |
### Phase 127: Erli Integration Foundation
@@ -51,6 +52,11 @@ Plans: 131-01 (complete)
Focus: Testy jednostkowe mapperow/klientow, logi integracji i bledow API, retry/idempotencja, manual smoke checklist na zywej konfiguracji oraz aktualizacja `DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md` i `DOCS/TECH_CHANGELOG.md`.
Plans: 132-01 (complete)
### Phase 133: Erli Cross-Surface Parity
Focus: Domknac Erli jako pelnoprawny kanal w istniejacych wspolnych powierzchniach: filtr `Zrodlo` na liscie zamowien, kanaly sprzedazy w `/statistics/orders` i `/statistics/summary`, warunki integracji w automatyzacjach oraz aktywne menu integracji. Wprowadzic maly wspolny rejestr zrodel, zeby ograniczyc kolejne lokalne pominiecia Erli.
Plans: 133-01 (complete)
## Previous Milestone (transition pending)
v3.7 Invoices (Fakturownia integration) — Complete in code, transition/follow-ups pending
@@ -561,4 +567,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
---
*Roadmap created: 2026-03-12*
*Last updated: 2026-05-16 - Phase 132 complete; v3.8 complete in code*
*Last updated: 2026-05-16 - Phase 133 complete; v3.8 complete in code*

View File

@@ -10,37 +10,37 @@ See: .paul/PROJECT.md (updated 2026-05-16)
## Current Position
Milestone: v3.8 Erli Marketplace Integration
Phase: 132 of 132 (Erli Hardening, Observability + Docs) - Complete
Plan: 132-01 unified
Phase: 133 of 133 (Erli Cross-Surface Parity) - Complete
Plan: 133-01 unified
Status: Loop complete; v3.8 complete in code
Last activity: 2026-05-16 15:51 - Unified .paul/phases/132-erli-hardening-observability-docs/132-01-PLAN.md
Last activity: 2026-05-16 16:22 - Unified .paul/phases/133-erli-cross-surface-parity/133-01-PLAN.md
Progress:
- Milestone v3.8: [##########] 100% (Phases 127-132 complete in code)
- Phase 132: [##########] 100% (complete)
- Milestone v3.8: [##########] 100% (Phases 127-133 complete in code)
- Phase 133: [##########] 100% (complete)
## Loop Position
Current loop state:
```
PLAN -> APPLY -> UNIFY
done done done [Loop complete - ready for next milestone]
done done done [Loop complete - ready for milestone completion or next milestone]
```
## Session Continuity
Last session: 2026-05-16 15:51
Stopped at: Phase 132 complete; v3.8 complete in code
Last session: 2026-05-16 16:22
Stopped at: Phase 133 complete; v3.8 complete in code
Next action: Choose next milestone or run $paul-complete-milestone for v3.8 archival/release closure
Resume file: .paul/phases/132-erli-hardening-observability-docs/132-01-SUMMARY.md
Resume file: .paul/phases/133-erli-cross-surface-parity/133-01-SUMMARY.md
## Pending parallel work
- None — Phase 118, 121, 122 wszystkie zacommitowane (8f14851, 360eef1).
## Git State
Last phase commit: feat(132): erli hardening observability docs
Previous: feat(131): erli tracking automation hooks
Last phase commit: feat(133): erli cross-surface parity
Previous: feat(132): erli hardening observability docs
Branch: main
### Skill Audit (Phase 129)
@@ -67,6 +67,12 @@ Branch: main
|----------|---------|-------|
| `sonar-scanner` | gap documented | Attempted after APPLY; CLI is not available in PATH. |
### Skill Audit (Phase 133)
| Expected | Invoked | Notes |
|----------|---------|-------|
| `sonar-scanner` | gap documented | Attempted after APPLY; CLI is not available in PATH. |
## Pending Actions
- Manualne testy AC-1..AC-7 dla Phase 112 na zywej bazie (XAMPP online).
@@ -102,6 +108,9 @@ Branch: main
- Phase 131 follow-up: manualny smoke po migracjach/konfiguracji Erli — utworz paczke dla zamowienia Erli z lokalnym providerem, potwierdz `shipment.created`, uruchom `shipment_tracking_sync`, sprawdz `shipment.status_changed` i retry `POST /shipping/external` tylko po pojawieniu sie tracking number.
- Phase 132 verification gap: `vendor/bin/phpunit` nie istnieje w checkoutcie, a globalny XAMPP PHPUnit jest niekompatybilny z PHP (`each()` removed), wiec testy `ErliOrdersSyncServiceTest`, `ErliStatusSyncServiceTest` i `ErliOrderMapperTest` nie zostaly uruchomione przez PHPUnit; wykonano `php -l` i `git diff --check`.
- Phase 132 skill gap: `sonar-scanner` nie jest dostepny w PATH, wiec skan SonarQube nie zostal uruchomiony.
- Phase 133 follow-up: manualny smoke `/orders/list` -> filtr `Zrodlo` ma Allegro, shopPRO i Erli; `/statistics/orders` i `/statistics/summary` -> kanal Erli widoczny i liczony; edycja automatyzacji -> aktywna integracja Erli dostepna w warunku Integracja.
- Phase 133 verification gap: `vendor/bin/phpunit` nie istnieje w checkoutcie, wiec testy `OrderSourceRegistryTest` i `OrdersStatisticsRepositoryTest` nie zostaly uruchomione przez PHPUnit; wykonano `php -l`, ad-hoc SQLite smoke i `git diff --check`.
- Phase 133 skill gap: `sonar-scanner` nie jest dostepny w PATH, wiec skan SonarQube nie zostal uruchomiony.
- Phase 127 follow-up: zaplanowac kolejna faze polkurier — `PolkurierShipmentService` (CreateOrder + GetLabel + OrderValuationV2 + AvailableCarriers mapping + UI mapowan metod dostawy + presety przesylek) — fundament + zweryfikowany kontrakt API gotowy.
- Phase 127 follow-up: drugi krok — `PolkurierTrackingService` + wpisy w `delivery_status_mappings` (provider='polkurier').
- Phase 127 follow-up: po polkurier shipment service rozwazyc fazy paczkomaty (`InpostParcelMachines` / `PocztexPostOffices` / `Kurier48PostOffices` API juz dostepne w SDK polkuriera).
@@ -123,4 +132,4 @@ Branch: main
## Skill Requirements
- `sonar-scanner` required after APPLY; Phase 116, Phase 117, Phase 121, Phase 122, Phase 128, Phase 129, Phase 130, Phase 131 and Phase 132 gaps documented because CLI was not available in PATH.
- `sonar-scanner` required after APPLY; Phase 116, Phase 117, Phase 121, Phase 122, Phase 128, Phase 129, Phase 130, Phase 131, Phase 132 and Phase 133 gaps documented because CLI was not available in PATH.

View File

@@ -22,6 +22,11 @@
- Blad `POST /inbox/mark-read` jest zapisywany przez istniejacy state failure path z czytelnym komunikatem.
- Dodano `ErliOrdersSyncServiceTest` oraz rozszerzono `ErliStatusSyncServiceTest` o diagnostyke nieudanego push statusu.
- Udokumentowano gapy srodowiskowe Phase 132: brak `vendor/bin/phpunit`, globalny XAMPP PHPUnit niekompatybilny z PHP, brak `sonar-scanner` w PATH.
- [Phase 133, Plan 01] Domknieto parytet Erli w powierzchniach wspolnych: lista zamowien, statystyki dzienne i podsumowanie, automatyzacje oraz menu integracji.
- Dodano `OrderSourceRegistry` jako wspolny kontrakt zrodel `allegro`, `shoppro`, `erli`.
- Statystyki zawsze pokazuja kanal Erli i licza `orders.source='erli'` w agregacjach dziennych, miesiecznych i diagnostyce.
- Automatyzacje pobieraja typy integracji zamowieniowych z rejestru, wiec aktywna integracja Erli jest dostepna w warunku Integracja.
- Udokumentowano gapy srodowiskowe Phase 133: brak `vendor/` dla PHPUnit oraz brak `sonar-scanner` w PATH.
## Zmienione pliki
@@ -69,3 +74,16 @@
- `.paul/phases/132-erli-hardening-observability-docs/132-01-PLAN.md`
- `.paul/phases/132-erli-hardening-observability-docs/132-01-SUMMARY.md`
- `tests/Unit/ErliOrdersSyncServiceTest.php`
- `.paul/phases/133-erli-cross-surface-parity/133-01-PLAN.md`
- `.paul/phases/133-erli-cross-surface-parity/133-01-SUMMARY.md`
- `src/Modules/Orders/OrderSourceRegistry.php`
- `src/Modules/Orders/OrdersRepository.php`
- `src/Modules/Orders/OrdersController.php`
- `src/Modules/Statistics/OrdersStatisticsRepository.php`
- `src/Modules/Automation/AutomationRepository.php`
- `src/Modules/Accounting/InvoiceService.php`
- `src/Modules/Settings/EmailTemplateController.php`
- `src/Modules/Settings/SmsTemplateController.php`
- `resources/views/layouts/app.php`
- `tests/Unit/OrderSourceRegistryTest.php`
- `tests/Unit/OrdersStatisticsRepositoryTest.php`

View File

@@ -0,0 +1,210 @@
---
phase: 133-erli-cross-surface-parity
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/Modules/Orders/OrderSourceRegistry.php
- src/Modules/Orders/OrdersRepository.php
- src/Modules/Orders/OrdersController.php
- src/Modules/Statistics/OrdersStatisticsRepository.php
- src/Modules/Automation/AutomationRepository.php
- src/Modules/Accounting/InvoiceService.php
- src/Modules/Settings/EmailTemplateController.php
- src/Modules/Settings/SmsTemplateController.php
- resources/views/layouts/app.php
- tests/Unit/OrderSourceRegistryTest.php
- tests/Unit/OrdersStatisticsRepositoryTest.php
- DOCS/DB_SCHEMA.md
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
autonomous: true
delegation: auto
---
<objective>
## Goal
Domknac parytet Erli w miejscach, gdzie aplikacja traktuje obecnie tylko Allegro/shopPRO jako zrodla zamowien: lista zamowien, statystyki dzienne i podsumowanie, automatyzacje oraz aktywny stan menu integracji.
## Purpose
Phase 127-132 dodaly techniczna integracje Erli, ale czesc powierzchni UI i raportowania nadal ma twardo wpisane tylko Allegro/shopPRO. Sprzedawca ma widziec Erli jako pelnoprawny kanal sprzedazy we wszystkich wspolnych przeplywach obslugi zamowien.
## Output
Powstanie maly wspolny rejestr zrodel zamowien, a istniejace ekrany i zapytania beda korzystac z tego kontraktu zamiast lokalnych list Allegro/shopPRO. Erli bedzie zawsze dostepne w statystykach jako kanal `erli`, a lista zamowien i automatyzacje beda pokazywac Erli z czytelna etykieta.
</objective>
<context>
<clarifications>
- **Zakres** - Czy plan ma objac wszystkie znalezione miejsca z parytetem Allegro/shopPRO, czy tylko wskazane ekrany listy zamowien i statystyk?
-> Odpowiedz: Wszedzie.
- **Kanaly** - Jak ma zachowywac sie filtr kanalow sprzedazy w statystykach dla Erli?
-> Odpowiedz: Zawsze.
- **Wdrozenie** - Czy podczas wdrozenia wolno wydzielic wspolny helper/rejestr kanalow, zeby ograniczyc kolejne pominiecia Erli?
-> Odpowiedz: Tak, moze.
</clarifications>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
@.paul/codebase/architecture.md
@.paul/codebase/db_schema.md
@.paul/phases/132-erli-hardening-observability-docs/132-01-SUMMARY.md
## Source Files
@src/Modules/Orders/OrdersRepository.php
@src/Modules/Orders/OrdersController.php
@src/Modules/Statistics/OrdersStatisticsRepository.php
@src/Modules/Statistics/OrdersStatisticsController.php
@src/Modules/Automation/AutomationRepository.php
@resources/views/layouts/app.php
@resources/views/statistics/orders.php
@resources/views/statistics/summary.php
@resources/views/orders/list.php
@DOCS/ARCHITECTURE.md
@DOCS/DB_SCHEMA.md
@DOCS/TECH_CHANGELOG.md
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| sonar-scanner | required | Po APPLY, przed UNIFY | o |
**BLOCKING:** `sonar-scanner` jest wymagany przez `.paul/SPECIAL-FLOWS.md`. Jezeli CLI nadal nie jest dostepny w PATH, udokumentuj gap w SUMMARY/STATE tak jak w poprzednich fazach.
</skills>
<acceptance_criteria>
## AC-1: Erli jest zrodlem zamowien na liscie zamowien
```gherkin
Given operator otwiera `/orders/list`
When rozwija filtr `Zrodlo`
Then widzi stale opcje Allegro, shopPRO i Erli z czytelnymi etykietami, nawet jezeli w bazie nie ma jeszcze zamowien Erli
```
## AC-2: Statystyki dzienne i podsumowanie zawsze obsluguja Erli
```gherkin
Given operator otwiera `/statistics/orders` albo `/statistics/summary`
When filtruje `Kanaly sprzedazy`
Then kanal `Erli` jest dostepny domyslnie, a agregacje dzienne, miesieczne, tabele, wykresy i diagnostyka licza zamowienia `orders.source='erli'`
```
## AC-3: Automatyzacje i menu integracji maja parytet Erli
```gherkin
Given aktywna integracja Erli istnieje w tabeli `integrations`
When operator tworzy albo edytuje regule automatyzacji z warunkiem `Integracja`
Then Erli pojawia sie na liscie integracji tak jak Allegro/shopPRO, a ekran konfiguracji Erli zachowuje aktywne podswietlenie menu Integracje
```
## AC-4: Rejestr zrodel ogranicza kolejne pominiecia Erli
```gherkin
Given kod potrzebuje etykiet lub list wspieranych zrodel zamowien
When uzywa wspolnego rejestru zrodel
Then Allegro, shopPRO i Erli maja jedno zrodlo prawdy dla wartosci technicznych i etykiet, a testy potwierdzaja obecnosc Erli
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Wydzielic rejestr zrodel i podpiac liste zamowien oraz automatyzacje</name>
<files>src/Modules/Orders/OrderSourceRegistry.php, src/Modules/Orders/OrdersRepository.php, src/Modules/Orders/OrdersController.php, src/Modules/Automation/AutomationRepository.php, resources/views/layouts/app.php</files>
<action>
Dodaj maly, finalny helper `OrderSourceRegistry` w module Orders:
- stale techniczne dla `allegro`, `shoppro`, `erli`;
- metode etykietowania `label(string $source): string`;
- metode zwracajaca stale zrodla marketplace uzywane w filtrach i raportach;
- metode zwracajaca typy integracji z zamowieniami dla automatyzacji.
Podlacz helper w istniejacych miejscach:
- `OrdersRepository::sourceOptions()` ma zwracac stale Allegro/shopPRO/Erli z etykietami oraz ewentualne dodatkowe zrodla znalezione w DB, bez dublowania kluczy;
- `OrdersController::sourceLabel()` ma korzystac z helpera;
- `AutomationRepository::listOrderIntegrations()` ma obejmowac `erli` przez helper albo przygotowana liste typow, nie przez twarde `('allegro','shoppro')`;
- `resources/views/layouts/app.php` ma traktowac `erli` jako aktywna podstrone integracji, jezeli kontroler lub przyszly kod przekaze `activeSettings='erli'`.
Nie zmieniaj kontraktu formularzy, nazw parametrow GET ani zapisu warunkow automatyzacji.
</action>
<verify>php -l src/Modules/Orders/OrderSourceRegistry.php; php -l src/Modules/Orders/OrdersRepository.php; php -l src/Modules/Orders/OrdersController.php; php -l src/Modules/Automation/AutomationRepository.php</verify>
<done>AC-1, AC-3 i AC-4 satisfied: Erli jest widoczne na liscie zrodel, w automatyzacjach i w menu integracji, a etykiety pochodza ze wspolnego helpera.</done>
</task>
<task type="auto">
<name>Task 2: Rozszerzyc statystyki dzienne, miesieczne i diagnostyke o Erli</name>
<files>src/Modules/Statistics/OrdersStatisticsRepository.php</files>
<action>
Zastap twarde zalozenie `LOWER(o.source) IN ("allegro", "shoppro")` wspolna lista z rejestru zrodel:
- `listChannelOptions()` ma zawsze zwracac `allegro` i `erli`, a shopPRO nadal rozbijac po `integration_id` tak jak obecnie;
- `channelSql()` ma mapowac Erli do stalego klucza `erli`;
- `aggregateByDay()` i `aggregateByMonth()` maja filtrowac po wspieranych zrodlach marketplace, w tym Erli;
- `diagnostics()` ma liczyc `in_date_and_source`, `after_channel_filter` i `after_status_filter` z Erli;
- zachowaj prepared statements i obecny model `channels[]`.
Nie zmieniaj widokow statystyk, jezeli same dzialaja z przekazana lista `channelOptions`; obecne widoki juz renderuja opcje dynamicznie.
</action>
<verify>php -l src/Modules/Statistics/OrdersStatisticsRepository.php</verify>
<done>AC-2 satisfied: `Erli` jest zawsze opcja kanalow i agregacje licza `orders.source='erli'` w dziennym raporcie, podsumowaniu oraz diagnostyce.</done>
</task>
<task type="auto">
<name>Task 3: Dodac testy regresji i dokumentacje parytetu Erli</name>
<files>tests/Unit/OrderSourceRegistryTest.php, tests/Unit/OrdersStatisticsRepositoryTest.php, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md</files>
<action>
Dodaj testy bez zewnetrznego API:
- `OrderSourceRegistryTest` ma potwierdzac etykiety Allegro/shopPRO/Erli, stale kanaly raportowe i typy integracji zamowien;
- `OrdersStatisticsRepositoryTest` ma potwierdzic, ze `listChannelOptions()` zwraca `Erli` nawet bez zamowien Erli oraz zachowuje warianty shopPRO po `integration_id` tam, gdzie testowa baza to wspiera.
Zaktualizuj dokumentacje:
- `DOCS/ARCHITECTURE.md`: dodaj opis wspolnego rejestru zrodel i tego, ze statystyki korzystaja z Allegro/shopPRO/Erli;
- `DOCS/TECH_CHANGELOG.md`: dodaj wpis z data 2026-05-16, opisujacy parytet Erli w UI/statystykach/automatyzacjach i powod zmiany.
`DOCS/DB_SCHEMA.md` nie wymaga zmian, jezeli implementacja nie doda migracji ani kolumn; nie dopisuj sztucznego wpisu bez zmiany schematu.
</action>
<verify>php -l tests/Unit/OrderSourceRegistryTest.php; php -l tests/Unit/OrdersStatisticsRepositoryTest.php; vendor/bin/phpunit tests/Unit/OrderSourceRegistryTest.php tests/Unit/OrdersStatisticsRepositoryTest.php; git diff --check -- DOCS/ARCHITECTURE.md DOCS/TECH_CHANGELOG.md</verify>
<done>AC-2 i AC-4 satisfied: testy chronia obecnosc Erli, a dokumentacja opisuje nowy kontrakt bez zmian schematu.</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Nie dodawaj migracji ani nowych tabel; plan dotyczy parytetu UI/raportow na istniejacym `orders.source='erli'`.
- Nie zmieniaj importu, ACK, status sync, przesylek ani API klienta Erli z Phase 127-132.
- Nie podpinaj `DB_HOST_REMOTE` do runtime aplikacji.
- Nie dodawaj native `alert()` / `confirm()` ani inline CSS w widokach.
- Nie zmieniaj nazw parametrow formularzy: `source`, `channels[]`, `status_groups[]`.
## SCOPE LIMITS
- Brak nowych ekranow i redesignu UI.
- Brak zmian w modelu danych automatyzacji; warunek integracji nadal uzywa `integration_ids`.
- Brak live smoke API Erli w tym planie.
- Brak refaktoru calego modulu statystyk poza wspolna lista zrodel i usunieciem twardych par Allegro/shopPRO.
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php -l` dla wszystkich zmienionych plikow PHP.
- [ ] `vendor/bin/phpunit tests/Unit/OrderSourceRegistryTest.php tests/Unit/OrdersStatisticsRepositoryTest.php` albo jawny gap, jezeli `vendor/bin/phpunit` nadal nie istnieje.
- [ ] `rg -n 'IN \("allegro", "shoppro"\)|type IN \(''allegro'', ''shoppro''\)|allegro.*shoppro|shoppro.*allegro' src resources -S --glob '!resources/scss/app.css.map' --glob '!public/assets/css/**'` nie pokazuje aktywnych par, ktore powinny obejmowac Erli.
- [ ] `git diff --check -- . ':!.vscode/ftp-kr.sync.cache.json'`.
- [ ] `sonar-scanner --version` i `sonar-scanner` uruchomiony albo gap udokumentowany zgodnie z `.paul/SPECIAL-FLOWS.md`.
- [ ] All acceptance criteria met.
</verification>
<success_criteria>
- Erli jest zawsze widoczne w filtrze zrodla zamowien.
- Erli jest zawsze widoczne i liczone w obu widokach statystyk.
- Erli jest dostepne w warunku integracji automatyzacji.
- Wspolny rejestr zrodel zastapil lokalne listy tam, gdzie dotyczy to zrodel zamowien.
- Testy i dokumentacja potwierdzaja nowy kontrakt.
</success_criteria>
<output>
After completion, create `.paul/phases/133-erli-cross-surface-parity/133-01-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,162 @@
---
phase: 133-erli-cross-surface-parity
plan: 01
subsystem: orders-statistics-automation
tags: [erli, orders, statistics, automation, source-registry]
requires:
- phase: 127-132-erli-marketplace-integration
provides: Erli settings, import, status sync, shipments, tracking, and hardening foundations
provides:
- Shared order source registry for Allegro, shopPRO, and Erli
- Erli parity in order source filters, statistics channels, automation integration filters, and integration menu state
- Regression tests for registry and statistics Erli aggregation
affects: [orders, statistics, automation, templates, documentation]
tech-stack:
added: []
patterns: [shared-order-source-registry]
key-files:
created:
- src/Modules/Orders/OrderSourceRegistry.php
- tests/Unit/OrderSourceRegistryTest.php
- tests/Unit/OrdersStatisticsRepositoryTest.php
modified:
- src/Modules/Orders/OrdersRepository.php
- src/Modules/Statistics/OrdersStatisticsRepository.php
- src/Modules/Automation/AutomationRepository.php
- resources/views/layouts/app.php
key-decisions:
- "Order marketplace sources use OrderSourceRegistry as the shared contract."
- "Statistics always expose Erli as a channel, while shopPRO remains split by integration id."
patterns-established:
- "Use OrderSourceRegistry for shared order source labels and marketplace source lists."
duration: 20min
started: 2026-05-16T16:01:00+02:00
completed: 2026-05-16T16:22:41+02:00
---
# Phase 133 Plan 01: Erli Cross-Surface Parity Summary
Erli is now treated as a first-class shared order source across order filters, statistics, automation integration filters, menu state, and source-template descriptions.
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~20min |
| Started | 2026-05-16T16:01:00+02:00 |
| Completed | 2026-05-16T16:22:41+02:00 |
| Tasks | 3 completed |
| Files modified | 17 plus PAUL metadata |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Erli is an order source on `/orders/list` | Pass | `OrdersRepository::sourceOptions()` starts from `OrderSourceRegistry::sourceOptions()`, so Allegro, shopPRO, and Erli are stable options even before matching rows exist. |
| AC-2: Daily and summary statistics support Erli | Pass | `listChannelOptions()` always exposes `erli`; `aggregateByDay()`, `aggregateByMonth()`, and `diagnostics()` use registry-backed marketplace source filtering. |
| AC-3: Automation and integration menu have Erli parity | Pass | `AutomationRepository::listOrderIntegrations()` uses registry integration types; the settings menu active list includes `erli`. |
| AC-4: Shared registry prevents future omissions | Pass | `OrderSourceRegistry` centralizes source keys/labels/types; unit tests cover labels and Erli presence. |
## Accomplishments
- Added `OrderSourceRegistry` as the single shared contract for `allegro`, `shoppro`, and `erli`.
- Removed hardcoded Allegro/shopPRO-only source filters from statistics and automation integration lookup.
- Kept Erli always visible in statistics as channel key `erli`, while preserving shopPRO per-integration keys.
- Updated labels/docs where the old descriptions still implied only Allegro/shopPRO.
## Task Commits
The phase was committed as one scoped transition commit:
| Task | Commit | Type | Description |
|------|--------|------|-------------|
| Phase 133 | Phase transition commit | feat | Erli cross-surface parity |
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `src/Modules/Orders/OrderSourceRegistry.php` | Created | Shared source keys, labels, marketplace source list, and order integration types. |
| `src/Modules/Orders/OrdersRepository.php` | Modified | Source filter options now include stable Allegro/shopPRO/Erli labels plus DB-discovered extras. |
| `src/Modules/Orders/OrdersController.php` | Modified | Source labels now use the shared registry. |
| `src/Modules/Statistics/OrdersStatisticsRepository.php` | Modified | Statistics channels and source filters include Erli; SQLite-compatible helpers support new tests. |
| `src/Modules/Automation/AutomationRepository.php` | Modified | Order integration filter uses registry-backed prepared placeholders and includes Erli. |
| `resources/views/layouts/app.php` | Modified | Erli settings keep the Integracje menu active. |
| `src/Modules/Settings/EmailTemplateController.php` | Modified | Source variable label mentions Erli. |
| `src/Modules/Settings/SmsTemplateController.php` | Modified | Source variable label mentions Erli. |
| `src/Modules/Accounting/InvoiceService.php` | Modified | NIP extraction comment documents Erli payload support. |
| `tests/Unit/OrderSourceRegistryTest.php` | Created | Covers labels and order integration source list. |
| `tests/Unit/OrdersStatisticsRepositoryTest.php` | Created | Covers Erli channel presence and day/month Erli aggregation. |
| `DOCS/ARCHITECTURE.md` | Modified | Documents `OrderSourceRegistry` and statistics Erli contract. |
| `DOCS/DB_SCHEMA.md` | Modified | Updates integration type/reporting notes to mention Erli. |
| `DOCS/TECH_CHANGELOG.md` | Modified | Adds Phase 133 technical changelog entry. |
| `.paul/ROADMAP.md` | Modified | Marks Phase 133/milestone transition state. |
| `.paul/STATE.md` | Modified | Tracks APPLY/UNIFY state and skill gaps. |
| `.paul/changelog/2026-05-16.md` | Modified | Adds human-readable Phase 133 changelog. |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Use `OrderSourceRegistry` for shared order source contracts | The user asked for Erli everywhere and approved a helper/registry. | Future source lists should use the registry instead of local Allegro/shopPRO pairs. |
| Keep Erli as one statistics channel | Current Erli settings are single-account/global, unlike shopPRO per-integration reporting. | Statistics use stable `erli` key and can split later only if Erli becomes multi-account. |
| Extend small documentation/template labels found by parity scan | The scope was "wszędzie"; old labels still implied Allegro/shopPRO-only source support. | No runtime behavior risk; reduces operator confusion. |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Scope additions | 3 | Documentation/template parity only; no schema or API change. |
| Deferred | 3 | Environment/manual verification gaps documented. |
### Scope Additions
- Updated `DOCS/DB_SCHEMA.md` because the integration type/reporting notes still omitted Erli.
- Updated e-mail/SMS source variable labels from `Allegro/shopPRO/...` to `Allegro/shopPRO/Erli`.
- Updated an invoice NIP extraction comment to document existing Erli payload support.
### Deferred Items
- Full PHPUnit run waits for `composer install` because `vendor/` is missing.
- SonarQube scan waits for `sonar-scanner` in PATH.
- Manual browser smoke remains for `/orders/list`, `/statistics/orders`, `/statistics/summary`, and automation rule edit UI.
## Verification Results
| Command | Result |
|---------|--------|
| `php -l` on changed PHP source/test files | Pass |
| Ad-hoc PHP/SQLite source/statistics check | Pass |
| `rg -F 'IN ("allegro", "shoppro")' ...` | Pass, no remaining hardcoded source filter |
| `rg -F "type IN ('allegro', 'shoppro')" ...` | Pass, no remaining hardcoded automation integration filter |
| `rg -F "Zrodlo (Allegro/shopPRO/...)" ...` | Pass, no remaining old source variable label |
| `git diff --check -- . ':!.vscode/ftp-kr.sync.cache.json'` | Pass; Git reported only LF/CRLF warnings |
| `vendor/bin/phpunit tests/Unit/OrderSourceRegistryTest.php tests/Unit/OrdersStatisticsRepositoryTest.php` | Not run; `vendor/` is missing |
| `sonar-scanner --version` | Not run successfully; command unavailable in PATH |
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Existing `SUMMARY.md` was created during APPLY because the plan output requested it. | Treated it as an APPLY draft and rewrote it during UNIFY with full frontmatter/reconciliation. |
| PHPUnit executable missing. | Added tests and verified syntax plus ad-hoc SQLite behavior; gap documented. |
| Sonar scanner missing. | Gap documented in STATE and SUMMARY. |
## Next Phase Readiness
**Ready:**
- v3.8 Erli marketplace integration is complete in code through cross-surface parity.
- New shared source registry is available for future order-source code.
**Concerns:**
- Live Erli smoke and migrations remain operator follow-up from prior phases.
- Full PHPUnit/Sonar verification still depends on local tooling setup.
**Blockers:**
- None for PAUL loop closure.
---
*Phase: 133-erli-cross-surface-parity, Plan: 01*
*Completed: 2026-05-16*

View File

@@ -34,7 +34,7 @@ HTTP Request
|--------|-------|-------------|---------|
| **Auth** | 3 | `AuthController`, `AuthMiddleware`, `AuthService` | Login/logout, session |
| **Users** | 2 | `UserController`, `UserRepository` | User CRUD |
| **Orders** | 3 | `OrdersController` (1187 LOC), `OrdersRepository` (1221 LOC) | Order list, detail, status, payment, correlated subquery for return-risk |
| **Orders** | 4 | `OrdersController` (1187 LOC), `OrdersRepository` (1221 LOC), `OrderSourceRegistry` | Order list, detail, status, payment, shared order source labels/options, correlated subquery for return-risk |
| **Shipments** | 17 | `ShipmentController`, provider services + tracking services | Shipment creation, label download, tracking polling |
| **Accounting** | 5 | `AccountingController`, `ReceiptService`, `ReceiptRepository` | Receipts, invoices, PDF, Excel export |
| **Email** | 3 | `EmailSendingService`, `VariableResolver`, `AttachmentGenerator` | Template-based email with PDF attachments |
@@ -47,6 +47,15 @@ HTTP Request
| **Statistics** | 3 | `OrdersStatisticsController`, `OrdersStatisticsRepository`, `statistics-summary-charts.js` | Daily order statistics and monthly summary charts |
| **Info** | 1 | `InfoController` | Health check |
## Order Source Registry
- `OrderSourceRegistry` (`src/Modules/Orders/OrderSourceRegistry.php`) is the shared contract for order marketplace sources.
- It exposes stable source keys and labels for `allegro`, `shoppro`, and `erli`.
- `OrdersRepository::sourceOptions()` starts from the registry so `/orders/list` shows Allegro, shopPRO, and Erli even before orders from every source exist locally.
- `OrdersController::sourceLabel()` uses the registry for badge/filter labels.
- `AutomationRepository::listOrderIntegrations()` uses the registry integration types so order automation conditions include active Erli integrations together with Allegro/shopPRO.
- `OrdersStatisticsRepository` uses the registry for marketplace source filtering. Statistics channels always include Allegro and Erli; shopPRO remains split per integration as `shoppro:{integration_id}`.
## Frontend Enhancement Modules
### Checkbox Multiselect (`public/assets/js/modules/checkbox-multiselect.js`)
@@ -77,6 +86,9 @@ HTTP Request
3. **Status sync** — Cron → `AllegroStatusSyncService` / `ShopproStatusSyncService` / `ErliStatusSyncService` → marketplace API
### Statistics Summary
Phase 133 keeps Erli in the shared statistics channel contract: `listChannelOptions()` always exposes `erli`, and daily/monthly aggregation filters marketplace orders through `OrderSourceRegistry::marketplaceSources()`.
1. **Request**`/statistics/summary``OrdersStatisticsController::summary()`
2. **Filters** — controller reuses statistics filter semantics: date range, `channels[]`, `status_groups[]`, default status groups excluding cancelled; default history starts at `2026-04-01`.
3. **Aggregation**`OrdersStatisticsRepository::aggregateByMonth()` groups existing `orders` rows by `YYYY-MM` and channel key, using the same effective date/channel/status/gross amount SQL helpers as the daily report.

View File

@@ -497,7 +497,7 @@ UNIQUE: `(provider, raw_status)`
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `type` | VARCHAR(32) | NO | e.g., 'allegro', 'shoppro', 'apaczka', 'inpost' |
| `type` | VARCHAR(32) | NO | e.g., 'allegro', 'shoppro', 'erli', 'apaczka', 'inpost' |
| `name` | VARCHAR(128) | NO | |
| `base_url` | VARCHAR(255) | NO | |
| `api_key_encrypted` | TEXT | YES | AES-encrypted |
@@ -1073,7 +1073,7 @@ Default keys: `cron_run_on_web`, `cron_web_limit`, `gs1_api_login`, `gs1_prefix`
**Statistics Summary (`/statistics/summary`)** — no dedicated reporting tables.
- Reads existing `orders` rows and groups by month using the same effective order date used by `/statistics/orders`.
- Default summary history starts at April 2026 (`2026-04-01`), even if older rows exist.
- Splits series by channel key: Allegro as one series and each shopPRO integration by `orders.integration_id`.
- Splits series by channel key: Allegro as one series, Erli as one series, and each shopPRO integration by `orders.integration_id`.
- Uses `integrations.name` only for display labels when available.
- Filters by selected status groups through `order_status_groups` and `order_statuses`.
- Uses existing gross amount columns via `OrdersStatisticsRepository::grossAmountSql()`.

View File

@@ -1,5 +1,22 @@
# Technical Changelog
## 2026-05-16 - Phase 133 Plan 01: Erli Cross-Surface Parity
**Co zrobiono:**
- Dodano `OrderSourceRegistry` jako wspolne zrodlo kluczy i etykiet `allegro`, `shoppro`, `erli`.
- Lista zamowien startuje od stalych opcji Allegro/shopPRO/Erli i dopiero potem dopina dodatkowe zrodla znalezione w bazie.
- Statystyki dzienne i podsumowanie zawsze pokazuja kanal Erli, licza `orders.source='erli'` i uzywaja wspolnego filtra zrodel marketplace.
- Automatyzacje pobieraja aktywne integracje zamowieniowe przez wspolny rejestr, wiec Erli jest dostepne w warunku integracji.
- Menu Integracje pozostaje aktywne na ekranach Erli.
- Opisy zmiennej `zrodlo` w szablonach e-mail/SMS oraz dokumentacja raportowania wskazuja Erli obok Allegro/shopPRO.
- Dodano testy jednostkowe rejestru zrodel i agregacji statystyk dla Erli.
**Dlaczego:**
- Phase 127-132 dodaly integracje Erli, ale czesc wspolnych powierzchni nadal miala lokalne listy Allegro/shopPRO. Jeden rejestr zmniejsza ryzyko kolejnych pominiec Erli.
**BREAKING / migracja:**
- Brak migracji i brak breaking changes. Zmiana korzysta z istniejacych wartosci `orders.source`, `orders.integration_id` oraz `integrations.type`.
## 2026-05-16 - Phase 132 Plan 01: Erli Hardening, Observability + Docs
**Co zrobiono:**

View File

@@ -109,7 +109,7 @@
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'users' ? ' is-active' : '' ?>" href="/settings/users">
<?= $e($t('navigation.users')) ?>
</a>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && in_array($currentSettings, ['integrations', 'allegro', 'apaczka', 'inpost', 'shoppro'], true) ? ' is-active' : '' ?>" href="/settings/integrations">
<a class="sidebar__sublink<?= $currentMenu === 'settings' && in_array($currentSettings, ['integrations', 'allegro', 'apaczka', 'inpost', 'shoppro', 'erli'], true) ? ' is-active' : '' ?>" href="/settings/integrations">
<?= $e($t('navigation.integrations')) ?>
</a>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'statuses' ? ' is-active' : '' ?>" href="/settings/statuses">

View File

@@ -412,7 +412,7 @@ final class InvoiceService
}
/**
* Extract NIP from various payload locations (Allegro, shopPRO).
* Extract NIP from various payload locations (Allegro, shopPRO, Erli).
*
* @param array<string, mixed> $order
* @param array<string, mixed>|null $buyerAddress

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Modules\Automation;
use App\Core\Http\ToggleableRepositoryTrait;
use App\Modules\Orders\OrderSourceRegistry;
use PDO;
use Throwable;
@@ -187,15 +188,37 @@ final class AutomationRepository
*/
public function listOrderIntegrations(): array
{
[$typeInSql, $params] = $this->buildStringInClause('type', OrderSourceRegistry::orderIntegrationTypes());
$statement = $this->pdo->prepare(
"SELECT id, type, name FROM integrations WHERE type IN ('allegro', 'shoppro') AND is_active = 1 ORDER BY type, name"
'SELECT id, type, name
FROM integrations
WHERE type IN (' . $typeInSql . ')
AND is_active = 1
ORDER BY type, name'
);
$statement->execute();
$statement->execute($params);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @param array<int, string> $values
* @return array{0:string,1:array<string,string>}
*/
private function buildStringInClause(string $prefix, array $values): array
{
$placeholders = [];
$params = [];
foreach (array_values($values) as $index => $value) {
$key = $prefix . '_' . $index;
$placeholders[] = ':' . $key;
$params[$key] = $value;
}
return [implode(', ', $placeholders), $params];
}
/**
* @return list<array{id: int, name: string}>
*/

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Modules\Orders;
final class OrderSourceRegistry
{
public const SOURCE_ALLEGRO = 'allegro';
public const SOURCE_SHOPPRO = 'shoppro';
public const SOURCE_ERLI = 'erli';
/** @var array<string, string> */
private const LABELS = [
self::SOURCE_ALLEGRO => 'Allegro',
self::SOURCE_SHOPPRO => 'shopPRO',
self::SOURCE_ERLI => 'Erli',
];
/**
* @return array<string, string>
*/
public static function sourceOptions(): array
{
return self::LABELS;
}
/**
* @return array<int, string>
*/
public static function marketplaceSources(): array
{
return array_keys(self::LABELS);
}
/**
* @return array<int, string>
*/
public static function orderIntegrationTypes(): array
{
return self::marketplaceSources();
}
public static function label(string $source): string
{
$normalized = self::normalize($source);
if ($normalized === '') {
return '';
}
return self::LABELS[$normalized] ?? ucfirst($normalized);
}
public static function normalize(string $source): string
{
return strtolower(trim($source));
}
}

View File

@@ -886,12 +886,7 @@ final class OrdersController
private function sourceLabel(string $source): string
{
return match (strtolower(trim($source))) {
'allegro' => 'Allegro',
'shoppro' => 'shopPRO',
'erli' => 'Erli',
default => ucfirst(strtolower(trim($source))),
};
return OrderSourceRegistry::label($source);
}
private function statusLabel(string $statusCode, array $statusLabelMap = []): string

View File

@@ -294,20 +294,22 @@ final class OrdersRepository
try {
$rows = $this->pdo->query('SELECT DISTINCT source FROM orders WHERE source IS NOT NULL AND source <> "" ORDER BY source ASC')->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable) {
return [];
return OrderSourceRegistry::sourceOptions();
}
if (!is_array($rows)) {
return [];
return OrderSourceRegistry::sourceOptions();
}
$options = [];
$options = OrderSourceRegistry::sourceOptions();
foreach ($rows as $row) {
$value = trim((string) $row);
$value = OrderSourceRegistry::normalize((string) $row);
if ($value === '') {
continue;
}
$options[$value] = $value;
if (!isset($options[$value])) {
$options[$value] = OrderSourceRegistry::label($value);
}
}
return $options;

View File

@@ -21,7 +21,7 @@ final class EmailTemplateController
'vars' => [
'numer' => 'Numer wewnetrzny (OP...)',
'numer_zewnetrzny' => 'Numer z platformy',
'zrodlo' => 'Zrodlo (Allegro/shopPRO/...)',
'zrodlo' => 'Zrodlo (Allegro/shopPRO/Erli)',
'kwota' => 'Kwota brutto',
'waluta' => 'Waluta (PLN/EUR/...)',
'data' => 'Data zamowienia',

View File

@@ -22,7 +22,7 @@ final class SmsTemplateController
'vars' => [
'numer' => 'Numer wewnetrzny (OP...)',
'numer_zewnetrzny' => 'Numer z platformy',
'zrodlo' => 'Zrodlo (Allegro/shopPRO/...)',
'zrodlo' => 'Zrodlo (Allegro/shopPRO/Erli)',
'kwota' => 'Kwota brutto',
'waluta' => 'Waluta (PLN/EUR/...)',
'data' => 'Data zamowienia',

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Statistics;
use App\Modules\Orders\OrderSourceRegistry;
use PDO;
use Throwable;
@@ -87,10 +88,17 @@ final class OrdersStatisticsRepository
}
$channels = [
['key' => 'allegro', 'label' => 'Allegro'],
[
'key' => OrderSourceRegistry::SOURCE_ALLEGRO,
'label' => OrderSourceRegistry::label(OrderSourceRegistry::SOURCE_ALLEGRO),
],
];
if (!is_array($rows)) {
$channels[] = [
'key' => OrderSourceRegistry::SOURCE_ERLI,
'label' => OrderSourceRegistry::label(OrderSourceRegistry::SOURCE_ERLI),
];
return $channels;
}
@@ -102,6 +110,11 @@ final class OrdersStatisticsRepository
];
}
$channels[] = [
'key' => OrderSourceRegistry::SOURCE_ERLI,
'label' => OrderSourceRegistry::label(OrderSourceRegistry::SOURCE_ERLI),
];
return $channels;
}
@@ -174,11 +187,16 @@ final class OrdersStatisticsRepository
$grossAmountSql = $this->grossAmountSql('o');
$rawStatusSql = $this->rawStatusSql('o');
['sql' => $sourceFilterSql, 'params' => $sourceParams] = $this->sourceFilterSql('o', 'src');
[$channelInSql, $channelParams] = $this->buildStringInClause('ch', $channels);
$params = array_merge($channelParams, [
$params = array_merge(
$sourceParams,
$channelParams,
[
'date_from' => $dateFrom . ' 00:00:00',
'date_to' => $dateTo . ' 23:59:59',
]);
]
);
$statusFilterSql = '';
if ($statusCodes !== []) {
@@ -197,7 +215,7 @@ final class OrdersStatisticsRepository
LEFT JOIN allegro_order_status_mappings asm
ON o.source = "allegro"
AND LOWER(' . $rawStatusSql . ') = asm.allegro_status_code
WHERE LOWER(COALESCE(o.source, "")) IN ("allegro", "shoppro")
WHERE ' . $sourceFilterSql . '
AND ' . $effectiveDateSql . ' IS NOT NULL
AND ' . $effectiveDateSql . ' >= :date_from
AND ' . $effectiveDateSql . ' <= :date_to
@@ -264,11 +282,16 @@ final class OrdersStatisticsRepository
$grossAmountSql = $this->grossAmountSql('o');
$rawStatusSql = $this->rawStatusSql('o');
['sql' => $sourceFilterSql, 'params' => $sourceParams] = $this->sourceFilterSql('o', 'month_src');
[$channelInSql, $channelParams] = $this->buildStringInClause('ch', $channels);
$params = array_merge($channelParams, [
$params = array_merge(
$sourceParams,
$channelParams,
[
'date_from' => $dateFrom . ' 00:00:00',
'date_to' => $dateTo . ' 23:59:59',
]);
]
);
$statusFilterSql = '';
if ($statusCodes !== []) {
@@ -277,7 +300,7 @@ final class OrdersStatisticsRepository
$params = array_merge($params, $statusParams);
}
$monthSql = 'DATE_FORMAT(' . $effectiveDateSql . ', "%Y-%m")';
$monthSql = $this->monthSql($effectiveDateSql);
$sql = 'SELECT
' . $monthSql . ' AS month,
' . $channelSql . ' AS channel_key,
@@ -287,7 +310,7 @@ final class OrdersStatisticsRepository
LEFT JOIN allegro_order_status_mappings asm
ON o.source = "allegro"
AND LOWER(' . $rawStatusSql . ') = asm.allegro_status_code
WHERE LOWER(COALESCE(o.source, "")) IN ("allegro", "shoppro")
WHERE ' . $sourceFilterSql . '
AND ' . $effectiveDateSql . ' IS NOT NULL
AND ' . $effectiveDateSql . ' >= :date_from
AND ' . $effectiveDateSql . ' <= :date_to
@@ -338,6 +361,7 @@ final class OrdersStatisticsRepository
$channelSql = $this->channelSql('o');
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$rawStatusSql = $this->rawStatusSql('o');
['sql' => $sourceFilterSql, 'params' => $sourceParams] = $this->sourceFilterSql('o', 'dbg_src');
$data = [
'columns' => [
@@ -356,14 +380,14 @@ final class OrdersStatisticsRepository
$data['counts']['in_date_and_source'] = $this->safeCount(
'SELECT COUNT(*)
FROM orders o
WHERE LOWER(COALESCE(o.source, "")) IN ("allegro", "shoppro")
WHERE ' . $sourceFilterSql . '
AND ' . $effectiveDateSql . ' IS NOT NULL
AND ' . $effectiveDateSql . ' >= :date_from
AND ' . $effectiveDateSql . ' <= :date_to',
[
array_merge($sourceParams, [
'date_from' => $dateFrom . ' 00:00:00',
'date_to' => $dateTo . ' 23:59:59',
],
]),
$data['errors'],
'in_date_and_source'
);
@@ -373,12 +397,13 @@ final class OrdersStatisticsRepository
$data['counts']['after_channel_filter'] = $this->safeCount(
'SELECT COUNT(*)
FROM orders o
WHERE LOWER(COALESCE(o.source, "")) IN ("allegro", "shoppro")
WHERE ' . $sourceFilterSql . '
AND ' . $effectiveDateSql . ' IS NOT NULL
AND ' . $effectiveDateSql . ' >= :date_from
AND ' . $effectiveDateSql . ' <= :date_to
AND ' . $channelSql . ' IN (' . $channelInSql . ')',
array_merge(
$sourceParams,
[
'date_from' => $dateFrom . ' 00:00:00',
'date_to' => $dateTo . ' 23:59:59',
@@ -400,12 +425,13 @@ final class OrdersStatisticsRepository
LEFT JOIN allegro_order_status_mappings asm
ON o.source = "allegro"
AND LOWER(' . $rawStatusSql . ') = asm.allegro_status_code
WHERE LOWER(COALESCE(o.source, "")) IN ("allegro", "shoppro")
WHERE ' . $sourceFilterSql . '
AND ' . $effectiveDateSql . ' IS NOT NULL
AND ' . $effectiveDateSql . ' >= :date_from
AND ' . $effectiveDateSql . ' <= :date_to
AND ' . $effectiveStatusSql . ' IN (' . $statusInSql . ')',
array_merge(
$sourceParams,
[
'date_from' => $dateFrom . ' 00:00:00',
'date_to' => $dateTo . ' 23:59:59',
@@ -439,6 +465,22 @@ final class OrdersStatisticsRepository
return [implode(', ', $placeholders), $params];
}
/**
* @return array{sql:string,params:array<string,string>}
*/
private function sourceFilterSql(string $orderAlias, string $prefix): array
{
[$sourceInSql, $sourceParams] = $this->buildStringInClause(
$prefix,
OrderSourceRegistry::marketplaceSources()
);
return [
'sql' => 'LOWER(COALESCE(' . $orderAlias . '.source, "")) IN (' . $sourceInSql . ')',
'params' => $sourceParams,
];
}
/**
* @param array<int, string> $values
* @return array{0:string,1:array<string,string>}
@@ -497,21 +539,47 @@ final class OrdersStatisticsRepository
)';
}
private function monthSql(string $dateSql): string
{
if ($this->isSqlite()) {
return 'strftime(\'%Y-%m\', ' . $dateSql . ')';
}
return 'DATE_FORMAT(' . $dateSql . ', "%Y-%m")';
}
private function channelSql(string $orderAlias): string
{
$collationSql = $this->channelCollationSql();
if ($this->hasOrdersColumn('integration_id')) {
return '(CASE
WHEN LOWER(COALESCE(' . $orderAlias . '.source, "")) = "allegro" THEN "allegro"
WHEN LOWER(COALESCE(' . $orderAlias . '.source, "")) = "shoppro" THEN CONCAT("shoppro:", COALESCE(CAST(' . $orderAlias . '.integration_id AS CHAR) COLLATE utf8mb4_unicode_ci, "0"))
WHEN LOWER(COALESCE(' . $orderAlias . '.source, "")) = "erli" THEN "erli"
WHEN LOWER(COALESCE(' . $orderAlias . '.source, "")) = "shoppro" THEN ' . $this->shopproChannelSql($orderAlias) . '
ELSE LOWER(COALESCE(' . $orderAlias . '.source, ""))
END) COLLATE utf8mb4_unicode_ci';
END)' . $collationSql;
}
return '(CASE
WHEN LOWER(COALESCE(' . $orderAlias . '.source, "")) = "allegro" THEN "allegro"
WHEN LOWER(COALESCE(' . $orderAlias . '.source, "")) = "erli" THEN "erli"
WHEN LOWER(COALESCE(' . $orderAlias . '.source, "")) = "shoppro" THEN "shoppro:0"
ELSE LOWER(COALESCE(' . $orderAlias . '.source, ""))
END) COLLATE utf8mb4_unicode_ci';
END)' . $collationSql;
}
private function shopproChannelSql(string $orderAlias): string
{
if ($this->isSqlite()) {
return '(\'shoppro:\' || COALESCE(CAST(' . $orderAlias . '.integration_id AS TEXT), \'0\'))';
}
return 'CONCAT("shoppro:", COALESCE(CAST(' . $orderAlias . '.integration_id AS CHAR) COLLATE utf8mb4_unicode_ci, "0"))';
}
private function channelCollationSql(): string
{
return $this->isSqlite() ? '' : ' COLLATE utf8mb4_unicode_ci';
}
private function shopproChannelLabel(int $integrationId): string
@@ -636,18 +704,7 @@ final class OrdersStatisticsRepository
}
try {
$stmt = $this->pdo->prepare(
'SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table_name
AND COLUMN_NAME = :column_name'
);
$stmt->execute([
'table_name' => 'orders',
'column_name' => $column,
]);
$exists = ((int) $stmt->fetchColumn()) > 0;
$exists = $this->detectOrdersColumn($column);
} catch (Throwable) {
$exists = false;
}
@@ -676,4 +733,42 @@ final class OrdersStatisticsRepository
return $exists;
}
private function detectOrdersColumn(string $column): bool
{
if ($this->isSqlite()) {
$stmt = $this->pdo->query('PRAGMA table_info(orders)');
$rows = $stmt !== false ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
if (!is_array($rows)) {
return false;
}
foreach ($rows as $row) {
if ((string) ($row['name'] ?? '') === $column) {
return true;
}
}
return false;
}
$stmt = $this->pdo->prepare(
'SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table_name
AND COLUMN_NAME = :column_name'
);
$stmt->execute([
'table_name' => 'orders',
'column_name' => $column,
]);
return ((int) $stmt->fetchColumn()) > 0;
}
private function isSqlite(): bool
{
return (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Modules\Orders\OrderSourceRegistry;
use PHPUnit\Framework\TestCase;
final class OrderSourceRegistryTest extends TestCase
{
public function testLabelsUseStableMarketplaceNames(): void
{
self::assertSame('Allegro', OrderSourceRegistry::label('allegro'));
self::assertSame('shopPRO', OrderSourceRegistry::label('shoppro'));
self::assertSame('Erli', OrderSourceRegistry::label('erli'));
self::assertSame('Custom', OrderSourceRegistry::label(' custom '));
}
public function testSourceOptionsAlwaysContainErli(): void
{
self::assertSame([
'allegro' => 'Allegro',
'shoppro' => 'shopPRO',
'erli' => 'Erli',
], OrderSourceRegistry::sourceOptions());
}
public function testOrderIntegrationTypesIncludeAllOrderSources(): void
{
self::assertSame(
['allegro', 'shoppro', 'erli'],
OrderSourceRegistry::orderIntegrationTypes()
);
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Modules\Statistics\OrdersStatisticsRepository;
use PDO;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
final class OrdersStatisticsRepositoryTest extends TestCase
{
private PDO $pdo;
private OrdersStatisticsRepository $repository;
protected function setUp(): void
{
$this->resetColumnCache();
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->createSchema();
$this->repository = new OrdersStatisticsRepository($this->pdo);
}
public function testListChannelOptionsAlwaysContainsErliWithoutErliOrders(): void
{
$this->seedIntegration(10, 'shoppro', 'Sklep PL');
$this->seedOrder('shoppro', 10, '2026-05-01 10:00:00', 100.0);
$labelsByKey = [];
foreach ($this->repository->listChannelOptions() as $option) {
$labelsByKey[(string) $option['key']] = (string) $option['label'];
}
self::assertSame('Allegro', $labelsByKey['allegro'] ?? null);
self::assertSame('Sklep PL', $labelsByKey['shoppro:10'] ?? null);
self::assertSame('Erli', $labelsByKey['erli'] ?? null);
}
public function testAggregateByDayCountsErliOrders(): void
{
$this->seedIntegration(20, 'erli', 'Erli');
$this->seedOrder('erli', 20, '2026-05-02 12:30:00', 123.45);
$rows = $this->repository->aggregateByDay('2026-05-01', '2026-05-03', ['erli'], []);
self::assertCount(1, $rows);
self::assertSame('2026-05-02', $rows[0]['day']);
self::assertSame('erli', $rows[0]['channel_key']);
self::assertSame(1, $rows[0]['orders_count']);
self::assertSame(123.45, $rows[0]['total_gross']);
}
public function testAggregateByMonthCountsErliOrders(): void
{
$this->seedIntegration(20, 'erli', 'Erli');
$this->seedOrder('erli', 20, '2026-05-02 12:30:00', 123.45);
$this->seedOrder('erli', 20, '2026-05-10 09:00:00', 76.55);
$rows = $this->repository->aggregateByMonth('2026-05-01', '2026-05-31', ['erli'], []);
self::assertCount(1, $rows);
self::assertSame('2026-05', $rows[0]['month']);
self::assertSame('erli', $rows[0]['channel_key']);
self::assertSame(2, $rows[0]['orders_count']);
self::assertSame(200.0, $rows[0]['total_gross']);
}
private function createSchema(): void
{
$this->pdo->exec(
'CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT,
integration_id INTEGER,
ordered_at TEXT,
source_created_at TEXT,
source_updated_at TEXT,
fetched_at TEXT,
status_code TEXT,
total_with_tax REAL
)'
);
$this->pdo->exec(
'CREATE TABLE integrations (
id INTEGER PRIMARY KEY,
type TEXT,
name TEXT
)'
);
$this->pdo->exec(
'CREATE TABLE allegro_order_status_mappings (
allegro_status_code TEXT,
orderpro_status_code TEXT
)'
);
}
private function seedIntegration(int $id, string $type, string $name): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO integrations (id, type, name) VALUES (:id, :type, :name)'
);
$stmt->execute([
'id' => $id,
'type' => $type,
'name' => $name,
]);
}
private function seedOrder(string $source, int $integrationId, string $orderedAt, float $gross): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO orders (source, integration_id, ordered_at, fetched_at, status_code, total_with_tax)
VALUES (:source, :integration_id, :ordered_at, :fetched_at, :status_code, :total_with_tax)'
);
$stmt->execute([
'source' => $source,
'integration_id' => $integrationId,
'ordered_at' => $orderedAt,
'fetched_at' => $orderedAt,
'status_code' => 'new',
'total_with_tax' => $gross,
]);
}
private function resetColumnCache(): void
{
foreach ([
'hasOrdersTotalWithoutTax',
'hasOrdersTotalNet',
'hasOrdersTotalWithTax',
'hasOrdersTotalGross',
'hasOrdersIntegrationId',
'hasOrdersStatusCode',
'hasOrdersExternalStatusId',
] as $propertyName) {
$property = new ReflectionProperty(OrdersStatisticsRepository::class, $propertyName);
$property->setAccessible(true);
$property->setValue(null, null);
}
}
}