diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md
index 331466a..100a8cf 100644
--- a/.paul/ROADMAP.md
+++ b/.paul/ROADMAP.md
@@ -13,6 +13,7 @@ Started: 2026-04-22
| Phase | Name | Plans | Status |
|-------|------|-------|--------|
| 106 | Customer Return Alert | 1/1 | Complete |
+| 107 | Automation Email Send Once | 0/1 | Planning |
## Next Milestone
diff --git a/.paul/STATE.md b/.paul/STATE.md
index a46135b..63c1b62 100644
--- a/.paul/STATE.md
+++ b/.paul/STATE.md
@@ -1,50 +1,50 @@
-# Project State
+# Project State
## Project Reference
See: .paul/PROJECT.md (updated 2026-04-22)
**Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami.
-**Current focus:** v3.1 Operational Enhancements — Phase 106 complete, ready for next phase definition.
+**Current focus:** v3.1 Operational Enhancements - Phase 107 planning in progress.
## Current Position
Milestone: v3.1 Operational Enhancements
-Phase: 106 (Customer Return Alert) — Complete
-Plan: 106-01 complete (SUMMARY created)
+Phase: 107 of 107 (Automation Email Send Once) - Planning
+Plan: 107-01 created, awaiting approval
Version: 3.1.0 (in progress)
-Status: Loop complete, ready for next PLAN or milestone decision
-Last activity: 2026-04-22 — UNIFY closed Phase 106 (SUMMARY + changelog + DOCS)
+Status: PLAN created, ready for APPLY
+Last activity: 2026-04-25 17:42:05 +02:00 - Created .paul/phases/107-automation-email-send-once/107-01-PLAN.md
Progress:
-- v3.1 Operational Enhancements: [##________] ~15% (1 phase complete, scope TBD)
-- Phase 106: [##########] 100%
+- Milestone: [##________] ~15%
+- Phase 107: [__________] 0%
## Loop Position
Current loop state:
```
PLAN --> APPLY --> UNIFY
- v v v [Loop complete — ready for next PLAN or milestone]
+ [x] [ ] [ ] [Plan created, awaiting approval]
```
## Session Continuity
-Last session: 2026-04-22 — /paul:unify for plan 106-01
-Stopped at: Phase 106 Complete — SUMMARY + changelog created
-Next action: `/paul:plan` dla nastepnej fazy v3.1, lub `/paul:discuss-milestone` aby rozszerzyc zakres milestone
-Resume file: .paul/phases/106-customer-return-alert/106-01-SUMMARY.md
+Last session: 2026-04-25 17:42:05 +02:00
+Stopped at: Plan 107-01 created
+Next action: Review and approve plan, then run $paul-apply .paul/phases/107-automation-email-send-once/107-01-PLAN.md
+Resume file: .paul/phases/107-automation-email-send-once/107-01-PLAN.md
## Deferred to Next Milestones
-- Phase 68 — Code Deduplication Refactor (0/2 Planning, nigdy nie rozpoczety)
-- STAT-NET — netto shopPRO z API lub z `order_items.tax_rate` (`.paul/TODO.md`)
+- Phase 68 - Code Deduplication Refactor (0/2 Planning, nigdy nie rozpoczety)
+- STAT-NET - netto shopPRO z API lub z `order_items.tax_rate` (`.paul/TODO.md`)
- Mobile Orders List / Mobile Order Details / Mobile Settings (TBD z poprzedniego roadmapu)
-- sonar-scanner — skan dla phase 105 i phase 106 nie zostal uruchomiony w sesji UNIFY (skill gap odnotowany)
-- INDEX-106-01 — indeksy DB dla query `customer_returned_count`: `order_addresses(order_id, address_type)`, `shipment_packages(order_id, delivery_status)` (gdy dataset >50k wierszy)
+- sonar-scanner - skan dla phase 105 i phase 106 nie zostal uruchomiony w sesji UNIFY (skill gap odnotowany)
+- INDEX-106-01 - indeksy DB dla query `customer_returned_count`: `order_addresses(order_id, address_type)`, `shipment_packages(order_id, delivery_status)` (gdy dataset >50k wierszy)
## Skill Audit (Phase 106)
| Expected | Invoked | Notes |
|----------|---------|-------|
-| sonar-scanner (required) | ○ | Nie uruchomiony — odlozony analogicznie do Phase 105 |
+| sonar-scanner (required) | o | Nie uruchomiony - odlozony analogicznie do Phase 105 |
diff --git a/.paul/docs/ARCHITECTURE.md b/.paul/docs/ARCHITECTURE.md
index 849ea33..2b145b0 100644
--- a/.paul/docs/ARCHITECTURE.md
+++ b/.paul/docs/ARCHITECTURE.md
@@ -85,6 +85,13 @@
- sprawdza warunki,
- wykonuje akcje (`send_email`, `issue_receipt`, `update_shipment_status`, `update_order_status`),
- zapisuje wynik do `automation_execution_logs`.
+- Warunek `order_status` czyta status z kontekstu eventu:
+ - `new_status` dla eventow zmianowych (`order.status_changed`),
+ - `current_status` dla eventu czasu w statusie (`order.status_aged`).
+- Akcja `send_email` ma opcjonalna flage `send_once_per_order`:
+ - konfiguracja trzymana w `automation_actions.action_config`,
+ - deduplikacja oparta o `automation_email_once_deliveries` (`rule_id + action_id + order_id` UNIQUE),
+ - wpis "wyslano raz" zapisywany tylko po udanej wysylce (`EmailSendingService::send(...)->success = true`).
## Shipment tracking — mapowanie statusow kuriera
- `ShipmentTrackingHandler` (job `shipment_tracking_sync`) iteruje po aktywnych paczkach i pobiera status z API przewoznika (`Inpost/Apaczka/AllegroTrackingService`).
diff --git a/.paul/docs/DB_SCHEMA.md b/.paul/docs/DB_SCHEMA.md
index b51ec7a..1a4e9d7 100644
--- a/.paul/docs/DB_SCHEMA.md
+++ b/.paul/docs/DB_SCHEMA.md
@@ -2,9 +2,11 @@
## Zakres i zrodlo prawdy
- Schemat wynika z migracji SQL w `database/migrations`.
-- Dokument odzwierciedla stan repo na 2026-04-19 (migracje do `20260413_000100`).
+- Dokument odzwierciedla stan repo na 2026-04-25 (migracje do `20260425_000102`).
## Ostatnie istotne migracje
+- `20260425_000102_create_automation_email_once_deliveries_table.sql`
+- `20260422_000101_backfill_delivery_status_unknowns.sql`
- `20260413_000100_ensure_orders_delivery_payment_columns.sql`
- `20260412_000099_add_requires_photo_to_project_mappings.sql`
- `20260412_000098_rename_external_status_id_to_status_code.sql`
@@ -131,6 +133,15 @@
### automation_rules, automation_conditions, automation_actions, automation_execution_logs
- Definicje regul i historia ich wykonan.
+### automation_email_once_deliveries
+- Rejestr jednorazowych wysylek e-mail dla akcji automatyzacji (`send_once_per_order`).
+- Klucz unikalny:
+- `(rule_id, action_id, order_id)` - gwarancja, ze ta sama akcja e-mail reguly nie zostanie oznaczona drugi raz dla tego samego zamowienia.
+- Relacje:
+- `rule_id -> automation_rules.id` (CASCADE),
+- `action_id -> automation_actions.id` (CASCADE),
+- `order_id -> orders.id` (CASCADE).
+
### shipment_packages
- Rekordy przesylek i etykiet.
- Wazne kolumny trackingowe (od `000060`):
diff --git a/.paul/docs/TECH_CHANGELOG.md b/.paul/docs/TECH_CHANGELOG.md
index 028256f..eade7e5 100644
--- a/.paul/docs/TECH_CHANGELOG.md
+++ b/.paul/docs/TECH_CHANGELOG.md
@@ -1,63 +1,105 @@
# TECH_CHANGELOG
-> Chronologiczny log zmian technicznych — co i dlaczego.
+> Chronologiczny log zmian technicznych — co i dlaczego.
+## 2026-04-25 - Fix: order_status condition for order.status_aged
+
+Powod: reguly order.status_aged z warunkiem order_status nie wykonywaly akcji, bo warunek sprawdzal tylko context.new_status, a ten event przekazuje context.current_status.
+
+Zmiany:
+- src/Modules/Automation/AutomationService.php
+ - evaluateOrderStatusCondition: fallback z new_status na current_status.
+- tests/Unit/AutomationServiceTest.php
+ - nowy test regresyjny: order.status_aged + current_status spelnia warunek order_status i wykonuje akcje.
+
+Efekt:
+- reguly order.status_aged z warunkiem statusu zamowienia dzialaja poprawnie,
+- eventy zmianowe order.status_changed nadal korzystaja z new_status bez regresji.
+## 2026-04-25 - Automatyzacja: jednorazowa wysylka e-mail per zamowienie (Phase 107)
+
+**Powod**: event `order.status_aged` jest cykliczny, przez co ta sama regula mogla wysylac klientowi ten sam e-mail przy kazdym przebiegu crona. Potrzebna byla kontrola "wyslij tylko raz dla tego zamowienia".
+
+**Zmiany**:
+- `database/migrations/20260425_000102_create_automation_email_once_deliveries_table.sql`:
+ - nowa tabela `automation_email_once_deliveries` z FK do `automation_rules`, `automation_actions`, `orders`;
+ - `UNIQUE (rule_id, action_id, order_id)` - twarda deduplikacja.
+- `src/Modules/Automation/AutomationEmailOnceRepository.php` (nowy):
+ - `wasSent(ruleId, actionId, orderId)` - sprawdzenie czy akcja e-mail byla juz wykonana jednorazowo;
+ - `markSent(ruleId, actionId, orderId)` - zapis idempotentny (`INSERT ... ON DUPLICATE KEY UPDATE`).
+- `src/Modules/Automation/AutomationController.php`:
+ - `parseActionConfig(send_email)` rozszerzone o `send_once_per_order` (0/1), domyslnie `0`.
+- `resources/views/automation/form.php` i `public/assets/js/modules/automation-form.js`:
+ - dodany checkbox w akcji `Wyslij e-mail`: "Wyslij tylko raz dla tego zamowienia".
+- `src/Modules/Automation/AutomationService.php`:
+ - akcja `send_email` uwzglednia `rule_id` i `action_id`;
+ - przy `send_once_per_order=1` pomija wysylke, gdy `wasSent(...) = true`;
+ - `markSent(...)` wykonywany tylko po udanej wysylce (`success=true`), wiec blad SMTP nie blokuje kolejnej proby.
+- `src/Modules/Cron/CronHandlerFactory.php` i `routes/web.php`:
+ - podpiecie nowego repozytorium do konstruktora `AutomationService`.
+- `tests/Unit/AutomationServiceTest.php` (nowy):
+ - test scenariusza jednorazowego (drugi trigger nie wysyla),
+ - test scenariusza domyslnego (bez flagi wysyla wielokrotnie).
+
+**Efekt**:
+- Operator moze zaznaczyc jednorazowosc na poziomie konkretnej akcji e-mail.
+- Dla jednego zamowienia i jednej akcji reguly mail nie duplikuje sie przy kolejnych uruchomieniach crona.
+- Zachowanie domyslne pozostaje bez zmian dla istniejacych regul bez zaznaczonej opcji.
## 2026-04-22 - Alert klienta z historia zwrotow (Phase 106)
-**Powod**: Operator wysylkowy nie widzial wczesniej, ze kupujacy juz raz nie odebral przesylki (`delivery_status='returned'`) zanim wyslal kolejna paczke — generowalo to kolejne koszty wysylki i magazynowania.
+**Powod**: Operator wysylkowy nie widzial wczesniej, ze kupujacy juz raz nie odebral przesylki (`delivery_status='returned'`) zanim wyslal kolejna paczke — generowalo to kolejne koszty wysylki i magazynowania.
**Zmiany**:
- `src/Modules/Orders/OrdersRepository.php`:
- - nowa metoda prywatna `customerReturnedCountSubquerySql(orderAlias, addressAlias)` — generuje correlated subquery zliczajaca inne zamowienia klienta biezacego wiersza z paczka `returned`.
- - `buildListSql()` — dodana kolumna `customer_returned_count` (EXISTS-style COUNT DISTINCT) do SELECT listy zamowien.
- - `transformOrderRow()` — przekazuje `customer_returned_count` do wiersza.
- - `findDetails()` — JOIN `order_addresses` typu customer + subquery `customer_returned_count`; zwraca rowniez `buyer_email`, `buyer_phone`, `buyer_name` w `$order`.
+ - nowa metoda prywatna `customerReturnedCountSubquerySql(orderAlias, addressAlias)` — generuje correlated subquery zliczajaca inne zamowienia klienta biezacego wiersza z paczka `returned`.
+ - `buildListSql()` — dodana kolumna `customer_returned_count` (EXISTS-style COUNT DISTINCT) do SELECT listy zamowien.
+ - `transformOrderRow()` — przekazuje `customer_returned_count` do wiersza.
+ - `findDetails()` — JOIN `order_addresses` typu customer + subquery `customer_returned_count`; zwraca rowniez `buyer_email`, `buyer_phone`, `buyer_name` w `$order`.
- `src/Modules/Shipments/ShipmentPackageRepository.php`:
- - nowa metoda `findReturnedByCustomer(array customer, int excludeOrderId, int limit=10)` — lista zwroconych paczek klienta (match OR: email lower+trim, phone tylko cyfry >=6, name lower+trim), sortowana po dacie malejaco.
+ - nowa metoda `findReturnedByCustomer(array customer, int excludeOrderId, int limit=10)` — lista zwroconych paczek klienta (match OR: email lower+trim, phone tylko cyfry >=6, name lower+trim), sortowana po dacie malejaco.
- `src/Modules/Orders/OrdersController.php`:
- - `toTableRow()` — dodano badge `zwroty: N` w kolumnie buyer + klasa `is-risk-return` na `
` gdy `customer_returned_count >= 1` (kompozycja z klasa aged orders z Phase 101).
- - `show()` — oblicza `$customerRiskInfo` i przekazuje do widoku.
- - nowe metody prywatne: `buildCustomerRiskInfo(order, orderId)`, `composeCustomerRiskText(count, email, phone, name)` — budowa tresci alertu zaleznie od dostepnosci pol (phone+email / email / phone / name).
-- `resources/views/orders/show.php` — banner `customer-risk-banner` u samej gory karty szczegolow (pod naglowkiem, nad flash messages i status change), z `` rozwijajacym liste zamowien ze zwrotem (order_id, data, tracking, provider).
+ - `toTableRow()` — dodano badge `zwroty: N` w kolumnie buyer + klasa `is-risk-return` na `
` gdy `customer_returned_count >= 1` (kompozycja z klasa aged orders z Phase 101).
+ - `show()` — oblicza `$customerRiskInfo` i przekazuje do widoku.
+ - nowe metody prywatne: `buildCustomerRiskInfo(order, orderId)`, `composeCustomerRiskText(count, email, phone, name)` — budowa tresci alertu zaleznie od dostepnosci pol (phone+email / email / phone / name).
+- `resources/views/orders/show.php` — banner `customer-risk-banner` u samej gory karty szczegolow (pod naglowkiem, nad flash messages i status change), z `` rozwijajacym liste zamowien ze zwrotem (order_id, data, tracking, provider).
- `resources/scss/modules/_customer-risk-alert.scss` (nowy modul):
- - `.customer-risk-banner` + `__icon`, `__body`, `__text`, `__list`, `__table` — czerwony banner z pastelowym tlem i lewym paskiem 4px.
- - `.risk-return-badge` — maly inline badge przy buyer name.
- - `tr.is-risk-return` — lewy pasek wiersza w tabeli zamowien.
-- `resources/scss/app.scss` — `@use "modules/customer-risk-alert"`.
-- `public/assets/css/app.css` — rebuild przez `npm run build:css`.
+ - `.customer-risk-banner` + `__icon`, `__body`, `__text`, `__list`, `__table` — czerwony banner z pastelowym tlem i lewym paskiem 4px.
+ - `.risk-return-badge` — maly inline badge przy buyer name.
+ - `tr.is-risk-return` — lewy pasek wiersza w tabeli zamowien.
+- `resources/scss/app.scss` — `@use "modules/customer-risk-alert"`.
+- `public/assets/css/app.css` — rebuild przez `npm run build:css`.
**Wymagania**:
- MySQL 8.0+ (REGEXP_REPLACE w subquery matching phone).
-- Wynik licznika wyliczany on-the-fly (brak migracji DB, brak materializacji). Indeksy na `order_addresses(order_id, address_type)` i `shipment_packages(order_id, delivery_status)` sugerowane jesli lista zamowien przekroczy ~50k wierszy — zglosic w kolejnym planie.
+- Wynik licznika wyliczany on-the-fly (brak migracji DB, brak materializacji). Indeksy na `order_addresses(order_id, address_type)` i `shipment_packages(order_id, delivery_status)` sugerowane jesli lista zamowien przekroczy ~50k wierszy — zglosic w kolejnym planie.
**Anti-fraud/false positive**:
-- Self-exclusion: `sp.order_id != o.id` — biezace zamowienie nie wlicza sie do licznika.
-- Minimum phone length 6 cyfr — eliminuje match na "", "+48", krotkich fragmentach.
-- OR matching po email/phone/name moze dac fałszywe pozytywy dla popularnych imion; swiadome odstepstwo (user wymagal szerokiego matchingu).
+- Self-exclusion: `sp.order_id != o.id` — biezace zamowienie nie wlicza sie do licznika.
+- Minimum phone length 6 cyfr — eliminuje match na "", "+48", krotkich fragmentach.
+- OR matching po email/phone/name moze dac fałszywe pozytywy dla popularnych imion; swiadome odstepstwo (user wymagal szerokiego matchingu).
## 2026-04-22 - Mapowanie statusow dostawy: wykrywanie niezmapowanych + runtime overrides
-**Powod**: zamowienia (np. OP000000357, OP000000638) pokazywaly `delivery_status=unknown`, mimo ze apaczka API zwracala `RETURNED_TO_SHIPPER`. UI `/settings/delivery-status-mappings` pokazywalo wylacznie statusy obecne w defaultach kodu — nowe raw statusy kuriera nie mialy gdzie zostac przypisane bez zmiany kodu.
+**Powod**: zamowienia (np. OP000000357, OP000000638) pokazywaly `delivery_status=unknown`, mimo ze apaczka API zwracala `RETURNED_TO_SHIPPER`. UI `/settings/delivery-status-mappings` pokazywalo wylacznie statusy obecne w defaultach kodu — nowe raw statusy kuriera nie mialy gdzie zostac przypisane bez zmiany kodu.
-**Faza A — quick fix (defaulty + backfill)**:
+**Faza A — quick fix (defaulty + backfill)**:
- `src/Modules/Shipments/DeliveryStatus.php`: dodano 3 brakujace mapowania:
- apaczka `RETURNED_TO_SHIPPER` -> `returned`
- apaczka `PICKUP` -> `in_transit`
- allegro_wza `collected_from_sender` -> `in_transit`
- `database/migrations/20260422_000101_backfill_delivery_status_unknowns.sql`: backfill 11 paczek (3 + 7 + 1). Bez trigger `shipment.status_changed` (backfill starych rekordow, nie runtime event).
-**Faza B — rozwiazanie systemowe**:
-- `DeliveryStatusMappingRepository::listUnmappedRawStatuses(provider, knownKeys)` — zwraca raw statusy z `shipment_packages` ktore nie wystepuja w defaultach ani overrides DB; z licznikiem paczek i ostatnim wystapieniem.
-- `DeliveryStatusMappingController::index` — przekazuje `unmappedRawStatuses` do widoku; rowniez wlacza do listy overrides, ktore nie maja odpowiadajacego defaultu (user moze dodac completely custom raw statusy).
-- `resources/views/settings/delivery-status-mappings.php` — nowa sekcja „Niezmapowane statusy wykryte w systemie (N)" z form submit do `save-bulk`. Pomaranczowy akcent aby wyroznic od defaultow.
-- `ShipmentTrackingHandler` — dostal `?DeliveryStatusMappingRepository` w konstruktorze; po kazdym `service->getDeliveryStatus()` wywoluje `DeliveryStatus::normalizeWithOverrides(provider, raw, overrides)` jesli overrides istnieja. Dzieki temu override z UI dziala runtime bez zmian kodu.
-- `CronHandlerFactory` — przekazuje `DeliveryStatusMappingRepository` do `ShipmentTrackingHandler`.
+**Faza B — rozwiazanie systemowe**:
+- `DeliveryStatusMappingRepository::listUnmappedRawStatuses(provider, knownKeys)` — zwraca raw statusy z `shipment_packages` ktore nie wystepuja w defaultach ani overrides DB; z licznikiem paczek i ostatnim wystapieniem.
+- `DeliveryStatusMappingController::index` — przekazuje `unmappedRawStatuses` do widoku; rowniez wlacza do listy overrides, ktore nie maja odpowiadajacego defaultu (user moze dodac completely custom raw statusy).
+- `resources/views/settings/delivery-status-mappings.php` — nowa sekcja „Niezmapowane statusy wykryte w systemie (N)" z form submit do `save-bulk`. Pomaranczowy akcent aby wyroznic od defaultow.
+- `ShipmentTrackingHandler` — dostal `?DeliveryStatusMappingRepository` w konstruktorze; po kazdym `service->getDeliveryStatus()` wywoluje `DeliveryStatus::normalizeWithOverrides(provider, raw, overrides)` jesli overrides istnieja. Dzieki temu override z UI dziala runtime bez zmian kodu.
+- `CronHandlerFactory` — przekazuje `DeliveryStatusMappingRepository` do `ShipmentTrackingHandler`.
-**Faza C — badge w menu**:
-- `Application` — dodano statyczny holder `self::$instance` + `Application::instance()`, aby layout mial dostep do kontenera.
-- `DeliveryStatusMappingRepository::countAllUnmappedForBadge()` — zlicza niezmapowane raw statusy dla wszystkich providerow UI (inpost, apaczka, allegro_wza); cache per-request.
-- `resources/views/layouts/app.php` — badge pomaranczowy przy linku „Mapowanie statusow dostawy" z liczba niezmapowanych statusow (jesli > 0). Try/catch — brak badge'a nie psuje layoutu.
-- `resources/scss/app.scss` — klasa `.sidebar__badge`.
+**Faza C — badge w menu**:
+- `Application` — dodano statyczny holder `self::$instance` + `Application::instance()`, aby layout mial dostep do kontenera.
+- `DeliveryStatusMappingRepository::countAllUnmappedForBadge()` — zlicza niezmapowane raw statusy dla wszystkich providerow UI (inpost, apaczka, allegro_wza); cache per-request.
+- `resources/views/layouts/app.php` — badge pomaranczowy przy linku „Mapowanie statusow dostawy" z liczba niezmapowanych statusow (jesli > 0). Try/catch — brak badge'a nie psuje layoutu.
+- `resources/scss/app.scss` — klasa `.sidebar__badge`.
**Efekt**: user nie musi modyfikowac kodu przy kazdym nowym statusie kuriera. Badge sygnalizuje pojawienie sie nieznanych statusow; sekcja na stronie mapowan pozwala przypisac je do znormalizowanych kategorii. Cron po nastepnym tick'u automatycznie przeliczy istniejace paczki zgodnie z override.
@@ -83,3 +125,4 @@
- `OrdersStatisticsRepository::netAmountSql()` dostal fallback: jesli `orders.total_without_tax` jest `NULL` lub `0`, a `orders.total_with_tax` ma wartosc, netto wyliczane jest jako `ROUND(total_with_tax / 1.23, 2)`.
- Uzasadnienie: shopPRO nie wysyla netto ani na zamowieniu ani w pozycjach (`order_items.original_price_without_tax` jest puste), wiec bez fallbacku kolumna `Netto` w statystykach pokazywala 0.
- Uwaga: fallback zaklada 23% VAT. Ostateczne rozwiazanie (prawidlowy netto z shopPRO / z `order_items.tax_rate`) opisane w `.paul/TODO.md` (tag `STAT-NET`).
+
diff --git a/.paul/phases/107-automation-email-send-once/107-01-PLAN.md b/.paul/phases/107-automation-email-send-once/107-01-PLAN.md
new file mode 100644
index 0000000..65e98ac
--- /dev/null
+++ b/.paul/phases/107-automation-email-send-once/107-01-PLAN.md
@@ -0,0 +1,196 @@
+---
+phase: 107-automation-email-send-once
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - database/migrations/20260425_000102_create_automation_email_once_deliveries_table.sql
+ - src/Modules/Automation/AutomationController.php
+ - src/Modules/Automation/AutomationRepository.php
+ - src/Modules/Automation/AutomationService.php
+ - src/Modules/Automation/AutomationEmailOnceRepository.php
+ - resources/views/automation/form.php
+ - public/assets/js/modules/automation-form.js
+ - src/Modules/Cron/CronHandlerFactory.php
+ - tests/Unit/AutomationServiceTest.php
+ - .paul/docs/DB_SCHEMA.md
+ - .paul/docs/ARCHITECTURE.md
+ - .paul/docs/TECH_CHANGELOG.md
+autonomous: true
+delegation: off
+---
+
+
+## Goal
+Dodac w akcji automatyzacji `send_email` opcje "wyslij tylko raz na zamowienie", ktora gwarantuje, ze dla tej samej reguly i tego samego zamowienia ten sam mail nie zostanie wyslany ponownie przy kolejnych przebiegach crona.
+
+## Purpose
+Event `order.status_aged` jest uruchamiany cyklicznie. Bez mechanizmu idempotencji klient moze dostawac ten sam mail przy kazdym przebiegu. Potrzebujemy bezpiecznego "once per order", ale tylko tam, gdzie operator swiadomie zaznaczy taka opcje.
+
+## Output
+- Checkbox w konfiguracji akcji `Wyslij e-mail`: "Wyslij tylko raz dla tego zamowienia"
+- Trwale zapamietanie wysylki jednorazowej per `rule_id + action_id + order_id`
+- Logika wykonania, ktora pomija ponowna wysylke tylko dla akcji z wlaczonym checkboxem
+- Testy jednostkowe dla scenariusza jednorazowego i scenariusza domyslnego (wielokrotnego)
+
+
+
+## Project Context
+@.paul/PROJECT.md
+@.paul/ROADMAP.md
+@.paul/STATE.md
+
+## Prior Work
+@.paul/phases/60-order-status-aged-event/60-01-SUMMARY.md
+
+## Source Files
+@src/Modules/Automation/AutomationController.php
+@src/Modules/Automation/AutomationRepository.php
+@src/Modules/Automation/AutomationService.php
+@src/Modules/Automation/OrderStatusAgedService.php
+@resources/views/automation/form.php
+@public/assets/js/modules/automation-form.js
+@src/Modules/Cron/CronHandlerFactory.php
+@database/migrations/20260318_000057_create_automation_tables.sql
+@database/migrations/20260328_000072_create_automation_execution_logs_table.sql
+
+
+
+## Required Skills (from SPECIAL-FLOWS.md)
+
+| Skill | Priority | When to Invoke | Loaded? |
+|-------|----------|----------------|---------|
+| sonar-scanner (CLI) | required | Po APPLY, przed UNIFY | o |
+
+## Skill Invocation Checklist
+- [ ] sonar-scanner uruchomiony po wdrozeniu zmian
+
+
+
+
+## AC-1: Konfiguracja akcji e-mail ma opcje jednorazowej wysylki
+```gherkin
+Given operator tworzy lub edytuje zadanie automatyczne
+When wybierze akcje "Wyslij e-mail"
+Then widzi checkbox "Wyslij tylko raz dla tego zamowienia"
+And wartosc checkboxa zapisuje sie w action_config i wraca poprawnie po ponownej edycji reguly
+```
+
+## AC-2: Jednorazowa wysylka dziala per zamowienie
+```gherkin
+Given regula z eventem "order.status_aged" i akcja send_email z wlaczona opcja "wyslij tylko raz"
+And zamowienie pozostaje w tym samym statusie przez kolejne uruchomienia crona
+When cron wyzwoli te sama regule wielokrotnie dla tego samego zamowienia
+Then e-mail zostanie wyslany tylko podczas pierwszego udanego wykonania tej akcji
+And kolejne wykonania pominaja tylko te akcje e-mail, nie przerywajac pozostalych akcji reguly
+```
+
+## AC-3: Zachowanie domyslne pozostaje bez zmian
+```gherkin
+Given regula z akcja send_email bez zaznaczonej opcji "wyslij tylko raz"
+When event uruchomi regule wielokrotnie dla tego samego zamowienia
+Then e-mail moze byc wysylany wielokrotnie jak dotychczas
+```
+
+## AC-4: Oznaczenie "wyslano raz" powstaje tylko po sukcesie wysylki
+```gherkin
+Given pierwsza proba wysylki zakonczyla sie bledem
+When kolejny przebieg crona ponowi wykonanie
+Then system ponownie sprobuje wyslac e-mail
+And blokada jednorazowosci nie jest zapisana po nieudanej probie
+```
+
+
+
+
+
+
+ Task 1: Dodac model danych jednorazowej wysylki i repozytorium idempotencji
+ database/migrations/20260425_000102_create_automation_email_once_deliveries_table.sql, src/Modules/Automation/AutomationEmailOnceRepository.php, src/Modules/Cron/CronHandlerFactory.php
+
+ 1. Dodac migracje tworzenia tabeli `automation_email_once_deliveries` z kolumnami:
+ - `id` (PK),
+ - `rule_id` (FK -> automation_rules.id),
+ - `action_id` (FK -> automation_actions.id),
+ - `order_id` (FK -> orders.id),
+ - `created_at`.
+ 2. Dodac UNIQUE KEY na `(rule_id, action_id, order_id)` dla twardej deduplikacji.
+ 3. Utworzyc `AutomationEmailOnceRepository` z metodami:
+ - `wasSent(int $ruleId, int $actionId, int $orderId): bool`
+ - `markSent(int $ruleId, int $actionId, int $orderId): void`
+ 4. Podpiac repozytorium w `CronHandlerFactory` i konstruktorze `AutomationService`.
+
+ Uruchomienie migracji lokalnie konczy sie bez bledu, a tabela i indeks UNIQUE istnieja.
+ AC-2 i AC-4 maja trwaly mechanizm danych.
+
+
+
+ Task 2: Rozszerzyc konfiguracje akcji send_email o flage "send_once_per_order"
+ src/Modules/Automation/AutomationController.php, resources/views/automation/form.php, public/assets/js/modules/automation-form.js, src/Modules/Automation/AutomationRepository.php
+
+ 1. W `AutomationController::parseActionConfig()` dla `send_email` dodac bool `send_once_per_order` (0/1), domyslnie `0`.
+ 2. W widoku formularza (`resources/views/automation/form.php`) dodac checkbox dla akcji e-mail:
+ `actions[idx][send_once_per_order]`.
+ 3. W generatorze dynamicznym JS (`automation-form.js`) dodac ten sam checkbox przy dodawaniu nowej akcji e-mail.
+ 4. Zachowac kompatybilnosc starych rekordow (brak pola = `false`).
+
+ Utworzenie i edycja reguly zapisuje/odczytuje checkbox poprawnie; brak regresji dla innych typow akcji.
+ AC-1 i AC-3 sa spelnione po stronie konfiguracji.
+
+
+
+ Task 3: Wdrozyc wykonanie jednorazowe w AutomationService i testy
+ src/Modules/Automation/AutomationService.php, tests/Unit/AutomationServiceTest.php, .paul/docs/DB_SCHEMA.md, .paul/docs/ARCHITECTURE.md, .paul/docs/TECH_CHANGELOG.md
+
+ 1. W `AutomationService::executeActions()` przekazac `ruleId` i `actionId` do obslugi send_email.
+ 2. W `handleSendEmail()`:
+ - odczytac `send_once_per_order`,
+ - gdy flaga aktywna: sprawdzic `wasSent(...)`; jesli true, pominac akcje e-mail,
+ - po udanej wysylce (bez wyjatku): zapisac `markSent(...)`.
+ 3. Nie oznaczac jako wyslane przy bledzie wysylki (wyjatek -> brak `markSent`).
+ 4. Dodac testy:
+ - `send_once_per_order=true` -> druga proba nie wywoluje `EmailSendingService::send`,
+ - `send_once_per_order=false` -> kolejne proby wysylaja dalej.
+ 5. Zaktualizowac `.paul/docs/DB_SCHEMA.md`, `.paul/docs/ARCHITECTURE.md`, `.paul/docs/TECH_CHANGELOG.md`.
+
+ `php vendor/bin/phpunit tests/Unit/AutomationServiceTest.php` przechodzi; test reczny cron potwierdza pojedyncza wysylke dla zaznaczonej opcji.
+ AC-2, AC-3, AC-4 sa spelnione end-to-end.
+
+
+
+
+
+
+## DO NOT CHANGE
+- Logika eventow i warunkow automatyzacji niezwiązanych z `send_email`
+- Mechanizmy wysylki e-mail poza potrzebnym miejscem integracji w `AutomationService`
+- Runtime konfiguracji DB hostow (`DB_HOST` / `DB_HOST_REMOTE`)
+
+## SCOPE LIMITS
+- Brak nowych eventow automatyzacji
+- Brak zmian w harmonogramie crona `order_status_aged`
+- Brak zmian UI listy historii poza tym, co konieczne do konfiguracji checkboxa
+
+
+
+
+Before declaring plan complete:
+- [ ] Migracja tworzy tabele i UNIQUE KEY dla deduplikacji
+- [ ] Checkbox "wyslij tylko raz" dziala przy create/edit reguly
+- [ ] Dla `send_once_per_order=1` e-mail idzie tylko raz na zamowienie
+- [ ] Dla `send_once_per_order=0` zachowanie pozostaje bez zmian
+- [ ] `php vendor/bin/phpunit tests/Unit/AutomationServiceTest.php` przechodzi
+- [ ] Dokumentacja `.paul/docs/*` zaktualizowana
+
+
+
+- Operator moze wlaczyc jednorazowa wysylke per zamowienie bez zmian kodu
+- Cron nie wysyla duplikatow dla tej samej jednorazowej akcji e-mail
+- Brak regresji istniejacych automatyzacji e-mail
+- Plan gotowy do uruchomienia przez `$paul-apply`
+
+
+
diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json
index 347fcbe..5829abd 100644
--- a/.vscode/ftp-kr.sync.cache.json
+++ b/.vscode/ftp-kr.sync.cache.json
@@ -5719,6 +5719,12 @@
},
"tools": {
"generowanie": {
+ "akrylowa_statuetka_chrzest_wzor1.py": {
+ "type": "-",
+ "size": 5576,
+ "lmtime": 1777025100104,
+ "modified": false
+ },
"akrylowa_statuetka_podziekowanie_rodzice_wzor1.py": {
"type": "-",
"size": 6729,
@@ -5811,6 +5817,12 @@
"lmtime": 1776845939580,
"modified": false
},
+ "_convert_tmp.py": {
+ "type": "-",
+ "size": 1056,
+ "lmtime": 1777025056743,
+ "modified": false
+ },
"_debug_tmp.py": {
"type": "-",
"size": 1316,
@@ -5849,9 +5861,9 @@
},
"_explore_tmp.py": {
"type": "-",
- "size": 544,
+ "size": 1525,
"lmtime": 1776856329482,
- "modified": false
+ "modified": true
},
"_explore_wzor3.py": {
"type": "-",
@@ -5859,6 +5871,24 @@
"lmtime": 0,
"modified": false
},
+ "_fetch_all_tmp.py": {
+ "type": "-",
+ "size": 1489,
+ "lmtime": 1777026149888,
+ "modified": false
+ },
+ "_fix_mapping.sql": {
+ "type": "-",
+ "size": 288,
+ "lmtime": 1777025157116,
+ "modified": false
+ },
+ "_inspect_tmp.py": {
+ "type": "-",
+ "size": 664,
+ "lmtime": 1777024666916,
+ "modified": false
+ },
"magnes_babcia_kocham_babciu.py": {
"type": "-",
"size": 2208,
@@ -6023,6 +6053,18 @@
"modified": false
}
},
+ "_q_tmp.sql": {
+ "type": "-",
+ "size": 214,
+ "lmtime": 1777026259224,
+ "modified": false
+ },
+ "_rename_biblia.py": {
+ "type": "-",
+ "size": 1329,
+ "lmtime": 1776930598673,
+ "modified": false
+ },
"_rename_psd.py": {
"type": "-",
"size": 1978,
@@ -6037,9 +6079,9 @@
},
"_rename_tmp.py": {
"type": "-",
- "size": 604,
+ "size": 828,
"lmtime": 1776856346084,
- "modified": false
+ "modified": true
},
"_rename_wzor3.py": {
"type": "-",
@@ -6097,12 +6139,6 @@
"size": 5328,
"lmtime": 1776032317220,
"modified": false
- },
- "_rename_biblia.py": {
- "type": "-",
- "size": 1329,
- "lmtime": 1776930598673,
- "modified": false
}
}
},
diff --git a/database/migrations/20260425_000102_create_automation_email_once_deliveries_table.sql b/database/migrations/20260425_000102_create_automation_email_once_deliveries_table.sql
new file mode 100644
index 0000000..672e872
--- /dev/null
+++ b/database/migrations/20260425_000102_create_automation_email_once_deliveries_table.sql
@@ -0,0 +1,13 @@
+CREATE TABLE IF NOT EXISTS `automation_email_once_deliveries` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `rule_id` INT UNSIGNED NOT NULL,
+ `action_id` INT UNSIGNED NOT NULL,
+ `order_id` BIGINT UNSIGNED NOT NULL,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `auto_email_once_unique` (`rule_id`, `action_id`, `order_id`),
+ KEY `auto_email_once_order_idx` (`order_id`),
+ CONSTRAINT `auto_email_once_rule_fk` FOREIGN KEY (`rule_id`) REFERENCES `automation_rules` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT `auto_email_once_action_fk` FOREIGN KEY (`action_id`) REFERENCES `automation_actions` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT `auto_email_once_order_fk` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/public/assets/js/modules/automation-form.js b/public/assets/js/modules/automation-form.js
index f3cf2de..7325ac9 100644
--- a/public/assets/js/modules/automation-form.js
+++ b/public/assets/js/modules/automation-form.js
@@ -106,6 +106,10 @@
html += '';
});
html += '';
+ html += '';
return html;
}
diff --git a/resources/views/automation/form.php b/resources/views/automation/form.php
index f1ed006..2e08a48 100644
--- a/resources/views/automation/form.php
+++ b/resources/views/automation/form.php
@@ -235,6 +235,10 @@ $orderStatusOptions = is_array($orderStatusOptions ?? null) ? $orderStatusOption
+
diff --git a/routes/web.php b/routes/web.php
index a546d80..5fc6fc8 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -57,6 +57,7 @@ use App\Modules\Automation\AutomationController;
use App\Modules\Automation\AutomationRepository;
use App\Modules\Automation\AutomationService;
use App\Modules\Automation\AutomationExecutionLogRepository;
+use App\Modules\Automation\AutomationEmailOnceRepository;
use App\Modules\Settings\CronSettingsController;
use App\Modules\Settings\DeliveryStatusMappingController;
use App\Modules\Settings\SettingsController;
@@ -250,6 +251,7 @@ return static function (Application $app): void {
$automationService = new AutomationService(
$automationRepository,
$automationExecutionLogRepository,
+ new AutomationEmailOnceRepository($app->db()),
$emailSendingService,
new OrdersRepository($app->db()),
$companySettingsRepository,
diff --git a/src/Modules/Automation/AutomationController.php b/src/Modules/Automation/AutomationController.php
index 5c7c63f..383e00a 100644
--- a/src/Modules/Automation/AutomationController.php
+++ b/src/Modules/Automation/AutomationController.php
@@ -534,12 +534,17 @@ final class AutomationController
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];
+ return [
+ 'template_id' => $templateId,
+ 'recipient' => $recipient,
+ 'send_once_per_order' => $sendOncePerOrder,
+ ];
}
if ($type === 'issue_receipt') {
diff --git a/src/Modules/Automation/AutomationEmailOnceRepository.php b/src/Modules/Automation/AutomationEmailOnceRepository.php
new file mode 100644
index 0000000..c962085
--- /dev/null
+++ b/src/Modules/Automation/AutomationEmailOnceRepository.php
@@ -0,0 +1,54 @@
+pdo->prepare(
+ 'SELECT 1
+ FROM automation_email_once_deliveries
+ WHERE rule_id = :rule_id
+ AND action_id = :action_id
+ AND order_id = :order_id
+ LIMIT 1'
+ );
+ $statement->execute([
+ 'rule_id' => $ruleId,
+ 'action_id' => $actionId,
+ 'order_id' => $orderId,
+ ]);
+
+ return $statement->fetchColumn() !== false;
+ }
+
+ public function markSent(int $ruleId, int $actionId, int $orderId): void
+ {
+ if ($ruleId <= 0 || $actionId <= 0 || $orderId <= 0) {
+ return;
+ }
+
+ $statement = $this->pdo->prepare(
+ 'INSERT INTO automation_email_once_deliveries (rule_id, action_id, order_id, created_at)
+ VALUES (:rule_id, :action_id, :order_id, NOW())
+ ON DUPLICATE KEY UPDATE created_at = created_at'
+ );
+ $statement->execute([
+ 'rule_id' => $ruleId,
+ 'action_id' => $actionId,
+ 'order_id' => $orderId,
+ ]);
+ }
+}
diff --git a/src/Modules/Automation/AutomationService.php b/src/Modules/Automation/AutomationService.php
index a2fafa5..4a15d69 100644
--- a/src/Modules/Automation/AutomationService.php
+++ b/src/Modules/Automation/AutomationService.php
@@ -34,6 +34,7 @@ final class AutomationService
public function __construct(
private readonly AutomationRepository $repository,
private readonly AutomationExecutionLogRepository $executionLogs,
+ private readonly AutomationEmailOnceRepository $emailOnceRepository,
private readonly EmailSendingService $emailService,
private readonly OrdersRepository $orders,
private readonly CompanySettingsRepository $companySettings,
@@ -78,11 +79,12 @@ final class AutomationService
$conditions = is_array($rule['conditions'] ?? null) ? $rule['conditions'] : [];
$actions = is_array($rule['actions'] ?? null) ? $rule['actions'] : [];
$ruleName = (string) ($rule['name'] ?? '');
+ $ruleId = (int) ($rule['id'] ?? 0);
$ruleContext = $this->withExecution($context, $executionKey);
$ruleMatched = $this->evaluateConditions($conditions, $order, $ruleContext);
if ($ruleMatched) {
- $this->executeActions($actions, $orderId, $ruleName, $ruleContext);
+ $this->executeActions($actions, $orderId, $ruleId, $ruleName, $ruleContext);
$this->logExecution($eventType, $ruleId, $ruleName, $orderId, 'success', 'Wykonano akcje automatyzacji', $ruleContext);
}
} catch (Throwable $exception) {
@@ -282,8 +284,14 @@ final class AutomationService
return false;
}
- $newStatus = strtolower(trim((string) ($context['new_status'] ?? '')));
- if ($newStatus === '') {
+ $statusCandidate = (string) ($context['new_status'] ?? '');
+ if ($statusCandidate === '') {
+ // order.status_aged carries "current_status" instead of "new_status"
+ $statusCandidate = (string) ($context['current_status'] ?? '');
+ }
+
+ $normalizedStatus = strtolower(trim($statusCandidate));
+ if ($normalizedStatus === '') {
return false;
}
@@ -292,7 +300,7 @@ final class AutomationService
$orderStatusCodes
);
- return in_array($newStatus, $normalizedCodes, true);
+ return in_array($normalizedStatus, $normalizedCodes, true);
}
/**
@@ -315,14 +323,15 @@ final class AutomationService
* @param list> $actions
* @param array $context
*/
- private function executeActions(array $actions, int $orderId, string $ruleName, array $context): void
+ private function executeActions(array $actions, int $orderId, int $ruleId, string $ruleName, array $context): void
{
foreach ($actions as $action) {
$type = (string) ($action['action_type'] ?? '');
$config = is_array($action['action_config'] ?? null) ? $action['action_config'] : [];
+ $actionId = (int) ($action['id'] ?? 0);
if ($type === 'send_email') {
- $this->handleSendEmail($config, $orderId, $ruleName);
+ $this->handleSendEmail($config, $orderId, $ruleId, $actionId, $ruleName);
continue;
}
@@ -345,26 +354,43 @@ final class AutomationService
/**
* @param array $config
*/
- private function handleSendEmail(array $config, int $orderId, string $ruleName): void
+ private function handleSendEmail(array $config, int $orderId, int $ruleId, int $actionId, string $ruleName): void
{
$templateId = (int) ($config['template_id'] ?? 0);
if ($templateId <= 0) {
return;
}
+ $sendOncePerOrder = (int) ($config['send_once_per_order'] ?? 0) === 1;
+ if (
+ $sendOncePerOrder
+ && $ruleId > 0
+ && $actionId > 0
+ && $this->emailOnceRepository->wasSent($ruleId, $actionId, $orderId)
+ ) {
+ return;
+ }
+
$recipient = (string) ($config['recipient'] ?? 'client');
$actorName = 'Automatyzacja: ' . $ruleName;
+ $wasSentSuccessfully = false;
+
if ($recipient === 'client' || $recipient === 'client_and_company') {
- $this->emailService->send($orderId, $templateId, null, $actorName);
+ $result = $this->emailService->send($orderId, $templateId, null, $actorName);
+ $wasSentSuccessfully = $wasSentSuccessfully || (bool) ($result['success'] ?? false);
}
if ($recipient === 'company' || $recipient === 'client_and_company') {
- $this->sendToCompany($orderId, $templateId, $actorName);
+ $wasSentSuccessfully = $this->sendToCompany($orderId, $templateId, $actorName) || $wasSentSuccessfully;
+ }
+
+ if ($sendOncePerOrder && $ruleId > 0 && $actionId > 0 && $wasSentSuccessfully) {
+ $this->emailOnceRepository->markSent($ruleId, $actionId, $orderId);
}
}
- private function sendToCompany(int $orderId, int $templateId, string $actorName): void
+ private function sendToCompany(int $orderId, int $templateId, string $actorName): bool
{
$settings = $this->companySettings->getSettings();
$companyEmail = trim((string) ($settings['email'] ?? ''));
@@ -378,12 +404,12 @@ final class AutomationService
'system',
$actorName
);
- return;
+ return false;
}
$companyName = trim((string) ($settings['company_name'] ?? ''));
- $this->emailService->send(
+ $result = $this->emailService->send(
$orderId,
$templateId,
null,
@@ -391,6 +417,8 @@ final class AutomationService
$companyEmail,
$companyName
);
+
+ return (bool) ($result['success'] ?? false);
}
/**
diff --git a/src/Modules/Cron/CronHandlerFactory.php b/src/Modules/Cron/CronHandlerFactory.php
index f58b6f7..7bd7a09 100644
--- a/src/Modules/Cron/CronHandlerFactory.php
+++ b/src/Modules/Cron/CronHandlerFactory.php
@@ -11,6 +11,7 @@ use App\Modules\Accounting\ReceiptService;
use App\Modules\Automation\AutomationRepository;
use App\Modules\Automation\AutomationService;
use App\Modules\Automation\AutomationExecutionLogRepository;
+use App\Modules\Automation\AutomationEmailOnceRepository;
use App\Modules\Automation\OrderStatusAgedService;
use App\Modules\Email\AttachmentGenerator;
use App\Modules\Email\EmailSendingService;
@@ -232,6 +233,7 @@ final class CronHandlerFactory
return new AutomationService(
$automationRepository,
$executionLogRepository,
+ new AutomationEmailOnceRepository($this->db),
$emailService,
$ordersRepository,
$companySettingsRepository,
diff --git a/tests/Unit/AutomationServiceTest.php b/tests/Unit/AutomationServiceTest.php
new file mode 100644
index 0000000..0da613a
--- /dev/null
+++ b/tests/Unit/AutomationServiceTest.php
@@ -0,0 +1,216 @@
+automationRepository = $this->createMock(AutomationRepository::class);
+ $this->executionLogRepository = $this->createMock(AutomationExecutionLogRepository::class);
+ $this->emailOnceRepository = $this->createMock(AutomationEmailOnceRepository::class);
+ $this->emailService = $this->createMock(EmailSendingService::class);
+ $this->ordersRepository = $this->createMock(OrdersRepository::class);
+ $this->companySettingsRepository = $this->createMock(CompanySettingsRepository::class);
+ $this->receiptRepository = $this->createMock(ReceiptRepository::class);
+ $this->receiptConfigRepository = $this->createMock(ReceiptConfigRepository::class);
+ $this->shipmentPackageRepository = $this->createMock(ShipmentPackageRepository::class);
+ $this->receiptService = $this->createMock(ReceiptService::class);
+
+ $this->service = new AutomationService(
+ $this->automationRepository,
+ $this->executionLogRepository,
+ $this->emailOnceRepository,
+ $this->emailService,
+ $this->ordersRepository,
+ $this->companySettingsRepository,
+ $this->receiptRepository,
+ $this->receiptConfigRepository,
+ $this->shipmentPackageRepository,
+ $this->receiptService
+ );
+ }
+
+ public function testSendEmailActionWithSendOncePerOrderSendsOnlyFirstTime(): void
+ {
+ $rule = [
+ 'id' => 7,
+ 'name' => 'Regula 1',
+ 'conditions' => [],
+ 'actions' => [[
+ 'id' => 11,
+ 'action_type' => 'send_email',
+ 'action_config' => [
+ 'template_id' => 3,
+ 'recipient' => 'client',
+ 'send_once_per_order' => 1,
+ ],
+ ]],
+ ];
+
+ $this->automationRepository
+ ->expects($this->exactly(2))
+ ->method('findActiveByEvent')
+ ->with('order.status_aged')
+ ->willReturn([$rule]);
+
+ $this->ordersRepository
+ ->expects($this->exactly(2))
+ ->method('findDetails')
+ ->with(123)
+ ->willReturn(['order' => ['id' => 123, 'integration_id' => 1]]);
+
+ $this->emailOnceRepository
+ ->expects($this->exactly(2))
+ ->method('wasSent')
+ ->with(7, 11, 123)
+ ->willReturnOnConsecutiveCalls(false, true);
+
+ $this->emailService
+ ->expects($this->once())
+ ->method('send')
+ ->with(123, 3, null, 'Automatyzacja: Regula 1')
+ ->willReturn(['success' => true, 'error' => null, 'log_id' => 1]);
+
+ $this->emailOnceRepository
+ ->expects($this->once())
+ ->method('markSent')
+ ->with(7, 11, 123);
+
+ $this->executionLogRepository
+ ->expects($this->exactly(2))
+ ->method('create');
+
+ $this->service->trigger('order.status_aged', 123, ['days_in_status' => 6]);
+ $this->service->trigger('order.status_aged', 123, ['days_in_status' => 7]);
+ }
+
+ public function testSendEmailActionWithoutSendOncePerOrderKeepsSending(): void
+ {
+ $rule = [
+ 'id' => 8,
+ 'name' => 'Regula 2',
+ 'conditions' => [],
+ 'actions' => [[
+ 'id' => 12,
+ 'action_type' => 'send_email',
+ 'action_config' => [
+ 'template_id' => 4,
+ 'recipient' => 'client',
+ 'send_once_per_order' => 0,
+ ],
+ ]],
+ ];
+
+ $this->automationRepository
+ ->expects($this->exactly(2))
+ ->method('findActiveByEvent')
+ ->with('order.status_aged')
+ ->willReturn([$rule]);
+
+ $this->ordersRepository
+ ->expects($this->exactly(2))
+ ->method('findDetails')
+ ->with(123)
+ ->willReturn(['order' => ['id' => 123, 'integration_id' => 1]]);
+
+ $this->emailOnceRepository
+ ->expects($this->never())
+ ->method('wasSent');
+
+ $this->emailOnceRepository
+ ->expects($this->never())
+ ->method('markSent');
+
+ $this->emailService
+ ->expects($this->exactly(2))
+ ->method('send')
+ ->with(123, 4, null, 'Automatyzacja: Regula 2')
+ ->willReturn(['success' => true, 'error' => null, 'log_id' => 2]);
+
+ $this->executionLogRepository
+ ->expects($this->exactly(2))
+ ->method('create');
+
+ $this->service->trigger('order.status_aged', 123, ['days_in_status' => 6]);
+ $this->service->trigger('order.status_aged', 123, ['days_in_status' => 7]);
+ }
+
+ public function testOrderStatusConditionForOrderStatusAgedUsesCurrentStatus(): void
+ {
+ $rule = [
+ 'id' => 9,
+ 'name' => 'Regula 3',
+ 'conditions' => [[
+ 'id' => 21,
+ 'condition_type' => 'order_status',
+ 'condition_value' => [
+ 'order_status_codes' => ['w_realizacji'],
+ ],
+ ]],
+ 'actions' => [[
+ 'id' => 13,
+ 'action_type' => 'send_email',
+ 'action_config' => [
+ 'template_id' => 5,
+ 'recipient' => 'client',
+ 'send_once_per_order' => 0,
+ ],
+ ]],
+ ];
+
+ $this->automationRepository
+ ->expects($this->once())
+ ->method('findActiveByEvent')
+ ->with('order.status_aged')
+ ->willReturn([$rule]);
+
+ $this->ordersRepository
+ ->expects($this->once())
+ ->method('findDetails')
+ ->with(123)
+ ->willReturn(['order' => ['id' => 123, 'integration_id' => 1]]);
+
+ $this->emailService
+ ->expects($this->once())
+ ->method('send')
+ ->with(123, 5, null, 'Automatyzacja: Regula 3')
+ ->willReturn(['success' => true, 'error' => null, 'log_id' => 3]);
+
+ $this->executionLogRepository
+ ->expects($this->once())
+ ->method('create');
+
+ $this->service->trigger('order.status_aged', 123, [
+ 'days_in_status' => 7,
+ 'current_status' => 'w_realizacji',
+ ]);
+ }
+}