From e12ebe3a6fdb60d347384f2e805bcaefd8525b30 Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Tue, 19 May 2026 23:23:18 +0200 Subject: [PATCH] update --- .paul/STATE.md | 26 +- .paul/changelog/2026-05-19.md | 41 ++ .paul/codebase/architecture.md | 3 +- .paul/codebase/quality_risks.md | 4 +- .paul/codebase/tech_changelog.md | 42 ++ .paul/governance/governance_2026-05-19.jsonl | 39 ++ .../PLAN.md | 262 ++++++++ .../SUMMARY.md | 59 ++ .../PLAN.md | 292 ++++++++ .../SUMMARY.md | 101 +++ .../AllegroImportImageWarningFormatter.php | 80 +++ .../Settings/AllegroImportScheduleService.php | 200 ++++++ .../Settings/AllegroIntegrationController.php | 498 ++------------ .../Settings/AllegroIntegrationModule.php | 48 +- .../Settings/AllegroIntegrationViewModel.php | 69 ++ .../Settings/AllegroOAuthFlowService.php | 109 +++ .../Settings/AllegroSaveSettingsValidator.php | 56 ++ .../Statistics/OrdersStatisticsController.php | 628 ++---------------- .../Statistics/OrdersStatisticsFilters.php | 296 +++++++++ .../OrdersStatisticsSummaryBuilder.php | 222 +++++++ .../OrdersStatisticsTableBuilder.php | 115 ++++ src/Modules/Statistics/StatisticsModule.php | 9 +- 22 files changed, 2160 insertions(+), 1039 deletions(-) create mode 100644 .paul/changelog/2026-05-19.md create mode 100644 .paul/plans/20260519-1430-refactor-orders-statistics-controller/PLAN.md create mode 100644 .paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md create mode 100644 .paul/plans/20260519-1600-refactor-allegro-integration-controller/PLAN.md create mode 100644 .paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md create mode 100644 src/Modules/Settings/AllegroImportImageWarningFormatter.php create mode 100644 src/Modules/Settings/AllegroImportScheduleService.php create mode 100644 src/Modules/Settings/AllegroIntegrationViewModel.php create mode 100644 src/Modules/Settings/AllegroOAuthFlowService.php create mode 100644 src/Modules/Settings/AllegroSaveSettingsValidator.php create mode 100644 src/Modules/Statistics/OrdersStatisticsFilters.php create mode 100644 src/Modules/Statistics/OrdersStatisticsSummaryBuilder.php create mode 100644 src/Modules/Statistics/OrdersStatisticsTableBuilder.php diff --git a/.paul/STATE.md b/.paul/STATE.md index f832569..a1eedc2 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -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 `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. diff --git a/.paul/changelog/2026-05-19.md b/.paul/changelog/2026-05-19.md new file mode 100644 index 0000000..6ab878c --- /dev/null +++ b/.paul/changelog/2026-05-19.md @@ -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` diff --git a/.paul/codebase/architecture.md b/.paul/codebase/architecture.md index f59faee..90164c0 100644 --- a/.paul/codebase/architecture.md +++ b/.paul/codebase/architecture.md @@ -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) | diff --git a/.paul/codebase/quality_risks.md b/.paul/codebase/quality_risks.md index 48771bc..98a23cc 100644 --- a/.paul/codebase/quality_risks.md +++ b/.paul/codebase/quality_risks.md @@ -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 `Module.php`. Patrz `.paul/plans/20260519-1200-refactor-routes-web/SUMMARY.md`. | ## Luki testowe (krytyczne) diff --git a/.paul/codebase/tech_changelog.md b/.paul/codebase/tech_changelog.md index 1fb87a1..3c54ca4 100644 --- a/.paul/codebase/tech_changelog.md +++ b/.paul/codebase/tech_changelog.md @@ -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 diff --git a/.paul/governance/governance_2026-05-19.jsonl b/.paul/governance/governance_2026-05-19.jsonl index 166a12c..0e51f45 100644 --- a/.paul/governance/governance_2026-05-19.jsonl +++ b/.paul/governance/governance_2026-05-19.jsonl @@ -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 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"} diff --git a/.paul/plans/20260519-1430-refactor-orders-statistics-controller/PLAN.md b/.paul/plans/20260519-1430-refactor-orders-statistics-controller/PLAN.md new file mode 100644 index 0000000..804d4b1 --- /dev/null +++ b/.paul/plans/20260519-1430-refactor-orders-statistics-controller/PLAN.md @@ -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 +--- + + +## 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`. + + + +## 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 + + + +- 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`. + + + +## 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. + + + +SPECIAL-FLOWS.md nieobecny — sekcja skills pominieta. + + + + +## 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 +``` + + + + + + + Task 1: Wydzielenie OrdersStatisticsFilters + src/Modules/Statistics/OrdersStatisticsFilters.php, src/Modules/Statistics/OrdersStatisticsController.php + + 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`. + + `php -l src/Modules/Statistics/OrdersStatisticsFilters.php` oraz `php -l src/Modules/Statistics/OrdersStatisticsController.php` zwracaja "No syntax errors". + AC-1, fundament dla AC-2. + + + + Task 2: Wydzielenie OrdersStatisticsTableBuilder + src/Modules/Statistics/OrdersStatisticsTableBuilder.php, src/Modules/Statistics/OrdersStatisticsController.php + + 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)`. + + `php -l` przechodzi; w kontrolerze brak metod `buildTable`, `emptyChannelsRow`, `dateRange`. + AC-1, AC-2. + + + + Task 3: Wydzielenie OrdersStatisticsSummaryBuilder + src/Modules/Statistics/OrdersStatisticsSummaryBuilder.php, src/Modules/Statistics/OrdersStatisticsController.php + + 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`). + + `php -l` przechodzi; w kontrolerze pozostaje wylacznie wywolanie `summaryBuilder->build(...)`. + AC-1, AC-2. + + + + Task 4: Slim OrdersStatisticsController i aktualizacja StatisticsModule + src/Modules/Statistics/OrdersStatisticsController.php, src/Modules/Statistics/StatisticsModule.php + + 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`). + + + `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`. + + AC-1, AC-2, AC-3. + + + + Task 5: Aktualizacja dokumentacji PAUL + .paul/codebase/architecture.md, .paul/codebase/quality_risks.md, .paul/codebase/tech_changelog.md + + - `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). + + `Select-String -Path .paul/codebase/quality_risks.md -Pattern 'OrdersStatisticsController'` pokazuje zaktualizowany wpis. + AC-4. + + + + + +## 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. + + + +- [ ] `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. + + + +- [ ] 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/`. + + + +SUMMARY.md path: `.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md` + diff --git a/.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md b/.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md new file mode 100644 index 0000000..160f2de --- /dev/null +++ b/.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md @@ -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. diff --git a/.paul/plans/20260519-1600-refactor-allegro-integration-controller/PLAN.md b/.paul/plans/20260519-1600-refactor-allegro-integration-controller/PLAN.md new file mode 100644 index 0000000..bc0fa45 --- /dev/null +++ b/.paul/plans/20260519-1600-refactor-allegro-integration-controller/PLAN.md @@ -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 +--- + + +## 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`. + + + +## 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 + + + +- 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). + + + +## 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. + + + +SPECIAL-FLOWS.md nieobecny — sekcja skills pominieta. + + + + +## 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 +``` + + + + + + + Task 1: Wydzielenie AllegroImportImageWarningFormatter + src/Modules/Settings/AllegroImportImageWarningFormatter.php, src/Modules/Settings/AllegroIntegrationController.php + + 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. + + `php -l src/Modules/Settings/AllegroImportImageWarningFormatter.php` oraz `php -l src/Modules/Settings/AllegroIntegrationController.php` zwracaja "No syntax errors". + AC-1 (czesc), fundament dla AC-2. + + + + Task 2: Wydzielenie AllegroImportScheduleService + src/Modules/Settings/AllegroImportScheduleService.php, src/Modules/Settings/AllegroIntegrationController.php + + 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->...()`. + + `php -l` na obu plikach. W kontrolerze brak metod `findImportSchedule`, `findStatusSyncSchedule`, `current*`, `ensureDefaultSchedulesExist`, `validateImportSettingsInput`, `allowedStatusSyncDirections` oraz brak stalych `ORDERS_IMPORT_*` i `STATUS_SYNC_*`. + AC-1 (czesc), AC-2 (saveImportSettings). + + + + Task 3: Wydzielenie AllegroOAuthFlowService + src/Modules/Settings/AllegroOAuthFlowService.php, src/Modules/Settings/AllegroIntegrationController.php + + 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. + + `php -l` na obu plikach. Kontroler nie zawiera juz `OAUTH_*` stalych, `requireOAuthCredentials`, `validateOAuthCallbackParams`. + AC-1 (czesc), AC-2 (startOAuth + oauthCallback). + + + + Task 4: Wydzielenie AllegroIntegrationViewModel + src/Modules/Settings/AllegroIntegrationViewModel.php, src/Modules/Settings/AllegroIntegrationController.php + + 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. + + `php -l` na obu plikach. Smoke-test (po Task 5) potwierdza identyczne klucze przekazywane do widoku. + AC-1 (czesc), AC-2 (index), fundament dla AC-3. + + + + Task 5: Slim controller + aktualizacja AllegroIntegrationModule + src/Modules/Settings/AllegroIntegrationController.php, src/Modules/Settings/AllegroIntegrationModule.php + + 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`). + + + `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). + + AC-1, AC-2, AC-3. + + + + Task 6: Aktualizacja dokumentacji PAUL + .paul/codebase/architecture.md, .paul/codebase/quality_risks.md, .paul/codebase/tech_changelog.md + + - `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). + + `Select-String -Path .paul/codebase/quality_risks.md -Pattern 'AllegroIntegrationController'` pokazuje zaktualizowany wpis. + AC-4. + + + + + +## 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. + + + +- [ ] `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. + + + +- [ ] 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/`. + + + +SUMMARY.md path: `.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md` + diff --git a/.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md b/.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md new file mode 100644 index 0000000..36f3525 --- /dev/null +++ b/.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md @@ -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. diff --git a/src/Modules/Settings/AllegroImportImageWarningFormatter.php b/src/Modules/Settings/AllegroImportImageWarningFormatter.php new file mode 100644 index 0000000..8e846ff --- /dev/null +++ b/src/Modules/Settings/AllegroImportImageWarningFormatter.php @@ -0,0 +1,80 @@ + $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 $reasonCountsRaw + * @return array + */ + 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, + }; + } +} diff --git a/src/Modules/Settings/AllegroImportScheduleService.php b/src/Modules/Settings/AllegroImportScheduleService.php new file mode 100644 index 0000000..df0db73 --- /dev/null +++ b/src/Modules/Settings/AllegroImportScheduleService.php @@ -0,0 +1,200 @@ + 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 + */ + private function allowedStatusSyncDirections(): array + { + return [ + self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO, + self::STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO, + ]; + } + + /** + * @return array + */ + 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 []; + } +} diff --git a/src/Modules/Settings/AllegroIntegrationController.php b/src/Modules/Settings/AllegroIntegrationController.php index cf89c73..791f6ca 100644 --- a/src/Modules/Settings/AllegroIntegrationController.php +++ b/src/Modules/Settings/AllegroIntegrationController.php @@ -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 $result + * @param array $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 - */ - 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 $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 - */ - 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 - */ - 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 - */ - 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 - } - } } diff --git a/src/Modules/Settings/AllegroIntegrationModule.php b/src/Modules/Settings/AllegroIntegrationModule.php index 110fb87..baf308e 100644 --- a/src/Modules/Settings/AllegroIntegrationModule.php +++ b/src/Modules/Settings/AllegroIntegrationModule.php @@ -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') )); } diff --git a/src/Modules/Settings/AllegroIntegrationViewModel.php b/src/Modules/Settings/AllegroIntegrationViewModel.php new file mode 100644 index 0000000..8023c66 --- /dev/null +++ b/src/Modules/Settings/AllegroIntegrationViewModel.php @@ -0,0 +1,69 @@ +defaultRedirectUri; + } + + /** + * @return array + */ + 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 + )), + ]; + } +} diff --git a/src/Modules/Settings/AllegroOAuthFlowService.php b/src/Modules/Settings/AllegroOAuthFlowService.php new file mode 100644 index 0000000..32d7086 --- /dev/null +++ b/src/Modules/Settings/AllegroOAuthFlowService.php @@ -0,0 +1,109 @@ +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 + */ + 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; + } +} diff --git a/src/Modules/Settings/AllegroSaveSettingsValidator.php b/src/Modules/Settings/AllegroSaveSettingsValidator.php new file mode 100644 index 0000000..d1b333a --- /dev/null +++ b/src/Modules/Settings/AllegroSaveSettingsValidator.php @@ -0,0 +1,56 @@ +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; + } +} diff --git a/src/Modules/Statistics/OrdersStatisticsController.php b/src/Modules/Statistics/OrdersStatisticsController.php index a5748f0..508a4ba 100644 --- a/src/Modules/Statistics/OrdersStatisticsController.php +++ b/src/Modules/Statistics/OrdersStatisticsController.php @@ -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 $groups - * @return array - */ - 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 $statusGroupOptions - * @return array - */ - 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 $statusGroupOptions - * @return array - */ - 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 $channels - * @return array - */ - 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 $channelOptions - * @return array - */ - 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 $selectedChannels - * @param array $aggregated - * @return array{rows:array>,totals:array,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 $selectedChannels - * @param array $channelOptions - * @param array $aggregated - * @return array - */ - 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 $channelOptions - * @return array - */ - 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 $selectedChannels - * @return array,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 $selectedChannels - * @return array - */ - private function emptySummaryChannels(array $selectedChannels): array - { - $channels = []; - foreach ($selectedChannels as $channelKey) { - $channels[$channelKey] = [ - 'orders_count' => 0, - 'total_gross' => 0.0, - ]; - } - - return $channels; - } - - /** - * @param array,total_orders_count:int,total_gross:float}> $rows - * @param array $selectedChannels - * @param array $channelLabels - * @return array - */ - 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}> $rows - * @param array $selectedChannels - * @param array $channelLabels - * @return array}> - */ - 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> $rows - * @return array{key:string,label:string,values:array} - */ - 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 $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 $channelKeys - * @return array - */ - 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 - */ - 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 - */ - 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 - */ - 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 - */ - 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)); - } } diff --git a/src/Modules/Statistics/OrdersStatisticsFilters.php b/src/Modules/Statistics/OrdersStatisticsFilters.php new file mode 100644 index 0000000..599406c --- /dev/null +++ b/src/Modules/Statistics/OrdersStatisticsFilters.php @@ -0,0 +1,296 @@ + $statusGroups + * @param array $channels + * @return array{ + * date_from:string, + * date_to:string, + * status_group_options:array, + * selected_status_groups:array, + * channel_options:array, + * selected_channels:array + * } + */ + 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 $statusGroups + * @param array $channels + * @return array{ + * date_from:string, + * date_to:string, + * status_group_options:array, + * selected_status_groups:array, + * channel_options:array, + * selected_channels:array + * } + */ + 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 $statusGroups + * @param array $channels + * @return array{ + * date_from:string, + * date_to:string, + * status_group_options:array, + * selected_status_groups:array, + * channel_options:array, + * selected_channels:array + * } + */ + 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 $groups + * @return array + */ + 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 $statusGroupOptions + * @return array + */ + 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 $statusGroupOptions + * @return array + */ + 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 $channels + * @return array + */ + 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 $channelOptions + * @return array + */ + 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 + */ + 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 + */ + 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)); + } +} diff --git a/src/Modules/Statistics/OrdersStatisticsSummaryBuilder.php b/src/Modules/Statistics/OrdersStatisticsSummaryBuilder.php new file mode 100644 index 0000000..a8a462a --- /dev/null +++ b/src/Modules/Statistics/OrdersStatisticsSummaryBuilder.php @@ -0,0 +1,222 @@ + $selectedChannels + * @param array $channelOptions + * @param array $aggregated + * @return array + */ + 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 $channelOptions + * @return array + */ + 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 $selectedChannels + * @return array,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 $selectedChannels + * @return array + */ + private function emptySummaryChannels(array $selectedChannels): array + { + $channels = []; + foreach ($selectedChannels as $channelKey) { + $channels[$channelKey] = [ + 'orders_count' => 0, + 'total_gross' => 0.0, + ]; + } + + return $channels; + } + + /** + * @param array,total_orders_count:int,total_gross:float}> $rows + * @param array $selectedChannels + * @param array $channelLabels + * @return array + */ + 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}> $rows + * @param array $selectedChannels + * @param array $channelLabels + * @return array}> + */ + 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> $rows + * @return array{key:string,label:string,values:array} + */ + 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 $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 + */ + 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; + } +} diff --git a/src/Modules/Statistics/OrdersStatisticsTableBuilder.php b/src/Modules/Statistics/OrdersStatisticsTableBuilder.php new file mode 100644 index 0000000..9528e53 --- /dev/null +++ b/src/Modules/Statistics/OrdersStatisticsTableBuilder.php @@ -0,0 +1,115 @@ + $selectedChannels + * @param array $aggregated + * @return array{rows:array>,totals:array,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 $channelKeys + * @return array + */ + 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 + */ + 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; + } +} diff --git a/src/Modules/Statistics/StatisticsModule.php b/src/Modules/Statistics/StatisticsModule.php index 29cae96..66180b1 100644 --- a/src/Modules/Statistics/StatisticsModule.php +++ b/src/Modules/Statistics/StatisticsModule.php @@ -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') )); }