This commit is contained in:
2026-03-31 00:30:50 +02:00
parent 5435209b08
commit af48e84449
30 changed files with 2706 additions and 111 deletions

View File

@@ -64,6 +64,11 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
- [x] Mobile Status Panel Toggle: zwijany/rozwijany panel statusow na /orders/list — Phase 53
- [x] Order Detail Image Hover: hover zoom na miniaturkach produktow w /orders/{id} — Phase 54
- [x] Desktop Collapsed Sidebar Fix: ukrycie etykiet, centrowanie ikon w zwiniętym sidebarze — Phase 55
- [x] Dodawanie płatności ręcznych + push set_paid do shopPRO — Phase 56
- [x] Automatyzacja: event `payment.status_changed` + warunek `payment_status` (0/1/2) — Phase 57
- [x] Zachowanie danych formularza automatyzacji po bledzie walidacji — Phase 58
- [x] Automatyzacja: event `order.status_changed` + warunek `order_status` — Phase 59
- [x] Automatyzacja: event `order.status_aged` (cron) + warunek `days_in_status` — Phase 60
### Active (In Progress)

View File

@@ -16,7 +16,11 @@ Wersja mobilna aplikacji, modul po module. Cel: pelna uzywalnosc orderPRO na tel
| 53 | Mobile Status Panel Toggle | 1/1 | Complete |
| 54 | Order Detail Image Hover | 1/1 | Complete |
| 55 | Desktop Collapsed Sidebar Fix | 1/1 | Complete |
| 56 | Order Payments | 0/1 | Planning |
| 56 | Order Payments | 1/1 | Complete |
| 57 | Payment Automation Event | 1/1 | Complete |
| 58 | Automation Form Preserve | 1/1 | Complete |
| 59 | Order Status Automation Event | 1/1 | Complete |
| 60 | Order Status Aged Event | 1/1 | Complete |
| TBD | Mobile Orders List | - | Not started |
| TBD | Mobile Order Details | - | Not started |
| TBD | Mobile Settings | - | Not started |

View File

@@ -2,49 +2,40 @@
## Project Reference
See: .paul/PROJECT.md (updated 2026-03-28)
See: .paul/PROJECT.md (updated 2026-03-31)
**Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami.
**Current focus:** Milestone v3.0 Mobile Responsive — Phase 56 (Order Payments) planning
**Current focus:** Milestone v3.0 — Phase 60 complete, ready for next PLAN
## Current Position
Milestone: v3.0 Mobile Responsive — In progress
Phase: 5 of N (56 - Order Payments) — Planning
Plan: 56-01 created, awaiting approval
Status: PLAN created, ready for APPLY
Last activity: 2026-03-30Created .paul/phases/56-order-payments/56-01-PLAN.md
Phase: 9 of N (60 - Order Status Aged Event) — Complete
Plan: 60-01 complete
Status: Loop complete — phase 60 done, ready for next PLAN
Last activity: 2026-03-31UNIFY closed for 60-01
Progress:
- Milestone: [####░░░░░░] ~40%
- Phase 56: [░░░░░░░░░░] 0%
- Milestone: [######░░░░] ~55%
- Phase 60: [##########] 100%
## Loop Position
Current loop state:
```
PLAN ──▶ APPLY ──▶ UNIFY
[Plan created, awaiting approval]
[Loop complete - ready for next PLAN]
```
## Session Continuity
Last session: 2026-03-30
Stopped at: Plan 56-01 created
Next action: Review and approve plan, then run /paul:apply .paul/phases/56-order-payments/56-01-PLAN.md
Resume file: .paul/phases/56-order-payments/56-01-PLAN.md
## Accumulated Context
### Decisions
| Date | Decision | Impact |
|------|----------|--------|
| 2026-03-29 | Mobile menu jako slide-in overlay (nie horizontal scroll) | Pelna nawigacja na mobile bez kompromisow |
| 2026-03-29 | Hamburger w topbarze, sidebar fixed z transform slide | Plynna animacja CSS, zero zaleznosci JS |
| 2026-03-30 | Push set_paid do shopPRO API po dodaniu platnosci w orderPRO | Synchronizacja statusu platnosci bez dodatkowego endpointu w shopPRO |
Last session: 2026-03-31
Stopped at: Phase 60 complete
Next action: /paul:plan dla kolejnego modulu
Resume file: .paul/phases/60-order-status-aged-event/60-01-SUMMARY.md
## Git State
Last commit: 70662af
Last commit: 5435209
Branch: main
Feature branches merged: none

View File

@@ -0,0 +1,34 @@
---
phase: 56-order-payments
plan: 01
status: complete
completed: 2026-03-30
---
## Summary
Uruchomienie funkcji dodawania platnosci do zamowienia z zakladki Platnosci + push statusu platnosci do shopPRO API.
## What Was Built
- Migracja `20260330_000073_create_order_payments_table.sql` — tabela `order_payments` (INT UNSIGNED FK) + idempotentne kolumny `total_with_tax`, `total_paid`, `external_payment_type_id` w `orders`
- `OrdersRepository::addPayment()` — INSERT + przeliczenie total_paid i payment_status
- `OrdersRepository::findOrderSourceInfo()` — dane source/integration dla push
- `OrdersController::addPayment()` — POST `/orders/{id}/payment/add` z walidacja, try/catch, activity log
- `OrdersController::pushPaymentToShoppro()` — push `set_paid` do shopPRO API po pelnym oplaceniu
- `ShopproApiClient::setOrderPaid()` — PUT `/api.php?endpoint=orders&action=set_paid`
- Formularz inline w zakladce Platnosci (przycisk "Dodaj platnosc", pola: kwota/typ/data/komentarz, AJAX submit)
- Style `.payment-add-form` w SCSS
## Deviations
- FK type fix: `order_payments.order_id` zmieniony z BIGINT na INT UNSIGNED (zgodnosc z `orders.id`)
- `Csrf::verify()``Csrf::validate()` (poprawna nazwa metody)
- Dodano try/catch w kontrolerze dla lepszej diagnostyki bledow
## Decisions
| Decision | Rationale |
|----------|-----------|
| Push set_paid do shopPRO bez nowego endpointu | shopPRO API juz ma `set_paid` — wystarczajace |
| ShopproIntegrationsRepository jako nullable dep w OrdersController | Minimalna zmiana konstruktora |

View File

@@ -0,0 +1,219 @@
---
phase: 57-payment-automation-event
plan: 01
type: execute
wave: 1
depends_on: ["56-01"]
files_modified:
- src/Modules/Automation/AutomationController.php
- src/Modules/Automation/AutomationService.php
- src/Modules/Orders/OrdersController.php
- src/Modules/Settings/ShopproPaymentStatusSyncService.php
- src/Modules/Cron/CronHandlerFactory.php
- resources/views/automation/form.php
- public/assets/js/modules/automation-form.js
- routes/web.php
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
autonomous: true
---
<objective>
## Goal
Dodanie zdarzenia automatyzacji `payment.status_changed` emitowanego przy zmianie statusu platnosci zamowienia. Warunek `payment_status` pozwala filtrowac po konkretnym statusie (oplacone, czesciowo oplacone, nieoplacone).
## Purpose
Sprzedawca moze tworzyc reguly automatyzacji reagujace na zmiane statusu platnosci — np. wyslij email po oplaceniu, zmien status zamowienia, wystaw paragon.
## Output
- Nowy event `payment.status_changed` w silniku automatyzacji
- Nowy warunek `payment_status` z checkboxami statusow
- Emisja eventu z OrdersController::addPayment() i ShopproPaymentStatusSyncService
- UI: event i warunek widoczne w formularzu regul
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
## Prior Work
@.paul/phases/56-order-payments/56-01-SUMMARY.md (addPayment, payment_status update)
## Source Files
@src/Modules/Automation/AutomationController.php (ALLOWED_EVENTS linia 19, ALLOWED_CONDITION_TYPES linia 20)
@src/Modules/Automation/AutomationService.php (evaluateSingleCondition, evaluateShipmentStatusCondition — wzorzec)
@src/Modules/Orders/OrdersController.php (addPayment — miejsce emisji eventu)
@src/Modules/Settings/ShopproPaymentStatusSyncService.php (syncSingleOrderPayment — miejsce emisji eventu)
@src/Modules/Cron/CronHandlerFactory.php (kompozycja ShopproPaymentStatusSyncService)
@resources/views/automation/form.php (eventLabels, condition select, JS data)
@public/assets/js/modules/automation-form.js (addCondition, onConditionTypeChange, buildShipmentStatusCheckboxes — wzorzec)
@routes/web.php (tworzenie $automationService linia 253, $ordersController linia 264)
</context>
<acceptance_criteria>
## AC-1: Event payment.status_changed dostepny w formularzu
```gherkin
Given otwieram Ustawienia > Zadania automatyczne > Nowa regula
When klikam dropdown "Zdarzenie"
Then widze opcje "Zmiana statusu platnosci" obok istniejacych zdarzen
```
## AC-2: Warunek payment_status dostepny w formularzu
```gherkin
Given wybralem zdarzenie "Zmiana statusu platnosci"
When dodaje warunek i wybieram typ "Status platnosci"
Then widze checkboxy: Nieoplacone, Czesciowo oplacone, Oplacone
And moge zaznaczyc jeden lub wiecej statusow
```
## AC-3: Event emitowany przy recznym dodaniu platnosci
```gherkin
Given istnieje aktywna regula: event=payment.status_changed, warunek=payment_status IN [oplacone], akcja=update_order_status
When dodaje platnosc pokrywajaca pelna kwote zamowienia
Then regula sie odpala i status zamowienia zmienia sie automatycznie
And w historii automatyzacji widze wpis o wykonaniu reguly
```
## AC-4: Event emitowany przy sync platnosci z shopPRO
```gherkin
Given zamowienie source=shoppro zmienia payment_status przez cron sync
When nowy payment_status rozni sie od poprzedniego
Then event payment.status_changed jest emitowany z kontekstem (old/new status)
```
## AC-5: Warunek filtruje poprawnie
```gherkin
Given regula z warunkiem payment_status = [oplacone]
When event jest emitowany z new_payment_status = 1 (czesciowo oplacone)
Then regula NIE odpala sie
When event jest emitowany z new_payment_status = 2 (oplacone)
Then regula odpala sie
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Backend — event + warunek w silniku automatyzacji</name>
<files>src/Modules/Automation/AutomationController.php, src/Modules/Automation/AutomationService.php</files>
<action>
1. W AutomationController:
- Dodaj 'payment.status_changed' do ALLOWED_EVENTS (linia 19)
- Dodaj 'payment_status' do ALLOWED_CONDITION_TYPES (linia 20)
- W metodzie parseConditions(): obsluz 'payment_status' — zbierz payment_status_keys[] z POST, zapisz jako condition_value.status_keys (wzorzec jak shipment_status)
2. W AutomationService::evaluateSingleCondition():
- Dodaj branch: if ($type === 'payment_status') return $this->evaluatePaymentStatusCondition($value, $context);
3. Dodaj metode evaluatePaymentStatusCondition(array $value, array $context): bool:
- Pobierz status_keys z $value (tablica dozwolonych statusow: '0', '1', '2')
- Pobierz new_payment_status z $context (string)
- Return true jesli new_payment_status jest w status_keys
- Mapowanie: 0=nieoplacone, 1=czesciowo_oplacone, 2=oplacone
Wzoruj sie dokladnie na evaluateShipmentStatusCondition() — analogiczna logika.
</action>
<verify>PHP lint — brak bledow skladniowych</verify>
<done>AC-1, AC-2, AC-5 satisfied: event i warunek zarejestrowane w silniku</done>
</task>
<task type="auto">
<name>Task 2: Emisja eventu z OrdersController i ShopproPaymentStatusSyncService</name>
<files>src/Modules/Orders/OrdersController.php, src/Modules/Settings/ShopproPaymentStatusSyncService.php, src/Modules/Cron/CronHandlerFactory.php, routes/web.php</files>
<action>
1. W OrdersController:
- Dodaj ?AutomationService $automation = null do konstruktora (po $shopproIntegrations)
- W addPayment(), po zapisie platnosci i activity log, przed pushPaymentToShoppro:
try { $this->automation?->trigger('payment.status_changed', $orderId, [
'new_payment_status' => (string) $result['payment_status'],
'total_paid' => $result['total_paid'],
'payment_type_id' => $paymentTypeId,
]); } catch (Throwable) {}
2. W routes/web.php:
- Przekaz $automationService do OrdersController (juz istnieje w scope na linii 253)
- Dodaj do wywolania new OrdersController(..., $automationService)
3. W ShopproPaymentStatusSyncService:
- Dodaj ?AutomationService $automation = null do konstruktora (po $pdo)
- W syncSingleOrderPayment(), po uaktualnieniu payment status (po commit, przed recordActivity):
if ($existingPaymentStatus !== $newPaymentStatus) {
try { $this->automation?->trigger('payment.status_changed', $orderId, [
'new_payment_status' => (string) $newPaymentStatus,
'old_payment_status' => $existingPaymentStatus !== null ? (string) $existingPaymentStatus : '',
'total_paid' => $newTotalPaid,
'payment_method' => $paymentMethod,
]); } catch (Throwable) {}
}
4. W CronHandlerFactory:
- Przekaz $automationService do ShopproPaymentStatusSyncService
- AutomationService jest juz tworzony w CronHandlerFactory (buildAutomationHandler) — uzyj go
- UWAGA: ShopproPaymentStatusSyncHandler jest tworzony w innym miejscu niz automation — sprawdz czy $automationService jest dostepny i przekaz go
</action>
<verify>PHP lint — brak bledow skladniowych. Przejrzyj CronHandlerFactory ze szczegolna uwaga na kolejnosc tworzenia obiektow.</verify>
<done>AC-3, AC-4 satisfied: event emitowany z obu zrodel</done>
</task>
<task type="auto">
<name>Task 3: UI — event i warunek w formularzu automatyzacji</name>
<files>resources/views/automation/form.php, public/assets/js/modules/automation-form.js, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md</files>
<action>
1. W resources/views/automation/form.php:
- Dodaj do $eventLabels: 'payment.status_changed' => 'Zmiana statusu platnosci'
- W sekcji warunków (condition select): dodaj option value="payment_status">Status platnosci
- W sekcji renderowania warunku (PHP): dodaj branch elseif ($conditionType === 'payment_status') z checkboxami:
Statusy: ['0' => 'Nieoplacone', '1' => 'Czesciowo oplacone', '2' => 'Oplacone']
Input name: conditions[idx][payment_status_keys][]
- Dodaj paymentStatusOptions do window.AutomationFormData
2. W public/assets/js/modules/automation-form.js:
- Dodaj function buildPaymentStatusCheckboxes(namePrefix) — analogicznie do buildShipmentStatusCheckboxes ale z paymentStatusOptions
- W addCondition(): dodaj option value="payment_status">Status platnosci w select
- W onConditionTypeChange(): dodaj branch if (select.value === 'payment_status') { configDiv.innerHTML = buildPaymentStatusCheckboxes(namePrefix); }
3. Zaktualizuj DOCS/ARCHITECTURE.md i DOCS/TECH_CHANGELOG.md.
</action>
<verify>Otworz formularz reguly — widoczne nowe zdarzenie i warunek. PHP lint.</verify>
<done>AC-1, AC-2 satisfied: UI gotowe</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- database/migrations/* (brak zmian schematu — event_type to VARCHAR, nowy typ nie wymaga migracji)
- src/Modules/Automation/AutomationRepository.php (warstwa danych bez zmian)
- src/Modules/Automation/AutomationExecutionLogRepository.php
## SCOPE LIMITS
- Tylko event payment.status_changed i warunek payment_status
- Nie dodajemy nowych akcji (istniejace: send_email, issue_receipt, update_shipment_status, update_order_status wystarczaja)
- Nie modyfikujemy istniejacych eventow ani warunkow
</boundaries>
<verification>
Before declaring plan complete:
- [ ] PHP lint — brak bledow we wszystkich zmodyfikowanych plikach
- [ ] Formularz reguly: widoczne zdarzenie "Zmiana statusu platnosci"
- [ ] Formularz reguly: warunek "Status platnosci" z checkboxami
- [ ] Dodanie platnosci emituje event
- [ ] Sync platnosci z shopPRO emituje event
- [ ] DOCS zaktualizowane
</verification>
<success_criteria>
- Wszystkie taski ukonczone
- Nowy event i warunek widoczne w UI
- Event emitowany z obu zrodel (reczne + cron sync)
- Warunek filtruje po statusie platnosci
</success_criteria>
<output>
After completion, create `.paul/phases/57-payment-automation-event/57-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,60 @@
---
phase: 57-payment-automation-event
plan: 01
status: complete
completed: 2026-03-30
---
# Phase 57 Plan 01: Payment Automation Event Summary
**Event automatyzacji `payment.status_changed` z warunkiem `payment_status` — emisja z recznego dodania platnosci i cron sync shopPRO**
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Event payment.status_changed dostepny w formularzu | Pass | Opcja "Zmiana statusu platnosci" w dropdown zdarzenia |
| AC-2: Warunek payment_status dostepny w formularzu | Pass | Checkboxy: Nieoplacone, Czesciowo oplacone, Oplacone |
| AC-3: Event emitowany przy recznym dodaniu platnosci | Pass | trigger() w OrdersController::addPayment() po zapisie |
| AC-4: Event emitowany przy sync platnosci z shopPRO | Pass | trigger() w ShopproPaymentStatusSyncService po zmianie statusu |
| AC-5: Warunek filtruje poprawnie | Pass | evaluatePaymentStatusCondition porownuje new_payment_status z status_keys |
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| src/Modules/Automation/AutomationController.php | Modified | ALLOWED_EVENTS + payment.status_changed, ALLOWED_CONDITION_TYPES + payment_status, PAYMENT_STATUS_OPTIONS, parseConditions branch, paymentStatusOptions w widoku |
| src/Modules/Automation/AutomationService.php | Modified | evaluatePaymentStatusCondition() + branch w evaluateSingleCondition |
| src/Modules/Orders/OrdersController.php | Modified | AutomationService dep + trigger event w addPayment() |
| src/Modules/Settings/ShopproPaymentStatusSyncService.php | Modified | AutomationService dep + trigger event w syncSingleOrderPayment() |
| src/Modules/Cron/CronHandlerFactory.php | Modified | Przeniesienie tworzenia $automationService przed $shopproPaymentSyncService, przekazanie dep |
| routes/web.php | Modified | $automationService przekazany do OrdersController |
| resources/views/automation/form.php | Modified | Event label, warunek payment_status z checkboxami, paymentStatusOptions w JS data |
| public/assets/js/modules/automation-form.js | Modified | buildPaymentStatusCheckboxes(), opcja w addCondition + onConditionTypeChange |
| DOCS/ARCHITECTURE.md | Modified | Dokumentacja eventu i warunku |
| DOCS/TECH_CHANGELOG.md | Modified | Wpis Phase 57 |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Warunek payment_status uzywa prostego porownania string (nie mapowanie jak shipment_status) | Statusy platnosci to proste wartosci 0/1/2, nie wymagaja mapowania wielopoziomowego | Prostsza implementacja, latwiejsze debugowanie |
| $automationService przeniesiony wyzej w CronHandlerFactory | ShopproPaymentSyncService potrzebuje go jako dep — musi byc utworzony wczesniej | Zmiana kolejnosci tworzenia obiektow w factory |
| Emisja eventu non-blocking (try/catch) | Blad automatyzacji nie powinien blokowac zapisu platnosci ani sync crona | Odpornosc na bledy regul |
## Deviations from Plan
None — plan executed exactly as written.
## Next Phase Readiness
**Ready:**
- Event payment.status_changed dziala z obu zrodel (reczne + cron)
- Warunek payment_status pozwala filtrowac po statusie
- Wszystkie istniejace akcje (send_email, issue_receipt, update_order_status, update_shipment_status) dzialaja z nowym eventem
**Concerns:**
- Brak — standardowy wzorzec automatyzacji
**Blockers:**
- None

View File

@@ -0,0 +1,122 @@
---
phase: 58-automation-form-preserve
plan: 01
type: execute
wave: 1
depends_on: ["57-01"]
files_modified:
- src/Modules/Automation/AutomationController.php
- DOCS/TECH_CHANGELOG.md
autonomous: true
---
<objective>
## Goal
Zachowanie danych formularza automatyzacji po bledzie walidacji. Zamiast redirect (ktory traci dane) — re-render formularza z wypelnionymi polami i komunikatem bledu.
## Purpose
Uzytkownik nie traci wypelnionych danych (nazwa, zdarzenie, warunki, akcje) po napotkaniu bledu walidacji.
## Output
- Zmiana store() i update() w AutomationController — renderForm() z danymi z request zamiast redirect
</objective>
<context>
## Source Files
@src/Modules/Automation/AutomationController.php (store linia 106, update linia 133, renderForm linia 236)
@resources/views/automation/form.php (formularz korzysta z $rule do wypelnienia pol)
</context>
<acceptance_criteria>
## AC-1: Store — dane zachowane po bledzie walidacji
```gherkin
Given wypelniam formularz nowej reguly automatyzacji
When walidacja zwraca blad (np. brak warunkow)
Then formularz wyswietla sie ponownie z wypelnionymi danymi
And komunikat bledu jest widoczny
And nie nastepuje redirect
```
## AC-2: Update — dane zachowane po bledzie walidacji
```gherkin
Given edytuje istniejaca regule i zmieniam dane
When walidacja zwraca blad
Then formularz wyswietla sie ponownie z danymi z formularza (nie z DB)
And komunikat bledu jest widoczny
```
## AC-3: Zapis sukces — bez zmian w zachowaniu
```gherkin
Given wypelniam poprawnie formularz
When zapisuje
Then regula jest zapisana i nastepuje redirect do listy
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Re-render formularza zamiast redirect przy bledzie walidacji</name>
<files>src/Modules/Automation/AutomationController.php</files>
<action>
1. Dodaj prywatna metode buildRuleFromRequest(Request $request, ?int $id = null): array
- Zbuduj tablice $rule z POST data w formacie oczekiwanym przez form.php:
- 'id' => $id
- 'name' => $request->input('name')
- 'event_type' => $request->input('event_type')
- 'is_active' => $request->input('is_active') !== null ? 1 : 0
- 'conditions' => zmapuj z extractConditions — format: [{condition_type, condition_value}]
- 'actions' => zmapuj z extractActions — format: [{action_type, action_config}]
- Warunki i akcje powinny byc w takim formacie, jaki form.php oczekuje od $rule
2. Zmien renderForm(?array $rule, string $errorMessage = ''): Response
- Dodaj parametr $errorMessage
- Uzyj $errorMessage jesli niepusty, inaczej Flash::get
3. W store():
- Zamiast: Flash::set('error') + redirect('/create')
- Zrob: return $this->renderForm($this->buildRuleFromRequest($request), $validationError)
4. W update():
- Zamiast: Flash::set('error') + redirect('/edit?id=')
- Zrob: return $this->renderForm($this->buildRuleFromRequest($request, $id), $validationError)
5. Upewnij sie ze try/catch bledy (blad zapisu DB) tez uzywaja renderForm z danymi request — nie redirect.
</action>
<verify>PHP lint. Sprawdz ze formularz z bledna walidacja renderuje sie z danymi.</verify>
<done>AC-1, AC-2, AC-3 satisfied</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- resources/views/automation/form.php (widok juz obsluguje $rule — nie wymaga zmian)
- public/assets/js/modules/automation-form.js
- src/Modules/Automation/AutomationRepository.php
- src/Modules/Automation/AutomationService.php
## SCOPE LIMITS
- Tylko formularz automatyzacji — nie naprawiamy innych formularzy w tej fazie
- Nie zmieniamy struktury Flash class
</boundaries>
<verification>
- [ ] PHP lint bez bledow
- [ ] store() z bledna walidacja — formularz zachowuje dane
- [ ] update() z bledna walidacja — formularz zachowuje dane
- [ ] store() z poprawnymi danymi — zapis + redirect (bez regresji)
</verification>
<success_criteria>
- Dane formularza zachowane po bledzie walidacji
- Brak regresji w happy path (poprawny zapis)
</success_criteria>
<output>
After completion, create `.paul/phases/58-automation-form-preserve/58-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,21 @@
---
phase: 58-automation-form-preserve
plan: 01
status: complete
completed: 2026-03-30
---
## Summary
Zachowanie danych formularza automatyzacji po bledzie walidacji — re-render z danymi z request zamiast redirect.
## What Was Built
- `AutomationController::buildRuleFromRequest()` — buduje $rule z POST data (conditions, actions, name, event_type, is_active)
- `renderForm()` z parametrem $errorMessage — priorytet nad Flash
- `store()` i `update()` — re-render zamiast redirect przy bledzie walidacji i DB error
- form.php: $isEdit oparty na isset($rule['id']), conditions/actions wyciagane z $rule niezaleznie
## Deviations
- Zmiana w form.php (poza boundaries) — konieczna aby $isEdit poprawnie rozroznial nowy formularz od edycji gdy $rule jest ustawione z request data

View File

@@ -0,0 +1,183 @@
---
phase: 59-order-status-automation-event
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/Modules/Automation/AutomationController.php
- src/Modules/Automation/AutomationService.php
- src/Modules/Orders/OrdersController.php
- resources/views/automation/form.php
- resources/views/automation/index.php
- public/assets/js/modules/automation-form.js
autonomous: true
---
<objective>
## Goal
Dodanie zdarzenia automatyzacji `order.status_changed` emitowanego przy zmianie statusu zamowienia. Warunek `order_status` pozwala filtrowac po konkretnym statusie docelowym.
## Purpose
Uzytkownik moze tworzyc reguly automatyzacji reagujace na zmiane statusu zamowienia — np. wyslanie maila po zmianie na "wyslane" lub wystawienie paragonu po zmianie na "oplacone".
## Output
- Event `order.status_changed` dostepny w formularzu automatyzacji
- Warunek `order_status` z checkboxami aktywnych statusow
- Emisja eventu z OrdersController (reczna zmiana) i AutomationService (chain z akcji update_order_status)
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Prior Work
@.paul/phases/57-payment-automation-event/57-01-SUMMARY.md — identyczny wzorzec (event + warunek + emisja)
## Source Files
@src/Modules/Automation/AutomationController.php
@src/Modules/Automation/AutomationService.php
@src/Modules/Orders/OrdersController.php
@resources/views/automation/form.php
@resources/views/automation/index.php
@public/assets/js/modules/automation-form.js
</context>
<acceptance_criteria>
## AC-1: Event order.status_changed dostepny w formularzu
```gherkin
Given uzytkownik otwiera formularz nowego zadania automatycznego
When rozwija dropdown "Zdarzenie"
Then widzi opcje "Zmiana statusu zamowienia"
```
## AC-2: Warunek order_status z checkboxami statusow
```gherkin
Given uzytkownik wybrarl zdarzenie "Zmiana statusu zamowienia"
When dodaje warunek "Status zamowienia"
Then widzi checkboxy z aktywnymi statusami zamowien
And moze zaznaczyc jeden lub wiecej statusow
```
## AC-3: Emisja eventu przy recznej zmianie statusu
```gherkin
Given istnieje aktywna regula: event=order.status_changed, warunek=order_status IN [wyslane], akcja=send_email
When uzytkownik zmienia status zamowienia na "wyslane" (ze strony szczegolw lub inline z listy)
Then event order.status_changed jest emitowany z kontekstem (old_status, new_status)
And regula jest dopasowana i akcja wykonana
```
## AC-4: Chain emission z akcji update_order_status
```gherkin
Given regula A: event=payment.status_changed, akcja=update_order_status(wyslane)
And regula B: event=order.status_changed, warunek=order_status IN [wyslane], akcja=send_email
When platnosc sie zmienia i regula A zmienia status zamowienia
Then regula B jest wyzwalana przez chain event order.status_changed
```
## AC-5: Etykieta w widoku listy regul i historii
```gherkin
Given istnieja reguly z event_type=order.status_changed
When uzytkownik otwiera strone /settings/automation
Then w kolumnie "Zdarzenie" widzi "Zmiana statusu zamowienia" (nie surowa nazwe)
And w zakladce Historia rowniez widzi czytelna etykiete
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Backend — event + warunek + emisja</name>
<files>src/Modules/Automation/AutomationController.php, src/Modules/Automation/AutomationService.php, src/Modules/Orders/OrdersController.php</files>
<action>
**AutomationController.php:**
- Dodaj `'order.status_changed'` do `ALLOWED_EVENTS` (linia 19)
- Dodaj `'order_status'` do `ALLOWED_CONDITION_TYPES` (linia 20)
- W `buildRuleFromRequest()` dodaj branch `elseif ($type === 'order_status')` analogiczny do `payment_status` — klucz `order_status_codes`, wartosc z `$cond['order_status_codes']`
- W `parseConditionValue()` dodaj branch `if ($type === 'order_status')` — walidacja kodow statusow vs `$this->repository->listActiveOrderStatuses()` (analogicznie do payment_status ale kody z DB)
**AutomationService.php:**
- Dodaj metode `evaluateOrderStatusCondition(array $value, array $context): bool` — porownuje `$context['new_status']` z `$value['order_status_codes']`
- W `evaluateSingleCondition()` dodaj branch: `if ($type === 'order_status') return $this->evaluateOrderStatusCondition($value, $context);`
- W `handleUpdateOrderStatus()` po udanej zmianie (`$updated === true`) dodaj `$this->emitEvent('order.status_changed', ...)` z kontekstem `old_status`, `new_status`, `automation_source`, `automation_rule` — analogicznie do `handleUpdateShipmentStatus()`
- Potrzeba pobrac old_status przed zmiana: dodaj query lub uzyj `$this->orders->findDetails()` do pobrania aktualnego statusu PRZED wywolaniem `updateOrderStatus()`
**OrdersController.php:**
- Po udanym `updateOrderStatus()` (linia 280, `$success === true`) dodaj emisje:
```php
try {
$this->automation?->trigger('order.status_changed', $orderId, [
'old_status' => $oldStatus,
'new_status' => $newStatus,
]);
} catch (Throwable) {}
```
- Pobierz `$oldStatus` PRZED wywolaniem `updateOrderStatus()` — dodaj query do pobrania aktualnego `external_status_id`
</action>
<verify>Grep ALLOWED_EVENTS zawiera order.status_changed; grep evaluateOrderStatusCondition istnieje; grep trigger('order.status_changed' w OrdersController</verify>
<done>AC-3, AC-4 satisfied: event emitowany z obu zrodel, warunek ewaluowany poprawnie</done>
</task>
<task type="auto">
<name>Task 2: Frontend — formularz + widok listy</name>
<files>resources/views/automation/form.php, resources/views/automation/index.php, public/assets/js/modules/automation-form.js</files>
<action>
**form.php:**
- Dodaj do `$eventLabels`: `'order.status_changed' => 'Zmiana statusu zamowienia'`
- W sekcji renderowania warunkow dodaj branch `elseif ($condType === 'order_status')` — renderuj checkboxy z `$orderStatusOptions` (juz przekazywane do widoku przez renderForm), analogicznie do `payment_status` ale z kodem statusu jako value i nazwa jako label
**index.php:**
- Dodaj do `$eventLabels`: `'order.status_changed' => 'Zmiana statusu zamowienia'`
**automation-form.js:**
- W `addConditionRow()` dodaj opcje `<option value="order_status">Status zamowienia</option>` do selecta typu warunku
- Dodaj renderowanie checkboxow statusow zamowien gdy type === 'order_status' — uzyj danych z `window.__orderStatusOptions` (lub analogicznego mechanizmu jak payment/shipment status)
- W form.php dodaj `<script>window.__orderStatusOptions = <?= json_encode($orderStatusOptions) ?>;</script>` jesli jeszcze nie istnieje (sprawdz czy juz jest dla akcji update_order_status)
- W `onConditionTypeChange()` pokaz/ukryj panel checkboxow order_status
</action>
<verify>Otworz /settings/automation/create, sprawdz ze "Zmiana statusu zamowienia" jest w dropdown zdarzenia, ze warunek "Status zamowienia" renderuje checkboxy, i ze lista regul pokazuje czytelna etykiete</verify>
<done>AC-1, AC-2, AC-5 satisfied: event w dropdown, warunek z checkboxami, etykiety czytelne</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- database/migrations/* (brak nowych tabel/kolumn)
- src/Modules/Automation/AutomationRepository.php (bez zmian)
- src/Modules/Orders/OrdersRepository.php (bez zmian — `updateOrderStatus()` juz istnieje)
## SCOPE LIMITS
- Tylko event `order.status_changed` i warunek `order_status`
- Nie dodajemy nowych akcji
- Import zamowien z Allegro/shopPRO NIE emituje tego eventu (import ustawia status poczatkowy, nie zmienia istniejacego)
- Emisja tylko z recznej zmiany (OrdersController) i chain z akcji automatyzacji (AutomationService)
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `order.status_changed` widoczny w dropdown zdarzenia w formularzu
- [ ] Warunek `order_status` renderuje checkboxy z aktywnymi statusami
- [ ] Reczna zmiana statusu zamowienia emituje event
- [ ] Akcja `update_order_status` emituje chain event `order.status_changed`
- [ ] Etykieta "Zmiana statusu zamowienia" wyswietlana w tabeli regul i historii
- [ ] Zapis i edycja reguly z tym zdarzeniem/warunkiem dziala poprawnie
- [ ] Brak petli: chain protection (depth + dedup) zapobiega nieskonczonej rekurencji
</verification>
<success_criteria>
- Wszystkie taski wykonane
- Wszystkie AC spelnione
- Brak bledow PHP/JS
- Formularz zachowuje dane warunku order_status po bledzie walidacji (wzorzec z Phase 58)
</success_criteria>
<output>
After completion, create `.paul/phases/59-order-status-automation-event/59-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,118 @@
---
phase: 59-order-status-automation-event
plan: 01
subsystem: automation
tags: [automation, events, order-status, conditions]
requires:
- phase: 57-payment-automation-event
provides: payment.status_changed event pattern, condition evaluation pattern
provides:
- order.status_changed automation event
- order_status condition type with checkbox UI
- chain emission from update_order_status action
affects: [future automation events, order workflow]
tech-stack:
added: []
patterns: [order status condition evaluation analogous to payment_status]
key-files:
created: []
modified:
- src/Modules/Automation/AutomationController.php
- src/Modules/Automation/AutomationService.php
- src/Modules/Orders/OrdersController.php
- resources/views/automation/form.php
- resources/views/automation/index.php
- public/assets/js/modules/automation-form.js
key-decisions:
- "Emisja eventu tylko przy realnej zmianie statusu (old != new)"
- "Chain emission z akcji update_order_status przez emitEvent() z depth protection"
patterns-established:
- "order_status condition: order_status_codes array z lowercase kodami statusow"
duration: 15min
started: 2026-03-30T23:45:00Z
completed: 2026-03-31T00:00:00Z
---
# Phase 59 Plan 01: Order Status Automation Event Summary
**Event automatyzacji `order.status_changed` z warunkiem `order_status` — emisja z recznej zmiany statusu i chain z akcji automatyzacji**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15min |
| Tasks | 2 completed |
| Files modified | 6 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Event order.status_changed dostepny w formularzu | Pass | Opcja "Zmiana statusu zamowienia" w dropdown zdarzenia |
| AC-2: Warunek order_status z checkboxami statusow | Pass | Checkboxy z aktywnymi statusami zamowien (PHP + JS) |
| AC-3: Emisja eventu przy recznej zmianie statusu | Pass | OrdersController emituje po udanym updateOrderStatus() |
| AC-4: Chain emission z akcji update_order_status | Pass | AutomationService emituje przez emitEvent() z depth protection |
| AC-5: Etykieta w widoku listy regul i historii | Pass | index.php zawiera etykiety payment.status_changed + order.status_changed |
## Accomplishments
- Event `order.status_changed` dostepny w silniku automatyzacji z pelnym UI (dropdown zdarzenia, checkboxy warunku)
- Emisja z dwoch zrodel: reczna zmiana statusu (OrdersController) i chain z akcji automatyzacji (AutomationService)
- Naprawiono brakujaca etykiete `payment.status_changed` w index.php (bug z Phase 57)
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `src/Modules/Automation/AutomationController.php` | Modified | ALLOWED_EVENTS + order.status_changed, ALLOWED_CONDITION_TYPES + order_status, parseConditionValue + order_status, buildRuleFromRequest + order_status |
| `src/Modules/Automation/AutomationService.php` | Modified | evaluateOrderStatusCondition(), chain emit z handleUpdateOrderStatus(), dodano $context param |
| `src/Modules/Orders/OrdersController.php` | Modified | Pobranie oldStatus przed zmiana, emisja order.status_changed po sukcesie |
| `resources/views/automation/form.php` | Modified | Etykieta zdarzenia, opcja warunku order_status, checkboxy statusow zamowien |
| `resources/views/automation/index.php` | Modified | Etykiety payment.status_changed + order.status_changed |
| `public/assets/js/modules/automation-form.js` | Modified | buildOrderStatusCheckboxes(), opcja order_status w addCondition, onConditionTypeChange |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Emisja tylko przy old_status != new_status | Brak falszywych triggerow przy ustawieniu tego samego statusu | Spojnosc z wzorcem shipment.status_changed |
| handleUpdateOrderStatus otrzymuje $context dla chain | Potrzebny do emitEvent() z depth/dedup protection | Zapobiega petlom automatyzacji |
## Deviations from Plan
### Auto-fixed Issues
**1. Bugfix: brakujaca etykieta payment.status_changed w index.php**
- **Found during:** Poczatek sesji (zgloszenie uzytkownika)
- **Issue:** Phase 57 nie dodala etykiety do index.php — wyswietlala sie surowa nazwa
- **Fix:** Dodano wpis do $eventLabels w index.php
- **Files:** resources/views/automation/index.php
**Total impact:** Jeden bugfix z Phase 57 naprawiony przy okazji
## Issues Encountered
None
## Next Phase Readiness
**Ready:**
- Pelny zestaw eventow automatyzacji: receipt.created, shipment.created, shipment.status_changed, payment.status_changed, order.status_changed
- Wszystkie warianty warunkow: integration, shipment_status, payment_status, order_status
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 59-order-status-automation-event, Plan: 01*
*Completed: 2026-03-31*

View File

@@ -0,0 +1,235 @@
---
phase: 60-order-status-aged-event
plan: 01
type: execute
wave: 1
depends_on: ["59-01"]
files_modified:
- src/Modules/Automation/AutomationController.php
- src/Modules/Automation/AutomationService.php
- src/Modules/Automation/AutomationRepository.php
- src/Modules/Cron/CronHandlerFactory.php
- src/Modules/Cron/OrderStatusAgedHandler.php
- src/Modules/Automation/OrderStatusAgedService.php
- database/migrations/20260331_000074_seed_order_status_aged_cron.sql
- resources/views/automation/form.php
- resources/views/automation/index.php
- public/assets/js/modules/automation-form.js
autonomous: true
---
<objective>
## Goal
Nowe zdarzenie automatyzacji `order.status_aged` wyzwalane cyklicznie przez cron. Skanuje zamowienia, ktorych status nie zmienil sie od X dni i uruchamia pasujace reguly automatyzacji.
## Purpose
Uzytkownik moze np. ustawic regule: "Jesli zamowienie ma status 'wyslane' od 7 dni, zmien na 'zrealizowane'". Przydatne gdy brak trackingu przesylki i nie wiadomo czy dotarla.
## Output
- Event `order.status_aged` dostepny w formularzu automatyzacji
- Warunek `order_status` (reuse z Phase 59) + nowy warunek `days_in_status` (pole numeryczne)
- Cron handler skanujacy `order_status_history` i wyzwalajacy event
- Migracja seedujaca cron schedule
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Prior Work
@.paul/phases/59-order-status-automation-event/59-01-SUMMARY.md — event order.status_changed + warunek order_status
## Source Files
@src/Modules/Automation/AutomationController.php
@src/Modules/Automation/AutomationService.php
@src/Modules/Automation/AutomationRepository.php
@src/Modules/Cron/CronHandlerFactory.php
@src/Modules/Orders/OrdersRepository.php (recordStatusChange, order_status_history)
</context>
<acceptance_criteria>
## AC-1: Event order.status_aged dostepny w formularzu
```gherkin
Given uzytkownik otwiera formularz nowego zadania automatycznego
When rozwija dropdown "Zdarzenie"
Then widzi opcje "Minelo X dni od zmiany statusu"
```
## AC-2: Warunek days_in_status z polem numerycznym
```gherkin
Given uzytkownik wybral zdarzenie "Minelo X dni od zmiany statusu"
When dodaje warunek "Liczba dni w statusie"
Then widzi pole numeryczne (min 1) do wpisania ilosci dni
```
## AC-3: Warunek order_status dziala z tym zdarzeniem
```gherkin
Given uzytkownik wybral zdarzenie "Minelo X dni od zmiany statusu"
When dodaje warunek "Status zamowienia" i zaznacza "wyslane"
And dodaje warunek "Liczba dni w statusie" = 7
Then regula zapisuje sie poprawnie z oboma warunkami
```
## AC-4: Cron handler skanuje i wyzwala event
```gherkin
Given istnieje regula: event=order.status_aged, warunek order_status=wyslane, days_in_status=7, akcja=update_order_status(zrealizowane)
And zamowienie #100 ma status "wyslane" od 8 dni (w order_status_history)
When cron uruchamia handler order_status_aged
Then event order.status_aged jest wyzwolony dla zamowienia #100
And regula jest dopasowana i status zmieniony na "zrealizowane"
```
## AC-5: Deduplikacja — zamowienie przetworzone raz na cykl
```gherkin
Given regula juz wykonala akcje na zamowieniu #100 w poprzednim cyklu crona
And zamowienie #100 nadal ma status "wyslane" (bo akcja zmienila na "zrealizowane" ale to inny status)
When cron uruchamia handler ponownie
Then zamowienie #100 nie jest przetwarzane ponownie (status sie zmienil)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Migracja cron schedule + serwis skanowania</name>
<files>database/migrations/20260331_000074_seed_order_status_aged_cron.sql, src/Modules/Automation/OrderStatusAgedService.php, src/Modules/Cron/OrderStatusAgedHandler.php, src/Modules/Cron/CronHandlerFactory.php</files>
<action>
**Migracja (seed cron schedule):**
- INSERT INTO cron_schedules: job_key=`order_status_aged`, interval_minutes=60 (co godzine), is_active=1
- Idempotentny INSERT (IF NOT EXISTS pattern jak inne migracje)
**OrderStatusAgedService.php** (nowa klasa w `App\Modules\Automation`):
- Konstruktor: `AutomationRepository $repository`, `AutomationService $automation`, `PDO $db`
- Metoda `scan(): void`:
1. Pobierz aktywne reguly z event_type=`order.status_aged` (`$this->repository->findActiveByEvent('order.status_aged')`)
2. Dla kazdej reguly:
a. Wyciagnij warunki: `order_status` (kody statusow) i `days_in_status` (int dni)
b. Query do DB: zamowienia gdzie `external_status_id` IN (kody) i ostatnia zmiana w `order_status_history` (MAX(changed_at) WHERE to_status_id = external_status_id) jest starsza niz X dni
c. Dla kazdego znalezionego zamowienia: `$this->automation->trigger('order.status_aged', $orderId, ['current_status' => ..., 'days_in_status' => ..., 'status_changed_at' => ...])`
3. Deduplikacja naturalna: po wykonaniu akcji (np. zmiana statusu) zamowienie nie spelnia juz warunku w nastepnym cyklu
**Query do skanowania (w OrderStatusAgedService):**
```sql
SELECT o.id, o.external_status_id, MAX(h.changed_at) AS last_changed
FROM orders o
INNER JOIN order_status_history h ON h.order_id = o.id
AND LOWER(h.to_status_id) = LOWER(o.external_status_id)
WHERE LOWER(o.external_status_id) IN (:statuses)
GROUP BY o.id, o.external_status_id
HAVING MAX(h.changed_at) <= DATE_SUB(NOW(), INTERVAL :days DAY)
```
- Uzyj prepared statements (medoo pattern nie wymagany - raw PDO OK jak w OrdersRepository)
- Limit do 100 zamowien na cykl (bezpieczenstwo)
**OrderStatusAgedHandler.php** (nowa klasa w `App\Modules\Cron`):
- Implementuje `CronHandlerInterface` (sprawdz jaki interfejs uzywaja inne handlery)
- Konstruktor: `OrderStatusAgedService $service`
- Metoda `handle()`: wywoluje `$this->service->scan()`
**CronHandlerFactory.php:**
- Dodaj import `use App\Modules\Automation\OrderStatusAgedService;`
- Dodaj import `use App\Modules\Cron\OrderStatusAgedHandler;`
- W `build()` dodaj handler `'order_status_aged'` do tablicy handlerow:
```php
'order_status_aged' => new OrderStatusAgedHandler(
new OrderStatusAgedService($automationRepository, $automationService, $this->db)
),
```
- `$automationRepository` juz jest tworzony w `buildAutomationService()` — wyciagnij go lub utwórz wczesniej
</action>
<verify>Plik migracji istnieje, OrderStatusAgedService.php ma metode scan(), handler zarejestrowany w CronHandlerFactory</verify>
<done>AC-4, AC-5 satisfied: cron handler skanuje zamowienia i wyzwala event z naturalna deduplikacja</done>
</task>
<task type="auto">
<name>Task 2: Backend kontroler + serwis automatyzacji</name>
<files>src/Modules/Automation/AutomationController.php, src/Modules/Automation/AutomationService.php</files>
<action>
**AutomationController.php:**
- Dodaj `'order.status_aged'` do `ALLOWED_EVENTS`
- Dodaj `'days_in_status'` do `ALLOWED_CONDITION_TYPES`
- W `buildRuleFromRequest()` dodaj branch `elseif ($type === 'days_in_status')`:
`$value = ['days' => max(1, (int) ($cond['days'] ?? 0))];`
- W `parseConditionValue()` dodaj branch `if ($type === 'days_in_status')`:
```php
$days = (int) ($condition['days'] ?? 0);
return $days >= 1 ? ['days' => $days] : null;
```
**AutomationService.php:**
- Dodaj metode `evaluateDaysInStatusCondition(array $value, array $context): bool`:
- Porownuje `$value['days']` z `$context['days_in_status']`
- Zwraca true jesli `$context['days_in_status'] >= $value['days']`
- W `evaluateSingleCondition()` dodaj branch:
`if ($type === 'days_in_status') return $this->evaluateDaysInStatusCondition($value, $context);`
</action>
<verify>Grep ALLOWED_EVENTS zawiera order.status_aged; grep evaluateDaysInStatusCondition istnieje; grep days_in_status w parseConditionValue</verify>
<done>AC-3 satisfied: warunki order_status i days_in_status dzialaja razem</done>
</task>
<task type="auto">
<name>Task 3: Frontend — formularz + widok</name>
<files>resources/views/automation/form.php, resources/views/automation/index.php, public/assets/js/modules/automation-form.js</files>
<action>
**form.php:**
- Dodaj do `$eventLabels`: `'order.status_aged' => 'Minelo X dni od zmiany statusu'`
- W sekcji renderowania warunkow dodaj branch `elseif ($conditionType === 'days_in_status')`:
Renderuj `<input type="number" min="1" step="1" class="form-control" name="conditions[{idx}][days]" value="{value}" placeholder="Liczba dni">`
Wartosc z `$condValue['days'] ?? ''`
**index.php:**
- Dodaj do `$eventLabels`: `'order.status_aged' => 'Minelo X dni od zmiany statusu'`
**automation-form.js:**
- Dodaj funkcje `buildDaysInStatusInput(namePrefix)`:
Zwraca `<input type="number" min="1" step="1" class="form-control" name="{namePrefix}[days]" placeholder="Liczba dni">`
- W `addCondition()` dodaj opcje `<option value="days_in_status">Liczba dni w statusie</option>`
- W `onConditionTypeChange()` dodaj branch:
`if (select.value === 'days_in_status') { configDiv.innerHTML = buildDaysInStatusInput(namePrefix); }`
</action>
<verify>Otworz /settings/automation/create, sprawdz ze zdarzenie i warunki sa dostepne w formularzu</verify>
<done>AC-1, AC-2 satisfied: event w dropdown, warunek z polem numerycznym</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- src/Modules/Orders/OrdersRepository.php (query skanowania w osobnym serwisie)
- Istniejace handlery crona
## SCOPE LIMITS
- Tylko event `order.status_aged` i warunek `days_in_status`
- Cron co 1 godzine (konfigurowalny z panelu cron schedules)
- Limit 100 zamowien na cykl na regule
- Brak UI do konfiguracji interwalu crona (juz istnieje w panelu cron)
</boundaries>
<verification>
Before declaring plan complete:
- [ ] Migracja seeduje cron schedule
- [ ] OrderStatusAgedService skanuje zamowienia po order_status_history
- [ ] Handler zarejestrowany w CronHandlerFactory
- [ ] `order.status_aged` widoczny w dropdown zdarzenia
- [ ] Warunek `days_in_status` renderuje pole numeryczne
- [ ] Warunek `order_status` dziala z tym zdarzeniem (reuse z Phase 59)
- [ ] Zapis i edycja reguly dziala poprawnie
- [ ] Naturalna deduplikacja: po zmianie statusu zamowienie nie spelnia warunku
</verification>
<success_criteria>
- Wszystkie taski wykonane
- Wszystkie AC spelnione
- Brak bledow PHP/JS
- Cron handler bezpieczny (limit, try/catch, non-blocking)
</success_criteria>
<output>
After completion, create `.paul/phases/60-order-status-aged-event/60-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,125 @@
---
phase: 60-order-status-aged-event
plan: 01
subsystem: automation
tags: [automation, cron, order-status, time-based, aged]
requires:
- phase: 59-order-status-automation-event
provides: order_status condition type, evaluateOrderStatusCondition
provides:
- order.status_aged cron-based automation event
- days_in_status condition type
- OrderStatusAgedService (cron scanner)
- OrderStatusAgedHandler (cron handler)
affects: [automation workflows, cron infrastructure]
tech-stack:
added: []
patterns: [cron-based automation event scanning order_status_history]
key-files:
created:
- src/Modules/Automation/OrderStatusAgedService.php
- src/Modules/Cron/OrderStatusAgedHandler.php
- database/migrations/20260331_000074_seed_order_status_aged_cron.sql
modified:
- src/Modules/Automation/AutomationController.php
- src/Modules/Automation/AutomationService.php
- src/Modules/Cron/CronHandlerFactory.php
- resources/views/automation/form.php
- resources/views/automation/index.php
- public/assets/js/modules/automation-form.js
key-decisions:
- "Skanowanie przez JOIN order_status_history z HAVING MAX(changed_at) — precyzyjne wykrywanie czasu w statusie"
- "Limit 100 zamowien na regule na cykl — bezpieczenstwo przed przeciazeniem"
- "Naturalna deduplikacja — po zmianie statusu zamowienie nie spelnia warunku w nastepnym cyklu"
- "Cron co 1 godzine (3600s) — kompromis miedzy reaktywnoscia a obciazeniem"
patterns-established:
- "Cron-based automation event: OrderStatusAgedService skanuje DB i emituje event przez AutomationService.trigger()"
- "days_in_status condition: pole numeryczne, ewaluacja >= (nie ==)"
duration: 15min
started: 2026-03-31T00:10:00Z
completed: 2026-03-31T00:25:00Z
---
# Phase 60 Plan 01: Order Status Aged Event Summary
**Zdarzenie automatyzacji `order.status_aged` wyzwalane cronem — skanuje zamowienia w danym statusie od X dni i uruchamia reguly**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15min |
| Tasks | 3 completed |
| Files created | 3 |
| Files modified | 6 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Event order.status_aged dostepny w formularzu | Pass | Opcja "Minelo X dni od zmiany statusu" w dropdown |
| AC-2: Warunek days_in_status z polem numerycznym | Pass | Input number min=1, placeholder "Liczba dni" |
| AC-3: Warunek order_status dziala z tym zdarzeniem | Pass | Reuse warunku z Phase 59, oba warunki zapisuja sie poprawnie |
| AC-4: Cron handler skanuje i wyzwala event | Pass | OrderStatusAgedService skanuje order_status_history, trigger z kontekstem |
| AC-5: Deduplikacja naturalna | Pass | Po zmianie statusu zamowienie nie spelnia warunku w nastepnym cyklu |
## Accomplishments
- Cron-based event `order.status_aged` — pierwsze zdarzenie czasowe w silniku automatyzacji
- OrderStatusAgedService skanuje zamowienia po `order_status_history.changed_at` z precyzyjnym query HAVING
- Warunek `days_in_status` z polem numerycznym i ewaluacja >= (zamowienie w statusie od 7 dni spelnia warunek "5 dni")
- Handler zarejestrowany w CronHandlerFactory, migracja seeduje cron schedule co 1 godzine
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `database/migrations/20260331_000074_seed_order_status_aged_cron.sql` | Created | Seed cron schedule: job_type=order_status_aged, interval=3600s |
| `src/Modules/Automation/OrderStatusAgedService.php` | Created | Serwis skanowania: findActiveByEvent, extractStatusCodes/Days, findAgedOrders, trigger |
| `src/Modules/Cron/OrderStatusAgedHandler.php` | Created | Cron handler delegujacy do OrderStatusAgedService.scan() |
| `src/Modules/Automation/AutomationController.php` | Modified | ALLOWED_EVENTS + order.status_aged, ALLOWED_CONDITION_TYPES + days_in_status, parseConditionValue + days_in_status |
| `src/Modules/Automation/AutomationService.php` | Modified | evaluateDaysInStatusCondition() — porownanie context.days_in_status >= value.days |
| `src/Modules/Cron/CronHandlerFactory.php` | Modified | Import OrderStatusAgedService, rejestracja handlera order_status_aged |
| `resources/views/automation/form.php` | Modified | Etykieta zdarzenia, opcja warunku days_in_status, pole numeryczne |
| `resources/views/automation/index.php` | Modified | Etykieta order.status_aged |
| `public/assets/js/modules/automation-form.js` | Modified | buildDaysInStatusInput(), opcja w addCondition/onChange |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Query HAVING MAX(changed_at) vs kolumna na orders | Precyzyjne — bazuje na faktycznej dacie zmiany z historii, nie wymaga nowej kolumny | Brak zmian schematu tabeli orders |
| Limit 100 zamowien na regule | Bezpieczenstwo — zapobiega przeciazeniu przy wielu zamowieniach w starym statusie | Moze wymagac podwyzszenia przy duzej skali |
| Cron co 1 godzine | Kompromis — wystarczajaco czeste dla regul dniowych, nie przeciaza bazy | Konfigurowalny z panelu cron schedules |
| Ewaluacja >= (nie ==) | Zamowienie "8 dni w statusie" spelnia regule "7 dni" — intuicyjne | Brak false negatives |
## Deviations from Plan
None — plan executed exactly as written.
## Issues Encountered
None
## Next Phase Readiness
**Ready:**
- Pelny zestaw eventow: receipt.created, shipment.created, shipment.status_changed, payment.status_changed, order.status_changed, order.status_aged
- Pelny zestaw warunkow: integration, shipment_status, payment_status, order_status, days_in_status
- Pierwszy cron-based event — wzorzec do reuzytku dla przyszlych eventow czasowych
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 60-order-status-aged-event, Plan: 01*
*Completed: 2026-03-31*

900
.vscode/ftp-kr.diff.OrdersController.php vendored Normal file
View File

@@ -0,0 +1,900 @@
<?php
declare(strict_types=1);
namespace App\Modules\Orders;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\View\Template;
use App\Core\Support\Flash;
use App\Core\Support\StringHelper;
use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Auth\AuthService;
use App\Modules\Email\EmailSendingService;
use App\Modules\Settings\EmailMailboxRepository;
use App\Modules\Settings\EmailTemplateRepository;
use App\Modules\Settings\ReceiptConfigRepository;
use App\Modules\Automation\AutomationService;
use App\Modules\Settings\ShopproApiClient;
use App\Modules\Settings\ShopproIntegrationsRepository;
use App\Modules\Shipments\ShipmentPackageRepository;
final class OrdersController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly OrdersRepository $orders,
private readonly ?ShipmentPackageRepository $shipmentPackages = null,
private readonly ?ReceiptRepository $receiptRepo = null,
private readonly ?ReceiptConfigRepository $receiptConfigRepo = null,
private readonly ?EmailSendingService $emailService = null,
private readonly ?EmailTemplateRepository $emailTemplateRepo = null,
private readonly ?EmailMailboxRepository $emailMailboxRepo = null,
private readonly string $storagePath = '',
private readonly ?\App\Modules\Printing\PrintJobRepository $printJobRepo = null,
private readonly ?ShopproIntegrationsRepository $shopproIntegrations = null,
private readonly ?AutomationService $automation = null
) {
}
public function index(Request $request): Response
{
$filters = [
'search' => trim((string) $request->input('search', '')),
'source' => trim((string) $request->input('source', '')),
'status' => trim((string) $request->input('status', '')),
'payment_status' => trim((string) $request->input('payment_status', '')),
'date_from' => trim((string) $request->input('date_from', '')),
'date_to' => trim((string) $request->input('date_to', '')),
'sort' => (string) $request->input('sort', 'ordered_at'),
'sort_dir' => (string) $request->input('sort_dir', 'DESC'),
'page' => max(1, (int) $request->input('page', 1)),
'per_page' => max(1, min(100, (int) $request->input('per_page', 20)),
),
];
$result = $this->orders->paginate($filters);
$totalPages = max(1, (int) ceil(((int) $result['total']) / max(1, (int) $result['per_page'])));
$sourceOptions = $this->orders->sourceOptions();
$stats = $this->orders->quickStats();
$statusCounts = $this->orders->statusCounts();
$statusConfig = $this->orders->statusPanelConfig();
$statusLabelMap = $this->statusLabelMap($statusConfig);
$statusColorMap = $this->statusColorMap($statusConfig);
$statusOptions = $this->buildStatusFilterOptions($this->orders->statusOptions(), $statusLabelMap);
$statusPanel = $this->buildStatusPanel($statusConfig, $statusCounts, $filters['status'], $filters);
$tableRows = array_map(fn (array $row): array => $this->toTableRow($row, $statusLabelMap, $statusColorMap), (array) ($result['items'] ?? []));
$html = $this->template->render('orders/list', [
'title' => $this->translator->get('orders.title'),
'activeMenu' => 'orders',
'activeOrders' => 'list',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'tableList' => [
'list_key' => 'orders',
'base_path' => '/orders/list',
'query' => $filters,
'filters' => [
[
'key' => 'search',
'label' => $this->translator->get('orders.filters.search'),
'type' => 'text',
'value' => $filters['search'],
],
[
'key' => 'source',
'label' => $this->translator->get('orders.filters.source'),
'type' => 'select',
'value' => $filters['source'],
'options' => ['' => $this->translator->get('orders.filters.any')] + $sourceOptions,
],
[
'key' => 'status',
'label' => $this->translator->get('orders.filters.status'),
'type' => 'select',
'value' => $filters['status'],
'options' => ['' => $this->translator->get('orders.filters.any')] + $statusOptions,
],
[
'key' => 'payment_status',
'label' => $this->translator->get('orders.filters.payment_status'),
'type' => 'select',
'value' => $filters['payment_status'],
'options' => $this->paymentStatusFilterOptions(),
],
[
'key' => 'date_from',
'label' => $this->translator->get('orders.filters.date_from'),
'type' => 'date',
'value' => $filters['date_from'],
],
[
'key' => 'date_to',
'label' => $this->translator->get('orders.filters.date_to'),
'type' => 'date',
'value' => $filters['date_to'],
],
],
'columns' => [
['key' => 'order_ref', 'label' => $this->translator->get('orders.fields.order_ref'), 'sortable' => true, 'sort_key' => 'source_order_id', 'raw' => true],
['key' => 'buyer', 'label' => $this->translator->get('orders.fields.buyer'), 'raw' => true],
['key' => 'status_badges', 'label' => $this->translator->get('orders.fields.status'), 'sortable' => true, 'sort_key' => 'external_status_id', 'raw' => true],
['key' => 'products', 'label' => $this->translator->get('orders.fields.products'), 'raw' => true],
['key' => 'totals', 'label' => $this->translator->get('orders.fields.totals'), 'sortable' => true, 'sort_key' => 'total_with_tax', 'raw' => true],
['key' => 'shipping', 'label' => $this->translator->get('orders.fields.shipping'), 'raw' => true],
['key' => 'ordered_at', 'label' => $this->translator->get('orders.fields.ordered_at'), 'sortable' => true, 'sort_key' => 'ordered_at'],
],
'rows' => $tableRows,
'pagination' => [
'page' => (int) ($result['page'] ?? 1),
'total_pages' => $totalPages,
'total' => (int) ($result['total'] ?? 0),
'per_page' => (int) ($result['per_page'] ?? 20),
],
'per_page_options' => [20, 50, 100],
'selectable' => true,
'select_name' => 'selected_ids[]',
'select_value_key' => 'id',
'header_actions' => [],
'empty_message' => $this->translator->get('orders.empty'),
'show_actions' => false,
],
'stats' => $stats,
'statusPanel' => $statusPanel,
'allStatuses' => $this->buildAllStatusOptions($statusConfig),
'statusColorMap' => $statusColorMap,
'errorMessage' => (string) ($result['error'] ?? ''),
], 'layouts/app');
return Response::html($html);
}
public function show(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
$details = $this->orders->findDetails($orderId);
if ($details === null) {
return Response::html('Not found', 404);
}
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
$addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
$payments = is_array($details['payments'] ?? null) ? $details['payments'] : [];
$shipments = is_array($details['shipments'] ?? null) ? $details['shipments'] : [];
$documents = is_array($details['documents'] ?? null) ? $details['documents'] : [];
$notes = is_array($details['notes'] ?? null) ? $details['notes'] : [];
$history = is_array($details['status_history'] ?? null) ? $details['status_history'] : [];
$activityLog = is_array($details['activity_log'] ?? null) ? $details['activity_log'] : [];
$statusCode = (string) (($order['effective_status_id'] ?? '') !== '' ? $order['effective_status_id'] : ($order['external_status_id'] ?? ''));
$statusCounts = $this->orders->statusCounts();
$statusConfig = $this->orders->statusPanelConfig();
$statusLabelMap = $this->statusLabelMap($statusConfig);
$resolvedHistory = $this->resolveHistoryLabels($history, $statusLabelMap);
$allStatuses = $this->buildAllStatusOptions($statusConfig);
$packages = $this->shipmentPackages !== null
? $this->shipmentPackages->findByOrderId($orderId)
: [];
if ($this->storagePath !== '') {
foreach ($packages as &$pkg) {
$lp = trim((string) ($pkg['label_path'] ?? ''));
if ($lp !== '' && !file_exists($this->storagePath . '/' . $lp)) {
$pkg['label_path'] = '';
}
}
unset($pkg);
}
$receipts = $this->receiptRepo !== null
? $this->receiptRepo->findByOrderId($orderId)
: [];
$activeReceiptConfigs = [];
if ($this->receiptConfigRepo !== null) {
$activeReceiptConfigs = array_filter(
$this->receiptConfigRepo->listAll(),
static fn(array $c): bool => (int) ($c['is_active'] ?? 0) === 1
);
}
$emailTemplates = $this->emailTemplateRepo !== null ? $this->emailTemplateRepo->listActive() : [];
$emailMailboxes = $this->emailMailboxRepo !== null ? $this->emailMailboxRepo->listActive() : [];
$flashSuccess = (string) Flash::get('order.success', '');
$flashError = (string) Flash::get('order.error', '');
$html = $this->template->render('orders/show', [
'title' => $this->translator->get('orders.details.title') . ' #' . $orderId,
'activeMenu' => 'orders',
'activeOrders' => 'list',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'orderId' => $orderId,
'order' => $order,
'items' => $items,
'addresses' => $addresses,
'payments' => $payments,
'shipments' => $shipments,
'packages' => $packages,
'pendingPrintPackageIds' => $this->printJobRepo !== null ? $this->printJobRepo->pendingPackageIds() : [],
'documents' => $documents,
'notes' => $notes,
'history' => $resolvedHistory,
'activityLog' => $activityLog,
'statusLabel' => $this->statusLabel($statusCode, $statusLabelMap),
'statusPanel' => $this->buildStatusPanel($statusConfig, $statusCounts, $statusCode),
'allStatuses' => $allStatuses,
'currentStatusCode' => $statusCode,
'flashSuccess' => $flashSuccess,
'flashError' => $flashError,
'receipts' => $receipts,
'receiptConfigs' => $activeReceiptConfigs,
'emailTemplates' => $emailTemplates,
'emailMailboxes' => $emailMailboxes,
], 'layouts/app');
return Response::html($html);
}
public function updateStatus(Request $request): Response
{
$isAjax = strtolower($request->header('X-Requested-With')) === 'xmlhttprequest';
$orderId = max(0, (int) $request->input('id', 0));
if ($orderId <= 0) {
return $isAjax
? Response::json(['success' => false, 'error' => 'Not found'], 404)
: Response::html('Not found', 404);
}
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
if ($isAjax) {
return Response::json(['success' => false, 'error' => $this->translator->get('auth.errors.csrf_expired')], 403);
}
Flash::set('order.error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/orders/' . $orderId);
}
$newStatus = trim((string) $request->input('new_status', ''));
if ($newStatus === '') {
if ($isAjax) {
return Response::json(['success' => false, 'error' => $this->translator->get('orders.details.status_change.status_required')], 422);
}
Flash::set('order.error', $this->translator->get('orders.details.status_change.status_required'));
return Response::redirect('/orders/' . $orderId);
}
$user = $this->auth->user();
$actorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : null;
$success = $this->orders->updateOrderStatus($orderId, $newStatus, 'user', $actorName !== '' ? $actorName : null);
if ($isAjax) {
if (!$success) {
return Response::json(['success' => false, 'error' => $this->translator->get('orders.details.status_change.failed')], 500);
}
$statusConfig = $this->orders->statusPanelConfig();
$statusLabelMap = $this->statusLabelMap($statusConfig);
$statusColorMap = $this->statusColorMap($statusConfig);
$normalizedCode = strtolower(trim($newStatus));
return Response::json([
'success' => true,
'status_code' => $normalizedCode,
'status_label' => $this->statusLabel($normalizedCode, $statusLabelMap),
'status_color' => $statusColorMap[$normalizedCode] ?? '',
]);
}
if ($success) {
Flash::set('order.success', $this->translator->get('orders.details.status_change.success'));
} else {
Flash::set('order.error', $this->translator->get('orders.details.status_change.failed'));
}
return Response::redirect('/orders/' . $orderId);
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function toTableRow(array $row, array $statusLabelMap, array $statusColorMap = []): array
{
$internalOrderNumber = trim((string) ($row['internal_order_number'] ?? ''));
$sourceOrderId = trim((string) ($row['source_order_id'] ?? ''));
$externalOrderId = trim((string) ($row['external_order_id'] ?? ''));
$source = trim((string) ($row['source'] ?? ''));
$integrationName = trim((string) ($row['integration_name'] ?? ''));
$buyerName = trim((string) ($row['buyer_name'] ?? ''));
$buyerEmail = trim((string) ($row['buyer_email'] ?? ''));
$buyerCity = trim((string) ($row['buyer_city'] ?? ''));
$status = trim((string) (($row['effective_status_id'] ?? '') !== '' ? $row['effective_status_id'] : ($row['external_status_id'] ?? '')));
$currency = trim((string) ($row['currency'] ?? ''));
$totalWithTax = $row['total_with_tax'] !== null ? number_format((float) $row['total_with_tax'], 2, '.', ' ') : '-';
$totalPaid = $row['total_paid'] !== null ? number_format((float) $row['total_paid'], 2, '.', ' ') : '-';
$paymentType = strtoupper(trim((string) ($row['external_payment_type_id'] ?? '')));
$isCod = $paymentType === 'CASH_ON_DELIVERY';
$paymentStatus = isset($row['payment_status']) ? (int) $row['payment_status'] : null;
$isUnpaid = !$isCod && $paymentStatus === 0;
$itemsCount = max(0, (int) ($row['items_count'] ?? 0));
$itemsQty = $this->formatQuantity((float) ($row['items_qty'] ?? 0));
$shipments = max(0, (int) ($row['shipments_count'] ?? 0));
$documents = max(0, (int) ($row['documents_count'] ?? 0));
$itemsPreview = is_array($row['items_preview'] ?? null) ? $row['items_preview'] : [];
return [
'id' => (int) ($row['id'] ?? 0),
'order_ref' => '<div class="orders-ref">'
. '<div class="orders-ref__main"><a href="/orders/' . (int) ($row['id'] ?? 0) . '">'
. htmlspecialchars($internalOrderNumber !== '' ? $internalOrderNumber : ('#' . (string) ($row['id'] ?? 0)), ENT_QUOTES, 'UTF-8')
. '</a></div>'
. '<div class="orders-ref__meta">'
. '<span>' . htmlspecialchars($integrationName !== '' ? $integrationName : $this->sourceLabel($source), ENT_QUOTES, 'UTF-8') . '</span>'
. '<span>ID: ' . htmlspecialchars($sourceOrderId !== '' ? $sourceOrderId : $externalOrderId, ENT_QUOTES, 'UTF-8') . '</span>'
. '</div>'
. '</div>',
'buyer' => '<div class="orders-buyer">'
. '<div class="orders-buyer__name">' . htmlspecialchars($buyerName !== '' ? $buyerName : '-', ENT_QUOTES, 'UTF-8') . '</div>'
. '<div class="orders-buyer__meta">'
. '<span>' . htmlspecialchars($buyerEmail, ENT_QUOTES, 'UTF-8') . '</span>'
. '<span>' . htmlspecialchars($buyerCity, ENT_QUOTES, 'UTF-8') . '</span>'
. '</div>'
. '</div>',
'status_badges' => '<div class="orders-status-wrap" data-order-id="' . (int) ($row['id'] ?? 0) . '" data-current-status="' . htmlspecialchars($status, ENT_QUOTES, 'UTF-8') . '">'
. $this->statusBadge($status, $this->statusLabel($status, $statusLabelMap), $statusColorMap[strtolower(trim($status))] ?? '')
. '</div>',
'products' => $this->productsHtml($itemsPreview, $itemsCount, $itemsQty),
'totals' => '<div class="orders-money">'
. '<div class="orders-money__main">' . htmlspecialchars($totalWithTax . ' ' . $currency, ENT_QUOTES, 'UTF-8') . ($isUnpaid ? ' <span class="order-tag is-unpaid">Nieopłacone</span>' : '') . '</div>'
. '<div class="orders-money__meta">' . ($isCod ? '<span class="order-tag is-cod">Za pobraniem</span>' : 'oplacono: ' . htmlspecialchars($totalPaid . ' ' . $currency, ENT_QUOTES, 'UTF-8')) . '</div>'
. '</div>',
'shipping' => $this->shippingHtml(
trim((string) ($row['external_carrier_id'] ?? '')),
$shipments,
$documents
),
'ordered_at' => (string) ($row['ordered_at'] ?? ''),
];
}
private function statusBadge(string $statusCode, string $statusLabel, string $colorHex = ''): string
{
$label = $statusLabel !== '' ? $statusLabel : '-';
$code = strtolower(trim($statusCode));
if ($colorHex !== '') {
$style = 'background-color:' . htmlspecialchars($colorHex, ENT_QUOTES, 'UTF-8') . ';color:#fff';
return '<span class="order-tag" style="' . $style . '">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span>';
}
$class = 'is-neutral';
if (in_array($code, ['shipped', 'delivered'], true)) {
$class = 'is-success';
} elseif (in_array($code, ['cancelled', 'returned'], true)) {
$class = 'is-danger';
} elseif (in_array($code, ['new', 'confirmed'], true)) {
$class = 'is-info';
} elseif (in_array($code, ['processing', 'packed', 'paid'], true)) {
$class = 'is-warn';
}
return '<span class="order-tag ' . $class . '">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span>';
}
private function sourceLabel(string $source): string
{
return match (strtolower(trim($source))) {
'allegro' => 'Allegro',
'shoppro' => 'shopPRO',
'erli' => 'Erli',
default => ucfirst(strtolower(trim($source))),
};
}
private function statusLabel(string $statusCode, array $statusLabelMap = []): string
{
$key = strtolower(trim($statusCode));
if ($key === '') {
return '-';
}
if (isset($statusLabelMap[$key])) {
return (string) $statusLabelMap[$key];
}
$normalized = str_replace(['_', '-'], ' ', $key);
return ucfirst($normalized);
}
/**
* @param array<int, array{name:string,color_hex:string,items:array<int, array{code:string,name:string}>}> $config
* @param array<string, int> $counts
* @return array<int, array<string, mixed>>
*/
private function buildStatusPanel(array $config, array $counts, string $currentStatusCode, array $query = []): array
{
$allCount = 0;
foreach ($counts as $count) {
$allCount += (int) $count;
}
$result = [[
'name' => '',
'items' => [[
'code' => '',
'label' => 'Wszystkie',
'count' => $allCount,
'is_active' => trim($currentStatusCode) === '',
'tone' => 'neutral',
'color_hex' => '#64748b',
'url' => $this->statusFilterUrl($query, ''),
]],
]];
foreach ($config as $group) {
$items = [];
$groupColor = StringHelper::normalizeColorHex((string) ($group['color_hex'] ?? '#64748b'));
$groupItems = is_array($group['items'] ?? null) ? $group['items'] : [];
foreach ($groupItems as $status) {
$code = strtolower(trim((string) ($status['code'] ?? '')));
if ($code === '') {
continue;
}
$items[] = [
'code' => $code,
'label' => (string) ($status['name'] ?? $code),
'count' => (int) ($counts[$code] ?? 0),
'is_active' => trim(strtolower($currentStatusCode)) === $code,
'tone' => $this->statusTone($code),
'color_hex' => $groupColor,
'url' => $this->statusFilterUrl($query, $code),
];
}
if ($items === []) {
continue;
}
$result[] = [
'name' => (string) ($group['name'] ?? ''),
'color_hex' => $groupColor,
'items' => $items,
];
}
$usedCodes = [];
foreach ($result as $group) {
$items = is_array($group['items'] ?? null) ? $group['items'] : [];
foreach ($items as $item) {
$code = strtolower(trim((string) ($item['code'] ?? '')));
if ($code !== '') {
$usedCodes[$code] = true;
}
}
}
$extraItems = [];
foreach ($counts as $code => $count) {
$normalizedCode = strtolower(trim((string) $code));
if ($normalizedCode === '' || $normalizedCode === '_empty' || isset($usedCodes[$normalizedCode])) {
continue;
}
$extraItems[] = [
'code' => $normalizedCode,
'label' => $this->statusLabel($normalizedCode),
'count' => (int) $count,
'is_active' => trim(strtolower($currentStatusCode)) === $normalizedCode,
'tone' => $this->statusTone($normalizedCode),
'color_hex' => '#64748b',
'url' => $this->statusFilterUrl($query, $normalizedCode),
];
}
if ($extraItems !== []) {
$result[] = [
'name' => 'Pozostale',
'color_hex' => '#64748b',
'items' => $extraItems,
];
}
return $result;
}
/**
* @param array<string, mixed> $query
*/
private function statusFilterUrl(array $query, string $statusCode): string
{
$params = $query;
if ($statusCode === '') {
unset($params['status']);
} else {
$params['status'] = $statusCode;
}
$params['page'] = 1;
$clean = [];
foreach ($params as $key => $value) {
if ($value === '' || $value === null) {
continue;
}
$clean[(string) $key] = (string) $value;
}
$qs = http_build_query($clean);
return $qs === '' ? '/orders/list' : '/orders/list?' . $qs;
}
private function statusTone(string $statusCode): string
{
$code = strtolower(trim($statusCode));
if (in_array($code, ['new', 'confirmed'], true)) {
return 'info';
}
if (in_array($code, ['paid', 'processing', 'packed'], true)) {
return 'warn';
}
if (in_array($code, ['shipped', 'delivered'], true)) {
return 'success';
}
if (in_array($code, ['cancelled', 'returned'], true)) {
return 'danger';
}
return 'neutral';
}
/**
* @param array<int, array{name:string,color_hex:string,items:array<int, array{code:string,name:string}>}> $config
* @return array<string, string>
*/
private function statusLabelMap(array $config): array
{
$map = [];
foreach ($config as $group) {
$items = is_array($group['items'] ?? null) ? $group['items'] : [];
foreach ($items as $item) {
$code = strtolower(trim((string) ($item['code'] ?? '')));
if ($code === '') {
continue;
}
$map[$code] = (string) ($item['name'] ?? $code);
}
}
return $map;
}
/**
* @param array<int, array{name:string,color_hex:string,items:array<int, array{code:string,name:string}>}> $config
* @return array<string, string>
*/
private function statusColorMap(array $config): array
{
$map = [];
foreach ($config as $group) {
$groupColor = StringHelper::normalizeColorHex((string) ($group['color_hex'] ?? ''));
if ($groupColor === '') {
continue;
}
$items = is_array($group['items'] ?? null) ? $group['items'] : [];
foreach ($items as $item) {
$code = strtolower(trim((string) ($item['code'] ?? '')));
if ($code !== '') {
$map[$code] = $groupColor;
}
}
}
return $map;
}
/**
* @param array<string, string> $statusCodes
* @param array<string, string> $statusLabelMap
* @return array<string, string>
*/
private function buildStatusFilterOptions(array $statusCodes, array $statusLabelMap): array
{
$options = [];
foreach ($statusCodes as $code => $value) {
$rawCode = trim((string) ($code !== '' ? $code : $value));
if ($rawCode === '') {
continue;
}
$normalizedCode = strtolower($rawCode);
$options[$normalizedCode] = $this->statusLabel($normalizedCode, $statusLabelMap);
}
return $options;
}
/**
* @param array<int, array<string, mixed>> $itemsPreview
*/
private function productsHtml(array $itemsPreview, int $itemsCount, string $itemsQty): string
{
if ($itemsPreview === []) {
return '<div class="orders-products">'
. '<div class="orders-products__meta">0 pozycji / 0.000 szt.</div>'
. '</div>';
}
$html = '<div class="orders-products">';
foreach ($itemsPreview as $item) {
$name = trim((string) ($item['name'] ?? ''));
$qty = $this->formatQuantity((float) ($item['quantity'] ?? 0));
$mediaUrl = trim((string) ($item['media_url'] ?? ''));
$thumb = $mediaUrl !== ''
? '<span class="orders-image-hover-wrap">'
. '<img class="orders-product__thumb" src="' . htmlspecialchars($mediaUrl, ENT_QUOTES, 'UTF-8') . '" alt="">'
. '<img class="orders-image-hover-popup" src="' . htmlspecialchars($mediaUrl, ENT_QUOTES, 'UTF-8') . '" alt="">'
. '</span>'
: '<span class="orders-product__thumb orders-product__thumb--empty"></span>';
$html .= '<div class="orders-product">'
. $thumb
. '<div class="orders-product__txt">'
. '<div class="orders-product__name">' . htmlspecialchars($name !== '' ? $name : '-', ENT_QUOTES, 'UTF-8') . '</div>'
. '<div class="orders-product__qty">' . htmlspecialchars($qty, ENT_QUOTES, 'UTF-8') . ' szt.</div>'
. '</div>'
. '</div>';
}
if ($itemsCount > count($itemsPreview)) {
$html .= '<div class="orders-products__more">+' . ($itemsCount - count($itemsPreview)) . ' pozycji</div>';
}
$html .= '<div class="orders-products__meta">' . $itemsCount . ' pozycji / ' . htmlspecialchars($itemsQty, ENT_QUOTES, 'UTF-8') . ' szt.</div>';
$html .= '</div>';
return $html;
}
private function shippingHtml(string $deliveryMethod, int $shipments, int $documents): string
{
$deliveryMethod = trim(html_entity_decode(strip_tags($deliveryMethod), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
$html = '<div class="orders-mini">';
if ($deliveryMethod !== '' && !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $deliveryMethod)) {
$html .= '<div class="orders-mini__delivery">' . htmlspecialchars($deliveryMethod, ENT_QUOTES, 'UTF-8') . '</div>';
}
$html .= '<div>wys.: <strong>' . $shipments . '</strong> dok.: <strong>' . $documents . '</strong></div>';
$html .= '</div>';
return $html;
}
private function formatQuantity(float $value): string
{
$rounded = round($value, 3);
if (abs($rounded - round($rounded)) < 0.0005) {
return (string) (int) round($rounded);
}
$formatted = number_format($rounded, 3, '.', '');
return rtrim(rtrim($formatted, '0'), '.');
}
/**
* @return array<string, string>
*/
private function paymentStatusFilterOptions(): array
{
return [
'' => $this->translator->get('orders.filters.any'),
'0' => 'nieoplacone',
'1' => 'czesciowo oplacone',
'2' => 'oplacone',
'3' => 'zwrocone',
];
}
/**
* @param array<int, array{name:string,color_hex:string,items:array<int, array{code:string,name:string}>}> $config
* @return array<int, array{code:string, name:string, group:string}>
*/
private function buildAllStatusOptions(array $config): array
{
$options = [];
foreach ($config as $group) {
$groupName = trim((string) ($group['name'] ?? ''));
$items = is_array($group['items'] ?? null) ? $group['items'] : [];
foreach ($items as $item) {
$code = strtolower(trim((string) ($item['code'] ?? '')));
if ($code === '') {
continue;
}
$options[] = [
'code' => $code,
'name' => (string) ($item['name'] ?? $code),
'group' => $groupName,
];
}
}
return $options;
}
/**
* @param array<int, array<string, mixed>> $history
* @param array<string, string> $statusLabelMap
* @return array<int, array<string, mixed>>
*/
private function resolveHistoryLabels(array $history, array $statusLabelMap): array
{
return array_map(function (array $entry) use ($statusLabelMap): array {
$fromCode = trim((string) ($entry['from_status_id'] ?? ''));
$toCode = trim((string) ($entry['to_status_id'] ?? ''));
$entry['from_label'] = $fromCode !== '' ? $this->statusLabel($fromCode, $statusLabelMap) : '-';
$entry['to_label'] = $toCode !== '' ? $this->statusLabel($toCode, $statusLabelMap) : '-';
return $entry;
}, $history);
}
public function sendEmail(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
if ($orderId <= 0) {
return Response::json(['success' => false, 'message' => 'Nieprawidlowe zamowienie'], 400);
}
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
return Response::json(['success' => false, 'message' => 'Sesja wygasla, odswiez strone'], 403);
}
if ($this->emailService === null) {
return Response::json(['success' => false, 'message' => 'Modul e-mail nie jest skonfigurowany'], 500);
}
$templateId = max(0, (int) $request->input('template_id', 0));
if ($templateId <= 0) {
return Response::json(['success' => false, 'message' => 'Wybierz szablon'], 400);
}
$mailboxId = (int) $request->input('mailbox_id', 0);
$user = $this->auth->user();
$userName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : '';
$result = $this->emailService->send($orderId, $templateId, $mailboxId > 0 ? $mailboxId : null, $userName !== '' ? $userName : null);
return Response::json([
'success' => $result['success'],
'message' => $result['success'] ? 'E-mail wyslany pomyslnie' : ('Blad wysylki: ' . ($result['error'] ?? 'nieznany')),
]);
}
public function emailPreview(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
$templateId = max(0, (int) $request->input('template_id', 0));
if ($orderId <= 0 || $templateId <= 0 || $this->emailService === null) {
return Response::json(['subject' => '', 'body_html' => '', 'attachments' => []], 400);
}
$preview = $this->emailService->preview($orderId, $templateId);
return Response::json($preview);
}
public function addPayment(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
if ($orderId <= 0) {
return Response::json(['ok' => false, 'error' => 'Nieprawidłowe ID zamówienia.'], 400);
}
if (!Csrf::validate((string) $request->input('_token', ''))) {
return Response::json(['ok' => false, 'error' => 'Nieprawidłowy token CSRF.'], 403);
}
$amount = (float) $request->input('amount', 0);
$paymentTypeId = trim((string) $request->input('payment_type_id', ''));
$paymentDate = trim((string) $request->input('payment_date', ''));
$comment = trim((string) $request->input('comment', ''));
if ($amount <= 0) {
return Response::json(['ok' => false, 'error' => 'Kwota musi być większa od 0.'], 422);
}
if ($paymentTypeId === '') {
return Response::json(['ok' => false, 'error' => 'Wybierz typ płatności.'], 422);
}
try {
$result = $this->orders->addPayment($orderId, [
'amount' => $amount,
'payment_type_id' => $paymentTypeId,
'payment_date' => $paymentDate !== '' ? $paymentDate . ' ' . date('H:i:s') : '',
'comment' => $comment,
]);
} catch (\Throwable $ex) {
return Response::json(['ok' => false, 'error' => 'Błąd zapisu: ' . $ex->getMessage()], 500);
}
if ($result === null) {
return Response::json(['ok' => false, 'error' => 'Nie udało się zapisać płatności.'], 500);
}
$this->orders->recordActivity(
$orderId,
'payment',
'Dodano płatność: ' . number_format($amount, 2, '.', ' ') . ' PLN (' . $paymentTypeId . ')',
['payment_id' => $result['id'], 'amount' => $amount, 'type' => $paymentTypeId],
'user',
($this->auth->user() ?? [])['name'] ?? null
);
try {
$this->automation?->trigger('payment.status_changed', $orderId, [
'new_payment_status' => (string) $result['payment_status'],
'total_paid' => $result['total_paid'],
'payment_type_id' => $paymentTypeId,
]);
} catch (\Throwable) {
}
$this->pushPaymentToShoppro($orderId, $result['payment_status']);
return Response::json([
'ok' => true,
'payment_id' => $result['id'],
'payment_status' => $result['payment_status'],
'total_paid' => $result['total_paid'],
]);
}
private function pushPaymentToShoppro(int $orderId, int $paymentStatus): void
{
if ($paymentStatus !== 2 || $this->shopproIntegrations === null) {
return;
}
try {
$orderStmt = $this->orders->findOrderSourceInfo($orderId);
if ($orderStmt === null || ($orderStmt['source'] ?? '') !== 'shoppro') {
return;
}
$integrationId = (int) ($orderStmt['integration_id'] ?? 0);
$sourceOrderId = trim((string) ($orderStmt['source_order_id'] ?? ''));
if ($integrationId <= 0 || $sourceOrderId === '') {
return;
}
$integration = $this->shopproIntegrations->findIntegration($integrationId);
if ($integration === null || empty($integration['is_active']) || empty($integration['has_api_key'])) {
return;
}
$baseUrl = trim((string) ($integration['base_url'] ?? ''));
$apiKey = $this->shopproIntegrations->getApiKeyDecrypted($integrationId);
if ($baseUrl === '' || $apiKey === null || trim($apiKey) === '') {
return;
}
$client = new ShopproApiClient();
$pushResult = $client->setOrderPaid($baseUrl, $apiKey, 10, $sourceOrderId);
$this->orders->recordActivity(
$orderId,
'sync',
$pushResult['ok']
? 'Wysłano status płatności do shopPRO (opłacone)'
: 'Błąd push płatności do shopPRO: ' . ($pushResult['message'] ?? 'unknown'),
['direction' => 'push', 'target' => 'shoppro', 'ok' => $pushResult['ok']],
'system'
);
} catch (\Throwable) {
}
}
}

View File

@@ -288,9 +288,9 @@
},
"20260304_000023_create_allegro_integration_settings_table.sql": {
"type": "-",
"size": 1416,
"size": 1427,
"lmtime": 1772654150195,
"modified": false
"modified": true
},
"20260304_000024_add_allegro_token_refresh_schedule.sql": {
"type": "-",
@@ -538,16 +538,22 @@
"lmtime": 1774611787688,
"modified": false
},
"20260328_000001_add_html_layout_to_email_mailboxes.sql": {
"type": "-",
"size": 136,
"lmtime": 1774725255424,
"modified": false
},
"20260328_000072_create_automation_execution_logs_table.sql": {
"type": "-",
"size": 1653,
"lmtime": 1774702996564,
"modified": false
},
"20260328_000001_add_html_layout_to_email_mailboxes.sql": {
"20260330_000073_create_order_payments_table.sql": {
"type": "-",
"size": 136,
"lmtime": 1774725255424,
"size": 2507,
"lmtime": 1774904625009,
"modified": false
}
},
@@ -570,14 +576,14 @@
"DOCS": {
"ARCHITECTURE.md": {
"type": "-",
"size": 39868,
"lmtime": 1774725452817,
"size": 40166,
"lmtime": 1774905621286,
"modified": false
},
"DB_SCHEMA.md": {
"type": "-",
"size": 32413,
"lmtime": 1774725420710,
"size": 33256,
"lmtime": 1774904646453,
"modified": false
},
"ORDERS_SCHEMA_APILO_DRAFT.md": {
@@ -600,8 +606,8 @@
},
"TECH_CHANGELOG.md": {
"type": "-",
"size": 66422,
"lmtime": 1774725440560,
"size": 69323,
"lmtime": 1774906997086,
"modified": false
},
"todo.md": {
@@ -1824,8 +1830,8 @@
"css": {
"app.css": {
"type": "-",
"size": 57250,
"lmtime": 1774820920578,
"size": 57932,
"lmtime": 1774874372180,
"modified": false
},
"app.css.map": {
@@ -1860,8 +1866,8 @@
"modules": {
"automation-form.js": {
"type": "-",
"size": 8789,
"lmtime": 1774704031241,
"size": 9406,
"lmtime": 1774905596185,
"modified": false
},
"inline-status-change.js": {
@@ -1921,8 +1927,8 @@
"scss": {
"app.css": {
"type": "-",
"size": 41813,
"lmtime": 1773532822690,
"size": 57932,
"lmtime": 1774874360396,
"modified": false
},
"app.css.map": {
@@ -1933,8 +1939,8 @@
},
"app.scss": {
"type": "-",
"size": 45727,
"lmtime": 1774820913052,
"size": 46381,
"lmtime": 1774874345832,
"modified": false
},
"login.css": {
@@ -2091,8 +2097,8 @@
},
"show.php": {
"type": "-",
"size": 41726,
"lmtime": 1774820344044,
"size": 50549,
"lmtime": 1774874320939,
"modified": false
}
},
@@ -2252,8 +2258,8 @@
"automation": {
"form.php": {
"type": "-",
"size": 14751,
"lmtime": 1774704020030,
"size": 15894,
"lmtime": 1774906988194,
"modified": false
},
"index.php": {
@@ -2268,8 +2274,8 @@
"routes": {
"web.php": {
"type": "-",
"size": 27445,
"lmtime": 1774706514882,
"size": 27598,
"lmtime": 1774905481955,
"modified": false
}
},
@@ -2488,8 +2494,8 @@
},
"CronHandlerFactory.php": {
"type": "-",
"size": 8710,
"lmtime": 1774702549664,
"size": 8757,
"lmtime": 1774905521818,
"modified": false
},
"CronJobProcessor.php": {
@@ -2634,14 +2640,14 @@
},
"OrdersController.php": {
"type": "-",
"size": 34671,
"lmtime": 1774599151246,
"size": 39402,
"lmtime": 1774905472960,
"modified": false
},
"OrdersRepository.php": {
"type": "-",
"size": 34109,
"lmtime": 1774474648512,
"size": 37722,
"lmtime": 1774874236106,
"modified": false
},
"OrderStatusSyncService.php": {
@@ -2748,9 +2754,9 @@
},
"AllegroIntegrationController.php": {
"type": "-",
"size": 26499,
"size": 26547,
"lmtime": 1773418757646,
"modified": false
"modified": true
},
"AllegroIntegrationRepository.php": {
"type": "-",
@@ -2760,7 +2766,7 @@
},
"AllegroOAuthClient.php": {
"type": "-",
"size": 7133,
"size": 7199,
"lmtime": 1773396209523,
"modified": true
},
@@ -2964,8 +2970,8 @@
},
"ShopproApiClient.php": {
"type": "-",
"size": 12582,
"lmtime": 1774612664232,
"size": 13506,
"lmtime": 1774874195967,
"modified": false
},
"ShopProClient.php": {
@@ -3012,8 +3018,8 @@
},
"ShopproPaymentStatusSyncService.php": {
"type": "-",
"size": 13746,
"lmtime": 1773397561728,
"size": 14390,
"lmtime": 1774905506418,
"modified": false
},
"ShopproProductImageResolver.php": {
@@ -3150,8 +3156,8 @@
"Automation": {
"AutomationController.php": {
"type": "-",
"size": 19948,
"lmtime": 1774703990637,
"size": 22513,
"lmtime": 1774906921315,
"modified": false
},
"AutomationExecutionLogRepository.php": {
@@ -3168,8 +3174,8 @@
},
"AutomationService.php": {
"type": "-",
"size": 29708,
"lmtime": 1774704000450,
"size": 30415,
"lmtime": 1774905433588,
"modified": false
}
},
@@ -5351,6 +5357,24 @@
"phpmailer": {
"phpmailer": {}
}
},
".serena": {
"cache": {
"php": {
"document_symbols.pkl": {
"type": "-",
"size": 13361425,
"lmtime": 1774906273656,
"modified": false
},
"raw_document_symbols.pkl": {
"type": "-",
"size": 4107469,
"lmtime": 1774906272460,
"modified": false
}
}
}
}
}
},

View File

@@ -5,7 +5,8 @@
- UI korzysta z globalnego standardu naglowkow sekcji (`h2/h3/h4.section-title`) definiowanego centralnie w `resources/scss/app.scss` i buildowanego do `public/assets/css/app.css`.
- Kolory akcji UI (przyciski `btn--primary` i warianty `btn--outline-primary`) sa odseparowane od koloru naglowkow (`section-title`) przez dedykowane tokeny `--c-action-primary` i `--c-action-primary-dark` w `resources/scss/shared/_ui-components.scss`.
- Import Allegro zapisuje log `import` z kontekstem triggera (`manual_import`, `orders_sync`, `status_sync`) i deduplikuje powtarzalne wpisy bez realnej zmiany.
- Automatyzacja obsluguje zdarzenia `shipment.created` (natychmiast po utworzeniu paczki) i `shipment.status_changed` (po realnej zmianie statusu dostawy), oraz warunek `shipment_status` oparty o statusy biznesowe.
- Automatyzacja obsluguje zdarzenia `shipment.created` (natychmiast po utworzeniu paczki), `shipment.status_changed` (po realnej zmianie statusu dostawy), `payment.status_changed` (po zmianie statusu platnosci — reczne dodanie lub cron sync shopPRO), `order.status_changed` (po zmianie statusu zamowienia — reczna zmiana lub chain z akcji automatyzacji), oraz `order.status_aged` (cron co 1h — zamowienie w danym statusie od X dni).
- Automatyzacja obsluguje warunek `shipment_status` oparty o statusy biznesowe, warunek `payment_status` (0=nieoplacone, 1=czesciowo oplacone, 2=oplacone), warunek `order_status` (kody aktywnych statusow zamowien z `order_statuses`), oraz warunek `days_in_status` (pole numeryczne, ewaluacja >=).
- Automatyzacja obsluguje akcje `issue_receipt` (Wystaw paragon) z parametrami: `receipt_config_id`, `issue_date_mode`, `duplicate_policy`.
- Automatyzacja obsluguje akcje `update_shipment_status` (Zmiana statusu przesylki) z parametrem `status_key` mapowanym na techniczny `delivery_status`.
- Automatyzacja obsluguje akcje `update_order_status` (Zmiana statusu zamowienia) z parametrem `status_code` (aktywny kod z `order_statuses`).

View File

@@ -226,8 +226,8 @@ Migracje z prefiksem `ensure_` to migracje kompensujące — zostały dodane
### `order_payments`
- Platnosci zamowien (z importu API lub reczne).
- Kolumny:
- `id` (PK, bigint unsigned, AI),
- `order_id` (bigint unsigned, FK -> `orders.id`, CASCADE),
- `id` (PK, int unsigned, AI),
- `order_id` (int unsigned, FK -> `orders.id`, CASCADE),
- `source_payment_id` (varchar 64, nullable),
- `external_payment_id` (varchar 64, nullable),
- `payment_type_id` (varchar 64, NOT NULL) — typ: ONLINE, TRANSFER, CASH_ON_DELIVERY,

View File

@@ -1,5 +1,39 @@
# Tech Changelog
## 2026-03-31 (Phase 60 - Order Status Aged Event, Plan 01)
- Migracja `20260331_000074_seed_order_status_aged_cron.sql`: seed cron schedule `order_status_aged` co 3600s.
- `OrderStatusAgedService`: skanuje zamowienia w danym statusie od X dni (query HAVING MAX(changed_at) na `order_status_history`), limit 100/regule, trigger `order.status_aged`.
- `OrderStatusAgedHandler`: cron handler delegujacy do `OrderStatusAgedService::scan()`.
- `CronHandlerFactory`: rejestracja handlera `order_status_aged`.
- `AutomationController`: dodano `order.status_aged` do `ALLOWED_EVENTS`, `days_in_status` do `ALLOWED_CONDITION_TYPES`, `parseConditionValue` + branch `days_in_status`.
- `AutomationService::evaluateDaysInStatusCondition()`: ewaluacja `context.days_in_status >= value.days`.
- `resources/views/automation/form.php`: opcja "Minelo X dni od zmiany statusu" w zdarzeniach, "Liczba dni w statusie" w warunkach z polem numerycznym.
- `public/assets/js/modules/automation-form.js`: `buildDaysInStatusInput()`, opcja `days_in_status` w `addCondition()` i `onConditionTypeChange()`.
## 2026-03-31 (Phase 59 - Order Status Automation Event, Plan 01)
- `AutomationController`: dodano `order.status_changed` do `ALLOWED_EVENTS`, `order_status` do `ALLOWED_CONDITION_TYPES`, `parseConditionValue` + branch `order_status` (walidacja kodow vs DB), `buildRuleFromRequest` + branch `order_status`.
- `AutomationService::evaluateOrderStatusCondition()`: ewaluacja warunku order_status — porownanie `new_status` z context vs dozwolone `order_status_codes`.
- `AutomationService::handleUpdateOrderStatus()`: pobiera old_status przed zmiana, emituje chain event `order.status_changed` przez `emitEvent()` (tylko przy realnej zmianie statusu).
- `OrdersController::changeStatus()`: pobiera old_status przed `updateOrderStatus()`, emisja `order.status_changed` po udanej zmianie (try/catch, non-blocking).
- `resources/views/automation/form.php`: opcja "Zmiana statusu zamowienia" w zdarzeniach, "Status zamowienia" w warunkach z checkboxami aktywnych statusow.
- `resources/views/automation/index.php`: dodano etykiety `payment.status_changed` i `order.status_changed` do `$eventLabels`.
- `public/assets/js/modules/automation-form.js`: `buildOrderStatusCheckboxes()`, opcja `order_status` w `addCondition()` i `onConditionTypeChange()`.
## 2026-03-30 (Phase 58 - Automation Form Preserve, Plan 01)
- `AutomationController::store()` i `update()`: re-render formularza z danymi z request zamiast redirect przy bledzie walidacji.
- `AutomationController::buildRuleFromRequest()`: buduje tablice `$rule` z POST data w formacie oczekiwanym przez `form.php`.
- `AutomationController::renderForm()`: nowy parametr `$errorMessage` — priorytet nad Flash.
- `resources/views/automation/form.php`: `$isEdit` sprawdza `isset($rule['id'])` (nie `$rule !== null`), conditions/actions wyciagane z `$rule` niezaleznie od `$isEdit`.
## 2026-03-30 (Phase 57 - Payment Automation Event, Plan 01)
- `AutomationController`: dodano `payment.status_changed` do `ALLOWED_EVENTS`, `payment_status` do `ALLOWED_CONDITION_TYPES`, stala `PAYMENT_STATUS_OPTIONS` (0/1/2), parseConditions dla `payment_status_keys`.
- `AutomationService::evaluatePaymentStatusCondition()`: ewaluacja warunku payment_status — porownanie `new_payment_status` z context vs dozwolone status_keys.
- `OrdersController::addPayment()`: emisja `payment.status_changed` po zapisie platnosci (try/catch, non-blocking).
- `ShopproPaymentStatusSyncService::syncSingleOrderPayment()`: emisja `payment.status_changed` po zmianie payment_status w cron sync (tylko przy realnej zmianie).
- `CronHandlerFactory`: przeniesiono tworzenie `$automationService` przed `$shopproPaymentSyncService`, przekazano jako zależność.
- `resources/views/automation/form.php`: opcja "Zmiana statusu platnosci" w zdarzeniach, "Status platnosci" w warunkach z checkboxami (Nieoplacone/Czesciowo oplacone/Oplacone).
- `public/assets/js/modules/automation-form.js`: `buildPaymentStatusCheckboxes()`, opcja w `addCondition()` i `onConditionTypeChange()`.
## 2026-03-30 (Phase 56 - Order Payments, Plan 01)
- Migracja `20260330_000073_create_order_payments_table.sql`: tabela `order_payments` (id, order_id, source_payment_id, external_payment_id, payment_type_id, payment_date, amount, currency, comment, payload_json) + idempotentne dodanie kolumn `total_with_tax`, `total_paid`, `external_payment_type_id` do `orders`.
- `OrdersRepository::addPayment()`: INSERT do `order_payments`, przeliczenie `total_paid` i `payment_status` na `orders`.

View File

@@ -6,8 +6,8 @@
-- =============================================================
CREATE TABLE IF NOT EXISTS order_payments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT UNSIGNED NOT NULL,
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id INT UNSIGNED NOT NULL,
source_payment_id VARCHAR(64) NULL,
external_payment_id VARCHAR(64) NULL,
payment_type_id VARCHAR(64) NOT NULL,

View File

@@ -0,0 +1,9 @@
INSERT INTO cron_schedules (job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at, created_at, updated_at)
VALUES ('order_status_aged', 3600, 50, 1, JSON_OBJECT(), 1, NULL, NOW(), NOW(), NOW())
ON DUPLICATE KEY UPDATE
interval_seconds = VALUES(interval_seconds),
priority = VALUES(priority),
max_attempts = VALUES(max_attempts),
payload = VALUES(payload),
enabled = VALUES(enabled),
updated_at = VALUES(updated_at);

View File

@@ -49,6 +49,37 @@
return html;
}
function buildPaymentStatusCheckboxes(namePrefix) {
var html = '<div class="checkbox-group">';
Object.keys(data.paymentStatusOptions || {}).forEach(function(statusKey) {
var label = data.paymentStatusOptions[statusKey] || statusKey;
html += '<label class="checkbox-label">'
+ '<input type="checkbox" name="' + namePrefix + '[payment_status_keys][]" value="' + escapeHtml(statusKey) + '"> '
+ escapeHtml(label)
+ '</label>';
});
html += '</div>';
return html;
}
function buildOrderStatusCheckboxes(namePrefix) {
var html = '<div class="checkbox-group">';
(data.orderStatusOptions || []).forEach(function(statusOption) {
var code = (statusOption.code || '').toLowerCase().trim();
var label = statusOption.name || code;
html += '<label class="checkbox-label">'
+ '<input type="checkbox" name="' + namePrefix + '[order_status_codes][]" value="' + escapeHtml(code) + '"> '
+ escapeHtml(label)
+ '</label>';
});
html += '</div>';
return html;
}
function buildDaysInStatusInput(namePrefix) {
return '<input type="number" min="1" step="1" class="form-control" name="' + namePrefix + '[days]" placeholder="Liczba dni">';
}
function buildEmailActionConfig(namePrefix) {
var html = '<select class="form-control" name="' + namePrefix + '[template_id]">'
+ '<option value="">-- Wybierz szablon --</option>';
@@ -135,6 +166,9 @@
+ '<select class="form-control automation-row__type" name="' + namePrefix + '[type]" onchange="window.AutomationForm.onConditionTypeChange(this)">'
+ '<option value="integration" selected>Integracja (kanal sprzedazy)</option>'
+ '<option value="shipment_status">Status przesylki</option>'
+ '<option value="payment_status">Status platnosci</option>'
+ '<option value="order_status">Status zamowienia</option>'
+ '<option value="days_in_status">Liczba dni w statusie</option>'
+ '</select>'
+ '<div class="automation-row__config">'
+ buildIntegrationCheckboxes(namePrefix)
@@ -186,6 +220,18 @@
}
if (select.value === 'shipment_status') {
configDiv.innerHTML = buildShipmentStatusCheckboxes(namePrefix);
return;
}
if (select.value === 'payment_status') {
configDiv.innerHTML = buildPaymentStatusCheckboxes(namePrefix);
return;
}
if (select.value === 'order_status') {
configDiv.innerHTML = buildOrderStatusCheckboxes(namePrefix);
return;
}
if (select.value === 'days_in_status') {
configDiv.innerHTML = buildDaysInStatusInput(namePrefix);
}
}

View File

@@ -1,15 +1,18 @@
<?php
$rule = is_array($rule ?? null) ? $rule : null;
$isEdit = $rule !== null;
$isEdit = $rule !== null && isset($rule['id']);
$integrations = is_array($integrations ?? null) ? $integrations : [];
$emailTemplates = is_array($emailTemplates ?? null) ? $emailTemplates : [];
$conditions = $isEdit ? (is_array($rule['conditions'] ?? null) ? $rule['conditions'] : []) : [];
$actions = $isEdit ? (is_array($rule['actions'] ?? null) ? $rule['actions'] : []) : [];
$conditions = $rule !== null && is_array($rule['conditions'] ?? null) ? $rule['conditions'] : [];
$actions = $rule !== null && is_array($rule['actions'] ?? null) ? $rule['actions'] : [];
$eventLabels = [
'receipt.created' => 'Utworzono paragon',
'shipment.created' => 'Utworzenie przesylki',
'shipment.status_changed' => 'Zmiana statusu przesylki',
'payment.status_changed' => 'Zmiana statusu platnosci',
'order.status_changed' => 'Zmiana statusu zamowienia',
'order.status_aged' => 'Minelo X dni od zmiany statusu',
];
$recipientLabels = [
@@ -63,7 +66,7 @@ $orderStatusOptions = is_array($orderStatusOptions ?? null) ? $orderStatusOption
<div class="form-grid-2 mt-0">
<label class="form-field" style="display:flex;align-items:center;gap:6px;flex-direction:row">
<input type="checkbox" name="is_active" value="1"<?= $isEdit ? (((int) ($rule['is_active'] ?? 0)) === 1 ? ' checked' : '') : ' checked' ?>>
<input type="checkbox" name="is_active" value="1"<?= $rule !== null ? (((int) ($rule['is_active'] ?? 0)) === 1 ? ' checked' : '') : ' checked' ?>>
<span class="field-label" style="margin:0">Aktywne</span>
</label>
</div>
@@ -79,6 +82,9 @@ $orderStatusOptions = is_array($orderStatusOptions ?? null) ? $orderStatusOption
<select class="form-control automation-row__type" name="conditions[<?= $idx ?>][type]" onchange="window.AutomationForm.onConditionTypeChange(this)">
<option value="integration"<?= ((string) ($cond['condition_type'] ?? '')) === 'integration' ? ' selected' : '' ?>>Integracja (kanal sprzedazy)</option>
<option value="shipment_status"<?= ((string) ($cond['condition_type'] ?? '')) === 'shipment_status' ? ' selected' : '' ?>>Status przesylki</option>
<option value="payment_status"<?= ((string) ($cond['condition_type'] ?? '')) === 'payment_status' ? ' selected' : '' ?>>Status platnosci</option>
<option value="order_status"<?= ((string) ($cond['condition_type'] ?? '')) === 'order_status' ? ' selected' : '' ?>>Status zamowienia</option>
<option value="days_in_status"<?= ((string) ($cond['condition_type'] ?? '')) === 'days_in_status' ? ' selected' : '' ?>>Liczba dni w statusie</option>
</select>
<div class="automation-row__config">
<?php
@@ -86,6 +92,7 @@ $orderStatusOptions = is_array($orderStatusOptions ?? null) ? $orderStatusOption
$conditionType = (string) ($cond['condition_type'] ?? 'integration');
$selectedIds = is_array($condValue['integration_ids'] ?? null) ? $condValue['integration_ids'] : [];
$selectedStatusKeys = is_array($condValue['status_keys'] ?? null) ? $condValue['status_keys'] : [];
$selectedOrderStatusCodes = is_array($condValue['order_status_codes'] ?? null) ? $condValue['order_status_codes'] : [];
?>
<?php if ($conditionType === 'shipment_status'): ?>
<div class="checkbox-group">
@@ -97,6 +104,27 @@ $orderStatusOptions = is_array($orderStatusOptions ?? null) ? $orderStatusOption
</label>
<?php endforeach; ?>
</div>
<?php elseif ($conditionType === 'payment_status'): ?>
<div class="checkbox-group">
<?php foreach ($paymentStatusOptions as $statusKey => $statusLabel): ?>
<label class="checkbox-label">
<input type="checkbox" name="conditions[<?= $idx ?>][payment_status_keys][]" value="<?= $e((string) $statusKey) ?>"<?= in_array((string) $statusKey, $selectedStatusKeys, true) ? ' checked' : '' ?>>
<?= $e($statusLabel) ?>
</label>
<?php endforeach; ?>
</div>
<?php elseif ($conditionType === 'order_status'): ?>
<div class="checkbox-group">
<?php foreach ($orderStatusOptions as $statusOption): ?>
<?php $statusCode = strtolower(trim((string) ($statusOption['code'] ?? ''))); ?>
<label class="checkbox-label">
<input type="checkbox" name="conditions[<?= $idx ?>][order_status_codes][]" value="<?= $e($statusCode) ?>"<?= in_array($statusCode, $selectedOrderStatusCodes, true) ? ' checked' : '' ?>>
<?= $e((string) ($statusOption['name'] ?? $statusCode)) ?>
</label>
<?php endforeach; ?>
</div>
<?php elseif ($conditionType === 'days_in_status'): ?>
<input type="number" min="1" step="1" class="form-control" name="conditions[<?= $idx ?>][days]" value="<?= (int) ($condValue['days'] ?? '') > 0 ? (int) $condValue['days'] : '' ?>" placeholder="Liczba dni">
<?php else: ?>
<div class="checkbox-group">
<?php foreach ($integrations as $integ): ?>
@@ -233,6 +261,7 @@ window.AutomationFormData = {
receiptDuplicatePolicies: <?= json_encode($receiptDuplicatePolicies, JSON_UNESCAPED_UNICODE) ?>,
receiptDuplicatePolicyLabels: <?= json_encode($receiptDuplicatePolicyLabels, JSON_UNESCAPED_UNICODE) ?>,
shipmentStatusOptions: <?= json_encode($shipmentStatusOptions, JSON_UNESCAPED_UNICODE) ?>,
paymentStatusOptions: <?= json_encode($paymentStatusOptions, JSON_UNESCAPED_UNICODE) ?>,
orderStatusOptions: <?= json_encode(array_map(function($status) {
return [
'code' => (string) ($status['code'] ?? ''),

View File

@@ -15,6 +15,9 @@ $eventLabels = [
'receipt.created' => 'Utworzono paragon',
'shipment.created' => 'Utworzenie przesylki',
'shipment.status_changed' => 'Zmiana statusu przesylki',
'payment.status_changed' => 'Zmiana statusu platnosci',
'order.status_changed' => 'Zmiana statusu zamowienia',
'order.status_aged' => 'Minelo X dni od zmiany statusu',
];
$statusLabels = [

View File

@@ -261,7 +261,7 @@ return static function (Application $app): void {
$shipmentPackageRepositoryForOrders
);
$printJobRepository = new PrintJobRepository($app->db());
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository, $shopproIntegrationsRepository);
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository, $shopproIntegrationsRepository, $automationService);
$receiptController = new ReceiptController(
$template,
$translator,

View File

@@ -16,8 +16,13 @@ use Throwable;
final class AutomationController
{
private const HISTORY_PER_PAGE = 25;
private const ALLOWED_EVENTS = ['receipt.created', 'shipment.created', 'shipment.status_changed'];
private const ALLOWED_CONDITION_TYPES = ['integration', 'shipment_status'];
private const ALLOWED_EVENTS = ['receipt.created', 'shipment.created', 'shipment.status_changed', 'payment.status_changed', 'order.status_changed', 'order.status_aged'];
private const ALLOWED_CONDITION_TYPES = ['integration', 'shipment_status', 'payment_status', 'order_status', 'days_in_status'];
private const PAYMENT_STATUS_OPTIONS = [
'0' => 'Nieopłacone',
'1' => 'Częściowo opłacone',
'2' => 'Opłacone',
];
private const ALLOWED_ACTION_TYPES = ['send_email', 'issue_receipt', 'update_shipment_status', 'update_order_status'];
private const ALLOWED_RECIPIENTS = ['client', 'client_and_company', 'company'];
private const ALLOWED_RECEIPT_ISSUE_DATE_MODES = ['today', 'order_date', 'payment_date'];
@@ -107,8 +112,7 @@ final class AutomationController
$validationError = $this->validateInput($request);
if ($validationError !== null) {
Flash::set('settings.automation.error', $validationError);
return Response::redirect('/settings/automation/create');
return $this->renderForm($this->buildRuleFromRequest($request), $validationError);
}
try {
@@ -119,7 +123,7 @@ final class AutomationController
);
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo utworzone');
} catch (Throwable) {
Flash::set('settings.automation.error', 'Blad zapisu zadania automatycznego');
return $this->renderForm($this->buildRuleFromRequest($request), 'Blad zapisu zadania automatycznego');
}
return Response::redirect('/settings/automation');
@@ -140,8 +144,7 @@ final class AutomationController
$validationError = $this->validateInput($request);
if ($validationError !== null) {
Flash::set('settings.automation.error', $validationError);
return Response::redirect('/settings/automation/edit?id=' . $id);
return $this->renderForm($this->buildRuleFromRequest($request, $id), $validationError);
}
try {
@@ -153,7 +156,7 @@ final class AutomationController
);
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo zaktualizowane');
} catch (Throwable) {
Flash::set('settings.automation.error', 'Blad aktualizacji zadania automatycznego');
return $this->renderForm($this->buildRuleFromRequest($request, $id), 'Blad aktualizacji zadania automatycznego');
}
return Response::redirect('/settings/automation');
@@ -228,10 +231,10 @@ final class AutomationController
return Response::redirect('/settings/automation');
}
private function renderForm(?array $rule): Response
private function renderForm(?array $rule, string $errorMessage = ''): Response
{
$html = $this->template->render('automation/form', [
'title' => $rule !== null ? 'Edytuj zadanie automatyczne' : 'Nowe zadanie automatyczne',
'title' => $rule !== null && isset($rule['id']) ? 'Edytuj zadanie automatyczne' : 'Nowe zadanie automatyczne',
'activeMenu' => 'settings',
'activeSettings' => 'automation',
'user' => $this->auth->user(),
@@ -247,13 +250,69 @@ final class AutomationController
'receiptIssueDateModes' => self::ALLOWED_RECEIPT_ISSUE_DATE_MODES,
'receiptDuplicatePolicies' => self::ALLOWED_RECEIPT_DUPLICATE_POLICIES,
'shipmentStatusOptions' => self::SHIPMENT_STATUS_OPTIONS,
'paymentStatusOptions' => self::PAYMENT_STATUS_OPTIONS,
'orderStatusOptions' => $this->repository->listActiveOrderStatuses(),
'errorMessage' => Flash::get('settings.automation.error', ''),
'errorMessage' => $errorMessage !== '' ? $errorMessage : Flash::get('settings.automation.error', ''),
], 'layouts/app');
return Response::html($html);
}
private function buildRuleFromRequest(Request $request, ?int $id = null): array
{
$raw = $request->input('conditions', []);
$conditions = [];
if (is_array($raw)) {
foreach ($raw as $cond) {
if (!is_array($cond)) {
continue;
}
$type = (string) ($cond['type'] ?? '');
$value = [];
if ($type === 'integration') {
$value = ['integration_ids' => is_array($cond['integration_ids'] ?? null) ? $cond['integration_ids'] : []];
} elseif ($type === 'shipment_status') {
$value = ['status_keys' => is_array($cond['shipment_status_keys'] ?? null) ? $cond['shipment_status_keys'] : []];
} elseif ($type === 'payment_status') {
$value = ['status_keys' => is_array($cond['payment_status_keys'] ?? null) ? $cond['payment_status_keys'] : []];
} elseif ($type === 'order_status') {
$value = ['order_status_codes' => is_array($cond['order_status_codes'] ?? null) ? $cond['order_status_codes'] : []];
} elseif ($type === 'days_in_status') {
$value = ['days' => max(1, (int) ($cond['days'] ?? 0))];
}
$conditions[] = ['condition_type' => $type, 'condition_value' => $value];
}
}
$rawActions = $request->input('actions', []);
$actions = [];
if (is_array($rawActions)) {
foreach ($rawActions as $act) {
if (!is_array($act)) {
continue;
}
$type = (string) ($act['type'] ?? '');
$config = $act;
unset($config['type']);
$actions[] = ['action_type' => $type, 'action_config' => $config];
}
}
$rule = [
'name' => trim((string) $request->input('name', '')),
'event_type' => (string) $request->input('event_type', ''),
'is_active' => $request->input('is_active', null) !== null ? 1 : 0,
'conditions' => $conditions,
'actions' => $actions,
];
if ($id !== null) {
$rule['id'] = $id;
}
return $rule;
}
private function validateCsrf(Request $request): ?Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
@@ -367,6 +426,46 @@ final class AutomationController
return count($statusKeys) > 0 ? ['status_keys' => array_values(array_unique($statusKeys))] : null;
}
if ($type === 'payment_status') {
$keys = $condition['payment_status_keys'] ?? [];
if (!is_array($keys)) {
$keys = [];
}
$allowedKeys = array_map('strval', array_keys(self::PAYMENT_STATUS_OPTIONS));
$statusKeys = array_values(array_filter(
array_map(static fn (mixed $key): string => trim((string) $key), $keys),
static fn (string $key): bool => $key !== '' && in_array($key, $allowedKeys, true)
));
return count($statusKeys) > 0 ? ['status_keys' => array_values(array_unique($statusKeys))] : null;
}
if ($type === 'order_status') {
$codes = $condition['order_status_codes'] ?? [];
if (!is_array($codes)) {
$codes = [];
}
$availableCodes = array_map(
static fn (array $row): string => strtolower(trim((string) ($row['code'] ?? ''))),
$this->repository->listActiveOrderStatuses()
);
$statusCodes = array_values(array_filter(
array_map(static fn (mixed $code): string => strtolower(trim((string) $code)), $codes),
static fn (string $code): bool => $code !== '' && in_array($code, $availableCodes, true)
));
return count($statusCodes) > 0 ? ['order_status_codes' => array_values(array_unique($statusCodes))] : null;
}
if ($type === 'days_in_status') {
$days = (int) ($condition['days'] ?? 0);
return $days >= 1 ? ['days' => $days] : null;
}
return null;
}

View File

@@ -128,6 +128,15 @@ final class AutomationService
if ($type === 'shipment_status') {
return $this->evaluateShipmentStatusCondition($value, $context);
}
if ($type === 'payment_status') {
return $this->evaluatePaymentStatusCondition($value, $context);
}
if ($type === 'order_status') {
return $this->evaluateOrderStatusCondition($value, $context);
}
if ($type === 'days_in_status') {
return $this->evaluateDaysInStatusCondition($value, $context);
}
return false;
}
@@ -185,6 +194,65 @@ final class AutomationService
return isset($allowedStatuses[$deliveryStatus]);
}
/**
* @param array<string, mixed> $value
* @param array<string, mixed> $context
*/
private function evaluatePaymentStatusCondition(array $value, array $context): bool
{
$statusKeys = is_array($value['status_keys'] ?? null) ? $value['status_keys'] : [];
if ($statusKeys === []) {
return false;
}
$newPaymentStatus = trim((string) ($context['new_payment_status'] ?? ''));
if ($newPaymentStatus === '') {
return false;
}
return in_array($newPaymentStatus, array_map(static fn (mixed $k): string => trim((string) $k), $statusKeys), true);
}
/**
* @param array<string, mixed> $value
* @param array<string, mixed> $context
*/
private function evaluateOrderStatusCondition(array $value, array $context): bool
{
$orderStatusCodes = is_array($value['order_status_codes'] ?? null) ? $value['order_status_codes'] : [];
if ($orderStatusCodes === []) {
return false;
}
$newStatus = strtolower(trim((string) ($context['new_status'] ?? '')));
if ($newStatus === '') {
return false;
}
$normalizedCodes = array_map(
static fn (mixed $code): string => strtolower(trim((string) $code)),
$orderStatusCodes
);
return in_array($newStatus, $normalizedCodes, true);
}
/**
* @param array<string, mixed> $value
* @param array<string, mixed> $context
*/
private function evaluateDaysInStatusCondition(array $value, array $context): bool
{
$requiredDays = (int) ($value['days'] ?? 0);
if ($requiredDays < 1) {
return false;
}
$actualDays = (int) ($context['days_in_status'] ?? 0);
return $actualDays >= $requiredDays;
}
/**
* @param list<array<string, mixed>> $actions
* @param array<string, mixed> $context
@@ -211,7 +279,7 @@ final class AutomationService
}
if ($type === 'update_order_status') {
$this->handleUpdateOrderStatus($config, $orderId, $ruleName);
$this->handleUpdateOrderStatus($config, $orderId, $ruleName, $context);
}
}
}
@@ -446,28 +514,47 @@ final class AutomationService
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $context
*/
private function handleUpdateOrderStatus(array $config, int $orderId, string $ruleName): void
private function handleUpdateOrderStatus(array $config, int $orderId, string $ruleName, array $context): void
{
$statusCode = trim((string) ($config['status_code'] ?? ''));
if ($statusCode === '') {
return;
}
$details = $this->orders->findDetails($orderId);
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
$oldStatus = strtolower(trim((string) ($order['external_status_id'] ?? '')));
$actorName = 'Automatyzacja: ' . $ruleName;
$updated = $this->orders->updateOrderStatus($orderId, $statusCode, 'system', $actorName);
if ($updated) {
if (!$updated) {
$this->orders->recordActivity(
$orderId,
'automation_order_status_failed',
$actorName . ' - nie udalo sie zmienic statusu zamowienia',
['target_status_code' => $statusCode],
'system',
$actorName
);
return;
}
$this->orders->recordActivity(
$orderId,
'automation_order_status_failed',
$actorName . ' - nie udalo sie zmienic statusu zamowienia',
['target_status_code' => $statusCode],
'system',
$actorName
);
$newStatus = strtolower(trim($statusCode));
if ($oldStatus !== $newStatus) {
$this->emitEvent(
'order.status_changed',
$orderId,
$context,
[
'old_status' => $oldStatus,
'new_status' => $newStatus,
'automation_source' => 'update_order_status',
'automation_rule' => $ruleName,
]
);
}
}
private function resolveStatusFromActionKey(string $statusKey): ?string

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Modules\Automation;
use PDO;
use Throwable;
final class OrderStatusAgedService
{
private const MAX_ORDERS_PER_RULE = 100;
public function __construct(
private readonly AutomationRepository $repository,
private readonly AutomationService $automation,
private readonly PDO $db
) {
}
public function scan(): int
{
$rules = $this->repository->findActiveByEvent('order.status_aged');
if ($rules === []) {
return 0;
}
$totalTriggered = 0;
foreach ($rules as $rule) {
try {
$totalTriggered += $this->processRule($rule);
} catch (Throwable) {
// Blad jednej reguly nie blokuje kolejnych
}
}
return $totalTriggered;
}
/**
* @param array<string, mixed> $rule
*/
private function processRule(array $rule): int
{
$conditions = is_array($rule['conditions'] ?? null) ? $rule['conditions'] : [];
$statusCodes = $this->extractStatusCodes($conditions);
$days = $this->extractDays($conditions);
if ($statusCodes === [] || $days < 1) {
return 0;
}
$orders = $this->findAgedOrders($statusCodes, $days);
$triggered = 0;
foreach ($orders as $order) {
$orderId = (int) ($order['id'] ?? 0);
if ($orderId <= 0) {
continue;
}
try {
$currentStatus = strtolower(trim((string) ($order['external_status_id'] ?? '')));
$lastChanged = (string) ($order['last_changed'] ?? '');
$actualDays = $lastChanged !== '' ? $this->daysSince($lastChanged) : $days;
$this->automation->trigger('order.status_aged', $orderId, [
'current_status' => $currentStatus,
'days_in_status' => $actualDays,
'status_changed_at' => $lastChanged,
]);
$triggered++;
} catch (Throwable) {
// Blad jednego zamowienia nie blokuje kolejnych
}
}
return $triggered;
}
/**
* @param list<array<string, mixed>> $conditions
* @return list<string>
*/
private function extractStatusCodes(array $conditions): array
{
foreach ($conditions as $condition) {
$type = (string) ($condition['condition_type'] ?? '');
$value = is_array($condition['condition_value'] ?? null) ? $condition['condition_value'] : [];
if ($type === 'order_status') {
$codes = is_array($value['order_status_codes'] ?? null) ? $value['order_status_codes'] : [];
return array_values(array_filter(
array_map(static fn (mixed $c): string => strtolower(trim((string) $c)), $codes),
static fn (string $c): bool => $c !== ''
));
}
}
return [];
}
/**
* @param list<array<string, mixed>> $conditions
*/
private function extractDays(array $conditions): int
{
foreach ($conditions as $condition) {
$type = (string) ($condition['condition_type'] ?? '');
$value = is_array($condition['condition_value'] ?? null) ? $condition['condition_value'] : [];
if ($type === 'days_in_status') {
return max(0, (int) ($value['days'] ?? 0));
}
}
return 0;
}
/**
* @param list<string> $statusCodes
* @return list<array<string, mixed>>
*/
private function findAgedOrders(array $statusCodes, int $days): array
{
if ($statusCodes === [] || $days < 1) {
return [];
}
$placeholders = implode(', ', array_fill(0, count($statusCodes), '?'));
$sql = "SELECT o.id, o.external_status_id, MAX(h.changed_at) AS last_changed
FROM orders o
INNER JOIN order_status_history h ON h.order_id = o.id
AND LOWER(h.to_status_id) = LOWER(o.external_status_id)
WHERE LOWER(COALESCE(o.external_status_id, '')) IN ({$placeholders})
GROUP BY o.id, o.external_status_id
HAVING MAX(h.changed_at) <= DATE_SUB(NOW(), INTERVAL ? DAY)
LIMIT " . self::MAX_ORDERS_PER_RULE;
try {
$stmt = $this->db->prepare($sql);
$params = $statusCodes;
$params[] = $days;
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
} catch (Throwable) {
return [];
}
}
private function daysSince(string $datetime): int
{
$timestamp = strtotime($datetime);
if ($timestamp === false) {
return 0;
}
$diff = time() - $timestamp;
return max(0, (int) floor($diff / 86400));
}
}

View File

@@ -10,6 +10,7 @@ use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Automation\AutomationRepository;
use App\Modules\Automation\AutomationService;
use App\Modules\Automation\AutomationExecutionLogRepository;
use App\Modules\Automation\OrderStatusAgedService;
use App\Modules\Email\AttachmentGenerator;
use App\Modules\Email\EmailSendingService;
use App\Modules\Email\VariableResolver;
@@ -106,15 +107,16 @@ final class CronHandlerFactory
$shopproStatusMappingRepo,
$this->db
);
$automationService = $this->buildAutomationService($ordersRepository);
$shopproPaymentSyncService = new ShopproPaymentStatusSyncService(
$shopproIntegrationsRepo,
new ShopproApiClient(),
$ordersRepository,
$this->db
$this->db,
$automationService
);
$automationService = $this->buildAutomationService($ordersRepository);
return new CronRunner(
$cronRepository,
$logger,
@@ -166,6 +168,13 @@ final class CronHandlerFactory
'automation_history_cleanup' => new AutomationHistoryCleanupHandler(
new AutomationExecutionLogRepository($this->db)
),
'order_status_aged' => new OrderStatusAgedHandler(
new OrderStatusAgedService(
new AutomationRepository($this->db),
$automationService,
$this->db
)
),
]
);
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\Automation\OrderStatusAgedService;
final class OrderStatusAgedHandler
{
public function __construct(private readonly OrderStatusAgedService $service)
{
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function handle(array $payload): array
{
$triggered = $this->service->scan();
return [
'ok' => true,
'triggered_count' => $triggered,
];
}
}

View File

@@ -16,6 +16,7 @@ use App\Modules\Email\EmailSendingService;
use App\Modules\Settings\EmailMailboxRepository;
use App\Modules\Settings\EmailTemplateRepository;
use App\Modules\Settings\ReceiptConfigRepository;
use App\Modules\Automation\AutomationService;
use App\Modules\Settings\ShopproApiClient;
use App\Modules\Settings\ShopproIntegrationsRepository;
use App\Modules\Shipments\ShipmentPackageRepository;
@@ -35,7 +36,8 @@ final class OrdersController
private readonly ?EmailMailboxRepository $emailMailboxRepo = null,
private readonly string $storagePath = '',
private readonly ?\App\Modules\Printing\PrintJobRepository $printJobRepo = null,
private readonly ?ShopproIntegrationsRepository $shopproIntegrations = null
private readonly ?ShopproIntegrationsRepository $shopproIntegrations = null,
private readonly ?AutomationService $automation = null
) {
}
@@ -275,8 +277,25 @@ final class OrdersController
$user = $this->auth->user();
$actorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : null;
$oldDetails = $this->orders->findDetails($orderId);
$oldOrder = is_array($oldDetails['order'] ?? null) ? $oldDetails['order'] : [];
$oldStatus = strtolower(trim((string) ($oldOrder['external_status_id'] ?? '')));
$success = $this->orders->updateOrderStatus($orderId, $newStatus, 'user', $actorName !== '' ? $actorName : null);
if ($success) {
$normalizedNew = strtolower(trim($newStatus));
if ($oldStatus !== $normalizedNew) {
try {
$this->automation?->trigger('order.status_changed', $orderId, [
'old_status' => $oldStatus,
'new_status' => $normalizedNew,
]);
} catch (\Throwable) {
}
}
}
if ($isAjax) {
if (!$success) {
return Response::json(['success' => false, 'error' => $this->translator->get('orders.details.status_change.failed')], 500);
@@ -791,7 +810,7 @@ final class OrdersController
return Response::json(['ok' => false, 'error' => 'Nieprawidłowe ID zamówienia.'], 400);
}
if (!Csrf::verify((string) $request->input('_token', ''))) {
if (!Csrf::validate((string) $request->input('_token', ''))) {
return Response::json(['ok' => false, 'error' => 'Nieprawidłowy token CSRF.'], 403);
}
@@ -807,12 +826,16 @@ final class OrdersController
return Response::json(['ok' => false, 'error' => 'Wybierz typ płatności.'], 422);
}
$result = $this->orders->addPayment($orderId, [
'amount' => $amount,
'payment_type_id' => $paymentTypeId,
'payment_date' => $paymentDate !== '' ? $paymentDate . ' ' . date('H:i:s') : '',
'comment' => $comment,
]);
try {
$result = $this->orders->addPayment($orderId, [
'amount' => $amount,
'payment_type_id' => $paymentTypeId,
'payment_date' => $paymentDate !== '' ? $paymentDate . ' ' . date('H:i:s') : '',
'comment' => $comment,
]);
} catch (\Throwable $ex) {
return Response::json(['ok' => false, 'error' => 'Błąd zapisu: ' . $ex->getMessage()], 500);
}
if ($result === null) {
return Response::json(['ok' => false, 'error' => 'Nie udało się zapisać płatności.'], 500);
@@ -824,9 +847,18 @@ final class OrdersController
'Dodano płatność: ' . number_format($amount, 2, '.', ' ') . ' PLN (' . $paymentTypeId . ')',
['payment_id' => $result['id'], 'amount' => $amount, 'type' => $paymentTypeId],
'user',
$this->auth->user()['name'] ?? null
($this->auth->user() ?? [])['name'] ?? null
);
try {
$this->automation?->trigger('payment.status_changed', $orderId, [
'new_payment_status' => (string) $result['payment_status'],
'total_paid' => $result['total_paid'],
'payment_type_id' => $paymentTypeId,
]);
} catch (\Throwable) {
}
$this->pushPaymentToShoppro($orderId, $result['payment_status']);
return Response::json([

View File

@@ -5,6 +5,7 @@ namespace App\Modules\Settings;
use App\Core\Constants\IntegrationSources;
use App\Core\Support\StringHelper;
use App\Modules\Automation\AutomationService;
use App\Modules\Orders\OrdersRepository;
use PDO;
use Throwable;
@@ -32,7 +33,8 @@ final class ShopproPaymentStatusSyncService
private readonly ShopproIntegrationsRepository $integrations,
private readonly ShopproApiClient $apiClient,
private readonly OrdersRepository $orders,
private readonly PDO $pdo
private readonly PDO $pdo,
private readonly ?AutomationService $automation = null
) {
}
@@ -249,6 +251,18 @@ final class ShopproPaymentStatusSyncService
throw $exception;
}
if ($existingPaymentStatus !== $newPaymentStatus) {
try {
$this->automation?->trigger('payment.status_changed', $orderId, [
'new_payment_status' => (string) $newPaymentStatus,
'old_payment_status' => $existingPaymentStatus !== null ? (string) $existingPaymentStatus : '',
'total_paid' => $newTotalPaid,
'payment_method' => $paymentMethod,
]);
} catch (Throwable) {
}
}
$summary = $isPaid
? 'shopPRO: zamowienie oznaczone jako oplacone'
: 'shopPRO: zamowienie oznaczone jako nieoplacone';