diff --git a/.paul/STATE.md b/.paul/STATE.md index bea2c1a..1c5f1ec 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -4,13 +4,18 @@ **Ostatnia aktualizacja:** 2026-05-20 ## Aktywna praca -UNIFY zakonczony dla `.paul/plans/20260520-1200-fix-login-page-and-remember-me/`. Petla zamknieta. Strona `/login`: usuniety subtitle, checkbox "Zapamiętaj mnie (30 dni)" w jednej linii (selektor `.form-field.remember-field`), `AuthController::showLogin` woluje `loginFromRememberToken()` -> uzytkownik z waznym cookie (30 dni) wraca automatycznie na `/settings/users` po wygasnieciu sesji. SUMMARY: `.paul/plans/20260520-1200-fix-login-page-and-remember-me/SUMMARY.md`. +UNIFY zakonczony dla `.paul/plans/20260520-1400-refactor-automation-controller/`. Petla zamknieta. `AutomationController.php` 677 → 221 lin. (67% redukcji). Wydzielono `AutomationRequestParser` (422), `AutomationFormViewModel` (80), `AutomationHistoryFilters` (58). `AutomationModule` zaktualizowany (3 nowe wpisy DI; `ReceiptConfigRepository` przeniesiony z controllera do view modelu). Powtarzajacy sie szablon `destroy/duplicate/toggleStatus` zlozony do `runIdAction()`. Zero zmian kontraktu HTTP/widokow/DB. `php -l` czysty dla 5/5 plikow. SUMMARY: `.paul/plans/20260520-1400-refactor-automation-controller/SUMMARY.md`. ``` PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [Loop complete — gotowe na nastepny PLAN] ``` +⚠ **Do weryfikacji recznej (vendor/phpunit niedostepny w sesji):** smoke-test `/settings/automation` (lista + zakladka history z filtrami), `/settings/automation/create`, `/settings/automation/edit?id=N` (wszystkie typy conditions/actions). + +## Poprzednia praca +UNIFY zakonczony dla `.paul/plans/20260520-1200-fix-login-page-and-remember-me/`. Strona `/login`: usuniety subtitle, checkbox "Zapamiętaj mnie (30 dni)" w jednej linii, `AuthController::showLogin` woluje `loginFromRememberToken()` -> auto-login z cookie (30 dni). SUMMARY: `.paul/plans/20260520-1200-fix-login-page-and-remember-me/SUMMARY.md`. + Rezultat: `DeliveryStatus.php` 657 -> 170 lin. (fasada zachowujaca pelny kontrakt). Wydzielono `DeliveryStatusProviderMap` (~330), `AllegroDescriptionGuesser` (~150), `DeliveryTrackingUrlBuilder` (~85). Zero zmian w 20 plikach konsumentow; `tests/Unit/DeliveryStatusTest.php` 4/4 bez modyfikacji (primary gate). Pelny suite 3/15 identyczny z baseline (pre-existing). SUMMARY: `.paul/plans/20260519-1730-refactor-delivery-status/SUMMARY.md`. ## Poprzednia praca diff --git a/.paul/codebase/architecture.md b/.paul/codebase/architecture.md index 4e2dc9e..7d2f054 100644 --- a/.paul/codebase/architecture.md +++ b/.paul/codebase/architecture.md @@ -72,7 +72,7 @@ Korzysci wzgledem poprzedniego monolitycznego `routes/web.php` (859 lin.): | Email | `src/Modules/Email/` | SMTP, wysylka maili | | Sms | `src/Modules/Sms/` | wysylka SMS, webhook SmsPlanet, konwersacje | | Notifications | `src/Modules/Notifications/` | powiadomienia w aplikacji + API | -| Automation | `src/Modules/Automation/` | reguly automatyzacji (status-aged, email-once) | +| Automation | `src/Modules/Automation/` | reguly automatyzacji (status-aged, email-once); slim `AutomationController` + `AutomationRequestParser` (parsing/walidacja conditions+actions + katalog stalych) + `AutomationFormViewModel` (zmienne template'a) + `AutomationHistoryFilters` (filtry historii + aktywna zakladka) + `AutomationService` (silnik wykonawczy). | | Settings | `src/Modules/Settings/` | firmy, integracje, mapowania statusow, szablony | | Cron | `src/Modules/Cron/` | handlery + scheduler | | Info | `src/Modules/Info/` | strona informacyjna | diff --git a/.paul/codebase/quality_risks.md b/.paul/codebase/quality_risks.md index 5bcdfa5..95a4405 100644 --- a/.paul/codebase/quality_risks.md +++ b/.paul/codebase/quality_risks.md @@ -20,7 +20,7 @@ Konwencja (`CLAUDE.md`): funkcja/klasa zwykle do 30-50 linii, max 3 poziomy zagn | `src/Modules/Automation/AutomationService.php` | 818 | silnik regul — kandydat na pipeline-strategy. | | `src/Modules/Shipments/PolkurierShipmentService.php` | 776 | | | `src/Modules/Accounting/InvoiceService.php` | 762 | | -| `src/Modules/Automation/AutomationController.php` | 677 | | +| ~~`src/Modules/Automation/AutomationController.php`~~ | ~~677~~ -> 221 | ✅ Zrefaktorowane 2026-05-20 — wydzielono `AutomationRequestParser` (422), `AutomationFormViewModel` (80), `AutomationHistoryFilters` (58); szablon `destroy/duplicate/toggleStatus` zlozony do `runIdAction()`. Zero zmian kontraktu HTTP/widokow/DB. Patrz `.paul/plans/20260520-1400-refactor-automation-controller/SUMMARY.md`. | | ~~`src/Modules/Shipments/DeliveryStatus.php`~~ | ~~657~~ -> 170 | ✅ Zrefaktorowane 2026-05-19 — fasada zachowujaca pelny kontrakt + wydzielono `DeliveryStatusProviderMap` (~330), `AllegroDescriptionGuesser` (~150), `DeliveryTrackingUrlBuilder` (~85). Zero zmian w 20 plikach konsumentow. Patrz `.paul/plans/20260519-1730-refactor-delivery-status/SUMMARY.md`. | | ~~`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`. | diff --git a/.paul/codebase/tech_changelog.md b/.paul/codebase/tech_changelog.md index de16d5c..319f6b8 100644 --- a/.paul/codebase/tech_changelog.md +++ b/.paul/codebase/tech_changelog.md @@ -2,6 +2,29 @@ Chronologiczny log zmian technicznych (co i dlaczego). Najnowsze na gorze. +## 2026-05-20 — Dekompozycja AutomationController (slim controller + 3 wspolpracownikow) + +### Co +- `src/Modules/Automation/AutomationController.php`: 677 -> 221 lin. (67% redukcji). +- Wydzielono trzy klasy w namespace `App\Modules\Automation`: + - `AutomationRequestParser` (422 lin.) — wszystkie stale ALLOWED_* + PAYMENT_*, metody `validate`, `extractRuleData`, `extractConditions`, `extractActions`, `buildRule`, `parseConditionValue`, `parseActionConfig` oraz gettery katalogow do widoku. + - `AutomationFormViewModel` (80 lin.) — przygotowanie zmiennych template'a `automation/form`: `buildShipmentStatusOptions`, `listActiveReceiptConfigs`, kompozycja wszystkich opcji. + - `AutomationHistoryFilters` (58 lin.) — `extract`, `resolveActiveTab`, `hasAny`. +- `AutomationModule.php`: zarejestrowane `automation.request_parser`, `automation.form_view_model`, `automation.history_filters`. Konstruktor controllera rozszerzony o 3 zaleznosci, usunieto `ReceiptConfigRepository` (przeniesiony do view modelu). +- Powtarzajacy sie szablon `destroy`/`duplicate`/`toggleStatus` zlozony do `runIdAction(Request, callable, ok, err)`. + +### Dlaczego +- `AutomationController` byl na liscie kandydatow do dekompozycji w `.paul/codebase/quality_risks.md` (677 lin.). +- Mieszal piec odpowiedzialnosci: routing HTTP, parsowanie/walidacja warunkow i akcji, view-model formularza, filtry historii, katalog stalych. +- Wzorzec analogiczny do udanych refaktorow Allegro (653 → 223), Statistics (640 → 110), DeliveryStatus (657 → 170). + +### Wplyw +- Zero zmian kontraktu HTTP — sciezki `/settings/automation/*` niezmienione. +- Zero zmian w widokach `automation/index.php` i `automation/form.php` — klucze template'a 1:1. +- Zero zmian schematu DB. +- `tests/Unit/AutomationServiceTest.php` nie modyfikowany (nie dotyka controllera). +- Plan: `.paul/plans/20260520-1400-refactor-automation-controller/PLAN.md`. + ## 2026-05-20 — Strona logowania: usuniecie subtitle, inline "Zapamietaj mnie (30 dni)", auto-login z cookie ### Co diff --git a/.paul/plans/20260520-1400-refactor-automation-controller/PLAN.md b/.paul/plans/20260520-1400-refactor-automation-controller/PLAN.md new file mode 100644 index 0000000..393e446 --- /dev/null +++ b/.paul/plans/20260520-1400-refactor-automation-controller/PLAN.md @@ -0,0 +1,307 @@ +--- +plan_id: 20260520-1400-refactor-automation-controller +title: Refaktoring AutomationController — dekompozycja na slim controller + 3 wspolpracownikow +storage: plan-first +legacy_phase: null +created: 2026-05-20T14:00:00+02:00 +status: planned +type: execute +autonomous: true +delegation: auto +files_modified: + - src/Modules/Automation/AutomationController.php + - src/Modules/Automation/AutomationModule.php + - src/Modules/Automation/AutomationRequestParser.php + - src/Modules/Automation/AutomationFormViewModel.php + - src/Modules/Automation/AutomationHistoryFilters.php + - .paul/codebase/architecture.md + - .paul/codebase/quality_risks.md + - .paul/codebase/tech_changelog.md +quality_radar: ok +--- + + +## Cel +Zredukowac `src/Modules/Automation/AutomationController.php` z 677 lin. do < 230 lin. przez wydzielenie wspolpracownikow zgodnie z SRP — bez zmiany publicznego kontraktu HTTP, bez zmian w widokach `resources/views/automation/`, bez zmian schematu DB. + +## Po co +- `AutomationController` jest na liscie kandydatow do dekompozycji w `.paul/codebase/quality_risks.md` (677 lin., > 500 lin. progu). +- Obecnie miesza piec odpowiedzialnosci: routing HTTP, parsowanie/walidacja conditions+actions, view-model formularza, filtry historii, katalog stalych. +- Wzorzec dekompozycji udowodniony na `AllegroIntegrationController` (653 -> 223, 66% redukcji) i `OrdersStatisticsController` (640 -> 110, 83%). + +## Co powstanie +- `AutomationRequestParser` (~280 lin.) — wszystkie metody odczytu/walidacji wejscia + stale ALLOWED_* katalogu warunkow/akcji. +- `AutomationFormViewModel` (~70 lin.) — przygotowanie danych do widoku `automation/form` (opcje statusow, lista aktywnych konfiguracji paragonow). +- `AutomationHistoryFilters` (~60 lin.) — odczyt filtrow historii + rozwiazywanie aktywnej zakladki. +- Slim `AutomationController` (< 230 lin.) — wylacznie akcje HTTP i delegacja. + + + +## Dokumenty projektu +@.paul/PROJECT.md +@.paul/STATE.md +@.paul/codebase/architecture.md +@.paul/codebase/impact_map.md +@.paul/codebase/quality_risks.md + +## Pliki zrodlowe +@src/Modules/Automation/AutomationController.php +@src/Modules/Automation/AutomationModule.php +@src/Modules/Automation/AutomationRepository.php +@src/Modules/Automation/AutomationExecutionLogRepository.php +@src/Modules/Settings/ReceiptConfigRepository.php +@src/Modules/Shipments/DeliveryStatus.php +@resources/views/automation/index.php +@resources/views/automation/form.php + +## Referencje (analogiczne refaktory) +@.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md +@.paul/plans/20260519-1430-refactor-orders-statistics-controller/SUMMARY.md + + + +- Brak wymaganych. Refactor czysto wewnetrzny — kontrakt HTTP (routes + payload formularzy) niezmieniony. +- Stale `ALLOWED_EVENTS`, `ALLOWED_CONDITION_TYPES`, `ALLOWED_ACTION_TYPES`, `ALLOWED_RECIPIENTS`, `ALLOWED_RECEIPT_*`, `PAYMENT_*_OPTIONS` przeniesione do `AutomationRequestParser` jako jedyne miejsce zrodla prawdy. Controller czyta katalogi przez gettery (`parser->allowedEvents()` itp.) tylko dla widoku formularza. + + + +## Quality Radar + +**Status:** ok +**Tryb:** plan (lekki skan na bazie `.paul/codebase/quality_risks.md` + `impact_map.md` — codebase-memory-mcp dostepny; jscpd/ast-grep wylaczone polityka) + +## Obszary objete zmiana + +- Modul `Automation` — kontroler + provider DI (`AutomationModule`). +- Brak wplywu na `AutomationService` (silnik wykonawczy regul) — to osobny refactor zaplanowany w `quality_risks.md` (818 lin.). +- Brak zmian w widokach `resources/views/automation/index.php` i `automation/form.php` — kontrakt zmiennych template'a zachowany 1:1. + +## Powiazania zewnetrzne (bez zmian) +- `routes/web.php` przez `AutomationModule::routes()` — sygnatura niezmieniona, lazy resolve `automation.controller`. +- `AutomationRepository` / `AutomationExecutionLogRepository` / `ReceiptConfigRepository` — wywolania niezmienione. +- `DeliveryStatus::getAllStatuses()` / `::getAllOptions()` — uzywane przez nowy parser/viewmodel bez zmiany API. + +## Ryzyka duplikatow / hardkody + +- Stale ALLOWED_* — przed refaktorem zdublowane w kilku miejscach (controller + service?). Zwerifikuje w Task 1 czy `AutomationService` nie powtarza tych list; jezeli tak — zostaje deferral do osobnego planu, controller deleguje do parsera, service nie tykany. + +## Jawne odroczenia + +- Refaktor `AutomationService.php` (818 lin.) — odrebny plan z `quality_risks.md`, poza zakresem. +- Wspolny `BaseIntegrationController` / trait — odrebne ryzyko, poza zakresem. + + + +Brak SPECIAL-FLOWS.md — sekcja niewymagana. + + + + +## AC-1: AutomationController ma < 230 linii i tylko akcje HTTP +```gherkin +Given plik src/Modules/Automation/AutomationController.php po refaktorze +When zliczam linie (`wc -l`) +Then wynik jest mniejszy niz 230 +And klasa zawiera wylacznie publiczne akcje (index, create, edit, store, update, destroy, duplicate, toggleStatus) plus prywatne `renderForm` i `validateCsrf` +And metody parseConditionValue / parseActionConfig / extractConditions / extractActions / extractRuleData / buildRuleFromRequest / validateInput / buildShipmentStatusOptions / listActiveReceiptConfigs / extractHistoryFilters / resolveActiveTab / hasHistoryFilters nie istnieja juz w controllerze +``` + +## AC-2: AutomationRequestParser przejmuje wejscie i katalogi +```gherkin +Given AutomationController::store, ::update otrzymuja Request +When kontroler woluje parser->parse(Request) / parser->validate(Request) +Then parser zwraca te same struktury co wczesniejsze extractRuleData/extractConditions/extractActions +And stale ALLOWED_EVENTS, ALLOWED_CONDITION_TYPES, ALLOWED_ACTION_TYPES, ALLOWED_RECIPIENTS, ALLOWED_RECEIPT_ISSUE_DATE_MODES, ALLOWED_RECEIPT_DUPLICATE_POLICIES, PAYMENT_STATUS_OPTIONS, PAYMENT_METHOD_OPTIONS sa wylacznie w AutomationRequestParser +And controller eksponuje je do widoku poprzez gettery parsera (np. parser->allowedEvents()) +``` + +## AC-3: AutomationFormViewModel przygotowuje dane formularza +```gherkin +Given AutomationController::renderForm(rule, errorMessage) +When viewModel->build(rule, errorMessage) jest wywolany +Then zwracana tablica zmiennych dla template'a 'automation/form' ma identyczne klucze i identyczne wartosci jak przed refaktorem (manualne porownanie sygnatur view'a) +And buildShipmentStatusOptions oraz listActiveReceiptConfigs zyja wylacznie w viewModelu +``` + +## AC-4: AutomationHistoryFilters obsluguje liste historii +```gherkin +Given AutomationController::index(Request) +When historyFilters->extract(Request), ->resolveActiveTab(Request, filters), ->hasAny(filters) sa wywolane +Then zwracane wartosci sa identyczne jak przed refaktorem +And extractHistoryFilters / resolveActiveTab / hasHistoryFilters nie istnieja juz w controllerze +``` + +## AC-5: Zero regresji testowej i lintera +```gherkin +Given pelny suite phpunit `vendor/bin/phpunit` +When uruchamiam testy +Then liczba przechodzacych testow jest >= baseline (12/15 zielone — 3/15 to pre-existing failures spoza modulu Automation) +And `tests/Unit/AutomationServiceTest.php` jest zielony bez modyfikacji +And `php -l` na wszystkich zmienionych/utworzonych plikach zwraca No syntax errors +``` + +## AC-6: Module DI dziala bez zmian publicznego kontraktu +```gherkin +Given AutomationModule::register(ServiceRegistry, Application) +When kontener buduje 'automation.controller' +Then konstruktor controllera otrzymuje zaleznosci: Template, Translator, AuthService, AutomationRepository, AutomationExecutionLogRepository, ReceiptConfigRepository, AutomationRequestParser, AutomationFormViewModel, AutomationHistoryFilters +And klucz 'automation.controller' i routy /settings/automation/* niezmienione +``` + + + + + + + Task 1: Wydzielenie AutomationRequestParser + + src/Modules/Automation/AutomationRequestParser.php (nowy), + src/Modules/Automation/AutomationController.php + + + 1. Utworz `AutomationRequestParser` (final, namespace `App\Modules\Automation`). + 2. Przenies do niego stale: ALLOWED_EVENTS, ALLOWED_CONDITION_TYPES, ALLOWED_ACTION_TYPES, ALLOWED_RECIPIENTS, ALLOWED_RECEIPT_ISSUE_DATE_MODES, ALLOWED_RECEIPT_DUPLICATE_POLICIES, PAYMENT_STATUS_OPTIONS, PAYMENT_METHOD_OPTIONS. + 3. Przenies metody: buildRuleFromRequest, validateInput, extractRuleData, extractConditions, parseConditionValue, extractActions, parseActionConfig. + 4. Konstruktor parsera: `private readonly AutomationRepository $repository` (potrzebny do listActiveOrderStatuses w parseConditionValue / parseActionConfig). + 5. Publiczne metody parsera: + - `validate(Request): ?string` + - `extractRuleData(Request): array` + - `extractConditions(Request): array` + - `extractActions(Request): array` + - `buildRule(Request, ?int $id = null): array` + - gettery katalogow: `allowedEvents(): array`, `allowedConditionTypes(): array`, `allowedActionTypes(): array`, `allowedRecipients(): array`, `allowedReceiptIssueDateModes(): array`, `allowedReceiptDuplicatePolicies(): array`, `paymentStatusOptions(): array`, `paymentMethodOptions(): array`. + 6. W kontrolerze usun przeniesione metody i stale. Akcje store/update/buildRuleFromRequest wywoluja parser. + 7. NIE modyfikuj na razie `AutomationModule.php` — to zrobimy w Task 4. + + `php -l src/Modules/Automation/AutomationRequestParser.php` zwraca "No syntax errors" + AC-2 (czesc parsera) + AC-1 (czesc metod usuniete z controllera) + + + + Task 2: Wydzielenie AutomationFormViewModel + + src/Modules/Automation/AutomationFormViewModel.php (nowy), + src/Modules/Automation/AutomationController.php + + + 1. Utworz `AutomationFormViewModel` (final, namespace `App\Modules\Automation`). + 2. Konstruktor: `private readonly AutomationRepository $repository, private readonly ReceiptConfigRepository $receiptConfigs, private readonly AutomationRequestParser $parser`. + 3. Przenies prywatne `buildShipmentStatusOptions` i `listActiveReceiptConfigs` (z controllera). + 4. Dodaj publiczne `build(?array $rule, string $errorMessage): array` zwracajace ten sam zestaw kluczy co dotychczasowy `renderForm` (poza `title`, `activeMenu`, `activeSettings`, `user`, `csrfToken` — te zostaja w controllerze, bo wymagaja AuthService/Csrf). + 5. Konkretnie viewModel zwraca: integrations, emailTemplates, eventTypes, conditionTypes, actionTypes, recipientOptions, receiptConfigs, receiptIssueDateModes, receiptDuplicatePolicies, shipmentStatusOptions, paymentStatusOptions, paymentMethodOptions, orderStatusOptions, rule (przekazane), errorMessage (z fallbackiem na Flash). + 6. W controller::renderForm: zlozenie `array_merge($viewModel->build($rule, $errorMessage), [HTTP-only keys])` i render template'a. + 7. Usun z controllera `buildShipmentStatusOptions` i `listActiveReceiptConfigs`. + + `php -l src/Modules/Automation/AutomationFormViewModel.php` zwraca "No syntax errors" + AC-3 + + + + Task 3: Wydzielenie AutomationHistoryFilters + + src/Modules/Automation/AutomationHistoryFilters.php (nowy), + src/Modules/Automation/AutomationController.php + + + 1. Utworz `AutomationHistoryFilters` (final, namespace `App\Modules\Automation`). + 2. Brak zaleznosci — pure logic. + 3. Publiczne metody: + - `extract(Request): array` (zwraca strukture `event_type, execution_status, rule_id, order_id, date_from, date_to`), + - `resolveActiveTab(Request, array $filters): string`, + - `hasAny(array $filters): bool`. + 4. W controller::index uzyj nowej klasy zamiast prywatnych metod. + 5. Usun z controllera `extractHistoryFilters`, `resolveActiveTab`, `hasHistoryFilters`. + + `php -l src/Modules/Automation/AutomationHistoryFilters.php` zwraca "No syntax errors" + AC-4 + + + + Task 4: Aktualizacja AutomationModule + slim AutomationController + + src/Modules/Automation/AutomationModule.php, + src/Modules/Automation/AutomationController.php + + + 1. W `AutomationModule::register` zarejestruj: + - `automation.request_parser` → `new AutomationRequestParser($s->get('automation.repo'))` + - `automation.form_view_model` → `new AutomationFormViewModel($s->get('automation.repo'), $s->get('accounting.receipts.config_repo'), $s->get('automation.request_parser'))` + - `automation.history_filters` → `new AutomationHistoryFilters()` + 2. Wstrzyknij te trzy zaleznosci do `automation.controller` (rozszerz konstruktor controllera o 3 readonly properties). + 3. ReceiptConfigRepository moze pozostac wstrzykniety do controllera dla kompatybilnosci LUB usuniety jezeli juz nigdzie nie uzywany (sprawdz w controllerze — po Task 2 powinien byc zbedny). Jezeli zbedny: usun parametr konstruktora i wpis w `AutomationModule`. + 4. Zweryfikuj final szkielet controllera: + - public: index, create, edit, store, update, destroy, duplicate, toggleStatus + - private: renderForm, validateCsrf + - brak innych metod, brak stalych ALLOWED_*/PAYMENT_* + 5. `wc -l src/Modules/Automation/AutomationController.php` < 230. + + + `php -l` na: AutomationController.php, AutomationModule.php, AutomationRequestParser.php, AutomationFormViewModel.php, AutomationHistoryFilters.php — wszystkie "No syntax errors". + `wc -l src/Modules/Automation/AutomationController.php` zwraca < 230. + + AC-1, AC-6 + + + + Task 5: Walidacja testami + aktualizacja docs + + .paul/codebase/architecture.md, + .paul/codebase/quality_risks.md, + .paul/codebase/tech_changelog.md + + + 1. Uruchom `vendor/bin/phpunit` (lub `composer test` jezeli zdefiniowane). + 2. Potwierdz: `tests/Unit/AutomationServiceTest.php` zielony, nie modyfikowany; baseline 12/15 ogolnie nieuszkodzony (3/15 pre-existing zewnetrzne failures). + 3. Smoke-test reczny widokow: + - `/settings/automation` (lista + zakladka history z filtrami i paginacja), + - `/settings/automation/create` i `/settings/automation/edit?id=N` (formularz z conditions + actions wszystkich typow). + 4. W `.paul/codebase/architecture.md` zaktualizuj wpis modulu Automation: + - opisz slim Controller + 3 wspolpracownikow. + 5. W `.paul/codebase/quality_risks.md` przekresl wiersz AutomationController z notatka o redukcji 677 → < 230, link do SUMMARY. + 6. Dopisz wpis w `.paul/codebase/tech_changelog.md` (data 2026-05-20). + + + Manualny przeglad widokow + `vendor/bin/phpunit --filter Automation` zielony. + + AC-5 (zero regresji) + + + + + +## Nie zmieniaj +- Publicznego kontraktu HTTP (`routes/web.php` przez `AutomationModule::routes` — sygnatury i sciezki). +- Plikow widokow `resources/views/automation/index.php` i `automation/form.php` — kontrakt zmiennych template'a 1:1. +- `AutomationService.php` (818 lin.) — refaktor odrebnego serwisu poza zakresem (osobny plan z `quality_risks.md`). +- `AutomationRepository` / `AutomationExecutionLogRepository` / `AutomationEmailOnceRepository` — bez zmian sygnatur. +- Schematu DB (`automation_rules`, `automation_execution_log`, `automation_email_once_deliveries`). +- `tests/Unit/AutomationServiceTest.php` — nie modyfikowac. + +## Ograniczenia zakresu +- Brak nowych funkcji UX, brak refaktoru widokow, brak refaktoru `AutomationService`. +- Brak ekstrakcji wspolnego `BaseIntegrationController` / trait — to inne ryzyko z `quality_risks.md`. +- Brak testow jednostkowych dla nowych klas (kandydat na nastepny plan); refaktor zachowuje istniejace pokrycie poprzez `AutomationServiceTest`. + + + +- [ ] `php -l src/Modules/Automation/AutomationController.php` → No syntax errors. +- [ ] `php -l src/Modules/Automation/AutomationModule.php` → No syntax errors. +- [ ] `php -l src/Modules/Automation/AutomationRequestParser.php` → No syntax errors. +- [ ] `php -l src/Modules/Automation/AutomationFormViewModel.php` → No syntax errors. +- [ ] `php -l src/Modules/Automation/AutomationHistoryFilters.php` → No syntax errors. +- [ ] `wc -l src/Modules/Automation/AutomationController.php` < 230. +- [ ] `vendor/bin/phpunit` — `tests/Unit/AutomationServiceTest.php` zielony, pelny suite na poziomie baseline (12/15). +- [ ] Smoke-test: `/settings/automation`, `/settings/automation/create`, `/settings/automation/edit?id=N` — bez bledow, formularze i listy renderuja sie identycznie. +- [ ] Quality Radar: aktualizacja `.paul/codebase/quality_risks.md` (przekreslenie wiersza AutomationController) + `architecture.md` + `tech_changelog.md`. + + + +- [ ] Wszystkie AC-1..AC-6 spelnione. +- [ ] Verification zaliczone. +- [ ] STATE.md wskazuje na ten plan jako aktywny (po APPLY -> po UNIFY: zamkniety). +- [ ] `quality_risks.md` zaktualizowany. + + + +SUMMARY.md path: `.paul/plans/20260520-1400-refactor-automation-controller/SUMMARY.md` + diff --git a/.paul/plans/20260520-1400-refactor-automation-controller/SUMMARY.md b/.paul/plans/20260520-1400-refactor-automation-controller/SUMMARY.md new file mode 100644 index 0000000..73fad43 --- /dev/null +++ b/.paul/plans/20260520-1400-refactor-automation-controller/SUMMARY.md @@ -0,0 +1,60 @@ +--- +plan_id: 20260520-1400-refactor-automation-controller +title: Refaktoring AutomationController — slim controller + 3 wspolpracownikow +status: completed +completed: 2026-05-20T14:30:00+02:00 +files_modified: + - src/Modules/Automation/AutomationController.php + - src/Modules/Automation/AutomationModule.php + - src/Modules/Automation/AutomationRequestParser.php (nowy) + - src/Modules/Automation/AutomationFormViewModel.php (nowy) + - src/Modules/Automation/AutomationHistoryFilters.php (nowy) + - .paul/codebase/architecture.md + - .paul/codebase/quality_risks.md + - .paul/codebase/tech_changelog.md +--- + +## Wynik + +`src/Modules/Automation/AutomationController.php`: **677 → 221 lin. (67% redukcji)**. Cel < 230 spelniony. + +### Metryki + +| Plik | Linie | Rola | +|---|---|---| +| `AutomationController.php` | 221 | Slim controller: 8 akcji HTTP + `runIdAction`/`renderForm`/`validateCsrf` | +| `AutomationRequestParser.php` | 422 | Stale ALLOWED_*/PAYMENT_*, walidacja, ekstrakcja conditions/actions/ruleData, `buildRule` | +| `AutomationFormViewModel.php` | 80 | Zmienne template'a `automation/form` | +| `AutomationHistoryFilters.php` | 58 | `extract` + `resolveActiveTab` + `hasAny` | +| `AutomationModule.php` | +14 lin. | Rejestracja 3 nowych serwisow + rozszerzony konstruktor controllera | + +## Acceptance Criteria + +| AC | Status | +|---|---| +| AC-1: Controller < 230 lin., tylko HTTP actions + helpery | ✅ 221 lin. | +| AC-2: Parser przejmuje wejscie + katalogi stalych | ✅ | +| AC-3: ViewModel przygotowuje dane formularza | ✅ klucze template'a 1:1 | +| AC-4: HistoryFilters obsluguje liste historii | ✅ | +| AC-5: Zero regresji testowej | ⚠ phpunit nieuruchamialny (brak `vendor/`); `php -l` czysty dla 5/5 plikow. Smoke-test pozostaje do reczego potwierdzenia | +| AC-6: Module DI dziala bez zmiany kontraktu HTTP | ✅ klucz `automation.controller` i routy niezmienione | + +## Decyzje + +- **`ReceiptConfigRepository` usuniety z konstruktora controllera** — przeniesiony do `AutomationFormViewModel`, ktory jest jedynym konsumentem. +- **`runIdAction(Request, callable, ok, err)`** — wprowadzony helper bo destroy/duplicate/toggleStatus mialy identyczny szablon CSRF + guard ID + try/catch + flash + redirect. Skrocenie ~50 lin., poprawa czytelnosci. +- **Getter pattern w parserze (`allowedEvents()` itp.)** zamiast publicznych stalych — zachowuje enkapsulacje katalogow w jednej klasie, view model i controller nie znaja konkretnych wartosci. +- **`buildRule()` z surowymi (nie zwalidowanymi) condition_value** — zachowane 1:1 z oryginalu, bo widok formularza wymaga raw wartosci do re-rendera po bledzie walidacji (np. zachowanie zaznaczonych checkboxow nawet jezeli ich klucze byly niewlasciwe). + +## Weryfikacja + +- ✅ `php -l` na wszystkich 5 plikach: No syntax errors. +- ✅ `wc -l src/Modules/Automation/AutomationController.php` = 221 (< 230). +- ⚠ `vendor/bin/phpunit` niedostepny w srodowisku (brak `vendor/`). `tests/Unit/AutomationServiceTest.php` nie byl modyfikowany — nie dotyka controllera, nadal powinien przechodzic po `composer install`. +- 🟡 **Do weryfikacji recznie:** `/settings/automation` (lista + zakladka history z filtrami i paginacja), `/settings/automation/create`, `/settings/automation/edit?id=N` (formularz dla wszystkich typow conditions/actions). + +## Deferrals + +- Refaktor `AutomationService.php` (818 lin.) — pozostaje na liscie kandydatow w `quality_risks.md`. +- Testy jednostkowe dla nowych klas (`AutomationRequestParser`, `AutomationFormViewModel`, `AutomationHistoryFilters`) — kandydat na nastepny plan. +- Wspolny `BaseIntegrationController` / trait — odrebne ryzyko. diff --git a/src/Modules/Automation/AutomationController.php b/src/Modules/Automation/AutomationController.php index 80e5131..80df1b7 100644 --- a/src/Modules/Automation/AutomationController.php +++ b/src/Modules/Automation/AutomationController.php @@ -10,52 +10,36 @@ use App\Core\Security\Csrf; use App\Core\Support\Flash; use App\Core\View\Template; use App\Modules\Auth\AuthService; -use App\Modules\Settings\ReceiptConfigRepository; -use App\Modules\Shipments\DeliveryStatus; use Throwable; final class AutomationController { private const HISTORY_PER_PAGE = 25; - private const ALLOWED_EVENTS = ['receipt.created', 'shipment.created', 'shipment.status_changed', 'payment.status_changed', 'order.status_changed', 'order.status_aged', 'order.imported']; - private const ALLOWED_CONDITION_TYPES = ['integration', 'shipment_status', 'payment_status', 'payment_method', 'order_status', 'days_in_status']; - private const PAYMENT_STATUS_OPTIONS = [ - '0' => 'Nieopłacone', - '1' => 'Częściowo opłacone', - '2' => 'Opłacone', - ]; - private const ALLOWED_ACTION_TYPES = ['send_email', 'issue_receipt', 'update_shipment_status', 'update_order_status']; - private const ALLOWED_RECIPIENTS = ['client', 'client_and_company', 'company']; - private const ALLOWED_RECEIPT_ISSUE_DATE_MODES = ['today', 'order_date', 'payment_date']; - private const ALLOWED_RECEIPT_DUPLICATE_POLICIES = ['skip_if_exists', 'allow_duplicates']; - private const PAYMENT_METHOD_OPTIONS = [ - 'cod' => 'Płatność przy odbiorze (COD)', - 'transfer' => 'Przelew bankowy', - 'online' => 'Karta / płatność online', - 'other' => 'Inna', - ]; + public function __construct( private readonly Template $template, private readonly Translator $translator, private readonly AuthService $auth, private readonly AutomationRepository $repository, private readonly AutomationExecutionLogRepository $executionLogs, - private readonly ReceiptConfigRepository $receiptConfigs + private readonly AutomationRequestParser $parser, + private readonly AutomationFormViewModel $formViewModel, + private readonly AutomationHistoryFilters $historyFilters ) { } public function index(Request $request): Response { $rules = $this->repository->findAll(); - $historyFilters = $this->extractHistoryFilters($request); + $filters = $this->historyFilters->extract($request); $historyPage = max(1, (int) $request->input('history_page', 1)); - $historyTotal = $this->executionLogs->count($historyFilters); + $historyTotal = $this->executionLogs->count($filters); $historyTotalPages = max(1, (int) ceil($historyTotal / self::HISTORY_PER_PAGE)); if ($historyPage > $historyTotalPages) { $historyPage = $historyTotalPages; } - $historyEntries = $this->executionLogs->paginate($historyFilters, $historyPage, self::HISTORY_PER_PAGE); - $activeTab = $this->resolveActiveTab($request, $historyFilters); + $historyEntries = $this->executionLogs->paginate($filters, $historyPage, self::HISTORY_PER_PAGE); + $activeTab = $this->historyFilters->resolveActiveTab($request, $filters); $html = $this->template->render('automation/index', [ 'title' => 'Zadania automatyczne', @@ -66,8 +50,8 @@ final class AutomationController 'rules' => $rules, 'activeTab' => $activeTab, 'historyEntries' => $historyEntries, - 'historyFilters' => $historyFilters, - 'historyEventTypes' => array_values(array_unique(array_merge(self::ALLOWED_EVENTS, $this->executionLogs->listEventTypes()))), + 'historyFilters' => $filters, + 'historyEventTypes' => array_values(array_unique(array_merge($this->parser->allowedEvents(), $this->executionLogs->listEventTypes()))), 'historyRuleOptions' => $this->repository->listRuleOptions(), 'historyPagination' => [ 'page' => $historyPage, @@ -107,20 +91,20 @@ final class AutomationController return $error; } - $validationError = $this->validateInput($request); + $validationError = $this->parser->validate($request); if ($validationError !== null) { - return $this->renderForm($this->buildRuleFromRequest($request), $validationError); + return $this->renderForm($this->parser->buildRule($request), $validationError); } try { $this->repository->create( - $this->extractRuleData($request), - $this->extractConditions($request), - $this->extractActions($request) + $this->parser->extractRuleData($request), + $this->parser->extractConditions($request), + $this->parser->extractActions($request) ); Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo utworzone'); } catch (Throwable) { - return $this->renderForm($this->buildRuleFromRequest($request), 'Błąd zapisu zadania automatycznego'); + return $this->renderForm($this->parser->buildRule($request), 'Błąd zapisu zadania automatycznego'); } return Response::redirect('/settings/automation'); @@ -139,21 +123,21 @@ final class AutomationController return Response::redirect('/settings/automation'); } - $validationError = $this->validateInput($request); + $validationError = $this->parser->validate($request); if ($validationError !== null) { - return $this->renderForm($this->buildRuleFromRequest($request, $id), $validationError); + return $this->renderForm($this->parser->buildRule($request, $id), $validationError); } try { $this->repository->update( $id, - $this->extractRuleData($request), - $this->extractConditions($request), - $this->extractActions($request) + $this->parser->extractRuleData($request), + $this->parser->extractConditions($request), + $this->parser->extractActions($request) ); Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo zaktualizowane'); } catch (Throwable) { - return $this->renderForm($this->buildRuleFromRequest($request, $id), 'Błąd aktualizacji zadania automatycznego'); + return $this->renderForm($this->parser->buildRule($request, $id), 'Błąd aktualizacji zadania automatycznego'); } return Response::redirect('/settings/automation'); @@ -161,55 +145,39 @@ final class AutomationController public function destroy(Request $request): Response { - $error = $this->validateCsrf($request); - if ($error !== null) { - return $error; - } - - $id = (int) $request->input('id', '0'); - if ($id <= 0) { - Flash::set('settings.automation.error', 'Nieprawidłowy identyfikator'); - return Response::redirect('/settings/automation'); - } - - try { - $this->repository->delete($id); - Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo usuńięte'); - } catch (Throwable) { - Flash::set('settings.automation.error', 'Błąd usuwania zadania automatycznego'); - } - - return Response::redirect('/settings/automation'); + return $this->runIdAction( + $request, + fn (int $id) => $this->repository->delete($id), + 'Zadanie automatyczne zostalo usuńięte', + 'Błąd usuwania zadania automatycznego' + ); } public function duplicate(Request $request): Response { - $error = $this->validateCsrf($request); - if ($error !== null) { - return $error; - } - - $id = (int) $request->input('id', '0'); - if ($id <= 0) { - Flash::set('settings.automation.error', 'Nieprawidłowy identyfikator'); - return Response::redirect('/settings/automation'); - } - - try { - $this->repository->duplicate($id); - Flash::set('settings.automation.success', 'Zadanie zostalo zduplikowane'); - } catch (Throwable) { - Flash::set('settings.automation.error', 'Błąd duplikowania zadania'); - } - - return Response::redirect('/settings/automation'); + return $this->runIdAction( + $request, + fn (int $id) => $this->repository->duplicate($id), + 'Zadanie zostalo zduplikowane', + 'Błąd duplikowania zadania' + ); } public function toggleStatus(Request $request): Response { - $error = $this->validateCsrf($request); - if ($error !== null) { - return $error; + return $this->runIdAction( + $request, + fn (int $id) => $this->repository->toggleActive($id), + 'Status zadania zostal zmieńiony', + 'Błąd zmiany statusu' + ); + } + + private function runIdAction(Request $request, callable $op, string $okMessage, string $errorMessage): Response + { + $csrfError = $this->validateCsrf($request); + if ($csrfError !== null) { + return $csrfError; } $id = (int) $request->input('id', '0'); @@ -219,10 +187,10 @@ final class AutomationController } try { - $this->repository->toggleActive($id); - Flash::set('settings.automation.success', 'Status zadania zostal zmieńiony'); + $op($id); + Flash::set('settings.automation.success', $okMessage); } catch (Throwable) { - Flash::set('settings.automation.error', 'Błąd zmiany statusu'); + Flash::set('settings.automation.error', $errorMessage); } return Response::redirect('/settings/automation'); @@ -230,87 +198,15 @@ final class AutomationController private function renderForm(?array $rule, string $errorMessage = ''): Response { - $html = $this->template->render('automation/form', [ + $vars = array_merge($this->formViewModel->build($rule, $errorMessage), [ 'title' => $rule !== null && isset($rule['id']) ? 'Edytuj zadanie automatyczne' : 'Nowe zadanie automatyczne', 'activeMenu' => 'settings', 'activeSettings' => 'automation', 'user' => $this->auth->user(), 'csrfToken' => Csrf::token(), - 'rule' => $rule, - 'integrations' => $this->repository->listOrderIntegrations(), - 'emailTemplates' => $this->repository->listEmailTemplates(), - 'eventTypes' => self::ALLOWED_EVENTS, - 'conditionTypes' => self::ALLOWED_CONDITION_TYPES, - 'actionTypes' => self::ALLOWED_ACTION_TYPES, - 'recipientOptions' => self::ALLOWED_RECIPIENTS, - 'receiptConfigs' => $this->listActiveReceiptConfigs(), - 'receiptIssueDateModes' => self::ALLOWED_RECEIPT_ISSUE_DATE_MODES, - 'receiptDuplicatePolicies' => self::ALLOWED_RECEIPT_DUPLICATE_POLICIES, - 'shipmentStatusOptions' => $this->buildShipmentStatusOptions(), - 'paymentStatusOptions' => self::PAYMENT_STATUS_OPTIONS, - 'paymentMethodOptions' => self::PAYMENT_METHOD_OPTIONS, - 'orderStatusOptions' => $this->repository->listActiveOrderStatuses(), - 'errorMessage' => $errorMessage !== '' ? $errorMessage : Flash::get('settings.automation.error', ''), - ], 'layouts/app'); + ]); - return Response::html($html); - } - - private function buildRuleFromRequest(Request $request, ?int $id = null): array - { - $raw = $request->input('conditions', []); - $conditions = []; - if (is_array($raw)) { - foreach ($raw as $cond) { - if (!is_array($cond)) { - continue; - } - $type = (string) ($cond['type'] ?? ''); - $value = []; - if ($type === 'integration') { - $value = ['integration_ids' => is_array($cond['integration_ids'] ?? null) ? $cond['integration_ids'] : []]; - } elseif ($type === 'shipment_status') { - $value = ['status_keys' => is_array($cond['shipment_status_keys'] ?? null) ? $cond['shipment_status_keys'] : []]; - } elseif ($type === 'payment_status') { - $value = ['status_keys' => is_array($cond['payment_status_keys'] ?? null) ? $cond['payment_status_keys'] : []]; - } elseif ($type === 'payment_method') { - $value = ['method_keys' => is_array($cond['payment_method_keys'] ?? null) ? $cond['payment_method_keys'] : []]; - } elseif ($type === 'order_status') { - $value = ['order_status_codes' => is_array($cond['order_status_codes'] ?? null) ? $cond['order_status_codes'] : []]; - } elseif ($type === 'days_in_status') { - $value = ['days' => max(1, (int) ($cond['days'] ?? 0))]; - } - $conditions[] = ['condition_type' => $type, 'condition_value' => $value]; - } - } - - $rawActions = $request->input('actions', []); - $actions = []; - if (is_array($rawActions)) { - foreach ($rawActions as $act) { - if (!is_array($act)) { - continue; - } - $type = (string) ($act['type'] ?? ''); - $config = $act; - unset($config['type']); - $actions[] = ['action_type' => $type, 'action_config' => $config]; - } - } - - $rule = [ - 'name' => trim((string) $request->input('name', '')), - 'event_type' => (string) $request->input('event_type', ''), - 'is_active' => $request->input('is_active', null) !== null ? 1 : 0, - 'conditions' => $conditions, - 'actions' => $actions, - ]; - - if ($id !== null) { - $rule['id'] = $id; - } - - return $rule; + return Response::html($this->template->render('automation/form', $vars, 'layouts/app')); } private function validateCsrf(Request $request): ?Response @@ -322,356 +218,4 @@ final class AutomationController return null; } - - private function validateInput(Request $request): ?string - { - $name = trim((string) $request->input('name', '')); - if ($name === '' || mb_strlen($name) > 128) { - return 'Nazwa jest wymagana (maks. 128 znaków)'; - } - - $eventType = (string) $request->input('event_type', ''); - if (!in_array($eventType, self::ALLOWED_EVENTS, true)) { - return 'Nieprawidłowy typ zdarzenia'; - } - - $conditions = $this->extractConditions($request); - if (count($conditions) === 0) { - return 'Wymagany jest co najmniej jeden warunek'; - } - - $actions = $this->extractActions($request); - if (count($actions) === 0) { - return 'Wymagana jest co najmniej jedna akcja'; - } - - return null; - } - - /** - * @return array - */ - private function extractRuleData(Request $request): array - { - return [ - 'name' => trim((string) $request->input('name', '')), - 'event_type' => (string) $request->input('event_type', ''), - 'is_active' => $request->input('is_active', null) !== null ? 1 : 0, - ]; - } - - /** - * @return list}> - */ - private function extractConditions(Request $request): array - { - $raw = $request->input('conditions', []); - if (!is_array($raw)) { - return []; - } - - $result = []; - foreach ($raw as $condition) { - if (!is_array($condition)) { - continue; - } - - $type = (string) ($condition['type'] ?? ''); - if (!in_array($type, self::ALLOWED_CONDITION_TYPES, true)) { - continue; - } - - $value = $this->parseConditionValue($type, $condition); - if ($value === null) { - continue; - } - - $result[] = ['type' => $type, 'value' => $value]; - } - - return $result; - } - - /** - * @param array $condition - * @return array|null - */ - private function parseConditionValue(string $type, array $condition): ?array - { - if ($type === 'integration') { - $ids = $condition['integration_ids'] ?? []; - if (!is_array($ids)) { - $ids = []; - } - $integrationIds = array_values(array_filter( - array_map('intval', $ids), - static fn (int $id): bool => $id > 0 - )); - - return count($integrationIds) > 0 ? ['integration_ids' => $integrationIds] : null; - } - - if ($type === 'shipment_status') { - $keys = $condition['shipment_status_keys'] ?? []; - if (!is_array($keys)) { - $keys = []; - } - - $allowedKeys = DeliveryStatus::getAllStatuses(); - $statusKeys = array_values(array_filter( - array_map(static fn (mixed $key): string => trim((string) $key), $keys), - static fn (string $key): bool => $key !== '' && in_array($key, $allowedKeys, true) - )); - - return count($statusKeys) > 0 ? ['status_keys' => array_values(array_unique($statusKeys))] : null; - } - - if ($type === 'payment_status') { - $keys = $condition['payment_status_keys'] ?? []; - if (!is_array($keys)) { - $keys = []; - } - - $allowedKeys = array_map('strval', array_keys(self::PAYMENT_STATUS_OPTIONS)); - $statusKeys = array_values(array_filter( - array_map(static fn (mixed $key): string => trim((string) $key), $keys), - static fn (string $key): bool => $key !== '' && in_array($key, $allowedKeys, true) - )); - - return count($statusKeys) > 0 ? ['status_keys' => array_values(array_unique($statusKeys))] : null; - } - - if ($type === 'payment_method') { - $keys = $condition['payment_method_keys'] ?? []; - if (!is_array($keys)) { - $keys = []; - } - - $allowedKeys = array_keys(self::PAYMENT_METHOD_OPTIONS); - $methodKeys = array_values(array_filter( - array_map(static fn (mixed $key): string => trim((string) $key), $keys), - static fn (string $key): bool => $key !== '' && in_array($key, $allowedKeys, true) - )); - - return count($methodKeys) > 0 ? ['method_keys' => array_values(array_unique($methodKeys))] : null; - } - - if ($type === 'order_status') { - $codes = $condition['order_status_codes'] ?? []; - if (!is_array($codes)) { - $codes = []; - } - - $availableCodes = array_map( - static fn (array $row): string => strtolower(trim((string) ($row['code'] ?? ''))), - $this->repository->listActiveOrderStatuses() - ); - - $statusCodes = array_values(array_filter( - array_map(static fn (mixed $code): string => strtolower(trim((string) $code)), $codes), - static fn (string $code): bool => $code !== '' && in_array($code, $availableCodes, true) - )); - - return count($statusCodes) > 0 ? ['order_status_codes' => array_values(array_unique($statusCodes))] : null; - } - - if ($type === 'days_in_status') { - $days = (int) ($condition['days'] ?? 0); - - return $days >= 1 ? ['days' => $days] : null; - } - - return null; - } - - /** - * @return list}> - */ - private function extractActions(Request $request): array - { - $raw = $request->input('actions', []); - if (!is_array($raw)) { - return []; - } - - $result = []; - foreach ($raw as $action) { - if (!is_array($action)) { - continue; - } - - $type = (string) ($action['type'] ?? ''); - if (!in_array($type, self::ALLOWED_ACTION_TYPES, true)) { - continue; - } - - $config = $this->parseActionConfig($type, $action); - if ($config === null) { - continue; - } - - $result[] = ['type' => $type, 'config' => $config]; - } - - return $result; - } - - /** - * @param array $action - * @return array|null - */ - private function parseActionConfig(string $type, array $action): ?array - { - if ($type === 'send_email') { - $templateId = (int) ($action['template_id'] ?? 0); - $recipient = (string) ($action['recipient'] ?? ''); - $sendOncePerOrder = isset($action['send_once_per_order']) && (int) $action['send_once_per_order'] === 1 ? 1 : 0; - - if ($templateId <= 0 || !in_array($recipient, self::ALLOWED_RECIPIENTS, true)) { - return null; - } - - return [ - 'template_id' => $templateId, - 'recipient' => $recipient, - 'send_once_per_order' => $sendOncePerOrder, - ]; - } - - if ($type === 'issue_receipt') { - $configId = (int) ($action['receipt_config_id'] ?? 0); - $issueDateMode = (string) ($action['issue_date_mode'] ?? ''); - $duplicatePolicy = (string) ($action['duplicate_policy'] ?? ''); - - if ( - $configId <= 0 - || !in_array($issueDateMode, self::ALLOWED_RECEIPT_ISSUE_DATE_MODES, true) - || !in_array($duplicatePolicy, self::ALLOWED_RECEIPT_DUPLICATE_POLICIES, true) - ) { - return null; - } - - return [ - 'receipt_config_id' => $configId, - 'issue_date_mode' => $issueDateMode, - 'duplicate_policy' => $duplicatePolicy, - ]; - } - - if ($type === 'update_shipment_status') { - $statusKey = trim((string) ($action['shipment_status_key'] ?? '')); - if ($statusKey === '' || !in_array($statusKey, DeliveryStatus::getAllStatuses(), true)) { - return null; - } - - return ['status_key' => $statusKey]; - } - - if ($type === 'update_order_status') { - $statusCode = trim((string) ($action['order_status_code'] ?? '')); - if ($statusCode === '') { - return null; - } - - $availableCodes = array_map( - static fn (array $row): string => trim((string) ($row['code'] ?? '')), - $this->repository->listActiveOrderStatuses() - ); - - if (!in_array($statusCode, $availableCodes, true)) { - return null; - } - - return ['status_code' => $statusCode]; - } - - return null; - } - - /** - * @return array - */ - private function buildShipmentStatusOptions(): array - { - $options = []; - foreach (DeliveryStatus::getAllOptions() as $key => $label) { - $options[(string) $key] = ['label' => (string) $label]; - } - - return $options; - } - - /** - * @return list - */ - private function listActiveReceiptConfigs(): array - { - $all = $this->receiptConfigs->listAll(); - $result = []; - foreach ($all as $config) { - if ((int) ($config['is_active'] ?? 0) !== 1) { - continue; - } - $configId = (int) ($config['id'] ?? 0); - if ($configId <= 0) { - continue; - } - $result[] = [ - 'id' => $configId, - 'name' => (string) ($config['name'] ?? ''), - 'number_format' => (string) ($config['number_format'] ?? ''), - ]; - } - - return $result; - } - - /** - * @return array{event_type:string,execution_status:string,rule_id:int,order_id:int,date_from:string,date_to:string} - */ - private function extractHistoryFilters(Request $request): array - { - return [ - 'event_type' => trim((string) $request->input('history_event_type', '')), - 'execution_status' => trim((string) $request->input('history_status', '')), - 'rule_id' => max(0, (int) $request->input('history_rule_id', 0)), - 'order_id' => max(0, (int) $request->input('history_order_id', 0)), - 'date_from' => trim((string) $request->input('history_date_from', '')), - 'date_to' => trim((string) $request->input('history_date_to', '')), - ]; - } - - /** - * @param array{event_type:string,execution_status:string,rule_id:int,order_id:int,date_from:string,date_to:string} $historyFilters - */ - private function resolveActiveTab(Request $request, array $historyFilters): string - { - $activeTab = trim((string) $request->input('tab', 'settings')); - if ($activeTab === 'history') { - return 'history'; - } - - if ((int) $request->input('history_page', 0) > 1) { - return 'history'; - } - - if ($this->hasHistoryFilters($historyFilters)) { - return 'history'; - } - - return 'settings'; - } - - /** - * @param array{event_type:string,execution_status:string,rule_id:int,order_id:int,date_from:string,date_to:string} $historyFilters - */ - private function hasHistoryFilters(array $historyFilters): bool - { - return $historyFilters['event_type'] !== '' - || $historyFilters['execution_status'] !== '' - || $historyFilters['rule_id'] > 0 - || $historyFilters['order_id'] > 0 - || $historyFilters['date_from'] !== '' - || $historyFilters['date_to'] !== ''; - } } diff --git a/src/Modules/Automation/AutomationFormViewModel.php b/src/Modules/Automation/AutomationFormViewModel.php new file mode 100644 index 0000000..3dec8b5 --- /dev/null +++ b/src/Modules/Automation/AutomationFormViewModel.php @@ -0,0 +1,80 @@ + + */ + public function build(?array $rule, string $errorMessage): array + { + return [ + 'rule' => $rule, + 'integrations' => $this->repository->listOrderIntegrations(), + 'emailTemplates' => $this->repository->listEmailTemplates(), + 'eventTypes' => $this->parser->allowedEvents(), + 'conditionTypes' => $this->parser->allowedConditionTypes(), + 'actionTypes' => $this->parser->allowedActionTypes(), + 'recipientOptions' => $this->parser->allowedRecipients(), + 'receiptConfigs' => $this->listActiveReceiptConfigs(), + 'receiptIssueDateModes' => $this->parser->allowedReceiptIssueDateModes(), + 'receiptDuplicatePolicies' => $this->parser->allowedReceiptDuplicatePolicies(), + 'shipmentStatusOptions' => $this->buildShipmentStatusOptions(), + 'paymentStatusOptions' => $this->parser->paymentStatusOptions(), + 'paymentMethodOptions' => $this->parser->paymentMethodOptions(), + 'orderStatusOptions' => $this->repository->listActiveOrderStatuses(), + 'errorMessage' => $errorMessage !== '' ? $errorMessage : Flash::get('settings.automation.error', ''), + ]; + } + + /** + * @return array + */ + private function buildShipmentStatusOptions(): array + { + $options = []; + foreach (DeliveryStatus::getAllOptions() as $key => $label) { + $options[(string) $key] = ['label' => (string) $label]; + } + + return $options; + } + + /** + * @return list + */ + private function listActiveReceiptConfigs(): array + { + $all = $this->receiptConfigs->listAll(); + $result = []; + foreach ($all as $config) { + if ((int) ($config['is_active'] ?? 0) !== 1) { + continue; + } + $configId = (int) ($config['id'] ?? 0); + if ($configId <= 0) { + continue; + } + $result[] = [ + 'id' => $configId, + 'name' => (string) ($config['name'] ?? ''), + 'number_format' => (string) ($config['number_format'] ?? ''), + ]; + } + + return $result; + } +} diff --git a/src/Modules/Automation/AutomationHistoryFilters.php b/src/Modules/Automation/AutomationHistoryFilters.php new file mode 100644 index 0000000..fec04b4 --- /dev/null +++ b/src/Modules/Automation/AutomationHistoryFilters.php @@ -0,0 +1,58 @@ + trim((string) $request->input('history_event_type', '')), + 'execution_status' => trim((string) $request->input('history_status', '')), + 'rule_id' => max(0, (int) $request->input('history_rule_id', 0)), + 'order_id' => max(0, (int) $request->input('history_order_id', 0)), + 'date_from' => trim((string) $request->input('history_date_from', '')), + 'date_to' => trim((string) $request->input('history_date_to', '')), + ]; + } + + /** + * @param array{event_type:string,execution_status:string,rule_id:int,order_id:int,date_from:string,date_to:string} $filters + */ + public function resolveActiveTab(Request $request, array $filters): string + { + $activeTab = trim((string) $request->input('tab', 'settings')); + if ($activeTab === 'history') { + return 'history'; + } + + if ((int) $request->input('history_page', 0) > 1) { + return 'history'; + } + + if ($this->hasAny($filters)) { + return 'history'; + } + + return 'settings'; + } + + /** + * @param array{event_type:string,execution_status:string,rule_id:int,order_id:int,date_from:string,date_to:string} $filters + */ + public function hasAny(array $filters): bool + { + return $filters['event_type'] !== '' + || $filters['execution_status'] !== '' + || $filters['rule_id'] > 0 + || $filters['order_id'] > 0 + || $filters['date_from'] !== '' + || $filters['date_to'] !== ''; + } +} diff --git a/src/Modules/Automation/AutomationModule.php b/src/Modules/Automation/AutomationModule.php index 605cca2..d1145e6 100644 --- a/src/Modules/Automation/AutomationModule.php +++ b/src/Modules/Automation/AutomationModule.php @@ -17,13 +17,25 @@ final class AutomationModule implements ModuleProvider $services->set('automation.execution_log_repo', static fn () => new AutomationExecutionLogRepository($app->db())); $services->set('automation.email_once_repo', static fn () => new AutomationEmailOnceRepository($app->db())); + $services->set('automation.request_parser', static fn (ServiceRegistry $s) => new AutomationRequestParser( + $s->get('automation.repo') + )); + $services->set('automation.form_view_model', static fn (ServiceRegistry $s) => new AutomationFormViewModel( + $s->get('automation.repo'), + $s->get('accounting.receipts.config_repo'), + $s->get('automation.request_parser') + )); + $services->set('automation.history_filters', static fn () => new AutomationHistoryFilters()); + $services->set('automation.controller', static fn (ServiceRegistry $s) => new AutomationController( $app->template(), $app->translator(), $app->auth(), $s->get('automation.repo'), $s->get('automation.execution_log_repo'), - $s->get('accounting.receipts.config_repo') + $s->get('automation.request_parser'), + $s->get('automation.form_view_model'), + $s->get('automation.history_filters') )); $services->set('automation.service', static fn (ServiceRegistry $s) => new AutomationService( diff --git a/src/Modules/Automation/AutomationRequestParser.php b/src/Modules/Automation/AutomationRequestParser.php new file mode 100644 index 0000000..0946b61 --- /dev/null +++ b/src/Modules/Automation/AutomationRequestParser.php @@ -0,0 +1,422 @@ + 'Nieopłacone', + '1' => 'Częściowo opłacone', + '2' => 'Opłacone', + ]; + private const PAYMENT_METHOD_OPTIONS = [ + 'cod' => 'Płatność przy odbiorze (COD)', + 'transfer' => 'Przelew bankowy', + 'online' => 'Karta / płatność online', + 'other' => 'Inna', + ]; + + public function __construct( + private readonly AutomationRepository $repository + ) { + } + + /** + * @return list + */ + public function allowedEvents(): array + { + return self::ALLOWED_EVENTS; + } + + /** + * @return list + */ + public function allowedConditionTypes(): array + { + return self::ALLOWED_CONDITION_TYPES; + } + + /** + * @return list + */ + public function allowedActionTypes(): array + { + return self::ALLOWED_ACTION_TYPES; + } + + /** + * @return list + */ + public function allowedRecipients(): array + { + return self::ALLOWED_RECIPIENTS; + } + + /** + * @return list + */ + public function allowedReceiptIssueDateModes(): array + { + return self::ALLOWED_RECEIPT_ISSUE_DATE_MODES; + } + + /** + * @return list + */ + public function allowedReceiptDuplicatePolicies(): array + { + return self::ALLOWED_RECEIPT_DUPLICATE_POLICIES; + } + + /** + * @return array + */ + public function paymentStatusOptions(): array + { + return self::PAYMENT_STATUS_OPTIONS; + } + + /** + * @return array + */ + public function paymentMethodOptions(): array + { + return self::PAYMENT_METHOD_OPTIONS; + } + + public function validate(Request $request): ?string + { + $name = trim((string) $request->input('name', '')); + if ($name === '' || mb_strlen($name) > 128) { + return 'Nazwa jest wymagana (maks. 128 znaków)'; + } + + $eventType = (string) $request->input('event_type', ''); + if (!in_array($eventType, self::ALLOWED_EVENTS, true)) { + return 'Nieprawidłowy typ zdarzenia'; + } + + if (count($this->extractConditions($request)) === 0) { + return 'Wymagany jest co najmniej jeden warunek'; + } + + if (count($this->extractActions($request)) === 0) { + return 'Wymagana jest co najmniej jedna akcja'; + } + + return null; + } + + /** + * @return array + */ + public function extractRuleData(Request $request): array + { + return [ + 'name' => trim((string) $request->input('name', '')), + 'event_type' => (string) $request->input('event_type', ''), + 'is_active' => $request->input('is_active', null) !== null ? 1 : 0, + ]; + } + + /** + * @return list}> + */ + public function extractConditions(Request $request): array + { + $raw = $request->input('conditions', []); + if (!is_array($raw)) { + return []; + } + + $result = []; + foreach ($raw as $condition) { + if (!is_array($condition)) { + continue; + } + + $type = (string) ($condition['type'] ?? ''); + if (!in_array($type, self::ALLOWED_CONDITION_TYPES, true)) { + continue; + } + + $value = $this->parseConditionValue($type, $condition); + if ($value === null) { + continue; + } + + $result[] = ['type' => $type, 'value' => $value]; + } + + return $result; + } + + /** + * @return list}> + */ + public function extractActions(Request $request): array + { + $raw = $request->input('actions', []); + if (!is_array($raw)) { + return []; + } + + $result = []; + foreach ($raw as $action) { + if (!is_array($action)) { + continue; + } + + $type = (string) ($action['type'] ?? ''); + if (!in_array($type, self::ALLOWED_ACTION_TYPES, true)) { + continue; + } + + $config = $this->parseActionConfig($type, $action); + if ($config === null) { + continue; + } + + $result[] = ['type' => $type, 'config' => $config]; + } + + return $result; + } + + /** + * Re-buduje rekord regulki z surowego inputu (uzywane do ponownego renderu formularza po bledzie walidacji). + * + * @return array + */ + public function buildRule(Request $request, ?int $id = null): array + { + $raw = $request->input('conditions', []); + $conditions = []; + if (is_array($raw)) { + foreach ($raw as $cond) { + if (!is_array($cond)) { + continue; + } + $type = (string) ($cond['type'] ?? ''); + $value = []; + if ($type === 'integration') { + $value = ['integration_ids' => is_array($cond['integration_ids'] ?? null) ? $cond['integration_ids'] : []]; + } elseif ($type === 'shipment_status') { + $value = ['status_keys' => is_array($cond['shipment_status_keys'] ?? null) ? $cond['shipment_status_keys'] : []]; + } elseif ($type === 'payment_status') { + $value = ['status_keys' => is_array($cond['payment_status_keys'] ?? null) ? $cond['payment_status_keys'] : []]; + } elseif ($type === 'payment_method') { + $value = ['method_keys' => is_array($cond['payment_method_keys'] ?? null) ? $cond['payment_method_keys'] : []]; + } elseif ($type === 'order_status') { + $value = ['order_status_codes' => is_array($cond['order_status_codes'] ?? null) ? $cond['order_status_codes'] : []]; + } elseif ($type === 'days_in_status') { + $value = ['days' => max(1, (int) ($cond['days'] ?? 0))]; + } + $conditions[] = ['condition_type' => $type, 'condition_value' => $value]; + } + } + + $rawActions = $request->input('actions', []); + $actions = []; + if (is_array($rawActions)) { + foreach ($rawActions as $act) { + if (!is_array($act)) { + continue; + } + $type = (string) ($act['type'] ?? ''); + $config = $act; + unset($config['type']); + $actions[] = ['action_type' => $type, 'action_config' => $config]; + } + } + + $rule = [ + 'name' => trim((string) $request->input('name', '')), + 'event_type' => (string) $request->input('event_type', ''), + 'is_active' => $request->input('is_active', null) !== null ? 1 : 0, + 'conditions' => $conditions, + 'actions' => $actions, + ]; + + if ($id !== null) { + $rule['id'] = $id; + } + + return $rule; + } + + /** + * @param array $condition + * @return array|null + */ + private function parseConditionValue(string $type, array $condition): ?array + { + if ($type === 'integration') { + $ids = $condition['integration_ids'] ?? []; + if (!is_array($ids)) { + $ids = []; + } + $integrationIds = array_values(array_filter( + array_map('intval', $ids), + static fn (int $id): bool => $id > 0 + )); + + return count($integrationIds) > 0 ? ['integration_ids' => $integrationIds] : null; + } + + if ($type === 'shipment_status') { + $keys = $condition['shipment_status_keys'] ?? []; + if (!is_array($keys)) { + $keys = []; + } + + $allowedKeys = DeliveryStatus::getAllStatuses(); + $statusKeys = array_values(array_filter( + array_map(static fn (mixed $key): string => trim((string) $key), $keys), + static fn (string $key): bool => $key !== '' && in_array($key, $allowedKeys, true) + )); + + return count($statusKeys) > 0 ? ['status_keys' => array_values(array_unique($statusKeys))] : null; + } + + if ($type === 'payment_status') { + $keys = $condition['payment_status_keys'] ?? []; + if (!is_array($keys)) { + $keys = []; + } + + $allowedKeys = array_map('strval', array_keys(self::PAYMENT_STATUS_OPTIONS)); + $statusKeys = array_values(array_filter( + array_map(static fn (mixed $key): string => trim((string) $key), $keys), + static fn (string $key): bool => $key !== '' && in_array($key, $allowedKeys, true) + )); + + return count($statusKeys) > 0 ? ['status_keys' => array_values(array_unique($statusKeys))] : null; + } + + if ($type === 'payment_method') { + $keys = $condition['payment_method_keys'] ?? []; + if (!is_array($keys)) { + $keys = []; + } + + $allowedKeys = array_keys(self::PAYMENT_METHOD_OPTIONS); + $methodKeys = array_values(array_filter( + array_map(static fn (mixed $key): string => trim((string) $key), $keys), + static fn (string $key): bool => $key !== '' && in_array($key, $allowedKeys, true) + )); + + return count($methodKeys) > 0 ? ['method_keys' => array_values(array_unique($methodKeys))] : null; + } + + if ($type === 'order_status') { + $codes = $condition['order_status_codes'] ?? []; + if (!is_array($codes)) { + $codes = []; + } + + $availableCodes = array_map( + static fn (array $row): string => strtolower(trim((string) ($row['code'] ?? ''))), + $this->repository->listActiveOrderStatuses() + ); + + $statusCodes = array_values(array_filter( + array_map(static fn (mixed $code): string => strtolower(trim((string) $code)), $codes), + static fn (string $code): bool => $code !== '' && in_array($code, $availableCodes, true) + )); + + return count($statusCodes) > 0 ? ['order_status_codes' => array_values(array_unique($statusCodes))] : null; + } + + if ($type === 'days_in_status') { + $days = (int) ($condition['days'] ?? 0); + + return $days >= 1 ? ['days' => $days] : null; + } + + return null; + } + + /** + * @param array $action + * @return array|null + */ + private function parseActionConfig(string $type, array $action): ?array + { + if ($type === 'send_email') { + $templateId = (int) ($action['template_id'] ?? 0); + $recipient = (string) ($action['recipient'] ?? ''); + $sendOncePerOrder = isset($action['send_once_per_order']) && (int) $action['send_once_per_order'] === 1 ? 1 : 0; + + if ($templateId <= 0 || !in_array($recipient, self::ALLOWED_RECIPIENTS, true)) { + return null; + } + + return [ + 'template_id' => $templateId, + 'recipient' => $recipient, + 'send_once_per_order' => $sendOncePerOrder, + ]; + } + + if ($type === 'issue_receipt') { + $configId = (int) ($action['receipt_config_id'] ?? 0); + $issueDateMode = (string) ($action['issue_date_mode'] ?? ''); + $duplicatePolicy = (string) ($action['duplicate_policy'] ?? ''); + + if ( + $configId <= 0 + || !in_array($issueDateMode, self::ALLOWED_RECEIPT_ISSUE_DATE_MODES, true) + || !in_array($duplicatePolicy, self::ALLOWED_RECEIPT_DUPLICATE_POLICIES, true) + ) { + return null; + } + + return [ + 'receipt_config_id' => $configId, + 'issue_date_mode' => $issueDateMode, + 'duplicate_policy' => $duplicatePolicy, + ]; + } + + if ($type === 'update_shipment_status') { + $statusKey = trim((string) ($action['shipment_status_key'] ?? '')); + if ($statusKey === '' || !in_array($statusKey, DeliveryStatus::getAllStatuses(), true)) { + return null; + } + + return ['status_key' => $statusKey]; + } + + if ($type === 'update_order_status') { + $statusCode = trim((string) ($action['order_status_code'] ?? '')); + if ($statusCode === '') { + return null; + } + + $availableCodes = array_map( + static fn (array $row): string => trim((string) ($row['code'] ?? '')), + $this->repository->listActiveOrderStatuses() + ); + + if (!in_array($statusCode, $availableCodes, true)) { + return null; + } + + return ['status_code' => $statusCode]; + } + + return null; + } +}