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.
+
+
+
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;
+ }
+}