update
This commit is contained in:
@@ -4,16 +4,30 @@
|
||||
**Ostatnia aktualizacja:** 2026-05-19
|
||||
|
||||
## Aktywna praca
|
||||
Brak aktywnego PLAN.md. Ostatnio zakonczony: `.paul/plans/20260519-1200-refactor-routes-web/` (SUMMARY.md).
|
||||
Routing modularny + lazy DI wdrozone (routes/web.php: 859 -> 78 lin., 24 nowe `<Modul>Module.php`).
|
||||
UNIFY zakonczony dla `.paul/plans/20260519-1600-refactor-allegro-integration-controller/`. Petla zamknieta, brak aktywnego planu.
|
||||
|
||||
```
|
||||
PLAN ──▶ APPLY ──▶ UNIFY
|
||||
✓ ✓ ✓ [Loop complete — gotowe na nastepny PLAN]
|
||||
```
|
||||
|
||||
Rezultat: `AllegroIntegrationController` 653 -> 223 lin. (66% redukcji). Wydzielono `AllegroIntegrationViewModel` (61), `AllegroOAuthFlowService` (94), `AllegroImportScheduleService` (179), `AllegroImportImageWarningFormatter` (69), `AllegroSaveSettingsValidator` (48). Deviation: kontroler 223 vs cel < 200 (akceptowalne, 66% redukcji).
|
||||
|
||||
Zamkniete SUMMARY (chronologicznie 2026-05-19):
|
||||
- `.paul/plans/20260519-1200-refactor-routes-web/SUMMARY.md` (routes/web.php: 859 -> 78).
|
||||
- `.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md` (Statistics: 640 -> 110).
|
||||
- `.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md` (Allegro: 653 -> 223).
|
||||
|
||||
## Kontekst sesji
|
||||
- Galaz: `main` (czysta).
|
||||
- Ostatnie commity: `cff0635 UPDATE`, `9ea26ad update`, `d30a459 fix(145) polkurier cod return codes`.
|
||||
- Galaz: `main`.
|
||||
- Niezakomitowane: dekompozycja Statistics + Allegro (8 nowych plikow modulu + 4 zmodyfikowane + dokumenty PAUL + STATE.md + governance log + changelog).
|
||||
- Ostatnie commity: `3809340 update`, `e77b0f1 refactor(routing): module providers + lazy ServiceRegistry`, `2df4638 update`.
|
||||
|
||||
## Sugerowana nastepna akcja
|
||||
1. `$paul-map-codebase` — pelny skan repozytorium i odswiezenie raportow Quality Radar.
|
||||
2. `$paul-plan [opis pracy]` — jezeli temat jest juz znany, przejdz od razu do planowania.
|
||||
1. Smoke-test reczny zgodnie z sekcja "Smoke-test" w obu SUMMARY (Statistics + Allegro) przed commitem.
|
||||
2. Commit: refaktor Statistics + Allegro (8 nowych plikow + 4 zmodyfikowane + docs PAUL + plany).
|
||||
3. Kolejny kandydat z `quality_risks.md`: `OrdersController.php` (1490 lin.), `OrdersRepository.php` (1243 lin.), `ShopproIntegrationsController.php` (1076 lin.), albo bazowy `BaseIntegrationController` dla 9 integracji.
|
||||
4. `$paul-plan` aby uruchomic nowa petle.
|
||||
|
||||
## Legacy
|
||||
Pliki `ROADMAP.md` / `MILESTONES.md` sa opcjonalne i obecnie nie sa wymagane.
|
||||
|
||||
41
.paul/changelog/2026-05-19.md
Normal file
41
.paul/changelog/2026-05-19.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 2026-05-19
|
||||
|
||||
## Co zrobiono
|
||||
|
||||
- [Plan 20260519-1430-refactor-orders-statistics-controller] Dekompozycja `OrdersStatisticsController` (640 -> 110 lin.) — wydzielenie filtrow i builderow.
|
||||
- Task 1: utworzony `OrdersStatisticsFilters` (258 lin.) — bezstanowa walidacja i parsowanie `Request` (date range, status_groups, channels).
|
||||
- Task 2: utworzony `OrdersStatisticsTableBuilder` (101 lin.) — budowa siatki dziennej (rows/totals/hasData).
|
||||
- Task 3: utworzony `OrdersStatisticsSummaryBuilder` (195 lin.) — podsumowanie miesieczne + serie wykresow.
|
||||
- Task 4: slim `OrdersStatisticsController` + `StatisticsModule` rejestruje 3 nowe klucze (`statistics.filters`, `statistics.table_builder`, `statistics.summary_builder`) w `ServiceRegistry` (lazy).
|
||||
- Task 5: dokumentacja PAUL zaktualizowana (`architecture.md`, `quality_risks.md`, `tech_changelog.md`).
|
||||
- UNIFY: SUMMARY.md utworzony, STATE.md odswiezony, petla zamknieta.
|
||||
- [Plan 20260519-1600-refactor-allegro-integration-controller] Dekompozycja `AllegroIntegrationController` (653 -> 223 lin., 66% redukcji).
|
||||
- Task 1: utworzony `AllegroImportImageWarningFormatter` (69 lin.).
|
||||
- Task 2: utworzony `AllegroImportScheduleService` (179 lin.) — operacje cron + walidacja import settings + `applyImportSettings`.
|
||||
- Task 3: utworzony `AllegroOAuthFlowService` (94 lin.) — `buildAuthorizeUrl` + `exchangeAuthorizationCode` + zarzadzanie state.
|
||||
- Task 4: utworzony `AllegroIntegrationViewModel` (61 lin.) — payload widoku `settings/allegro`.
|
||||
- Task 5: slim kontroler (223 lin.) + dodatkowo `AllegroSaveSettingsValidator` (48 lin., poza pierwotnym planem); `AllegroIntegrationModule` rejestruje 5 nowych kluczy w `ServiceRegistry`.
|
||||
- Task 6: dokumentacja PAUL zaktualizowana (`architecture.md`, `quality_risks.md`, `tech_changelog.md`).
|
||||
|
||||
## Zmienione pliki
|
||||
|
||||
- `src/Modules/Statistics/OrdersStatisticsController.php`
|
||||
- `src/Modules/Statistics/OrdersStatisticsFilters.php`
|
||||
- `src/Modules/Statistics/OrdersStatisticsTableBuilder.php`
|
||||
- `src/Modules/Statistics/OrdersStatisticsSummaryBuilder.php`
|
||||
- `src/Modules/Statistics/StatisticsModule.php`
|
||||
- `src/Modules/Settings/AllegroIntegrationController.php`
|
||||
- `src/Modules/Settings/AllegroIntegrationViewModel.php`
|
||||
- `src/Modules/Settings/AllegroOAuthFlowService.php`
|
||||
- `src/Modules/Settings/AllegroImportScheduleService.php`
|
||||
- `src/Modules/Settings/AllegroImportImageWarningFormatter.php`
|
||||
- `src/Modules/Settings/AllegroSaveSettingsValidator.php`
|
||||
- `src/Modules/Settings/AllegroIntegrationModule.php`
|
||||
- `.paul/plans/20260519-1600-refactor-allegro-integration-controller/PLAN.md`
|
||||
- `.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md`
|
||||
- `.paul/codebase/architecture.md`
|
||||
- `.paul/codebase/quality_risks.md`
|
||||
- `.paul/codebase/tech_changelog.md`
|
||||
- `.paul/STATE.md`
|
||||
- `.paul/plans/20260519-1430-refactor-orders-statistics-controller/PLAN.md`
|
||||
- `.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md`
|
||||
@@ -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) |
|
||||
|
||||
@@ -22,8 +22,8 @@ Konwencja (`CLAUDE.md`): funkcja/klasa zwykle do 30-50 linii, max 3 poziomy zagn
|
||||
| `src/Modules/Accounting/InvoiceService.php` | 762 | |
|
||||
| `src/Modules/Automation/AutomationController.php` | 677 | |
|
||||
| `src/Modules/Shipments/DeliveryStatus.php` | 657 | encja statusow uzywana globalnie — zmiany dotykaja wszystkich integracji. |
|
||||
| `src/Modules/Settings/AllegroIntegrationController.php` | 653 | |
|
||||
| `src/Modules/Statistics/OrdersStatisticsController.php` | 640 | |
|
||||
| ~~`src/Modules/Settings/AllegroIntegrationController.php`~~ | ~~653~~ -> 223 | ✅ Zrefaktorowane 2026-05-19 — wydzielono `AllegroIntegrationViewModel` (61), `AllegroOAuthFlowService` (94), `AllegroImportScheduleService` (179), `AllegroImportImageWarningFormatter` (69), `AllegroSaveSettingsValidator` (48). Patrz `.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md`. |
|
||||
| ~~`src/Modules/Statistics/OrdersStatisticsController.php`~~ | ~~640~~ -> 110 | ✅ Zrefaktorowane 2026-05-19 — wydzielono `OrdersStatisticsFilters` (258), `OrdersStatisticsTableBuilder` (101), `OrdersStatisticsSummaryBuilder` (195). Patrz `.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md`. |
|
||||
| ~~`routes/web.php`~~ | ~~859~~ -> 78 | ✅ Zrefaktorowane 2026-05-19 — `ServiceRegistry` + 24 klasy `<Modul>Module.php`. Patrz `.paul/plans/20260519-1200-refactor-routes-web/SUMMARY.md`. |
|
||||
|
||||
## Luki testowe (krytyczne)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -237,3 +237,42 @@
|
||||
{"ts":"2026-05-19T19:24:54Z","tool":"Bash","cmd":"git add src/Core/Routing src/Modules routes/web.php tests/Unit/Core bin/smoke_routes.php .paul/codebase/architecture.md .paul/codebase/quality_risks.md .paul/codebase/tech_changelog.md .paul/STATE.md ","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T19:25:08Z","tool":"Bash","cmd":"git commit -m \"$(cat <<'EOF'\\nrefactor(routing): module providers + lazy ServiceRegistry\\n\\nRozbicie routes/web.php (859 lin.) na 24 klasy <Modul>Module.php zgodnie\\nz quality_risks.md priorytet ","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T19:25:16Z","tool":"Bash","cmd":"git log -1 --stat | tail -5; git status --short 2>&1 | head -5\",\"description\":\"Verify commit\"},\"tool_response\":{\"stdout\":\" src/Modules/Sms/SmsModule.php | 58 ++\\n src/","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T20:41:15Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\plans\\\\20260519-1430-refactor-orders-statistics-controller\\\\PLAN.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T20:41:23Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T20:43:18Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Statistics\\\\OrdersStatisticsFilters.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T20:43:37Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Statistics\\\\OrdersStatisticsTableBuilder.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T20:44:05Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Statistics\\\\OrdersStatisticsSummaryBuilder.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T20:44:24Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Statistics\\\\OrdersStatisticsController.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T20:44:34Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Statistics\\\\StatisticsModule.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T20:44:55Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\quality_risks.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T20:45:00Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\architecture.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T20:45:26Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\tech_changelog.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T20:45:54Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\plans\\\\20260519-1430-refactor-orders-statistics-controller\\\\SUMMARY.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T20:46:02Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T20:59:10Z","tool":"Bash","cmd":"git diff --name-only HEAD; echo ---; git status -s\",\"description\":\"Lista zmienionych plików\"},\"tool_response\":{\"stdout\":\"warning: in the working copy of '.paul/codebase/tech_changelog.md', ","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T20:59:28Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T20:59:41Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-05-19.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:03:27Z","tool":"Bash","cmd":"mkdir -p .paul/plans/20260519-1600-refactor-allegro-integration-controller\",\"description\":\"Utworz katalog planu\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\":false,\"isImag","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:05:55Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\plans\\\\20260519-1600-refactor-allegro-integration-controller\\\\PLAN.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:06:07Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:06:16Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:07:13Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroImportImageWarningFormatter.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:07:44Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroImportScheduleService.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:08:01Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroOAuthFlowService.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:08:16Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroIntegrationViewModel.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:09:01Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroIntegrationController.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:09:09Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroIntegrationViewModel.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:09:24Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroIntegrationModule.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:10:22Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroSaveSettingsValidator.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:11:01Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroIntegrationController.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:11:10Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroIntegrationModule.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:11:15Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\AllegroIntegrationModule.php","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:11:48Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\quality_risks.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:12:09Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\tech_changelog.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:12:22Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\architecture.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:13:30Z","tool":"Write","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\plans\\\\20260519-1600-refactor-allegro-integration-controller\\\\SUMMARY.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:13:47Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:13:58Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:14:09Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-05-19.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:14:17Z","tool":"Edit","file":"C:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-05-19.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-19T21:22:01Z","tool":"Write","file":"c:\\\\visual-studio-code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual-studio-code/projekty/orderPRO"}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
---
|
||||
plan_id: 20260519-1430-refactor-orders-statistics-controller
|
||||
title: Refaktoryzacja OrdersStatisticsController (640 lin.) – wydzielenie filtrów oraz builderów tabeli i podsumowania
|
||||
storage: plan-first
|
||||
legacy_phase: null
|
||||
created: 2026-05-19T14:30:00+02:00
|
||||
status: planned
|
||||
type: execute
|
||||
autonomous: true
|
||||
delegation: auto
|
||||
files_modified:
|
||||
- src/Modules/Statistics/OrdersStatisticsController.php
|
||||
- src/Modules/Statistics/OrdersStatisticsFilters.php
|
||||
- src/Modules/Statistics/OrdersStatisticsTableBuilder.php
|
||||
- src/Modules/Statistics/OrdersStatisticsSummaryBuilder.php
|
||||
- src/Modules/Statistics/StatisticsModule.php
|
||||
- .paul/codebase/architecture.md
|
||||
- .paul/codebase/quality_risks.md
|
||||
- .paul/codebase/tech_changelog.md
|
||||
quality_radar: ok
|
||||
---
|
||||
|
||||
<objective>
|
||||
## Cel
|
||||
Rozbic `src/Modules/Statistics/OrdersStatisticsController.php` (640 lin.) na slim kontroler + trzy wyspecjalizowane klasy pomocnicze: `OrdersStatisticsFilters`, `OrdersStatisticsTableBuilder`, `OrdersStatisticsSummaryBuilder`.
|
||||
|
||||
## Powod
|
||||
- Kontroler znacznie przekracza limit z `CLAUDE.md` (klasa do 30-50 lin. na metode, max 3 poziomy zagniezdzen) i pelni 3 odpowiedzialnosci: parsowanie/walidacja filtrow, budowa siatki dziennej, budowa podsumowania miesiecznego z seriami wykresow.
|
||||
- `quality_risks.md` (sekcja "Pliki przekraczajace 500 linii") wskazuje ten plik jako kandydata do dekompozycji.
|
||||
- Logika builderow nie ma testow — po wydzieleniu staje sie latwiejsza do pokrycia testami jednostkowymi (bez stubowania Template/Request/Auth).
|
||||
|
||||
## Wynik
|
||||
- Slim `OrdersStatisticsController` (cel: < 150 lin., wylacznie orkiestracja `index` / `summary`).
|
||||
- Trzy nowe klasy w tym samym namespace `App\Modules\Statistics` z jasnymi odpowiedzialnosciami.
|
||||
- `StatisticsModule` zaktualizowany (wstrzykiwanie nowych zaleznosci przez `ServiceRegistry`).
|
||||
- Brak zmian w kontrakcie HTTP (`/statistics/orders`, `/statistics/summary`), brak zmian w widokach (`resources/views/statistics/*`), brak zmian w `OrdersStatisticsRepository`.
|
||||
- Aktualizacja `.paul/codebase/architecture.md`, `quality_risks.md`, `tech_changelog.md`.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
## Dokumenty projektu
|
||||
@.paul/PROJECT.md
|
||||
@.paul/STATE.md
|
||||
@.paul/codebase/architecture.md
|
||||
@.paul/codebase/db_schema.md
|
||||
@.paul/codebase/impact_map.md
|
||||
@.paul/codebase/quality_risks.md
|
||||
|
||||
## Pliki zrodlowe
|
||||
@src/Modules/Statistics/OrdersStatisticsController.php
|
||||
@src/Modules/Statistics/OrdersStatisticsRepository.php
|
||||
@src/Modules/Statistics/StatisticsModule.php
|
||||
@routes/web.php
|
||||
@resources/views/statistics/orders.php
|
||||
@resources/views/statistics/summary.php
|
||||
</context>
|
||||
|
||||
<clarifications>
|
||||
- Brak dodatkowych pytan. Refaktor wewnetrzny modulu, bez zmian zachowania, kontraktu HTTP ani danych przekazywanych do widokow.
|
||||
- Zachowujemy konwencje "poor man's DI" — wstrzykiwanie przez konstruktor, fabryka w `StatisticsModule::register`.
|
||||
</clarifications>
|
||||
|
||||
<impact_scan>
|
||||
## Quality Radar
|
||||
|
||||
**Status:** ok
|
||||
**Tryb:** plan (codebase-memory-mcp odpytany; jscpd i ast-grep wylaczone polityka — `.paul/config.md`).
|
||||
**Zrodla:** `.paul/codebase/quality_risks.md` (linia 26), `.paul/codebase/impact_map.md` (sekcja Statistics).
|
||||
|
||||
## Obszary objete zmianami
|
||||
- `src/Modules/Statistics/*` — wszystkie pliki modulu (kontroler + module provider + 3 nowe klasy).
|
||||
- `.paul/codebase/{architecture.md,quality_risks.md,tech_changelog.md}` — dokumentacja po dekompozycji.
|
||||
|
||||
## Obszary stykowe (bez zmian, weryfikacja regresji)
|
||||
- `OrdersStatisticsRepository` (publiczne API: `listStatusGroups`, `listChannelOptions`, `statusCodesByGroupIds`, `aggregateByDay`, `aggregateByMonth`, `diagnostics`).
|
||||
- Widoki `resources/views/statistics/orders.php` i `resources/views/statistics/summary.php` — kontrakt zmiennych przekazywanych przez `template->render(...)` musi pozostac identyczny.
|
||||
- Routing: `routes/web.php` woła `$services->lazy('statistics.controller', 'index'|'summary')` — sygnatura akcji (`Request -> Response`) bez zmian.
|
||||
|
||||
## Ryzyka duplikatu / hardkodu
|
||||
- `isCancelledGroup()` (mapa polskich znakow) — pozostawiamy w `OrdersStatisticsFilters` jako prywatna metoda; nie dodajemy nowego "source of truth" dla normalizacji nazw, ale lokalna heurystyka pozostaje (rozwazyc wspolny helper przy nastepnej okazji — udokumentowane jako deferral).
|
||||
- `toStringList` / `toIntegerList` — proste helpery; pozostawiamy lokalnie w `OrdersStatisticsFilters` (deferral wspolnego utila).
|
||||
|
||||
## Jawne odroczenia
|
||||
- Wspolny helper normalizacji polskich znakow — poza zakresem. Reason: brak innego producenta wymagajacego dzis tego samego.
|
||||
- Testy jednostkowe builderow — poza zakresem tego planu (kandydat na osobny plan po wydzieleniu klas, zgodnie z rekomendacja w `quality_risks.md`).
|
||||
- Dekompozycja `OrdersStatisticsRepository.php` (901 lin.) — osobne ryzyko z `quality_risks.md`, nie ruszamy w tym planie.
|
||||
</impact_scan>
|
||||
|
||||
<skills>
|
||||
SPECIAL-FLOWS.md nieobecny — sekcja skills pominieta.
|
||||
</skills>
|
||||
|
||||
<acceptance_criteria>
|
||||
|
||||
## AC-1: Slim kontroler i nowe klasy istnieja
|
||||
```gherkin
|
||||
Given pliki `src/Modules/Statistics/OrdersStatisticsController.php` (< 150 lin.),
|
||||
`OrdersStatisticsFilters.php`, `OrdersStatisticsTableBuilder.php`, `OrdersStatisticsSummaryBuilder.php`
|
||||
When uruchomie `php -l` na kazdym z nich
|
||||
Then kazdy plik przechodzi parser bez bledow
|
||||
```
|
||||
|
||||
## AC-2: Zachowane sygnatury HTTP
|
||||
```gherkin
|
||||
Given dzialajaca aplikacja po refaktorze
|
||||
When wejde przegladarka na `/statistics/orders` oraz `/statistics/summary`
|
||||
z roznymi kombinacjami `date_from`, `date_to`, `channels[]`, `status_groups[]`, `debug=1`
|
||||
Then widoki renderuja sie bez bledow, a wartosci w tabelach i wykresach
|
||||
sa identyczne z wynikiem sprzed refaktoru dla tych samych danych
|
||||
```
|
||||
|
||||
## AC-3: Wstrzykiwanie zaleznosci przez ServiceRegistry
|
||||
```gherkin
|
||||
Given `src/Modules/Statistics/StatisticsModule.php`
|
||||
When `register(ServiceRegistry, Application)` zostanie wywolane
|
||||
Then klucz `statistics.controller` wciaz buduje sie leniwie i odpalany jest tylko
|
||||
gdy router dispatchuje route `/statistics/...`
|
||||
And kontroler dostaje 4 stare zaleznosci + 3 nowe collaboratory
|
||||
```
|
||||
|
||||
## AC-4: Dokumentacja zaktualizowana
|
||||
```gherkin
|
||||
Given dekompozycja zakonczona
|
||||
When sprawdze `.paul/codebase/architecture.md`, `quality_risks.md`, `tech_changelog.md`
|
||||
Then `architecture.md` opisuje nowy uklad klas modulu Statistics
|
||||
And `quality_risks.md` ma wpis o `OrdersStatisticsController.php` z linia liczby zaktualizowana lub zmieniony status (jak przy `routes/web.php`)
|
||||
And `tech_changelog.md` zawiera wpis z data 2026-05-19 i krotkim opisem refaktoru
|
||||
```
|
||||
|
||||
</acceptance_criteria>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Wydzielenie OrdersStatisticsFilters</name>
|
||||
<files>src/Modules/Statistics/OrdersStatisticsFilters.php, src/Modules/Statistics/OrdersStatisticsController.php</files>
|
||||
<action>
|
||||
Utworz `OrdersStatisticsFilters` (final class, namespace `App\Modules\Statistics`) i przeniesc do niej z kontrolera:
|
||||
- `resolveDateRange(Request): array{0:string,1:string}`
|
||||
- `resolveSummaryDateRange(Request): array{0:string,1:string}`
|
||||
- `mapStatusGroupOptions(array): array`
|
||||
- `resolveSelectedStatusGroups(Request, array): array`
|
||||
- `defaultStatusGroupIds(array): array`
|
||||
- `isCancelledGroup(string): bool`
|
||||
- `mapChannelOptions(array): array`
|
||||
- `resolveSelectedChannels(Request, array): array`
|
||||
- `isValidDate(string): bool`
|
||||
- `toStringList(mixed): array`
|
||||
- `toIntegerList(mixed): array`
|
||||
Metody publiczne: `resolveDailyFilters(Request, array $statusGroups, array $channelOptions): array` (i odpowiednik `resolveSummaryFilters(...)`) zwracajace strukture `{date_from, date_to, selected_status_groups, selected_channels}`.
|
||||
Pozostale metody prywatne. Klasa bezstanowa (brak zaleznosci konstruktora) — moze byc instancjowana bezposrednio.
|
||||
W kontrolerze podmien wywolania na delegacje do `OrdersStatisticsFilters`.
|
||||
</action>
|
||||
<verify>`php -l src/Modules/Statistics/OrdersStatisticsFilters.php` oraz `php -l src/Modules/Statistics/OrdersStatisticsController.php` zwracaja "No syntax errors".</verify>
|
||||
<done>AC-1, fundament dla AC-2.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wydzielenie OrdersStatisticsTableBuilder</name>
|
||||
<files>src/Modules/Statistics/OrdersStatisticsTableBuilder.php, src/Modules/Statistics/OrdersStatisticsController.php</files>
|
||||
<action>
|
||||
Utworz `OrdersStatisticsTableBuilder` (final class, namespace `App\Modules\Statistics`) i przeniesc:
|
||||
- `buildTable(string, string, array, array): array` -> jako publiczna `build(...)`,
|
||||
- `emptyChannelsRow(array): array` (prywatna),
|
||||
- `dateRange(string, string): array` (prywatna).
|
||||
Zachowaj 1:1 dotychczasowy ksztalt zwracanej struktury (`rows`, `totals`, `hasData`).
|
||||
Kontroler trzyma instancje przez DI i wola `tableBuilder->build($dateFrom, $dateTo, $selectedChannels, $aggregated)`.
|
||||
</action>
|
||||
<verify>`php -l` przechodzi; w kontrolerze brak metod `buildTable`, `emptyChannelsRow`, `dateRange`.</verify>
|
||||
<done>AC-1, AC-2.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Wydzielenie OrdersStatisticsSummaryBuilder</name>
|
||||
<files>src/Modules/Statistics/OrdersStatisticsSummaryBuilder.php, src/Modules/Statistics/OrdersStatisticsController.php</files>
|
||||
<action>
|
||||
Utworz `OrdersStatisticsSummaryBuilder` (final class, namespace `App\Modules\Statistics`) i przeniesc:
|
||||
- `buildSummary(string, string, array, array, array): array` -> publiczna `build(...)`,
|
||||
- `channelLabels(array): array`,
|
||||
- `emptySummaryRows(string, string, array): array`,
|
||||
- `emptySummaryChannels(array): array`,
|
||||
- `summaryPayload(array, array, array): array`,
|
||||
- `displayMonth(string): string`,
|
||||
- `summarySeries(array, array, array, string): array`,
|
||||
- `totalSeries(array, string, string): array`,
|
||||
- `summaryHasData(array): bool`,
|
||||
- `monthRange(string, string): array`.
|
||||
Zachowaj identyczna strukture wyniku (klucze `months`, `rows`, `hasData`, `countChart`, `valueChart`).
|
||||
</action>
|
||||
<verify>`php -l` przechodzi; w kontrolerze pozostaje wylacznie wywolanie `summaryBuilder->build(...)`.</verify>
|
||||
<done>AC-1, AC-2.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Slim OrdersStatisticsController i aktualizacja StatisticsModule</name>
|
||||
<files>src/Modules/Statistics/OrdersStatisticsController.php, src/Modules/Statistics/StatisticsModule.php</files>
|
||||
<action>
|
||||
1. Konstruktor `OrdersStatisticsController` rozszerz o trzy nowe parametry:
|
||||
`private readonly OrdersStatisticsFilters $filters,
|
||||
private readonly OrdersStatisticsTableBuilder $tableBuilder,
|
||||
private readonly OrdersStatisticsSummaryBuilder $summaryBuilder`.
|
||||
2. Metody `index` i `summary` powinny tylko: pobrac dane referencyjne z repo, zlozyc filtry przez `$this->filters`, wywolac builder, przekazac wynik do `template->render(...)` z identycznym zestawem zmiennych jak przed refaktorem.
|
||||
3. W `StatisticsModule::register` zarejestruj fabryki:
|
||||
- `statistics.filters` -> `new OrdersStatisticsFilters()`,
|
||||
- `statistics.table_builder` -> `new OrdersStatisticsTableBuilder()`,
|
||||
- `statistics.summary_builder` -> `new OrdersStatisticsSummaryBuilder()`,
|
||||
a fabryka `statistics.controller` pobiera te trzy klucze przez `$services->get(...)` w closure.
|
||||
4. Cel wielkosci: kontroler < 150 lin. (`wc -l` lub `Get-Content | Measure-Object -Line`).
|
||||
</action>
|
||||
<verify>
|
||||
`php -l` na obu plikach. `(Get-Content src/Modules/Statistics/OrdersStatisticsController.php | Measure-Object -Line).Lines` < 150.
|
||||
Reczne smoke-test: `php -S 127.0.0.1:8000 -t public` + wizyta na `/statistics/orders` i `/statistics/summary`.
|
||||
</verify>
|
||||
<done>AC-1, AC-2, AC-3.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Aktualizacja dokumentacji PAUL</name>
|
||||
<files>.paul/codebase/architecture.md, .paul/codebase/quality_risks.md, .paul/codebase/tech_changelog.md</files>
|
||||
<action>
|
||||
- `architecture.md`: w sekcji "Moduly domenowe" / "Warstwy" doprecyzuj, ze modul Statistics sklada sie z `Controller`, `Filters`, `TableBuilder`, `SummaryBuilder`, `Repository`.
|
||||
- `quality_risks.md`: zaktualizuj wiersz `src/Modules/Statistics/OrdersStatisticsController.php` (640) na nowa liczbe linii i dopisz notatke "✅ Zrefaktorowane 2026-05-19 — wydzielono Filters/TableBuilder/SummaryBuilder. Patrz .paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md". W "Rekomendacje (priorytet)" mozna dopisac status "czesciowo zrobione".
|
||||
- `tech_changelog.md`: dopisac wpis z data 2026-05-19 ze skrocon lista zmian i powodem (dekompozycja kontrolera, brak zmian kontraktu HTTP).
|
||||
</action>
|
||||
<verify>`Select-String -Path .paul/codebase/quality_risks.md -Pattern 'OrdersStatisticsController'` pokazuje zaktualizowany wpis.</verify>
|
||||
<done>AC-4.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<boundaries>
|
||||
## Nie ruszac
|
||||
- `OrdersStatisticsRepository.php` — publiczne metody i SQL bez zmian.
|
||||
- `resources/views/statistics/orders.php` oraz `resources/views/statistics/summary.php` — kontrakt zmiennych przekazywanych do widokow musi pozostac dokladnie taki sam.
|
||||
- `routes/web.php` — `StatisticsModule` dalej rejestruje `/statistics/summary` i `/statistics/orders` z tymi samymi `lazy` kluczami akcji.
|
||||
- Konwencja "poor man's DI" w `ServiceRegistry` — bez autowire, bez refleksji.
|
||||
|
||||
## Poza zakresem
|
||||
- Refaktor `OrdersStatisticsRepository.php` (901 lin.) — osobny plan.
|
||||
- Testy phpunit dla builderow — kandydat na osobny plan.
|
||||
- Zmiana formatu zwracanych danych (np. nowe pola w `summary`), zmiana widokow lub CSS.
|
||||
- Wprowadzenie wspolnego helpera normalizacji polskich znakow.
|
||||
</boundaries>
|
||||
|
||||
<verification>
|
||||
- [ ] `php -l` na 4 plikach modulu Statistics przechodzi.
|
||||
- [ ] `(Get-Content src/Modules/Statistics/OrdersStatisticsController.php | Measure-Object -Line).Lines` < 150.
|
||||
- [ ] Smoke-test reczny `/statistics/orders` i `/statistics/summary` (default range + custom range + `debug=1` + wybor channels i status_groups) — porownanie wynikow z gita sprzed refaktoru (`git stash` lub osobny worktree).
|
||||
- [ ] Brak nowych ostrzezen w `storage/logs/`.
|
||||
- [ ] Quality Radar: ryzyka z `quality_risks.md` (linia OrdersStatisticsController) obsluzone.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] Wszystkie AC (AC-1..AC-4) zaliczone.
|
||||
- [ ] Lista linii w plikach: Controller < 150, Filters < 250, TableBuilder < 150, SummaryBuilder < 300 (przyblizone targety).
|
||||
- [ ] Brak zmian w widokach i repository.
|
||||
- [ ] Dokumentacja PAUL zaktualizowana, SUMMARY.md utworzony w `.paul/plans/20260519-1430-refactor-orders-statistics-controller/`.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
SUMMARY.md path: `.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md`
|
||||
</output>
|
||||
@@ -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.
|
||||
@@ -0,0 +1,292 @@
|
||||
---
|
||||
plan_id: 20260519-1600-refactor-allegro-integration-controller
|
||||
title: Refaktoryzacja AllegroIntegrationController (653 lin.) — slim kontroler + ViewModel + OAuth/Schedule/Warning services
|
||||
storage: plan-first
|
||||
legacy_phase: null
|
||||
created: 2026-05-19T16:00:00+02:00
|
||||
status: planned
|
||||
type: execute
|
||||
autonomous: true
|
||||
delegation: auto
|
||||
files_modified:
|
||||
- src/Modules/Settings/AllegroIntegrationController.php
|
||||
- src/Modules/Settings/AllegroIntegrationViewModel.php
|
||||
- src/Modules/Settings/AllegroOAuthFlowService.php
|
||||
- src/Modules/Settings/AllegroImportScheduleService.php
|
||||
- src/Modules/Settings/AllegroImportImageWarningFormatter.php
|
||||
- src/Modules/Settings/AllegroIntegrationModule.php
|
||||
- .paul/codebase/architecture.md
|
||||
- .paul/codebase/quality_risks.md
|
||||
- .paul/codebase/tech_changelog.md
|
||||
quality_radar: ok
|
||||
---
|
||||
|
||||
<objective>
|
||||
## Cel
|
||||
Rozbic `src/Modules/Settings/AllegroIntegrationController.php` (653 lin.) na slim kontroler (6 akcji HTTP) + 4 wyspecjalizowane klasy pomocnicze w namespace `App\Modules\Settings`:
|
||||
|
||||
- `AllegroIntegrationViewModel` — sklada `$data` dla widoku `settings/allegro` (delegacje do repozytoriow + delivery controller).
|
||||
- `AllegroOAuthFlowService` — orkiestracja OAuth (build authorize URL, walidacja `state`, wymiana `code` -> token, zapis tokenu); hermetyzuje `OAUTH_SCOPES`, `OAUTH_STATE_SESSION_KEY`.
|
||||
- `AllegroImportScheduleService` — operacje na harmonogramie cron (`allegro_orders_import`, `allegro_status_sync`), `current*Interval*` gettery, `ensureDefaults`, walidacja `saveImportSettings`, dozwolone kierunki sync.
|
||||
- `AllegroImportImageWarningFormatter` — `buildImportImageWarningMessage` + `reasonLabel`.
|
||||
|
||||
## Powod
|
||||
- Kontroler przekracza limit z `CLAUDE.md` (klasa/metoda do 30-50 lin., max 3 poziomy zagniezdzen) i pelni 4 odpowiedzialnosci: view-model dla `index`, flow OAuth, zarzadzanie harmonogramem importu/sync, formatowanie warningow z importu pojedynczego zamowienia.
|
||||
- `quality_risks.md` wskazuje plik jako kandydata do dekompozycji (`AllegroIntegrationController.php` 653 lin.).
|
||||
- Po wydzieleniu service'y staja sie testowalne jednostkowo (bezstanowe + jawne zaleznosci), kontroler upraszcza sie do orkiestracji `Request -> walidacja -> service -> Flash -> Response`.
|
||||
|
||||
## Wynik
|
||||
- Slim `AllegroIntegrationController` (cel: < 200 lin., wylacznie orkiestracja 6 akcji HTTP).
|
||||
- 4 nowe klasy w `src/Modules/Settings/` (namespace `App\Modules\Settings`).
|
||||
- `AllegroIntegrationModule` zaktualizowany (4 nowe klucze w `ServiceRegistry`, leniwa rejestracja).
|
||||
- Brak zmian w kontrakcie HTTP (12 route'ow w `routes/web.php` przez `AllegroIntegrationModule::routes`), brak zmian w widoku `resources/views/settings/allegro.php`, brak zmian w repozytoriach.
|
||||
- Aktualizacja `.paul/codebase/architecture.md`, `quality_risks.md`, `tech_changelog.md`.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
## Dokumenty projektu
|
||||
@.paul/PROJECT.md
|
||||
@.paul/STATE.md
|
||||
@.paul/codebase/architecture.md
|
||||
@.paul/codebase/db_schema.md
|
||||
@.paul/codebase/impact_map.md
|
||||
@.paul/codebase/quality_risks.md
|
||||
|
||||
## Pliki zrodlowe
|
||||
@src/Modules/Settings/AllegroIntegrationController.php
|
||||
@src/Modules/Settings/AllegroIntegrationModule.php
|
||||
@src/Modules/Settings/AllegroIntegrationRepository.php
|
||||
@src/Modules/Settings/AllegroOAuthClient.php
|
||||
@src/Modules/Settings/AllegroDeliveryMappingController.php
|
||||
@routes/web.php
|
||||
@resources/views/settings/allegro.php
|
||||
</context>
|
||||
|
||||
<clarifications>
|
||||
- Brak dodatkowych pytan. Refaktor wewnetrzny modulu — bez zmian zachowania, kontraktu HTTP, kontraktu widoku ani SQL.
|
||||
- Zachowujemy konwencje "poor man's DI" — wstrzykiwanie przez konstruktor, fabryki w `AllegroIntegrationModule::register`.
|
||||
- Bazujemy na wzorcu uzytym w `OrdersStatisticsController` (slim kontroler + bezstanowe builders/services).
|
||||
</clarifications>
|
||||
|
||||
<impact_scan>
|
||||
## Quality Radar
|
||||
|
||||
**Status:** ok
|
||||
**Tryb:** plan (codebase-memory-mcp odpytany; jscpd i ast-grep wylaczone polityka — `.paul/config.md`).
|
||||
**Zrodla:** `.paul/codebase/quality_risks.md` (wpis `AllegroIntegrationController.php` 653 lin., sekcja "Pliki przekraczajace 500 linii"), `.paul/codebase/impact_map.md` (sekcja Integracje per dostawca).
|
||||
|
||||
## Obszary objete zmianami
|
||||
- `src/Modules/Settings/AllegroIntegrationController.php` — slim refactor.
|
||||
- `src/Modules/Settings/AllegroIntegrationModule.php` — 4 nowe klucze w `ServiceRegistry` + injection do kontrolera.
|
||||
- 4 nowe pliki: `AllegroIntegrationViewModel.php`, `AllegroOAuthFlowService.php`, `AllegroImportScheduleService.php`, `AllegroImportImageWarningFormatter.php`.
|
||||
- `.paul/codebase/{architecture.md,quality_risks.md,tech_changelog.md}`.
|
||||
|
||||
## Obszary stykowe (bez zmian, weryfikacja regresji)
|
||||
- `routes/web.php` — `AllegroIntegrationModule::routes` rejestruje 12 sciezek pod kluczem `integrations.allegro.controller` (oraz `.status_mapping_controller`, `.delivery_mapping_controller`). Metody publiczne kontrolera musza zachowac nazwy i sygnatury `Request -> Response`.
|
||||
- `AllegroIntegrationRepository`, `AllegroOAuthClient`, `AllegroOrderImportService`, `AllegroStatusDiscoveryService`, `AllegroStatusMappingRepository`, `AllegroPullStatusMappingRepository`, `OrderStatusRepository`, `CronRepository`, `AllegroDeliveryMappingController` — bez zmian, kontroler nadal je trzyma (lub przekazuje do view modelu / OAuth service / schedule service).
|
||||
- Widok `resources/views/settings/allegro.php` — kontrakt zmiennych `$settings`, `$activeTab`, `$importIntervalSeconds`, `$statusSyncDirection`, `$statusSyncIntervalMinutes`, `$statusMappings`, `$pullStatusMappings`, `$orderproStatuses`, `$allegroStatuses`, `$defaultRedirectUri`, `$errorMessage`, `$successMessage`, `$warningMessage`, `$deliveryMappings`, `$orderDeliveryMethods`, `$allegroDeliveryServices`, `$apaczkaDeliveryServices`, `$allegroDeliveryServicesError`, `$inpostDeliveryServices` musi pozostac niezmieniony.
|
||||
|
||||
## Ryzyka duplikatu / hardkodu
|
||||
- Stale `OAUTH_STATE_SESSION_KEY`, `ORDERS_IMPORT_JOB_TYPE`, `STATUS_SYNC_JOB_TYPE`, `STATUS_SYNC_DIRECTION_*`, `ORDERS_IMPORT_DEFAULT_*`, `OAUTH_SCOPES` przeniesione 1:1 do wlasciwych klas service'ow (single source of truth per domena). Nie tworzymy duplikatow.
|
||||
- `isValidHttpUrl`, `isValidDate`, `validateSaveInput`, `validateImportSettingsInput`, `validateOAuthCallbackParams` — `isValidHttpUrl`/`isValidDate` zostaja w kontrolerze (uzywane przez `validateSaveInput`), reszta w odpowiednich service'ach. Deferral wspolnego helpera walidacji URL/Date (osobny producent nie istnieje).
|
||||
- `findImportSchedule` i `findStatusSyncSchedule` — bardzo podobne (foreach z filtrem po `job_type`). Po przeniesieniu do `AllegroImportScheduleService` mozna zlozyc w jedna prywatna `findScheduleByType(string)`.
|
||||
|
||||
## Jawne odroczenia
|
||||
- Bazowy `BaseIntegrationController` lub trait dla wszystkich integracji (Allegro / Apaczka / Erli / Inpost / Polkurier / Shoppro / Fakturownia / HostedSms / Smsplanet) — poza zakresem. Reason: rekomendacja z `quality_risks.md` punkt 3, wymaga osobnej analizy 9 kontrolerow.
|
||||
- Testy phpunit dla nowych service'ow — poza zakresem (kandydat na osobny plan po wydzieleniu).
|
||||
- Refaktor `AllegroOrderImportService.php` (poprzednio 834 lin. wedlug `quality_risks.md`) — osobne ryzyko, nie ruszamy.
|
||||
</impact_scan>
|
||||
|
||||
<skills>
|
||||
SPECIAL-FLOWS.md nieobecny — sekcja skills pominieta.
|
||||
</skills>
|
||||
|
||||
<acceptance_criteria>
|
||||
|
||||
## AC-1: Slim kontroler i 4 nowe klasy istnieja
|
||||
```gherkin
|
||||
Given pliki `src/Modules/Settings/AllegroIntegrationController.php` (< 200 lin.),
|
||||
`AllegroIntegrationViewModel.php`, `AllegroOAuthFlowService.php`,
|
||||
`AllegroImportScheduleService.php`, `AllegroImportImageWarningFormatter.php`
|
||||
When uruchomie `php -l` na kazdym z nich
|
||||
Then kazdy plik przechodzi parser bez bledow
|
||||
```
|
||||
|
||||
## AC-2: Zachowane sygnatury HTTP i kontrakt widoku
|
||||
```gherkin
|
||||
Given dzialajaca aplikacja po refaktorze
|
||||
When wejde przegladarka na `/settings/integrations/allegro` (wszystkie 4 zakladki: integration, statuses, settings, delivery)
|
||||
i wykonam akcje: zapis ustawien, zapis import settings, start OAuth, callback OAuth, import pojedynczego zamowienia
|
||||
Then widok renderuje sie bez bledow, wartosci sa identyczne jak sprzed refaktoru,
|
||||
Flash messages (success/error/warning) pojawiaja sie w tych samych miejscach,
|
||||
redirecty trafiaja w te same URL-e
|
||||
```
|
||||
|
||||
## AC-3: Wstrzykiwanie zaleznosci przez ServiceRegistry
|
||||
```gherkin
|
||||
Given `src/Modules/Settings/AllegroIntegrationModule.php`
|
||||
When `register(ServiceRegistry, Application)` zostanie wywolane
|
||||
Then klucze `integrations.allegro.view_model`, `integrations.allegro.oauth_flow`,
|
||||
`integrations.allegro.import_schedule_service`, `integrations.allegro.image_warning_formatter`
|
||||
buduja sie leniwie
|
||||
And `integrations.allegro.controller` dostaje slim zestaw zaleznosci (4 nowe collaboratory + niezbedne repo/translator/template/auth/csrf), wciaz leniwy
|
||||
```
|
||||
|
||||
## AC-4: Dokumentacja zaktualizowana
|
||||
```gherkin
|
||||
Given dekompozycja zakonczona
|
||||
When sprawdze `.paul/codebase/architecture.md`, `quality_risks.md`, `tech_changelog.md`
|
||||
Then `architecture.md` w sekcji "Moduly domenowe" / "Integracje" wspomina o slim `AllegroIntegrationController` + 4 wspolpracownikach
|
||||
And `quality_risks.md` ma wpis `AllegroIntegrationController.php` zaktualizowany (przekreslone 653, nowa liczba linii) ze wskazaniem na SUMMARY.md
|
||||
And `tech_changelog.md` zawiera wpis 2026-05-19 z krotkim opisem refaktoru
|
||||
```
|
||||
|
||||
</acceptance_criteria>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Wydzielenie AllegroImportImageWarningFormatter</name>
|
||||
<files>src/Modules/Settings/AllegroImportImageWarningFormatter.php, src/Modules/Settings/AllegroIntegrationController.php</files>
|
||||
<action>
|
||||
Utworz `AllegroImportImageWarningFormatter` (final class, namespace `App\Modules\Settings`) z konstruktorem `(Translator $translator)` i przeniesc 1:1:
|
||||
- publiczna `format(array $imageDiagnostics): string` (z `buildImportImageWarningMessage`),
|
||||
- prywatna `reasonLabel(string): string`.
|
||||
W kontrolerze podmien wywolanie `$this->buildImportImageWarningMessage(...)` na `$this->warningFormatter->format(...)` i usun obie metody.
|
||||
</action>
|
||||
<verify>`php -l src/Modules/Settings/AllegroImportImageWarningFormatter.php` oraz `php -l src/Modules/Settings/AllegroIntegrationController.php` zwracaja "No syntax errors".</verify>
|
||||
<done>AC-1 (czesc), fundament dla AC-2.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wydzielenie AllegroImportScheduleService</name>
|
||||
<files>src/Modules/Settings/AllegroImportScheduleService.php, src/Modules/Settings/AllegroIntegrationController.php</files>
|
||||
<action>
|
||||
Utworz `AllegroImportScheduleService` (final class, namespace `App\Modules\Settings`) z konstruktorem `(CronRepository $cronRepository, Translator $translator)`. Przenies stale:
|
||||
- `ORDERS_IMPORT_JOB_TYPE`, `STATUS_SYNC_JOB_TYPE`,
|
||||
- `ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS`, `ORDERS_IMPORT_DEFAULT_PRIORITY`, `ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS`, `ORDERS_IMPORT_DEFAULT_PAYLOAD`,
|
||||
- `STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO`, `STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO`, `STATUS_SYNC_DEFAULT_INTERVAL_MINUTES`.
|
||||
Przenies metody:
|
||||
- `currentImportIntervalSeconds(): int`,
|
||||
- `currentStatusSyncDirection(): string`,
|
||||
- `currentStatusSyncIntervalMinutes(): int`,
|
||||
- `allowedStatusSyncDirections(): array` (publiczna lub prywatna; w MVP prywatna, wystawic geter jesli widok jej potrzebuje — nie potrzebuje),
|
||||
- `ensureDefaultSchedulesExist(): void`,
|
||||
- `validateImportSettingsInput(int, int, string, int, int): ?string`,
|
||||
- prywatne `findImportSchedule()` i `findStatusSyncSchedule()` (uprosc do jednej `findScheduleByType(string $jobType): array`).
|
||||
Dodaj publiczna metode `applyImportSettings(int $intervalMinutes, string $statusSyncDirection, int $statusSyncIntervalMinutes): void` zamykajaca dwa `upsertSchedule` + 2 `upsertSetting` (logika z `saveImportSettings`).
|
||||
W kontrolerze `saveImportSettings` redukuje sie do: parse + walidacja przez `$this->scheduleService` + try/catch + Flash + redirect.
|
||||
Pozostale uzycia (`index`: `currentImportIntervalSeconds`, `currentStatusSyncDirection`, `currentStatusSyncIntervalMinutes`, `ensureDefaultSchedulesExist`; `save`: `ensureDefaultSchedulesExist`) przelacz na `$this->scheduleService->...()`.
|
||||
</action>
|
||||
<verify>`php -l` na obu plikach. W kontrolerze brak metod `findImportSchedule`, `findStatusSyncSchedule`, `current*`, `ensureDefaultSchedulesExist`, `validateImportSettingsInput`, `allowedStatusSyncDirections` oraz brak stalych `ORDERS_IMPORT_*` i `STATUS_SYNC_*`.</verify>
|
||||
<done>AC-1 (czesc), AC-2 (saveImportSettings).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Wydzielenie AllegroOAuthFlowService</name>
|
||||
<files>src/Modules/Settings/AllegroOAuthFlowService.php, src/Modules/Settings/AllegroIntegrationController.php</files>
|
||||
<action>
|
||||
Utworz `AllegroOAuthFlowService` (final class, namespace `App\Modules\Settings`) z konstruktorem `(AllegroOAuthClient $oauthClient, AllegroIntegrationRepository $repository, Translator $translator)`. Przenies stale:
|
||||
- `OAUTH_STATE_SESSION_KEY`, `OAUTH_SCOPES`.
|
||||
Publiczne API:
|
||||
- `buildAuthorizeUrl(): string` — generuje state (random_bytes), zapisuje do sesji (`Session::set`), zwraca authorize URL z `AllegroOAuthClient::buildAuthorizeUrl` (rzuca `IntegrationConfigException` jesli brak credentials).
|
||||
- `exchangeAuthorizationCode(string $state, string $authorizationCode): void` — pobiera expected state z `Session::pull`, woła `validateOAuthCallbackParams`, wymienia kod, liczy expiresAt, `repository->saveTokens(...)` (rzuca Throwable z opisem bledu).
|
||||
Prywatne:
|
||||
- `requireOAuthCredentials(): array`,
|
||||
- `validateOAuthCallbackParams(string, string, string): ?string`.
|
||||
W kontrolerze `startOAuth` redukuje sie do: csrf + try { redirect = $this->oauthFlow->buildAuthorizeUrl(); } catch (...) { flash + redirect }.
|
||||
`oauthCallback` redukuje sie do: sprawdzenie `error`, pobranie `state`/`code` z Request, try { $this->oauthFlow->exchangeAuthorizationCode(...); flash_success } catch (...) { flash_error }, redirect.
|
||||
</action>
|
||||
<verify>`php -l` na obu plikach. Kontroler nie zawiera juz `OAUTH_*` stalych, `requireOAuthCredentials`, `validateOAuthCallbackParams`.</verify>
|
||||
<done>AC-1 (czesc), AC-2 (startOAuth + oauthCallback).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Wydzielenie AllegroIntegrationViewModel</name>
|
||||
<files>src/Modules/Settings/AllegroIntegrationViewModel.php, src/Modules/Settings/AllegroIntegrationController.php</files>
|
||||
<action>
|
||||
Utworz `AllegroIntegrationViewModel` (final class, namespace `App\Modules\Settings`) z konstruktorem `(AllegroIntegrationRepository $repository, AllegroStatusMappingRepository $statusMappings, AllegroPullStatusMappingRepository $pullStatusMappings, OrderStatusRepository $orderStatuses, AllegroImportScheduleService $scheduleService, AllegroDeliveryMappingController $deliveryController, string $defaultRedirectUri)`.
|
||||
Publiczna `build(string $activeEnv, string $tab): array` zwraca pelny payload do widoku (klucze identyczne jak obecne `$data` w `template->render('settings/allegro', ...)`), bez kluczy zwiazanych z layoutem (`title`, `activeMenu`, `activeSettings`, `user`, `csrfToken`, `errorMessage`, `successMessage`, `warningMessage`) — te zostaja w kontrolerze (zaleza od translator/auth/flash).
|
||||
Kontroler w `index`:
|
||||
- pobiera/normalizuje `env` i `tab` (krotkie inline'y),
|
||||
- `$viewData = $this->viewModel->build($activeEnv, $tab);`
|
||||
- dokleja layoutowe klucze (`title`, `activeMenu`, `activeSettings`, `user`, `csrfToken`, `errorMessage`, `successMessage`, `warningMessage`),
|
||||
- `template->render('settings/allegro', $viewData, 'layouts/app')`.
|
||||
Cel: metoda `index` < 35 lin.
|
||||
</action>
|
||||
<verify>`php -l` na obu plikach. Smoke-test (po Task 5) potwierdza identyczne klucze przekazywane do widoku.</verify>
|
||||
<done>AC-1 (czesc), AC-2 (index), fundament dla AC-3.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Slim controller + aktualizacja AllegroIntegrationModule</name>
|
||||
<files>src/Modules/Settings/AllegroIntegrationController.php, src/Modules/Settings/AllegroIntegrationModule.php</files>
|
||||
<action>
|
||||
1. Skrocenie konstruktora `AllegroIntegrationController` — pozostawic:
|
||||
`(Template, Translator, AuthService, AllegroIntegrationRepository, AllegroIntegrationViewModel, AllegroOAuthFlowService, AllegroImportScheduleService, AllegroImportImageWarningFormatter, AllegroOrderImportService)`.
|
||||
Repozytoria mapowan statusow, `OrderStatusRepository`, `CronRepository`, `AllegroOAuthClient`, `AllegroStatusDiscoveryService`, `$appUrl`, `AllegroDeliveryMappingController` — zostaja jako zaleznosci nowych klas (lub w `viewModel`/`oauthFlow`/`scheduleService`), nie w kontrolerze. (`AllegroDeliveryMappingController` nadal trzymany przez `viewModel`; nieuzywany w controllerze).
|
||||
2. Akcje publiczne kontrolera bez zmian sygnatur: `index`, `save`, `saveImportSettings`, `startOAuth`, `oauthCallback`, `importSingleOrder`. Kazda < 40 lin.
|
||||
3. W kontrolerze pozostaja prywatne: `validateCsrf`, `validateSaveInput`, `isValidHttpUrl`, `isValidDate`, `defaultRedirectUri` (uzywane przy `save`).
|
||||
4. W `AllegroIntegrationModule::register` dorejestruj 4 nowe klucze (leniwe):
|
||||
- `integrations.allegro.image_warning_formatter` -> `new AllegroImportImageWarningFormatter($app->translator())`,
|
||||
- `integrations.allegro.import_schedule_service` -> `new AllegroImportScheduleService($s->get('shared.cron.repo'), $app->translator())`,
|
||||
- `integrations.allegro.oauth_flow` -> `new AllegroOAuthFlowService($s->get('integrations.allegro.oauth'), $s->get('integrations.allegro.repo'), $app->translator())`,
|
||||
- `integrations.allegro.view_model` -> `new AllegroIntegrationViewModel($s->get('integrations.allegro.repo'), $s->get('integrations.allegro.status_mapping_repo'), $s->get('integrations.allegro.pull_status_mapping_repo'), $app->orderStatuses(), $s->get('integrations.allegro.import_schedule_service'), $s->get('integrations.allegro.delivery_mapping_controller'), $this->defaultRedirectUri((string) $app->config('app.url', '')))` — `defaultRedirectUri` mozna wyliczyc inline (jak w obecnym kontrolerze) lub przeniesc jako helper modulu.
|
||||
Fabryka `integrations.allegro.controller` aktualizuje konstruktor do nowej slim sygnatury.
|
||||
5. Cel wielkosci: kontroler < 200 lin. (`(Get-Content src/Modules/Settings/AllegroIntegrationController.php | Measure-Object -Line).Lines`).
|
||||
</action>
|
||||
<verify>
|
||||
`php -l` na obu plikach.
|
||||
`(Get-Content src/Modules/Settings/AllegroIntegrationController.php | Measure-Object -Line).Lines` < 200.
|
||||
Smoke-test: `php -S 127.0.0.1:8000 -t public` + wizyta na `/settings/integrations/allegro` (4 zakladki), `save`, `saveImportSettings` (zmiana interwalu), `startOAuth` (state w sesji), `importSingleOrder` (existing checkoutFormId), `oauthCallback` (z poprawnym/niepoprawnym state — gallery wszystkie flash branche).
|
||||
</verify>
|
||||
<done>AC-1, AC-2, AC-3.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Aktualizacja dokumentacji PAUL</name>
|
||||
<files>.paul/codebase/architecture.md, .paul/codebase/quality_risks.md, .paul/codebase/tech_changelog.md</files>
|
||||
<action>
|
||||
- `architecture.md`: w sekcji "Moduly domenowe" / "Integracje" dopisac, ze modul Allegro sklada sie z `Controller` (slim), `ViewModel`, `OAuthFlowService`, `ImportScheduleService`, `ImportImageWarningFormatter`, `OrderImportService`, `StatusDiscoveryService`, `StatusMappingController`, `DeliveryMappingController`, `Repository`, `OAuthClient`, `ApiClient`.
|
||||
- `quality_risks.md`: zaktualizowac wiersz `src/Modules/Settings/AllegroIntegrationController.php` (653) na nowa liczbe linii (przekreslenie + notatka "✅ Zrefaktorowane 2026-05-19 — wydzielono ViewModel/OAuthFlow/ImportSchedule/WarningFormatter. Patrz `.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md`").
|
||||
- `tech_changelog.md`: wpis z data 2026-05-19 ze skrocona lista zmian (slim kontroler + 4 nowe klasy + 4 nowe klucze ServiceRegistry; brak zmian kontraktu HTTP/widoku/repo).
|
||||
</action>
|
||||
<verify>`Select-String -Path .paul/codebase/quality_risks.md -Pattern 'AllegroIntegrationController'` pokazuje zaktualizowany wpis.</verify>
|
||||
<done>AC-4.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<boundaries>
|
||||
## Nie ruszac
|
||||
- `AllegroIntegrationRepository.php`, `AllegroOAuthClient.php`, `AllegroOrderImportService.php`, `AllegroStatusDiscoveryService.php`, `AllegroStatusMappingRepository.php`, `AllegroPullStatusMappingRepository.php`, `OrderStatusRepository.php`, `CronRepository.php`, `AllegroDeliveryMappingController.php`, `AllegroStatusMappingController.php` — publiczne API bez zmian.
|
||||
- `resources/views/settings/allegro.php` — kontrakt zmiennych identyczny.
|
||||
- `routes/web.php` — `AllegroIntegrationModule::routes` rejestruje te same 12 sciezek (klucze i metody bez zmian).
|
||||
- Konwencja "poor man's DI" w `ServiceRegistry` — bez autowire, bez refleksji.
|
||||
|
||||
## Poza zakresem
|
||||
- Bazowy `BaseIntegrationController` lub trait dla 9 integracji — osobny plan.
|
||||
- Testy phpunit nowych service'ow — kandydat na osobny plan po wydzieleniu.
|
||||
- Refaktor `AllegroOrderImportService.php` — osobne ryzyko z `quality_risks.md`.
|
||||
- Wspolny helper walidacji URL/Date (`isValidHttpUrl`/`isValidDate`) — pozostaje lokalnie w kontrolerze do `validateSaveInput`.
|
||||
- Wspolny helper Csrf+Flash+Redirect — poza zakresem.
|
||||
</boundaries>
|
||||
|
||||
<verification>
|
||||
- [ ] `php -l` na 5 plikach modulu (`Controller`, `ViewModel`, `OAuthFlowService`, `ImportScheduleService`, `ImportImageWarningFormatter`) i na `AllegroIntegrationModule.php` przechodzi.
|
||||
- [ ] `(Get-Content src/Modules/Settings/AllegroIntegrationController.php | Measure-Object -Line).Lines` < 200.
|
||||
- [ ] Smoke-test reczny: `/settings/integrations/allegro` (4 zakladki), `save`, `saveImportSettings`, `startOAuth` (state w sesji), `importSingleOrder` (z poprawnym i pustym `checkout_form_id`), `oauthCallback` (poprawny state, niepoprawny state, error param). Porownanie z gita sprzed refaktoru.
|
||||
- [ ] Brak nowych ostrzezen w `storage/logs/`.
|
||||
- [ ] Quality Radar: ryzyko z `quality_risks.md` (linia `AllegroIntegrationController`) obsluzone.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] Wszystkie AC (AC-1..AC-4) zaliczone.
|
||||
- [ ] Targety wielkosci (przyblizone): `Controller` < 200, `ViewModel` < 120, `OAuthFlowService` < 130, `ImportScheduleService` < 220, `ImportImageWarningFormatter` < 90.
|
||||
- [ ] Brak zmian w widoku `settings/allegro` i w repozytoriach.
|
||||
- [ ] Dokumentacja PAUL zaktualizowana, SUMMARY.md utworzony w `.paul/plans/20260519-1600-refactor-allegro-integration-controller/`.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
SUMMARY.md path: `.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md`
|
||||
</output>
|
||||
@@ -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.
|
||||
80
src/Modules/Settings/AllegroImportImageWarningFormatter.php
Normal file
80
src/Modules/Settings/AllegroImportImageWarningFormatter.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\I18n\Translator;
|
||||
|
||||
final class AllegroImportImageWarningFormatter
|
||||
{
|
||||
public function __construct(private readonly Translator $translator)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $imageDiagnostics
|
||||
*/
|
||||
public function format(array $imageDiagnostics): string
|
||||
{
|
||||
$withoutImage = (int) ($imageDiagnostics['without_image'] ?? 0);
|
||||
if ($withoutImage <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$reasonCountsRaw = $imageDiagnostics['reason_counts'] ?? [];
|
||||
if (!is_array($reasonCountsRaw) || $reasonCountsRaw === []) {
|
||||
return $this->genericWarning($withoutImage);
|
||||
}
|
||||
|
||||
$parts = $this->formatReasonCounts($reasonCountsRaw);
|
||||
if ($parts === []) {
|
||||
return $this->genericWarning($withoutImage);
|
||||
}
|
||||
|
||||
return $this->translator->get('settings.allegro.flash.import_single_media_warning', [
|
||||
'without_image' => (string) $withoutImage,
|
||||
'reasons' => implode(', ', $parts),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $reasonCountsRaw
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function formatReasonCounts(array $reasonCountsRaw): array
|
||||
{
|
||||
$parts = [];
|
||||
foreach ($reasonCountsRaw as $reason => $countRaw) {
|
||||
$count = (int) $countRaw;
|
||||
if ($count <= 0) {
|
||||
continue;
|
||||
}
|
||||
$parts[] = $this->reasonLabel((string) $reason) . ': ' . $count;
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
private function genericWarning(int $withoutImage): string
|
||||
{
|
||||
return $this->translator->get('settings.allegro.flash.import_single_media_warning_generic', [
|
||||
'without_image' => (string) $withoutImage,
|
||||
]);
|
||||
}
|
||||
|
||||
private function reasonLabel(string $reasonCode): string
|
||||
{
|
||||
return match ($reasonCode) {
|
||||
'missing_offer_id' => 'brak ID oferty',
|
||||
'missing_in_checkout_form' => 'brak obrazka w checkout form',
|
||||
'missing_in_offer_api' => 'brak obrazka w API oferty',
|
||||
'offer_api_access_denied_403' => 'brak uprawnien API ofert (403)',
|
||||
'offer_api_unauthorized_401' => 'token nieautoryzowany dla API ofert (401)',
|
||||
'offer_api_not_found_404' => 'oferta nie znaleziona (404)',
|
||||
'offer_api_request_failed' => 'blad zapytania do API oferty',
|
||||
default => str_starts_with($reasonCode, 'offer_api_http_')
|
||||
? 'blad API oferty (' . str_replace('offer_api_http_', '', $reasonCode) . ')'
|
||||
: $reasonCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
200
src/Modules/Settings/AllegroImportScheduleService.php
Normal file
200
src/Modules/Settings/AllegroImportScheduleService.php
Normal file
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Modules\Cron\CronRepository;
|
||||
use Throwable;
|
||||
|
||||
final class AllegroImportScheduleService
|
||||
{
|
||||
public const ORDERS_IMPORT_JOB_TYPE = 'allegro_orders_import';
|
||||
public const STATUS_SYNC_JOB_TYPE = 'allegro_status_sync';
|
||||
public const STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO = 'allegro_to_orderpro';
|
||||
public const STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO = 'orderpro_to_allegro';
|
||||
|
||||
private const ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS = 300;
|
||||
private const ORDERS_IMPORT_DEFAULT_PRIORITY = 20;
|
||||
private const ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS = 3;
|
||||
private const STATUS_SYNC_DEFAULT_INTERVAL_MINUTES = 15;
|
||||
private const ORDERS_IMPORT_DEFAULT_PAYLOAD = [
|
||||
'max_pages' => 5,
|
||||
'page_limit' => 50,
|
||||
'max_orders' => 200,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly CronRepository $cronRepository,
|
||||
private readonly Translator $translator
|
||||
) {
|
||||
}
|
||||
|
||||
public function currentImportIntervalSeconds(): int
|
||||
{
|
||||
$schedule = $this->findScheduleByType(self::ORDERS_IMPORT_JOB_TYPE);
|
||||
$value = (int) ($schedule['interval_seconds'] ?? self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS);
|
||||
return max(60, min(86400, $value));
|
||||
}
|
||||
|
||||
public function currentStatusSyncDirection(): string
|
||||
{
|
||||
$value = trim($this->cronRepository->getStringSetting(
|
||||
'allegro_status_sync_direction',
|
||||
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO
|
||||
));
|
||||
|
||||
if (!in_array($value, $this->allowedStatusSyncDirections(), true)) {
|
||||
return self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function currentStatusSyncIntervalMinutes(): int
|
||||
{
|
||||
return $this->cronRepository->getIntSetting(
|
||||
'allegro_status_sync_interval_minutes',
|
||||
self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES,
|
||||
1,
|
||||
1440
|
||||
);
|
||||
}
|
||||
|
||||
public function statusSyncDefaultIntervalMinutes(): int
|
||||
{
|
||||
return self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES;
|
||||
}
|
||||
|
||||
public function defaultStatusSyncDirection(): string
|
||||
{
|
||||
return self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO;
|
||||
}
|
||||
|
||||
public function ensureDefaultSchedulesExist(): void
|
||||
{
|
||||
try {
|
||||
if ($this->findScheduleByType(self::ORDERS_IMPORT_JOB_TYPE) === []) {
|
||||
$this->cronRepository->upsertSchedule(
|
||||
self::ORDERS_IMPORT_JOB_TYPE,
|
||||
self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS,
|
||||
self::ORDERS_IMPORT_DEFAULT_PRIORITY,
|
||||
self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS,
|
||||
self::ORDERS_IMPORT_DEFAULT_PAYLOAD,
|
||||
true
|
||||
);
|
||||
}
|
||||
if ($this->findScheduleByType(self::STATUS_SYNC_JOB_TYPE) === []) {
|
||||
$this->cronRepository->upsertSchedule(
|
||||
self::STATUS_SYNC_JOB_TYPE,
|
||||
self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES * 60,
|
||||
self::ORDERS_IMPORT_DEFAULT_PRIORITY,
|
||||
self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS,
|
||||
null,
|
||||
true
|
||||
);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// non-critical: schedules will be created when user explicitly saves import settings
|
||||
}
|
||||
}
|
||||
|
||||
public function validateImportSettingsInput(
|
||||
int $intervalMinutesRaw,
|
||||
int $intervalMinutes,
|
||||
string $statusSyncDirection,
|
||||
int $statusSyncIntervalRaw,
|
||||
int $statusSyncInterval
|
||||
): ?string {
|
||||
if ($intervalMinutesRaw !== $intervalMinutes) {
|
||||
return $this->translator->get('settings.allegro.validation.orders_import_interval_invalid');
|
||||
}
|
||||
if (!in_array($statusSyncDirection, $this->allowedStatusSyncDirections(), true)) {
|
||||
return $this->translator->get('settings.allegro.validation.status_sync_direction_invalid');
|
||||
}
|
||||
if ($statusSyncIntervalRaw !== $statusSyncInterval) {
|
||||
return $this->translator->get('settings.allegro.validation.status_sync_interval_invalid');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function applyImportSettings(
|
||||
int $intervalMinutes,
|
||||
string $statusSyncDirection,
|
||||
int $statusSyncIntervalMinutes
|
||||
): void {
|
||||
$importSchedule = $this->findScheduleByType(self::ORDERS_IMPORT_JOB_TYPE);
|
||||
$importPriority = (int) ($importSchedule['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY);
|
||||
$importMaxAttempts = (int) ($importSchedule['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS);
|
||||
$importPayload = is_array($importSchedule['payload'] ?? null)
|
||||
? (array) $importSchedule['payload']
|
||||
: self::ORDERS_IMPORT_DEFAULT_PAYLOAD;
|
||||
$importEnabled = array_key_exists('enabled', $importSchedule)
|
||||
? (bool) $importSchedule['enabled']
|
||||
: true;
|
||||
|
||||
$statusSchedule = $this->findScheduleByType(self::STATUS_SYNC_JOB_TYPE);
|
||||
$statusPriority = (int) ($statusSchedule['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY);
|
||||
$statusMaxAttempts = (int) ($statusSchedule['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS);
|
||||
$statusEnabled = array_key_exists('enabled', $statusSchedule)
|
||||
? (bool) $statusSchedule['enabled']
|
||||
: true;
|
||||
|
||||
$this->cronRepository->upsertSchedule(
|
||||
self::ORDERS_IMPORT_JOB_TYPE,
|
||||
$intervalMinutes * 60,
|
||||
$importPriority,
|
||||
$importMaxAttempts,
|
||||
$importPayload,
|
||||
$importEnabled
|
||||
);
|
||||
$this->cronRepository->upsertSchedule(
|
||||
self::STATUS_SYNC_JOB_TYPE,
|
||||
$statusSyncIntervalMinutes * 60,
|
||||
$statusPriority,
|
||||
$statusMaxAttempts,
|
||||
null,
|
||||
$statusEnabled
|
||||
);
|
||||
$this->cronRepository->upsertSetting('allegro_status_sync_direction', $statusSyncDirection);
|
||||
$this->cronRepository->upsertSetting(
|
||||
'allegro_status_sync_interval_minutes',
|
||||
(string) $statusSyncIntervalMinutes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedStatusSyncDirections(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO,
|
||||
self::STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function findScheduleByType(string $jobType): array
|
||||
{
|
||||
try {
|
||||
$schedules = $this->cronRepository->listSchedules();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($schedules as $schedule) {
|
||||
if (!is_array($schedule)) {
|
||||
continue;
|
||||
}
|
||||
if ((string) ($schedule['job_type'] ?? '') !== $jobType) {
|
||||
continue;
|
||||
}
|
||||
return $schedule;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\Constants\RedirectPaths;
|
||||
use App\Core\Http\RedirectPathResolver;
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use App\Core\I18n\Translator;
|
||||
@@ -10,54 +12,21 @@ use App\Core\Security\Csrf;
|
||||
use App\Core\Support\Flash;
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use App\Modules\Cron\CronRepository;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use App\Core\Constants\IntegrationSources;
|
||||
use App\Core\Constants\RedirectPaths;
|
||||
use App\Core\Exceptions\IntegrationConfigException;
|
||||
use App\Core\Http\RedirectPathResolver;
|
||||
use App\Core\Support\Session;
|
||||
use Throwable;
|
||||
|
||||
final class AllegroIntegrationController
|
||||
{
|
||||
private const OAUTH_STATE_SESSION_KEY = 'allegro_oauth_state';
|
||||
private const ORDERS_IMPORT_JOB_TYPE = 'allegro_orders_import';
|
||||
private const STATUS_SYNC_JOB_TYPE = 'allegro_status_sync';
|
||||
private const ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS = 300;
|
||||
private const ORDERS_IMPORT_DEFAULT_PRIORITY = 20;
|
||||
private const ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS = 3;
|
||||
private const STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO = 'allegro_to_orderpro';
|
||||
private const STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO = 'orderpro_to_allegro';
|
||||
private const STATUS_SYNC_DEFAULT_INTERVAL_MINUTES = 15;
|
||||
private const ORDERS_IMPORT_DEFAULT_PAYLOAD = [
|
||||
'max_pages' => 5,
|
||||
'page_limit' => 50,
|
||||
'max_orders' => 200,
|
||||
];
|
||||
private const OAUTH_SCOPES = [
|
||||
AllegroOAuthClient::ORDERS_READ_SCOPE,
|
||||
AllegroOAuthClient::ORDERS_WRITE_SCOPE,
|
||||
AllegroOAuthClient::SALE_OFFERS_READ_SCOPE,
|
||||
AllegroOAuthClient::SHIPMENTS_READ_SCOPE,
|
||||
AllegroOAuthClient::SHIPMENTS_WRITE_SCOPE,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly AllegroIntegrationRepository $repository,
|
||||
private readonly AllegroStatusMappingRepository $statusMappings,
|
||||
private readonly AllegroPullStatusMappingRepository $pullStatusMappings,
|
||||
private readonly OrderStatusRepository $orderStatuses,
|
||||
private readonly CronRepository $cronRepository,
|
||||
private readonly AllegroOAuthClient $oauthClient,
|
||||
private readonly AllegroOrderImportService $orderImportService,
|
||||
private readonly AllegroStatusDiscoveryService $statusDiscoveryService,
|
||||
private readonly string $appUrl,
|
||||
private readonly AllegroDeliveryMappingController $deliveryController
|
||||
private readonly AllegroIntegrationViewModel $viewModel,
|
||||
private readonly AllegroOAuthFlowService $oauthFlow,
|
||||
private readonly AllegroImportScheduleService $scheduleService,
|
||||
private readonly AllegroImportImageWarningFormatter $warningFormatter,
|
||||
private readonly AllegroSaveSettingsValidator $saveValidator,
|
||||
private readonly AllegroOrderImportService $orderImportService
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -67,60 +36,33 @@ final class AllegroIntegrationController
|
||||
$activeEnv = in_array($envParam, ['sandbox', 'production'], true)
|
||||
? $envParam
|
||||
: $this->repository->getActiveEnvironment();
|
||||
$settings = $this->repository->getSettings($activeEnv);
|
||||
|
||||
$tab = trim((string) $request->input('tab', 'integration'));
|
||||
if (!in_array($tab, ['integration', 'statuses', 'settings', 'delivery'], true)) {
|
||||
$tab = 'integration';
|
||||
}
|
||||
$defaultRedirectUri = $this->defaultRedirectUri();
|
||||
|
||||
if (trim((string) ($settings['redirect_uri'] ?? '')) === '') {
|
||||
$settings['redirect_uri'] = $defaultRedirectUri;
|
||||
}
|
||||
$this->ensureDefaultSchedulesExist();
|
||||
$importIntervalSeconds = $this->currentImportIntervalSeconds();
|
||||
$statusSyncDirection = $this->currentStatusSyncDirection();
|
||||
$statusSyncIntervalMinutes = $this->currentStatusSyncIntervalMinutes();
|
||||
$deliveryServicesData = $tab === 'delivery' ? $this->deliveryController->loadDeliveryServices($settings) : [[], [], ''];
|
||||
$deliveryMappings = $this->deliveryController->getDeliveryMappingsRepository();
|
||||
|
||||
$html = $this->template->render('settings/allegro', [
|
||||
$viewData = $this->viewModel->build($activeEnv, $tab) + [
|
||||
'title' => $this->translator->get('settings.allegro.title'),
|
||||
'activeMenu' => 'settings',
|
||||
'activeSettings' => 'allegro',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'settings' => $settings,
|
||||
'activeTab' => $tab,
|
||||
'importIntervalSeconds' => $importIntervalSeconds,
|
||||
'statusSyncDirection' => $statusSyncDirection,
|
||||
'statusSyncIntervalMinutes' => $statusSyncIntervalMinutes,
|
||||
'statusMappings' => $this->statusMappings->listMappings(),
|
||||
'pullStatusMappings' => $this->pullStatusMappings->listAll(),
|
||||
'orderproStatuses' => $this->orderStatuses->listStatuses(),
|
||||
'allegroStatuses' => $this->statusMappings->listExternalStatuses(),
|
||||
'defaultRedirectUri' => $defaultRedirectUri,
|
||||
'errorMessage' => (string) Flash::get('settings_error', ''),
|
||||
'successMessage' => (string) Flash::get('settings_success', ''),
|
||||
'warningMessage' => (string) Flash::get('settings_warning', ''),
|
||||
'deliveryMappings' => $deliveryMappings !== null ? $deliveryMappings->listMappings(IntegrationSources::ALLEGRO, 0) : [],
|
||||
'orderDeliveryMethods' => $deliveryMappings !== null ? $deliveryMappings->getDistinctOrderDeliveryMethods(IntegrationSources::ALLEGRO, 0) : [],
|
||||
'allegroDeliveryServices' => $deliveryServicesData[0],
|
||||
'apaczkaDeliveryServices' => $deliveryServicesData[1],
|
||||
'allegroDeliveryServicesError' => $deliveryServicesData[2],
|
||||
'inpostDeliveryServices' => array_values(array_filter(
|
||||
$deliveryServicesData[0],
|
||||
static fn(array $svc) => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false
|
||||
)),
|
||||
], 'layouts/app');
|
||||
];
|
||||
|
||||
return Response::html($html);
|
||||
return Response::html($this->template->render('settings/allegro', $viewData, 'layouts/app'));
|
||||
}
|
||||
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$redirectTo = RedirectPathResolver::resolve((string) $request->input('return_to', RedirectPaths::ALLEGRO_INTEGRATION), ['/settings/integrations'], RedirectPaths::ALLEGRO_INTEGRATION);
|
||||
$redirectTo = RedirectPathResolver::resolve(
|
||||
(string) $request->input('return_to', RedirectPaths::ALLEGRO_INTEGRATION),
|
||||
['/settings/integrations'],
|
||||
RedirectPaths::ALLEGRO_INTEGRATION
|
||||
);
|
||||
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
|
||||
return Response::redirect($redirectTo);
|
||||
@@ -129,10 +71,10 @@ final class AllegroIntegrationController
|
||||
$environment = trim((string) $request->input('environment', 'sandbox'));
|
||||
$clientId = trim((string) $request->input('client_id', ''));
|
||||
$redirectUriInput = trim((string) $request->input('redirect_uri', ''));
|
||||
$redirectUri = $redirectUriInput !== '' ? $redirectUriInput : $this->defaultRedirectUri();
|
||||
$redirectUri = $redirectUriInput !== '' ? $redirectUriInput : $this->viewModel->defaultRedirectUriValue();
|
||||
$ordersFetchStartDate = trim((string) $request->input('orders_fetch_start_date', ''));
|
||||
|
||||
$validationError = $this->validateSaveInput($environment, $clientId, $redirectUri, $ordersFetchStartDate);
|
||||
$validationError = $this->saveValidator->validate($environment, $clientId, $redirectUri, $ordersFetchStartDate);
|
||||
if ($validationError !== null) {
|
||||
Flash::set('settings_error', $validationError);
|
||||
return Response::redirect($redirectTo);
|
||||
@@ -147,7 +89,7 @@ final class AllegroIntegrationController
|
||||
'orders_fetch_enabled' => (string) $request->input('orders_fetch_enabled', '0') === '1',
|
||||
'orders_fetch_start_date' => $ordersFetchStartDate,
|
||||
]);
|
||||
$this->ensureDefaultSchedulesExist();
|
||||
$this->scheduleService->ensureDefaultSchedulesExist();
|
||||
Flash::set('settings_success', $this->translator->get('settings.allegro.flash.saved'));
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set(
|
||||
@@ -171,16 +113,16 @@ final class AllegroIntegrationController
|
||||
|
||||
$statusSyncDirection = trim((string) $request->input(
|
||||
'status_sync_direction',
|
||||
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO
|
||||
$this->scheduleService->defaultStatusSyncDirection()
|
||||
));
|
||||
|
||||
$statusSyncIntervalRaw = (int) $request->input(
|
||||
'status_sync_interval_minutes',
|
||||
self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES
|
||||
$this->scheduleService->statusSyncDefaultIntervalMinutes()
|
||||
);
|
||||
$statusSyncInterval = max(1, min(1440, $statusSyncIntervalRaw));
|
||||
|
||||
$validationError = $this->validateImportSettingsInput(
|
||||
$validationError = $this->scheduleService->validateImportSettingsInput(
|
||||
$intervalMinutesRaw,
|
||||
$intervalMinutes,
|
||||
$statusSyncDirection,
|
||||
@@ -192,41 +134,8 @@ final class AllegroIntegrationController
|
||||
return Response::redirect(RedirectPaths::ALLEGRO_SETTINGS_TAB);
|
||||
}
|
||||
|
||||
$existing = $this->findImportSchedule();
|
||||
$priority = (int) ($existing['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY);
|
||||
$maxAttempts = (int) ($existing['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS);
|
||||
$payload = is_array($existing['payload'] ?? null)
|
||||
? (array) $existing['payload']
|
||||
: self::ORDERS_IMPORT_DEFAULT_PAYLOAD;
|
||||
$enabled = array_key_exists('enabled', $existing)
|
||||
? (bool) $existing['enabled']
|
||||
: true;
|
||||
$statusSchedule = $this->findStatusSyncSchedule();
|
||||
$statusPriority = (int) ($statusSchedule['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY);
|
||||
$statusMaxAttempts = (int) ($statusSchedule['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS);
|
||||
$statusEnabled = array_key_exists('enabled', $statusSchedule)
|
||||
? (bool) $statusSchedule['enabled']
|
||||
: true;
|
||||
|
||||
try {
|
||||
$this->cronRepository->upsertSchedule(
|
||||
self::ORDERS_IMPORT_JOB_TYPE,
|
||||
$intervalMinutes * 60,
|
||||
$priority,
|
||||
$maxAttempts,
|
||||
$payload,
|
||||
$enabled
|
||||
);
|
||||
$this->cronRepository->upsertSchedule(
|
||||
self::STATUS_SYNC_JOB_TYPE,
|
||||
$statusSyncInterval * 60,
|
||||
$statusPriority,
|
||||
$statusMaxAttempts,
|
||||
null,
|
||||
$statusEnabled
|
||||
);
|
||||
$this->cronRepository->upsertSetting('allegro_status_sync_direction', $statusSyncDirection);
|
||||
$this->cronRepository->upsertSetting('allegro_status_sync_interval_minutes', (string) $statusSyncInterval);
|
||||
$this->scheduleService->applyImportSettings($intervalMinutes, $statusSyncDirection, $statusSyncInterval);
|
||||
Flash::set('settings_success', $this->translator->get('settings.allegro.flash.import_settings_saved'));
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set(
|
||||
@@ -246,19 +155,7 @@ final class AllegroIntegrationController
|
||||
}
|
||||
|
||||
try {
|
||||
$credentials = $this->requireOAuthCredentials();
|
||||
$state = bin2hex(random_bytes(24));
|
||||
Session::set(self::OAUTH_STATE_SESSION_KEY, $state);
|
||||
|
||||
$url = $this->oauthClient->buildAuthorizeUrl(
|
||||
(string) $credentials['environment'],
|
||||
(string) $credentials['client_id'],
|
||||
(string) $credentials['redirect_uri'],
|
||||
$state,
|
||||
self::OAUTH_SCOPES
|
||||
);
|
||||
|
||||
return Response::redirect($url);
|
||||
return Response::redirect($this->oauthFlow->buildAuthorizeUrl());
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set('settings_error', $exception->getMessage());
|
||||
return Response::redirect(RedirectPaths::ALLEGRO_INTEGRATION);
|
||||
@@ -278,44 +175,17 @@ final class AllegroIntegrationController
|
||||
return Response::redirect(RedirectPaths::ALLEGRO_INTEGRATION);
|
||||
}
|
||||
|
||||
$state = trim((string) $request->input('state', ''));
|
||||
$expectedState = trim((string) Session::pull(self::OAUTH_STATE_SESSION_KEY, ''));
|
||||
$authorizationCode = trim((string) $request->input('code', ''));
|
||||
|
||||
$validationError = $this->validateOAuthCallbackParams($state, $expectedState, $authorizationCode);
|
||||
if ($validationError !== null) {
|
||||
Flash::set('settings_error', $validationError);
|
||||
return Response::redirect(RedirectPaths::ALLEGRO_INTEGRATION);
|
||||
}
|
||||
|
||||
try {
|
||||
$credentials = $this->requireOAuthCredentials();
|
||||
$token = $this->oauthClient->exchangeAuthorizationCode(
|
||||
(string) $credentials['environment'],
|
||||
(string) $credentials['client_id'],
|
||||
(string) $credentials['client_secret'],
|
||||
(string) $credentials['redirect_uri'],
|
||||
$authorizationCode
|
||||
$this->oauthFlow->exchangeAuthorizationCode(
|
||||
trim((string) $request->input('state', '')),
|
||||
trim((string) $request->input('code', ''))
|
||||
);
|
||||
|
||||
$expiresAt = null;
|
||||
if ((int) ($token['expires_in'] ?? 0) > 0) {
|
||||
$expiresAt = (new DateTimeImmutable('now'))
|
||||
->add(new DateInterval('PT' . (int) $token['expires_in'] . 'S'))
|
||||
->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$this->repository->saveTokens(
|
||||
(string) ($token['access_token'] ?? ''),
|
||||
(string) ($token['refresh_token'] ?? ''),
|
||||
(string) ($token['token_type'] ?? ''),
|
||||
(string) ($token['scope'] ?? ''),
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
Flash::set('settings_success', $this->translator->get('settings.allegro.flash.oauth_connected'));
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set('settings_error', $this->translator->get('settings.allegro.flash.oauth_failed') . ' ' . $exception->getMessage());
|
||||
Flash::set(
|
||||
'settings_error',
|
||||
$this->translator->get('settings.allegro.flash.oauth_failed') . ' ' . $exception->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
return Response::redirect(RedirectPaths::ALLEGRO_INTEGRATION);
|
||||
@@ -337,23 +207,9 @@ final class AllegroIntegrationController
|
||||
try {
|
||||
$result = $this->orderImportService->importSingleOrder($checkoutFormId);
|
||||
$imageDiagnostics = is_array($result['image_diagnostics'] ?? null) ? $result['image_diagnostics'] : [];
|
||||
Flash::set(
|
||||
'settings_success',
|
||||
$this->translator->get('settings.allegro.flash.import_single_ok', [
|
||||
'source_order_id' => (string) ($result['source_order_id'] ?? $checkoutFormId),
|
||||
'local_id' => (string) ((int) ($result['order_id'] ?? 0)),
|
||||
'action' => !empty($result['created'])
|
||||
? $this->translator->get('settings.allegro.import_action.created')
|
||||
: $this->translator->get('settings.allegro.import_action.updated'),
|
||||
]) . ' '
|
||||
. $this->translator->get('settings.allegro.flash.import_single_media_summary', [
|
||||
'with_image' => (string) ((int) ($imageDiagnostics['with_image'] ?? 0)),
|
||||
'total_items' => (string) ((int) ($imageDiagnostics['total_items'] ?? 0)),
|
||||
'without_image' => (string) ((int) ($imageDiagnostics['without_image'] ?? 0)),
|
||||
])
|
||||
);
|
||||
Flash::set('settings_success', $this->buildImportSingleSuccessMessage($result, $imageDiagnostics, $checkoutFormId));
|
||||
|
||||
$warningDetails = $this->buildImportImageWarningMessage($imageDiagnostics);
|
||||
$warningDetails = $this->warningFormatter->format($imageDiagnostics);
|
||||
if ($warningDetails !== '') {
|
||||
Flash::set('settings_warning', $warningDetails);
|
||||
}
|
||||
@@ -367,14 +223,24 @@ final class AllegroIntegrationController
|
||||
return Response::redirect(RedirectPaths::ALLEGRO_INTEGRATION);
|
||||
}
|
||||
|
||||
private function defaultRedirectUri(): string
|
||||
/**
|
||||
* @param array<string, mixed> $result
|
||||
* @param array<string, mixed> $imageDiagnostics
|
||||
*/
|
||||
private function buildImportSingleSuccessMessage(array $result, array $imageDiagnostics, string $checkoutFormId): string
|
||||
{
|
||||
$base = trim($this->appUrl);
|
||||
if ($base === '') {
|
||||
$base = 'http://localhost:8000';
|
||||
}
|
||||
|
||||
return rtrim($base, '/') . RedirectPaths::ALLEGRO_OAUTH_CALLBACK_PATH;
|
||||
return $this->translator->get('settings.allegro.flash.import_single_ok', [
|
||||
'source_order_id' => (string) ($result['source_order_id'] ?? $checkoutFormId),
|
||||
'local_id' => (string) ((int) ($result['order_id'] ?? 0)),
|
||||
'action' => !empty($result['created'])
|
||||
? $this->translator->get('settings.allegro.import_action.created')
|
||||
: $this->translator->get('settings.allegro.import_action.updated'),
|
||||
]) . ' '
|
||||
. $this->translator->get('settings.allegro.flash.import_single_media_summary', [
|
||||
'with_image' => (string) ((int) ($imageDiagnostics['with_image'] ?? 0)),
|
||||
'total_items' => (string) ((int) ($imageDiagnostics['total_items'] ?? 0)),
|
||||
'without_image' => (string) ((int) ($imageDiagnostics['without_image'] ?? 0)),
|
||||
]);
|
||||
}
|
||||
|
||||
private function validateCsrf(string $token): ?Response
|
||||
@@ -386,268 +252,4 @@ final class AllegroIntegrationController
|
||||
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
|
||||
return Response::redirect(RedirectPaths::ALLEGRO_INTEGRATION);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function requireOAuthCredentials(): array
|
||||
{
|
||||
$credentials = $this->repository->getOAuthCredentials();
|
||||
if ($credentials === null) {
|
||||
throw new IntegrationConfigException($this->translator->get('settings.allegro.flash.credentials_missing'));
|
||||
}
|
||||
|
||||
return $credentials;
|
||||
}
|
||||
|
||||
private function isValidHttpUrl(string $url): bool
|
||||
{
|
||||
$trimmed = trim($url);
|
||||
if ($trimmed === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter_var($trimmed, FILTER_VALIDATE_URL) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scheme = strtolower((string) parse_url($trimmed, PHP_URL_SCHEME));
|
||||
return $scheme === 'http' || $scheme === 'https';
|
||||
}
|
||||
|
||||
private function isValidDate(string $value): bool
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d', $value);
|
||||
return $date instanceof DateTimeImmutable && $date->format('Y-m-d') === $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $imageDiagnostics
|
||||
*/
|
||||
private function buildImportImageWarningMessage(array $imageDiagnostics): string
|
||||
{
|
||||
$withoutImage = (int) ($imageDiagnostics['without_image'] ?? 0);
|
||||
if ($withoutImage <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$reasonCountsRaw = $imageDiagnostics['reason_counts'] ?? [];
|
||||
if (!is_array($reasonCountsRaw) || $reasonCountsRaw === []) {
|
||||
return $this->translator->get('settings.allegro.flash.import_single_media_warning_generic', [
|
||||
'without_image' => (string) $withoutImage,
|
||||
]);
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
foreach ($reasonCountsRaw as $reason => $countRaw) {
|
||||
$count = (int) $countRaw;
|
||||
if ($count <= 0) {
|
||||
continue;
|
||||
}
|
||||
$parts[] = $this->reasonLabel((string) $reason) . ': ' . $count;
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
return $this->translator->get('settings.allegro.flash.import_single_media_warning_generic', [
|
||||
'without_image' => (string) $withoutImage,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->translator->get('settings.allegro.flash.import_single_media_warning', [
|
||||
'without_image' => (string) $withoutImage,
|
||||
'reasons' => implode(', ', $parts),
|
||||
]);
|
||||
}
|
||||
|
||||
private function reasonLabel(string $reasonCode): string
|
||||
{
|
||||
return match ($reasonCode) {
|
||||
'missing_offer_id' => 'brak ID oferty',
|
||||
'missing_in_checkout_form' => 'brak obrazka w checkout form',
|
||||
'missing_in_offer_api' => 'brak obrazka w API oferty',
|
||||
'offer_api_access_denied_403' => 'brak uprawnien API ofert (403)',
|
||||
'offer_api_unauthorized_401' => 'token nieautoryzowany dla API ofert (401)',
|
||||
'offer_api_not_found_404' => 'oferta nie znaleziona (404)',
|
||||
'offer_api_request_failed' => 'blad zapytania do API oferty',
|
||||
default => str_starts_with($reasonCode, 'offer_api_http_')
|
||||
? 'blad API oferty (' . str_replace('offer_api_http_', '', $reasonCode) . ')'
|
||||
: $reasonCode,
|
||||
};
|
||||
}
|
||||
|
||||
private function currentImportIntervalSeconds(): int
|
||||
{
|
||||
$schedule = $this->findImportSchedule();
|
||||
$value = (int) ($schedule['interval_seconds'] ?? self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS);
|
||||
return max(60, min(86400, $value));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function findImportSchedule(): array
|
||||
{
|
||||
try {
|
||||
$schedules = $this->cronRepository->listSchedules();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($schedules as $schedule) {
|
||||
if (!is_array($schedule)) {
|
||||
continue;
|
||||
}
|
||||
if ((string) ($schedule['job_type'] ?? '') !== self::ORDERS_IMPORT_JOB_TYPE) {
|
||||
continue;
|
||||
}
|
||||
return $schedule;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function findStatusSyncSchedule(): array
|
||||
{
|
||||
try {
|
||||
$schedules = $this->cronRepository->listSchedules();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($schedules as $schedule) {
|
||||
if (!is_array($schedule)) {
|
||||
continue;
|
||||
}
|
||||
if ((string) ($schedule['job_type'] ?? '') !== self::STATUS_SYNC_JOB_TYPE) {
|
||||
continue;
|
||||
}
|
||||
return $schedule;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function currentStatusSyncDirection(): string
|
||||
{
|
||||
$value = trim($this->cronRepository->getStringSetting(
|
||||
'allegro_status_sync_direction',
|
||||
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO
|
||||
));
|
||||
|
||||
if (!in_array($value, $this->allowedStatusSyncDirections(), true)) {
|
||||
return self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function currentStatusSyncIntervalMinutes(): int
|
||||
{
|
||||
return $this->cronRepository->getIntSetting(
|
||||
'allegro_status_sync_interval_minutes',
|
||||
self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES,
|
||||
1,
|
||||
1440
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedStatusSyncDirections(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO,
|
||||
self::STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO,
|
||||
];
|
||||
}
|
||||
|
||||
private function validateSaveInput(
|
||||
string $environment,
|
||||
string $clientId,
|
||||
string $redirectUri,
|
||||
string $ordersFetchStartDate
|
||||
): ?string {
|
||||
if (!in_array($environment, ['sandbox', 'production'], true)) {
|
||||
return $this->translator->get('settings.allegro.validation.environment_invalid');
|
||||
}
|
||||
if ($clientId !== '' && mb_strlen($clientId) > 128) {
|
||||
return $this->translator->get('settings.allegro.validation.client_id_too_long');
|
||||
}
|
||||
if (!$this->isValidHttpUrl($redirectUri)) {
|
||||
return $this->translator->get('settings.allegro.validation.redirect_uri_invalid');
|
||||
}
|
||||
if ($ordersFetchStartDate !== '' && !$this->isValidDate($ordersFetchStartDate)) {
|
||||
return $this->translator->get('settings.allegro.validation.orders_fetch_start_date_invalid');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function validateImportSettingsInput(
|
||||
int $intervalMinutesRaw,
|
||||
int $intervalMinutes,
|
||||
string $statusSyncDirection,
|
||||
int $statusSyncIntervalRaw,
|
||||
int $statusSyncInterval
|
||||
): ?string {
|
||||
if ($intervalMinutesRaw !== $intervalMinutes) {
|
||||
return $this->translator->get('settings.allegro.validation.orders_import_interval_invalid');
|
||||
}
|
||||
if (!in_array($statusSyncDirection, $this->allowedStatusSyncDirections(), true)) {
|
||||
return $this->translator->get('settings.allegro.validation.status_sync_direction_invalid');
|
||||
}
|
||||
if ($statusSyncIntervalRaw !== $statusSyncInterval) {
|
||||
return $this->translator->get('settings.allegro.validation.status_sync_interval_invalid');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function validateOAuthCallbackParams(
|
||||
string $state,
|
||||
string $expectedState,
|
||||
string $authorizationCode
|
||||
): ?string {
|
||||
if ($state === '' || $expectedState === '' || !hash_equals($expectedState, $state)) {
|
||||
return $this->translator->get('settings.allegro.flash.oauth_state_invalid');
|
||||
}
|
||||
if ($authorizationCode === '') {
|
||||
return $this->translator->get('settings.allegro.flash.oauth_code_missing');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function ensureDefaultSchedulesExist(): void
|
||||
{
|
||||
try {
|
||||
if ($this->findImportSchedule() === []) {
|
||||
$this->cronRepository->upsertSchedule(
|
||||
self::ORDERS_IMPORT_JOB_TYPE,
|
||||
self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS,
|
||||
self::ORDERS_IMPORT_DEFAULT_PRIORITY,
|
||||
self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS,
|
||||
self::ORDERS_IMPORT_DEFAULT_PAYLOAD,
|
||||
true
|
||||
);
|
||||
}
|
||||
if ($this->findStatusSyncSchedule() === []) {
|
||||
$this->cronRepository->upsertSchedule(
|
||||
self::STATUS_SYNC_JOB_TYPE,
|
||||
self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES * 60,
|
||||
self::ORDERS_IMPORT_DEFAULT_PRIORITY,
|
||||
self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS,
|
||||
null,
|
||||
true
|
||||
);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// non-critical: schedules will be created when user explicitly saves import settings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
69
src/Modules/Settings/AllegroIntegrationViewModel.php
Normal file
69
src/Modules/Settings/AllegroIntegrationViewModel.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\Constants\IntegrationSources;
|
||||
|
||||
final class AllegroIntegrationViewModel
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AllegroIntegrationRepository $repository,
|
||||
private readonly AllegroStatusMappingRepository $statusMappings,
|
||||
private readonly AllegroPullStatusMappingRepository $pullStatusMappings,
|
||||
private readonly OrderStatusRepository $orderStatuses,
|
||||
private readonly AllegroImportScheduleService $scheduleService,
|
||||
private readonly AllegroDeliveryMappingController $deliveryController,
|
||||
private readonly string $defaultRedirectUri
|
||||
) {
|
||||
}
|
||||
|
||||
public function defaultRedirectUriValue(): string
|
||||
{
|
||||
return $this->defaultRedirectUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function build(string $activeEnv, string $tab): array
|
||||
{
|
||||
$settings = $this->repository->getSettings($activeEnv);
|
||||
if (trim((string) ($settings['redirect_uri'] ?? '')) === '') {
|
||||
$settings['redirect_uri'] = $this->defaultRedirectUri;
|
||||
}
|
||||
|
||||
$this->scheduleService->ensureDefaultSchedulesExist();
|
||||
|
||||
$deliveryServicesData = $tab === 'delivery'
|
||||
? $this->deliveryController->loadDeliveryServices($settings)
|
||||
: [[], [], ''];
|
||||
$deliveryMappings = $this->deliveryController->getDeliveryMappingsRepository();
|
||||
|
||||
return [
|
||||
'settings' => $settings,
|
||||
'activeTab' => $tab,
|
||||
'importIntervalSeconds' => $this->scheduleService->currentImportIntervalSeconds(),
|
||||
'statusSyncDirection' => $this->scheduleService->currentStatusSyncDirection(),
|
||||
'statusSyncIntervalMinutes' => $this->scheduleService->currentStatusSyncIntervalMinutes(),
|
||||
'statusMappings' => $this->statusMappings->listMappings(),
|
||||
'pullStatusMappings' => $this->pullStatusMappings->listAll(),
|
||||
'orderproStatuses' => $this->orderStatuses->listStatuses(),
|
||||
'allegroStatuses' => $this->statusMappings->listExternalStatuses(),
|
||||
'defaultRedirectUri' => $this->defaultRedirectUri,
|
||||
'deliveryMappings' => $deliveryMappings !== null
|
||||
? $deliveryMappings->listMappings(IntegrationSources::ALLEGRO, 0)
|
||||
: [],
|
||||
'orderDeliveryMethods' => $deliveryMappings !== null
|
||||
? $deliveryMappings->getDistinctOrderDeliveryMethods(IntegrationSources::ALLEGRO, 0)
|
||||
: [],
|
||||
'allegroDeliveryServices' => $deliveryServicesData[0],
|
||||
'apaczkaDeliveryServices' => $deliveryServicesData[1],
|
||||
'allegroDeliveryServicesError' => $deliveryServicesData[2],
|
||||
'inpostDeliveryServices' => array_values(array_filter(
|
||||
$deliveryServicesData[0],
|
||||
static fn(array $svc) => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
109
src/Modules/Settings/AllegroOAuthFlowService.php
Normal file
109
src/Modules/Settings/AllegroOAuthFlowService.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\Exceptions\IntegrationConfigException;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Core\Support\Session;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
|
||||
final class AllegroOAuthFlowService
|
||||
{
|
||||
private const OAUTH_STATE_SESSION_KEY = 'allegro_oauth_state';
|
||||
|
||||
private const OAUTH_SCOPES = [
|
||||
AllegroOAuthClient::ORDERS_READ_SCOPE,
|
||||
AllegroOAuthClient::ORDERS_WRITE_SCOPE,
|
||||
AllegroOAuthClient::SALE_OFFERS_READ_SCOPE,
|
||||
AllegroOAuthClient::SHIPMENTS_READ_SCOPE,
|
||||
AllegroOAuthClient::SHIPMENTS_WRITE_SCOPE,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly AllegroOAuthClient $oauthClient,
|
||||
private readonly AllegroIntegrationRepository $repository,
|
||||
private readonly Translator $translator
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildAuthorizeUrl(): string
|
||||
{
|
||||
$credentials = $this->requireOAuthCredentials();
|
||||
$state = bin2hex(random_bytes(24));
|
||||
Session::set(self::OAUTH_STATE_SESSION_KEY, $state);
|
||||
|
||||
return $this->oauthClient->buildAuthorizeUrl(
|
||||
(string) $credentials['environment'],
|
||||
(string) $credentials['client_id'],
|
||||
(string) $credentials['redirect_uri'],
|
||||
$state,
|
||||
self::OAUTH_SCOPES
|
||||
);
|
||||
}
|
||||
|
||||
public function exchangeAuthorizationCode(string $state, string $authorizationCode): void
|
||||
{
|
||||
$expectedState = trim((string) Session::pull(self::OAUTH_STATE_SESSION_KEY, ''));
|
||||
|
||||
$validationError = $this->validateCallbackParams($state, $expectedState, $authorizationCode);
|
||||
if ($validationError !== null) {
|
||||
throw new RuntimeException($validationError);
|
||||
}
|
||||
|
||||
$credentials = $this->requireOAuthCredentials();
|
||||
$token = $this->oauthClient->exchangeAuthorizationCode(
|
||||
(string) $credentials['environment'],
|
||||
(string) $credentials['client_id'],
|
||||
(string) $credentials['client_secret'],
|
||||
(string) $credentials['redirect_uri'],
|
||||
$authorizationCode
|
||||
);
|
||||
|
||||
$expiresAt = null;
|
||||
if ((int) ($token['expires_in'] ?? 0) > 0) {
|
||||
$expiresAt = (new DateTimeImmutable('now'))
|
||||
->add(new DateInterval('PT' . (int) $token['expires_in'] . 'S'))
|
||||
->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$this->repository->saveTokens(
|
||||
(string) ($token['access_token'] ?? ''),
|
||||
(string) ($token['refresh_token'] ?? ''),
|
||||
(string) ($token['token_type'] ?? ''),
|
||||
(string) ($token['scope'] ?? ''),
|
||||
$expiresAt
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function requireOAuthCredentials(): array
|
||||
{
|
||||
$credentials = $this->repository->getOAuthCredentials();
|
||||
if ($credentials === null) {
|
||||
throw new IntegrationConfigException(
|
||||
$this->translator->get('settings.allegro.flash.credentials_missing')
|
||||
);
|
||||
}
|
||||
|
||||
return $credentials;
|
||||
}
|
||||
|
||||
private function validateCallbackParams(
|
||||
string $state,
|
||||
string $expectedState,
|
||||
string $authorizationCode
|
||||
): ?string {
|
||||
if ($state === '' || $expectedState === '' || !hash_equals($expectedState, $state)) {
|
||||
return $this->translator->get('settings.allegro.flash.oauth_state_invalid');
|
||||
}
|
||||
if ($authorizationCode === '') {
|
||||
return $this->translator->get('settings.allegro.flash.oauth_code_missing');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
56
src/Modules/Settings/AllegroSaveSettingsValidator.php
Normal file
56
src/Modules/Settings/AllegroSaveSettingsValidator.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Core\I18n\Translator;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class AllegroSaveSettingsValidator
|
||||
{
|
||||
public function __construct(private readonly Translator $translator)
|
||||
{
|
||||
}
|
||||
|
||||
public function validate(
|
||||
string $environment,
|
||||
string $clientId,
|
||||
string $redirectUri,
|
||||
string $ordersFetchStartDate
|
||||
): ?string {
|
||||
if (!in_array($environment, ['sandbox', 'production'], true)) {
|
||||
return $this->translator->get('settings.allegro.validation.environment_invalid');
|
||||
}
|
||||
if ($clientId !== '' && mb_strlen($clientId) > 128) {
|
||||
return $this->translator->get('settings.allegro.validation.client_id_too_long');
|
||||
}
|
||||
if (!$this->isValidHttpUrl($redirectUri)) {
|
||||
return $this->translator->get('settings.allegro.validation.redirect_uri_invalid');
|
||||
}
|
||||
if ($ordersFetchStartDate !== '' && !$this->isValidDate($ordersFetchStartDate)) {
|
||||
return $this->translator->get('settings.allegro.validation.orders_fetch_start_date_invalid');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isValidHttpUrl(string $url): bool
|
||||
{
|
||||
$trimmed = trim($url);
|
||||
if ($trimmed === '' || filter_var($trimmed, FILTER_VALIDATE_URL) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scheme = strtolower((string) parse_url($trimmed, PHP_URL_SCHEME));
|
||||
return $scheme === 'http' || $scheme === 'https';
|
||||
}
|
||||
|
||||
private function isValidDate(string $value): bool
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d', $value);
|
||||
return $date instanceof DateTimeImmutable && $date->format('Y-m-d') === $value;
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,6 @@ use App\Core\Security\Csrf;
|
||||
use App\Core\View\Template;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use DateInterval;
|
||||
use DatePeriod;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class OrdersStatisticsController
|
||||
{
|
||||
@@ -19,29 +16,40 @@ final class OrdersStatisticsController
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly OrdersStatisticsRepository $repository
|
||||
private readonly OrdersStatisticsRepository $repository,
|
||||
private readonly OrdersStatisticsFilters $filters,
|
||||
private readonly OrdersStatisticsTableBuilder $tableBuilder,
|
||||
private readonly OrdersStatisticsSummaryBuilder $summaryBuilder
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
[$dateFrom, $dateTo] = $this->resolveDateRange($request);
|
||||
$filters = $this->filters->resolveDailyFilters(
|
||||
$request,
|
||||
$this->repository->listStatusGroups(),
|
||||
$this->repository->listChannelOptions()
|
||||
);
|
||||
|
||||
$statusGroups = $this->repository->listStatusGroups();
|
||||
$statusGroupOptions = $this->mapStatusGroupOptions($statusGroups);
|
||||
$selectedStatusGroups = $this->resolveSelectedStatusGroups($request, $statusGroupOptions);
|
||||
$statusCodes = $this->repository->statusCodesByGroupIds($filters['selected_status_groups']);
|
||||
$aggregated = $this->repository->aggregateByDay(
|
||||
$filters['date_from'],
|
||||
$filters['date_to'],
|
||||
$filters['selected_channels'],
|
||||
$statusCodes
|
||||
);
|
||||
|
||||
$channelOptions = $this->mapChannelOptions($this->repository->listChannelOptions());
|
||||
$selectedChannels = $this->resolveSelectedChannels($request, $channelOptions);
|
||||
|
||||
$statusCodes = $this->repository->statusCodesByGroupIds($selectedStatusGroups);
|
||||
$aggregated = $this->repository->aggregateByDay($dateFrom, $dateTo, $selectedChannels, $statusCodes);
|
||||
$debugEnabled = (string) $request->input('debug', '') === '1';
|
||||
$diagnostics = $debugEnabled
|
||||
? $this->repository->diagnostics($dateFrom, $dateTo, $selectedChannels, $statusCodes)
|
||||
? $this->repository->diagnostics($filters['date_from'], $filters['date_to'], $filters['selected_channels'], $statusCodes)
|
||||
: [];
|
||||
|
||||
$table = $this->buildTable($dateFrom, $dateTo, $selectedChannels, $aggregated);
|
||||
$table = $this->tableBuilder->build(
|
||||
$filters['date_from'],
|
||||
$filters['date_to'],
|
||||
$filters['selected_channels'],
|
||||
$aggregated
|
||||
);
|
||||
|
||||
$html = $this->template->render('statistics/orders', [
|
||||
'title' => $this->translator->get('statistics.orders.title'),
|
||||
@@ -50,19 +58,19 @@ final class OrdersStatisticsController
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'filters' => [
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
'selected_channels' => $selectedChannels,
|
||||
'selected_status_groups' => $selectedStatusGroups,
|
||||
'date_from' => $filters['date_from'],
|
||||
'date_to' => $filters['date_to'],
|
||||
'selected_channels' => $filters['selected_channels'],
|
||||
'selected_status_groups' => $filters['selected_status_groups'],
|
||||
],
|
||||
'channelOptions' => $channelOptions,
|
||||
'statusGroupOptions' => $statusGroupOptions,
|
||||
'channelOptions' => $filters['channel_options'],
|
||||
'statusGroupOptions' => $filters['status_group_options'],
|
||||
'table' => $table,
|
||||
'debugEnabled' => $debugEnabled,
|
||||
'diagnostics' => $diagnostics,
|
||||
'debugMeta' => [
|
||||
'selected_channels' => $selectedChannels,
|
||||
'selected_status_groups' => $selectedStatusGroups,
|
||||
'selected_channels' => $filters['selected_channels'],
|
||||
'selected_status_groups' => $filters['selected_status_groups'],
|
||||
'status_codes' => $statusCodes,
|
||||
],
|
||||
], 'layouts/app');
|
||||
@@ -72,18 +80,27 @@ final class OrdersStatisticsController
|
||||
|
||||
public function summary(Request $request): Response
|
||||
{
|
||||
[$dateFrom, $dateTo] = $this->resolveSummaryDateRange($request);
|
||||
$filters = $this->filters->resolveSummaryFilters(
|
||||
$request,
|
||||
$this->repository->listStatusGroups(),
|
||||
$this->repository->listChannelOptions()
|
||||
);
|
||||
|
||||
$statusGroups = $this->repository->listStatusGroups();
|
||||
$statusGroupOptions = $this->mapStatusGroupOptions($statusGroups);
|
||||
$selectedStatusGroups = $this->resolveSelectedStatusGroups($request, $statusGroupOptions);
|
||||
$statusCodes = $this->repository->statusCodesByGroupIds($filters['selected_status_groups']);
|
||||
$aggregated = $this->repository->aggregateByMonth(
|
||||
$filters['date_from'],
|
||||
$filters['date_to'],
|
||||
$filters['selected_channels'],
|
||||
$statusCodes
|
||||
);
|
||||
|
||||
$channelOptions = $this->mapChannelOptions($this->repository->listChannelOptions());
|
||||
$selectedChannels = $this->resolveSelectedChannels($request, $channelOptions);
|
||||
|
||||
$statusCodes = $this->repository->statusCodesByGroupIds($selectedStatusGroups);
|
||||
$aggregated = $this->repository->aggregateByMonth($dateFrom, $dateTo, $selectedChannels, $statusCodes);
|
||||
$summary = $this->buildSummary($dateFrom, $dateTo, $selectedChannels, $channelOptions, $aggregated);
|
||||
$summary = $this->summaryBuilder->build(
|
||||
$filters['date_from'],
|
||||
$filters['date_to'],
|
||||
$filters['selected_channels'],
|
||||
$filters['channel_options'],
|
||||
$aggregated
|
||||
);
|
||||
|
||||
$html = $this->template->render('statistics/summary', [
|
||||
'title' => $this->translator->get('statistics.summary.title'),
|
||||
@@ -92,549 +109,16 @@ final class OrdersStatisticsController
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'filters' => [
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
'selected_channels' => $selectedChannels,
|
||||
'selected_status_groups' => $selectedStatusGroups,
|
||||
'date_from' => $filters['date_from'],
|
||||
'date_to' => $filters['date_to'],
|
||||
'selected_channels' => $filters['selected_channels'],
|
||||
'selected_status_groups' => $filters['selected_status_groups'],
|
||||
],
|
||||
'channelOptions' => $channelOptions,
|
||||
'statusGroupOptions' => $statusGroupOptions,
|
||||
'channelOptions' => $filters['channel_options'],
|
||||
'statusGroupOptions' => $filters['status_group_options'],
|
||||
'summary' => $summary,
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string}
|
||||
*/
|
||||
private function resolveDateRange(Request $request): array
|
||||
{
|
||||
$now = new DateTimeImmutable('now');
|
||||
$defaultFrom = $now->modify('first day of this month')->format('Y-m-d');
|
||||
$defaultTo = $now->modify('last day of this month')->format('Y-m-d');
|
||||
|
||||
$dateFrom = trim((string) $request->input('date_from', $defaultFrom));
|
||||
$dateTo = trim((string) $request->input('date_to', $defaultTo));
|
||||
|
||||
if (!$this->isValidDate($dateFrom)) {
|
||||
$dateFrom = $defaultFrom;
|
||||
}
|
||||
if (!$this->isValidDate($dateTo)) {
|
||||
$dateTo = $defaultTo;
|
||||
}
|
||||
if ($dateFrom > $dateTo) {
|
||||
[$dateFrom, $dateTo] = [$dateTo, $dateFrom];
|
||||
}
|
||||
|
||||
return [$dateFrom, $dateTo];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string}
|
||||
*/
|
||||
private function resolveSummaryDateRange(Request $request): array
|
||||
{
|
||||
$now = new DateTimeImmutable('now');
|
||||
$defaultFrom = '2026-04-01';
|
||||
$defaultTo = $now->format('Y-m-d');
|
||||
|
||||
$dateFrom = trim((string) $request->input('date_from', $defaultFrom));
|
||||
$dateTo = trim((string) $request->input('date_to', $defaultTo));
|
||||
|
||||
if (!$this->isValidDate($dateFrom)) {
|
||||
$dateFrom = $defaultFrom;
|
||||
}
|
||||
if (!$this->isValidDate($dateTo)) {
|
||||
$dateTo = $defaultTo;
|
||||
}
|
||||
if ($dateFrom > $dateTo) {
|
||||
[$dateFrom, $dateTo] = [$dateTo, $dateFrom];
|
||||
}
|
||||
|
||||
return [$dateFrom, $dateTo];
|
||||
}
|
||||
|
||||
private function isValidDate(string $date): bool
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return DateTimeImmutable::createFromFormat('Y-m-d', $date) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id:int,name:string}> $groups
|
||||
* @return array<int, array{id:int,name:string}>
|
||||
*/
|
||||
private function mapStatusGroupOptions(array $groups): array
|
||||
{
|
||||
$options = [];
|
||||
foreach ($groups as $group) {
|
||||
$groupId = (int) ($group['id'] ?? 0);
|
||||
if ($groupId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options[] = [
|
||||
'id' => $groupId,
|
||||
'name' => trim((string) ($group['name'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id:int,name:string}> $statusGroupOptions
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function resolveSelectedStatusGroups(Request $request, array $statusGroupOptions): array
|
||||
{
|
||||
$allowed = [];
|
||||
foreach ($statusGroupOptions as $option) {
|
||||
$allowed[] = (int) $option['id'];
|
||||
}
|
||||
|
||||
$allInput = $request->all();
|
||||
$hasStatusGroupsParam = array_key_exists('status_groups', $allInput);
|
||||
$selected = $this->toIntegerList($request->input('status_groups', []));
|
||||
if (!$hasStatusGroupsParam) {
|
||||
$selected = $this->defaultStatusGroupIds($statusGroupOptions);
|
||||
}
|
||||
|
||||
$selected = array_values(array_intersect($selected, $allowed));
|
||||
if ($selected !== []) {
|
||||
return $selected;
|
||||
}
|
||||
|
||||
return $hasStatusGroupsParam ? [] : $this->defaultStatusGroupIds($statusGroupOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id:int,name:string}> $statusGroupOptions
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function defaultStatusGroupIds(array $statusGroupOptions): array
|
||||
{
|
||||
$selected = [];
|
||||
foreach ($statusGroupOptions as $group) {
|
||||
$name = trim((string) ($group['name'] ?? ''));
|
||||
if ($this->isCancelledGroup($name)) {
|
||||
continue;
|
||||
}
|
||||
$selected[] = (int) ($group['id'] ?? 0);
|
||||
}
|
||||
|
||||
return array_values(array_filter($selected, static fn (int $id): bool => $id > 0));
|
||||
}
|
||||
|
||||
private function isCancelledGroup(string $name): bool
|
||||
{
|
||||
$normalized = strtr(mb_strtolower(trim($name)), [
|
||||
'ą' => 'a',
|
||||
'ć' => 'c',
|
||||
'ę' => 'e',
|
||||
'ł' => 'l',
|
||||
'ń' => 'n',
|
||||
'ó' => 'o',
|
||||
'ś' => 's',
|
||||
'ż' => 'z',
|
||||
'ź' => 'z',
|
||||
]);
|
||||
|
||||
return in_array($normalized, ['anulowane', 'anulowany', 'cancelled', 'canceled'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{key:string,label:string}> $channels
|
||||
* @return array<int, array{key:string,label:string}>
|
||||
*/
|
||||
private function mapChannelOptions(array $channels): array
|
||||
{
|
||||
$options = [];
|
||||
foreach ($channels as $channel) {
|
||||
$key = trim((string) ($channel['key'] ?? ''));
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$options[] = [
|
||||
'key' => $key,
|
||||
'label' => trim((string) ($channel['label'] ?? $key)),
|
||||
];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{key:string,label:string}> $channelOptions
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolveSelectedChannels(Request $request, array $channelOptions): array
|
||||
{
|
||||
$allowed = [];
|
||||
foreach ($channelOptions as $option) {
|
||||
$allowed[] = (string) $option['key'];
|
||||
}
|
||||
|
||||
$allInput = $request->all();
|
||||
$hasChannelsParam = array_key_exists('channels', $allInput);
|
||||
$selected = $this->toStringList($request->input('channels', []));
|
||||
if (!$hasChannelsParam) {
|
||||
return $allowed;
|
||||
}
|
||||
|
||||
return array_values(array_intersect($selected, $allowed));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $selectedChannels
|
||||
* @param array<int, array{day:string,channel_key:string,orders_count:int,total_net:float,total_gross:float}> $aggregated
|
||||
* @return array{rows:array<int,array<string,mixed>>,totals:array<string,mixed>,hasData:bool}
|
||||
*/
|
||||
private function buildTable(string $dateFrom, string $dateTo, array $selectedChannels, array $aggregated): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($this->dateRange($dateFrom, $dateTo) as $day) {
|
||||
$rows[$day] = [
|
||||
'day' => $day,
|
||||
'channels' => $this->emptyChannelsRow($selectedChannels),
|
||||
'day_total_orders' => 0,
|
||||
'day_total_net' => 0.0,
|
||||
'day_total_gross' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($aggregated as $item) {
|
||||
$day = (string) ($item['day'] ?? '');
|
||||
$channelKey = (string) ($item['channel_key'] ?? '');
|
||||
if ($day === '' || $channelKey === '' || !isset($rows[$day]['channels'][$channelKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ordersCount = (int) ($item['orders_count'] ?? 0);
|
||||
$totalNet = (float) ($item['total_net'] ?? 0);
|
||||
$totalGross = (float) ($item['total_gross'] ?? 0);
|
||||
|
||||
$rows[$day]['channels'][$channelKey] = [
|
||||
'orders_count' => $ordersCount,
|
||||
'total_net' => $totalNet,
|
||||
'total_gross' => $totalGross,
|
||||
];
|
||||
$rows[$day]['day_total_orders'] += $ordersCount;
|
||||
$rows[$day]['day_total_net'] += $totalNet;
|
||||
$rows[$day]['day_total_gross'] += $totalGross;
|
||||
}
|
||||
|
||||
$totals = [
|
||||
'channels' => $this->emptyChannelsRow($selectedChannels),
|
||||
'orders_count' => 0,
|
||||
'total_net' => 0.0,
|
||||
'total_gross' => 0.0,
|
||||
];
|
||||
|
||||
$hasData = false;
|
||||
foreach ($rows as $row) {
|
||||
foreach ($selectedChannels as $channelKey) {
|
||||
$channelStats = $row['channels'][$channelKey];
|
||||
$totals['channels'][$channelKey]['orders_count'] += (int) ($channelStats['orders_count'] ?? 0);
|
||||
$totals['channels'][$channelKey]['total_net'] += (float) ($channelStats['total_net'] ?? 0);
|
||||
$totals['channels'][$channelKey]['total_gross'] += (float) ($channelStats['total_gross'] ?? 0);
|
||||
}
|
||||
$totals['orders_count'] += (int) ($row['day_total_orders'] ?? 0);
|
||||
$totals['total_net'] += (float) ($row['day_total_net'] ?? 0);
|
||||
$totals['total_gross'] += (float) ($row['day_total_gross'] ?? 0);
|
||||
if ((int) ($row['day_total_orders'] ?? 0) > 0) {
|
||||
$hasData = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'rows' => array_values($rows),
|
||||
'totals' => $totals,
|
||||
'hasData' => $hasData,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $selectedChannels
|
||||
* @param array<int, array{key:string,label:string}> $channelOptions
|
||||
* @param array<int, array{month:string,channel_key:string,orders_count:int,total_gross:float}> $aggregated
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildSummary(
|
||||
string $dateFrom,
|
||||
string $dateTo,
|
||||
array $selectedChannels,
|
||||
array $channelOptions,
|
||||
array $aggregated
|
||||
): array {
|
||||
$channelLabels = $this->channelLabels($channelOptions);
|
||||
$rows = $this->emptySummaryRows($dateFrom, $dateTo, $selectedChannels);
|
||||
|
||||
foreach ($aggregated as $item) {
|
||||
$month = (string) ($item['month'] ?? '');
|
||||
$channelKey = (string) ($item['channel_key'] ?? '');
|
||||
if ($month === '' || $channelKey === '' || !isset($rows[$month]['channels'][$channelKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ordersCount = (int) ($item['orders_count'] ?? 0);
|
||||
$totalGross = (float) ($item['total_gross'] ?? 0);
|
||||
$rows[$month]['channels'][$channelKey] = [
|
||||
'orders_count' => $ordersCount,
|
||||
'total_gross' => $totalGross,
|
||||
];
|
||||
$rows[$month]['total_orders_count'] += $ordersCount;
|
||||
$rows[$month]['total_gross'] += $totalGross;
|
||||
}
|
||||
|
||||
return $this->summaryPayload($rows, $selectedChannels, $channelLabels);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{key:string,label:string}> $channelOptions
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function channelLabels(array $channelOptions): array
|
||||
{
|
||||
$labels = [];
|
||||
foreach ($channelOptions as $option) {
|
||||
$key = (string) ($option['key'] ?? '');
|
||||
if ($key !== '') {
|
||||
$labels[$key] = (string) ($option['label'] ?? $key);
|
||||
}
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $selectedChannels
|
||||
* @return array<string, array{month:string,channels:array<string,array{orders_count:int,total_gross:float}>,total_orders_count:int,total_gross:float}>
|
||||
*/
|
||||
private function emptySummaryRows(string $dateFrom, string $dateTo, array $selectedChannels): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($this->monthRange($dateFrom, $dateTo) as $month) {
|
||||
$rows[$month] = [
|
||||
'month' => $month,
|
||||
'channels' => $this->emptySummaryChannels($selectedChannels),
|
||||
'total_orders_count' => 0,
|
||||
'total_gross' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $selectedChannels
|
||||
* @return array<string, array{orders_count:int,total_gross:float}>
|
||||
*/
|
||||
private function emptySummaryChannels(array $selectedChannels): array
|
||||
{
|
||||
$channels = [];
|
||||
foreach ($selectedChannels as $channelKey) {
|
||||
$channels[$channelKey] = [
|
||||
'orders_count' => 0,
|
||||
'total_gross' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{month:string,channels:array<string,array{orders_count:int,total_gross:float}>,total_orders_count:int,total_gross:float}> $rows
|
||||
* @param array<int, string> $selectedChannels
|
||||
* @param array<string, string> $channelLabels
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function summaryPayload(array $rows, array $selectedChannels, array $channelLabels): array
|
||||
{
|
||||
$months = array_keys($rows);
|
||||
$labels = array_map(fn (string $month): string => $this->displayMonth($month), $months);
|
||||
$countSeries = $this->summarySeries($rows, $selectedChannels, $channelLabels, 'orders_count');
|
||||
$valueSeries = $this->summarySeries($rows, $selectedChannels, $channelLabels, 'total_gross');
|
||||
|
||||
$countSeries[] = $this->totalSeries($rows, 'Razem', 'total_orders_count');
|
||||
$valueSeries[] = $this->totalSeries($rows, 'Razem', 'total_gross');
|
||||
|
||||
return [
|
||||
'months' => $months,
|
||||
'rows' => array_values($rows),
|
||||
'hasData' => $this->summaryHasData($rows),
|
||||
'countChart' => [
|
||||
'labels' => $labels,
|
||||
'series' => $countSeries,
|
||||
'valueType' => 'number',
|
||||
],
|
||||
'valueChart' => [
|
||||
'labels' => $labels,
|
||||
'series' => $valueSeries,
|
||||
'valueType' => 'money',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function displayMonth(string $month): string
|
||||
{
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d', $month . '-01');
|
||||
if (!$date instanceof DateTimeImmutable) {
|
||||
return $month;
|
||||
}
|
||||
|
||||
return $date->format('m-Y');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{channels:array<string,array{orders_count:int,total_gross:float}>}> $rows
|
||||
* @param array<int, string> $selectedChannels
|
||||
* @param array<string, string> $channelLabels
|
||||
* @return array<int, array{key:string,label:string,values:array<int,int|float>}>
|
||||
*/
|
||||
private function summarySeries(array $rows, array $selectedChannels, array $channelLabels, string $metric): array
|
||||
{
|
||||
$series = [];
|
||||
foreach ($selectedChannels as $channelKey) {
|
||||
$values = [];
|
||||
foreach ($rows as $row) {
|
||||
$channelStats = $row['channels'][$channelKey] ?? [];
|
||||
$values[] = $metric === 'orders_count'
|
||||
? (int) ($channelStats[$metric] ?? 0)
|
||||
: (float) ($channelStats[$metric] ?? 0);
|
||||
}
|
||||
|
||||
$series[] = [
|
||||
'key' => $channelKey,
|
||||
'label' => $channelLabels[$channelKey] ?? $channelKey,
|
||||
'values' => $values,
|
||||
];
|
||||
}
|
||||
|
||||
return $series;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $rows
|
||||
* @return array{key:string,label:string,values:array<int,int|float>}
|
||||
*/
|
||||
private function totalSeries(array $rows, string $label, string $metric): array
|
||||
{
|
||||
$values = [];
|
||||
foreach ($rows as $row) {
|
||||
$values[] = $metric === 'total_orders_count'
|
||||
? (int) ($row[$metric] ?? 0)
|
||||
: (float) ($row[$metric] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => 'total',
|
||||
'label' => $label,
|
||||
'values' => $values,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{total_orders_count:int}> $rows
|
||||
*/
|
||||
private function summaryHasData(array $rows): bool
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
if ((int) ($row['total_orders_count'] ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $channelKeys
|
||||
* @return array<string, array{orders_count:int,total_net:float,total_gross:float}>
|
||||
*/
|
||||
private function emptyChannelsRow(array $channelKeys): array
|
||||
{
|
||||
$row = [];
|
||||
foreach ($channelKeys as $channelKey) {
|
||||
$row[$channelKey] = [
|
||||
'orders_count' => 0,
|
||||
'total_net' => 0.0,
|
||||
'total_gross' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function dateRange(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
$start = new DateTimeImmutable($dateFrom);
|
||||
$end = (new DateTimeImmutable($dateTo))->add(new DateInterval('P1D'));
|
||||
$period = new DatePeriod($start, new DateInterval('P1D'), $end);
|
||||
|
||||
$dates = [];
|
||||
foreach ($period as $date) {
|
||||
$dates[] = $date->format('Y-m-d');
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function monthRange(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
$start = (new DateTimeImmutable($dateFrom))->modify('first day of this month');
|
||||
$end = (new DateTimeImmutable($dateTo))->modify('first day of next month');
|
||||
$period = new DatePeriod($start, new DateInterval('P1M'), $end);
|
||||
|
||||
$months = [];
|
||||
foreach ($period as $date) {
|
||||
$months[] = $date->format('Y-m');
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function toStringList(mixed $value): array
|
||||
{
|
||||
$items = is_array($value) ? $value : ($value !== null && $value !== '' ? [$value] : []);
|
||||
$list = [];
|
||||
foreach ($items as $item) {
|
||||
$normalized = trim((string) $item);
|
||||
if ($normalized !== '') {
|
||||
$list[] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($list));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function toIntegerList(mixed $value): array
|
||||
{
|
||||
$items = is_array($value) ? $value : ($value !== null && $value !== '' ? [$value] : []);
|
||||
$list = [];
|
||||
foreach ($items as $item) {
|
||||
$number = (int) $item;
|
||||
if ($number > 0) {
|
||||
$list[] = $number;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($list));
|
||||
}
|
||||
}
|
||||
|
||||
296
src/Modules/Statistics/OrdersStatisticsFilters.php
Normal file
296
src/Modules/Statistics/OrdersStatisticsFilters.php
Normal file
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Statistics;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class OrdersStatisticsFilters
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{id:int,name:string}> $statusGroups
|
||||
* @param array<int, array{key:string,label:string}> $channels
|
||||
* @return array{
|
||||
* date_from:string,
|
||||
* date_to:string,
|
||||
* status_group_options:array<int, array{id:int,name:string}>,
|
||||
* selected_status_groups:array<int,int>,
|
||||
* channel_options:array<int, array{key:string,label:string}>,
|
||||
* selected_channels:array<int,string>
|
||||
* }
|
||||
*/
|
||||
public function resolveDailyFilters(Request $request, array $statusGroups, array $channels): array
|
||||
{
|
||||
[$dateFrom, $dateTo] = $this->resolveDateRange($request);
|
||||
|
||||
return $this->buildFilters($request, $dateFrom, $dateTo, $statusGroups, $channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id:int,name:string}> $statusGroups
|
||||
* @param array<int, array{key:string,label:string}> $channels
|
||||
* @return array{
|
||||
* date_from:string,
|
||||
* date_to:string,
|
||||
* status_group_options:array<int, array{id:int,name:string}>,
|
||||
* selected_status_groups:array<int,int>,
|
||||
* channel_options:array<int, array{key:string,label:string}>,
|
||||
* selected_channels:array<int,string>
|
||||
* }
|
||||
*/
|
||||
public function resolveSummaryFilters(Request $request, array $statusGroups, array $channels): array
|
||||
{
|
||||
[$dateFrom, $dateTo] = $this->resolveSummaryDateRange($request);
|
||||
|
||||
return $this->buildFilters($request, $dateFrom, $dateTo, $statusGroups, $channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id:int,name:string}> $statusGroups
|
||||
* @param array<int, array{key:string,label:string}> $channels
|
||||
* @return array{
|
||||
* date_from:string,
|
||||
* date_to:string,
|
||||
* status_group_options:array<int, array{id:int,name:string}>,
|
||||
* selected_status_groups:array<int,int>,
|
||||
* channel_options:array<int, array{key:string,label:string}>,
|
||||
* selected_channels:array<int,string>
|
||||
* }
|
||||
*/
|
||||
private function buildFilters(
|
||||
Request $request,
|
||||
string $dateFrom,
|
||||
string $dateTo,
|
||||
array $statusGroups,
|
||||
array $channels
|
||||
): array {
|
||||
$statusGroupOptions = $this->mapStatusGroupOptions($statusGroups);
|
||||
$selectedStatusGroups = $this->resolveSelectedStatusGroups($request, $statusGroupOptions);
|
||||
|
||||
$channelOptions = $this->mapChannelOptions($channels);
|
||||
$selectedChannels = $this->resolveSelectedChannels($request, $channelOptions);
|
||||
|
||||
return [
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
'status_group_options' => $statusGroupOptions,
|
||||
'selected_status_groups' => $selectedStatusGroups,
|
||||
'channel_options' => $channelOptions,
|
||||
'selected_channels' => $selectedChannels,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string}
|
||||
*/
|
||||
private function resolveDateRange(Request $request): array
|
||||
{
|
||||
$now = new DateTimeImmutable('now');
|
||||
$defaultFrom = $now->modify('first day of this month')->format('Y-m-d');
|
||||
$defaultTo = $now->modify('last day of this month')->format('Y-m-d');
|
||||
|
||||
return $this->normalizeRequestedRange($request, $defaultFrom, $defaultTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string}
|
||||
*/
|
||||
private function resolveSummaryDateRange(Request $request): array
|
||||
{
|
||||
$now = new DateTimeImmutable('now');
|
||||
$defaultFrom = '2026-04-01';
|
||||
$defaultTo = $now->format('Y-m-d');
|
||||
|
||||
return $this->normalizeRequestedRange($request, $defaultFrom, $defaultTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string}
|
||||
*/
|
||||
private function normalizeRequestedRange(Request $request, string $defaultFrom, string $defaultTo): array
|
||||
{
|
||||
$dateFrom = trim((string) $request->input('date_from', $defaultFrom));
|
||||
$dateTo = trim((string) $request->input('date_to', $defaultTo));
|
||||
|
||||
if (!$this->isValidDate($dateFrom)) {
|
||||
$dateFrom = $defaultFrom;
|
||||
}
|
||||
if (!$this->isValidDate($dateTo)) {
|
||||
$dateTo = $defaultTo;
|
||||
}
|
||||
if ($dateFrom > $dateTo) {
|
||||
[$dateFrom, $dateTo] = [$dateTo, $dateFrom];
|
||||
}
|
||||
|
||||
return [$dateFrom, $dateTo];
|
||||
}
|
||||
|
||||
private function isValidDate(string $date): bool
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return DateTimeImmutable::createFromFormat('Y-m-d', $date) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id:int,name:string}> $groups
|
||||
* @return array<int, array{id:int,name:string}>
|
||||
*/
|
||||
private function mapStatusGroupOptions(array $groups): array
|
||||
{
|
||||
$options = [];
|
||||
foreach ($groups as $group) {
|
||||
$groupId = (int) ($group['id'] ?? 0);
|
||||
if ($groupId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options[] = [
|
||||
'id' => $groupId,
|
||||
'name' => trim((string) ($group['name'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id:int,name:string}> $statusGroupOptions
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function resolveSelectedStatusGroups(Request $request, array $statusGroupOptions): array
|
||||
{
|
||||
$allowed = [];
|
||||
foreach ($statusGroupOptions as $option) {
|
||||
$allowed[] = (int) $option['id'];
|
||||
}
|
||||
|
||||
$allInput = $request->all();
|
||||
$hasStatusGroupsParam = array_key_exists('status_groups', $allInput);
|
||||
$selected = $this->toIntegerList($request->input('status_groups', []));
|
||||
if (!$hasStatusGroupsParam) {
|
||||
$selected = $this->defaultStatusGroupIds($statusGroupOptions);
|
||||
}
|
||||
|
||||
$selected = array_values(array_intersect($selected, $allowed));
|
||||
if ($selected !== []) {
|
||||
return $selected;
|
||||
}
|
||||
|
||||
return $hasStatusGroupsParam ? [] : $this->defaultStatusGroupIds($statusGroupOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id:int,name:string}> $statusGroupOptions
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function defaultStatusGroupIds(array $statusGroupOptions): array
|
||||
{
|
||||
$selected = [];
|
||||
foreach ($statusGroupOptions as $group) {
|
||||
$name = trim((string) ($group['name'] ?? ''));
|
||||
if ($this->isCancelledGroup($name)) {
|
||||
continue;
|
||||
}
|
||||
$selected[] = (int) ($group['id'] ?? 0);
|
||||
}
|
||||
|
||||
return array_values(array_filter($selected, static fn (int $id): bool => $id > 0));
|
||||
}
|
||||
|
||||
private function isCancelledGroup(string $name): bool
|
||||
{
|
||||
$normalized = strtr(mb_strtolower(trim($name)), [
|
||||
'ą' => 'a',
|
||||
'ć' => 'c',
|
||||
'ę' => 'e',
|
||||
'ł' => 'l',
|
||||
'ń' => 'n',
|
||||
'ó' => 'o',
|
||||
'ś' => 's',
|
||||
'ż' => 'z',
|
||||
'ź' => 'z',
|
||||
]);
|
||||
|
||||
return in_array($normalized, ['anulowane', 'anulowany', 'cancelled', 'canceled'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{key:string,label:string}> $channels
|
||||
* @return array<int, array{key:string,label:string}>
|
||||
*/
|
||||
private function mapChannelOptions(array $channels): array
|
||||
{
|
||||
$options = [];
|
||||
foreach ($channels as $channel) {
|
||||
$key = trim((string) ($channel['key'] ?? ''));
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$options[] = [
|
||||
'key' => $key,
|
||||
'label' => trim((string) ($channel['label'] ?? $key)),
|
||||
];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{key:string,label:string}> $channelOptions
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolveSelectedChannels(Request $request, array $channelOptions): array
|
||||
{
|
||||
$allowed = [];
|
||||
foreach ($channelOptions as $option) {
|
||||
$allowed[] = (string) $option['key'];
|
||||
}
|
||||
|
||||
$allInput = $request->all();
|
||||
$hasChannelsParam = array_key_exists('channels', $allInput);
|
||||
$selected = $this->toStringList($request->input('channels', []));
|
||||
if (!$hasChannelsParam) {
|
||||
return $allowed;
|
||||
}
|
||||
|
||||
return array_values(array_intersect($selected, $allowed));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function toStringList(mixed $value): array
|
||||
{
|
||||
$items = is_array($value) ? $value : ($value !== null && $value !== '' ? [$value] : []);
|
||||
$list = [];
|
||||
foreach ($items as $item) {
|
||||
$normalized = trim((string) $item);
|
||||
if ($normalized !== '') {
|
||||
$list[] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($list));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function toIntegerList(mixed $value): array
|
||||
{
|
||||
$items = is_array($value) ? $value : ($value !== null && $value !== '' ? [$value] : []);
|
||||
$list = [];
|
||||
foreach ($items as $item) {
|
||||
$number = (int) $item;
|
||||
if ($number > 0) {
|
||||
$list[] = $number;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($list));
|
||||
}
|
||||
}
|
||||
222
src/Modules/Statistics/OrdersStatisticsSummaryBuilder.php
Normal file
222
src/Modules/Statistics/OrdersStatisticsSummaryBuilder.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Statistics;
|
||||
|
||||
use DateInterval;
|
||||
use DatePeriod;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class OrdersStatisticsSummaryBuilder
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $selectedChannels
|
||||
* @param array<int, array{key:string,label:string}> $channelOptions
|
||||
* @param array<int, array{month:string,channel_key:string,orders_count:int,total_gross:float}> $aggregated
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function build(
|
||||
string $dateFrom,
|
||||
string $dateTo,
|
||||
array $selectedChannels,
|
||||
array $channelOptions,
|
||||
array $aggregated
|
||||
): array {
|
||||
$channelLabels = $this->channelLabels($channelOptions);
|
||||
$rows = $this->emptySummaryRows($dateFrom, $dateTo, $selectedChannels);
|
||||
|
||||
foreach ($aggregated as $item) {
|
||||
$month = (string) ($item['month'] ?? '');
|
||||
$channelKey = (string) ($item['channel_key'] ?? '');
|
||||
if ($month === '' || $channelKey === '' || !isset($rows[$month]['channels'][$channelKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ordersCount = (int) ($item['orders_count'] ?? 0);
|
||||
$totalGross = (float) ($item['total_gross'] ?? 0);
|
||||
$rows[$month]['channels'][$channelKey] = [
|
||||
'orders_count' => $ordersCount,
|
||||
'total_gross' => $totalGross,
|
||||
];
|
||||
$rows[$month]['total_orders_count'] += $ordersCount;
|
||||
$rows[$month]['total_gross'] += $totalGross;
|
||||
}
|
||||
|
||||
return $this->summaryPayload($rows, $selectedChannels, $channelLabels);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{key:string,label:string}> $channelOptions
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function channelLabels(array $channelOptions): array
|
||||
{
|
||||
$labels = [];
|
||||
foreach ($channelOptions as $option) {
|
||||
$key = (string) ($option['key'] ?? '');
|
||||
if ($key !== '') {
|
||||
$labels[$key] = (string) ($option['label'] ?? $key);
|
||||
}
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $selectedChannels
|
||||
* @return array<string, array{month:string,channels:array<string,array{orders_count:int,total_gross:float}>,total_orders_count:int,total_gross:float}>
|
||||
*/
|
||||
private function emptySummaryRows(string $dateFrom, string $dateTo, array $selectedChannels): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($this->monthRange($dateFrom, $dateTo) as $month) {
|
||||
$rows[$month] = [
|
||||
'month' => $month,
|
||||
'channels' => $this->emptySummaryChannels($selectedChannels),
|
||||
'total_orders_count' => 0,
|
||||
'total_gross' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $selectedChannels
|
||||
* @return array<string, array{orders_count:int,total_gross:float}>
|
||||
*/
|
||||
private function emptySummaryChannels(array $selectedChannels): array
|
||||
{
|
||||
$channels = [];
|
||||
foreach ($selectedChannels as $channelKey) {
|
||||
$channels[$channelKey] = [
|
||||
'orders_count' => 0,
|
||||
'total_gross' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{month:string,channels:array<string,array{orders_count:int,total_gross:float}>,total_orders_count:int,total_gross:float}> $rows
|
||||
* @param array<int, string> $selectedChannels
|
||||
* @param array<string, string> $channelLabels
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function summaryPayload(array $rows, array $selectedChannels, array $channelLabels): array
|
||||
{
|
||||
$months = array_keys($rows);
|
||||
$labels = array_map(fn (string $month): string => $this->displayMonth($month), $months);
|
||||
$countSeries = $this->summarySeries($rows, $selectedChannels, $channelLabels, 'orders_count');
|
||||
$valueSeries = $this->summarySeries($rows, $selectedChannels, $channelLabels, 'total_gross');
|
||||
|
||||
$countSeries[] = $this->totalSeries($rows, 'Razem', 'total_orders_count');
|
||||
$valueSeries[] = $this->totalSeries($rows, 'Razem', 'total_gross');
|
||||
|
||||
return [
|
||||
'months' => $months,
|
||||
'rows' => array_values($rows),
|
||||
'hasData' => $this->summaryHasData($rows),
|
||||
'countChart' => [
|
||||
'labels' => $labels,
|
||||
'series' => $countSeries,
|
||||
'valueType' => 'number',
|
||||
],
|
||||
'valueChart' => [
|
||||
'labels' => $labels,
|
||||
'series' => $valueSeries,
|
||||
'valueType' => 'money',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function displayMonth(string $month): string
|
||||
{
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d', $month . '-01');
|
||||
if (!$date instanceof DateTimeImmutable) {
|
||||
return $month;
|
||||
}
|
||||
|
||||
return $date->format('m-Y');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{channels:array<string,array{orders_count:int,total_gross:float}>}> $rows
|
||||
* @param array<int, string> $selectedChannels
|
||||
* @param array<string, string> $channelLabels
|
||||
* @return array<int, array{key:string,label:string,values:array<int,int|float>}>
|
||||
*/
|
||||
private function summarySeries(array $rows, array $selectedChannels, array $channelLabels, string $metric): array
|
||||
{
|
||||
$series = [];
|
||||
foreach ($selectedChannels as $channelKey) {
|
||||
$values = [];
|
||||
foreach ($rows as $row) {
|
||||
$channelStats = $row['channels'][$channelKey] ?? [];
|
||||
$values[] = $metric === 'orders_count'
|
||||
? (int) ($channelStats[$metric] ?? 0)
|
||||
: (float) ($channelStats[$metric] ?? 0);
|
||||
}
|
||||
|
||||
$series[] = [
|
||||
'key' => $channelKey,
|
||||
'label' => $channelLabels[$channelKey] ?? $channelKey,
|
||||
'values' => $values,
|
||||
];
|
||||
}
|
||||
|
||||
return $series;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $rows
|
||||
* @return array{key:string,label:string,values:array<int,int|float>}
|
||||
*/
|
||||
private function totalSeries(array $rows, string $label, string $metric): array
|
||||
{
|
||||
$values = [];
|
||||
foreach ($rows as $row) {
|
||||
$values[] = $metric === 'total_orders_count'
|
||||
? (int) ($row[$metric] ?? 0)
|
||||
: (float) ($row[$metric] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => 'total',
|
||||
'label' => $label,
|
||||
'values' => $values,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{total_orders_count:int}> $rows
|
||||
*/
|
||||
private function summaryHasData(array $rows): bool
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
if ((int) ($row['total_orders_count'] ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function monthRange(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
$start = (new DateTimeImmutable($dateFrom))->modify('first day of this month');
|
||||
$end = (new DateTimeImmutable($dateTo))->modify('first day of next month');
|
||||
$period = new DatePeriod($start, new DateInterval('P1M'), $end);
|
||||
|
||||
$months = [];
|
||||
foreach ($period as $date) {
|
||||
$months[] = $date->format('Y-m');
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
}
|
||||
115
src/Modules/Statistics/OrdersStatisticsTableBuilder.php
Normal file
115
src/Modules/Statistics/OrdersStatisticsTableBuilder.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Statistics;
|
||||
|
||||
use DateInterval;
|
||||
use DatePeriod;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class OrdersStatisticsTableBuilder
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $selectedChannels
|
||||
* @param array<int, array{day:string,channel_key:string,orders_count:int,total_net:float,total_gross:float}> $aggregated
|
||||
* @return array{rows:array<int,array<string,mixed>>,totals:array<string,mixed>,hasData:bool}
|
||||
*/
|
||||
public function build(string $dateFrom, string $dateTo, array $selectedChannels, array $aggregated): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($this->dateRange($dateFrom, $dateTo) as $day) {
|
||||
$rows[$day] = [
|
||||
'day' => $day,
|
||||
'channels' => $this->emptyChannelsRow($selectedChannels),
|
||||
'day_total_orders' => 0,
|
||||
'day_total_net' => 0.0,
|
||||
'day_total_gross' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($aggregated as $item) {
|
||||
$day = (string) ($item['day'] ?? '');
|
||||
$channelKey = (string) ($item['channel_key'] ?? '');
|
||||
if ($day === '' || $channelKey === '' || !isset($rows[$day]['channels'][$channelKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ordersCount = (int) ($item['orders_count'] ?? 0);
|
||||
$totalNet = (float) ($item['total_net'] ?? 0);
|
||||
$totalGross = (float) ($item['total_gross'] ?? 0);
|
||||
|
||||
$rows[$day]['channels'][$channelKey] = [
|
||||
'orders_count' => $ordersCount,
|
||||
'total_net' => $totalNet,
|
||||
'total_gross' => $totalGross,
|
||||
];
|
||||
$rows[$day]['day_total_orders'] += $ordersCount;
|
||||
$rows[$day]['day_total_net'] += $totalNet;
|
||||
$rows[$day]['day_total_gross'] += $totalGross;
|
||||
}
|
||||
|
||||
$totals = [
|
||||
'channels' => $this->emptyChannelsRow($selectedChannels),
|
||||
'orders_count' => 0,
|
||||
'total_net' => 0.0,
|
||||
'total_gross' => 0.0,
|
||||
];
|
||||
|
||||
$hasData = false;
|
||||
foreach ($rows as $row) {
|
||||
foreach ($selectedChannels as $channelKey) {
|
||||
$channelStats = $row['channels'][$channelKey];
|
||||
$totals['channels'][$channelKey]['orders_count'] += (int) ($channelStats['orders_count'] ?? 0);
|
||||
$totals['channels'][$channelKey]['total_net'] += (float) ($channelStats['total_net'] ?? 0);
|
||||
$totals['channels'][$channelKey]['total_gross'] += (float) ($channelStats['total_gross'] ?? 0);
|
||||
}
|
||||
$totals['orders_count'] += (int) ($row['day_total_orders'] ?? 0);
|
||||
$totals['total_net'] += (float) ($row['day_total_net'] ?? 0);
|
||||
$totals['total_gross'] += (float) ($row['day_total_gross'] ?? 0);
|
||||
if ((int) ($row['day_total_orders'] ?? 0) > 0) {
|
||||
$hasData = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'rows' => array_values($rows),
|
||||
'totals' => $totals,
|
||||
'hasData' => $hasData,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $channelKeys
|
||||
* @return array<string, array{orders_count:int,total_net:float,total_gross:float}>
|
||||
*/
|
||||
private function emptyChannelsRow(array $channelKeys): array
|
||||
{
|
||||
$row = [];
|
||||
foreach ($channelKeys as $channelKey) {
|
||||
$row[$channelKey] = [
|
||||
'orders_count' => 0,
|
||||
'total_net' => 0.0,
|
||||
'total_gross' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function dateRange(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
$start = new DateTimeImmutable($dateFrom);
|
||||
$end = (new DateTimeImmutable($dateTo))->add(new DateInterval('P1D'));
|
||||
$period = new DatePeriod($start, new DateInterval('P1D'), $end);
|
||||
|
||||
$dates = [];
|
||||
foreach ($period as $date) {
|
||||
$dates[] = $date->format('Y-m-d');
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user