refactor(automation): rozbij AutomationController na slim + 3 wspolpracownikow

AutomationController.php 677 -> 221 lin. (67% redukcji). Wydzielono:
- AutomationRequestParser (422) - stale ALLOWED_*/PAYMENT_*, walidacja,
  ekstrakcja conditions/actions/ruleData, buildRule.
- AutomationFormViewModel (80) - przygotowanie zmiennych template'a
  automation/form (przejal ReceiptConfigRepository).
- AutomationHistoryFilters (58) - filtry historii + aktywna zakladka.

Szablon destroy/duplicate/toggleStatus zlozony do runIdAction().
Zero zmian kontraktu HTTP/widokow/DB.

Plan: .paul/plans/20260520-1400-refactor-automation-controller/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 10:58:52 +02:00
parent 2c1ad4a262
commit 67e525529e
11 changed files with 1024 additions and 513 deletions

View File

@@ -4,13 +4,18 @@
**Ostatnia aktualizacja:** 2026-05-20 **Ostatnia aktualizacja:** 2026-05-20
## Aktywna praca ## 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 PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Loop complete — gotowe na nastepny PLAN] ✓ ✓ ✓ [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`. 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 ## Poprzednia praca

View File

@@ -72,7 +72,7 @@ Korzysci wzgledem poprzedniego monolitycznego `routes/web.php` (859 lin.):
| Email | `src/Modules/Email/` | SMTP, wysylka maili | | Email | `src/Modules/Email/` | SMTP, wysylka maili |
| Sms | `src/Modules/Sms/` | wysylka SMS, webhook SmsPlanet, konwersacje | | Sms | `src/Modules/Sms/` | wysylka SMS, webhook SmsPlanet, konwersacje |
| Notifications | `src/Modules/Notifications/` | powiadomienia w aplikacji + API | | 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 | | Settings | `src/Modules/Settings/` | firmy, integracje, mapowania statusow, szablony |
| Cron | `src/Modules/Cron/` | handlery + scheduler | | Cron | `src/Modules/Cron/` | handlery + scheduler |
| Info | `src/Modules/Info/` | strona informacyjna | | Info | `src/Modules/Info/` | strona informacyjna |

View File

@@ -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/Automation/AutomationService.php` | 818 | silnik regul — kandydat na pipeline-strategy. |
| `src/Modules/Shipments/PolkurierShipmentService.php` | 776 | | | `src/Modules/Shipments/PolkurierShipmentService.php` | 776 | |
| `src/Modules/Accounting/InvoiceService.php` | 762 | | | `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/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/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`. | | ~~`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`. |

View File

@@ -2,6 +2,29 @@
Chronologiczny log zmian technicznych (co i dlaczego). Najnowsze na gorze. 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 ## 2026-05-20 — Strona logowania: usuniecie subtitle, inline "Zapamietaj mnie (30 dni)", auto-login z cookie
### Co ### Co

View File

@@ -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
---
<objective>
## 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.
</objective>
<context>
## 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
</context>
<clarifications>
- 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.
</clarifications>
<impact_scan>
## 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.
</impact_scan>
<skills>
Brak SPECIAL-FLOWS.md — sekcja niewymagana.
</skills>
<acceptance_criteria>
## 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
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Wydzielenie AutomationRequestParser</name>
<files>
src/Modules/Automation/AutomationRequestParser.php (nowy),
src/Modules/Automation/AutomationController.php
</files>
<action>
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.
</action>
<verify>`php -l src/Modules/Automation/AutomationRequestParser.php` zwraca "No syntax errors"</verify>
<done>AC-2 (czesc parsera) + AC-1 (czesc metod usuniete z controllera)</done>
</task>
<task type="auto">
<name>Task 2: Wydzielenie AutomationFormViewModel</name>
<files>
src/Modules/Automation/AutomationFormViewModel.php (nowy),
src/Modules/Automation/AutomationController.php
</files>
<action>
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`.
</action>
<verify>`php -l src/Modules/Automation/AutomationFormViewModel.php` zwraca "No syntax errors"</verify>
<done>AC-3</done>
</task>
<task type="auto">
<name>Task 3: Wydzielenie AutomationHistoryFilters</name>
<files>
src/Modules/Automation/AutomationHistoryFilters.php (nowy),
src/Modules/Automation/AutomationController.php
</files>
<action>
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`.
</action>
<verify>`php -l src/Modules/Automation/AutomationHistoryFilters.php` zwraca "No syntax errors"</verify>
<done>AC-4</done>
</task>
<task type="auto">
<name>Task 4: Aktualizacja AutomationModule + slim AutomationController</name>
<files>
src/Modules/Automation/AutomationModule.php,
src/Modules/Automation/AutomationController.php
</files>
<action>
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.
</action>
<verify>
`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.
</verify>
<done>AC-1, AC-6</done>
</task>
<task type="auto">
<name>Task 5: Walidacja testami + aktualizacja docs</name>
<files>
.paul/codebase/architecture.md,
.paul/codebase/quality_risks.md,
.paul/codebase/tech_changelog.md
</files>
<action>
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).
</action>
<verify>
Manualny przeglad widokow + `vendor/bin/phpunit --filter Automation` zielony.
</verify>
<done>AC-5 (zero regresji)</done>
</task>
</tasks>
<boundaries>
## 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`.
</boundaries>
<verification>
- [ ] `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`.
</verification>
<success_criteria>
- [ ] 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.
</success_criteria>
<output>
SUMMARY.md path: `.paul/plans/20260520-1400-refactor-automation-controller/SUMMARY.md`
</output>

View File

@@ -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.

View File

@@ -10,52 +10,36 @@ use App\Core\Security\Csrf;
use App\Core\Support\Flash; use App\Core\Support\Flash;
use App\Core\View\Template; use App\Core\View\Template;
use App\Modules\Auth\AuthService; use App\Modules\Auth\AuthService;
use App\Modules\Settings\ReceiptConfigRepository;
use App\Modules\Shipments\DeliveryStatus;
use Throwable; use Throwable;
final class AutomationController final class AutomationController
{ {
private const HISTORY_PER_PAGE = 25; 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( public function __construct(
private readonly Template $template, private readonly Template $template,
private readonly Translator $translator, private readonly Translator $translator,
private readonly AuthService $auth, private readonly AuthService $auth,
private readonly AutomationRepository $repository, private readonly AutomationRepository $repository,
private readonly AutomationExecutionLogRepository $executionLogs, 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 public function index(Request $request): Response
{ {
$rules = $this->repository->findAll(); $rules = $this->repository->findAll();
$historyFilters = $this->extractHistoryFilters($request); $filters = $this->historyFilters->extract($request);
$historyPage = max(1, (int) $request->input('history_page', 1)); $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)); $historyTotalPages = max(1, (int) ceil($historyTotal / self::HISTORY_PER_PAGE));
if ($historyPage > $historyTotalPages) { if ($historyPage > $historyTotalPages) {
$historyPage = $historyTotalPages; $historyPage = $historyTotalPages;
} }
$historyEntries = $this->executionLogs->paginate($historyFilters, $historyPage, self::HISTORY_PER_PAGE); $historyEntries = $this->executionLogs->paginate($filters, $historyPage, self::HISTORY_PER_PAGE);
$activeTab = $this->resolveActiveTab($request, $historyFilters); $activeTab = $this->historyFilters->resolveActiveTab($request, $filters);
$html = $this->template->render('automation/index', [ $html = $this->template->render('automation/index', [
'title' => 'Zadania automatyczne', 'title' => 'Zadania automatyczne',
@@ -66,8 +50,8 @@ final class AutomationController
'rules' => $rules, 'rules' => $rules,
'activeTab' => $activeTab, 'activeTab' => $activeTab,
'historyEntries' => $historyEntries, 'historyEntries' => $historyEntries,
'historyFilters' => $historyFilters, 'historyFilters' => $filters,
'historyEventTypes' => array_values(array_unique(array_merge(self::ALLOWED_EVENTS, $this->executionLogs->listEventTypes()))), 'historyEventTypes' => array_values(array_unique(array_merge($this->parser->allowedEvents(), $this->executionLogs->listEventTypes()))),
'historyRuleOptions' => $this->repository->listRuleOptions(), 'historyRuleOptions' => $this->repository->listRuleOptions(),
'historyPagination' => [ 'historyPagination' => [
'page' => $historyPage, 'page' => $historyPage,
@@ -107,20 +91,20 @@ final class AutomationController
return $error; return $error;
} }
$validationError = $this->validateInput($request); $validationError = $this->parser->validate($request);
if ($validationError !== null) { if ($validationError !== null) {
return $this->renderForm($this->buildRuleFromRequest($request), $validationError); return $this->renderForm($this->parser->buildRule($request), $validationError);
} }
try { try {
$this->repository->create( $this->repository->create(
$this->extractRuleData($request), $this->parser->extractRuleData($request),
$this->extractConditions($request), $this->parser->extractConditions($request),
$this->extractActions($request) $this->parser->extractActions($request)
); );
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo utworzone'); Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo utworzone');
} catch (Throwable) { } 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'); return Response::redirect('/settings/automation');
@@ -139,21 +123,21 @@ final class AutomationController
return Response::redirect('/settings/automation'); return Response::redirect('/settings/automation');
} }
$validationError = $this->validateInput($request); $validationError = $this->parser->validate($request);
if ($validationError !== null) { if ($validationError !== null) {
return $this->renderForm($this->buildRuleFromRequest($request, $id), $validationError); return $this->renderForm($this->parser->buildRule($request, $id), $validationError);
} }
try { try {
$this->repository->update( $this->repository->update(
$id, $id,
$this->extractRuleData($request), $this->parser->extractRuleData($request),
$this->extractConditions($request), $this->parser->extractConditions($request),
$this->extractActions($request) $this->parser->extractActions($request)
); );
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo zaktualizowane'); Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo zaktualizowane');
} catch (Throwable) { } 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'); return Response::redirect('/settings/automation');
@@ -161,55 +145,39 @@ final class AutomationController
public function destroy(Request $request): Response public function destroy(Request $request): Response
{ {
$error = $this->validateCsrf($request); return $this->runIdAction(
if ($error !== null) { $request,
return $error; fn (int $id) => $this->repository->delete($id),
} 'Zadanie automatyczne zostalo usuńięte',
'Błąd usuwania zadania automatycznego'
$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');
} }
public function duplicate(Request $request): Response public function duplicate(Request $request): Response
{ {
$error = $this->validateCsrf($request); return $this->runIdAction(
if ($error !== null) { $request,
return $error; fn (int $id) => $this->repository->duplicate($id),
} 'Zadanie zostalo zduplikowane',
'Błąd duplikowania zadania'
$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');
} }
public function toggleStatus(Request $request): Response public function toggleStatus(Request $request): Response
{ {
$error = $this->validateCsrf($request); return $this->runIdAction(
if ($error !== null) { $request,
return $error; 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'); $id = (int) $request->input('id', '0');
@@ -219,10 +187,10 @@ final class AutomationController
} }
try { try {
$this->repository->toggleActive($id); $op($id);
Flash::set('settings.automation.success', 'Status zadania zostal zmieńiony'); Flash::set('settings.automation.success', $okMessage);
} catch (Throwable) { } catch (Throwable) {
Flash::set('settings.automation.error', 'Błąd zmiany statusu'); Flash::set('settings.automation.error', $errorMessage);
} }
return Response::redirect('/settings/automation'); return Response::redirect('/settings/automation');
@@ -230,87 +198,15 @@ final class AutomationController
private function renderForm(?array $rule, string $errorMessage = ''): Response 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', 'title' => $rule !== null && isset($rule['id']) ? 'Edytuj zadanie automatyczne' : 'Nowe zadanie automatyczne',
'activeMenu' => 'settings', 'activeMenu' => 'settings',
'activeSettings' => 'automation', 'activeSettings' => 'automation',
'user' => $this->auth->user(), 'user' => $this->auth->user(),
'csrfToken' => Csrf::token(), '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); return Response::html($this->template->render('automation/form', $vars, 'layouts/app'));
}
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;
} }
private function validateCsrf(Request $request): ?Response private function validateCsrf(Request $request): ?Response
@@ -322,356 +218,4 @@ final class AutomationController
return null; 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<string, mixed>
*/
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<array{type: string, value: array<string, mixed>}>
*/
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<string, mixed> $condition
* @return array<string, mixed>|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<array{type: string, config: array<string, mixed>}>
*/
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<string, mixed> $action
* @return array<string, mixed>|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<string, array{label:string}>
*/
private function buildShipmentStatusOptions(): array
{
$options = [];
foreach (DeliveryStatus::getAllOptions() as $key => $label) {
$options[(string) $key] = ['label' => (string) $label];
}
return $options;
}
/**
* @return list<array{id:int,name:string,number_format:string}>
*/
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'] !== '';
}
} }

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Modules\Automation;
use App\Core\Support\Flash;
use App\Modules\Settings\ReceiptConfigRepository;
use App\Modules\Shipments\DeliveryStatus;
final class AutomationFormViewModel
{
public function __construct(
private readonly AutomationRepository $repository,
private readonly ReceiptConfigRepository $receiptConfigs,
private readonly AutomationRequestParser $parser
) {
}
/**
* @return array<string, mixed>
*/
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<string, array{label:string}>
*/
private function buildShipmentStatusOptions(): array
{
$options = [];
foreach (DeliveryStatus::getAllOptions() as $key => $label) {
$options[(string) $key] = ['label' => (string) $label];
}
return $options;
}
/**
* @return list<array{id:int,name:string,number_format:string}>
*/
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;
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Modules\Automation;
use App\Core\Http\Request;
final class AutomationHistoryFilters
{
/**
* @return array{event_type:string,execution_status:string,rule_id:int,order_id:int,date_from:string,date_to:string}
*/
public function extract(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} $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'] !== '';
}
}

View File

@@ -17,13 +17,25 @@ final class AutomationModule implements ModuleProvider
$services->set('automation.execution_log_repo', static fn () => new AutomationExecutionLogRepository($app->db())); $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.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( $services->set('automation.controller', static fn (ServiceRegistry $s) => new AutomationController(
$app->template(), $app->template(),
$app->translator(), $app->translator(),
$app->auth(), $app->auth(),
$s->get('automation.repo'), $s->get('automation.repo'),
$s->get('automation.execution_log_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( $services->set('automation.service', static fn (ServiceRegistry $s) => new AutomationService(

View File

@@ -0,0 +1,422 @@
<?php
declare(strict_types=1);
namespace App\Modules\Automation;
use App\Core\Http\Request;
use App\Modules\Shipments\DeliveryStatus;
final class AutomationRequestParser
{
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 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_STATUS_OPTIONS = [
'0' => '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<string>
*/
public function allowedEvents(): array
{
return self::ALLOWED_EVENTS;
}
/**
* @return list<string>
*/
public function allowedConditionTypes(): array
{
return self::ALLOWED_CONDITION_TYPES;
}
/**
* @return list<string>
*/
public function allowedActionTypes(): array
{
return self::ALLOWED_ACTION_TYPES;
}
/**
* @return list<string>
*/
public function allowedRecipients(): array
{
return self::ALLOWED_RECIPIENTS;
}
/**
* @return list<string>
*/
public function allowedReceiptIssueDateModes(): array
{
return self::ALLOWED_RECEIPT_ISSUE_DATE_MODES;
}
/**
* @return list<string>
*/
public function allowedReceiptDuplicatePolicies(): array
{
return self::ALLOWED_RECEIPT_DUPLICATE_POLICIES;
}
/**
* @return array<string, string>
*/
public function paymentStatusOptions(): array
{
return self::PAYMENT_STATUS_OPTIONS;
}
/**
* @return array<string, string>
*/
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<string, mixed>
*/
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<array{type: string, value: array<string, mixed>}>
*/
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<array{type: string, config: array<string, mixed>}>
*/
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<string, mixed>
*/
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<string, mixed> $condition
* @return array<string, mixed>|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<string, mixed> $action
* @return array<string, mixed>|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;
}
}