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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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
|
||||
|
||||
307
.paul/plans/20260520-1400-refactor-automation-controller/PLAN.md
Normal file
307
.paul/plans/20260520-1400-refactor-automation-controller/PLAN.md
Normal 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>
|
||||
@@ -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.
|
||||
@@ -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<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'] !== '';
|
||||
}
|
||||
}
|
||||
|
||||
80
src/Modules/Automation/AutomationFormViewModel.php
Normal file
80
src/Modules/Automation/AutomationFormViewModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
58
src/Modules/Automation/AutomationHistoryFilters.php
Normal file
58
src/Modules/Automation/AutomationHistoryFilters.php
Normal 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'] !== '';
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
422
src/Modules/Automation/AutomationRequestParser.php
Normal file
422
src/Modules/Automation/AutomationRequestParser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user