This commit is contained in:
2026-04-25 21:31:50 +02:00
parent 4931c55338
commit 4b998ea5be
16 changed files with 697 additions and 75 deletions

View File

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

View File

@@ -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`):

View File

@@ -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 `<tr>` 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 `<details>` rozwijajacym liste zamowien ze zwrotem (order_id, data, tracking, provider).
- `toTableRow()` — dodano badge `zwroty: N` w kolumnie buyer + klasa `is-risk-return` na `<tr>` 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 `<details>` 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`).