From 4b998ea5be917df1aad90bba87da50f800b46fe4 Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Sat, 25 Apr 2026 21:31:50 +0200 Subject: [PATCH] update --- .paul/ROADMAP.md | 1 + .paul/STATE.md | 36 +-- .paul/docs/ARCHITECTURE.md | 7 + .paul/docs/DB_SCHEMA.md | 13 +- .paul/docs/TECH_CHANGELOG.md | 109 ++++++--- .../107-01-PLAN.md | 196 ++++++++++++++++ .vscode/ftp-kr.sync.cache.json | 56 ++++- ...automation_email_once_deliveries_table.sql | 13 ++ public/assets/js/modules/automation-form.js | 4 + resources/views/automation/form.php | 4 + routes/web.php | 2 + .../Automation/AutomationController.php | 7 +- .../AutomationEmailOnceRepository.php | 54 +++++ src/Modules/Automation/AutomationService.php | 52 ++++- src/Modules/Cron/CronHandlerFactory.php | 2 + tests/Unit/AutomationServiceTest.php | 216 ++++++++++++++++++ 16 files changed, 697 insertions(+), 75 deletions(-) create mode 100644 .paul/phases/107-automation-email-send-once/107-01-PLAN.md create mode 100644 database/migrations/20260425_000102_create_automation_email_once_deliveries_table.sql create mode 100644 src/Modules/Automation/AutomationEmailOnceRepository.php create mode 100644 tests/Unit/AutomationServiceTest.php 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` + + + +After completion, create `.paul/phases/107-automation-email-send-once/107-01-SUMMARY.md` + 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', + ]); + } +}