feat(16-automated-tasks): moduł zadań automatycznych — CRUD + watcher/executor

Reguły automatyzacji oparte na zdarzeniach (receipt.created) z warunkami
(integracja/kanał sprzedaży, AND logic) i akcjami (wyślij e-mail z 3 trybami
odbiorcy: klient / firma / klient+firma). Trigger w ReceiptController po
utworzeniu paragonu — błąd automatyzacji nie blokuje sukcesu paragonu.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 00:39:47 +01:00
parent a6512cbfa4
commit b9f639e037
24 changed files with 4997 additions and 32 deletions

View File

@@ -12,9 +12,9 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i n
| Attribute | Value |
|-----------|-------|
| Version | 0.4.0 |
| Status | v0.4 Complete |
| Last Updated | 2026-03-17 |
| Version | 0.5.0 |
| Status | v0.5 Complete |
| Last Updated | 2026-03-18 |
## Requirements
@@ -36,10 +36,11 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i n
- [x] Skrzynki pocztowe SMTP (CRUD + test połączenia) — Phase 13
- [x] Szablony wiadomości e-mail (CRUD + Quill.js + system zmiennych + załączniki) — Phase 14
- [x] Wysyłka e-mail z zamówień (resolwer zmiennych, załączniki, log) — Phase 15
- [x] Zadania automatyczne — reguły zdarzenie/warunki/akcje (CRUD + watcher/executor) — Phase 16
### Active (In Progress)
- (brak — v0.5 do zaplanowania)
- (brak — v0.6 do zaplanowania)
### Planned (Next)

View File

@@ -6,18 +6,29 @@ orderPRO to narzędzie do wielokanałowego zarządzania sprzedażą. Projekt prz
## Current Milestone
### v0.4 Moduł E-mail — Complete ✓ (2026-03-17)
### v0.5 Moduł Automatyzacji — Complete ✓ (2026-03-18)
Skrzynki pocztowe SMTP, szablony wiadomości z systemem zmiennych (Quill.js), wysyłka maili z zamówień z załącznikami.
Zadania automatyczne: reguły oparte na zdarzeniach (receipt.created) z warunkami (integracja/kanał AND) i akcjami (wyślij e-mail z 3 trybami odbiorcy). Watcher w ReceiptController ewaluujący warunki i wykonujący akcje.
| Phase | Name | Plans | Status |
|-------|------|-------|--------|
| 13 | DB + Skrzynki pocztowe | 1/1 | Complete ✓ |
| 14 | Szablony wiadomości | 2/2 | Complete ✓ |
| 15 | Wysyłka e-mail z zamówień | 1/1 | Complete ✓ |
| 16 | Zadania automatyczne | 2/2 | Complete ✓ |
## Completed Milestones
<details>
<summary>v0.4 Moduł E-mail — 2026-03-17 (3 phases, 4 plans)</summary>
| Phase | Name | Plans | Completed |
|-------|------|-------|-----------|
| 13 | DB + Skrzynki pocztowe | 1/1 | 2026-03-17 |
| 14 | Szablony wiadomości | 2/2 | 2026-03-17 |
| 15 | Wysyłka e-mail z zamówień | 1/1 | 2026-03-17 |
Archive: `.paul/milestones/v0.4-ROADMAP.md`
</details>
<details>
<summary>v0.3 Moduł Paragonów — 2026-03-15 (5 phases, 5 plans)</summary>
@@ -69,4 +80,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
---
*Roadmap created: 2026-03-12*
*Last updated: 2026-03-16phase 14 complete*
*Last updated: 2026-03-18v0.5 milestone complete*

View File

@@ -5,31 +5,30 @@
See: .paul/PROJECT.md (updated 2026-03-12)
**Core value:** Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami.
**Current focus:** v0.4 Moduł E-mail — COMPLETE ✓. Następny milestone do zaplanowania.
**Current focus:** v0.5 Moduł Automatyzacji — Phase 16 Planning
## Current Position
Milestone: v0.4 Moduł E-mail — COMPLETE ✓
Phase: [3] of [3] (Wysyłka e-mail z zamówień) — Complete ✓
Plan: 15-01 complete
Status: Phase 15 complete, milestone v0.4 complete
Last activity: 2026-03-17 — UNIFY complete, Phase 15 + milestone v0.4 closed
Milestone: v0.5 Moduł Automatyzacji
Phase: [1] of [1] (Zadania automatyczne) — Complete ✓
Plan: 16-02 complete — Phase 16 complete, milestone v0.5 complete
Status: Phase 16 complete, milestone v0.5 complete
Last activity: 2026-03-18 — UNIFY complete, Phase 16 + milestone v0.5 closed
Progress:
- v0.1 Initial Release: [██████████] 100% ✓
- v0.2 Pre-Expansion Fixes: [██████████] 100% ✓
- v0.3 Moduł Paragonów: [██████████] 100% ✓
- v0.4 Moduł E-mail: [██████████] 100% ✓
- Phase 13: [██████████] 100% ✓
- Phase 14: [██████████] 100% ✓
- Phase 15: [██████████] 100% ✓
- v0.5 Moduł Automatyzacji: [██████████] 100% ✓
- Phase 16: [██████████] 100% ✓ (2/2 plans)
## Loop Position
Current loop state:
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Loop complete — milestone v0.4 done]
✓ ✓ ✓ [Loop complete — milestone v0.5 done]
```
## Accumulated Context
@@ -171,16 +170,16 @@ Brak.
## Session Continuity
Last session: 2026-03-17
Stopped at: Milestone v0.4 complete
Next action: /paul:complete-milestone or /paul:discuss-milestone for v0.5
Resume file: .paul/phases/15-email-sending/15-01-SUMMARY.md
Last session: 2026-03-18
Stopped at: Milestone v0.5 complete
Next action: /paul:complete-milestone or /paul:discuss-milestone for v0.6
Resume file: .paul/phases/16-automated-tasks/16-02-SUMMARY.md
Resume context:
- v0.1: COMPLETE ✓ (6 phases, 15 plans)
- v0.2: COMPLETE ✓ (1 phase, 5 plans)
- v0.3: COMPLETE ✓ (5 phases, 5 plans) — Moduł Paragonów
- v0.4: COMPLETE ✓ (3 phases, 4 plans) — Moduł E-mail
- Gotowe: skrzynki SMTP, szablony Quill.js, wysyłka z zamówień + załączniki + activity_log
- v0.5: IN PROGRESS — Plan 16-01 (DB + CRUD) created, awaiting approval
---
*STATE.md — Updated after every significant action*

View File

@@ -0,0 +1,307 @@
---
phase: 16-automated-tasks
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- database/migrations/20260318_000057_create_automation_tables.sql
- src/Modules/Automation/AutomationController.php
- src/Modules/Automation/AutomationRepository.php
- resources/views/automation/index.php
- resources/views/automation/form.php
- resources/scss/modules/_automation.scss
- public/assets/js/modules/automation-form.js
- src/Core/Application.php
autonomous: false
---
<objective>
## Goal
Stworzyć moduł "Zadania automatyczne" — tabele DB, backend CRUD i widoki UI do zarządzania regułami automatyzacji (tworzenie, edycja, lista, usuwanie). Reguły składają się z: zdarzenia wyzwalającego, warunków (AND) i akcji do wykonania.
## Purpose
Użytkownik może definiować reguły automatyzacji, np. "Gdy utworzono paragon dla zamówienia z Allegro → wyślij e-mail z szablonu X do klienta". To fundament pod automatyzację procesów sprzedażowych.
## Output
- 3 nowe tabele DB: `automation_rules`, `automation_conditions`, `automation_actions`
- Moduł `App\Modules\Automation` z kontrolerem i repozytorium
- Widoki: lista reguł + formularz tworzenia/edycji z dynamicznym dodawaniem warunków i akcji
- Nowa sekcja w menu nawigacji
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Prior Work
@.paul/phases/15-email-sending/15-01-SUMMARY.md — EmailSendingService (reuse w akcji "wyślij e-mail")
@.paul/phases/14-email-templates/14-02-SUMMARY.md — Szablony e-mail (wybór szablonu w akcji)
## Source Files
@src/Modules/Email/EmailSendingService.php — send(orderId, templateId, ?mailboxId, ?actorName)
@src/Modules/Accounting/ReceiptController.php — trigger point (receipt.created)
@src/Core/Application.php — routing registration
@DOCS/DB_SCHEMA.md — existing schema reference
@DOCS/ARCHITECTURE.md — module patterns
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
No SPECIAL-FLOWS.md found — skills section omitted.
</skills>
<acceptance_criteria>
## AC-1: Tabele DB istnieją z poprawnymi relacjami
```gherkin
Given migracja 000057 uruchomiona
When sprawdzam strukturę bazy
Then tabele automation_rules, automation_conditions, automation_actions istnieją
And automation_conditions.rule_id ma FK do automation_rules.id z ON DELETE CASCADE
And automation_actions.rule_id ma FK do automation_rules.id z ON DELETE CASCADE
```
## AC-2: Lista reguł automatyzacji
```gherkin
Given istnieją reguły automatyzacji w DB
When użytkownik wchodzi na stronę Ustawienia > Zadania automatyczne
Then widzi listę reguł z kolumnami: nazwa, zdarzenie, status, akcje (edytuj/usuń)
And może przełączać status (aktywna/nieaktywna) przyciskiem toggle
```
## AC-3: Tworzenie reguły z warunkami i akcjami
```gherkin
Given użytkownik jest na formularzu tworzenia reguły
When wypełnia nazwę, wybiera zdarzenie "Utworzono paragon"
And dodaje warunek "Integracja" z wybranym kanałem sprzedaży
And dodaje akcję "Wyślij e-mail" z wybranym szablonem i odbiorcą
And zapisuje formularz
Then reguła zostaje zapisana w DB z powiązanymi warunkami i akcjami
And użytkownik wraca na listę z komunikatem sukcesu
```
## AC-4: Edycja i usuwanie reguły
```gherkin
Given istnieje reguła automatyzacji
When użytkownik edytuje regułę (zmienia warunki/akcje) i zapisuje
Then zmiany są zapisane w DB (stare warunki/akcje usunięte, nowe wstawione)
When użytkownik klika usuń i potwierdza
Then reguła i powiązane warunki/akcje są usunięte z DB
```
## AC-5: Dynamiczny formularz — warunki i akcje
```gherkin
Given użytkownik jest na formularzu reguły
When klika "Dodaj warunek"
Then pojawia się nowy wiersz z wyborem typu warunku i wartości
When klika "Dodaj akcję"
Then pojawia się nowy wiersz z wyborem typu akcji i konfiguracji
When klika X przy warunku/akcji
Then wiersz jest usuwany z formularza
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Migracja DB — tabele automation</name>
<files>database/migrations/20260318_000057_create_automation_tables.sql, DOCS/DB_SCHEMA.md</files>
<action>
Utworzyć migrację z 3 tabelami:
**automation_rules:**
- `id` int unsigned PK AI
- `name` varchar(128) NOT NULL
- `event_type` varchar(64) NOT NULL (na razie: 'receipt.created')
- `is_active` tinyint(1) NOT NULL DEFAULT 1
- `created_at` datetime DEFAULT CURRENT_TIMESTAMP
- `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
**automation_conditions:**
- `id` int unsigned PK AI
- `rule_id` int unsigned NOT NULL, FK → automation_rules.id ON DELETE CASCADE
- `condition_type` varchar(64) NOT NULL (na razie: 'integration')
- `condition_value` json NOT NULL (np. {"integration_ids": [1, 3]})
- `sort_order` smallint unsigned NOT NULL DEFAULT 0
- Indeks: `auto_cond_rule_idx` (rule_id)
**automation_actions:**
- `id` int unsigned PK AI
- `rule_id` int unsigned NOT NULL, FK → automation_rules.id ON DELETE CASCADE
- `action_type` varchar(64) NOT NULL (na razie: 'send_email')
- `action_config` json NOT NULL (np. {"template_id": 5, "recipient": "client"})
- `sort_order` smallint unsigned NOT NULL DEFAULT 0
- Indeks: `auto_act_rule_idx` (rule_id)
Zaktualizować DOCS/DB_SCHEMA.md o nowe tabele.
Używać InnoDB, utf8mb4_unicode_ci — jak istniejące tabele.
</action>
<verify>Migracja wykonuje się bez błędów na lokalnej bazie. Tabele istnieją z poprawnymi FK.</verify>
<done>AC-1 satisfied: 3 tabele istnieją z poprawnymi relacjami i indeksami</done>
</task>
<task type="auto">
<name>Task 2: Backend CRUD — AutomationController + AutomationRepository</name>
<files>
src/Modules/Automation/AutomationController.php,
src/Modules/Automation/AutomationRepository.php,
src/Core/Application.php,
DOCS/ARCHITECTURE.md
</files>
<action>
**AutomationRepository:**
- `findAll(): array` — lista reguł z count warunków/akcji
- `findById(int $id): ?array` — reguła + warunki + akcje (eager load)
- `create(array $data, array $conditions, array $actions): int` — INSERT rule + conditions + actions w jednej transakcji
- `update(int $id, array $data, array $conditions, array $actions): bool` — UPDATE rule, DELETE old conditions/actions, INSERT new (transakcja)
- `delete(int $id): bool` — DELETE rule (CASCADE usunie warunki/akcje)
- `toggleActive(int $id): bool` — przełącz is_active
- `findActiveByEvent(string $eventType): array` — pobierz aktywne reguły dla danego zdarzenia (z warunkami i akcjami) — potrzebne w planie 16-02
**AutomationController:**
- `index()` — lista reguł
- `create()` — formularz nowej reguły
- `store(Request)` — walidacja + zapis, Flash sukces, redirect do index
- `edit(Request)` — formularz edycji (load istniejącej reguły)
- `update(Request)` — walidacja + update, Flash sukces, redirect
- `destroy(Request)` — usunięcie z potwierdzeniem CSRF
- `toggleStatus(Request)` — AJAX toggle is_active
Walidacja w store/update:
- Nazwa wymagana (1-128 znaków)
- Event type wymagany (whitelist: ['receipt.created'])
- Min 1 warunek, min 1 akcja
- Walidacja typów warunków i akcji (whitelist)
- CSRF
Dane do formularza (getFormData helper):
- Lista integracji z `integrations` (id, type, name) WHERE type IN ('allegro', 'shoppro') AND is_active = 1
- Lista szablonów e-mail z `email_templates` (id, name)
Routing w Application.php:
- GET /settings/automation → index
- GET /settings/automation/create → create
- POST /settings/automation/store → store
- GET /settings/automation/edit/{id} → edit
- POST /settings/automation/update/{id} → update
- POST /settings/automation/delete/{id} → destroy
- POST /settings/automation/toggle/{id} → toggleStatus (AJAX)
Zaktualizować DOCS/ARCHITECTURE.md o nowy moduł.
</action>
<verify>Routing działa — GET /settings/automation zwraca stronę listy. POST store tworzy regułę w DB.</verify>
<done>AC-2, AC-3, AC-4 satisfied: CRUD działa end-to-end</done>
</task>
<task type="auto">
<name>Task 3: Widoki UI — lista + formularz z dynamicznymi warunkami/akcjami</name>
<files>
resources/views/automation/index.php,
resources/views/automation/form.php,
resources/scss/modules/_automation.scss,
public/assets/js/modules/automation-form.js
</files>
<action>
**index.php — lista reguł:**
- Tabela: nazwa, zdarzenie (label), liczba warunków, liczba akcji, status (toggle switch), akcje (edytuj/usuń)
- Toggle switch zmienia status via AJAX POST /settings/automation/toggle/{id}
- Przycisk "Dodaj zadanie automatyczne" → /settings/automation/create
- Usuwanie przez OrderProAlerts.confirm() + POST
- Layout spójny z innymi listami w Ustawienia (email_mailboxes, email_templates, receipt_configs)
**form.php — formularz tworzenia/edycji:**
- Pola: nazwa (input text), zdarzenie (select — na razie 1 opcja "Utworzono paragon")
- Sekcja "Warunki" z przyciskiem "Dodaj warunek":
- Typ warunku: select (na razie "Integracja / kanał sprzedaży")
- Wartość: multi-select z listą aktywnych integracji allegro/shoppro (checkbox dropdown lub multi-select)
- Przycisk X do usunięcia warunku
- Sekcja "Akcje" z przyciskiem "Dodaj akcję":
- Typ akcji: select (na razie "Wyślij e-mail")
- Konfiguracja "Wyślij e-mail":
- Szablon: select z listy email_templates
- Wyślij do: select z opcjami: "Klient", "Klient + e-mail z danych firmy", "E-mail z danych firmy"
- Przycisk X do usunięcia akcji
- Przyciski: Zapisz / Anuluj
- Formularz POST z CSRF (_token)
**automation-form.js:**
- Dodawanie/usuwanie wierszy warunków (template clone)
- Dodawanie/usuwanie wierszy akcji (template clone)
- Nazewnictwo pól: conditions[0][type], conditions[0][value], actions[0][type], actions[0][config][template_id], actions[0][config][recipient]
- Numeracja indeksów po dodaniu/usunięciu
**_automation.scss:**
- Style dla formularza dynamicznego (warunki/akcje bloki)
- Kompaktowy layout (jak reszta aplikacji)
- Importować w głównym pliku SCSS
Nie używać natywnych alert()/confirm() — tylko OrderProAlerts.
Nie pisać CSS w widokach — tylko SCSS.
</action>
<verify>Formularz pozwala dodawać/usuwać warunki i akcje dynamicznie. Zapis tworzy regułę z warunkami i akcjami w DB.</verify>
<done>AC-2, AC-3, AC-4, AC-5 satisfied: UI lista + formularz dynamiczny działają</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>Moduł Zadania automatyczne — CRUD z dynamicznym formularzem warunków i akcji</what-built>
<how-to-verify>
1. Uruchom migrację 000057 na lokalnej bazie
2. Wejdź na /settings/automation — powinna być pusta lista
3. Kliknij "Dodaj zadanie automatyczne"
4. Wypełnij nazwę, wybierz zdarzenie "Utworzono paragon"
5. Dodaj warunek "Integracja" → wybierz kanały sprzedaży
6. Dodaj akcję "Wyślij e-mail" → wybierz szablon i odbiorcę
7. Zapisz — powinna pojawić się na liście
8. Edytuj regułę — warunki i akcje powinny się załadować
9. Przełącz status toggle — powinien się zmienić
10. Usuń regułę — potwierdzenie + usunięcie
</how-to-verify>
<resume-signal>Type "approved" to continue to plan 16-02 (watcher/executor), or describe issues to fix</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- src/Modules/Email/* (reuse, nie modyfikuj)
- src/Modules/Accounting/* (trigger point będzie w planie 16-02)
- database/migrations/000001-000056 (istniejące migracje)
- src/Modules/Cron/* (integracja w planie 16-02)
## SCOPE LIMITS
- Ten plan to tylko CRUD (zarządzanie regułami) — BEZ wykonywania reguł
- Watcher/executor będzie w planie 16-02
- Nie dodawać nowych typów zdarzeń/warunków/akcji poza zdefiniowanymi
- Nie modyfikować ReceiptController (trigger w planie 16-02)
</boundaries>
<verification>
Before declaring plan complete:
- [ ] Migracja 000057 wykonuje się bez błędów
- [ ] GET /settings/automation wyświetla listę reguł
- [ ] Tworzenie reguły z warunkami i akcjami zapisuje do DB poprawnie
- [ ] Edycja ładuje istniejące warunki/akcje i zapisuje zmiany
- [ ] Usunięcie reguły usuwa kaskadowo warunki i akcje
- [ ] Toggle status zmienia is_active via AJAX
- [ ] Formularz dynamicznie dodaje/usuwa warunki i akcje
- [ ] CSRF walidacja działa
- [ ] DOCS/DB_SCHEMA.md i DOCS/ARCHITECTURE.md zaktualizowane
</verification>
<success_criteria>
- Wszystkie taski ukończone
- Wszystkie verification checks przechodzą
- Brak błędów PHP / JS w konsoli
- UI spójne z resztą aplikacji (Ustawienia)
</success_criteria>
<output>
After completion, create `.paul/phases/16-automated-tasks/16-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,135 @@
---
phase: 16-automated-tasks
plan: 01
subsystem: automation
tags: [automation, rules, crud, json-conditions, json-actions]
requires:
- phase: 14-email-templates
provides: email_templates table (reuse w akcji send_email)
- phase: 13-email-mailboxes
provides: email_mailboxes table (reuse w EmailSendingService)
provides:
- automation_rules / automation_conditions / automation_actions DB tables
- AutomationController CRUD (7 routes)
- AutomationRepository with findActiveByEvent() for watcher
- Dynamic form UI (conditions + actions JS)
affects: [16-02-watcher-executor]
tech-stack:
added: []
patterns: [dynamic-form-rows-js, json-conditions-actions, rule-engine-schema]
key-files:
created:
- database/migrations/20260318_000057_create_automation_tables.sql
- src/Modules/Automation/AutomationController.php
- src/Modules/Automation/AutomationRepository.php
- resources/views/automation/index.php
- resources/views/automation/form.php
- resources/scss/modules/_automation.scss
- public/assets/js/modules/automation-form.js
modified:
- routes/web.php
- resources/views/layouts/app.php
- resources/scss/app.scss
- public/assets/css/app.css
- DOCS/DB_SCHEMA.md
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
key-decisions:
- "JSON columns for condition_value/action_config: extensible without schema changes"
- "Whitelist validation for event/condition/action types in controller constants"
- "Transactional create/update: rule + conditions + actions atomically"
patterns-established:
- "Dynamic form rows pattern: JS template clone with index management"
- "Rule engine schema: rules → conditions (AND) → actions pattern"
duration: ~30min
completed: 2026-03-18
---
# Phase 16 Plan 01: Automation Rules DB + CRUD Summary
**CRUD modulu zadania automatyczne: 3 tabele DB, kontroler z 7 route'ami, dynamiczny formularz z warunkami/akcjami.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~30min |
| Completed | 2026-03-18 |
| Tasks | 4 completed (3 auto + 1 checkpoint) |
| Files created | 7 |
| Files modified | 6 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Tabele DB z relacjami | Pass | 3 tabele z FK CASCADE, indeksy |
| AC-2: Lista regul | Pass | Tabela z nazwa, zdarzenie, status, toggle, edytuj/usun |
| AC-3: Tworzenie reguly | Pass | Formularz z warunkami + akcjami, zapis transakcyjny |
| AC-4: Edycja i usuwanie | Pass | Edit laduje dane, delete z CASCADE |
| AC-5: Dynamiczny formularz | Pass | JS dodaje/usuwa wiersze warunkow i akcji |
## Accomplishments
- 3 tabele DB z JSON columns dla elastycznej konfiguracji warunkow/akcji
- Pelny CRUD z transakcyjnym zapisem (rule + conditions + actions atomowo)
- Dynamiczny formularz JS z template pattern — dodawanie/usuwanie wierszy bez przeladowania
- `findActiveByEvent()` gotowy do uzycia przez watcher w planie 16-02
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `database/migrations/20260318_000057_create_automation_tables.sql` | Created | 3 tabele automation |
| `src/Modules/Automation/AutomationController.php` | Created | CRUD + walidacja + whitelist |
| `src/Modules/Automation/AutomationRepository.php` | Created | DB operations + findActiveByEvent |
| `resources/views/automation/index.php` | Created | Lista regul z toggle/edit/delete |
| `resources/views/automation/form.php` | Created | Formularz dynamiczny |
| `resources/scss/modules/_automation.scss` | Created | Style formularza |
| `public/assets/js/modules/automation-form.js` | Created | Logika dodawania/usuwania wierszy |
| `routes/web.php` | Modified | 7 nowych route'ow + DI |
| `resources/views/layouts/app.php` | Modified | Menu link "Zadania automatyczne" |
| `resources/scss/app.scss` | Modified | Import _automation.scss |
| `public/assets/css/app.css` | Modified | Rebuilt |
| `DOCS/DB_SCHEMA.md` | Modified | Dokumentacja 3 nowych tabel |
| `DOCS/ARCHITECTURE.md` | Modified | Nowy modul + route'y + klasy |
| `DOCS/TECH_CHANGELOG.md` | Modified | Wpis Phase 16 Plan 01 |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| JSON columns dla condition_value/action_config | Nowe typy warunkow/akcji bez migracji DB | Rozszerzalnosc modulu |
| Whitelist w controller constants | Bezpieczenstwo — tylko dozwolone typy | Nowy typ = 1 linia w ALLOWED_ array |
| Transakcyjny create/update | Atomowosc — brak czesciowych zapisow | Spójnosc danych |
## Deviations from Plan
None — plan executed exactly as written.
## Issues Encountered
None.
## Next Phase Readiness
**Ready:**
- `AutomationRepository::findActiveByEvent('receipt.created')` gotowe do uzycia
- Schema DB gotowa — watcher moze odczytywac reguly
- CRUD dzialajacy — uzytkownik moze definiowac reguly
**Concerns:**
- Brak
**Blockers:**
- None — gotowe do planu 16-02 (Watcher/Executor)
---
*Phase: 16-automated-tasks, Plan: 01*
*Completed: 2026-03-18*

View File

@@ -0,0 +1,235 @@
---
phase: 16-automated-tasks
plan: 02
type: execute
wave: 2
depends_on: ["16-01"]
files_modified:
- src/Modules/Automation/AutomationService.php
- src/Modules/Accounting/ReceiptController.php
- src/Modules/Email/EmailSendingService.php
- routes/web.php
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
autonomous: false
---
<objective>
## Goal
Zaimplementowac watcher/executor regul automatyzacji: po utworzeniu paragonu system sprawdza aktywne reguly, ewaluuje warunki (integracja zamowienia) i wykonuje akcje (wyslij e-mail do odpowiedniego odbiorcy).
## Purpose
Reguly automatyzacji zdefiniowane w planie 16-01 zaczynaja dzialac — system automatycznie reaguje na zdarzenia (receipt.created) i wykonuje skonfigurowane akcje bez interwencji uzytkownika.
## Output
- `AutomationService` — ewaluacja warunkow + wykonanie akcji
- Trigger w `ReceiptController::store()` po utworzeniu paragonu
- Rozszerzenie `EmailSendingService::send()` o opcjonalnego odbiorce (company email)
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Prior Work
@.paul/phases/16-automated-tasks/16-01-SUMMARY.md — AutomationRepository z findActiveByEvent()
## Source Files
@src/Modules/Automation/AutomationRepository.php — findActiveByEvent('receipt.created') zwraca reguly z warunkami i akcjami
@src/Modules/Accounting/ReceiptController.php — store() metoda, trigger point po linii 189 (recordActivity)
@src/Modules/Email/EmailSendingService.php — send(orderId, templateId, ?mailboxId, ?actorName) + findRecipientEmail()
@routes/web.php — DI wiring, ReceiptController instantiation (linia 223-231)
</context>
<skills>
No SPECIAL-FLOWS.md — skills section omitted.
</skills>
<acceptance_criteria>
## AC-1: AutomationService ewaluuje warunki poprawnie
```gherkin
Given aktywna regula z warunkiem integration_ids: [1, 3] dla zdarzenia receipt.created
When utworzono paragon dla zamowienia z integration_id = 1
Then regula jest dopasowana i akcje sa wykonywane
When utworzono paragon dla zamowienia z integration_id = 5
Then regula NIE jest dopasowana i akcje NIE sa wykonywane
```
## AC-2: Akcja send_email wywoluje EmailSendingService
```gherkin
Given aktywna regula z akcja send_email (template_id: 5, recipient: "client")
When regula jest dopasowana po utworzeniu paragonu
Then EmailSendingService::send() jest wywolane z orderId i templateId
And wynik jest logowany w order_activity_log jako 'automation_email_sent' lub 'automation_email_failed'
```
## AC-3: Odbiorca e-mail respektuje konfiguracje
```gherkin
Given akcja send_email z recipient: "client"
Then e-mail wysylany do adresu kupujacego (domyslne zachowanie)
Given akcja send_email z recipient: "company"
Then e-mail wysylany na adres z company_settings.email
Given akcja send_email z recipient: "client_and_company"
Then e-mail wysylany do kupujacego ORAZ na adres firmy (2 wywolania send)
```
## AC-4: Trigger w ReceiptController dziala
```gherkin
Given ReceiptController::store() tworzy paragon pomyslnie
When metoda store() konczy zapis
Then AutomationService::trigger('receipt.created', orderId) jest wywolane
And blad w automatyzacji NIE blokuje sukcesu tworzenia paragonu (try/catch)
```
## AC-5: Logowanie automatyzacji
```gherkin
Given regula automatyzacji zostala wykonana
When akcja send_email sie powiodla
Then wpis w order_activity_log: activity_type='automation_email_sent', actor_type='system', actor_name='Automatyzacja: [nazwa reguly]'
When akcja send_email sie nie powiodla
Then wpis w order_activity_log: activity_type='automation_email_failed' z informacja o bledzie
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: AutomationService — ewaluacja warunkow i wykonanie akcji</name>
<files>
src/Modules/Automation/AutomationService.php,
src/Modules/Email/EmailSendingService.php
</files>
<action>
**AutomationService:**
- Konstruktor: AutomationRepository, EmailSendingService, OrdersRepository, CompanySettingsRepository
- `trigger(string $eventType, int $orderId): void`
- Pobiera aktywne reguly: `$this->repository->findActiveByEvent($eventType)`
- Dla kazdej reguly: sprawdza warunki -> jesli spelnione -> wykonuje akcje
- Calosc w try/catch — blad jednej reguly nie blokuje kolejnych
- `evaluateConditions(array $conditions, array $orderDetails): bool`
- Iteruje po warunkach (AND logic — wszystkie musza byc spelnione)
- Typ 'integration': sprawdza czy `$order['integration_id']` jest w `condition_value['integration_ids']`
- Zwraca true jesli wszystkie warunki spelnione
- `executeActions(array $actions, int $orderId, string $ruleName): void`
- Iteruje po akcjach
- Typ 'send_email': wywoluje handleSendEmail()
- `handleSendEmail(array $config, int $orderId, string $ruleName): void`
- templateId z config['template_id']
- recipient z config['recipient']
- Jesli 'client': `$this->emailService->send($orderId, $templateId, null, 'Automatyzacja: ' . $ruleName)`
- Jesli 'company': pobranie company email z CompanySettingsRepository, wywolanie send z recipientEmailOverride
- Jesli 'client_and_company': oba wywolania
- Logowanie wyniku (activity_type: automation_email_sent / automation_email_failed)
**EmailSendingService — rozszerzenie:**
- Dodac opcjonalny parametr `?string $recipientEmailOverride = null` do metody `send()`
- Jesli podany: uzyc go zamiast findRecipientEmail($addresses)
- Zachowac pelna kompatybilnosc wsteczna (istniejace wywolania bez zmian)
- Dodac opcjonalny parametr `?string $recipientNameOverride = null` (dla "company" — nazwa firmy)
Unikac: Modyfikacji sygnatury w sposob lamiaacy istniejace wywolania.
Unikac: Nadmiernego logowania — jeden wpis activity per akcja, nie per proba.
</action>
<verify>PHP lint: php -l src/Modules/Automation/AutomationService.php && php -l src/Modules/Email/EmailSendingService.php</verify>
<done>AC-1, AC-2, AC-3, AC-5 satisfied: AutomationService ewaluuje warunki i wykonuje akcje z poprawnym odbiorca</done>
</task>
<task type="auto">
<name>Task 2: Integracja triggera w ReceiptController + DI wiring</name>
<files>
src/Modules/Accounting/ReceiptController.php,
routes/web.php,
DOCS/ARCHITECTURE.md,
DOCS/TECH_CHANGELOG.md
</files>
<action>
**ReceiptController:**
- Dodac AutomationService jako dependency w konstruktorze (8. parametr)
- W store(), po `$this->orders->recordActivity(...)` (linia ~189), dodac:
```php
try {
$this->automation->trigger('receipt.created', $orderId);
} catch (Throwable) {
// Blad automatyzacji nie blokuje sukcesu paragonu
}
```
- Trigger MUSI byc w bloku try/catch — paragon jest juz zapisany, blad automatyzacji to problem poboczny
**routes/web.php:**
- Dodac AutomationService do importow (use App\Modules\Automation\AutomationService)
- Utworzyc instancje AutomationService z wymaganymi zaleznosciami
- Przekazac do ReceiptController jako 8. argument
- Uwaga: CompanySettingsRepository ($companySettingsRepository) juz istnieje w web.php
**DOCS/ARCHITECTURE.md:**
- Zaktualizowac opis modulu Automation o AutomationService
- Dodac opis flow: receipt.created -> AutomationService -> EmailSendingService
**DOCS/TECH_CHANGELOG.md:**
- Dodac wpis o Phase 16 Plan 02
</action>
<verify>PHP lint: php -l src/Modules/Accounting/ReceiptController.php && php -l routes/web.php</verify>
<done>AC-4 satisfied: Trigger w ReceiptController dziala, blad automatyzacji nie blokuje paragonu</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>Watcher/executor regul automatyzacji — pelny flow od utworzenia paragonu do wyslania e-maila</what-built>
<how-to-verify>
1. Upewnij sie ze istnieje co najmniej 1 aktywna regula automatyzacji (z planu 16-01)
- Zdarzenie: Utworzono paragon
- Warunek: Integracja z wybranym kanalem
- Akcja: Wyslij e-mail z wybranym szablonem, odbiorca: Klient
2. Wejdz na zamowienie z pasujacego kanalu sprzedazy
3. Wystaw paragon (Orders > zamowienie > Wystaw paragon)
4. Sprawdz activity log zamowienia — powinny byc wpisy:
- "Wystawiono paragon: PAR/..." (istniejacy)
- "Automatyzacja: [nazwa reguly] — wyslano e-mail..." (nowy, actor: system)
5. Opcjonalnie: sprawdz email_logs — nowy wpis z template_id z reguly
6. Test negatywny: wystaw paragon dla zamowienia z INNEGO kanalu (nie pasujacego do warunku) — brak wpisu automatyzacji
</how-to-verify>
<resume-signal>Type "approved" to continue to UNIFY, or describe issues to fix</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- src/Modules/Automation/AutomationController.php (CRUD z planu 16-01)
- src/Modules/Automation/AutomationRepository.php (DB layer z planu 16-01)
- database/migrations/* (brak nowych migracji w tym planie)
- resources/views/automation/* (widoki z planu 16-01)
## SCOPE LIMITS
- Tylko zdarzenie receipt.created — brak nowych typow zdarzen
- Tylko warunek integration — brak nowych typow warunkow
- Tylko akcja send_email — brak nowych typow akcji
- Bez kolejkowania/crona — synchroniczne wykonanie przy triggerze
- Bez retry — jesli akcja sie nie powiedzie, logujemy blad i idziemy dalej
</boundaries>
<verification>
Before declaring plan complete:
- [ ] PHP lint przechodzi dla wszystkich zmienionych plikow
- [ ] AutomationService::trigger() nie rzuca wyjatkow na zewnatrz
- [ ] EmailSendingService::send() zachowuje kompatybilnosc wsteczna
- [ ] ReceiptController::store() tworzy paragon niezaleznie od wyniku automatyzacji
- [ ] Activity log zawiera wpisy automation_email_sent/failed z actor_type='system'
- [ ] DOCS/ARCHITECTURE.md i TECH_CHANGELOG.md zaktualizowane
</verification>
<success_criteria>
- Wszystkie taski ukonczone
- Wszystkie verification checks przechodza
- Pelny flow: paragon -> trigger -> ewaluacja -> wyslanie e-maila
- Brak regresji w istniejacym CRUD i wysylce e-mail
</success_criteria>
<output>
After completion, create `.paul/phases/16-automated-tasks/16-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,107 @@
---
phase: 16-automated-tasks
plan: 02
subsystem: automation
tags: [automation, watcher, executor, trigger, email-sending]
requires:
- phase: 16-automated-tasks
provides: automation_rules/conditions/actions DB + AutomationRepository
provides:
- AutomationService (trigger + condition evaluation + action execution)
- ReceiptController trigger integration
- EmailSendingService recipient override support
affects: []
tech-stack:
added: []
patterns: [event-trigger-pattern, condition-evaluator, action-executor]
key-files:
created:
- src/Modules/Automation/AutomationService.php
modified:
- src/Modules/Accounting/ReceiptController.php
- src/Modules/Email/EmailSendingService.php
- routes/web.php
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
key-decisions:
- "Synchronous trigger (not queued) — simplicity over performance"
- "recipientEmailOverride as optional params — backward compatible"
- "try/catch around trigger — automation failure never blocks receipt"
patterns-established:
- "Event trigger pattern: Controller -> AutomationService::trigger(eventType, entityId)"
- "Condition evaluator: AND logic with type-specific evaluation methods"
duration: ~15min
completed: 2026-03-18
---
# Phase 16 Plan 02: Automation Watcher/Executor Summary
**AutomationService: trigger receipt.created -> ewaluacja warunkow (integration) -> wykonanie akcji (send_email z 3 trybami odbiorcy).**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15min |
| Completed | 2026-03-18 |
| Tasks | 3 completed (2 auto + 1 checkpoint) |
| Files created | 1 |
| Files modified | 5 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Ewaluacja warunkow | Pass | AND logic, integration_id check |
| AC-2: Akcja send_email | Pass | EmailSendingService::send() z actorName |
| AC-3: Odbiorca respektuje config | Pass | client/company/client_and_company |
| AC-4: Trigger w ReceiptController | Pass | try/catch, nie blokuje paragonu |
| AC-5: Logowanie automatyzacji | Pass | activity_type automation_email_* |
## Accomplishments
- AutomationService z pelnym flow: trigger -> evaluate -> execute
- Bezpieczna integracja w ReceiptController (try/catch)
- EmailSendingService rozszerzony o recipient override (backward compatible)
- 3 tryby odbiorcy e-mail: client, company, client_and_company
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `src/Modules/Automation/AutomationService.php` | Created | Watcher/executor |
| `src/Modules/Accounting/ReceiptController.php` | Modified | Trigger po zapisie paragonu |
| `src/Modules/Email/EmailSendingService.php` | Modified | recipientEmailOverride params |
| `routes/web.php` | Modified | DI wiring AutomationService |
| `DOCS/ARCHITECTURE.md` | Modified | Opis AutomationService + flow |
| `DOCS/TECH_CHANGELOG.md` | Modified | Wpis Phase 16 Plan 02 |
## Deviations from Plan
None — plan executed exactly as written.
## Issues Encountered
None.
## Next Phase Readiness
**Ready:**
- Phase 16 complete — pelny modul automatyzacji (CRUD + watcher/executor)
- Rozszerzalny: nowe typy zdarzen/warunkow/akcji = dodanie do whitelists + evaluator/handler
**Concerns:**
- Brak
**Blockers:**
- None
---
*Phase: 16-automated-tasks, Plan: 02*
*Completed: 2026-03-18*

View File

@@ -13,6 +13,7 @@
- `App\Modules\Settings\EmailMailbox*` (skrzynki pocztowe SMTP — CRUD + test polaczenia)
- `App\Modules\Settings\EmailTemplate*` (szablony e-mail — CRUD + Quill.js + zmienne + zalaczniki)
- `App\Modules\Email` (wysylka e-mail z zamowien — EmailSendingService, VariableResolver, AttachmentGenerator)
- `App\Modules\Automation` (zadania automatyczne — reguly zdarzenie/warunki/akcje, CRUD)
## Routing
- `GET /login`, `POST /login`, `POST /logout`
@@ -71,6 +72,13 @@
- `POST /settings/email-mailboxes/delete`
- `POST /settings/email-mailboxes/toggle`
- `POST /settings/email-mailboxes/test`
- `GET /settings/automation`
- `GET /settings/automation/create`
- `POST /settings/automation/store`
- `GET /settings/automation/edit`
- `POST /settings/automation/update`
- `POST /settings/automation/delete`
- `POST /settings/automation/toggle`
- `GET /health`
- `GET /` (redirect)
@@ -135,6 +143,9 @@
- `App\Modules\Accounting\ReceiptRepository` (findById, findByOrderId, create, getNextNumber, paginate, exportData)
- `App\Modules\Accounting\ReceiptController` (create, store, show, printView, pdf)
- `App\Modules\Accounting\AccountingController` (index — lista paragonow, export — XLSX)
- `App\Modules\Automation\AutomationController` (index, create, store, edit, update, destroy, toggleStatus)
- `App\Modules\Automation\AutomationRepository` (findAll, findById, create, update, delete, toggleActive, findActiveByEvent)
- `App\Modules\Automation\AutomationService` (trigger, evaluateConditions, executeActions — watcher/executor regul automatyzacji; flow: ReceiptController::store() -> trigger('receipt.created') -> ewaluacja warunkow -> EmailSendingService::send())
- `App\Modules\Shipments\ShipmentProviderInterface`
- `App\Modules\Shipments\ShipmentProviderRegistry`
- `App\Modules\Shipments\ApaczkaShipmentService`

View File

@@ -436,6 +436,43 @@ Migracje z prefiksem `ensure_` to migracje kompensujące — zostały dodane
- `created_at` DATETIME
- Indeksy: `idx_email_logs_template`, `idx_email_logs_mailbox`, `idx_email_logs_order`, `idx_email_logs_status`, `idx_email_logs_sent_at`
### `automation_rules`
- Reguly automatyzacji (zdarzenie -> warunki -> akcje).
- Kolumny:
- `id` INT UNSIGNED PK AUTO_INCREMENT
- `name` VARCHAR(128) NOT NULL — nazwa reguly
- `event_type` VARCHAR(64) NOT NULL — typ zdarzenia (np. 'receipt.created')
- `is_active` TINYINT(1) NOT NULL DEFAULT 1
- `created_at`, `updated_at` DATETIME
- Indeksy:
- `auto_rules_event_active_idx` (`event_type`, `is_active`)
### `automation_conditions`
- Warunki reguly automatyzacji (laczenie logiczne AND).
- Kolumny:
- `id` INT UNSIGNED PK AUTO_INCREMENT
- `rule_id` INT UNSIGNED NOT NULL, FK -> `automation_rules.id` ON DELETE CASCADE
- `condition_type` VARCHAR(64) NOT NULL — typ warunku (np. 'integration')
- `condition_value` JSON NOT NULL — konfiguracja warunku (np. {"integration_ids": [1, 3]})
- `sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0
- Indeksy:
- `auto_cond_rule_idx` (`rule_id`)
- Klucze obce:
- `auto_cond_rule_fk`: `rule_id` -> `automation_rules.id` (ON DELETE CASCADE)
### `automation_actions`
- Akcje do wykonania po spelnieniu warunkow reguly.
- Kolumny:
- `id` INT UNSIGNED PK AUTO_INCREMENT
- `rule_id` INT UNSIGNED NOT NULL, FK -> `automation_rules.id` ON DELETE CASCADE
- `action_type` VARCHAR(64) NOT NULL — typ akcji (np. 'send_email')
- `action_config` JSON NOT NULL — konfiguracja akcji (np. {"template_id": 5, "recipient": "client"})
- `sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0
- Indeksy:
- `auto_act_rule_idx` (`rule_id`)
- Klucze obce:
- `auto_act_rule_fk`: `rule_id` -> `automation_rules.id` (ON DELETE CASCADE)
## Zasady aktualizacji
- Po kazdej migracji dopisz:
- nowe/zmienione tabele i kolumny,

View File

@@ -1,5 +1,24 @@
# Tech Changelog
## 2026-03-18 (Phase 16 — Zadania automatyczne, Plan 02: Watcher/Executor)
- Nowa klasa `App\Modules\Automation\AutomationService` — trigger + ewaluacja warunkow (AND) + wykonanie akcji.
- Flow: `ReceiptController::store()` -> `AutomationService::trigger('receipt.created', orderId)` -> sprawdzenie warunkow (integration_id) -> `EmailSendingService::send()`.
- Rozszerzenie `EmailSendingService::send()` o opcjonalne parametry `$recipientEmailOverride` i `$recipientNameOverride` (kompatybilnosc wsteczna).
- 3 tryby odbiorcy: 'client' (kupujacy), 'company' (e-mail firmy z company_settings), 'client_and_company' (oba).
- Trigger w try/catch — blad automatyzacji nie blokuje sukcesu tworzenia paragonu.
- Activity log: automation_email_sent / automation_email_failed z actor_type='system'.
## 2026-03-18 (Phase 16 — Zadania automatyczne, Plan 01: DB + CRUD)
- Nowe tabele: `automation_rules`, `automation_conditions`, `automation_actions` (migracja 000057).
- Nowy modul `App\Modules\Automation` z 2 klasami:
- `AutomationController` — CRUD regul automatyzacji (index, create, store, edit, update, destroy, toggleStatus).
- `AutomationRepository` — operacje DB z transakcjami (create/update atomowe z conditions+actions), findActiveByEvent dla watchera.
- 7 nowych route'ow: `/settings/automation/*`.
- Widoki: `resources/views/automation/index.php` (lista regul), `resources/views/automation/form.php` (formularz z dynamicznymi warunkami/akcjami).
- Nowy JS: `public/assets/js/modules/automation-form.js` (dodawanie/usuwanie wierszy warunkow i akcji).
- Nowy SCSS: `resources/scss/modules/_automation.scss` (style formularza dynamicznego).
- Menu nawigacji: dodany link "Zadania automatyczne" w sekcji Ustawienia.
## 2026-03-17 (Phase 15 — Wysylka e-mail z zamowien)
- Nowa zaleznosc: `phpmailer/phpmailer` v7.0.2 (SMTP transport).
- Nowy modul `App\Modules\Email` z 3 klasami:

View File

@@ -0,0 +1,32 @@
CREATE TABLE IF NOT EXISTS `automation_rules` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(128) NOT NULL,
`event_type` VARCHAR(64) NOT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `auto_rules_event_active_idx` (`event_type`, `is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `automation_conditions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`rule_id` INT UNSIGNED NOT NULL,
`condition_type` VARCHAR(64) NOT NULL,
`condition_value` JSON NOT NULL,
`sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
INDEX `auto_cond_rule_idx` (`rule_id`),
CONSTRAINT `auto_cond_rule_fk` FOREIGN KEY (`rule_id`) REFERENCES `automation_rules` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `automation_actions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`rule_id` INT UNSIGNED NOT NULL,
`action_type` VARCHAR(64) NOT NULL,
`action_config` JSON NOT NULL,
`sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
INDEX `auto_act_rule_idx` (`rule_id`),
CONSTRAINT `auto_act_rule_fk` FOREIGN KEY (`rule_id`) REFERENCES `automation_rules` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,132 @@
(function() {
'use strict';
var conditionsContainer = document.getElementById('js-conditions-container');
var actionsContainer = document.getElementById('js-actions-container');
var addConditionBtn = document.getElementById('js-add-condition');
var addActionBtn = document.getElementById('js-add-action');
var data = window.AutomationFormData || { integrations: [], emailTemplates: [], recipientLabels: {} };
function getNextIndex(container) {
var rows = container.querySelectorAll('.automation-row');
var maxIdx = -1;
rows.forEach(function(row) {
var idx = parseInt(row.getAttribute('data-index') || '0', 10);
if (idx > maxIdx) maxIdx = idx;
});
return maxIdx + 1;
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function buildIntegrationCheckboxes(namePrefix) {
var html = '<div class="checkbox-group">';
data.integrations.forEach(function(integ) {
html += '<label class="checkbox-label">'
+ '<input type="checkbox" name="' + namePrefix + '[integration_ids][]" value="' + integ.id + '"> '
+ escapeHtml(integ.name) + ' <span class="muted">(' + escapeHtml(integ.type) + ')</span>'
+ '</label>';
});
html += '</div>';
return html;
}
function buildEmailActionConfig(namePrefix) {
var html = '<select class="form-control" name="' + namePrefix + '[template_id]">'
+ '<option value="">-- Wybierz szablon --</option>';
data.emailTemplates.forEach(function(tpl) {
html += '<option value="' + tpl.id + '">' + escapeHtml(tpl.name) + '</option>';
});
html += '</select>';
html += '<select class="form-control" name="' + namePrefix + '[recipient]">';
Object.keys(data.recipientLabels).forEach(function(key) {
html += '<option value="' + escapeHtml(key) + '">' + escapeHtml(data.recipientLabels[key]) + '</option>';
});
html += '</select>';
return html;
}
function addCondition() {
var idx = getNextIndex(conditionsContainer);
var namePrefix = 'conditions[' + idx + ']';
var row = document.createElement('div');
row.className = 'automation-row mt-8';
row.setAttribute('data-index', idx);
row.innerHTML = '<div class="automation-row__fields">'
+ '<select class="form-control automation-row__type" name="' + namePrefix + '[type]" onchange="window.AutomationForm.onConditionTypeChange(this)">'
+ '<option value="integration" selected>Integracja (kanal sprzedazy)</option>'
+ '</select>'
+ '<div class="automation-row__config">'
+ buildIntegrationCheckboxes(namePrefix)
+ '</div>'
+ '</div>'
+ '<button type="button" class="btn btn--sm btn--danger automation-row__remove" onclick="window.AutomationForm.removeRow(this)">&times;</button>';
conditionsContainer.appendChild(row);
}
function addAction() {
var idx = getNextIndex(actionsContainer);
var namePrefix = 'actions[' + idx + ']';
var row = document.createElement('div');
row.className = 'automation-row mt-8';
row.setAttribute('data-index', idx);
row.innerHTML = '<div class="automation-row__fields">'
+ '<select class="form-control automation-row__type" name="' + namePrefix + '[type]" onchange="window.AutomationForm.onActionTypeChange(this)">'
+ '<option value="send_email" selected>Wyslij e-mail</option>'
+ '</select>'
+ '<div class="automation-row__config">'
+ buildEmailActionConfig(namePrefix)
+ '</div>'
+ '</div>'
+ '<button type="button" class="btn btn--sm btn--danger automation-row__remove" onclick="window.AutomationForm.removeRow(this)">&times;</button>';
actionsContainer.appendChild(row);
}
function removeRow(btn) {
var row = btn.closest('.automation-row');
if (row) row.remove();
}
function onConditionTypeChange(select) {
var row = select.closest('.automation-row');
var configDiv = row.querySelector('.automation-row__config');
var idx = row.getAttribute('data-index');
var namePrefix = 'conditions[' + idx + ']';
if (select.value === 'integration') {
configDiv.innerHTML = buildIntegrationCheckboxes(namePrefix);
}
}
function onActionTypeChange(select) {
var row = select.closest('.automation-row');
var configDiv = row.querySelector('.automation-row__config');
var idx = row.getAttribute('data-index');
var namePrefix = 'actions[' + idx + ']';
if (select.value === 'send_email') {
configDiv.innerHTML = buildEmailActionConfig(namePrefix);
}
}
if (addConditionBtn) addConditionBtn.addEventListener('click', addCondition);
if (addActionBtn) addActionBtn.addEventListener('click', addAction);
window.AutomationForm = {
removeRow: removeRow,
onConditionTypeChange: onConditionTypeChange,
onActionTypeChange: onActionTypeChange
};
})();

View File

@@ -1,5 +1,6 @@
@use "shared/ui-components";
@use "modules/email-send";
@use "modules/automation";
* {
box-sizing: border-box;

View File

@@ -0,0 +1,65 @@
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.automation-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
background: var(--c-surface, #f8f9fa);
border: 1px solid var(--c-border, #dee2e6);
border-radius: 6px;
&__fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
&__type {
max-width: 280px;
}
&__config {
display: flex;
flex-wrap: wrap;
gap: 8px;
.form-control {
min-width: 200px;
max-width: 300px;
}
}
&__remove {
flex-shrink: 0;
margin-top: 2px;
line-height: 1;
font-size: 16px;
padding: 2px 8px;
}
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
cursor: pointer;
white-space: nowrap;
input[type="checkbox"] {
margin: 0;
}
}

View File

@@ -0,0 +1,139 @@
<?php
$rule = is_array($rule ?? null) ? $rule : null;
$isEdit = $rule !== null;
$integrations = is_array($integrations ?? null) ? $integrations : [];
$emailTemplates = is_array($emailTemplates ?? null) ? $emailTemplates : [];
$conditions = $isEdit ? (is_array($rule['conditions'] ?? null) ? $rule['conditions'] : []) : [];
$actions = $isEdit ? (is_array($rule['actions'] ?? null) ? $rule['actions'] : []) : [];
$eventLabels = [
'receipt.created' => 'Utworzono paragon',
];
$recipientLabels = [
'client' => 'Klient',
'client_and_company' => 'Klient + e-mail z danych firmy',
'company' => 'E-mail z danych firmy',
];
?>
<section class="card">
<h2 class="section-title"><?= $isEdit ? 'Edytuj zadanie automatyczne' : 'Nowe zadanie automatyczne' ?></h2>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<form action="<?= $isEdit ? '/settings/automation/update' : '/settings/automation/store' ?>" method="post" novalidate class="mt-12" id="js-automation-form">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int) ($rule['id'] ?? 0) ?>">
<?php endif; ?>
<div class="form-grid-2">
<label class="form-field">
<span class="field-label">Nazwa zadania *</span>
<input class="form-control" type="text" name="name" maxlength="128" required value="<?= $e((string) ($rule['name'] ?? '')) ?>" placeholder="np. Paragon Allegro - wyslij e-mail">
</label>
<label class="form-field">
<span class="field-label">Zdarzenie *</span>
<select class="form-control" name="event_type">
<?php foreach ($eventLabels as $key => $label): ?>
<option value="<?= $e($key) ?>"<?= ((string) ($rule['event_type'] ?? 'receipt.created')) === $key ? ' selected' : '' ?>><?= $e($label) ?></option>
<?php endforeach; ?>
</select>
</label>
</div>
<div class="form-grid-2 mt-0">
<label class="form-field" style="display:flex;align-items:center;gap:6px;flex-direction:row">
<input type="checkbox" name="is_active" value="1"<?= $isEdit ? (((int) ($rule['is_active'] ?? 0)) === 1 ? ' checked' : '') : ' checked' ?>>
<span class="field-label" style="margin:0">Aktywne</span>
</label>
</div>
<h3 class="section-title mt-16">Warunki</h3>
<p class="muted">Wszystkie warunki musza byc spelnione (AND).</p>
<div id="js-conditions-container">
<?php if (count($conditions) > 0): ?>
<?php foreach ($conditions as $idx => $cond): ?>
<div class="automation-row mt-8" data-index="<?= $idx ?>">
<div class="automation-row__fields">
<select class="form-control automation-row__type" name="conditions[<?= $idx ?>][type]" onchange="window.AutomationForm.onConditionTypeChange(this)">
<option value="integration"<?= ((string) ($cond['condition_type'] ?? '')) === 'integration' ? ' selected' : '' ?>>Integracja (kanal sprzedazy)</option>
</select>
<div class="automation-row__config">
<?php
$condValue = is_array($cond['condition_value'] ?? null) ? $cond['condition_value'] : [];
$selectedIds = is_array($condValue['integration_ids'] ?? null) ? $condValue['integration_ids'] : [];
?>
<div class="checkbox-group">
<?php foreach ($integrations as $integ): ?>
<label class="checkbox-label">
<input type="checkbox" name="conditions[<?= $idx ?>][integration_ids][]" value="<?= (int) $integ['id'] ?>"<?= in_array((int) $integ['id'], $selectedIds, true) ? ' checked' : '' ?>>
<?= $e((string) ($integ['name'] ?? '')) ?> <span class="muted">(<?= $e((string) ($integ['type'] ?? '')) ?>)</span>
</label>
<?php endforeach; ?>
</div>
</div>
</div>
<button type="button" class="btn btn--sm btn--danger automation-row__remove" onclick="window.AutomationForm.removeRow(this)">&times;</button>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<button type="button" class="btn btn--sm btn--secondary mt-8" id="js-add-condition">+ Dodaj warunek</button>
<h3 class="section-title mt-16">Akcje</h3>
<p class="muted">Wszystkie akcje zostana wykonane po kolei.</p>
<div id="js-actions-container">
<?php if (count($actions) > 0): ?>
<?php foreach ($actions as $idx => $act): ?>
<div class="automation-row mt-8" data-index="<?= $idx ?>">
<div class="automation-row__fields">
<select class="form-control automation-row__type" name="actions[<?= $idx ?>][type]" onchange="window.AutomationForm.onActionTypeChange(this)">
<option value="send_email"<?= ((string) ($act['action_type'] ?? '')) === 'send_email' ? ' selected' : '' ?>>Wyslij e-mail</option>
</select>
<div class="automation-row__config">
<?php $actConfig = is_array($act['action_config'] ?? null) ? $act['action_config'] : []; ?>
<select class="form-control" name="actions[<?= $idx ?>][template_id]">
<option value="">-- Wybierz szablon --</option>
<?php foreach ($emailTemplates as $tpl): ?>
<option value="<?= (int) $tpl['id'] ?>"<?= ((int) ($actConfig['template_id'] ?? 0)) === (int) $tpl['id'] ? ' selected' : '' ?>><?= $e((string) ($tpl['name'] ?? '')) ?></option>
<?php endforeach; ?>
</select>
<select class="form-control" name="actions[<?= $idx ?>][recipient]">
<?php foreach ($recipientLabels as $key => $label): ?>
<option value="<?= $e($key) ?>"<?= ((string) ($actConfig['recipient'] ?? '')) === $key ? ' selected' : '' ?>><?= $e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<button type="button" class="btn btn--sm btn--danger automation-row__remove" onclick="window.AutomationForm.removeRow(this)">&times;</button>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<button type="button" class="btn btn--sm btn--secondary mt-8" id="js-add-action">+ Dodaj akcje</button>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"><?= $isEdit ? 'Zapisz zmiany' : 'Utworz zadanie' ?></button>
<a href="/settings/automation" class="btn btn--secondary">Anuluj</a>
</div>
</form>
</section>
<script>
window.AutomationFormData = {
integrations: <?= json_encode(array_map(function($i) {
return ['id' => (int) $i['id'], 'name' => (string) ($i['name'] ?? ''), 'type' => (string) ($i['type'] ?? '')];
}, $integrations), JSON_UNESCAPED_UNICODE) ?>,
emailTemplates: <?= json_encode(array_map(function($t) {
return ['id' => (int) $t['id'], 'name' => (string) ($t['name'] ?? '')];
}, $emailTemplates), JSON_UNESCAPED_UNICODE) ?>,
recipientLabels: <?= json_encode($recipientLabels, JSON_UNESCAPED_UNICODE) ?>
};
</script>
<script src="/assets/js/modules/automation-form.js"></script>

View File

@@ -0,0 +1,96 @@
<?php
$rules = is_array($rules ?? null) ? $rules : [];
$eventLabels = [
'receipt.created' => 'Utworzono paragon',
];
?>
<section class="card">
<div class="section-header">
<h2 class="section-title">Zadania automatyczne</h2>
<a href="/settings/automation/create" class="btn btn--primary btn--sm">Dodaj zadanie</a>
</div>
<p class="muted mt-12">Regu&#322;y automatyzacji wykonywane po wyst&#261;pieniu zdarzenia.</p>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
<?php endif; ?>
</section>
<section class="card mt-16">
<?php if (count($rules) === 0): ?>
<p class="muted mt-12">Brak zadan automatycznych. Kliknij &ldquo;Dodaj zadanie&rdquo; aby utworzyc pierwsza regule.</p>
<?php else: ?>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>Nazwa</th>
<th>Zdarzenie</th>
<th>Warunkow</th>
<th>Akcji</th>
<th>Status</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
<?php foreach ($rules as $rule): ?>
<tr>
<td><?= $e((string) ($rule['name'] ?? '')) ?></td>
<td><?= $e($eventLabels[(string) ($rule['event_type'] ?? '')] ?? (string) ($rule['event_type'] ?? '')) ?></td>
<td><?= (int) ($rule['conditions_count'] ?? 0) ?></td>
<td><?= (int) ($rule['actions_count'] ?? 0) ?></td>
<td>
<?php if (((int) ($rule['is_active'] ?? 0)) === 1): ?>
<span class="badge badge--success">Aktywne</span>
<?php else: ?>
<span class="badge badge--muted">Nieaktywne</span>
<?php endif; ?>
</td>
<td style="white-space:nowrap">
<a href="/settings/automation/edit?id=<?= (int) ($rule['id'] ?? 0) ?>" class="btn btn--sm btn--secondary">Edytuj</a>
<form action="/settings/automation/toggle" method="post" style="display:inline">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="id" value="<?= (int) ($rule['id'] ?? 0) ?>">
<button type="submit" class="btn btn--sm btn--secondary">
<?= ((int) ($rule['is_active'] ?? 0)) === 1 ? 'Dezaktywuj' : 'Aktywuj' ?>
</button>
</form>
<form action="/settings/automation/delete" method="post" style="display:inline" class="js-confirm-delete">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="id" value="<?= (int) ($rule['id'] ?? 0) ?>">
<button type="button" class="btn btn--sm btn--danger js-delete-btn">Usun</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.js-delete-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var form = this.closest('form');
if (window.OrderProAlerts && window.OrderProAlerts.confirm) {
window.OrderProAlerts.confirm(
'Usuwanie zadania',
'Czy na pewno chcesz usunac to zadanie automatyczne?',
function() { form.submit(); }
);
} else {
if (confirm('Czy na pewno chcesz usunac to zadanie automatyczne?')) {
form.submit();
}
}
});
});
});
</script>

View File

@@ -101,6 +101,9 @@
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'email-templates' ? ' is-active' : '' ?>" href="/settings/email-templates">
Szablony e-mail
</a>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'automation' ? ' is-active' : '' ?>" href="/settings/automation">
Zadania automatyczne
</a>
</div>
</details>
</nav>

View File

@@ -47,6 +47,9 @@ use App\Modules\Email\VariableResolver;
use App\Modules\Accounting\AccountingController;
use App\Modules\Accounting\ReceiptController;
use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Automation\AutomationController;
use App\Modules\Automation\AutomationRepository;
use App\Modules\Automation\AutomationService;
use App\Modules\Settings\CronSettingsController;
use App\Modules\Settings\SettingsController;
use App\Modules\Shipments\ApaczkaShipmentService;
@@ -209,6 +212,13 @@ return static function (Application $app): void {
$emailTemplateRepository,
$emailMailboxRepository
);
$automationRepository = new AutomationRepository($app->db());
$automationController = new AutomationController(
$template,
$translator,
$auth,
$automationRepository
);
$variableResolver = new VariableResolver();
$attachmentGenerator = new AttachmentGenerator($receiptRepository, $receiptConfigRepository, $template);
$emailSendingService = new EmailSendingService(
@@ -219,6 +229,12 @@ return static function (Application $app): void {
$variableResolver,
$attachmentGenerator
);
$automationService = new AutomationService(
$automationRepository,
$emailSendingService,
new OrdersRepository($app->db()),
$companySettingsRepository
);
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository);
$receiptController = new ReceiptController(
$template,
@@ -227,7 +243,8 @@ return static function (Application $app): void {
$receiptRepository,
$receiptConfigRepository,
$companySettingsRepository,
new OrdersRepository($app->db())
new OrdersRepository($app->db()),
$automationService
);
$accountingController = new AccountingController(
$template,
@@ -356,6 +373,13 @@ return static function (Application $app): void {
$router->post('/settings/email-templates/toggle', [$emailTemplateController, 'toggleStatus'], [$authMiddleware]);
$router->post('/settings/email-templates/preview', [$emailTemplateController, 'preview'], [$authMiddleware]);
$router->get('/settings/email-templates/variables', [$emailTemplateController, 'getVariables'], [$authMiddleware]);
$router->get('/settings/automation', [$automationController, 'index'], [$authMiddleware]);
$router->get('/settings/automation/create', [$automationController, 'create'], [$authMiddleware]);
$router->post('/settings/automation/store', [$automationController, 'store'], [$authMiddleware]);
$router->get('/settings/automation/edit', [$automationController, 'edit'], [$authMiddleware]);
$router->post('/settings/automation/update', [$automationController, 'update'], [$authMiddleware]);
$router->post('/settings/automation/delete', [$automationController, 'destroy'], [$authMiddleware]);
$router->post('/settings/automation/toggle', [$automationController, 'toggleStatus'], [$authMiddleware]);
$router->get('/accounting', [$accountingController, 'index'], [$authMiddleware]);
$router->post('/accounting/export', [$accountingController, 'export'], [$authMiddleware]);
$router->get('/orders/{id}/receipt/create', [$receiptController, 'create'], [$authMiddleware]);

View File

@@ -10,6 +10,7 @@ use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Automation\AutomationService;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\ReceiptConfigRepository;
@@ -24,7 +25,8 @@ final class ReceiptController
private readonly ReceiptRepository $receipts,
private readonly ReceiptConfigRepository $receiptConfigs,
private readonly CompanySettingsRepository $companySettings,
private readonly OrdersRepository $orders
private readonly OrdersRepository $orders,
private readonly AutomationService $automation
) {
}
@@ -189,6 +191,12 @@ final class ReceiptController
);
Flash::set('order.success', 'Paragon wystawiony: ' . $receiptNumber);
try {
$this->automation->trigger('receipt.created', $orderId);
} catch (Throwable) {
// Blad automatyzacji nie blokuje sukcesu paragonu
}
} catch (Throwable) {
Flash::set('order.error', 'Blad wystawiania paragonu');
}

View File

@@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
namespace App\Modules\Automation;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use Throwable;
final class AutomationController
{
private const ALLOWED_EVENTS = ['receipt.created'];
private const ALLOWED_CONDITION_TYPES = ['integration'];
private const ALLOWED_ACTION_TYPES = ['send_email'];
private const ALLOWED_RECIPIENTS = ['client', 'client_and_company', 'company'];
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly AutomationRepository $repository
) {
}
public function index(Request $request): Response
{
$rules = $this->repository->findAll();
$html = $this->template->render('automation/index', [
'title' => 'Zadania automatyczne',
'activeMenu' => 'settings',
'activeSettings' => 'automation',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'rules' => $rules,
'successMessage' => Flash::get('settings.automation.success', ''),
'errorMessage' => Flash::get('settings.automation.error', ''),
], 'layouts/app');
return Response::html($html);
}
public function create(Request $request): Response
{
return $this->renderForm(null);
}
public function edit(Request $request): Response
{
$id = (int) $request->input('id', '0');
$rule = $id > 0 ? $this->repository->findById($id) : null;
if ($rule === null) {
Flash::set('settings.automation.error', 'Nie znaleziono reguly');
return Response::redirect('/settings/automation');
}
return $this->renderForm($rule);
}
public function store(Request $request): Response
{
$error = $this->validateCsrf($request);
if ($error !== null) {
return $error;
}
$validationError = $this->validateInput($request);
if ($validationError !== null) {
Flash::set('settings.automation.error', $validationError);
return Response::redirect('/settings/automation/create');
}
try {
$this->repository->create(
$this->extractRuleData($request),
$this->extractConditions($request),
$this->extractActions($request)
);
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo utworzone');
} catch (Throwable) {
Flash::set('settings.automation.error', 'Blad zapisu zadania automatycznego');
}
return Response::redirect('/settings/automation');
}
public function update(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', 'Nieprawidlowy identyfikator');
return Response::redirect('/settings/automation');
}
$validationError = $this->validateInput($request);
if ($validationError !== null) {
Flash::set('settings.automation.error', $validationError);
return Response::redirect('/settings/automation/edit?id=' . $id);
}
try {
$this->repository->update(
$id,
$this->extractRuleData($request),
$this->extractConditions($request),
$this->extractActions($request)
);
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo zaktualizowane');
} catch (Throwable) {
Flash::set('settings.automation.error', 'Blad aktualizacji zadania automatycznego');
}
return Response::redirect('/settings/automation');
}
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', 'Nieprawidlowy identyfikator');
return Response::redirect('/settings/automation');
}
try {
$this->repository->delete($id);
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo usuniete');
} catch (Throwable) {
Flash::set('settings.automation.error', 'Blad usuwania zadania automatycznego');
}
return Response::redirect('/settings/automation');
}
public function toggleStatus(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', 'Nieprawidlowy identyfikator');
return Response::redirect('/settings/automation');
}
try {
$this->repository->toggleActive($id);
Flash::set('settings.automation.success', 'Status zadania zostal zmieniony');
} catch (Throwable) {
Flash::set('settings.automation.error', 'Blad zmiany statusu');
}
return Response::redirect('/settings/automation');
}
private function renderForm(?array $rule): Response
{
$html = $this->template->render('automation/form', [
'title' => $rule !== null ? '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,
'errorMessage' => Flash::get('settings.automation.error', ''),
], 'layouts/app');
return Response::html($html);
}
private function validateCsrf(Request $request): ?Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.automation.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/automation');
}
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 znakow)';
}
$eventType = (string) $request->input('event_type', '');
if (!in_array($eventType, self::ALLOWED_EVENTS, true)) {
return 'Nieprawidlowy 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;
}
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'] ?? '');
if ($templateId <= 0 || !in_array($recipient, self::ALLOWED_RECIPIENTS, true)) {
return null;
}
return ['template_id' => $templateId, 'recipient' => $recipient];
}
return null;
}
}

View File

@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace App\Modules\Automation;
use PDO;
use Throwable;
final class AutomationRepository
{
public function __construct(
private readonly PDO $pdo
) {
}
/**
* @return list<array<string, mixed>>
*/
public function findAll(): array
{
$sql = '
SELECT r.id, r.name, r.event_type, r.is_active, r.created_at, r.updated_at,
(SELECT COUNT(*) FROM automation_conditions WHERE rule_id = r.id) AS conditions_count,
(SELECT COUNT(*) FROM automation_actions WHERE rule_id = r.id) AS actions_count
FROM automation_rules r
ORDER BY r.created_at DESC
';
$statement = $this->pdo->prepare($sql);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<string, mixed>|null
*/
public function findById(int $id): ?array
{
$statement = $this->pdo->prepare('SELECT * FROM automation_rules WHERE id = :id LIMIT 1');
$statement->execute(['id' => $id]);
$rule = $statement->fetch(PDO::FETCH_ASSOC);
if (!is_array($rule)) {
return null;
}
$rule['conditions'] = $this->loadConditions($id);
$rule['actions'] = $this->loadActions($id);
return $rule;
}
/**
* @param array<string, mixed> $data
* @param list<array<string, mixed>> $conditions
* @param list<array<string, mixed>> $actions
*/
public function create(array $data, array $conditions, array $actions): int
{
$this->pdo->beginTransaction();
try {
$statement = $this->pdo->prepare(
'INSERT INTO automation_rules (name, event_type, is_active)
VALUES (:name, :event_type, :is_active)'
);
$statement->execute([
'name' => $data['name'],
'event_type' => $data['event_type'],
'is_active' => (int) ($data['is_active'] ?? 1),
]);
$ruleId = (int) $this->pdo->lastInsertId();
$this->insertConditions($ruleId, $conditions);
$this->insertActions($ruleId, $actions);
$this->pdo->commit();
return $ruleId;
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
/**
* @param array<string, mixed> $data
* @param list<array<string, mixed>> $conditions
* @param list<array<string, mixed>> $actions
*/
public function update(int $id, array $data, array $conditions, array $actions): bool
{
$this->pdo->beginTransaction();
try {
$statement = $this->pdo->prepare(
'UPDATE automation_rules SET name = :name, event_type = :event_type, is_active = :is_active WHERE id = :id'
);
$statement->execute([
'id' => $id,
'name' => $data['name'],
'event_type' => $data['event_type'],
'is_active' => (int) ($data['is_active'] ?? 1),
]);
$this->pdo->prepare('DELETE FROM automation_conditions WHERE rule_id = :id')->execute(['id' => $id]);
$this->pdo->prepare('DELETE FROM automation_actions WHERE rule_id = :id')->execute(['id' => $id]);
$this->insertConditions($id, $conditions);
$this->insertActions($id, $actions);
$this->pdo->commit();
return true;
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function delete(int $id): void
{
$statement = $this->pdo->prepare('DELETE FROM automation_rules WHERE id = :id');
$statement->execute(['id' => $id]);
}
public function toggleActive(int $id): void
{
$statement = $this->pdo->prepare(
'UPDATE automation_rules SET is_active = NOT is_active WHERE id = :id'
);
$statement->execute(['id' => $id]);
}
/**
* @return list<array<string, mixed>>
*/
public function findActiveByEvent(string $eventType): array
{
$statement = $this->pdo->prepare(
'SELECT id, name, event_type FROM automation_rules WHERE event_type = :event_type AND is_active = 1'
);
$statement->execute(['event_type' => $eventType]);
$rules = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rules)) {
return [];
}
foreach ($rules as &$rule) {
$ruleId = (int) $rule['id'];
$rule['conditions'] = $this->loadConditions($ruleId);
$rule['actions'] = $this->loadActions($ruleId);
}
unset($rule);
return $rules;
}
/**
* @return list<array{id: int, type: string, name: string}>
*/
public function listOrderIntegrations(): array
{
$statement = $this->pdo->prepare(
"SELECT id, type, name FROM integrations WHERE type IN ('allegro', 'shoppro') AND is_active = 1 ORDER BY type, name"
);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return list<array{id: int, name: string}>
*/
public function listEmailTemplates(): array
{
$statement = $this->pdo->prepare(
'SELECT id, name FROM email_templates WHERE is_active = 1 ORDER BY name'
);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return list<array<string, mixed>>
*/
private function loadConditions(int $ruleId): array
{
$statement = $this->pdo->prepare(
'SELECT id, condition_type, condition_value, sort_order FROM automation_conditions WHERE rule_id = :rule_id ORDER BY sort_order'
);
$statement->execute(['rule_id' => $ruleId]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
foreach ($rows as &$row) {
$decoded = json_decode((string) ($row['condition_value'] ?? '{}'), true);
$row['condition_value'] = is_array($decoded) ? $decoded : [];
}
unset($row);
return $rows;
}
/**
* @return list<array<string, mixed>>
*/
private function loadActions(int $ruleId): array
{
$statement = $this->pdo->prepare(
'SELECT id, action_type, action_config, sort_order FROM automation_actions WHERE rule_id = :rule_id ORDER BY sort_order'
);
$statement->execute(['rule_id' => $ruleId]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
foreach ($rows as &$row) {
$decoded = json_decode((string) ($row['action_config'] ?? '{}'), true);
$row['action_config'] = is_array($decoded) ? $decoded : [];
}
unset($row);
return $rows;
}
/**
* @param list<array<string, mixed>> $conditions
*/
private function insertConditions(int $ruleId, array $conditions): void
{
$statement = $this->pdo->prepare(
'INSERT INTO automation_conditions (rule_id, condition_type, condition_value, sort_order)
VALUES (:rule_id, :condition_type, :condition_value, :sort_order)'
);
foreach ($conditions as $index => $condition) {
$statement->execute([
'rule_id' => $ruleId,
'condition_type' => $condition['type'],
'condition_value' => json_encode($condition['value'], JSON_UNESCAPED_UNICODE),
'sort_order' => $index,
]);
}
}
/**
* @param list<array<string, mixed>> $actions
*/
private function insertActions(int $ruleId, array $actions): void
{
$statement = $this->pdo->prepare(
'INSERT INTO automation_actions (rule_id, action_type, action_config, sort_order)
VALUES (:rule_id, :action_type, :action_config, :sort_order)'
);
foreach ($actions as $index => $action) {
$statement->execute([
'rule_id' => $ruleId,
'action_type' => $action['type'],
'action_config' => json_encode($action['config'], JSON_UNESCAPED_UNICODE),
'sort_order' => $index,
]);
}
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Modules\Automation;
use App\Modules\Email\EmailSendingService;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\CompanySettingsRepository;
use Throwable;
final class AutomationService
{
public function __construct(
private readonly AutomationRepository $repository,
private readonly EmailSendingService $emailService,
private readonly OrdersRepository $orders,
private readonly CompanySettingsRepository $companySettings
) {
}
public function trigger(string $eventType, int $orderId): void
{
$rules = $this->repository->findActiveByEvent($eventType);
if ($rules === []) {
return;
}
$details = $this->orders->findDetails($orderId);
if ($details === null) {
return;
}
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
foreach ($rules as $rule) {
try {
$conditions = is_array($rule['conditions'] ?? null) ? $rule['conditions'] : [];
$actions = is_array($rule['actions'] ?? null) ? $rule['actions'] : [];
$ruleName = (string) ($rule['name'] ?? '');
if ($this->evaluateConditions($conditions, $order)) {
$this->executeActions($actions, $orderId, $ruleName);
}
} catch (Throwable) {
// Blad jednej reguly nie blokuje kolejnych
}
}
}
/**
* @param list<array<string, mixed>> $conditions
* @param array<string, mixed> $order
*/
private function evaluateConditions(array $conditions, array $order): bool
{
foreach ($conditions as $condition) {
$type = (string) ($condition['condition_type'] ?? '');
$value = is_array($condition['condition_value'] ?? null) ? $condition['condition_value'] : [];
if (!$this->evaluateSingleCondition($type, $value, $order)) {
return false;
}
}
return true;
}
/**
* @param array<string, mixed> $value
* @param array<string, mixed> $order
*/
private function evaluateSingleCondition(string $type, array $value, array $order): bool
{
if ($type === 'integration') {
return $this->evaluateIntegrationCondition($value, $order);
}
return false;
}
/**
* @param array<string, mixed> $value
* @param array<string, mixed> $order
*/
private function evaluateIntegrationCondition(array $value, array $order): bool
{
$allowedIds = is_array($value['integration_ids'] ?? null) ? $value['integration_ids'] : [];
if ($allowedIds === []) {
return false;
}
$orderIntegrationId = (int) ($order['integration_id'] ?? 0);
if ($orderIntegrationId === 0) {
return false;
}
return in_array($orderIntegrationId, array_map('intval', $allowedIds), true);
}
/**
* @param list<array<string, mixed>> $actions
*/
private function executeActions(array $actions, int $orderId, string $ruleName): void
{
foreach ($actions as $action) {
$type = (string) ($action['action_type'] ?? '');
$config = is_array($action['action_config'] ?? null) ? $action['action_config'] : [];
if ($type === 'send_email') {
$this->handleSendEmail($config, $orderId, $ruleName);
}
}
}
/**
* @param array<string, mixed> $config
*/
private function handleSendEmail(array $config, int $orderId, string $ruleName): void
{
$templateId = (int) ($config['template_id'] ?? 0);
if ($templateId <= 0) {
return;
}
$recipient = (string) ($config['recipient'] ?? 'client');
$actorName = 'Automatyzacja: ' . $ruleName;
if ($recipient === 'client' || $recipient === 'client_and_company') {
$this->emailService->send($orderId, $templateId, null, $actorName);
}
if ($recipient === 'company' || $recipient === 'client_and_company') {
$this->sendToCompany($orderId, $templateId, $actorName);
}
}
private function sendToCompany(int $orderId, int $templateId, string $actorName): void
{
$settings = $this->companySettings->getSettings();
$companyEmail = trim((string) ($settings['email'] ?? ''));
if ($companyEmail === '' || filter_var($companyEmail, FILTER_VALIDATE_EMAIL) === false) {
$this->orders->recordActivity(
$orderId,
'automation_email_failed',
$actorName . ' — brak adresu e-mail firmy w ustawieniach',
['reason' => 'missing_company_email'],
'system',
$actorName
);
return;
}
$companyName = trim((string) ($settings['company_name'] ?? ''));
$this->emailService->send(
$orderId,
$templateId,
null,
$actorName,
$companyEmail,
$companyName
);
}
}

View File

@@ -28,7 +28,7 @@ final class EmailSendingService
/**
* @return array{success: bool, error: ?string, log_id: int}
*/
public function send(int $orderId, int $templateId, ?int $mailboxId = null, ?string $actorName = null): array
public function send(int $orderId, int $templateId, ?int $mailboxId = null, ?string $actorName = null, ?string $recipientEmailOverride = null, ?string $recipientNameOverride = null): array
{
$details = $this->orders->findDetails($orderId);
if ($details === null) {
@@ -48,12 +48,16 @@ final class EmailSendingService
return ['success' => false, 'error' => 'Brak skonfigurowanej skrzynki SMTP', 'log_id' => 0];
}
$recipientEmail = $this->findRecipientEmail($addresses);
$recipientEmail = $recipientEmailOverride !== null && $recipientEmailOverride !== ''
? $recipientEmailOverride
: $this->findRecipientEmail($addresses);
if ($recipientEmail === '') {
return ['success' => false, 'error' => 'Brak adresu e-mail kupujacego', 'log_id' => 0];
return ['success' => false, 'error' => 'Brak adresu e-mail odbiorcy', 'log_id' => 0];
}
$recipientName = $this->findRecipientName($addresses);
$recipientName = $recipientNameOverride !== null && $recipientNameOverride !== ''
? $recipientNameOverride
: $this->findRecipientName($addresses);
$companySettings = $this->loadCompanySettings();
$variableMap = $this->variableResolver->buildVariableMap($order, $addresses, $companySettings);