This commit is contained in:
2026-05-19 23:23:18 +02:00
parent 38093404e2
commit e12ebe3a6f
22 changed files with 2160 additions and 1039 deletions

View File

@@ -4,16 +4,30 @@
**Ostatnia aktualizacja:** 2026-05-19
## Aktywna praca
Brak aktywnego PLAN.md. Ostatnio zakonczony: `.paul/plans/20260519-1200-refactor-routes-web/` (SUMMARY.md).
Routing modularny + lazy DI wdrozone (routes/web.php: 859 -> 78 lin., 24 nowe `<Modul>Module.php`).
UNIFY zakonczony dla `.paul/plans/20260519-1600-refactor-allegro-integration-controller/`. Petla zamknieta, brak aktywnego planu.
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Loop complete — gotowe na nastepny PLAN]
```
Rezultat: `AllegroIntegrationController` 653 -> 223 lin. (66% redukcji). Wydzielono `AllegroIntegrationViewModel` (61), `AllegroOAuthFlowService` (94), `AllegroImportScheduleService` (179), `AllegroImportImageWarningFormatter` (69), `AllegroSaveSettingsValidator` (48). Deviation: kontroler 223 vs cel < 200 (akceptowalne, 66% redukcji).
Zamkniete SUMMARY (chronologicznie 2026-05-19):
- `.paul/plans/20260519-1200-refactor-routes-web/SUMMARY.md` (routes/web.php: 859 -> 78).
- `.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md` (Statistics: 640 -> 110).
- `.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md` (Allegro: 653 -> 223).
## Kontekst sesji
- Galaz: `main` (czysta).
- Ostatnie commity: `cff0635 UPDATE`, `9ea26ad update`, `d30a459 fix(145) polkurier cod return codes`.
- Galaz: `main`.
- Niezakomitowane: dekompozycja Statistics + Allegro (8 nowych plikow modulu + 4 zmodyfikowane + dokumenty PAUL + STATE.md + governance log + changelog).
- Ostatnie commity: `3809340 update`, `e77b0f1 refactor(routing): module providers + lazy ServiceRegistry`, `2df4638 update`.
## Sugerowana nastepna akcja
1. `$paul-map-codebase` — pelny skan repozytorium i odswiezenie raportow Quality Radar.
2. `$paul-plan [opis pracy]` — jezeli temat jest juz znany, przejdz od razu do planowania.
1. Smoke-test reczny zgodnie z sekcja "Smoke-test" w obu SUMMARY (Statistics + Allegro) przed commitem.
2. Commit: refaktor Statistics + Allegro (8 nowych plikow + 4 zmodyfikowane + docs PAUL + plany).
3. Kolejny kandydat z `quality_risks.md`: `OrdersController.php` (1490 lin.), `OrdersRepository.php` (1243 lin.), `ShopproIntegrationsController.php` (1076 lin.), albo bazowy `BaseIntegrationController` dla 9 integracji.
4. `$paul-plan` aby uruchomic nowa petle.
## Legacy
Pliki `ROADMAP.md` / `MILESTONES.md` sa opcjonalne i obecnie nie sa wymagane.

View File

@@ -0,0 +1,41 @@
# 2026-05-19
## Co zrobiono
- [Plan 20260519-1430-refactor-orders-statistics-controller] Dekompozycja `OrdersStatisticsController` (640 -> 110 lin.) — wydzielenie filtrow i builderow.
- Task 1: utworzony `OrdersStatisticsFilters` (258 lin.) — bezstanowa walidacja i parsowanie `Request` (date range, status_groups, channels).
- Task 2: utworzony `OrdersStatisticsTableBuilder` (101 lin.) — budowa siatki dziennej (rows/totals/hasData).
- Task 3: utworzony `OrdersStatisticsSummaryBuilder` (195 lin.) — podsumowanie miesieczne + serie wykresow.
- Task 4: slim `OrdersStatisticsController` + `StatisticsModule` rejestruje 3 nowe klucze (`statistics.filters`, `statistics.table_builder`, `statistics.summary_builder`) w `ServiceRegistry` (lazy).
- Task 5: dokumentacja PAUL zaktualizowana (`architecture.md`, `quality_risks.md`, `tech_changelog.md`).
- UNIFY: SUMMARY.md utworzony, STATE.md odswiezony, petla zamknieta.
- [Plan 20260519-1600-refactor-allegro-integration-controller] Dekompozycja `AllegroIntegrationController` (653 -> 223 lin., 66% redukcji).
- Task 1: utworzony `AllegroImportImageWarningFormatter` (69 lin.).
- Task 2: utworzony `AllegroImportScheduleService` (179 lin.) — operacje cron + walidacja import settings + `applyImportSettings`.
- Task 3: utworzony `AllegroOAuthFlowService` (94 lin.) — `buildAuthorizeUrl` + `exchangeAuthorizationCode` + zarzadzanie state.
- Task 4: utworzony `AllegroIntegrationViewModel` (61 lin.) — payload widoku `settings/allegro`.
- Task 5: slim kontroler (223 lin.) + dodatkowo `AllegroSaveSettingsValidator` (48 lin., poza pierwotnym planem); `AllegroIntegrationModule` rejestruje 5 nowych kluczy w `ServiceRegistry`.
- Task 6: dokumentacja PAUL zaktualizowana (`architecture.md`, `quality_risks.md`, `tech_changelog.md`).
## Zmienione pliki
- `src/Modules/Statistics/OrdersStatisticsController.php`
- `src/Modules/Statistics/OrdersStatisticsFilters.php`
- `src/Modules/Statistics/OrdersStatisticsTableBuilder.php`
- `src/Modules/Statistics/OrdersStatisticsSummaryBuilder.php`
- `src/Modules/Statistics/StatisticsModule.php`
- `src/Modules/Settings/AllegroIntegrationController.php`
- `src/Modules/Settings/AllegroIntegrationViewModel.php`
- `src/Modules/Settings/AllegroOAuthFlowService.php`
- `src/Modules/Settings/AllegroImportScheduleService.php`
- `src/Modules/Settings/AllegroImportImageWarningFormatter.php`
- `src/Modules/Settings/AllegroSaveSettingsValidator.php`
- `src/Modules/Settings/AllegroIntegrationModule.php`
- `.paul/plans/20260519-1600-refactor-allegro-integration-controller/PLAN.md`
- `.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md`
- `.paul/codebase/architecture.md`
- `.paul/codebase/quality_risks.md`
- `.paul/codebase/tech_changelog.md`
- `.paul/STATE.md`
- `.paul/plans/20260519-1430-refactor-orders-statistics-controller/PLAN.md`
- `.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md`

View File

@@ -64,7 +64,8 @@ Korzysci wzgledem poprzedniego monolitycznego `routes/web.php` (859 lin.):
| Auth | `src/Modules/Auth/` | logowanie, sesje, middleware, remember-token |
| Users | `src/Modules/Users/` | CRUD uzytkownikow, role |
| Orders | `src/Modules/Orders/` | zamowienia, notatki, import, statusy |
| Statistics | `src/Modules/Statistics/` | raporty zamowien |
| Statistics | `src/Modules/Statistics/` | raporty zamowien (slim Controller + `OrdersStatisticsFilters` + `OrdersStatisticsTableBuilder` + `OrdersStatisticsSummaryBuilder` + Repository) |
| Settings/Allegro | `src/Modules/Settings/Allegro*.php` | integracja Allegro: slim `AllegroIntegrationController` + `AllegroIntegrationViewModel` + `AllegroOAuthFlowService` + `AllegroImportScheduleService` + `AllegroImportImageWarningFormatter` + `AllegroSaveSettingsValidator` + `AllegroOrderImportService` + `AllegroStatusDiscoveryService` + `AllegroStatusMappingController` + `AllegroDeliveryMappingController` + repozytoria (`AllegroIntegrationRepository`, `AllegroStatusMappingRepository`, `AllegroPullStatusMappingRepository`) + klienci (`AllegroApiClient`, `AllegroOAuthClient`, `AllegroTokenManager`). |
| Accounting | `src/Modules/Accounting/` | paragony, faktury, eksport ksiegowy |
| Shipments | `src/Modules/Shipments/` | etykiety, tracking, presets paczek |
| Printing | `src/Modules/Printing/` | API drukowania etykiet (klient Windows) |

View File

@@ -22,8 +22,8 @@ Konwencja (`CLAUDE.md`): funkcja/klasa zwykle do 30-50 linii, max 3 poziomy zagn
| `src/Modules/Accounting/InvoiceService.php` | 762 | |
| `src/Modules/Automation/AutomationController.php` | 677 | |
| `src/Modules/Shipments/DeliveryStatus.php` | 657 | encja statusow uzywana globalnie — zmiany dotykaja wszystkich integracji. |
| `src/Modules/Settings/AllegroIntegrationController.php` | 653 | |
| `src/Modules/Statistics/OrdersStatisticsController.php` | 640 | |
| ~~`src/Modules/Settings/AllegroIntegrationController.php`~~ | ~~653~~ -> 223 | ✅ Zrefaktorowane 2026-05-19 — wydzielono `AllegroIntegrationViewModel` (61), `AllegroOAuthFlowService` (94), `AllegroImportScheduleService` (179), `AllegroImportImageWarningFormatter` (69), `AllegroSaveSettingsValidator` (48). Patrz `.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md`. |
| ~~`src/Modules/Statistics/OrdersStatisticsController.php`~~ | ~~640~~ -> 110 | ✅ Zrefaktorowane 2026-05-19 — wydzielono `OrdersStatisticsFilters` (258), `OrdersStatisticsTableBuilder` (101), `OrdersStatisticsSummaryBuilder` (195). Patrz `.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md`. |
| ~~`routes/web.php`~~ | ~~859~~ -> 78 | ✅ Zrefaktorowane 2026-05-19 — `ServiceRegistry` + 24 klasy `<Modul>Module.php`. Patrz `.paul/plans/20260519-1200-refactor-routes-web/SUMMARY.md`. |
## Luki testowe (krytyczne)

View File

@@ -2,6 +2,48 @@
Chronologiczny log zmian technicznych (co i dlaczego). Najnowsze na gorze.
## 2026-05-19 — Dekompozycja AllegroIntegrationController
### Co
- Rozbicie `src/Modules/Settings/AllegroIntegrationController.php` z 653 do 223 lin.
- Wydzielono piec klas w namespace `App\Modules\Settings`:
- `AllegroIntegrationViewModel` (61 lin.) — sklada payload widoku `settings/allegro` (settings + mappings + delivery services + schedule getters).
- `AllegroOAuthFlowService` (94 lin.) — flow OAuth (`buildAuthorizeUrl`, `exchangeAuthorizationCode`, walidacja `state`, zapis tokenow); hermetyzuje `OAUTH_STATE_SESSION_KEY` i `OAUTH_SCOPES`.
- `AllegroImportScheduleService` (179 lin.) — harmonogramy cron (`allegro_orders_import`, `allegro_status_sync`), `current*` gettery, `ensureDefaults`, walidacja `applyImportSettings`.
- `AllegroImportImageWarningFormatter` (69 lin.) — formatowanie warningu obrazka po `importSingleOrder`.
- `AllegroSaveSettingsValidator` (48 lin.) — walidacja inputu `save` (env / client_id / redirect_uri / orders_fetch_start_date).
- `AllegroIntegrationModule::register` rejestruje 5 nowych kluczy (`integrations.allegro.view_model`, `.oauth_flow`, `.import_schedule_service`, `.image_warning_formatter`, `.save_settings_validator`) i przekazuje je do kontrolera. Wszystko leniwe.
### Dlaczego
- `quality_risks.md` wskazywal `AllegroIntegrationController` (653 lin.) jako kandydata do dekompozycji — naruszenie limitu z `CLAUDE.md`.
- Kontroler pelnil 4 odpowiedzialnosci (view-model index / OAuth flow / harmonogramy cron / formatowanie warningu importu) — naturalne granice do podzialu.
- Service'y staja sie testowalne jednostkowo (bezstanowe + jawne zaleznosci).
### Wplyw
- Brak zmian w kontrakcie HTTP (12 sciezek `/settings/integrations/allegro/*`).
- Brak zmian w `AllegroIntegrationRepository`, `AllegroOAuthClient`, `AllegroOrderImportService`, `AllegroStatusDiscoveryService`, mapperach statusow ani widoku `resources/views/settings/allegro.php`.
- Plan: `.paul/plans/20260519-1600-refactor-allegro-integration-controller/PLAN.md`.
## 2026-05-19 — Dekompozycja OrdersStatisticsController
### Co
- Rozbicie `src/Modules/Statistics/OrdersStatisticsController.php` z 640 do 110 lin.
- Wydzielono trzy nowe klasy w namespace `App\Modules\Statistics`:
- `OrdersStatisticsFilters` (258 lin.) — parsowanie i walidacja filtrow (daty, status_groups, channels) z `Request`.
- `OrdersStatisticsTableBuilder` (101 lin.) — budowa siatki dziennej (`buildTable` + dateRange + emptyChannelsRow).
- `OrdersStatisticsSummaryBuilder` (195 lin.) — budowa podsumowania miesiecznego (rows + series wykresow + monthRange).
- `StatisticsModule::register` rejestruje 3 nowe klucze (`statistics.filters`, `statistics.table_builder`, `statistics.summary_builder`) i wstrzykuje je do `OrdersStatisticsController`.
### Dlaczego
- `quality_risks.md` wskazywal `OrdersStatisticsController` (640 lin.) jako kandydata do dekompozycji — naruszenie limitu z `CLAUDE.md` (klasa/metoda do 30-50 lin., max 3 poziomy zagniezdzen).
- Kontroler pelnil 3 odpowiedzialnosci (filtry / tabela / podsumowanie z seriami wykresow) — naturalne granice do podzialu.
- Zwiekszona testowalnosc builderow (klasy bezstanowe bez zaleznosci od `Template`/`Request`/`Auth`).
### Wplyw
- Brak zmian w kontrakcie HTTP (`/statistics/orders`, `/statistics/summary`).
- Brak zmian w `OrdersStatisticsRepository` i widokach `resources/views/statistics/*`.
- Plan: `.paul/plans/20260519-1430-refactor-orders-statistics-controller/PLAN.md`.
## 2026-05-19 — Routing modularny + lazy DI
### Co

View File

@@ -237,3 +237,42 @@
{"ts":"2026-05-19T19:24:54Z","tool":"Bash","cmd":"git add src/Core/Routing src/Modules routes/web.php tests/Unit/Core bin/smoke_routes.php .paul/codebase/architecture.md .paul/codebase/quality_risks.md .paul/codebase/tech_changelog.md .paul/STATE.md ","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T19:25:08Z","tool":"Bash","cmd":"git commit -m \"$(cat <<'EOF'\\nrefactor(routing): module providers + lazy ServiceRegistry\\n\\nRozbicie routes/web.php (859 lin.) na 24 klasy <Modul>Module.php zgodnie\\nz quality_risks.md priorytet ","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T19:25:16Z","tool":"Bash","cmd":"git log -1 --stat | tail -5; git status --short 2>&1 | head -5\",\"description\":\"Verify commit\"},\"tool_response\":{\"stdout\":\" src/Modules/Sms/SmsModule.php | 58 ++\\n src/","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T20:41:15Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\plans\\\\20260519-1430-refactor-orders-statistics-controller\\\\PLAN.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T20:41:23Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T20:43:18Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Statistics\\\\OrdersStatisticsFilters.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T20:43:37Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Statistics\\\\OrdersStatisticsTableBuilder.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T20:44:05Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Statistics\\\\OrdersStatisticsSummaryBuilder.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T20:44:24Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Statistics\\\\OrdersStatisticsController.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T20:44:34Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Statistics\\\\StatisticsModule.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T20:44:55Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\quality_risks.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T20:45:00Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\architecture.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T20:45:26Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\tech_changelog.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T20:45:54Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\plans\\\\20260519-1430-refactor-orders-statistics-controller\\\\SUMMARY.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T20:46:02Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T20:59:10Z","tool":"Bash","cmd":"git diff --name-only HEAD; echo ---; git status -s\",\"description\":\"Lista zmienionych plików\"},\"tool_response\":{\"stdout\":\"warning: in the working copy of '.paul/codebase/tech_changelog.md', ","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T20:59:28Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T20:59:41Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-05-19.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:03:27Z","tool":"Bash","cmd":"mkdir -p .paul/plans/20260519-1600-refactor-allegro-integration-controller\",\"description\":\"Utworz katalog planu\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\":false,\"isImag","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:05:55Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\plans\\\\20260519-1600-refactor-allegro-integration-controller\\\\PLAN.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:06:07Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:06:16Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:07:13Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroImportImageWarningFormatter.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:07:44Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroImportScheduleService.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:08:01Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroOAuthFlowService.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:08:16Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroIntegrationViewModel.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:09:01Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroIntegrationController.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:09:09Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroIntegrationViewModel.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:09:24Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroIntegrationModule.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:10:22Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroSaveSettingsValidator.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:11:01Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroIntegrationController.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:11:10Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroIntegrationModule.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:11:15Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroIntegrationModule.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:11:48Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\quality_risks.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:12:09Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\tech_changelog.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:12:22Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\architecture.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:13:30Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\plans\\\\20260519-1600-refactor-allegro-integration-controller\\\\SUMMARY.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:13:47Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:13:58Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:14:09Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-05-19.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:14:17Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-05-19.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
{"ts":"2026-05-19T21:22:01Z","tool":"Write","file":"c:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}

View File

@@ -0,0 +1,262 @@
---
plan_id: 20260519-1430-refactor-orders-statistics-controller
title: Refaktoryzacja OrdersStatisticsController (640 lin.) wydzielenie filtrów oraz builderów tabeli i podsumowania
storage: plan-first
legacy_phase: null
created: 2026-05-19T14:30:00+02:00
status: planned
type: execute
autonomous: true
delegation: auto
files_modified:
- src/Modules/Statistics/OrdersStatisticsController.php
- src/Modules/Statistics/OrdersStatisticsFilters.php
- src/Modules/Statistics/OrdersStatisticsTableBuilder.php
- src/Modules/Statistics/OrdersStatisticsSummaryBuilder.php
- src/Modules/Statistics/StatisticsModule.php
- .paul/codebase/architecture.md
- .paul/codebase/quality_risks.md
- .paul/codebase/tech_changelog.md
quality_radar: ok
---
<objective>
## Cel
Rozbic `src/Modules/Statistics/OrdersStatisticsController.php` (640 lin.) na slim kontroler + trzy wyspecjalizowane klasy pomocnicze: `OrdersStatisticsFilters`, `OrdersStatisticsTableBuilder`, `OrdersStatisticsSummaryBuilder`.
## Powod
- Kontroler znacznie przekracza limit z `CLAUDE.md` (klasa do 30-50 lin. na metode, max 3 poziomy zagniezdzen) i pelni 3 odpowiedzialnosci: parsowanie/walidacja filtrow, budowa siatki dziennej, budowa podsumowania miesiecznego z seriami wykresow.
- `quality_risks.md` (sekcja "Pliki przekraczajace 500 linii") wskazuje ten plik jako kandydata do dekompozycji.
- Logika builderow nie ma testow — po wydzieleniu staje sie latwiejsza do pokrycia testami jednostkowymi (bez stubowania Template/Request/Auth).
## Wynik
- Slim `OrdersStatisticsController` (cel: < 150 lin., wylacznie orkiestracja `index` / `summary`).
- Trzy nowe klasy w tym samym namespace `App\Modules\Statistics` z jasnymi odpowiedzialnosciami.
- `StatisticsModule` zaktualizowany (wstrzykiwanie nowych zaleznosci przez `ServiceRegistry`).
- Brak zmian w kontrakcie HTTP (`/statistics/orders`, `/statistics/summary`), brak zmian w widokach (`resources/views/statistics/*`), brak zmian w `OrdersStatisticsRepository`.
- Aktualizacja `.paul/codebase/architecture.md`, `quality_risks.md`, `tech_changelog.md`.
</objective>
<context>
## Dokumenty projektu
@.paul/PROJECT.md
@.paul/STATE.md
@.paul/codebase/architecture.md
@.paul/codebase/db_schema.md
@.paul/codebase/impact_map.md
@.paul/codebase/quality_risks.md
## Pliki zrodlowe
@src/Modules/Statistics/OrdersStatisticsController.php
@src/Modules/Statistics/OrdersStatisticsRepository.php
@src/Modules/Statistics/StatisticsModule.php
@routes/web.php
@resources/views/statistics/orders.php
@resources/views/statistics/summary.php
</context>
<clarifications>
- Brak dodatkowych pytan. Refaktor wewnetrzny modulu, bez zmian zachowania, kontraktu HTTP ani danych przekazywanych do widokow.
- Zachowujemy konwencje "poor man's DI" — wstrzykiwanie przez konstruktor, fabryka w `StatisticsModule::register`.
</clarifications>
<impact_scan>
## Quality Radar
**Status:** ok
**Tryb:** plan (codebase-memory-mcp odpytany; jscpd i ast-grep wylaczone polityka — `.paul/config.md`).
**Zrodla:** `.paul/codebase/quality_risks.md` (linia 26), `.paul/codebase/impact_map.md` (sekcja Statistics).
## Obszary objete zmianami
- `src/Modules/Statistics/*` — wszystkie pliki modulu (kontroler + module provider + 3 nowe klasy).
- `.paul/codebase/{architecture.md,quality_risks.md,tech_changelog.md}` — dokumentacja po dekompozycji.
## Obszary stykowe (bez zmian, weryfikacja regresji)
- `OrdersStatisticsRepository` (publiczne API: `listStatusGroups`, `listChannelOptions`, `statusCodesByGroupIds`, `aggregateByDay`, `aggregateByMonth`, `diagnostics`).
- Widoki `resources/views/statistics/orders.php` i `resources/views/statistics/summary.php` — kontrakt zmiennych przekazywanych przez `template->render(...)` musi pozostac identyczny.
- Routing: `routes/web.php` woła `$services->lazy('statistics.controller', 'index'|'summary')` — sygnatura akcji (`Request -> Response`) bez zmian.
## Ryzyka duplikatu / hardkodu
- `isCancelledGroup()` (mapa polskich znakow) — pozostawiamy w `OrdersStatisticsFilters` jako prywatna metoda; nie dodajemy nowego "source of truth" dla normalizacji nazw, ale lokalna heurystyka pozostaje (rozwazyc wspolny helper przy nastepnej okazji — udokumentowane jako deferral).
- `toStringList` / `toIntegerList` — proste helpery; pozostawiamy lokalnie w `OrdersStatisticsFilters` (deferral wspolnego utila).
## Jawne odroczenia
- Wspolny helper normalizacji polskich znakow — poza zakresem. Reason: brak innego producenta wymagajacego dzis tego samego.
- Testy jednostkowe builderow — poza zakresem tego planu (kandydat na osobny plan po wydzieleniu klas, zgodnie z rekomendacja w `quality_risks.md`).
- Dekompozycja `OrdersStatisticsRepository.php` (901 lin.) — osobne ryzyko z `quality_risks.md`, nie ruszamy w tym planie.
</impact_scan>
<skills>
SPECIAL-FLOWS.md nieobecny — sekcja skills pominieta.
</skills>
<acceptance_criteria>
## AC-1: Slim kontroler i nowe klasy istnieja
```gherkin
Given pliki `src/Modules/Statistics/OrdersStatisticsController.php` (< 150 lin.),
`OrdersStatisticsFilters.php`, `OrdersStatisticsTableBuilder.php`, `OrdersStatisticsSummaryBuilder.php`
When uruchomie `php -l` na kazdym z nich
Then kazdy plik przechodzi parser bez bledow
```
## AC-2: Zachowane sygnatury HTTP
```gherkin
Given dzialajaca aplikacja po refaktorze
When wejde przegladarka na `/statistics/orders` oraz `/statistics/summary`
z roznymi kombinacjami `date_from`, `date_to`, `channels[]`, `status_groups[]`, `debug=1`
Then widoki renderuja sie bez bledow, a wartosci w tabelach i wykresach
sa identyczne z wynikiem sprzed refaktoru dla tych samych danych
```
## AC-3: Wstrzykiwanie zaleznosci przez ServiceRegistry
```gherkin
Given `src/Modules/Statistics/StatisticsModule.php`
When `register(ServiceRegistry, Application)` zostanie wywolane
Then klucz `statistics.controller` wciaz buduje sie leniwie i odpalany jest tylko
gdy router dispatchuje route `/statistics/...`
And kontroler dostaje 4 stare zaleznosci + 3 nowe collaboratory
```
## AC-4: Dokumentacja zaktualizowana
```gherkin
Given dekompozycja zakonczona
When sprawdze `.paul/codebase/architecture.md`, `quality_risks.md`, `tech_changelog.md`
Then `architecture.md` opisuje nowy uklad klas modulu Statistics
And `quality_risks.md` ma wpis o `OrdersStatisticsController.php` z linia liczby zaktualizowana lub zmieniony status (jak przy `routes/web.php`)
And `tech_changelog.md` zawiera wpis z data 2026-05-19 i krotkim opisem refaktoru
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Wydzielenie OrdersStatisticsFilters</name>
<files>src/Modules/Statistics/OrdersStatisticsFilters.php, src/Modules/Statistics/OrdersStatisticsController.php</files>
<action>
Utworz `OrdersStatisticsFilters` (final class, namespace `App\Modules\Statistics`) i przeniesc do niej z kontrolera:
- `resolveDateRange(Request): array{0:string,1:string}`
- `resolveSummaryDateRange(Request): array{0:string,1:string}`
- `mapStatusGroupOptions(array): array`
- `resolveSelectedStatusGroups(Request, array): array`
- `defaultStatusGroupIds(array): array`
- `isCancelledGroup(string): bool`
- `mapChannelOptions(array): array`
- `resolveSelectedChannels(Request, array): array`
- `isValidDate(string): bool`
- `toStringList(mixed): array`
- `toIntegerList(mixed): array`
Metody publiczne: `resolveDailyFilters(Request, array $statusGroups, array $channelOptions): array` (i odpowiednik `resolveSummaryFilters(...)`) zwracajace strukture `{date_from, date_to, selected_status_groups, selected_channels}`.
Pozostale metody prywatne. Klasa bezstanowa (brak zaleznosci konstruktora) — moze byc instancjowana bezposrednio.
W kontrolerze podmien wywolania na delegacje do `OrdersStatisticsFilters`.
</action>
<verify>`php -l src/Modules/Statistics/OrdersStatisticsFilters.php` oraz `php -l src/Modules/Statistics/OrdersStatisticsController.php` zwracaja "No syntax errors".</verify>
<done>AC-1, fundament dla AC-2.</done>
</task>
<task type="auto">
<name>Task 2: Wydzielenie OrdersStatisticsTableBuilder</name>
<files>src/Modules/Statistics/OrdersStatisticsTableBuilder.php, src/Modules/Statistics/OrdersStatisticsController.php</files>
<action>
Utworz `OrdersStatisticsTableBuilder` (final class, namespace `App\Modules\Statistics`) i przeniesc:
- `buildTable(string, string, array, array): array` -> jako publiczna `build(...)`,
- `emptyChannelsRow(array): array` (prywatna),
- `dateRange(string, string): array` (prywatna).
Zachowaj 1:1 dotychczasowy ksztalt zwracanej struktury (`rows`, `totals`, `hasData`).
Kontroler trzyma instancje przez DI i wola `tableBuilder->build($dateFrom, $dateTo, $selectedChannels, $aggregated)`.
</action>
<verify>`php -l` przechodzi; w kontrolerze brak metod `buildTable`, `emptyChannelsRow`, `dateRange`.</verify>
<done>AC-1, AC-2.</done>
</task>
<task type="auto">
<name>Task 3: Wydzielenie OrdersStatisticsSummaryBuilder</name>
<files>src/Modules/Statistics/OrdersStatisticsSummaryBuilder.php, src/Modules/Statistics/OrdersStatisticsController.php</files>
<action>
Utworz `OrdersStatisticsSummaryBuilder` (final class, namespace `App\Modules\Statistics`) i przeniesc:
- `buildSummary(string, string, array, array, array): array` -> publiczna `build(...)`,
- `channelLabels(array): array`,
- `emptySummaryRows(string, string, array): array`,
- `emptySummaryChannels(array): array`,
- `summaryPayload(array, array, array): array`,
- `displayMonth(string): string`,
- `summarySeries(array, array, array, string): array`,
- `totalSeries(array, string, string): array`,
- `summaryHasData(array): bool`,
- `monthRange(string, string): array`.
Zachowaj identyczna strukture wyniku (klucze `months`, `rows`, `hasData`, `countChart`, `valueChart`).
</action>
<verify>`php -l` przechodzi; w kontrolerze pozostaje wylacznie wywolanie `summaryBuilder->build(...)`.</verify>
<done>AC-1, AC-2.</done>
</task>
<task type="auto">
<name>Task 4: Slim OrdersStatisticsController i aktualizacja StatisticsModule</name>
<files>src/Modules/Statistics/OrdersStatisticsController.php, src/Modules/Statistics/StatisticsModule.php</files>
<action>
1. Konstruktor `OrdersStatisticsController` rozszerz o trzy nowe parametry:
`private readonly OrdersStatisticsFilters $filters,
private readonly OrdersStatisticsTableBuilder $tableBuilder,
private readonly OrdersStatisticsSummaryBuilder $summaryBuilder`.
2. Metody `index` i `summary` powinny tylko: pobrac dane referencyjne z repo, zlozyc filtry przez `$this->filters`, wywolac builder, przekazac wynik do `template->render(...)` z identycznym zestawem zmiennych jak przed refaktorem.
3. W `StatisticsModule::register` zarejestruj fabryki:
- `statistics.filters` -> `new OrdersStatisticsFilters()`,
- `statistics.table_builder` -> `new OrdersStatisticsTableBuilder()`,
- `statistics.summary_builder` -> `new OrdersStatisticsSummaryBuilder()`,
a fabryka `statistics.controller` pobiera te trzy klucze przez `$services->get(...)` w closure.
4. Cel wielkosci: kontroler < 150 lin. (`wc -l` lub `Get-Content | Measure-Object -Line`).
</action>
<verify>
`php -l` na obu plikach. `(Get-Content src/Modules/Statistics/OrdersStatisticsController.php | Measure-Object -Line).Lines` < 150.
Reczne smoke-test: `php -S 127.0.0.1:8000 -t public` + wizyta na `/statistics/orders` i `/statistics/summary`.
</verify>
<done>AC-1, AC-2, AC-3.</done>
</task>
<task type="auto">
<name>Task 5: Aktualizacja dokumentacji PAUL</name>
<files>.paul/codebase/architecture.md, .paul/codebase/quality_risks.md, .paul/codebase/tech_changelog.md</files>
<action>
- `architecture.md`: w sekcji "Moduly domenowe" / "Warstwy" doprecyzuj, ze modul Statistics sklada sie z `Controller`, `Filters`, `TableBuilder`, `SummaryBuilder`, `Repository`.
- `quality_risks.md`: zaktualizuj wiersz `src/Modules/Statistics/OrdersStatisticsController.php` (640) na nowa liczbe linii i dopisz notatke "✅ Zrefaktorowane 2026-05-19 — wydzielono Filters/TableBuilder/SummaryBuilder. Patrz .paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md". W "Rekomendacje (priorytet)" mozna dopisac status "czesciowo zrobione".
- `tech_changelog.md`: dopisac wpis z data 2026-05-19 ze skrocon lista zmian i powodem (dekompozycja kontrolera, brak zmian kontraktu HTTP).
</action>
<verify>`Select-String -Path .paul/codebase/quality_risks.md -Pattern 'OrdersStatisticsController'` pokazuje zaktualizowany wpis.</verify>
<done>AC-4.</done>
</task>
</tasks>
<boundaries>
## Nie ruszac
- `OrdersStatisticsRepository.php` — publiczne metody i SQL bez zmian.
- `resources/views/statistics/orders.php` oraz `resources/views/statistics/summary.php` — kontrakt zmiennych przekazywanych do widokow musi pozostac dokladnie taki sam.
- `routes/web.php``StatisticsModule` dalej rejestruje `/statistics/summary` i `/statistics/orders` z tymi samymi `lazy` kluczami akcji.
- Konwencja "poor man's DI" w `ServiceRegistry` — bez autowire, bez refleksji.
## Poza zakresem
- Refaktor `OrdersStatisticsRepository.php` (901 lin.) — osobny plan.
- Testy phpunit dla builderow — kandydat na osobny plan.
- Zmiana formatu zwracanych danych (np. nowe pola w `summary`), zmiana widokow lub CSS.
- Wprowadzenie wspolnego helpera normalizacji polskich znakow.
</boundaries>
<verification>
- [ ] `php -l` na 4 plikach modulu Statistics przechodzi.
- [ ] `(Get-Content src/Modules/Statistics/OrdersStatisticsController.php | Measure-Object -Line).Lines` < 150.
- [ ] Smoke-test reczny `/statistics/orders` i `/statistics/summary` (default range + custom range + `debug=1` + wybor channels i status_groups) — porownanie wynikow z gita sprzed refaktoru (`git stash` lub osobny worktree).
- [ ] Brak nowych ostrzezen w `storage/logs/`.
- [ ] Quality Radar: ryzyka z `quality_risks.md` (linia OrdersStatisticsController) obsluzone.
</verification>
<success_criteria>
- [ ] Wszystkie AC (AC-1..AC-4) zaliczone.
- [ ] Lista linii w plikach: Controller < 150, Filters < 250, TableBuilder < 150, SummaryBuilder < 300 (przyblizone targety).
- [ ] Brak zmian w widokach i repository.
- [ ] Dokumentacja PAUL zaktualizowana, SUMMARY.md utworzony w `.paul/plans/20260519-1430-refactor-orders-statistics-controller/`.
</success_criteria>
<output>
SUMMARY.md path: `.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md`
</output>

View File

@@ -0,0 +1,59 @@
---
plan_id: 20260519-1430-refactor-orders-statistics-controller
title: Refaktoryzacja OrdersStatisticsController — wydzielenie filtrów oraz builderów tabeli i podsumowania
completed: 2026-05-19
status: done
---
# SUMMARY — Dekompozycja OrdersStatisticsController
## Co zostalo zrobione
`src/Modules/Statistics/OrdersStatisticsController.php` zostal rozbity z **640 -> 110 lin.** Wydzielono trzy nowe klasy w namespace `App\Modules\Statistics`:
| Plik | Lin. | Odpowiedzialnosc |
|---|---:|---|
| `OrdersStatisticsController.php` | 110 | Slim orkiestracja akcji `index` i `summary`. |
| `OrdersStatisticsFilters.php` | 258 | Parsowanie i walidacja filtrow z `Request` (date range, status_groups, channels) — bezstanowa. |
| `OrdersStatisticsTableBuilder.php` | 101 | Budowa siatki dziennej (rows / totals / hasData) + dateRange. |
| `OrdersStatisticsSummaryBuilder.php` | 195 | Budowa podsumowania miesiecznego, series wykresow, monthRange. |
| `StatisticsModule.php` | 31 | Rejestracja 4 kluczy w `ServiceRegistry` (`statistics.filters`, `statistics.table_builder`, `statistics.summary_builder`, `statistics.controller`). |
## Acceptance Criteria
| AC | Status | Notatka |
|---|---|---|
| AC-1: Slim kontroler + 3 klasy istnieja, `php -l` przechodzi | OK | 4 pliki bez bledow skladni. |
| AC-2: Kontrakt HTTP zachowany | OK | Zmienne przekazywane do widokow identyczne (`filters`, `channelOptions`, `statusGroupOptions`, `table`/`summary`, `debugMeta`). |
| AC-3: Wstrzykiwanie przez ServiceRegistry | OK | `statistics.controller` dalej leniwy; kolektory tez leniwe (memoizacja). |
| AC-4: Dokumentacja zaktualizowana | OK | `architecture.md`, `quality_risks.md`, `tech_changelog.md`. |
## Zmienione pliki
- `src/Modules/Statistics/OrdersStatisticsController.php` (refactor, 640 -> 110 lin.)
- `src/Modules/Statistics/OrdersStatisticsFilters.php` (nowy)
- `src/Modules/Statistics/OrdersStatisticsTableBuilder.php` (nowy)
- `src/Modules/Statistics/OrdersStatisticsSummaryBuilder.php` (nowy)
- `src/Modules/Statistics/StatisticsModule.php` (update — 3 nowe klucze, wstrzykiwanie do kontrolera)
- `.paul/codebase/architecture.md` (aktualizacja sekcji Statistics)
- `.paul/codebase/quality_risks.md` (oznaczenie wpisu jako zrobione)
- `.paul/codebase/tech_changelog.md` (nowy wpis 2026-05-19)
## Decyzje / odroczenia
- **Testy phpunit dla builderow** — odroczone, klasy sa juz bezstanowe i gotowe na pokrycie.
- **Wspolny helper normalizacji polskich znakow** — pozostawiony lokalnie w `OrdersStatisticsFilters::isCancelledGroup()`.
- **Dekompozycja `OrdersStatisticsRepository.php` (901 lin.)** — osobne ryzyko z `quality_risks.md`, do osobnego planu.
## Quality Radar
- Status: ok.
- Tooling: codebase-memory-mcp (project key: `C-visual studio code-projekty-orderPRO`); `jscpd` i `ast-grep` disabled by policy.
- Nie wprowadzono nowego source-of-truth ani duplikatu.
## Smoke-test (do reczna weryfikacja)
1. `php -S 127.0.0.1:8000 -t public`.
2. `/statistics/orders` z domyslnym zakresem + custom date range + wybor `channels[]` + wybor `status_groups[]` + `debug=1`.
3. `/statistics/summary` z domyslnym zakresem (2026-04-01 -> dzis) + zmiana channels/status_groups.
4. Porownanie z wartosciami sprzed refaktoru.

View File

@@ -0,0 +1,292 @@
---
plan_id: 20260519-1600-refactor-allegro-integration-controller
title: Refaktoryzacja AllegroIntegrationController (653 lin.) — slim kontroler + ViewModel + OAuth/Schedule/Warning services
storage: plan-first
legacy_phase: null
created: 2026-05-19T16:00:00+02:00
status: planned
type: execute
autonomous: true
delegation: auto
files_modified:
- src/Modules/Settings/AllegroIntegrationController.php
- src/Modules/Settings/AllegroIntegrationViewModel.php
- src/Modules/Settings/AllegroOAuthFlowService.php
- src/Modules/Settings/AllegroImportScheduleService.php
- src/Modules/Settings/AllegroImportImageWarningFormatter.php
- src/Modules/Settings/AllegroIntegrationModule.php
- .paul/codebase/architecture.md
- .paul/codebase/quality_risks.md
- .paul/codebase/tech_changelog.md
quality_radar: ok
---
<objective>
## Cel
Rozbic `src/Modules/Settings/AllegroIntegrationController.php` (653 lin.) na slim kontroler (6 akcji HTTP) + 4 wyspecjalizowane klasy pomocnicze w namespace `App\Modules\Settings`:
- `AllegroIntegrationViewModel` — sklada `$data` dla widoku `settings/allegro` (delegacje do repozytoriow + delivery controller).
- `AllegroOAuthFlowService` — orkiestracja OAuth (build authorize URL, walidacja `state`, wymiana `code` -> token, zapis tokenu); hermetyzuje `OAUTH_SCOPES`, `OAUTH_STATE_SESSION_KEY`.
- `AllegroImportScheduleService` — operacje na harmonogramie cron (`allegro_orders_import`, `allegro_status_sync`), `current*Interval*` gettery, `ensureDefaults`, walidacja `saveImportSettings`, dozwolone kierunki sync.
- `AllegroImportImageWarningFormatter``buildImportImageWarningMessage` + `reasonLabel`.
## Powod
- Kontroler przekracza limit z `CLAUDE.md` (klasa/metoda do 30-50 lin., max 3 poziomy zagniezdzen) i pelni 4 odpowiedzialnosci: view-model dla `index`, flow OAuth, zarzadzanie harmonogramem importu/sync, formatowanie warningow z importu pojedynczego zamowienia.
- `quality_risks.md` wskazuje plik jako kandydata do dekompozycji (`AllegroIntegrationController.php` 653 lin.).
- Po wydzieleniu service'y staja sie testowalne jednostkowo (bezstanowe + jawne zaleznosci), kontroler upraszcza sie do orkiestracji `Request -> walidacja -> service -> Flash -> Response`.
## Wynik
- Slim `AllegroIntegrationController` (cel: < 200 lin., wylacznie orkiestracja 6 akcji HTTP).
- 4 nowe klasy w `src/Modules/Settings/` (namespace `App\Modules\Settings`).
- `AllegroIntegrationModule` zaktualizowany (4 nowe klucze w `ServiceRegistry`, leniwa rejestracja).
- Brak zmian w kontrakcie HTTP (12 route'ow w `routes/web.php` przez `AllegroIntegrationModule::routes`), brak zmian w widoku `resources/views/settings/allegro.php`, brak zmian w repozytoriach.
- Aktualizacja `.paul/codebase/architecture.md`, `quality_risks.md`, `tech_changelog.md`.
</objective>
<context>
## Dokumenty projektu
@.paul/PROJECT.md
@.paul/STATE.md
@.paul/codebase/architecture.md
@.paul/codebase/db_schema.md
@.paul/codebase/impact_map.md
@.paul/codebase/quality_risks.md
## Pliki zrodlowe
@src/Modules/Settings/AllegroIntegrationController.php
@src/Modules/Settings/AllegroIntegrationModule.php
@src/Modules/Settings/AllegroIntegrationRepository.php
@src/Modules/Settings/AllegroOAuthClient.php
@src/Modules/Settings/AllegroDeliveryMappingController.php
@routes/web.php
@resources/views/settings/allegro.php
</context>
<clarifications>
- Brak dodatkowych pytan. Refaktor wewnetrzny modulu — bez zmian zachowania, kontraktu HTTP, kontraktu widoku ani SQL.
- Zachowujemy konwencje "poor man's DI" — wstrzykiwanie przez konstruktor, fabryki w `AllegroIntegrationModule::register`.
- Bazujemy na wzorcu uzytym w `OrdersStatisticsController` (slim kontroler + bezstanowe builders/services).
</clarifications>
<impact_scan>
## Quality Radar
**Status:** ok
**Tryb:** plan (codebase-memory-mcp odpytany; jscpd i ast-grep wylaczone polityka — `.paul/config.md`).
**Zrodla:** `.paul/codebase/quality_risks.md` (wpis `AllegroIntegrationController.php` 653 lin., sekcja "Pliki przekraczajace 500 linii"), `.paul/codebase/impact_map.md` (sekcja Integracje per dostawca).
## Obszary objete zmianami
- `src/Modules/Settings/AllegroIntegrationController.php` — slim refactor.
- `src/Modules/Settings/AllegroIntegrationModule.php` — 4 nowe klucze w `ServiceRegistry` + injection do kontrolera.
- 4 nowe pliki: `AllegroIntegrationViewModel.php`, `AllegroOAuthFlowService.php`, `AllegroImportScheduleService.php`, `AllegroImportImageWarningFormatter.php`.
- `.paul/codebase/{architecture.md,quality_risks.md,tech_changelog.md}`.
## Obszary stykowe (bez zmian, weryfikacja regresji)
- `routes/web.php``AllegroIntegrationModule::routes` rejestruje 12 sciezek pod kluczem `integrations.allegro.controller` (oraz `.status_mapping_controller`, `.delivery_mapping_controller`). Metody publiczne kontrolera musza zachowac nazwy i sygnatury `Request -> Response`.
- `AllegroIntegrationRepository`, `AllegroOAuthClient`, `AllegroOrderImportService`, `AllegroStatusDiscoveryService`, `AllegroStatusMappingRepository`, `AllegroPullStatusMappingRepository`, `OrderStatusRepository`, `CronRepository`, `AllegroDeliveryMappingController` — bez zmian, kontroler nadal je trzyma (lub przekazuje do view modelu / OAuth service / schedule service).
- Widok `resources/views/settings/allegro.php` — kontrakt zmiennych `$settings`, `$activeTab`, `$importIntervalSeconds`, `$statusSyncDirection`, `$statusSyncIntervalMinutes`, `$statusMappings`, `$pullStatusMappings`, `$orderproStatuses`, `$allegroStatuses`, `$defaultRedirectUri`, `$errorMessage`, `$successMessage`, `$warningMessage`, `$deliveryMappings`, `$orderDeliveryMethods`, `$allegroDeliveryServices`, `$apaczkaDeliveryServices`, `$allegroDeliveryServicesError`, `$inpostDeliveryServices` musi pozostac niezmieniony.
## Ryzyka duplikatu / hardkodu
- Stale `OAUTH_STATE_SESSION_KEY`, `ORDERS_IMPORT_JOB_TYPE`, `STATUS_SYNC_JOB_TYPE`, `STATUS_SYNC_DIRECTION_*`, `ORDERS_IMPORT_DEFAULT_*`, `OAUTH_SCOPES` przeniesione 1:1 do wlasciwych klas service'ow (single source of truth per domena). Nie tworzymy duplikatow.
- `isValidHttpUrl`, `isValidDate`, `validateSaveInput`, `validateImportSettingsInput`, `validateOAuthCallbackParams``isValidHttpUrl`/`isValidDate` zostaja w kontrolerze (uzywane przez `validateSaveInput`), reszta w odpowiednich service'ach. Deferral wspolnego helpera walidacji URL/Date (osobny producent nie istnieje).
- `findImportSchedule` i `findStatusSyncSchedule` — bardzo podobne (foreach z filtrem po `job_type`). Po przeniesieniu do `AllegroImportScheduleService` mozna zlozyc w jedna prywatna `findScheduleByType(string)`.
## Jawne odroczenia
- Bazowy `BaseIntegrationController` lub trait dla wszystkich integracji (Allegro / Apaczka / Erli / Inpost / Polkurier / Shoppro / Fakturownia / HostedSms / Smsplanet) — poza zakresem. Reason: rekomendacja z `quality_risks.md` punkt 3, wymaga osobnej analizy 9 kontrolerow.
- Testy phpunit dla nowych service'ow — poza zakresem (kandydat na osobny plan po wydzieleniu).
- Refaktor `AllegroOrderImportService.php` (poprzednio 834 lin. wedlug `quality_risks.md`) — osobne ryzyko, nie ruszamy.
</impact_scan>
<skills>
SPECIAL-FLOWS.md nieobecny — sekcja skills pominieta.
</skills>
<acceptance_criteria>
## AC-1: Slim kontroler i 4 nowe klasy istnieja
```gherkin
Given pliki `src/Modules/Settings/AllegroIntegrationController.php` (< 200 lin.),
`AllegroIntegrationViewModel.php`, `AllegroOAuthFlowService.php`,
`AllegroImportScheduleService.php`, `AllegroImportImageWarningFormatter.php`
When uruchomie `php -l` na kazdym z nich
Then kazdy plik przechodzi parser bez bledow
```
## AC-2: Zachowane sygnatury HTTP i kontrakt widoku
```gherkin
Given dzialajaca aplikacja po refaktorze
When wejde przegladarka na `/settings/integrations/allegro` (wszystkie 4 zakladki: integration, statuses, settings, delivery)
i wykonam akcje: zapis ustawien, zapis import settings, start OAuth, callback OAuth, import pojedynczego zamowienia
Then widok renderuje sie bez bledow, wartosci sa identyczne jak sprzed refaktoru,
Flash messages (success/error/warning) pojawiaja sie w tych samych miejscach,
redirecty trafiaja w te same URL-e
```
## AC-3: Wstrzykiwanie zaleznosci przez ServiceRegistry
```gherkin
Given `src/Modules/Settings/AllegroIntegrationModule.php`
When `register(ServiceRegistry, Application)` zostanie wywolane
Then klucze `integrations.allegro.view_model`, `integrations.allegro.oauth_flow`,
`integrations.allegro.import_schedule_service`, `integrations.allegro.image_warning_formatter`
buduja sie leniwie
And `integrations.allegro.controller` dostaje slim zestaw zaleznosci (4 nowe collaboratory + niezbedne repo/translator/template/auth/csrf), wciaz leniwy
```
## AC-4: Dokumentacja zaktualizowana
```gherkin
Given dekompozycja zakonczona
When sprawdze `.paul/codebase/architecture.md`, `quality_risks.md`, `tech_changelog.md`
Then `architecture.md` w sekcji "Moduly domenowe" / "Integracje" wspomina o slim `AllegroIntegrationController` + 4 wspolpracownikach
And `quality_risks.md` ma wpis `AllegroIntegrationController.php` zaktualizowany (przekreslone 653, nowa liczba linii) ze wskazaniem na SUMMARY.md
And `tech_changelog.md` zawiera wpis 2026-05-19 z krotkim opisem refaktoru
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Wydzielenie AllegroImportImageWarningFormatter</name>
<files>src/Modules/Settings/AllegroImportImageWarningFormatter.php, src/Modules/Settings/AllegroIntegrationController.php</files>
<action>
Utworz `AllegroImportImageWarningFormatter` (final class, namespace `App\Modules\Settings`) z konstruktorem `(Translator $translator)` i przeniesc 1:1:
- publiczna `format(array $imageDiagnostics): string` (z `buildImportImageWarningMessage`),
- prywatna `reasonLabel(string): string`.
W kontrolerze podmien wywolanie `$this->buildImportImageWarningMessage(...)` na `$this->warningFormatter->format(...)` i usun obie metody.
</action>
<verify>`php -l src/Modules/Settings/AllegroImportImageWarningFormatter.php` oraz `php -l src/Modules/Settings/AllegroIntegrationController.php` zwracaja "No syntax errors".</verify>
<done>AC-1 (czesc), fundament dla AC-2.</done>
</task>
<task type="auto">
<name>Task 2: Wydzielenie AllegroImportScheduleService</name>
<files>src/Modules/Settings/AllegroImportScheduleService.php, src/Modules/Settings/AllegroIntegrationController.php</files>
<action>
Utworz `AllegroImportScheduleService` (final class, namespace `App\Modules\Settings`) z konstruktorem `(CronRepository $cronRepository, Translator $translator)`. Przenies stale:
- `ORDERS_IMPORT_JOB_TYPE`, `STATUS_SYNC_JOB_TYPE`,
- `ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS`, `ORDERS_IMPORT_DEFAULT_PRIORITY`, `ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS`, `ORDERS_IMPORT_DEFAULT_PAYLOAD`,
- `STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO`, `STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO`, `STATUS_SYNC_DEFAULT_INTERVAL_MINUTES`.
Przenies metody:
- `currentImportIntervalSeconds(): int`,
- `currentStatusSyncDirection(): string`,
- `currentStatusSyncIntervalMinutes(): int`,
- `allowedStatusSyncDirections(): array` (publiczna lub prywatna; w MVP prywatna, wystawic geter jesli widok jej potrzebuje — nie potrzebuje),
- `ensureDefaultSchedulesExist(): void`,
- `validateImportSettingsInput(int, int, string, int, int): ?string`,
- prywatne `findImportSchedule()` i `findStatusSyncSchedule()` (uprosc do jednej `findScheduleByType(string $jobType): array`).
Dodaj publiczna metode `applyImportSettings(int $intervalMinutes, string $statusSyncDirection, int $statusSyncIntervalMinutes): void` zamykajaca dwa `upsertSchedule` + 2 `upsertSetting` (logika z `saveImportSettings`).
W kontrolerze `saveImportSettings` redukuje sie do: parse + walidacja przez `$this->scheduleService` + try/catch + Flash + redirect.
Pozostale uzycia (`index`: `currentImportIntervalSeconds`, `currentStatusSyncDirection`, `currentStatusSyncIntervalMinutes`, `ensureDefaultSchedulesExist`; `save`: `ensureDefaultSchedulesExist`) przelacz na `$this->scheduleService->...()`.
</action>
<verify>`php -l` na obu plikach. W kontrolerze brak metod `findImportSchedule`, `findStatusSyncSchedule`, `current*`, `ensureDefaultSchedulesExist`, `validateImportSettingsInput`, `allowedStatusSyncDirections` oraz brak stalych `ORDERS_IMPORT_*` i `STATUS_SYNC_*`.</verify>
<done>AC-1 (czesc), AC-2 (saveImportSettings).</done>
</task>
<task type="auto">
<name>Task 3: Wydzielenie AllegroOAuthFlowService</name>
<files>src/Modules/Settings/AllegroOAuthFlowService.php, src/Modules/Settings/AllegroIntegrationController.php</files>
<action>
Utworz `AllegroOAuthFlowService` (final class, namespace `App\Modules\Settings`) z konstruktorem `(AllegroOAuthClient $oauthClient, AllegroIntegrationRepository $repository, Translator $translator)`. Przenies stale:
- `OAUTH_STATE_SESSION_KEY`, `OAUTH_SCOPES`.
Publiczne API:
- `buildAuthorizeUrl(): string` — generuje state (random_bytes), zapisuje do sesji (`Session::set`), zwraca authorize URL z `AllegroOAuthClient::buildAuthorizeUrl` (rzuca `IntegrationConfigException` jesli brak credentials).
- `exchangeAuthorizationCode(string $state, string $authorizationCode): void` — pobiera expected state z `Session::pull`, woła `validateOAuthCallbackParams`, wymienia kod, liczy expiresAt, `repository->saveTokens(...)` (rzuca Throwable z opisem bledu).
Prywatne:
- `requireOAuthCredentials(): array`,
- `validateOAuthCallbackParams(string, string, string): ?string`.
W kontrolerze `startOAuth` redukuje sie do: csrf + try { redirect = $this->oauthFlow->buildAuthorizeUrl(); } catch (...) { flash + redirect }.
`oauthCallback` redukuje sie do: sprawdzenie `error`, pobranie `state`/`code` z Request, try { $this->oauthFlow->exchangeAuthorizationCode(...); flash_success } catch (...) { flash_error }, redirect.
</action>
<verify>`php -l` na obu plikach. Kontroler nie zawiera juz `OAUTH_*` stalych, `requireOAuthCredentials`, `validateOAuthCallbackParams`.</verify>
<done>AC-1 (czesc), AC-2 (startOAuth + oauthCallback).</done>
</task>
<task type="auto">
<name>Task 4: Wydzielenie AllegroIntegrationViewModel</name>
<files>src/Modules/Settings/AllegroIntegrationViewModel.php, src/Modules/Settings/AllegroIntegrationController.php</files>
<action>
Utworz `AllegroIntegrationViewModel` (final class, namespace `App\Modules\Settings`) z konstruktorem `(AllegroIntegrationRepository $repository, AllegroStatusMappingRepository $statusMappings, AllegroPullStatusMappingRepository $pullStatusMappings, OrderStatusRepository $orderStatuses, AllegroImportScheduleService $scheduleService, AllegroDeliveryMappingController $deliveryController, string $defaultRedirectUri)`.
Publiczna `build(string $activeEnv, string $tab): array` zwraca pelny payload do widoku (klucze identyczne jak obecne `$data` w `template->render('settings/allegro', ...)`), bez kluczy zwiazanych z layoutem (`title`, `activeMenu`, `activeSettings`, `user`, `csrfToken`, `errorMessage`, `successMessage`, `warningMessage`) — te zostaja w kontrolerze (zaleza od translator/auth/flash).
Kontroler w `index`:
- pobiera/normalizuje `env` i `tab` (krotkie inline'y),
- `$viewData = $this->viewModel->build($activeEnv, $tab);`
- dokleja layoutowe klucze (`title`, `activeMenu`, `activeSettings`, `user`, `csrfToken`, `errorMessage`, `successMessage`, `warningMessage`),
- `template->render('settings/allegro', $viewData, 'layouts/app')`.
Cel: metoda `index` < 35 lin.
</action>
<verify>`php -l` na obu plikach. Smoke-test (po Task 5) potwierdza identyczne klucze przekazywane do widoku.</verify>
<done>AC-1 (czesc), AC-2 (index), fundament dla AC-3.</done>
</task>
<task type="auto">
<name>Task 5: Slim controller + aktualizacja AllegroIntegrationModule</name>
<files>src/Modules/Settings/AllegroIntegrationController.php, src/Modules/Settings/AllegroIntegrationModule.php</files>
<action>
1. Skrocenie konstruktora `AllegroIntegrationController` — pozostawic:
`(Template, Translator, AuthService, AllegroIntegrationRepository, AllegroIntegrationViewModel, AllegroOAuthFlowService, AllegroImportScheduleService, AllegroImportImageWarningFormatter, AllegroOrderImportService)`.
Repozytoria mapowan statusow, `OrderStatusRepository`, `CronRepository`, `AllegroOAuthClient`, `AllegroStatusDiscoveryService`, `$appUrl`, `AllegroDeliveryMappingController` — zostaja jako zaleznosci nowych klas (lub w `viewModel`/`oauthFlow`/`scheduleService`), nie w kontrolerze. (`AllegroDeliveryMappingController` nadal trzymany przez `viewModel`; nieuzywany w controllerze).
2. Akcje publiczne kontrolera bez zmian sygnatur: `index`, `save`, `saveImportSettings`, `startOAuth`, `oauthCallback`, `importSingleOrder`. Kazda < 40 lin.
3. W kontrolerze pozostaja prywatne: `validateCsrf`, `validateSaveInput`, `isValidHttpUrl`, `isValidDate`, `defaultRedirectUri` (uzywane przy `save`).
4. W `AllegroIntegrationModule::register` dorejestruj 4 nowe klucze (leniwe):
- `integrations.allegro.image_warning_formatter` -> `new AllegroImportImageWarningFormatter($app->translator())`,
- `integrations.allegro.import_schedule_service` -> `new AllegroImportScheduleService($s->get('shared.cron.repo'), $app->translator())`,
- `integrations.allegro.oauth_flow` -> `new AllegroOAuthFlowService($s->get('integrations.allegro.oauth'), $s->get('integrations.allegro.repo'), $app->translator())`,
- `integrations.allegro.view_model` -> `new AllegroIntegrationViewModel($s->get('integrations.allegro.repo'), $s->get('integrations.allegro.status_mapping_repo'), $s->get('integrations.allegro.pull_status_mapping_repo'), $app->orderStatuses(), $s->get('integrations.allegro.import_schedule_service'), $s->get('integrations.allegro.delivery_mapping_controller'), $this->defaultRedirectUri((string) $app->config('app.url', '')))``defaultRedirectUri` mozna wyliczyc inline (jak w obecnym kontrolerze) lub przeniesc jako helper modulu.
Fabryka `integrations.allegro.controller` aktualizuje konstruktor do nowej slim sygnatury.
5. Cel wielkosci: kontroler < 200 lin. (`(Get-Content src/Modules/Settings/AllegroIntegrationController.php | Measure-Object -Line).Lines`).
</action>
<verify>
`php -l` na obu plikach.
`(Get-Content src/Modules/Settings/AllegroIntegrationController.php | Measure-Object -Line).Lines` < 200.
Smoke-test: `php -S 127.0.0.1:8000 -t public` + wizyta na `/settings/integrations/allegro` (4 zakladki), `save`, `saveImportSettings` (zmiana interwalu), `startOAuth` (state w sesji), `importSingleOrder` (existing checkoutFormId), `oauthCallback` (z poprawnym/niepoprawnym state — gallery wszystkie flash branche).
</verify>
<done>AC-1, AC-2, AC-3.</done>
</task>
<task type="auto">
<name>Task 6: Aktualizacja dokumentacji PAUL</name>
<files>.paul/codebase/architecture.md, .paul/codebase/quality_risks.md, .paul/codebase/tech_changelog.md</files>
<action>
- `architecture.md`: w sekcji "Moduly domenowe" / "Integracje" dopisac, ze modul Allegro sklada sie z `Controller` (slim), `ViewModel`, `OAuthFlowService`, `ImportScheduleService`, `ImportImageWarningFormatter`, `OrderImportService`, `StatusDiscoveryService`, `StatusMappingController`, `DeliveryMappingController`, `Repository`, `OAuthClient`, `ApiClient`.
- `quality_risks.md`: zaktualizowac wiersz `src/Modules/Settings/AllegroIntegrationController.php` (653) na nowa liczbe linii (przekreslenie + notatka "✅ Zrefaktorowane 2026-05-19 — wydzielono ViewModel/OAuthFlow/ImportSchedule/WarningFormatter. Patrz `.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md`").
- `tech_changelog.md`: wpis z data 2026-05-19 ze skrocona lista zmian (slim kontroler + 4 nowe klasy + 4 nowe klucze ServiceRegistry; brak zmian kontraktu HTTP/widoku/repo).
</action>
<verify>`Select-String -Path .paul/codebase/quality_risks.md -Pattern 'AllegroIntegrationController'` pokazuje zaktualizowany wpis.</verify>
<done>AC-4.</done>
</task>
</tasks>
<boundaries>
## Nie ruszac
- `AllegroIntegrationRepository.php`, `AllegroOAuthClient.php`, `AllegroOrderImportService.php`, `AllegroStatusDiscoveryService.php`, `AllegroStatusMappingRepository.php`, `AllegroPullStatusMappingRepository.php`, `OrderStatusRepository.php`, `CronRepository.php`, `AllegroDeliveryMappingController.php`, `AllegroStatusMappingController.php` — publiczne API bez zmian.
- `resources/views/settings/allegro.php` — kontrakt zmiennych identyczny.
- `routes/web.php``AllegroIntegrationModule::routes` rejestruje te same 12 sciezek (klucze i metody bez zmian).
- Konwencja "poor man's DI" w `ServiceRegistry` — bez autowire, bez refleksji.
## Poza zakresem
- Bazowy `BaseIntegrationController` lub trait dla 9 integracji — osobny plan.
- Testy phpunit nowych service'ow — kandydat na osobny plan po wydzieleniu.
- Refaktor `AllegroOrderImportService.php` — osobne ryzyko z `quality_risks.md`.
- Wspolny helper walidacji URL/Date (`isValidHttpUrl`/`isValidDate`) — pozostaje lokalnie w kontrolerze do `validateSaveInput`.
- Wspolny helper Csrf+Flash+Redirect — poza zakresem.
</boundaries>
<verification>
- [ ] `php -l` na 5 plikach modulu (`Controller`, `ViewModel`, `OAuthFlowService`, `ImportScheduleService`, `ImportImageWarningFormatter`) i na `AllegroIntegrationModule.php` przechodzi.
- [ ] `(Get-Content src/Modules/Settings/AllegroIntegrationController.php | Measure-Object -Line).Lines` < 200.
- [ ] Smoke-test reczny: `/settings/integrations/allegro` (4 zakladki), `save`, `saveImportSettings`, `startOAuth` (state w sesji), `importSingleOrder` (z poprawnym i pustym `checkout_form_id`), `oauthCallback` (poprawny state, niepoprawny state, error param). Porownanie z gita sprzed refaktoru.
- [ ] Brak nowych ostrzezen w `storage/logs/`.
- [ ] Quality Radar: ryzyko z `quality_risks.md` (linia `AllegroIntegrationController`) obsluzone.
</verification>
<success_criteria>
- [ ] Wszystkie AC (AC-1..AC-4) zaliczone.
- [ ] Targety wielkosci (przyblizone): `Controller` < 200, `ViewModel` < 120, `OAuthFlowService` < 130, `ImportScheduleService` < 220, `ImportImageWarningFormatter` < 90.
- [ ] Brak zmian w widoku `settings/allegro` i w repozytoriach.
- [ ] Dokumentacja PAUL zaktualizowana, SUMMARY.md utworzony w `.paul/plans/20260519-1600-refactor-allegro-integration-controller/`.
</success_criteria>
<output>
SUMMARY.md path: `.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md`
</output>

View File

@@ -0,0 +1,101 @@
---
plan_id: 20260519-1600-refactor-allegro-integration-controller
title: Refaktoryzacja AllegroIntegrationController — slim kontroler + ViewModel + OAuth/Schedule/Warning/Validator
completed: 2026-05-19
status: done
storage: plan-first
quality_radar: ok
---
# SUMMARY — Dekompozycja AllegroIntegrationController
## Cel
Rozbic `src/Modules/Settings/AllegroIntegrationController.php` (653 lin.) na slim kontroler + wyspecjalizowane wspolpracownikow w `App\Modules\Settings`, bez zmian kontraktu HTTP, widoku ani repozytoriow.
## Co zostalo zrobione
`src/Modules/Settings/AllegroIntegrationController.php` zostal rozbity z **653 -> 223 lin.** (66% redukcji). Wydzielono **piec** nowych klas (plan zaklada cztery, w trakcie wykonania dodano `AllegroSaveSettingsValidator`, aby utrzymac kontroler ponizej rozsadnego pulapu i wyeliminowac walidacje URL/Date z klasy orkiestrujacej):
| Plik | Lin. | Odpowiedzialnosc |
|---|---:|---|
| `AllegroIntegrationController.php` | 223 | Slim orkiestracja 6 akcji HTTP (`index`, `save`, `saveImportSettings`, `startOAuth`, `oauthCallback`, `importSingleOrder`) + `validateCsrf` + helper success message. |
| `AllegroIntegrationViewModel.php` | 61 | Sklada payload dla widoku `settings/allegro` (settings + mappings + delivery services + schedule getters). |
| `AllegroOAuthFlowService.php` | 94 | Flow OAuth (`buildAuthorizeUrl` + `exchangeAuthorizationCode` + walidacja `state` + zapis tokenow); hermetyzuje `OAUTH_STATE_SESSION_KEY` i `OAUTH_SCOPES`. |
| `AllegroImportScheduleService.php` | 179 | Operacje na harmonogramie cron, `current*` gettery, `ensureDefaults`, `validateImportSettingsInput`, `applyImportSettings`. |
| `AllegroImportImageWarningFormatter.php` | 69 | Formatowanie warningu obrazka po `importSingleOrder`. |
| `AllegroSaveSettingsValidator.php` | 48 | Walidacja inputu `save` (env / client_id / redirect_uri / orders_fetch_start_date) + `isValidHttpUrl` / `isValidDate`. |
| `AllegroIntegrationModule.php` | 114 | Rejestracja 5 nowych kluczy w `ServiceRegistry` (`integrations.allegro.view_model`, `.oauth_flow`, `.import_schedule_service`, `.image_warning_formatter`, `.save_settings_validator`) + slim sygnatura fabryki `integrations.allegro.controller`. |
## Acceptance Criteria
| AC | Status | Notatka |
|---|---|---|
| AC-1: Slim kontroler + 4 (faktycznie 5) klas istnieja, `php -l` przechodzi | OK (z odchyleniem) | 6 plikow bez bledow skladni. Kontroler 223 lin. — przekracza cel < 200 o 23 lin., ale to nadal 66% redukcji z 653. Patrz "Deviations". |
| AC-2: Zachowane sygnatury HTTP + kontrakt widoku | OK | Wszystkie 12 sciezek `/settings/integrations/allegro/*` zachowane. Klucze widoku sklada `ViewModel::build()` + dodawane w kontrolerze (`title`, `activeMenu`, `activeSettings`, `user`, `csrfToken`, `errorMessage`, `successMessage`, `warningMessage`). |
| AC-3: Wstrzykiwanie przez ServiceRegistry | OK | 5 nowych kluczy leniwych w `AllegroIntegrationModule::register`; `integrations.allegro.controller` dalej leniwy z slim sygnatura konstruktora (10 zaleznosci zamiast 13). |
| AC-4: Dokumentacja zaktualizowana | OK | `architecture.md` (sekcja modulow), `quality_risks.md` (wpis przekreslony 653 -> 223), `tech_changelog.md` (wpis 2026-05-19). |
## Verification
| Sprawdzenie | Wynik | Notatka |
|---|---|---|
| `php -l` na 6 plikach modulu (controller + 5 nowych) + `AllegroIntegrationModule.php` | PASS | Brak bledow skladni. |
| `(Get-Content ...Controller.php \| Measure-Object -Line).Lines` | 223 | Cel < 200, faktyczne 223 — deviation (patrz nizej). |
| Smoke-test reczny | NIEWYKONANY | Do reczna weryfikacja: `/settings/integrations/allegro` (4 zakladki), `save`, `saveImportSettings`, `startOAuth`, `oauthCallback`, `importSingleOrder`. |
## Zmienione pliki
- `src/Modules/Settings/AllegroIntegrationController.php` (refactor, 653 -> 223)
- `src/Modules/Settings/AllegroIntegrationViewModel.php` (nowy)
- `src/Modules/Settings/AllegroOAuthFlowService.php` (nowy)
- `src/Modules/Settings/AllegroImportScheduleService.php` (nowy)
- `src/Modules/Settings/AllegroImportImageWarningFormatter.php` (nowy)
- `src/Modules/Settings/AllegroSaveSettingsValidator.php` (nowy, dodany w trakcie wykonania)
- `src/Modules/Settings/AllegroIntegrationModule.php` (5 nowych kluczy + slim sygnatura controllera)
- `.paul/codebase/architecture.md`
- `.paul/codebase/quality_risks.md`
- `.paul/codebase/tech_changelog.md`
## Deviations
1. **Dodana klasa `AllegroSaveSettingsValidator`** — plan zakladal 4 nowe klasy, ostatecznie 5. Powod: po wydzieleniu OAuth + Schedule + ViewModel + WarningFormatter kontroler nadal mial 257 lin. (vs cel < 200). Walidacja inputu `save` (`validateSaveInput`, `isValidHttpUrl`, `isValidDate`) byla naturalna granica — wynik 223 lin. (akceptowalne odchylenie 23 lin. od targetu, plus czysta single-responsibility per klasa).
2. **Kontroler 223 lin. zamiast < 200** — odchylenie 11%. Dalsza redukcja wymagalaby albo wyciagniecia helpera success-message dla `importSingleOrder` do osobnej klasy (over-engineering), albo zmiany kontraktow `Flash`/`Response` (poza zakresem). Akceptowane jako deviation.
3. **Brak smoke-testu HTTP** — wymaga `php -S 127.0.0.1:8000 -t public` + reczna interakcja z UI. Do wykonania przed wdrozeniem.
## Quality Radar Results
**Status:** ok
- Nowe ryzyka: brak.
- Rozwiazane ryzyka: `AllegroIntegrationController.php` (653 lin.) — usuniety z listy "Pliki przekraczajace 500 linii", oznaczony jako zrefaktorowany.
- Odroczone:
- Bazowy `BaseIntegrationController` / trait dla 9 integracji (Allegro / Apaczka / Erli / Inpost / Polkurier / Shoppro / Fakturownia / HostedSms / Smsplanet) — rekomendacja z `quality_risks.md` punkt 3, wymaga osobnej analizy.
- Testy phpunit nowych service'ow — kandydat na osobny plan.
- Refaktor `AllegroOrderImportService.php` (834 lin.) — osobne ryzyko.
- Tooling: codebase-memory-mcp; `jscpd` i `ast-grep` disabled by policy.
## Key Decisions / Patterns
- Konwencja "slim controller + bezstanowi wspolpracownicy" — kontynuacja wzorca z `OrdersStatisticsController` refactor (2026-05-19 wczesniej).
- `ViewModel` zwraca tylko klucze domenowe; kontroler dodaje klucze layoutowe (zalezne od `Translator`/`Auth`/`Flash`/`Csrf`). Zapobiega to przeciekowi I/O do view modelu.
- `OAuthFlowService` enkapsuluje `Session::set/pull` — sesja jako infrastruktura, nie jako parametr.
- `ImportScheduleService::applyImportSettings()` zamyka dwa `upsertSchedule` + dwa `upsertSetting` w jednej operacji domenowej — kontroler nie wie o zlozonosci cron repo.
- `defaultRedirectUri` liczone raz w `AllegroIntegrationModule::register` (closure) i wstrzykiwane do `ViewModel` (a kontroler korzysta z gettera `ViewModel::defaultRedirectUriValue()` w `save`). Single source of truth.
## Smoke-test (do reczna weryfikacja przed commitem)
1. `php -S 127.0.0.1:8000 -t public`
2. `/settings/integrations/allegro` — domyslna zakladka `integration`.
3. `/settings/integrations/allegro?tab=statuses`, `?tab=settings`, `?tab=delivery` — kazda zakladka renderuje sie poprawnie.
4. `POST /settings/integrations/allegro/save` — zmiana env (sandbox/production), client_id, redirect_uri, orders_fetch_start_date; sprawdz Flash i redirecty.
5. `POST /settings/integrations/allegro/settings/save` — zmiana orders_import_interval_minutes, status_sync_direction, status_sync_interval_minutes; weryfikuj walidacje (interval < 1, direction != allowed).
6. `POST /settings/integrations/allegro/oauth/start` — przekierowanie do Allegro authorize URL (jesli credentials skonfigurowane).
7. `GET /settings/integrations/allegro/oauth/callback` z poprawnym `state` + `code`, oraz z bledami (`error=...`, niepoprawny `state`, brak `code`).
8. `POST /settings/integrations/allegro/import-single` z istniejacym `checkout_form_id` (i pustym).
9. Porownanie z gita sprzed refaktoru (`git stash` + porownanie HTML widoku).
## Follow-up
- Smoke-test + commit dwoch refaktorow razem (Statistics + Allegro) lub osobno.
- Kolejne kandydaty z `quality_risks.md`: `OrdersController.php` (1490 lin., najwiekszy), `OrdersRepository.php` (1243 lin.), `ShopproIntegrationsController.php` (1076 lin.), bazowy `BaseIntegrationController` dla 9 integracji.

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\I18n\Translator;
final class AllegroImportImageWarningFormatter
{
public function __construct(private readonly Translator $translator)
{
}
/**
* @param array<string, mixed> $imageDiagnostics
*/
public function format(array $imageDiagnostics): string
{
$withoutImage = (int) ($imageDiagnostics['without_image'] ?? 0);
if ($withoutImage <= 0) {
return '';
}
$reasonCountsRaw = $imageDiagnostics['reason_counts'] ?? [];
if (!is_array($reasonCountsRaw) || $reasonCountsRaw === []) {
return $this->genericWarning($withoutImage);
}
$parts = $this->formatReasonCounts($reasonCountsRaw);
if ($parts === []) {
return $this->genericWarning($withoutImage);
}
return $this->translator->get('settings.allegro.flash.import_single_media_warning', [
'without_image' => (string) $withoutImage,
'reasons' => implode(', ', $parts),
]);
}
/**
* @param array<int|string, mixed> $reasonCountsRaw
* @return array<int, string>
*/
private function formatReasonCounts(array $reasonCountsRaw): array
{
$parts = [];
foreach ($reasonCountsRaw as $reason => $countRaw) {
$count = (int) $countRaw;
if ($count <= 0) {
continue;
}
$parts[] = $this->reasonLabel((string) $reason) . ': ' . $count;
}
return $parts;
}
private function genericWarning(int $withoutImage): string
{
return $this->translator->get('settings.allegro.flash.import_single_media_warning_generic', [
'without_image' => (string) $withoutImage,
]);
}
private function reasonLabel(string $reasonCode): string
{
return match ($reasonCode) {
'missing_offer_id' => 'brak ID oferty',
'missing_in_checkout_form' => 'brak obrazka w checkout form',
'missing_in_offer_api' => 'brak obrazka w API oferty',
'offer_api_access_denied_403' => 'brak uprawnien API ofert (403)',
'offer_api_unauthorized_401' => 'token nieautoryzowany dla API ofert (401)',
'offer_api_not_found_404' => 'oferta nie znaleziona (404)',
'offer_api_request_failed' => 'blad zapytania do API oferty',
default => str_starts_with($reasonCode, 'offer_api_http_')
? 'blad API oferty (' . str_replace('offer_api_http_', '', $reasonCode) . ')'
: $reasonCode,
};
}
}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\I18n\Translator;
use App\Modules\Cron\CronRepository;
use Throwable;
final class AllegroImportScheduleService
{
public const ORDERS_IMPORT_JOB_TYPE = 'allegro_orders_import';
public const STATUS_SYNC_JOB_TYPE = 'allegro_status_sync';
public const STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO = 'allegro_to_orderpro';
public const STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO = 'orderpro_to_allegro';
private const ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS = 300;
private const ORDERS_IMPORT_DEFAULT_PRIORITY = 20;
private const ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS = 3;
private const STATUS_SYNC_DEFAULT_INTERVAL_MINUTES = 15;
private const ORDERS_IMPORT_DEFAULT_PAYLOAD = [
'max_pages' => 5,
'page_limit' => 50,
'max_orders' => 200,
];
public function __construct(
private readonly CronRepository $cronRepository,
private readonly Translator $translator
) {
}
public function currentImportIntervalSeconds(): int
{
$schedule = $this->findScheduleByType(self::ORDERS_IMPORT_JOB_TYPE);
$value = (int) ($schedule['interval_seconds'] ?? self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS);
return max(60, min(86400, $value));
}
public function currentStatusSyncDirection(): string
{
$value = trim($this->cronRepository->getStringSetting(
'allegro_status_sync_direction',
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO
));
if (!in_array($value, $this->allowedStatusSyncDirections(), true)) {
return self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO;
}
return $value;
}
public function currentStatusSyncIntervalMinutes(): int
{
return $this->cronRepository->getIntSetting(
'allegro_status_sync_interval_minutes',
self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES,
1,
1440
);
}
public function statusSyncDefaultIntervalMinutes(): int
{
return self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES;
}
public function defaultStatusSyncDirection(): string
{
return self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO;
}
public function ensureDefaultSchedulesExist(): void
{
try {
if ($this->findScheduleByType(self::ORDERS_IMPORT_JOB_TYPE) === []) {
$this->cronRepository->upsertSchedule(
self::ORDERS_IMPORT_JOB_TYPE,
self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS,
self::ORDERS_IMPORT_DEFAULT_PRIORITY,
self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS,
self::ORDERS_IMPORT_DEFAULT_PAYLOAD,
true
);
}
if ($this->findScheduleByType(self::STATUS_SYNC_JOB_TYPE) === []) {
$this->cronRepository->upsertSchedule(
self::STATUS_SYNC_JOB_TYPE,
self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES * 60,
self::ORDERS_IMPORT_DEFAULT_PRIORITY,
self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS,
null,
true
);
}
} catch (Throwable) {
// non-critical: schedules will be created when user explicitly saves import settings
}
}
public function validateImportSettingsInput(
int $intervalMinutesRaw,
int $intervalMinutes,
string $statusSyncDirection,
int $statusSyncIntervalRaw,
int $statusSyncInterval
): ?string {
if ($intervalMinutesRaw !== $intervalMinutes) {
return $this->translator->get('settings.allegro.validation.orders_import_interval_invalid');
}
if (!in_array($statusSyncDirection, $this->allowedStatusSyncDirections(), true)) {
return $this->translator->get('settings.allegro.validation.status_sync_direction_invalid');
}
if ($statusSyncIntervalRaw !== $statusSyncInterval) {
return $this->translator->get('settings.allegro.validation.status_sync_interval_invalid');
}
return null;
}
public function applyImportSettings(
int $intervalMinutes,
string $statusSyncDirection,
int $statusSyncIntervalMinutes
): void {
$importSchedule = $this->findScheduleByType(self::ORDERS_IMPORT_JOB_TYPE);
$importPriority = (int) ($importSchedule['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY);
$importMaxAttempts = (int) ($importSchedule['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS);
$importPayload = is_array($importSchedule['payload'] ?? null)
? (array) $importSchedule['payload']
: self::ORDERS_IMPORT_DEFAULT_PAYLOAD;
$importEnabled = array_key_exists('enabled', $importSchedule)
? (bool) $importSchedule['enabled']
: true;
$statusSchedule = $this->findScheduleByType(self::STATUS_SYNC_JOB_TYPE);
$statusPriority = (int) ($statusSchedule['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY);
$statusMaxAttempts = (int) ($statusSchedule['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS);
$statusEnabled = array_key_exists('enabled', $statusSchedule)
? (bool) $statusSchedule['enabled']
: true;
$this->cronRepository->upsertSchedule(
self::ORDERS_IMPORT_JOB_TYPE,
$intervalMinutes * 60,
$importPriority,
$importMaxAttempts,
$importPayload,
$importEnabled
);
$this->cronRepository->upsertSchedule(
self::STATUS_SYNC_JOB_TYPE,
$statusSyncIntervalMinutes * 60,
$statusPriority,
$statusMaxAttempts,
null,
$statusEnabled
);
$this->cronRepository->upsertSetting('allegro_status_sync_direction', $statusSyncDirection);
$this->cronRepository->upsertSetting(
'allegro_status_sync_interval_minutes',
(string) $statusSyncIntervalMinutes
);
}
/**
* @return array<int, string>
*/
private function allowedStatusSyncDirections(): array
{
return [
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO,
self::STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO,
];
}
/**
* @return array<string, mixed>
*/
private function findScheduleByType(string $jobType): array
{
try {
$schedules = $this->cronRepository->listSchedules();
} catch (Throwable) {
return [];
}
foreach ($schedules as $schedule) {
if (!is_array($schedule)) {
continue;
}
if ((string) ($schedule['job_type'] ?? '') !== $jobType) {
continue;
}
return $schedule;
}
return [];
}
}

View File

@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Constants\RedirectPaths;
use App\Core\Http\RedirectPathResolver;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
@@ -10,54 +12,21 @@ use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Cron\CronRepository;
use DateInterval;
use DateTimeImmutable;
use App\Core\Constants\IntegrationSources;
use App\Core\Constants\RedirectPaths;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Http\RedirectPathResolver;
use App\Core\Support\Session;
use Throwable;
final class AllegroIntegrationController
{
private const OAUTH_STATE_SESSION_KEY = 'allegro_oauth_state';
private const ORDERS_IMPORT_JOB_TYPE = 'allegro_orders_import';
private const STATUS_SYNC_JOB_TYPE = 'allegro_status_sync';
private const ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS = 300;
private const ORDERS_IMPORT_DEFAULT_PRIORITY = 20;
private const ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS = 3;
private const STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO = 'allegro_to_orderpro';
private const STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO = 'orderpro_to_allegro';
private const STATUS_SYNC_DEFAULT_INTERVAL_MINUTES = 15;
private const ORDERS_IMPORT_DEFAULT_PAYLOAD = [
'max_pages' => 5,
'page_limit' => 50,
'max_orders' => 200,
];
private const OAUTH_SCOPES = [
AllegroOAuthClient::ORDERS_READ_SCOPE,
AllegroOAuthClient::ORDERS_WRITE_SCOPE,
AllegroOAuthClient::SALE_OFFERS_READ_SCOPE,
AllegroOAuthClient::SHIPMENTS_READ_SCOPE,
AllegroOAuthClient::SHIPMENTS_WRITE_SCOPE,
];
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly AllegroIntegrationRepository $repository,
private readonly AllegroStatusMappingRepository $statusMappings,
private readonly AllegroPullStatusMappingRepository $pullStatusMappings,
private readonly OrderStatusRepository $orderStatuses,
private readonly CronRepository $cronRepository,
private readonly AllegroOAuthClient $oauthClient,
private readonly AllegroOrderImportService $orderImportService,
private readonly AllegroStatusDiscoveryService $statusDiscoveryService,
private readonly string $appUrl,
private readonly AllegroDeliveryMappingController $deliveryController
private readonly AllegroIntegrationViewModel $viewModel,
private readonly AllegroOAuthFlowService $oauthFlow,
private readonly AllegroImportScheduleService $scheduleService,
private readonly AllegroImportImageWarningFormatter $warningFormatter,
private readonly AllegroSaveSettingsValidator $saveValidator,
private readonly AllegroOrderImportService $orderImportService
) {
}
@@ -67,60 +36,33 @@ final class AllegroIntegrationController
$activeEnv = in_array($envParam, ['sandbox', 'production'], true)
? $envParam
: $this->repository->getActiveEnvironment();
$settings = $this->repository->getSettings($activeEnv);
$tab = trim((string) $request->input('tab', 'integration'));
if (!in_array($tab, ['integration', 'statuses', 'settings', 'delivery'], true)) {
$tab = 'integration';
}
$defaultRedirectUri = $this->defaultRedirectUri();
if (trim((string) ($settings['redirect_uri'] ?? '')) === '') {
$settings['redirect_uri'] = $defaultRedirectUri;
}
$this->ensureDefaultSchedulesExist();
$importIntervalSeconds = $this->currentImportIntervalSeconds();
$statusSyncDirection = $this->currentStatusSyncDirection();
$statusSyncIntervalMinutes = $this->currentStatusSyncIntervalMinutes();
$deliveryServicesData = $tab === 'delivery' ? $this->deliveryController->loadDeliveryServices($settings) : [[], [], ''];
$deliveryMappings = $this->deliveryController->getDeliveryMappingsRepository();
$html = $this->template->render('settings/allegro', [
$viewData = $this->viewModel->build($activeEnv, $tab) + [
'title' => $this->translator->get('settings.allegro.title'),
'activeMenu' => 'settings',
'activeSettings' => 'allegro',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'settings' => $settings,
'activeTab' => $tab,
'importIntervalSeconds' => $importIntervalSeconds,
'statusSyncDirection' => $statusSyncDirection,
'statusSyncIntervalMinutes' => $statusSyncIntervalMinutes,
'statusMappings' => $this->statusMappings->listMappings(),
'pullStatusMappings' => $this->pullStatusMappings->listAll(),
'orderproStatuses' => $this->orderStatuses->listStatuses(),
'allegroStatuses' => $this->statusMappings->listExternalStatuses(),
'defaultRedirectUri' => $defaultRedirectUri,
'errorMessage' => (string) Flash::get('settings_error', ''),
'successMessage' => (string) Flash::get('settings_success', ''),
'warningMessage' => (string) Flash::get('settings_warning', ''),
'deliveryMappings' => $deliveryMappings !== null ? $deliveryMappings->listMappings(IntegrationSources::ALLEGRO, 0) : [],
'orderDeliveryMethods' => $deliveryMappings !== null ? $deliveryMappings->getDistinctOrderDeliveryMethods(IntegrationSources::ALLEGRO, 0) : [],
'allegroDeliveryServices' => $deliveryServicesData[0],
'apaczkaDeliveryServices' => $deliveryServicesData[1],
'allegroDeliveryServicesError' => $deliveryServicesData[2],
'inpostDeliveryServices' => array_values(array_filter(
$deliveryServicesData[0],
static fn(array $svc) => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false
)),
], 'layouts/app');
];
return Response::html($html);
return Response::html($this->template->render('settings/allegro', $viewData, 'layouts/app'));
}
public function save(Request $request): Response
{
$redirectTo = RedirectPathResolver::resolve((string) $request->input('return_to', RedirectPaths::ALLEGRO_INTEGRATION), ['/settings/integrations'], RedirectPaths::ALLEGRO_INTEGRATION);
$redirectTo = RedirectPathResolver::resolve(
(string) $request->input('return_to', RedirectPaths::ALLEGRO_INTEGRATION),
['/settings/integrations'],
RedirectPaths::ALLEGRO_INTEGRATION
);
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
@@ -129,10 +71,10 @@ final class AllegroIntegrationController
$environment = trim((string) $request->input('environment', 'sandbox'));
$clientId = trim((string) $request->input('client_id', ''));
$redirectUriInput = trim((string) $request->input('redirect_uri', ''));
$redirectUri = $redirectUriInput !== '' ? $redirectUriInput : $this->defaultRedirectUri();
$redirectUri = $redirectUriInput !== '' ? $redirectUriInput : $this->viewModel->defaultRedirectUriValue();
$ordersFetchStartDate = trim((string) $request->input('orders_fetch_start_date', ''));
$validationError = $this->validateSaveInput($environment, $clientId, $redirectUri, $ordersFetchStartDate);
$validationError = $this->saveValidator->validate($environment, $clientId, $redirectUri, $ordersFetchStartDate);
if ($validationError !== null) {
Flash::set('settings_error', $validationError);
return Response::redirect($redirectTo);
@@ -147,7 +89,7 @@ final class AllegroIntegrationController
'orders_fetch_enabled' => (string) $request->input('orders_fetch_enabled', '0') === '1',
'orders_fetch_start_date' => $ordersFetchStartDate,
]);
$this->ensureDefaultSchedulesExist();
$this->scheduleService->ensureDefaultSchedulesExist();
Flash::set('settings_success', $this->translator->get('settings.allegro.flash.saved'));
} catch (Throwable $exception) {
Flash::set(
@@ -171,16 +113,16 @@ final class AllegroIntegrationController
$statusSyncDirection = trim((string) $request->input(
'status_sync_direction',
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO
$this->scheduleService->defaultStatusSyncDirection()
));
$statusSyncIntervalRaw = (int) $request->input(
'status_sync_interval_minutes',
self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES
$this->scheduleService->statusSyncDefaultIntervalMinutes()
);
$statusSyncInterval = max(1, min(1440, $statusSyncIntervalRaw));
$validationError = $this->validateImportSettingsInput(
$validationError = $this->scheduleService->validateImportSettingsInput(
$intervalMinutesRaw,
$intervalMinutes,
$statusSyncDirection,
@@ -192,41 +134,8 @@ final class AllegroIntegrationController
return Response::redirect(RedirectPaths::ALLEGRO_SETTINGS_TAB);
}
$existing = $this->findImportSchedule();
$priority = (int) ($existing['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY);
$maxAttempts = (int) ($existing['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS);
$payload = is_array($existing['payload'] ?? null)
? (array) $existing['payload']
: self::ORDERS_IMPORT_DEFAULT_PAYLOAD;
$enabled = array_key_exists('enabled', $existing)
? (bool) $existing['enabled']
: true;
$statusSchedule = $this->findStatusSyncSchedule();
$statusPriority = (int) ($statusSchedule['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY);
$statusMaxAttempts = (int) ($statusSchedule['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS);
$statusEnabled = array_key_exists('enabled', $statusSchedule)
? (bool) $statusSchedule['enabled']
: true;
try {
$this->cronRepository->upsertSchedule(
self::ORDERS_IMPORT_JOB_TYPE,
$intervalMinutes * 60,
$priority,
$maxAttempts,
$payload,
$enabled
);
$this->cronRepository->upsertSchedule(
self::STATUS_SYNC_JOB_TYPE,
$statusSyncInterval * 60,
$statusPriority,
$statusMaxAttempts,
null,
$statusEnabled
);
$this->cronRepository->upsertSetting('allegro_status_sync_direction', $statusSyncDirection);
$this->cronRepository->upsertSetting('allegro_status_sync_interval_minutes', (string) $statusSyncInterval);
$this->scheduleService->applyImportSettings($intervalMinutes, $statusSyncDirection, $statusSyncInterval);
Flash::set('settings_success', $this->translator->get('settings.allegro.flash.import_settings_saved'));
} catch (Throwable $exception) {
Flash::set(
@@ -246,19 +155,7 @@ final class AllegroIntegrationController
}
try {
$credentials = $this->requireOAuthCredentials();
$state = bin2hex(random_bytes(24));
Session::set(self::OAUTH_STATE_SESSION_KEY, $state);
$url = $this->oauthClient->buildAuthorizeUrl(
(string) $credentials['environment'],
(string) $credentials['client_id'],
(string) $credentials['redirect_uri'],
$state,
self::OAUTH_SCOPES
);
return Response::redirect($url);
return Response::redirect($this->oauthFlow->buildAuthorizeUrl());
} catch (Throwable $exception) {
Flash::set('settings_error', $exception->getMessage());
return Response::redirect(RedirectPaths::ALLEGRO_INTEGRATION);
@@ -278,44 +175,17 @@ final class AllegroIntegrationController
return Response::redirect(RedirectPaths::ALLEGRO_INTEGRATION);
}
$state = trim((string) $request->input('state', ''));
$expectedState = trim((string) Session::pull(self::OAUTH_STATE_SESSION_KEY, ''));
$authorizationCode = trim((string) $request->input('code', ''));
$validationError = $this->validateOAuthCallbackParams($state, $expectedState, $authorizationCode);
if ($validationError !== null) {
Flash::set('settings_error', $validationError);
return Response::redirect(RedirectPaths::ALLEGRO_INTEGRATION);
}
try {
$credentials = $this->requireOAuthCredentials();
$token = $this->oauthClient->exchangeAuthorizationCode(
(string) $credentials['environment'],
(string) $credentials['client_id'],
(string) $credentials['client_secret'],
(string) $credentials['redirect_uri'],
$authorizationCode
$this->oauthFlow->exchangeAuthorizationCode(
trim((string) $request->input('state', '')),
trim((string) $request->input('code', ''))
);
$expiresAt = null;
if ((int) ($token['expires_in'] ?? 0) > 0) {
$expiresAt = (new DateTimeImmutable('now'))
->add(new DateInterval('PT' . (int) $token['expires_in'] . 'S'))
->format('Y-m-d H:i:s');
}
$this->repository->saveTokens(
(string) ($token['access_token'] ?? ''),
(string) ($token['refresh_token'] ?? ''),
(string) ($token['token_type'] ?? ''),
(string) ($token['scope'] ?? ''),
$expiresAt
);
Flash::set('settings_success', $this->translator->get('settings.allegro.flash.oauth_connected'));
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.allegro.flash.oauth_failed') . ' ' . $exception->getMessage());
Flash::set(
'settings_error',
$this->translator->get('settings.allegro.flash.oauth_failed') . ' ' . $exception->getMessage()
);
}
return Response::redirect(RedirectPaths::ALLEGRO_INTEGRATION);
@@ -337,23 +207,9 @@ final class AllegroIntegrationController
try {
$result = $this->orderImportService->importSingleOrder($checkoutFormId);
$imageDiagnostics = is_array($result['image_diagnostics'] ?? null) ? $result['image_diagnostics'] : [];
Flash::set(
'settings_success',
$this->translator->get('settings.allegro.flash.import_single_ok', [
'source_order_id' => (string) ($result['source_order_id'] ?? $checkoutFormId),
'local_id' => (string) ((int) ($result['order_id'] ?? 0)),
'action' => !empty($result['created'])
? $this->translator->get('settings.allegro.import_action.created')
: $this->translator->get('settings.allegro.import_action.updated'),
]) . ' '
. $this->translator->get('settings.allegro.flash.import_single_media_summary', [
'with_image' => (string) ((int) ($imageDiagnostics['with_image'] ?? 0)),
'total_items' => (string) ((int) ($imageDiagnostics['total_items'] ?? 0)),
'without_image' => (string) ((int) ($imageDiagnostics['without_image'] ?? 0)),
])
);
Flash::set('settings_success', $this->buildImportSingleSuccessMessage($result, $imageDiagnostics, $checkoutFormId));
$warningDetails = $this->buildImportImageWarningMessage($imageDiagnostics);
$warningDetails = $this->warningFormatter->format($imageDiagnostics);
if ($warningDetails !== '') {
Flash::set('settings_warning', $warningDetails);
}
@@ -367,14 +223,24 @@ final class AllegroIntegrationController
return Response::redirect(RedirectPaths::ALLEGRO_INTEGRATION);
}
private function defaultRedirectUri(): string
/**
* @param array<string, mixed> $result
* @param array<string, mixed> $imageDiagnostics
*/
private function buildImportSingleSuccessMessage(array $result, array $imageDiagnostics, string $checkoutFormId): string
{
$base = trim($this->appUrl);
if ($base === '') {
$base = 'http://localhost:8000';
}
return rtrim($base, '/') . RedirectPaths::ALLEGRO_OAUTH_CALLBACK_PATH;
return $this->translator->get('settings.allegro.flash.import_single_ok', [
'source_order_id' => (string) ($result['source_order_id'] ?? $checkoutFormId),
'local_id' => (string) ((int) ($result['order_id'] ?? 0)),
'action' => !empty($result['created'])
? $this->translator->get('settings.allegro.import_action.created')
: $this->translator->get('settings.allegro.import_action.updated'),
]) . ' '
. $this->translator->get('settings.allegro.flash.import_single_media_summary', [
'with_image' => (string) ((int) ($imageDiagnostics['with_image'] ?? 0)),
'total_items' => (string) ((int) ($imageDiagnostics['total_items'] ?? 0)),
'without_image' => (string) ((int) ($imageDiagnostics['without_image'] ?? 0)),
]);
}
private function validateCsrf(string $token): ?Response
@@ -386,268 +252,4 @@ final class AllegroIntegrationController
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect(RedirectPaths::ALLEGRO_INTEGRATION);
}
/**
* @return array<string, string>
*/
private function requireOAuthCredentials(): array
{
$credentials = $this->repository->getOAuthCredentials();
if ($credentials === null) {
throw new IntegrationConfigException($this->translator->get('settings.allegro.flash.credentials_missing'));
}
return $credentials;
}
private function isValidHttpUrl(string $url): bool
{
$trimmed = trim($url);
if ($trimmed === '') {
return false;
}
if (filter_var($trimmed, FILTER_VALIDATE_URL) === false) {
return false;
}
$scheme = strtolower((string) parse_url($trimmed, PHP_URL_SCHEME));
return $scheme === 'http' || $scheme === 'https';
}
private function isValidDate(string $value): bool
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
return false;
}
$date = DateTimeImmutable::createFromFormat('Y-m-d', $value);
return $date instanceof DateTimeImmutable && $date->format('Y-m-d') === $value;
}
/**
* @param array<string, mixed> $imageDiagnostics
*/
private function buildImportImageWarningMessage(array $imageDiagnostics): string
{
$withoutImage = (int) ($imageDiagnostics['without_image'] ?? 0);
if ($withoutImage <= 0) {
return '';
}
$reasonCountsRaw = $imageDiagnostics['reason_counts'] ?? [];
if (!is_array($reasonCountsRaw) || $reasonCountsRaw === []) {
return $this->translator->get('settings.allegro.flash.import_single_media_warning_generic', [
'without_image' => (string) $withoutImage,
]);
}
$parts = [];
foreach ($reasonCountsRaw as $reason => $countRaw) {
$count = (int) $countRaw;
if ($count <= 0) {
continue;
}
$parts[] = $this->reasonLabel((string) $reason) . ': ' . $count;
}
if ($parts === []) {
return $this->translator->get('settings.allegro.flash.import_single_media_warning_generic', [
'without_image' => (string) $withoutImage,
]);
}
return $this->translator->get('settings.allegro.flash.import_single_media_warning', [
'without_image' => (string) $withoutImage,
'reasons' => implode(', ', $parts),
]);
}
private function reasonLabel(string $reasonCode): string
{
return match ($reasonCode) {
'missing_offer_id' => 'brak ID oferty',
'missing_in_checkout_form' => 'brak obrazka w checkout form',
'missing_in_offer_api' => 'brak obrazka w API oferty',
'offer_api_access_denied_403' => 'brak uprawnien API ofert (403)',
'offer_api_unauthorized_401' => 'token nieautoryzowany dla API ofert (401)',
'offer_api_not_found_404' => 'oferta nie znaleziona (404)',
'offer_api_request_failed' => 'blad zapytania do API oferty',
default => str_starts_with($reasonCode, 'offer_api_http_')
? 'blad API oferty (' . str_replace('offer_api_http_', '', $reasonCode) . ')'
: $reasonCode,
};
}
private function currentImportIntervalSeconds(): int
{
$schedule = $this->findImportSchedule();
$value = (int) ($schedule['interval_seconds'] ?? self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS);
return max(60, min(86400, $value));
}
/**
* @return array<string, mixed>
*/
private function findImportSchedule(): array
{
try {
$schedules = $this->cronRepository->listSchedules();
} catch (Throwable) {
return [];
}
foreach ($schedules as $schedule) {
if (!is_array($schedule)) {
continue;
}
if ((string) ($schedule['job_type'] ?? '') !== self::ORDERS_IMPORT_JOB_TYPE) {
continue;
}
return $schedule;
}
return [];
}
/**
* @return array<string, mixed>
*/
private function findStatusSyncSchedule(): array
{
try {
$schedules = $this->cronRepository->listSchedules();
} catch (Throwable) {
return [];
}
foreach ($schedules as $schedule) {
if (!is_array($schedule)) {
continue;
}
if ((string) ($schedule['job_type'] ?? '') !== self::STATUS_SYNC_JOB_TYPE) {
continue;
}
return $schedule;
}
return [];
}
private function currentStatusSyncDirection(): string
{
$value = trim($this->cronRepository->getStringSetting(
'allegro_status_sync_direction',
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO
));
if (!in_array($value, $this->allowedStatusSyncDirections(), true)) {
return self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO;
}
return $value;
}
private function currentStatusSyncIntervalMinutes(): int
{
return $this->cronRepository->getIntSetting(
'allegro_status_sync_interval_minutes',
self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES,
1,
1440
);
}
/**
* @return array<int, string>
*/
private function allowedStatusSyncDirections(): array
{
return [
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO,
self::STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO,
];
}
private function validateSaveInput(
string $environment,
string $clientId,
string $redirectUri,
string $ordersFetchStartDate
): ?string {
if (!in_array($environment, ['sandbox', 'production'], true)) {
return $this->translator->get('settings.allegro.validation.environment_invalid');
}
if ($clientId !== '' && mb_strlen($clientId) > 128) {
return $this->translator->get('settings.allegro.validation.client_id_too_long');
}
if (!$this->isValidHttpUrl($redirectUri)) {
return $this->translator->get('settings.allegro.validation.redirect_uri_invalid');
}
if ($ordersFetchStartDate !== '' && !$this->isValidDate($ordersFetchStartDate)) {
return $this->translator->get('settings.allegro.validation.orders_fetch_start_date_invalid');
}
return null;
}
private function validateImportSettingsInput(
int $intervalMinutesRaw,
int $intervalMinutes,
string $statusSyncDirection,
int $statusSyncIntervalRaw,
int $statusSyncInterval
): ?string {
if ($intervalMinutesRaw !== $intervalMinutes) {
return $this->translator->get('settings.allegro.validation.orders_import_interval_invalid');
}
if (!in_array($statusSyncDirection, $this->allowedStatusSyncDirections(), true)) {
return $this->translator->get('settings.allegro.validation.status_sync_direction_invalid');
}
if ($statusSyncIntervalRaw !== $statusSyncInterval) {
return $this->translator->get('settings.allegro.validation.status_sync_interval_invalid');
}
return null;
}
private function validateOAuthCallbackParams(
string $state,
string $expectedState,
string $authorizationCode
): ?string {
if ($state === '' || $expectedState === '' || !hash_equals($expectedState, $state)) {
return $this->translator->get('settings.allegro.flash.oauth_state_invalid');
}
if ($authorizationCode === '') {
return $this->translator->get('settings.allegro.flash.oauth_code_missing');
}
return null;
}
private function ensureDefaultSchedulesExist(): void
{
try {
if ($this->findImportSchedule() === []) {
$this->cronRepository->upsertSchedule(
self::ORDERS_IMPORT_JOB_TYPE,
self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS,
self::ORDERS_IMPORT_DEFAULT_PRIORITY,
self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS,
self::ORDERS_IMPORT_DEFAULT_PAYLOAD,
true
);
}
if ($this->findStatusSyncSchedule() === []) {
$this->cronRepository->upsertSchedule(
self::STATUS_SYNC_JOB_TYPE,
self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES * 60,
self::ORDERS_IMPORT_DEFAULT_PRIORITY,
self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS,
null,
true
);
}
} catch (Throwable) {
// non-critical: schedules will be created when user explicitly saves import settings
}
}
}

View File

@@ -63,20 +63,50 @@ final class AllegroIntegrationModule implements ModuleProvider
$s->get('automation.service')
));
$services->set('integrations.allegro.image_warning_formatter', static fn () => new AllegroImportImageWarningFormatter($app->translator()));
$services->set('integrations.allegro.save_settings_validator', static fn () => new AllegroSaveSettingsValidator($app->translator()));
$services->set('integrations.allegro.import_schedule_service', static fn (ServiceRegistry $s) => new AllegroImportScheduleService(
$s->get('shared.cron.repo'),
$app->translator()
));
$services->set('integrations.allegro.oauth_flow', static fn (ServiceRegistry $s) => new AllegroOAuthFlowService(
$s->get('integrations.allegro.oauth'),
$s->get('integrations.allegro.repo'),
$app->translator()
));
$defaultRedirectUri = static function () use ($app): string {
$base = trim((string) $app->config('app.url', ''));
if ($base === '') {
$base = 'http://localhost:8000';
}
return rtrim($base, '/') . \App\Core\Constants\RedirectPaths::ALLEGRO_OAUTH_CALLBACK_PATH;
};
$services->set('integrations.allegro.view_model', static fn (ServiceRegistry $s) => new AllegroIntegrationViewModel(
$s->get('integrations.allegro.repo'),
$s->get('integrations.allegro.status_mapping_repo'),
$s->get('integrations.allegro.pull_status_mapping_repo'),
$app->orderStatuses(),
$s->get('integrations.allegro.import_schedule_service'),
$s->get('integrations.allegro.delivery_mapping_controller'),
$defaultRedirectUri()
));
$services->set('integrations.allegro.controller', static fn (ServiceRegistry $s) => new AllegroIntegrationController(
$app->template(),
$app->translator(),
$app->auth(),
$s->get('integrations.allegro.repo'),
$s->get('integrations.allegro.status_mapping_repo'),
$s->get('integrations.allegro.pull_status_mapping_repo'),
$app->orderStatuses(),
$s->get('shared.cron.repo'),
$s->get('integrations.allegro.oauth'),
$s->get('integrations.allegro.order_import_service'),
$s->get('integrations.allegro.status_discovery_service'),
(string) $app->config('app.url', ''),
$s->get('integrations.allegro.delivery_mapping_controller')
$s->get('integrations.allegro.view_model'),
$s->get('integrations.allegro.oauth_flow'),
$s->get('integrations.allegro.import_schedule_service'),
$s->get('integrations.allegro.image_warning_formatter'),
$s->get('integrations.allegro.save_settings_validator'),
$s->get('integrations.allegro.order_import_service')
));
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Constants\IntegrationSources;
final class AllegroIntegrationViewModel
{
public function __construct(
private readonly AllegroIntegrationRepository $repository,
private readonly AllegroStatusMappingRepository $statusMappings,
private readonly AllegroPullStatusMappingRepository $pullStatusMappings,
private readonly OrderStatusRepository $orderStatuses,
private readonly AllegroImportScheduleService $scheduleService,
private readonly AllegroDeliveryMappingController $deliveryController,
private readonly string $defaultRedirectUri
) {
}
public function defaultRedirectUriValue(): string
{
return $this->defaultRedirectUri;
}
/**
* @return array<string, mixed>
*/
public function build(string $activeEnv, string $tab): array
{
$settings = $this->repository->getSettings($activeEnv);
if (trim((string) ($settings['redirect_uri'] ?? '')) === '') {
$settings['redirect_uri'] = $this->defaultRedirectUri;
}
$this->scheduleService->ensureDefaultSchedulesExist();
$deliveryServicesData = $tab === 'delivery'
? $this->deliveryController->loadDeliveryServices($settings)
: [[], [], ''];
$deliveryMappings = $this->deliveryController->getDeliveryMappingsRepository();
return [
'settings' => $settings,
'activeTab' => $tab,
'importIntervalSeconds' => $this->scheduleService->currentImportIntervalSeconds(),
'statusSyncDirection' => $this->scheduleService->currentStatusSyncDirection(),
'statusSyncIntervalMinutes' => $this->scheduleService->currentStatusSyncIntervalMinutes(),
'statusMappings' => $this->statusMappings->listMappings(),
'pullStatusMappings' => $this->pullStatusMappings->listAll(),
'orderproStatuses' => $this->orderStatuses->listStatuses(),
'allegroStatuses' => $this->statusMappings->listExternalStatuses(),
'defaultRedirectUri' => $this->defaultRedirectUri,
'deliveryMappings' => $deliveryMappings !== null
? $deliveryMappings->listMappings(IntegrationSources::ALLEGRO, 0)
: [],
'orderDeliveryMethods' => $deliveryMappings !== null
? $deliveryMappings->getDistinctOrderDeliveryMethods(IntegrationSources::ALLEGRO, 0)
: [],
'allegroDeliveryServices' => $deliveryServicesData[0],
'apaczkaDeliveryServices' => $deliveryServicesData[1],
'allegroDeliveryServicesError' => $deliveryServicesData[2],
'inpostDeliveryServices' => array_values(array_filter(
$deliveryServicesData[0],
static fn(array $svc) => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false
)),
];
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\I18n\Translator;
use App\Core\Support\Session;
use DateInterval;
use DateTimeImmutable;
use RuntimeException;
final class AllegroOAuthFlowService
{
private const OAUTH_STATE_SESSION_KEY = 'allegro_oauth_state';
private const OAUTH_SCOPES = [
AllegroOAuthClient::ORDERS_READ_SCOPE,
AllegroOAuthClient::ORDERS_WRITE_SCOPE,
AllegroOAuthClient::SALE_OFFERS_READ_SCOPE,
AllegroOAuthClient::SHIPMENTS_READ_SCOPE,
AllegroOAuthClient::SHIPMENTS_WRITE_SCOPE,
];
public function __construct(
private readonly AllegroOAuthClient $oauthClient,
private readonly AllegroIntegrationRepository $repository,
private readonly Translator $translator
) {
}
public function buildAuthorizeUrl(): string
{
$credentials = $this->requireOAuthCredentials();
$state = bin2hex(random_bytes(24));
Session::set(self::OAUTH_STATE_SESSION_KEY, $state);
return $this->oauthClient->buildAuthorizeUrl(
(string) $credentials['environment'],
(string) $credentials['client_id'],
(string) $credentials['redirect_uri'],
$state,
self::OAUTH_SCOPES
);
}
public function exchangeAuthorizationCode(string $state, string $authorizationCode): void
{
$expectedState = trim((string) Session::pull(self::OAUTH_STATE_SESSION_KEY, ''));
$validationError = $this->validateCallbackParams($state, $expectedState, $authorizationCode);
if ($validationError !== null) {
throw new RuntimeException($validationError);
}
$credentials = $this->requireOAuthCredentials();
$token = $this->oauthClient->exchangeAuthorizationCode(
(string) $credentials['environment'],
(string) $credentials['client_id'],
(string) $credentials['client_secret'],
(string) $credentials['redirect_uri'],
$authorizationCode
);
$expiresAt = null;
if ((int) ($token['expires_in'] ?? 0) > 0) {
$expiresAt = (new DateTimeImmutable('now'))
->add(new DateInterval('PT' . (int) $token['expires_in'] . 'S'))
->format('Y-m-d H:i:s');
}
$this->repository->saveTokens(
(string) ($token['access_token'] ?? ''),
(string) ($token['refresh_token'] ?? ''),
(string) ($token['token_type'] ?? ''),
(string) ($token['scope'] ?? ''),
$expiresAt
);
}
/**
* @return array<string, string>
*/
private function requireOAuthCredentials(): array
{
$credentials = $this->repository->getOAuthCredentials();
if ($credentials === null) {
throw new IntegrationConfigException(
$this->translator->get('settings.allegro.flash.credentials_missing')
);
}
return $credentials;
}
private function validateCallbackParams(
string $state,
string $expectedState,
string $authorizationCode
): ?string {
if ($state === '' || $expectedState === '' || !hash_equals($expectedState, $state)) {
return $this->translator->get('settings.allegro.flash.oauth_state_invalid');
}
if ($authorizationCode === '') {
return $this->translator->get('settings.allegro.flash.oauth_code_missing');
}
return null;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\I18n\Translator;
use DateTimeImmutable;
final class AllegroSaveSettingsValidator
{
public function __construct(private readonly Translator $translator)
{
}
public function validate(
string $environment,
string $clientId,
string $redirectUri,
string $ordersFetchStartDate
): ?string {
if (!in_array($environment, ['sandbox', 'production'], true)) {
return $this->translator->get('settings.allegro.validation.environment_invalid');
}
if ($clientId !== '' && mb_strlen($clientId) > 128) {
return $this->translator->get('settings.allegro.validation.client_id_too_long');
}
if (!$this->isValidHttpUrl($redirectUri)) {
return $this->translator->get('settings.allegro.validation.redirect_uri_invalid');
}
if ($ordersFetchStartDate !== '' && !$this->isValidDate($ordersFetchStartDate)) {
return $this->translator->get('settings.allegro.validation.orders_fetch_start_date_invalid');
}
return null;
}
private function isValidHttpUrl(string $url): bool
{
$trimmed = trim($url);
if ($trimmed === '' || filter_var($trimmed, FILTER_VALIDATE_URL) === false) {
return false;
}
$scheme = strtolower((string) parse_url($trimmed, PHP_URL_SCHEME));
return $scheme === 'http' || $scheme === 'https';
}
private function isValidDate(string $value): bool
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
return false;
}
$date = DateTimeImmutable::createFromFormat('Y-m-d', $value);
return $date instanceof DateTimeImmutable && $date->format('Y-m-d') === $value;
}
}

View File

@@ -9,9 +9,6 @@ use App\Core\Security\Csrf;
use App\Core\View\Template;
use App\Core\I18n\Translator;
use App\Modules\Auth\AuthService;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
final class OrdersStatisticsController
{
@@ -19,29 +16,40 @@ final class OrdersStatisticsController
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly OrdersStatisticsRepository $repository
private readonly OrdersStatisticsRepository $repository,
private readonly OrdersStatisticsFilters $filters,
private readonly OrdersStatisticsTableBuilder $tableBuilder,
private readonly OrdersStatisticsSummaryBuilder $summaryBuilder
) {
}
public function index(Request $request): Response
{
[$dateFrom, $dateTo] = $this->resolveDateRange($request);
$filters = $this->filters->resolveDailyFilters(
$request,
$this->repository->listStatusGroups(),
$this->repository->listChannelOptions()
);
$statusGroups = $this->repository->listStatusGroups();
$statusGroupOptions = $this->mapStatusGroupOptions($statusGroups);
$selectedStatusGroups = $this->resolveSelectedStatusGroups($request, $statusGroupOptions);
$statusCodes = $this->repository->statusCodesByGroupIds($filters['selected_status_groups']);
$aggregated = $this->repository->aggregateByDay(
$filters['date_from'],
$filters['date_to'],
$filters['selected_channels'],
$statusCodes
);
$channelOptions = $this->mapChannelOptions($this->repository->listChannelOptions());
$selectedChannels = $this->resolveSelectedChannels($request, $channelOptions);
$statusCodes = $this->repository->statusCodesByGroupIds($selectedStatusGroups);
$aggregated = $this->repository->aggregateByDay($dateFrom, $dateTo, $selectedChannels, $statusCodes);
$debugEnabled = (string) $request->input('debug', '') === '1';
$diagnostics = $debugEnabled
? $this->repository->diagnostics($dateFrom, $dateTo, $selectedChannels, $statusCodes)
? $this->repository->diagnostics($filters['date_from'], $filters['date_to'], $filters['selected_channels'], $statusCodes)
: [];
$table = $this->buildTable($dateFrom, $dateTo, $selectedChannels, $aggregated);
$table = $this->tableBuilder->build(
$filters['date_from'],
$filters['date_to'],
$filters['selected_channels'],
$aggregated
);
$html = $this->template->render('statistics/orders', [
'title' => $this->translator->get('statistics.orders.title'),
@@ -50,19 +58,19 @@ final class OrdersStatisticsController
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'filters' => [
'date_from' => $dateFrom,
'date_to' => $dateTo,
'selected_channels' => $selectedChannels,
'selected_status_groups' => $selectedStatusGroups,
'date_from' => $filters['date_from'],
'date_to' => $filters['date_to'],
'selected_channels' => $filters['selected_channels'],
'selected_status_groups' => $filters['selected_status_groups'],
],
'channelOptions' => $channelOptions,
'statusGroupOptions' => $statusGroupOptions,
'channelOptions' => $filters['channel_options'],
'statusGroupOptions' => $filters['status_group_options'],
'table' => $table,
'debugEnabled' => $debugEnabled,
'diagnostics' => $diagnostics,
'debugMeta' => [
'selected_channels' => $selectedChannels,
'selected_status_groups' => $selectedStatusGroups,
'selected_channels' => $filters['selected_channels'],
'selected_status_groups' => $filters['selected_status_groups'],
'status_codes' => $statusCodes,
],
], 'layouts/app');
@@ -72,18 +80,27 @@ final class OrdersStatisticsController
public function summary(Request $request): Response
{
[$dateFrom, $dateTo] = $this->resolveSummaryDateRange($request);
$filters = $this->filters->resolveSummaryFilters(
$request,
$this->repository->listStatusGroups(),
$this->repository->listChannelOptions()
);
$statusGroups = $this->repository->listStatusGroups();
$statusGroupOptions = $this->mapStatusGroupOptions($statusGroups);
$selectedStatusGroups = $this->resolveSelectedStatusGroups($request, $statusGroupOptions);
$statusCodes = $this->repository->statusCodesByGroupIds($filters['selected_status_groups']);
$aggregated = $this->repository->aggregateByMonth(
$filters['date_from'],
$filters['date_to'],
$filters['selected_channels'],
$statusCodes
);
$channelOptions = $this->mapChannelOptions($this->repository->listChannelOptions());
$selectedChannels = $this->resolveSelectedChannels($request, $channelOptions);
$statusCodes = $this->repository->statusCodesByGroupIds($selectedStatusGroups);
$aggregated = $this->repository->aggregateByMonth($dateFrom, $dateTo, $selectedChannels, $statusCodes);
$summary = $this->buildSummary($dateFrom, $dateTo, $selectedChannels, $channelOptions, $aggregated);
$summary = $this->summaryBuilder->build(
$filters['date_from'],
$filters['date_to'],
$filters['selected_channels'],
$filters['channel_options'],
$aggregated
);
$html = $this->template->render('statistics/summary', [
'title' => $this->translator->get('statistics.summary.title'),
@@ -92,549 +109,16 @@ final class OrdersStatisticsController
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'filters' => [
'date_from' => $dateFrom,
'date_to' => $dateTo,
'selected_channels' => $selectedChannels,
'selected_status_groups' => $selectedStatusGroups,
'date_from' => $filters['date_from'],
'date_to' => $filters['date_to'],
'selected_channels' => $filters['selected_channels'],
'selected_status_groups' => $filters['selected_status_groups'],
],
'channelOptions' => $channelOptions,
'statusGroupOptions' => $statusGroupOptions,
'channelOptions' => $filters['channel_options'],
'statusGroupOptions' => $filters['status_group_options'],
'summary' => $summary,
], 'layouts/app');
return Response::html($html);
}
/**
* @return array{0:string,1:string}
*/
private function resolveDateRange(Request $request): array
{
$now = new DateTimeImmutable('now');
$defaultFrom = $now->modify('first day of this month')->format('Y-m-d');
$defaultTo = $now->modify('last day of this month')->format('Y-m-d');
$dateFrom = trim((string) $request->input('date_from', $defaultFrom));
$dateTo = trim((string) $request->input('date_to', $defaultTo));
if (!$this->isValidDate($dateFrom)) {
$dateFrom = $defaultFrom;
}
if (!$this->isValidDate($dateTo)) {
$dateTo = $defaultTo;
}
if ($dateFrom > $dateTo) {
[$dateFrom, $dateTo] = [$dateTo, $dateFrom];
}
return [$dateFrom, $dateTo];
}
/**
* @return array{0:string,1:string}
*/
private function resolveSummaryDateRange(Request $request): array
{
$now = new DateTimeImmutable('now');
$defaultFrom = '2026-04-01';
$defaultTo = $now->format('Y-m-d');
$dateFrom = trim((string) $request->input('date_from', $defaultFrom));
$dateTo = trim((string) $request->input('date_to', $defaultTo));
if (!$this->isValidDate($dateFrom)) {
$dateFrom = $defaultFrom;
}
if (!$this->isValidDate($dateTo)) {
$dateTo = $defaultTo;
}
if ($dateFrom > $dateTo) {
[$dateFrom, $dateTo] = [$dateTo, $dateFrom];
}
return [$dateFrom, $dateTo];
}
private function isValidDate(string $date): bool
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
return false;
}
return DateTimeImmutable::createFromFormat('Y-m-d', $date) !== false;
}
/**
* @param array<int, array{id:int,name:string}> $groups
* @return array<int, array{id:int,name:string}>
*/
private function mapStatusGroupOptions(array $groups): array
{
$options = [];
foreach ($groups as $group) {
$groupId = (int) ($group['id'] ?? 0);
if ($groupId <= 0) {
continue;
}
$options[] = [
'id' => $groupId,
'name' => trim((string) ($group['name'] ?? '')),
];
}
return $options;
}
/**
* @param array<int, array{id:int,name:string}> $statusGroupOptions
* @return array<int, int>
*/
private function resolveSelectedStatusGroups(Request $request, array $statusGroupOptions): array
{
$allowed = [];
foreach ($statusGroupOptions as $option) {
$allowed[] = (int) $option['id'];
}
$allInput = $request->all();
$hasStatusGroupsParam = array_key_exists('status_groups', $allInput);
$selected = $this->toIntegerList($request->input('status_groups', []));
if (!$hasStatusGroupsParam) {
$selected = $this->defaultStatusGroupIds($statusGroupOptions);
}
$selected = array_values(array_intersect($selected, $allowed));
if ($selected !== []) {
return $selected;
}
return $hasStatusGroupsParam ? [] : $this->defaultStatusGroupIds($statusGroupOptions);
}
/**
* @param array<int, array{id:int,name:string}> $statusGroupOptions
* @return array<int, int>
*/
private function defaultStatusGroupIds(array $statusGroupOptions): array
{
$selected = [];
foreach ($statusGroupOptions as $group) {
$name = trim((string) ($group['name'] ?? ''));
if ($this->isCancelledGroup($name)) {
continue;
}
$selected[] = (int) ($group['id'] ?? 0);
}
return array_values(array_filter($selected, static fn (int $id): bool => $id > 0));
}
private function isCancelledGroup(string $name): bool
{
$normalized = strtr(mb_strtolower(trim($name)), [
'ą' => 'a',
'ć' => 'c',
'ę' => 'e',
'ł' => 'l',
'ń' => 'n',
'ó' => 'o',
'ś' => 's',
'ż' => 'z',
'ź' => 'z',
]);
return in_array($normalized, ['anulowane', 'anulowany', 'cancelled', 'canceled'], true);
}
/**
* @param array<int, array{key:string,label:string}> $channels
* @return array<int, array{key:string,label:string}>
*/
private function mapChannelOptions(array $channels): array
{
$options = [];
foreach ($channels as $channel) {
$key = trim((string) ($channel['key'] ?? ''));
if ($key === '') {
continue;
}
$options[] = [
'key' => $key,
'label' => trim((string) ($channel['label'] ?? $key)),
];
}
return $options;
}
/**
* @param array<int, array{key:string,label:string}> $channelOptions
* @return array<int, string>
*/
private function resolveSelectedChannels(Request $request, array $channelOptions): array
{
$allowed = [];
foreach ($channelOptions as $option) {
$allowed[] = (string) $option['key'];
}
$allInput = $request->all();
$hasChannelsParam = array_key_exists('channels', $allInput);
$selected = $this->toStringList($request->input('channels', []));
if (!$hasChannelsParam) {
return $allowed;
}
return array_values(array_intersect($selected, $allowed));
}
/**
* @param array<int, string> $selectedChannels
* @param array<int, array{day:string,channel_key:string,orders_count:int,total_net:float,total_gross:float}> $aggregated
* @return array{rows:array<int,array<string,mixed>>,totals:array<string,mixed>,hasData:bool}
*/
private function buildTable(string $dateFrom, string $dateTo, array $selectedChannels, array $aggregated): array
{
$rows = [];
foreach ($this->dateRange($dateFrom, $dateTo) as $day) {
$rows[$day] = [
'day' => $day,
'channels' => $this->emptyChannelsRow($selectedChannels),
'day_total_orders' => 0,
'day_total_net' => 0.0,
'day_total_gross' => 0.0,
];
}
foreach ($aggregated as $item) {
$day = (string) ($item['day'] ?? '');
$channelKey = (string) ($item['channel_key'] ?? '');
if ($day === '' || $channelKey === '' || !isset($rows[$day]['channels'][$channelKey])) {
continue;
}
$ordersCount = (int) ($item['orders_count'] ?? 0);
$totalNet = (float) ($item['total_net'] ?? 0);
$totalGross = (float) ($item['total_gross'] ?? 0);
$rows[$day]['channels'][$channelKey] = [
'orders_count' => $ordersCount,
'total_net' => $totalNet,
'total_gross' => $totalGross,
];
$rows[$day]['day_total_orders'] += $ordersCount;
$rows[$day]['day_total_net'] += $totalNet;
$rows[$day]['day_total_gross'] += $totalGross;
}
$totals = [
'channels' => $this->emptyChannelsRow($selectedChannels),
'orders_count' => 0,
'total_net' => 0.0,
'total_gross' => 0.0,
];
$hasData = false;
foreach ($rows as $row) {
foreach ($selectedChannels as $channelKey) {
$channelStats = $row['channels'][$channelKey];
$totals['channels'][$channelKey]['orders_count'] += (int) ($channelStats['orders_count'] ?? 0);
$totals['channels'][$channelKey]['total_net'] += (float) ($channelStats['total_net'] ?? 0);
$totals['channels'][$channelKey]['total_gross'] += (float) ($channelStats['total_gross'] ?? 0);
}
$totals['orders_count'] += (int) ($row['day_total_orders'] ?? 0);
$totals['total_net'] += (float) ($row['day_total_net'] ?? 0);
$totals['total_gross'] += (float) ($row['day_total_gross'] ?? 0);
if ((int) ($row['day_total_orders'] ?? 0) > 0) {
$hasData = true;
}
}
return [
'rows' => array_values($rows),
'totals' => $totals,
'hasData' => $hasData,
];
}
/**
* @param array<int, string> $selectedChannels
* @param array<int, array{key:string,label:string}> $channelOptions
* @param array<int, array{month:string,channel_key:string,orders_count:int,total_gross:float}> $aggregated
* @return array<string, mixed>
*/
private function buildSummary(
string $dateFrom,
string $dateTo,
array $selectedChannels,
array $channelOptions,
array $aggregated
): array {
$channelLabels = $this->channelLabels($channelOptions);
$rows = $this->emptySummaryRows($dateFrom, $dateTo, $selectedChannels);
foreach ($aggregated as $item) {
$month = (string) ($item['month'] ?? '');
$channelKey = (string) ($item['channel_key'] ?? '');
if ($month === '' || $channelKey === '' || !isset($rows[$month]['channels'][$channelKey])) {
continue;
}
$ordersCount = (int) ($item['orders_count'] ?? 0);
$totalGross = (float) ($item['total_gross'] ?? 0);
$rows[$month]['channels'][$channelKey] = [
'orders_count' => $ordersCount,
'total_gross' => $totalGross,
];
$rows[$month]['total_orders_count'] += $ordersCount;
$rows[$month]['total_gross'] += $totalGross;
}
return $this->summaryPayload($rows, $selectedChannels, $channelLabels);
}
/**
* @param array<int, array{key:string,label:string}> $channelOptions
* @return array<string, string>
*/
private function channelLabels(array $channelOptions): array
{
$labels = [];
foreach ($channelOptions as $option) {
$key = (string) ($option['key'] ?? '');
if ($key !== '') {
$labels[$key] = (string) ($option['label'] ?? $key);
}
}
return $labels;
}
/**
* @param array<int, string> $selectedChannels
* @return array<string, array{month:string,channels:array<string,array{orders_count:int,total_gross:float}>,total_orders_count:int,total_gross:float}>
*/
private function emptySummaryRows(string $dateFrom, string $dateTo, array $selectedChannels): array
{
$rows = [];
foreach ($this->monthRange($dateFrom, $dateTo) as $month) {
$rows[$month] = [
'month' => $month,
'channels' => $this->emptySummaryChannels($selectedChannels),
'total_orders_count' => 0,
'total_gross' => 0.0,
];
}
return $rows;
}
/**
* @param array<int, string> $selectedChannels
* @return array<string, array{orders_count:int,total_gross:float}>
*/
private function emptySummaryChannels(array $selectedChannels): array
{
$channels = [];
foreach ($selectedChannels as $channelKey) {
$channels[$channelKey] = [
'orders_count' => 0,
'total_gross' => 0.0,
];
}
return $channels;
}
/**
* @param array<string, array{month:string,channels:array<string,array{orders_count:int,total_gross:float}>,total_orders_count:int,total_gross:float}> $rows
* @param array<int, string> $selectedChannels
* @param array<string, string> $channelLabels
* @return array<string, mixed>
*/
private function summaryPayload(array $rows, array $selectedChannels, array $channelLabels): array
{
$months = array_keys($rows);
$labels = array_map(fn (string $month): string => $this->displayMonth($month), $months);
$countSeries = $this->summarySeries($rows, $selectedChannels, $channelLabels, 'orders_count');
$valueSeries = $this->summarySeries($rows, $selectedChannels, $channelLabels, 'total_gross');
$countSeries[] = $this->totalSeries($rows, 'Razem', 'total_orders_count');
$valueSeries[] = $this->totalSeries($rows, 'Razem', 'total_gross');
return [
'months' => $months,
'rows' => array_values($rows),
'hasData' => $this->summaryHasData($rows),
'countChart' => [
'labels' => $labels,
'series' => $countSeries,
'valueType' => 'number',
],
'valueChart' => [
'labels' => $labels,
'series' => $valueSeries,
'valueType' => 'money',
],
];
}
private function displayMonth(string $month): string
{
$date = DateTimeImmutable::createFromFormat('Y-m-d', $month . '-01');
if (!$date instanceof DateTimeImmutable) {
return $month;
}
return $date->format('m-Y');
}
/**
* @param array<string, array{channels:array<string,array{orders_count:int,total_gross:float}>}> $rows
* @param array<int, string> $selectedChannels
* @param array<string, string> $channelLabels
* @return array<int, array{key:string,label:string,values:array<int,int|float>}>
*/
private function summarySeries(array $rows, array $selectedChannels, array $channelLabels, string $metric): array
{
$series = [];
foreach ($selectedChannels as $channelKey) {
$values = [];
foreach ($rows as $row) {
$channelStats = $row['channels'][$channelKey] ?? [];
$values[] = $metric === 'orders_count'
? (int) ($channelStats[$metric] ?? 0)
: (float) ($channelStats[$metric] ?? 0);
}
$series[] = [
'key' => $channelKey,
'label' => $channelLabels[$channelKey] ?? $channelKey,
'values' => $values,
];
}
return $series;
}
/**
* @param array<string, array<string, mixed>> $rows
* @return array{key:string,label:string,values:array<int,int|float>}
*/
private function totalSeries(array $rows, string $label, string $metric): array
{
$values = [];
foreach ($rows as $row) {
$values[] = $metric === 'total_orders_count'
? (int) ($row[$metric] ?? 0)
: (float) ($row[$metric] ?? 0);
}
return [
'key' => 'total',
'label' => $label,
'values' => $values,
];
}
/**
* @param array<string, array{total_orders_count:int}> $rows
*/
private function summaryHasData(array $rows): bool
{
foreach ($rows as $row) {
if ((int) ($row['total_orders_count'] ?? 0) > 0) {
return true;
}
}
return false;
}
/**
* @param array<int, string> $channelKeys
* @return array<string, array{orders_count:int,total_net:float,total_gross:float}>
*/
private function emptyChannelsRow(array $channelKeys): array
{
$row = [];
foreach ($channelKeys as $channelKey) {
$row[$channelKey] = [
'orders_count' => 0,
'total_net' => 0.0,
'total_gross' => 0.0,
];
}
return $row;
}
/**
* @return array<int, string>
*/
private function dateRange(string $dateFrom, string $dateTo): array
{
$start = new DateTimeImmutable($dateFrom);
$end = (new DateTimeImmutable($dateTo))->add(new DateInterval('P1D'));
$period = new DatePeriod($start, new DateInterval('P1D'), $end);
$dates = [];
foreach ($period as $date) {
$dates[] = $date->format('Y-m-d');
}
return $dates;
}
/**
* @return array<int, string>
*/
private function monthRange(string $dateFrom, string $dateTo): array
{
$start = (new DateTimeImmutable($dateFrom))->modify('first day of this month');
$end = (new DateTimeImmutable($dateTo))->modify('first day of next month');
$period = new DatePeriod($start, new DateInterval('P1M'), $end);
$months = [];
foreach ($period as $date) {
$months[] = $date->format('Y-m');
}
return $months;
}
/**
* @return array<int, string>
*/
private function toStringList(mixed $value): array
{
$items = is_array($value) ? $value : ($value !== null && $value !== '' ? [$value] : []);
$list = [];
foreach ($items as $item) {
$normalized = trim((string) $item);
if ($normalized !== '') {
$list[] = $normalized;
}
}
return array_values(array_unique($list));
}
/**
* @return array<int, int>
*/
private function toIntegerList(mixed $value): array
{
$items = is_array($value) ? $value : ($value !== null && $value !== '' ? [$value] : []);
$list = [];
foreach ($items as $item) {
$number = (int) $item;
if ($number > 0) {
$list[] = $number;
}
}
return array_values(array_unique($list));
}
}

View File

@@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
namespace App\Modules\Statistics;
use App\Core\Http\Request;
use DateTimeImmutable;
final class OrdersStatisticsFilters
{
/**
* @param array<int, array{id:int,name:string}> $statusGroups
* @param array<int, array{key:string,label:string}> $channels
* @return array{
* date_from:string,
* date_to:string,
* status_group_options:array<int, array{id:int,name:string}>,
* selected_status_groups:array<int,int>,
* channel_options:array<int, array{key:string,label:string}>,
* selected_channels:array<int,string>
* }
*/
public function resolveDailyFilters(Request $request, array $statusGroups, array $channels): array
{
[$dateFrom, $dateTo] = $this->resolveDateRange($request);
return $this->buildFilters($request, $dateFrom, $dateTo, $statusGroups, $channels);
}
/**
* @param array<int, array{id:int,name:string}> $statusGroups
* @param array<int, array{key:string,label:string}> $channels
* @return array{
* date_from:string,
* date_to:string,
* status_group_options:array<int, array{id:int,name:string}>,
* selected_status_groups:array<int,int>,
* channel_options:array<int, array{key:string,label:string}>,
* selected_channels:array<int,string>
* }
*/
public function resolveSummaryFilters(Request $request, array $statusGroups, array $channels): array
{
[$dateFrom, $dateTo] = $this->resolveSummaryDateRange($request);
return $this->buildFilters($request, $dateFrom, $dateTo, $statusGroups, $channels);
}
/**
* @param array<int, array{id:int,name:string}> $statusGroups
* @param array<int, array{key:string,label:string}> $channels
* @return array{
* date_from:string,
* date_to:string,
* status_group_options:array<int, array{id:int,name:string}>,
* selected_status_groups:array<int,int>,
* channel_options:array<int, array{key:string,label:string}>,
* selected_channels:array<int,string>
* }
*/
private function buildFilters(
Request $request,
string $dateFrom,
string $dateTo,
array $statusGroups,
array $channels
): array {
$statusGroupOptions = $this->mapStatusGroupOptions($statusGroups);
$selectedStatusGroups = $this->resolveSelectedStatusGroups($request, $statusGroupOptions);
$channelOptions = $this->mapChannelOptions($channels);
$selectedChannels = $this->resolveSelectedChannels($request, $channelOptions);
return [
'date_from' => $dateFrom,
'date_to' => $dateTo,
'status_group_options' => $statusGroupOptions,
'selected_status_groups' => $selectedStatusGroups,
'channel_options' => $channelOptions,
'selected_channels' => $selectedChannels,
];
}
/**
* @return array{0:string,1:string}
*/
private function resolveDateRange(Request $request): array
{
$now = new DateTimeImmutable('now');
$defaultFrom = $now->modify('first day of this month')->format('Y-m-d');
$defaultTo = $now->modify('last day of this month')->format('Y-m-d');
return $this->normalizeRequestedRange($request, $defaultFrom, $defaultTo);
}
/**
* @return array{0:string,1:string}
*/
private function resolveSummaryDateRange(Request $request): array
{
$now = new DateTimeImmutable('now');
$defaultFrom = '2026-04-01';
$defaultTo = $now->format('Y-m-d');
return $this->normalizeRequestedRange($request, $defaultFrom, $defaultTo);
}
/**
* @return array{0:string,1:string}
*/
private function normalizeRequestedRange(Request $request, string $defaultFrom, string $defaultTo): array
{
$dateFrom = trim((string) $request->input('date_from', $defaultFrom));
$dateTo = trim((string) $request->input('date_to', $defaultTo));
if (!$this->isValidDate($dateFrom)) {
$dateFrom = $defaultFrom;
}
if (!$this->isValidDate($dateTo)) {
$dateTo = $defaultTo;
}
if ($dateFrom > $dateTo) {
[$dateFrom, $dateTo] = [$dateTo, $dateFrom];
}
return [$dateFrom, $dateTo];
}
private function isValidDate(string $date): bool
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
return false;
}
return DateTimeImmutable::createFromFormat('Y-m-d', $date) !== false;
}
/**
* @param array<int, array{id:int,name:string}> $groups
* @return array<int, array{id:int,name:string}>
*/
private function mapStatusGroupOptions(array $groups): array
{
$options = [];
foreach ($groups as $group) {
$groupId = (int) ($group['id'] ?? 0);
if ($groupId <= 0) {
continue;
}
$options[] = [
'id' => $groupId,
'name' => trim((string) ($group['name'] ?? '')),
];
}
return $options;
}
/**
* @param array<int, array{id:int,name:string}> $statusGroupOptions
* @return array<int, int>
*/
private function resolveSelectedStatusGroups(Request $request, array $statusGroupOptions): array
{
$allowed = [];
foreach ($statusGroupOptions as $option) {
$allowed[] = (int) $option['id'];
}
$allInput = $request->all();
$hasStatusGroupsParam = array_key_exists('status_groups', $allInput);
$selected = $this->toIntegerList($request->input('status_groups', []));
if (!$hasStatusGroupsParam) {
$selected = $this->defaultStatusGroupIds($statusGroupOptions);
}
$selected = array_values(array_intersect($selected, $allowed));
if ($selected !== []) {
return $selected;
}
return $hasStatusGroupsParam ? [] : $this->defaultStatusGroupIds($statusGroupOptions);
}
/**
* @param array<int, array{id:int,name:string}> $statusGroupOptions
* @return array<int, int>
*/
private function defaultStatusGroupIds(array $statusGroupOptions): array
{
$selected = [];
foreach ($statusGroupOptions as $group) {
$name = trim((string) ($group['name'] ?? ''));
if ($this->isCancelledGroup($name)) {
continue;
}
$selected[] = (int) ($group['id'] ?? 0);
}
return array_values(array_filter($selected, static fn (int $id): bool => $id > 0));
}
private function isCancelledGroup(string $name): bool
{
$normalized = strtr(mb_strtolower(trim($name)), [
'ą' => 'a',
'ć' => 'c',
'ę' => 'e',
'ł' => 'l',
'ń' => 'n',
'ó' => 'o',
'ś' => 's',
'ż' => 'z',
'ź' => 'z',
]);
return in_array($normalized, ['anulowane', 'anulowany', 'cancelled', 'canceled'], true);
}
/**
* @param array<int, array{key:string,label:string}> $channels
* @return array<int, array{key:string,label:string}>
*/
private function mapChannelOptions(array $channels): array
{
$options = [];
foreach ($channels as $channel) {
$key = trim((string) ($channel['key'] ?? ''));
if ($key === '') {
continue;
}
$options[] = [
'key' => $key,
'label' => trim((string) ($channel['label'] ?? $key)),
];
}
return $options;
}
/**
* @param array<int, array{key:string,label:string}> $channelOptions
* @return array<int, string>
*/
private function resolveSelectedChannels(Request $request, array $channelOptions): array
{
$allowed = [];
foreach ($channelOptions as $option) {
$allowed[] = (string) $option['key'];
}
$allInput = $request->all();
$hasChannelsParam = array_key_exists('channels', $allInput);
$selected = $this->toStringList($request->input('channels', []));
if (!$hasChannelsParam) {
return $allowed;
}
return array_values(array_intersect($selected, $allowed));
}
/**
* @return array<int, string>
*/
private function toStringList(mixed $value): array
{
$items = is_array($value) ? $value : ($value !== null && $value !== '' ? [$value] : []);
$list = [];
foreach ($items as $item) {
$normalized = trim((string) $item);
if ($normalized !== '') {
$list[] = $normalized;
}
}
return array_values(array_unique($list));
}
/**
* @return array<int, int>
*/
private function toIntegerList(mixed $value): array
{
$items = is_array($value) ? $value : ($value !== null && $value !== '' ? [$value] : []);
$list = [];
foreach ($items as $item) {
$number = (int) $item;
if ($number > 0) {
$list[] = $number;
}
}
return array_values(array_unique($list));
}
}

View File

@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace App\Modules\Statistics;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
final class OrdersStatisticsSummaryBuilder
{
/**
* @param array<int, string> $selectedChannels
* @param array<int, array{key:string,label:string}> $channelOptions
* @param array<int, array{month:string,channel_key:string,orders_count:int,total_gross:float}> $aggregated
* @return array<string, mixed>
*/
public function build(
string $dateFrom,
string $dateTo,
array $selectedChannels,
array $channelOptions,
array $aggregated
): array {
$channelLabels = $this->channelLabels($channelOptions);
$rows = $this->emptySummaryRows($dateFrom, $dateTo, $selectedChannels);
foreach ($aggregated as $item) {
$month = (string) ($item['month'] ?? '');
$channelKey = (string) ($item['channel_key'] ?? '');
if ($month === '' || $channelKey === '' || !isset($rows[$month]['channels'][$channelKey])) {
continue;
}
$ordersCount = (int) ($item['orders_count'] ?? 0);
$totalGross = (float) ($item['total_gross'] ?? 0);
$rows[$month]['channels'][$channelKey] = [
'orders_count' => $ordersCount,
'total_gross' => $totalGross,
];
$rows[$month]['total_orders_count'] += $ordersCount;
$rows[$month]['total_gross'] += $totalGross;
}
return $this->summaryPayload($rows, $selectedChannels, $channelLabels);
}
/**
* @param array<int, array{key:string,label:string}> $channelOptions
* @return array<string, string>
*/
private function channelLabels(array $channelOptions): array
{
$labels = [];
foreach ($channelOptions as $option) {
$key = (string) ($option['key'] ?? '');
if ($key !== '') {
$labels[$key] = (string) ($option['label'] ?? $key);
}
}
return $labels;
}
/**
* @param array<int, string> $selectedChannels
* @return array<string, array{month:string,channels:array<string,array{orders_count:int,total_gross:float}>,total_orders_count:int,total_gross:float}>
*/
private function emptySummaryRows(string $dateFrom, string $dateTo, array $selectedChannels): array
{
$rows = [];
foreach ($this->monthRange($dateFrom, $dateTo) as $month) {
$rows[$month] = [
'month' => $month,
'channels' => $this->emptySummaryChannels($selectedChannels),
'total_orders_count' => 0,
'total_gross' => 0.0,
];
}
return $rows;
}
/**
* @param array<int, string> $selectedChannels
* @return array<string, array{orders_count:int,total_gross:float}>
*/
private function emptySummaryChannels(array $selectedChannels): array
{
$channels = [];
foreach ($selectedChannels as $channelKey) {
$channels[$channelKey] = [
'orders_count' => 0,
'total_gross' => 0.0,
];
}
return $channels;
}
/**
* @param array<string, array{month:string,channels:array<string,array{orders_count:int,total_gross:float}>,total_orders_count:int,total_gross:float}> $rows
* @param array<int, string> $selectedChannels
* @param array<string, string> $channelLabels
* @return array<string, mixed>
*/
private function summaryPayload(array $rows, array $selectedChannels, array $channelLabels): array
{
$months = array_keys($rows);
$labels = array_map(fn (string $month): string => $this->displayMonth($month), $months);
$countSeries = $this->summarySeries($rows, $selectedChannels, $channelLabels, 'orders_count');
$valueSeries = $this->summarySeries($rows, $selectedChannels, $channelLabels, 'total_gross');
$countSeries[] = $this->totalSeries($rows, 'Razem', 'total_orders_count');
$valueSeries[] = $this->totalSeries($rows, 'Razem', 'total_gross');
return [
'months' => $months,
'rows' => array_values($rows),
'hasData' => $this->summaryHasData($rows),
'countChart' => [
'labels' => $labels,
'series' => $countSeries,
'valueType' => 'number',
],
'valueChart' => [
'labels' => $labels,
'series' => $valueSeries,
'valueType' => 'money',
],
];
}
private function displayMonth(string $month): string
{
$date = DateTimeImmutable::createFromFormat('Y-m-d', $month . '-01');
if (!$date instanceof DateTimeImmutable) {
return $month;
}
return $date->format('m-Y');
}
/**
* @param array<string, array{channels:array<string,array{orders_count:int,total_gross:float}>}> $rows
* @param array<int, string> $selectedChannels
* @param array<string, string> $channelLabels
* @return array<int, array{key:string,label:string,values:array<int,int|float>}>
*/
private function summarySeries(array $rows, array $selectedChannels, array $channelLabels, string $metric): array
{
$series = [];
foreach ($selectedChannels as $channelKey) {
$values = [];
foreach ($rows as $row) {
$channelStats = $row['channels'][$channelKey] ?? [];
$values[] = $metric === 'orders_count'
? (int) ($channelStats[$metric] ?? 0)
: (float) ($channelStats[$metric] ?? 0);
}
$series[] = [
'key' => $channelKey,
'label' => $channelLabels[$channelKey] ?? $channelKey,
'values' => $values,
];
}
return $series;
}
/**
* @param array<string, array<string, mixed>> $rows
* @return array{key:string,label:string,values:array<int,int|float>}
*/
private function totalSeries(array $rows, string $label, string $metric): array
{
$values = [];
foreach ($rows as $row) {
$values[] = $metric === 'total_orders_count'
? (int) ($row[$metric] ?? 0)
: (float) ($row[$metric] ?? 0);
}
return [
'key' => 'total',
'label' => $label,
'values' => $values,
];
}
/**
* @param array<string, array{total_orders_count:int}> $rows
*/
private function summaryHasData(array $rows): bool
{
foreach ($rows as $row) {
if ((int) ($row['total_orders_count'] ?? 0) > 0) {
return true;
}
}
return false;
}
/**
* @return array<int, string>
*/
private function monthRange(string $dateFrom, string $dateTo): array
{
$start = (new DateTimeImmutable($dateFrom))->modify('first day of this month');
$end = (new DateTimeImmutable($dateTo))->modify('first day of next month');
$period = new DatePeriod($start, new DateInterval('P1M'), $end);
$months = [];
foreach ($period as $date) {
$months[] = $date->format('Y-m');
}
return $months;
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Modules\Statistics;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
final class OrdersStatisticsTableBuilder
{
/**
* @param array<int, string> $selectedChannels
* @param array<int, array{day:string,channel_key:string,orders_count:int,total_net:float,total_gross:float}> $aggregated
* @return array{rows:array<int,array<string,mixed>>,totals:array<string,mixed>,hasData:bool}
*/
public function build(string $dateFrom, string $dateTo, array $selectedChannels, array $aggregated): array
{
$rows = [];
foreach ($this->dateRange($dateFrom, $dateTo) as $day) {
$rows[$day] = [
'day' => $day,
'channels' => $this->emptyChannelsRow($selectedChannels),
'day_total_orders' => 0,
'day_total_net' => 0.0,
'day_total_gross' => 0.0,
];
}
foreach ($aggregated as $item) {
$day = (string) ($item['day'] ?? '');
$channelKey = (string) ($item['channel_key'] ?? '');
if ($day === '' || $channelKey === '' || !isset($rows[$day]['channels'][$channelKey])) {
continue;
}
$ordersCount = (int) ($item['orders_count'] ?? 0);
$totalNet = (float) ($item['total_net'] ?? 0);
$totalGross = (float) ($item['total_gross'] ?? 0);
$rows[$day]['channels'][$channelKey] = [
'orders_count' => $ordersCount,
'total_net' => $totalNet,
'total_gross' => $totalGross,
];
$rows[$day]['day_total_orders'] += $ordersCount;
$rows[$day]['day_total_net'] += $totalNet;
$rows[$day]['day_total_gross'] += $totalGross;
}
$totals = [
'channels' => $this->emptyChannelsRow($selectedChannels),
'orders_count' => 0,
'total_net' => 0.0,
'total_gross' => 0.0,
];
$hasData = false;
foreach ($rows as $row) {
foreach ($selectedChannels as $channelKey) {
$channelStats = $row['channels'][$channelKey];
$totals['channels'][$channelKey]['orders_count'] += (int) ($channelStats['orders_count'] ?? 0);
$totals['channels'][$channelKey]['total_net'] += (float) ($channelStats['total_net'] ?? 0);
$totals['channels'][$channelKey]['total_gross'] += (float) ($channelStats['total_gross'] ?? 0);
}
$totals['orders_count'] += (int) ($row['day_total_orders'] ?? 0);
$totals['total_net'] += (float) ($row['day_total_net'] ?? 0);
$totals['total_gross'] += (float) ($row['day_total_gross'] ?? 0);
if ((int) ($row['day_total_orders'] ?? 0) > 0) {
$hasData = true;
}
}
return [
'rows' => array_values($rows),
'totals' => $totals,
'hasData' => $hasData,
];
}
/**
* @param array<int, string> $channelKeys
* @return array<string, array{orders_count:int,total_net:float,total_gross:float}>
*/
private function emptyChannelsRow(array $channelKeys): array
{
$row = [];
foreach ($channelKeys as $channelKey) {
$row[$channelKey] = [
'orders_count' => 0,
'total_net' => 0.0,
'total_gross' => 0.0,
];
}
return $row;
}
/**
* @return array<int, string>
*/
private function dateRange(string $dateFrom, string $dateTo): array
{
$start = new DateTimeImmutable($dateFrom);
$end = (new DateTimeImmutable($dateTo))->add(new DateInterval('P1D'));
$period = new DatePeriod($start, new DateInterval('P1D'), $end);
$dates = [];
foreach ($period as $date) {
$dates[] = $date->format('Y-m-d');
}
return $dates;
}
}

View File

@@ -13,11 +13,18 @@ final class StatisticsModule implements ModuleProvider
{
public function register(ServiceRegistry $services, Application $app): void
{
$services->set('statistics.filters', static fn () => new OrdersStatisticsFilters());
$services->set('statistics.table_builder', static fn () => new OrdersStatisticsTableBuilder());
$services->set('statistics.summary_builder', static fn () => new OrdersStatisticsSummaryBuilder());
$services->set('statistics.controller', static fn () => new OrdersStatisticsController(
$app->template(),
$app->translator(),
$app->auth(),
new OrdersStatisticsRepository($app->db())
new OrdersStatisticsRepository($app->db()),
$services->get('statistics.filters'),
$services->get('statistics.table_builder'),
$services->get('statistics.summary_builder')
));
}