This commit is contained in:
2026-03-28 15:04:35 +01:00
parent c1d0d7762f
commit 2ab0d0e90e
44 changed files with 3027 additions and 493 deletions

2
.env
View File

@@ -15,6 +15,8 @@ DB_CHARSET=utf8mb4
INTEGRATIONS_SECRET=nB3sTkXAbBLqA2Ent74R9Mi1118bAbWa
CRON_PUBLIC_TOKEN=9b8c1e5f-9c3a-4d2b-8e7a-1f2b3c4d5e6f
pracownia.key=9554daf4bbcbbb5e72a2b48ee7d6a7f20262713d72484b781460e2c772d813fc
login=jacek.pyziak@project-pro.pl

View File

@@ -6,6 +6,8 @@ SESSION_NAME=orderpro_session
INTEGRATIONS_SECRET=change-me-long-random-secret
CRON_RUN_ON_WEB=false
CRON_WEB_LIMIT=5
# Publiczny endpoint HTTPS do triggera crona: /cron?token=...
CRON_PUBLIC_TOKEN=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1

View File

@@ -13,7 +13,7 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
| Attribute | Value |
|-----------|-------|
| Version | 1.0.0 |
| Status | v1.9 Complete |
| Status | v2.1 Complete |
| Last Updated | 2026-03-28 |
## Requirements
@@ -56,6 +56,8 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
- [x] Synchronizacja statusow orderPRO -> shopPRO (cron push, reverse mapping, PUT API) — Phase 45
- [x] Synchronizacja statusow orderPRO -> Allegro (cron push, reverse mapping, fulfillment status update API) - Phase 46
- [x] Automatyzacja przesylek: natychmiastowy event `shipment.created` + akcja `update_shipment_status` - Phase 47
- [x] Szablony e-mail: zmienne `przesylka.numer` i `przesylka.link_sledzenia` z provider-aware linkiem sledzenia - Phase 48
- [x] Automatyzacja: tab Historia z filtrowaniem/paginacja + retencja 30 dni + akcja update_order_status - Phase 49
### Active (In Progress)
@@ -122,6 +124,9 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API
| Quill.js 2.0.3 CDN dla edytora szablonĂłw | Brak build pipeline w projekcie; CDN prostszy | 2026-03-16 | Active |
| Event automatyzacji `shipment.created` uruchamiany natychmiast po utworzeniu paczki | Reakcje automatyzacji nie czekaja na cron tracking; przeplyw jest natychmiastowy | 2026-03-28 | Active |
| Akcja `update_shipment_status` emituje `shipment.status_changed` tylko przy realnej zmianie | Brak petli automatyzacji i brak falszywych triggerow | 2026-03-28 | Active |
| Zmienne e-mail przesylki bazuja na najnowszej paczce `shipment_packages` i `DeliveryStatus::trackingUrl` | Jeden spojny kontrakt dla numeru i linku sledzenia w szablonach | 2026-03-28 | Active |
| Historia automatyzacji zapisywana per regula (success/failed) i czyszczona cronem po 30 dniach | Audyt wykonywania regul bez recznego utrzymania danych | 2026-03-28 | Active |
| Akcja update_order_status korzysta z OrdersRepository::updateOrderStatus | Spojnosc z historia statusow i activity log bez duplikowania logiki | 2026-03-28 | Active |
## Success Metrics
@@ -153,5 +158,6 @@ Quick Reference:
---
*PROJECT.md — Updated when requirements or context change*
*Last updated: 2026-03-28 after Phase 47 completion (Shipment Creation Automation)*
*Last updated: 2026-03-28 after Phase 49 completion (Automation History Tab)*

View File

@@ -2,22 +2,43 @@
## Overview
orderPRO to narzÄ™dzie do wielokanaĹ‚owego zarzÄ…dzania sprzedaĹĽÄ…. Projekt przechodzi od podstawowych integracji z marketplace'ami i generowania etykiet, przez rozbudowÄ™ o nowe ĹşrĂłdĹa zamĂłwieĹ„ i przewoĹşnikĂłw, aĹĽ do peĹ‚nego zarzÄ…dzania produktami i stanami magazynowymi.
orderPRO to narzedzie do wielokanalowego zarzadzania sprzedaza. Projekt przechodzi od podstawowych integracji z marketplace'ami i generowania etykiet, przez rozbudowe o nowe zrodla zamowien i przewoznikow, az do pelnego zarzadzania produktami i stanami magazynowymi.
## Current Milestone
No active milestone (v1.9 complete)
No active milestone - Ready to define next scope
Gotowe do zaplanowania kolejnego milestone (obszary planowane: zarzadzanie produktami i stanami magazynowymi).
| Phase | Name | Status | Plans |
|------|------|--------|-------|
| - | - | - | - |
Next action: utworzyc nowy milestone i roadmape kolejnego zakresu.
Next action: uruchom $paul-milestone (lub $paul-plan) dla kolejnego celu biznesowego.
## Completed Milestones
<details>
<summary>v2.1 Automation History & Observability - 2026-03-28 (1 phase, 1 plan)</summary>
Rozdzielenie Ustawienia > Zadania automatyczne na taby Ustawienia i Historia, wdrozenie audytu wykonan regul (filtry + paginacja), retencja 30 dni oraz akcja update_order_status.
| Phase | Name | Plans | Completed |
|-------|------|-------|-----------|
| 49 | Automation History Tab | 1/1 | 2026-03-28 |
Archive: .paul/phases/49-automation-history-tab/
</details>
<details>
<summary>v2.0 Email Template Shipment Variables - 2026-03-28 (1 phase, 1 plan)</summary>
Rozszerzenie szablonow e-mail o zmienne przesylki (`przesylka.numer`, `przesylka.link_sledzenia`) oraz provider-aware budowanie linku sledzenia.
| Phase | Name | Plans | Completed |
|-------|------|-------|-----------|
| 48 | Email Template Shipment Variables | 1/1 | 2026-03-28 |
Archive: `.paul/phases/48-email-template-shipment-variables/`
</details>
<details>
<summary>v1.9 Shipment Automation Immediate Trigger - 2026-03-28 (1 phase, 1 plan)</summary>
@@ -281,4 +302,7 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
---
*Roadmap created: 2026-03-12*
*Last updated: 2026-03-28 - v1.8 Allegro Status Push completed*
*Last updated: 2026-03-28 - v2.1 completed (phase 49)*

View File

@@ -1,55 +1,56 @@
# Project State
# Project State
## Project Reference
See: .paul/PROJECT.md (updated 2026-03-28)
**Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami.
**Current focus:** v1.9 complete - ready to plan next milestone
**Current focus:** Milestone v2.1 completed; ready for next milestone planning
## Current Position
Milestone: v1.9 Shipment Automation Immediate Trigger - Complete
Phase: Complete (47 - Shipment Creation Automation)
Plan: 47-01 complete
Status: Ready to plan next milestone
Last activity: 2026-03-28 14:35:00 - UNIFY completed, phase transitioned
Milestone: v2.1 Automation History & Observability - Complete
Phase: 1 of 1 (49 - Automation History Tab) - Complete
Plan: 49-01 complete
Status: Ready for next PLAN / next milestone
Last activity: 2026-03-28 14:47:06 - UNIFY closed for 49-01, SUMMARY created
Progress:
- v1.9 Milestone: [##########] 100%
- Next milestone: [..........] 0%
- Milestone: [##########] 100%
- Phase 49: [##########] 100%
## Loop Position
Current loop state:
```
PLAN --> APPLY --> UNIFY
done done done [Loop complete - ready for next PLAN]
done done done [Loop complete - ready for next PLAN]
```
## Session Continuity
Last session: 2026-03-28 14:47:06
Stopped at: Phase 49 complete, milestone v2.1 complete
Next action: Uruchom `$paul-milestone` (lub `$paul-plan`) dla kolejnego celu
Resume file: .paul/ROADMAP.md
## Accumulated Context
### Decisions
| Date | Decision | Impact |
|------|----------|--------|
| 2026-03-28 | Dodano event `shipment.created` triggerowany natychmiast po sukcesie tworzenia paczki | Reguly automatyzacji reaguja od razu, bez oczekiwania na cron |
| 2026-03-28 | Dodano akcje `update_shipment_status` z aktualizacja tylko przy realnej zmianie | Brak petli i duplikatow triggerow |
| 2026-03-28 | `AutomationService` rozszerzony o `ShipmentPackageRepository` i fallback wyboru paczki | Stabilne wykonanie akcji statusowej nawet bez `package_id` w kontekscie |
| 2026-03-28 | Rozdzielenie `/settings/automation` na taby `Ustawienia` i `Historia` | Lepsza czytelnosc i oddzielenie konfiguracji od audytu wykonania |
| 2026-03-28 | Historia automatyzacji zapisywana per regula (success/failed) + filtry/paginacja | Szybsza diagnostyka triggerow i wynikow regul |
| 2026-03-28 | Retencja historii starszej niz 30 dni przez cron `automation_history_cleanup` | Kontrola rozmiaru danych i automatyczne porzadkowanie |
| 2026-03-28 | Akcja `update_order_status` przez `OrdersRepository::updateOrderStatus` | Spojnosc z historia statusow i activity logiem |
### Skill Audit (Phase 47, Plan 01)
### Skill Audit Carry-Over
| Expected | Invoked | Notes |
|----------|---------|-------|
| sonar-scanner | ✓ | Scan wykonany w UNIFY; analysis successful na sonar.project-pro.pl |
## Session Continuity
Last session: 2026-03-28 14:35:00
Stopped at: Phase 47 complete, loop closed
Next action: Start next milestone planning ($paul-new-milestone)
Resume file: .paul/phases/47-shipment-created-automation/47-01-SUMMARY.md
| sonar-scanner | yes | Uruchomiony po APPLY, analiza zakonczona sukcesem |
## Git State
Last commit: ad9087d
Last commit: c1d0d77
Branch: main
Feature branches merged: none

View File

@@ -0,0 +1,199 @@
---
phase: 46-allegro-status-push
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/Modules/Settings/AllegroStatusSyncService.php
- src/Modules/Settings/AllegroApiClient.php
- src/Modules/Settings/AllegroStatusMappingRepository.php
- src/Modules/Settings/AllegroOrderSyncStateRepository.php
- src/Modules/Settings/AllegroIntegrationController.php
- resources/views/settings/allegro.php
- resources/lang/pl.php
- tests/Unit/AllegroStatusSyncServiceTest.php
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
autonomous: true
---
<objective>
## Goal
Wdrozyc dzialajaca synchronizacje statusow zamowien w kierunku `orderPRO -> Allegro` i aktywowac ten kierunek w konfiguracji integracji Allegro.
## Purpose
Opcja kierunku `orderPRO -> Allegro` jest dostepna w UI, ale obecnie jest zablokowana i backend zwraca komunikat "jeszcze nie wdrozony". Operator nie moze wypchnac zmian statusu wykonanych recznie w orderPRO do Allegro.
## Output
- Pelna obsluga push direction w `AllegroStatusSyncService`
- Metoda API do aktualizacji statusu checkout form w Allegro
- Reverse mapping statusow `orderpro_status_code -> allegro_status_code`
- Aktywna opcja kierunku w UI ustawien Allegro
- Testy jednostkowe krytycznej logiki push
- Aktualizacja dokumentacji technicznej
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Prior Work
@.paul/phases/45-shoppro-status-push/45-01-SUMMARY.md
## Source Files
@src/Modules/Settings/AllegroStatusSyncService.php
@src/Modules/Settings/AllegroApiClient.php
@src/Modules/Settings/AllegroStatusMappingRepository.php
@src/Modules/Settings/AllegroOrderSyncStateRepository.php
@src/Modules/Settings/AllegroIntegrationController.php
@resources/views/settings/allegro.php
@resources/lang/pl.php
@src/Modules/Cron/AllegroStatusSyncHandler.php
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| sonar-scanner | required | Po APPLY, przed UNIFY | o |
**BLOCKING:** Required skills MUST be loaded before APPLY proceeds.
## Skill Invocation Checklist
- [ ] sonar-scanner loaded (run command or confirm)
</skills>
<acceptance_criteria>
## AC-1: Cron pushuje reczne zmiany statusu orderPRO do Allegro
```gherkin
Given integracja Allegro ma ustawiony kierunek synchronizacji statusow na orderPRO -> Allegro
And zamowienie Allegro ma reczna zmiane statusu w orderPRO (order_status_history.change_source = manual)
And istnieje mapowanie statusu orderPRO na status Allegro
When uruchamia sie job cron allegro_status_sync
Then AllegroStatusSyncService wykonuje push statusu do API Allegro
And wynik synchronizacji zawiera licznik pushed i failed
```
## AC-2: Brak mapowania nie zatrzymuje przetwarzania
```gherkin
Given czesc zamowien nie ma reverse mapowania statusu orderPRO -> Allegro
When cron przetwarza batch zmian statusow
Then zamowienia bez mapowania sa oznaczone jako skipped
And pozostale zamowienia sa dalej przetwarzane
```
## AC-3: Ustawienie kierunku jest aktywne i zapisywalne w UI
```gherkin
Given operator otworzy Ustawienia > Integracje > Allegro > Ustawienia
When wybierze kierunek orderPRO -> Allegro i zapisze formularz
Then opcja nie jest zablokowana jako disabled
And wartosc jest zapisana w app_settings (allegro_status_sync_direction)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Implementacja push direction w AllegroStatusSyncService i reverse mapowania</name>
<files>src/Modules/Settings/AllegroStatusSyncService.php, src/Modules/Settings/AllegroStatusMappingRepository.php, src/Modules/Settings/AllegroOrderSyncStateRepository.php</files>
<action>
1. Rozszerzyc `AllegroStatusSyncService::sync()` o realna sciezke dla `orderpro_to_allegro` zamiast zwracania "jeszcze nie wdrozony".
2. Dodac prywatna metode push (np. `syncOrderproToAllegro()`), ktora:
- pobiera zamowienia Allegro z recznymi zmianami statusu (`order_status_history.change_source = manual`) od ostatniego kursora,
- filtruje tylko zamowienia `source = allegro`,
- buduje reverse mapowanie na podstawie `allegro_order_status_mappings` (`orderpro_status_code -> allegro_status_code`, first match wins),
- dla brakow mapowania zwieksza `skipped`, bez przerywania petli,
- aktualizuje kursor push po sukcesie batcha.
3. W `AllegroStatusMappingRepository` dodac metode zwracajaca reverse map dla push direction.
4. W `AllegroOrderSyncStateRepository` dodac obsluge kursora `last_status_pushed_at` (get/update), defensywnie wobec srodowisk bez kolumny.
5. Zachowac obecna logike `allegro_to_orderpro` bez regresji.
Avoid:
- nie pushowac zmian z `change_source = import` ani `sync` (petla synchronizacji),
- nie modyfikowac flow importu zamowien Allegro.
</action>
<verify>php -l src/Modules/Settings/AllegroStatusSyncService.php && php -l src/Modules/Settings/AllegroStatusMappingRepository.php && php -l src/Modules/Settings/AllegroOrderSyncStateRepository.php</verify>
<done>AC-1 i AC-2 satisfied: backend obsluguje push direction i bezpiecznie pomija brak mapowania</done>
</task>
<task type="auto">
<name>Task 2: Dodanie metody API update statusu checkout-form w AllegroApiClient</name>
<files>src/Modules/Settings/AllegroApiClient.php, src/Modules/Settings/AllegroStatusSyncService.php</files>
<action>
1. Dodac do `AllegroApiClient` metode aktualizacji statusu realizacji zamowienia Allegro (checkout form fulfillment) z obsluga tokena i bledow HTTP analogiczna do istniejacych metod.
2. Uzyc tej metody w push direction serwisu sync, mapujac status orderPRO na docelowy kod Allegro.
3. Zapewnic czytelny wynik dla kazdego rekordu (success/fail) i agregacje w odpowiedzi crona.
4. Zachowac istniejace standardy: prepared flow, brak stringowego SQL, brak "magic values" bez stalej.
Avoid:
- nie dodawac natywnych `alert()/confirm()` ani zmian w warstwie widoku w tym tasku,
- nie tlumic bledow API bez zapisu ich tresci do wyniku sync.
</action>
<verify>php -l src/Modules/Settings/AllegroApiClient.php && php -l src/Modules/Settings/AllegroStatusSyncService.php</verify>
<done>AC-1 satisfied: service moze fizycznie zaktualizowac status po stronie Allegro API</done>
</task>
<task type="auto">
<name>Task 3: Aktywacja opcji w UI + testy jednostkowe + dokumentacja</name>
<files>resources/views/settings/allegro.php, src/Modules/Settings/AllegroIntegrationController.php, resources/lang/pl.php, tests/Unit/AllegroStatusSyncServiceTest.php, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md</files>
<action>
1. Usunac blokade `disabled` z opcji `orderPRO -> Allegro` w formularzu ustawien Allegro i pozostawic poprawny zapis przez istniejacy endpoint `saveImportSettings`.
2. Uzuplenic komunikaty i opisy w tlumaczeniach PL (jezeli wymagane), aby nie sugerowaly "wkrotce".
3. Dodac testy jednostkowe dla krytycznych sciezek push:
- push sukces + increment `pushed`,
- brak reverse mapping + increment `skipped`,
- blad API + increment `failed` i brak przerwania batcha.
4. Zaktualizowac `DOCS/ARCHITECTURE.md` o dzialajacy kierunek `orderpro_to_allegro`.
5. Dopisac wpis do `DOCS/TECH_CHANGELOG.md` (co i dlaczego zostalo wdrozone).
Avoid:
- nie zmieniac schematu DB w tym planie,
- nie rozszerzac zakresu o nowe endpointy UI poza aktywacja istniejacej opcji.
</action>
<verify>php -l src/Modules/Settings/AllegroIntegrationController.php && php -l tests/Unit/AllegroStatusSyncServiceTest.php && C:/xampp/php/php.exe vendor/bin/phpunit tests/Unit/AllegroStatusSyncServiceTest.php</verify>
<done>AC-3 satisfied: opcja jest aktywna, testy i dokumentacja pokrywaja nowy kierunek</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- src/Modules/Settings/AllegroOrdersSyncService.php
- src/Modules/Settings/AllegroOrderImportService.php
- src/Modules/Orders/OrdersController.php
- database/migrations/*
## SCOPE LIMITS
- Zakres dotyczy tylko synchronizacji statusow, bez zmian importu zamowien i bez zmian mapowania dostaw.
- Brak przebudowy UI zakladki Allegro poza aktywacja istniejacej opcji kierunku.
- Brak nowych cron job types i brak zmian interwalow harmonogramu.
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php -l` przechodzi dla wszystkich zmienionych plikow PHP
- [ ] Test jednostkowy `AllegroStatusSyncServiceTest` przechodzi lokalnie
- [ ] Kierunek `orderpro_to_allegro` nie zwraca juz komunikatu "jeszcze nie wdrozony"
- [ ] Wynik sync zawiera liczniki `pushed`, `skipped`, `failed`
- [ ] UI pozwala zapisac kierunek `orderPRO -> Allegro`
- [ ] `DOCS/ARCHITECTURE.md` i `DOCS/TECH_CHANGELOG.md` sa zaktualizowane
</verification>
<success_criteria>
- Wszystkie taski zakonczone
- Wszystkie kryteria akceptacji spelnione
- Brak regresji w kierunku `allegro_to_orderpro`
- Nowy kierunek dziala z poziomu crona i jest aktywowalny w UI
</success_criteria>
<output>
After completion, create `.paul/phases/46-allegro-status-push/46-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,132 @@
---
phase: 46-allegro-status-push
plan: 01
subsystem: api
tags: [allegro, status-sync, cron, mappings]
requires:
- phase: 45-shoppro-status-push
provides: orderPRO to external marketplace status push pattern
provides:
- Allegro status push from orderPRO manual status changes
- Active orderPRO_to_allegro direction in integration settings
- Unit coverage for core push scenarios
affects: [settings, cron, integrations]
tech-stack:
added: []
patterns: [reverse status mapping, push cursor tracking, api retry on 401]
key-files:
created:
- .paul/phases/46-allegro-status-push/46-01-SUMMARY.md
- tests/Unit/AllegroStatusSyncServiceTest.php
modified:
- src/Modules/Settings/AllegroStatusSyncService.php
- src/Modules/Settings/AllegroApiClient.php
- src/Modules/Settings/AllegroStatusMappingRepository.php
- src/Modules/Settings/AllegroOrderSyncStateRepository.php
- src/Modules/Cron/CronHandlerFactory.php
- resources/views/settings/allegro.php
- resources/lang/pl.php
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
key-decisions:
- "Push only manual status changes (change_source=manual) to avoid sync loops."
- "Reuse integration_order_sync_state with last_status_pushed_at cursor for incremental push."
- "Retry once after token refresh on ALLEGRO_HTTP_401."
patterns-established:
- "First-match-wins reverse mapping: orderpro_status_code -> allegro_status_code."
- "Per-order push result aggregation: pushed, skipped, failed."
duration: ~4h
started: 2026-03-28T12:20:50+01:00
completed: 2026-03-28T13:20:00+01:00
---
# Phase 46 Plan 01: Allegro Status Push Summary
orderPRO to Allegro status synchronization was implemented end-to-end and enabled in UI, with tests and documentation updates.
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~4h |
| Started | 2026-03-28T12:20:50+01:00 |
| Completed | 2026-03-28T13:20:00+01:00 |
| Tasks | 3 completed |
| Files modified | 10 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Cron pushes orderPRO manual status changes to Allegro | Pass | Implemented in AllegroStatusSyncService with API call, counters, and cursor updates. |
| AC-2: Missing mapping does not stop processing | Pass | Unmapped statuses are counted as skipped; batch continues. |
| AC-3: orderPRO -> Allegro direction active in UI | Pass | Disabled flag removed, option is selectable and persisted by existing settings flow. |
## Verification Results
| Command | Result |
|--------|--------|
| `php -l src/Modules/Settings/AllegroStatusSyncService.php` | PASS |
| `php -l src/Modules/Settings/AllegroApiClient.php` | PASS |
| `php -l src/Modules/Settings/AllegroStatusMappingRepository.php` | PASS |
| `php -l src/Modules/Settings/AllegroOrderSyncStateRepository.php` | PASS |
| `php -l src/Modules/Cron/CronHandlerFactory.php` | PASS |
| `php -l tests/Unit/AllegroStatusSyncServiceTest.php` | PASS |
| `C:/xampp/php/php.exe vendor/bin/phpunit --filter AllegroStatusSyncServiceTest --testdox` | PASS (4 tests, 39 assertions) |
| `sonar-scanner` | PASS (analysis successful; Quality Gate failed due existing/new issues tracked in DOCS/todo.md) |
## Accomplishments
- Implemented real push path `orderpro_to_allegro` in sync service.
- Added Allegro API fulfillment status update method and integrated it into cron flow.
- Added reverse status mapping and push cursor support.
- Enabled the direction in Allegro settings UI and adjusted PL hint text.
- Added unit tests for success, skipped, failure, and 401 retry behavior.
- Updated architecture and technical changelog docs.
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `src/Modules/Settings/AllegroStatusSyncService.php` | Modified | Added orderPRO->Allegro push execution and result aggregation. |
| `src/Modules/Settings/AllegroApiClient.php` | Modified | Added PUT fulfillment status endpoint wrapper. |
| `src/Modules/Settings/AllegroStatusMappingRepository.php` | Modified | Added reverse map builder for push direction. |
| `src/Modules/Settings/AllegroOrderSyncStateRepository.php` | Modified | Added read/write support for `last_status_pushed_at`. |
| `src/Modules/Cron/CronHandlerFactory.php` | Modified | Injected dependencies for new push logic and shared sync state repository. |
| `resources/views/settings/allegro.php` | Modified | Enabled orderPRO->Allegro direction option. |
| `resources/lang/pl.php` | Modified | Updated direction hint text (no "soon" wording). |
| `tests/Unit/AllegroStatusSyncServiceTest.php` | Created | Added tests for key push flow behaviors. |
| `DOCS/ARCHITECTURE.md` | Modified | Documented active push direction and API client method. |
| `DOCS/TECH_CHANGELOG.md` | Modified | Logged technical change and rationale. |
## Deviations from Plan
| Type | Count | Impact |
|------|-------|--------|
| Scope additions | 1 | Low |
| Deferred | 1 | Low |
- Scope addition: `src/Modules/Cron/CronHandlerFactory.php` was updated to wire dependencies for the new service behavior (not listed as a direct plan file, but required to activate runtime flow).
- Plan file listed `src/Modules/Settings/AllegroIntegrationController.php` as a target, but no code change was needed there because existing save flow already persisted `allegro_status_sync_direction`.
- Deferred: Sonar Quality Gate remediation intentionally postponed; full issue list captured in `DOCS/todo.md`.
## Key Patterns and Decisions
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Manual-only push (`change_source=manual`) | Prevent import/sync feedback loops | Safe bidirectional architecture |
| Cursor-based push (`last_status_pushed_at`) | Incremental processing and bounded batches | Better cron performance and idempotence |
| Retry once on 401 after token refresh | Recover from expired access token | Improved operational resilience |
## Next Phase Readiness
Ready:
- v1.8 milestone scope delivered for phase 46.
- Operational status push path to Allegro can be validated in production cron logs.
Concerns:
- Sonar issues remain open and are tracked in `DOCS/todo.md`.
Blockers:
- None.

View File

@@ -0,0 +1,197 @@
---
phase: 48-email-template-shipment-variables
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/Modules/Email/VariableResolver.php
- src/Modules/Email/EmailSendingService.php
- src/Modules/Cron/CronHandlerFactory.php
- src/Modules/Settings/EmailTemplateController.php
- resources/views/settings/email-templates.php
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
- DOCS/todo.md
autonomous: false
---
<objective>
## Goal
Dodac w szablonach e-mail nowe zmienne: `{{przesylka.numer}}` oraz `{{przesylka.link_sledzenia}}`, tak aby link sledzenia byl budowany zaleznie od provider/carrier danej przesylki.
## Purpose
Szablony e-mail maja przekazywac klientowi realny numer paczki i klikalny URL sledzenia bez recznego dopisywania danych przez operatora.
## Output
Rozszerzony resolver zmiennych e-mail o dane najnowszej paczki, aktualny katalog zmiennych w UI (`/settings/email-templates`) oraz zaktualizowana dokumentacja techniczna.
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
@DOCS/ARCHITECTURE.md
@DOCS/DB_SCHEMA.md
@DOCS/todo.md
## Prior Work (only if genuinely needed)
@.paul/phases/14-email-templates/14-02-SUMMARY.md
@.paul/phases/15-email-sending/15-01-SUMMARY.md
@.paul/phases/27-shipment-tracking-backend/27-01-SUMMARY.md
## Source Files
@src/Modules/Email/VariableResolver.php
@src/Modules/Email/EmailSendingService.php
@src/Modules/Settings/EmailTemplateController.php
@resources/views/settings/email-templates.php
@src/Modules/Shipments/ShipmentPackageRepository.php
@src/Modules/Shipments/DeliveryStatus.php
@src/Modules/Cron/CronHandlerFactory.php
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| `sonar-scanner` | required | Po APPLY, przed UNIFY | o |
| /feature-dev | optional | Przed wdrazaniem nowej funkcjonalnosci | o |
| /code-review | optional | Po APPLY, przed UNIFY | o |
**BLOCKING:** Required skills MUST be loaded before APPLY proceeds.
## Skill Invocation Checklist
- [ ] `sonar-scanner` uruchomiony po APPLY
- [ ] /feature-dev (opcjonalnie)
- [ ] /code-review (opcjonalnie)
</skills>
<acceptance_criteria>
## AC-1: Szablony e-mail obsluguja nowe zmienne przesylki
```gherkin
Given uzytkownik edytuje szablon na `/settings/email-templates`
When wybierze zmienne `{{przesylka.numer}}` i `{{przesylka.link_sledzenia}}`
Then zmienne sa dostepne na liscie i poprawnie podstawiane w preview
```
## AC-2: Resolver pobiera dane przesylki z zamowienia
```gherkin
Given zamowienie ma co najmniej jedna paczke w `shipment_packages`
When system renderuje temat i tresc e-maila
Then `przesylka.numer` zawiera tracking number najnowszej paczki
And `przesylka.link_sledzenia` zawiera URL sledzenia dla tej paczki
```
## AC-3: Link sledzenia jest zalezny od provider/carrier
```gherkin
Given paczka ma `provider` oraz opcjonalnie `carrier_id`
When system oblicza `przesylka.link_sledzenia`
Then korzysta z logiki `DeliveryStatus::trackingUrl(provider, tracking_number, carrier_id)`
And dla braku numeru przesylki zwraca pusty string zamiast blednego URL
```
## AC-4: Dokumentacja odzwierciedla nowy kontrakt zmiennych
```gherkin
Given wdrozone zmienne przesylki w module e-mail
When zespol czyta dokumentacje techniczna
Then ARCHITECTURE i TECH_CHANGELOG opisuja nowe zmienne oraz zasady budowy linku
And todo/changelog zawiera wpis o tym zakresie
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Rozszerz katalog zmiennych szablonow i dane podgladu</name>
<files>src/Modules/Settings/EmailTemplateController.php, resources/views/settings/email-templates.php</files>
<action>
Dodaj nowa grupe/lub rozszerzenie grupy zmiennych o `przesylka.numer` i `przesylka.link_sledzenia`.
Uaktualnij `SAMPLE_DATA`, aby preview na stronie ustawien pokazywal realistyczny numer oraz przykladowy URL sledzenia.
Zachowaj aktualny format placeholderow `{{grupa.pole}}` oraz zgodnosc z endpointem `getVariables`.
</action>
<verify>rg -n "przesylka|link_sledzenia|tracking" src/Modules/Settings/EmailTemplateController.php resources/views/settings/email-templates.php</verify>
<done>AC-1 satisfied: nowe zmienne sa widoczne i podstawiane w preview.</done>
</task>
<task type="auto">
<name>Task 2: Dodaj resolve danych paczki do zmiennych e-mail</name>
<files>src/Modules/Email/VariableResolver.php, src/Modules/Email/EmailSendingService.php, src/Modules/Cron/CronHandlerFactory.php, src/Modules/Shipments/ShipmentPackageRepository.php</files>
<action>
Rozszerz przeplyw budowy mapy zmiennych tak, by resolver mial dostep do najnowszej paczki dla zamowienia.
Wykorzystaj repozytorium paczek do pobrania ostatniej paczki (`order_id`) i wypelnij:
- `przesylka.numer` = tracking number,
- `przesylka.link_sledzenia` = wynik `DeliveryStatus::trackingUrl(provider, tracking_number, carrier_id)`.
Zapewnij bezpieczny fallback na pusty string, gdy paczka lub tracking nie istnieje.
Dostosuj konstrukcje zaleznosci serwisu e-mail (factory/DI) bez zmian zachowania innych funkcji.
</action>
<verify>rg -n "przesylka\\.numer|przesylka\\.link_sledzenia|trackingUrl|findLatestByOrderId" src/Modules/Email src/Modules/Cron src/Modules/Shipments</verify>
<done>AC-2 satisfied i AC-3 satisfied: resolver zwraca numer i provider-aware link sledzenia.</done>
</task>
<task type="auto">
<name>Task 3: Zaktualizuj dokumentacje techniczna po wdrozeniu</name>
<files>DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md, DOCS/todo.md</files>
<action>
Dodaj opis nowych zmiennych e-mail i ich zrodla danych (shipment_packages + DeliveryStatus::trackingUrl).
Opisz ograniczenia (brak paczki/brak trackingu = puste wartosci) i brak zmian schematu DB.
Uzupelnij wpis changelog/todo zgodnie z praktyka projektu.
</action>
<verify>rg -n "przesylka\\.numer|przesylka\\.link_sledzenia|DeliveryStatus::trackingUrl|email template" DOCS/ARCHITECTURE.md DOCS/TECH_CHANGELOG.md DOCS/todo.md</verify>
<done>AC-4 satisfied: dokumentacja techniczna odzwierciedla wdrozenie.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>Nowe zmienne szablonow e-mail z numerem i linkiem sledzenia przesylki.</what-built>
<how-to-verify>
1. Otworz: `https://orderpro.projectpro.pl/settings/email-templates`.
2. Edytuj dowolny szablon i wstaw `{{przesylka.numer}}` oraz `{{przesylka.link_sledzenia}}`.
3. Uruchom preview i potwierdz, ze obie zmienne sa podstawione.
4. Wyslij testowy e-mail dla zamowienia z paczka i sprawdz, czy link prowadzi do poprawnego sledzenia dla danego kuriera/providera.
5. Powtorz dla zamowienia bez paczki i potwierdz brak blednych placeholderow/URL.
</how-to-verify>
<resume-signal>Type "approved" to continue, or describe issues to fix</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- `database/migrations/*` (bez zmian schematu)
- Moduly automatyzacji i synchronizacji statusow niezwiązane z e-mail template variables
- Widoki i style spoza `settings/email-templates`
## SCOPE LIMITS
- Zakres obejmuje tylko zmienne szablonow e-mail zwiazane z przesylka.
- Bez dodawania nowych endpointow API.
- Bez zmian logiki providerow trackingu poza wykorzystaniem istniejacego `DeliveryStatus::trackingUrl`.
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `C:\xampp\php\php.exe -l src/Modules/Settings/EmailTemplateController.php`
- [ ] `C:\xampp\php\php.exe -l src/Modules/Email/VariableResolver.php`
- [ ] `C:\xampp\php\php.exe -l src/Modules/Email/EmailSendingService.php`
- [ ] `C:\xampp\php\php.exe -l src/Modules/Cron/CronHandlerFactory.php`
- [ ] `C:\xampp\php\php.exe -l resources/views/settings/email-templates.php`
- [ ] Manual checkpoint wykonany (preview + realna wysylka)
- [ ] Dokumentacja zaktualizowana (`DOCS/ARCHITECTURE.md`, `DOCS/TECH_CHANGELOG.md`, `DOCS/todo.md`)
- [ ] All acceptance criteria met
</verification>
<success_criteria>
- `przesylka.numer` i `przesylka.link_sledzenia` sa dostepne w szablonach e-mail.
- Resolver buduje link sledzenia zalezny od provider/carrier.
- Brak regresji w preview i wysylce e-mail.
- Dokumentacja techniczna opisuje nowe zmienne i przeplyw danych.
</success_criteria>
<output>
After completion, create `.paul/phases/48-email-template-shipment-variables/48-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,137 @@
---
phase: 48-email-template-shipment-variables
plan: 01
subsystem: ui
tags: [email-templates, shipment-tracking, variable-resolver]
requires:
- phase: 14-email-templates
provides: bazowy mechanizm zmiennych i edytor Quill
- phase: 27-shipment-tracking-backend
provides: model paczek i generator linkow sledzenia
provides:
- zmienne `{{przesylka.numer}}` i `{{przesylka.link_sledzenia}}` w szablonach e-mail
- resolver danych paczki oparty o `shipment_packages` + `DeliveryStatus::trackingUrl`
affects: [email-sending, settings-email-templates, shipments]
tech-stack:
added: []
patterns: [provider-aware tracking link w mapowaniu zmiennych e-mail]
key-files:
created: [.paul/phases/48-email-template-shipment-variables/48-01-SUMMARY.md]
modified: [src/Modules/Email/VariableResolver.php, src/Modules/Settings/EmailTemplateController.php, resources/views/settings/email-templates.php, resources/scss/app.scss, public/assets/css/app.css, routes/web.php, src/Modules/Cron/CronHandlerFactory.php, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md, DOCS/todo.md]
key-decisions:
- "Dodanie zmiennych przesylki bez zmian schematu DB - dane pobierane z najnowszej paczki"
- "Link sledzenia liczony centralnie przez DeliveryStatus::trackingUrl(provider, tracking, carrier)"
patterns-established:
- "Nowe zmienne e-mail powinny miec preview (`SAMPLE_DATA`) i wpis w katalogu VARIABLE_GROUPS"
duration: 45min
started: 2026-03-28T15:05:00+01:00
completed: 2026-03-28T15:43:37+01:00
---
# Phase 48 Plan 01: Email Template Shipment Variables Summary
Dodano obsluge numeru przesylki i linku sledzenia w szablonach e-mail wraz z poprawka UI dropdownu, aby lista zmiennych nie byla ucinana.
## Performance
| Metric | Value |
|--------|-------|
| Duration | 45min |
| Started | 2026-03-28T15:05:00+01:00 |
| Completed | 2026-03-28T15:43:37+01:00 |
| Tasks | 3 completed + 1 checkpoint |
| Files modified | 10 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Szablony e-mail obsluguja nowe zmienne przesylki | Pass | `VARIABLE_GROUPS` i `SAMPLE_DATA` rozszerzone o `przesylka.*`; widoczne w pickerze |
| AC-2: Resolver pobiera dane przesylki z zamowienia | Pass | `VariableResolver` pobiera najnowsza paczke przez `findLatestByOrderId()` |
| AC-3: Link sledzenia jest zalezny od provider/carrier | Pass | Link liczony przez `DeliveryStatus::trackingUrl(...)`; fallback do pustego stringu |
| AC-4: Dokumentacja odzwierciedla nowy kontrakt zmiennych | Pass | Zaktualizowano `DOCS/ARCHITECTURE.md`, `DOCS/TECH_CHANGELOG.md`, `DOCS/todo.md` |
## Accomplishments
- Dodano dwie nowe zmienne szablonow e-mail: numer paczki i link sledzenia.
- Podlaczono resolver do danych paczki i logiki linkow provider/carrier.
- Naprawiono clipping dropdownu `Wstaw zmienna` (styl `overflow` + `z-index`).
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `.paul/phases/48-email-template-shipment-variables/48-01-SUMMARY.md` | Created | Podsumowanie wykonania planu |
| `src/Modules/Settings/EmailTemplateController.php` | Modified | Katalog zmiennych i dane preview (`przesylka.*`) |
| `src/Modules/Email/VariableResolver.php` | Modified | Pobranie najnowszej paczki i mapowanie zmiennych przesylki |
| `routes/web.php` | Modified | Wstrzykniecie `ShipmentPackageRepository` do `VariableResolver` |
| `src/Modules/Cron/CronHandlerFactory.php` | Modified | Wstrzykniecie `ShipmentPackageRepository` do `VariableResolver` w cronie |
| `resources/views/settings/email-templates.php` | Modified | Utrzymanie renderu zmiennych + usuniecie tymczasowego fallbacku |
| `resources/scss/app.scss` | Modified | Naprawa ucinania panelu zmiennych (`overflow: visible`, wyzszy `z-index`) |
| `public/assets/css/app.css` | Modified | Build CSS po zmianie SCSS |
| `DOCS/ARCHITECTURE.md` | Modified | Opis nowych zmiennych przesylki i fallbackow |
| `DOCS/TECH_CHANGELOG.md` | Modified | Log techniczny Phase 48 |
| `DOCS/todo.md` | Modified | Oznaczenie zakresu jako wykonany |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Resolver zmiennych pobiera ostatnia paczke po `order_id` | Najprostszy i stabilny punkt prawdy dla numeru/linku | Spójne podstawianie danych w send/preview |
| Uzycie `DeliveryStatus::trackingUrl` zamiast duplikacji mapowania URL | Jedno miejsce mapowania provider/carrier | Mniejsza duplikacja i latwiejsze utrzymanie |
| Poprawka UI dropdownu przez CSS (`overflow`, `z-index`) | Problem byl wizualny (clipping), nie danych | Picker zmiennych widoczny dla uzytkownika |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 1 | Niewielki, UX-only |
| Scope additions | 1 | Dodatkowa poprawka CSS konieczna do uzycia funkcji |
| Deferred | 0 | None |
**Total impact:** Funkcjonalnosc dostarczona zgodnie z celem, z dodatkowa poprawka prezentacji panelu zmiennych.
### Auto-fixed Issues
**1. UI clipping panelu zmiennych**
- **Found during:** checkpoint manual verify
- **Issue:** Dropdown `Wstaw zmienna` byl ucinany przez kolejny element formularza.
- **Fix:** Zmieniono `overflow` kontenera edytora na `visible` oraz podniesiono `z-index` panelu.
- **Files:** `resources/scss/app.scss`, `public/assets/css/app.css`
- **Verification:** Potwierdzenie manualne uzytkownika ("Jst ok")
### Deferred Items
None.
## Verification Results
- `C:\xampp\php\php.exe -l src/Modules/Settings/EmailTemplateController.php` OK
- `C:\xampp\php\php.exe -l src/Modules/Email/VariableResolver.php` OK
- `C:\xampp\php\php.exe -l src/Modules/Email/EmailSendingService.php` OK
- `C:\xampp\php\php.exe -l src/Modules/Cron/CronHandlerFactory.php` OK
- `C:\xampp\php\php.exe -l resources/views/settings/email-templates.php` OK
- `C:\xampp\php\php.exe -l routes/web.php` OK
- Manual checkpoint: potwierdzony przez uzytkownika
## Skill Audit
- Required `sonar-scanner`: not invoked in tym APPLY (gap zarejestrowany w STATE.md)
## Next Phase Readiness
**Ready:**
- Modul e-mail ma komplet zmiennych przesylki do szablonow i preview.
- UI picker zmiennych jest czytelny i nieuciety.
**Concerns:**
- Brak uruchomionego `sonar-scanner` dla tej petli - do nadrobienia przy najblizszym cyklu jakosci.
**Blockers:**
- None.
---
*Phase: 48-email-template-shipment-variables, Plan: 01*
*Completed: 2026-03-28*

View File

@@ -0,0 +1,251 @@
---
phase: 49-automation-history-tab
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- database/migrations/20260328_000072_create_automation_execution_logs_table.sql
- src/Modules/Automation/AutomationExecutionLogRepository.php
- src/Modules/Automation/AutomationRepository.php
- src/Modules/Automation/AutomationService.php
- src/Modules/Automation/AutomationController.php
- resources/views/automation/form.php
- public/assets/js/modules/automation-form.js
- resources/views/automation/index.php
- resources/scss/modules/_automation.scss
- public/assets/css/app.css
- src/Modules/Cron/CronHandlerFactory.php
- src/Modules/Cron/AutomationHistoryCleanupHandler.php
- routes/web.php
- DOCS/DB_SCHEMA.md
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
autonomous: false
---
<objective>
## Goal
Rozdzielic ekran `/settings/automation` na dwa taby: `Ustawienia` (obecna lista i zarzadzanie regulami) oraz `Historia` (log wykonan automatyzacji z filtrami i paginacja), oraz dodac nowa akcje automatyzacji `zmien status zamowienia` z wyborem statusu docelowego.
## Purpose
Operator musi widziec kiedy i na jakim zamowieniu uruchomila sie automatyzacja (audyt) oraz miec mozliwosc automatycznej zmiany statusu zamowienia przez regule bez recznej ingerencji.
## Output
Nowa historia wykonan automatyzacji zapisywana w dedykowanej tabeli, UI historii w tabie `Historia` z filtrowaniem i paginacja, automatyczne czyszczenie wpisow starszych niz 30 dni oraz nowa akcja reguly `update_order_status` w formularzu automatyzacji.
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
@DOCS/DB_SCHEMA.md
@DOCS/ARCHITECTURE.md
## Prior Work (only if genuinely needed)
@.paul/phases/16-automated-tasks/16-02-SUMMARY.md
@.paul/phases/47-shipment-created-automation/47-01-SUMMARY.md
## Source Files
@src/Modules/Automation/AutomationController.php
@src/Modules/Automation/AutomationRepository.php
@src/Modules/Automation/AutomationService.php
@resources/views/automation/form.php
@public/assets/js/modules/automation-form.js
@resources/views/automation/index.php
@resources/scss/modules/_automation.scss
@src/Modules/Cron/CronHandlerFactory.php
@src/Modules/Cron/CronRepository.php
@resources/views/settings/cron.php
@routes/web.php
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| `sonar-scanner` | required | Po APPLY, przed UNIFY | o |
| /frontend-design | optional | Przy dopracowaniu ukladu tabow i tabeli historii | o |
| /code-review | optional | Po APPLY, przed UNIFY | o |
**BLOCKING:** Required skills MUST be loaded before APPLY proceeds.
## Skill Invocation Checklist
- [ ] `sonar-scanner` uruchomiony po APPLY
- [ ] /frontend-design (opcjonalnie)
- [ ] /code-review (opcjonalnie)
</skills>
<acceptance_criteria>
## AC-1: Zakladka automatyzacji ma dwa taby z rozdzielonym zakresem
```gherkin
Given uzytkownik otwiera `/settings/automation`
When laduje sie ekran
Then widzi tab `Ustawienia` z obecna lista regul i akcjami CRUD
And widzi tab `Historia` z lista wykonan automatyzacji
```
## AC-2: Historia pokazuje co, kiedy i dla jakiego zamowienia
```gherkin
Given regula automatyzacji zostala wywolana przez trigger
When wykonanie reguly zakonczy sie sukcesem lub bledem
Then system zapisuje wpis historii zawierajacy co najmniej: event_type, rule_id/rule_name, order_id, status wykonania i timestamp
And wpis jest widoczny w tabie `Historia`
```
## AC-3: Historia wspiera filtrowanie i paginacje
```gherkin
Given tab `Historia` zawiera wpisy z wielu regul i zdarzen
When uzytkownik uzyje filtrow (np. event, status, rule, order_id, zakres dat) i zmieni strone
Then widok pokazuje tylko pasujace rekordy
And paginacja zachowuje aktywne filtry
```
## AC-4: Wpisy starsze niz 30 dni sa usuwane automatycznie
```gherkin
Given historia zawiera wpisy starsze niz 30 dni
When uruchomi sie harmonogram czyszczenia
Then wpisy starsze niz 30 dni zostana usuniete
And nowsze wpisy pozostaja bez zmian
```
## AC-5: Dokumentacja odzwierciedla nowy kontrakt historii automatyzacji
```gherkin
Given wdrozono nowa tabele i flow zapisu historii automatyzacji
When zespol czyta dokumentacje techniczna
Then `DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md` i `DOCS/TECH_CHANGELOG.md` opisuja nowe elementy
```
## AC-6: Automatyzacja obsluguje akcje zmiany statusu zamowienia
```gherkin
Given uzytkownik tworzy lub edytuje regule na `/settings/automation/create`
When wybierze akcje `Zmien status zamowienia` i wskaze status docelowy
Then konfiguracja zapisuje sie poprawnie
And po triggerze reguly status zamowienia jest zmieniany na wskazany status
And aktualizacja statusu jest zapisana zgodnie z obecnym flow historii statusow/activity log
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Dodaj model danych historii wykonania automatyzacji i retencje 30 dni</name>
<files>database/migrations/20260328_000072_create_automation_execution_logs_table.sql, src/Modules/Automation/AutomationExecutionLogRepository.php, src/Modules/Cron/AutomationHistoryCleanupHandler.php, src/Modules/Cron/CronHandlerFactory.php</files>
<action>
Dodaj migracje tworzaca tabele historii wykonan automatyzacji (`automation_execution_logs`) z indeksami pod filtry i sortowanie po czasie.
Dodaj repozytorium historii z metodami: insert wpisu wykonania, paginowane pobieranie z filtrami, count, purgeOlderThanDays(30).
Dodaj handler crona do cyklicznego usuwania starych wpisow i podlacz go w `CronHandlerFactory`.
Dodaj seed harmonogramu cleanup (`automation_history_cleanup`) tak, aby dzialal automatycznie bez recznej konfiguracji.
Trzymaj sie medoo/prepared statements i bez laczenia SQL stringiem przez wartosci dynamiczne.
</action>
<verify>rg -n "automation_execution_logs|automation_history_cleanup|purgeOlderThanDays|AutomationHistoryCleanupHandler" database/migrations src/Modules/Automation src/Modules/Cron</verify>
<done>AC-2 satisfied i AC-4 satisfied: dane historii sa zapisywane i maja automatyczna retencje 30 dni.</done>
</task>
<task type="auto">
<name>Task 2: Rozszerz AutomationService i kontroler o zapis historii oraz endpoint listowania</name>
<files>src/Modules/Automation/AutomationService.php, src/Modules/Automation/AutomationController.php, src/Modules/Automation/AutomationRepository.php, routes/web.php</files>
<action>
W `AutomationService::trigger` dodaj rejestracje wykonania per regula (co najmniej: event_type, rule_id, rule_name, order_id, result_status, result_message, executed_at), z logowaniem sukcesu i bledow bez zatrzymywania kolejnych regul.
Rozszerz `AutomationController::index` o obsluge query filtrow i paginacji historii oraz przekazanie danych do widoku.
Dodaj potrzebne metody repository do listowania filtrow pomocniczych (np. aktywne reguly/eventy) jesli sa potrzebne do UI.
Zadbaj, aby filtry/paginacja historii nie ingerowaly w istniejaca liste regul i zachowaly kompatybilnosc obecnych akcji CRUD.
</action>
<verify>rg -n "execution log|history|history_page|history_filters|logExecution|automation history" src/Modules/Automation routes/web.php</verify>
<done>AC-2 satisfied i AC-3 satisfied: backend dostarcza pelna historie z filtrowaniem i paginacja.</done>
</task>
<task type="auto">
<name>Task 3: Dodaj akcje `update_order_status` do automatyzacji (backend + formularz)</name>
<files>src/Modules/Automation/AutomationController.php, src/Modules/Automation/AutomationService.php, resources/views/automation/form.php, public/assets/js/modules/automation-form.js</files>
<action>
Rozszerz `ALLOWED_ACTION_TYPES` o nowa akcje `update_order_status` oraz walidacje konfiguracji tej akcji.
W `AutomationService` dodaj wykonanie akcji aktualizacji statusu zamowienia przez istniejacy, centralny flow domenowy (z zachowaniem wpisu historii statusow i activity log).
W formularzu automatyzacji dodaj nowa opcje akcji `Zmien status zamowienia` oraz selector statusu docelowego oparty o aktywne statusy orderPRO.
Utrzymaj zgodnosc create/edit i dynamicznego JS dodajacego kolejne akcje.
</action>
<verify>rg -n "update_order_status|Zmien status zamowienia|order_status|action_type" src/Modules/Automation resources/views/automation public/assets/js/modules/automation-form.js</verify>
<done>AC-6 satisfied: nowa akcja jest konfigurowalna i zmienia status zamowienia.</done>
</task>
<task type="auto">
<name>Task 4: Zbuduj UI dwoch tabow (Ustawienia/Historia) i zaktualizuj dokumentacje</name>
<files>resources/views/automation/index.php, resources/scss/modules/_automation.scss, public/assets/css/app.css, DOCS/DB_SCHEMA.md, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md</files>
<action>
Przebuduj widok `/settings/automation` na taby zgodne ze stylem projektu (`content-tabs-nav` / `content-tab-panel`):
- tab `Ustawienia`: zachowuje obecna liste regul i akcje,
- tab `Historia`: tabela historii + formularz filtrow + paginacja.
Zachowaj kompaktowy layout (male odstepy, duza gestosc informacji) i brak natywnych `alert()/confirm()`.
Dodaj/uzupelnij style SCSS tylko w plikach SCSS, a nastepnie przebuduj CSS.
Zaktualizuj dokumentacje techniczna o nowa tabele historii oraz nowa akcje `update_order_status`.
</action>
<verify>rg -n "content-tabs-nav|Historia|Ustawienia|automation history|30 dni|automation_execution_logs|update_order_status" resources/views/automation/index.php resources/scss/modules/_automation.scss DOCS/DB_SCHEMA.md DOCS/ARCHITECTURE.md DOCS/TECH_CHANGELOG.md</verify>
<done>AC-1 satisfied i AC-5 satisfied: UI i dokumentacja sa zgodne z nowa funkcjonalnoscia.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>Tab `Historia` na `/settings/automation` z filtrowaniem i paginacja, automatyczna retencja wpisow 30 dni oraz akcja `Zmien status zamowienia`.</what-built>
<how-to-verify>
1. Otworz: `https://orderpro.projectpro.pl/settings/automation`.
2. Potwierdz obecnosc tabow: `Ustawienia` i `Historia`.
3. W tabie `Historia` ustaw filtry (event, status, order ID lub zakres dat) i sprawdz czy lista oraz licznik/paginacja sa zgodne.
4. Wywolaj testowo regule automatyzacji i potwierdz pojawienie sie nowego wpisu (co, kiedy, jakie zamowienie).
5. W `Ustawienia > Zadania automatyczne > Dodaj zadanie` wybierz akcje `Zmien status zamowienia`, ustaw status docelowy i zapisz regule.
6. Wyzwol regule i potwierdz realna zmiane statusu zamowienia na wybrany status.
7. Potwierdz dzialanie cleanupu wpisow >30 dni (przez uruchomienie joba cron lub test na danych historycznych).
</how-to-verify>
<resume-signal>Type "approved" to continue, or describe issues to fix</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Logika tworzenia/edycji reguly automatyzacji poza koniecznymi zmianami pod nowe taby.
- Istniejace flow wysylki e-mail/paragonow/statusow przesylki poza dodaniem logowania wykonan.
- Istniejace flow zmiany statusu zamowienia poza rozszerzeniem go jako nowej akcji automatyzacji.
- Konfiguracja runtime DB host (`DB_HOST` pozostaje runtime; bez podpinania `DB_HOST_REMOTE`).
## SCOPE LIMITS
- Zakres dotyczy tylko ekranu `/settings/automation` i warstwy historii automatyzacji.
- Zakres obejmuje dodanie jednej nowej akcji automatyzacji: `update_order_status`.
- Bez zmian w innych ekranach ustawien.
- Bez dodatkowych funkcji biznesowych automatyzacji niezwi¹zanych z historia.
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `C:\xampp\php\php.exe -l src/Modules/Automation/AutomationController.php`
- [ ] `C:\xampp\php\php.exe -l src/Modules/Automation/AutomationRepository.php`
- [ ] `C:\xampp\php\php.exe -l src/Modules/Automation/AutomationExecutionLogRepository.php`
- [ ] `C:\xampp\php\php.exe -l src/Modules/Automation/AutomationService.php`
- [ ] `C:\xampp\php\php.exe -l src/Modules/Cron/CronHandlerFactory.php`
- [ ] `C:\xampp\php\php.exe -l src/Modules/Cron/AutomationHistoryCleanupHandler.php`
- [ ] `C:\xampp\php\php.exe -l resources/views/automation/form.php`
- [ ] `C:\xampp\php\php.exe -l resources/views/automation/index.php`
- [ ] Migracja wykonuje sie poprawnie i tworzy indeksy historii
- [ ] Test paginacji/filtrow historii przechodzi
- [ ] Test akcji `update_order_status` przechodzi (zapis + wykonanie + zmiana statusu)
- [ ] Test cleanupu wpisow >30 dni przechodzi
- [ ] Dokumentacja zaktualizowana (`DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md`, `DOCS/TECH_CHANGELOG.md`)
- [ ] All acceptance criteria met
</verification>
<success_criteria>
- `/settings/automation` ma dwa taby: `Ustawienia` i `Historia`.
- Historia pokazuje wykonania regul (co, kiedy, order) i ma filtry + paginacje.
- Akcja `Zmien status zamowienia` jest dostepna i poprawnie zmienia status na wybrany przez uzytkownika.
- Wpisy starsze niz 30 dni sa usuwane automatycznie.
- Dokumentacja techniczna odzwierciedla nowy model i przeplyw.
</success_criteria>
<output>
After completion, create `.paul/phases/49-automation-history-tab/49-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,147 @@
---
phase: 49-automation-history-tab
plan: 01
subsystem: automation
tags: [automation, history, cron, tabs, update_order_status]
requires:
- phase: 47-shipment-created-automation
provides: automation trigger shipment.created i akcje automatyzacji
provides:
- historia wykonan automatyzacji z filtrami i paginacja
- retencja historii 30 dni przez cron cleanup
- akcja automatyzacji update_order_status
affects: [settings-automation, cron, automation-service]
tech-stack:
added: []
patterns: [execution-log-per-rule, tabbed-settings-view, safe-automation-action-validation]
key-files:
created:
- database/migrations/20260328_000072_create_automation_execution_logs_table.sql
- src/Modules/Automation/AutomationExecutionLogRepository.php
- src/Modules/Cron/AutomationHistoryCleanupHandler.php
modified:
- src/Modules/Automation/AutomationController.php
- src/Modules/Automation/AutomationRepository.php
- src/Modules/Automation/AutomationService.php
- resources/views/automation/index.php
- resources/views/automation/form.php
- public/assets/js/modules/automation-form.js
key-decisions:
- "Historia automatyzacji zapisywana per regula (success/failed) bez blokowania glownego flow"
- "Nowa akcja update_order_status korzysta z centralnego OrdersRepository::updateOrderStatus"
- "Retencja historii realizowana cronem automation_history_cleanup (30 dni)"
patterns-established:
- "Tab settings/history z zapamietywaniem aktywnej zakladki"
- "Walidacja configu akcji automatyzacji po stronie kontrolera"
duration: ~55min
started: 2026-03-28T13:50:03+01:00
completed: 2026-03-28T14:45:16+01:00
---
# Phase 49 Plan 01: Automation History + Update Order Status Summary
**Dostarczono rozdzielenie `/settings/automation` na `Ustawienia/Historia`, audyt historii wykonan regul i akcje `update_order_status`, a dodatkowo po testach wdrozono hotfix fallbacku daty pickup dla Apaczka.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~55 min |
| Started | 2026-03-28T13:50:03+01:00 |
| Completed | 2026-03-28T14:45:16+01:00 |
| Tasks | 4 completed |
| Files modified | 16+ |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Dwa taby na `/settings/automation` | Pass | `resources/views/automation/index.php` ma taby `Ustawienia` i `Historia` |
| AC-2: Log co/kiedy/jakie zamowienie | Pass | `AutomationService::logExecution()` + `automation_execution_logs` |
| AC-3: Filtry + paginacja historii | Pass | `AutomationController::index` + `AutomationExecutionLogRepository::paginate/count` |
| AC-4: Auto-usuwanie wpisow >30 dni | Pass | cron `automation_history_cleanup` + `purgeOlderThanDays()` |
| AC-5: Dokumentacja techniczna | Pass | zaktualizowane `DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md`, `DOCS/TECH_CHANGELOG.md` |
| AC-6: Akcja `update_order_status` | Pass | formularz + walidacja + wykonanie przez `OrdersRepository::updateOrderStatus` |
## Verification Results
- `C:\xampp\php\php.exe -l src/Modules/Automation/AutomationController.php` -> OK
- `C:\xampp\php\php.exe -l src/Modules/Automation/AutomationRepository.php` -> OK
- `C:\xampp\php\php.exe -l src/Modules/Automation/AutomationExecutionLogRepository.php` -> OK
- `C:\xampp\php\php.exe -l src/Modules/Automation/AutomationService.php` -> OK
- `C:\xampp\php\php.exe -l src/Modules/Cron/CronHandlerFactory.php` -> OK
- `C:\xampp\php\php.exe -l src/Modules/Cron/AutomationHistoryCleanupHandler.php` -> OK
- `C:\xampp\php\php.exe -l resources/views/automation/form.php` -> OK
- `C:\xampp\php\php.exe -l resources/views/automation/index.php` -> OK
- `sonar-scanner` uruchomiony pomyslnie (project `orderPRO`, dashboard updated)
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `database/migrations/20260328_000072_create_automation_execution_logs_table.sql` | Created | tabela historii + seed cleanup cron |
| `src/Modules/Automation/AutomationExecutionLogRepository.php` | Created | zapis/listowanie/paginacja/purge historii |
| `src/Modules/Cron/AutomationHistoryCleanupHandler.php` | Created | czyszczenie wpisow >30 dni |
| `src/Modules/Cron/CronHandlerFactory.php` | Modified | podpiety handler cleanup |
| `src/Modules/Automation/AutomationController.php` | Modified | backend tabu historii + nowa akcja update_order_status |
| `src/Modules/Automation/AutomationRepository.php` | Modified | sortowanie nazw regul + statusy zamowien do akcji |
| `src/Modules/Automation/AutomationService.php` | Modified | log execution + wykonanie update_order_status |
| `resources/views/automation/index.php` | Modified | taby, filtry i paginacja historii |
| `resources/views/automation/form.php` | Modified | UI akcji `Zmiana statusu zamowienia` |
| `public/assets/js/modules/automation-form.js` | Modified | dynamiczna obsluga nowej akcji |
| `DOCS/DB_SCHEMA.md` | Modified | kontrakt tabeli historii i zmiany flow |
| `DOCS/ARCHITECTURE.md` | Modified | opis nowego flow i akcji |
| `DOCS/TECH_CHANGELOG.md` | Modified | chronologia wdrozenia |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Log historii per regula (success/failed) | pelny audyt bez przerywania triggera | szybsza diagnostyka automatyzacji |
| `update_order_status` przez `OrdersRepository::updateOrderStatus` | reuse centralnego flow status history + activity log | brak duplikacji logiki i spojnosc domenowa |
| Cleanup historii jako cron | retencja bez manualnej obslugi | stabilny rozmiar danych |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 0 | None |
| Scope additions | 1 | Niski, uzasadniony produkcyjnym bledem |
| Deferred | 0 | None |
### Scope addition
- Po APPLY i testach produkcyjnych dodano hotfix `ApaczkaShipmentService`:
- fallback retry dla bledow niedostepnego dnia pickup (`Pickup not available...` oraz `you can't place an order today`),
- automatyczne przesuwanie `pickup.date` na kolejny dzien roboczy (max 7 prob).
- Powod: rzeczywisty blad operacyjny blokujacy tworzenie przesylek.
## Skill Audit
- Required `sonar-scanner`: invoked ✓
- Optional `/code-review`: not invoked
- Optional `/frontend-design`: not invoked
## Next Phase Readiness
**Ready:**
- Automatyzacja ma audytowalna historie z retencja.
- Reguly obsluguja automatyczna zmiane statusu zamowienia.
**Concerns:**
- Warto rozwazyc logowanie rowniez przypadkow `skipped` (warunki niespelnione), aby ulatwic debug bez diagnostyki DB.
**Blockers:**
- None
---
*Phase: 49-automation-history-tab, Plan: 01*
*Completed: 2026-03-28*

View File

@@ -2,5 +2,5 @@ projectKey=orderPRO
serverUrl=https://sonar.project-pro.pl
serverVersion=26.3.0.120487
dashboardUrl=https://sonar.project-pro.pl/dashboard?id=orderPRO
ceTaskId=37b32633-2562-4240-8b42-c6c993262727
ceTaskUrl=https://sonar.project-pro.pl/api/ce/task?id=37b32633-2562-4240-8b42-c6c993262727
ceTaskId=995489dc-a44a-4b15-a2e9-d992a8884994
ceTaskUrl=https://sonar.project-pro.pl/api/ce/task?id=995489dc-a44a-4b15-a2e9-d992a8884994

View File

@@ -19,358 +19,6 @@
"lmtime": 1772490697553,
"modified": false
},
"archive": {
"2026-03-02_users-only-reset": {
"resources": {
"views": {
"dashboard": {
"index.php": {
"type": "-",
"size": 315,
"lmtime": 1771866989000,
"modified": false
}
},
"marketplace": {
"index.php": {
"type": "-",
"size": 1669,
"lmtime": 1771922314000,
"modified": false
},
"offers.php": {
"type": "-",
"size": 19158,
"lmtime": 1772397952604,
"modified": false
}
},
"orders": {
"index.php": {
"type": "-",
"size": 710,
"lmtime": 1772490033013,
"modified": false
}
},
"products": {
"create.php": {
"type": "-",
"size": 7204,
"lmtime": 1771868875000,
"modified": false
},
"edit.php": {
"type": "-",
"size": 27948,
"lmtime": 1772397133535,
"modified": false
},
"index.php": {
"type": "-",
"size": 11064,
"lmtime": 1771956268000,
"modified": false
},
"links.php": {
"type": "-",
"size": 13765,
"lmtime": 1771954576000,
"modified": false
},
"show.php": {
"type": "-",
"size": 9854,
"lmtime": 1772220108000,
"modified": false
}
},
"settings": {
"cron.php": {
"type": "-",
"size": 7180,
"lmtime": 1772485558106,
"modified": false
},
"database.php": {
"type": "-",
"size": 4478,
"lmtime": 1772485529509,
"modified": false
},
"gs1.php": {
"type": "-",
"size": 3499,
"lmtime": 1772485576494,
"modified": false
},
"integrations.php": {
"type": "-",
"size": 11056,
"lmtime": 1772488994330,
"modified": false
},
"order-statuses.php": {
"type": "-",
"size": 5566,
"lmtime": 1772485520769,
"modified": false
},
"products.php": {
"type": "-",
"size": 2225,
"lmtime": 1772485593115,
"modified": false
}
}
}
},
"src": {
"Modules": {
"Cron": {
"CronJobProcessor.php": {
"type": "-",
"size": 6385,
"lmtime": 1771954453000,
"modified": false
},
"CronJobRepository.php": {
"type": "-",
"size": 17045,
"lmtime": 1771954938000,
"modified": false
},
"CronJobType.php": {
"type": "-",
"size": 1231,
"lmtime": 1772489146286,
"modified": false
},
"ProductLinksHealthCheckHandler.php": {
"type": "-",
"size": 5247,
"lmtime": 1771954535000,
"modified": false
},
"ShopProOfferTitlesRefreshHandler.php": {
"type": "-",
"size": 3788,
"lmtime": 1772397918784,
"modified": false
},
"ShopProOrdersImportHandler.php": {
"type": "-",
"size": 536,
"lmtime": 1772484067565,
"modified": false
},
"ShopProOrderStatusSyncHandler.php": {
"type": "-",
"size": 528,
"lmtime": 1772489139382,
"modified": false
}
},
"GS1": {
"GS1Service.php": {
"type": "-",
"size": 2412,
"lmtime": 1772132619000,
"modified": false
},
"MojeGS1Client.php": {
"type": "-",
"size": 6727,
"lmtime": 1771961979000,
"modified": false
}
},
"Marketplace": {
"MarketplaceController.php": {
"type": "-",
"size": 28819,
"lmtime": 1772398277623,
"modified": false
},
"MarketplaceRepository.php": {
"type": "-",
"size": 10298,
"lmtime": 1772398268053,
"modified": false
}
},
"Orders": {
"OrderImportService.php": {
"type": "-",
"size": 21009,
"lmtime": 1772490222940,
"modified": false
},
"OrdersController.php": {
"type": "-",
"size": 35423,
"lmtime": 1772490255436,
"modified": false
},
"OrdersRepository.php": {
"type": "-",
"size": 25665,
"lmtime": 1772489045864,
"modified": false
},
"OrderStatusSyncService.php": {
"type": "-",
"size": 17295,
"lmtime": 1772489130897,
"modified": false
}
},
"ProductLinks": {
"ChannelOffersRepository.php": {
"type": "-",
"size": 10755,
"lmtime": 1771954497000,
"modified": false
},
"LinkMatcherService.php": {
"type": "-",
"size": 1893,
"lmtime": 1771882685000,
"modified": false
},
"OfferImportService.php": {
"type": "-",
"size": 8091,
"lmtime": 1771954510000,
"modified": false
},
"ProductLinksController.php": {
"type": "-",
"size": 5392,
"lmtime": 1771882733000,
"modified": false
},
"ProductLinksRepository.php": {
"type": "-",
"size": 20901,
"lmtime": 1771954562000,
"modified": false
},
"ProductLinksService.php": {
"type": "-",
"size": 14754,
"lmtime": 1771927037000,
"modified": false
}
},
"Products": {
"ProductRepository.php": {
"type": "-",
"size": 29887,
"lmtime": 1772395707501,
"modified": false
},
"ProductsController.php": {
"type": "-",
"size": 49058,
"lmtime": 1772395718310,
"modified": false
},
"ProductService.php": {
"type": "-",
"size": 17193,
"lmtime": 1772395136766,
"modified": false
},
"ProductSkuGenerator.php": {
"type": "-",
"size": 3044,
"lmtime": 1772395702627,
"modified": false
},
"ProductValidator.php": {
"type": "-",
"size": 3675,
"lmtime": 1771868735000,
"modified": false
},
"ShopProExportService.php": {
"type": "-",
"size": 45644,
"lmtime": 1772395159115,
"modified": false
}
},
"Settings": {
"AppSettingsRepository.php": {
"type": "-",
"size": 1905,
"lmtime": 1771954924000,
"modified": false
},
"IntegrationRepository.php": {
"type": "-",
"size": 25754,
"lmtime": 1772488971508,
"modified": false
},
"OrderStatusMappingRepository.php": {
"type": "-",
"size": 4135,
"lmtime": 1772489019745,
"modified": false
},
"SettingsController.php": {
"type": "-",
"size": 73812,
"lmtime": 1772488985859,
"modified": false
},
"ShopProClient.php": {
"type": "-",
"size": 40035,
"lmtime": 1772490209403,
"modified": false
}
}
}
},
"bin": {
"cron.php": {
"type": "-",
"size": 4062,
"lmtime": 1772489168039,
"modified": false
}
},
"tests": {
"Unit": {
"Cron": {
"CronJobTypeTest.php": {
"type": "-",
"size": 603,
"lmtime": 1772489500486,
"modified": false
}
},
"Settings": {
"OrderStatusMappingRepositoryTest.php": {
"type": "-",
"size": 2415,
"lmtime": 1772489512491,
"modified": false
},
"ShopProClientTest.php": {
"type": "-",
"size": 972,
"lmtime": 1772489519995,
"modified": false
}
}
}
}
}
},
"bin": {
"build-assets.php": {
"type": "-",
@@ -914,6 +562,12 @@
"size": 538,
"lmtime": 1774304095531,
"modified": false
},
"20260327_000071_add_last_status_pushed_at_to_sync_state.sql": {
"type": "-",
"size": 319,
"lmtime": 1774611787688,
"modified": false
}
},
"seeders": {},
@@ -956,6 +610,12 @@
"lmtime": 1772490689218,
"modified": false
},
"delivery-tab-bug.png": {
"type": "-",
"size": 124327,
"lmtime": 1774565855738,
"modified": false
},
"deploy-vendor.php": {
"type": "-",
"size": 2097,
@@ -965,14 +625,14 @@
"DOCS": {
"ARCHITECTURE.md": {
"type": "-",
"size": 35120,
"lmtime": 1774475884811,
"size": 35558,
"lmtime": 1774612062257,
"modified": false
},
"DB_SCHEMA.md": {
"type": "-",
"size": 29737,
"lmtime": 1773789666999,
"size": 29871,
"lmtime": 1774612041000,
"modified": false
},
"ORDERS_SCHEMA_APILO_DRAFT.md": {
@@ -995,8 +655,8 @@
},
"TECH_CHANGELOG.md": {
"type": "-",
"size": 57304,
"lmtime": 1774475891183,
"size": 57684,
"lmtime": 1774612077539,
"modified": false
},
"todo.md": {
@@ -2258,8 +1918,8 @@
"css": {
"app.css": {
"type": "-",
"size": 43993,
"lmtime": 1774474931663,
"size": 44903,
"lmtime": 1774600385594,
"modified": false
},
"app.css.map": {
@@ -2298,6 +1958,12 @@
"lmtime": 1774475530521,
"modified": false
},
"inline-status-change.js": {
"type": "-",
"size": 6603,
"lmtime": 1774600361548,
"modified": false
},
"jquery-alerts.js": {
"type": "-",
"size": 5768,
@@ -2361,8 +2027,8 @@
},
"app.scss": {
"type": "-",
"size": 42711,
"lmtime": 1774304207044,
"size": 43794,
"lmtime": 1774600368218,
"modified": false
},
"login.css": {
@@ -2499,8 +2165,8 @@
},
"list.php": {
"type": "-",
"size": 1603,
"lmtime": 1774473665048,
"size": 2015,
"lmtime": 1774599283649,
"modified": false
},
"partials": {
@@ -2957,8 +2623,8 @@
},
"CronHandlerFactory.php": {
"type": "-",
"size": 7691,
"lmtime": 1774475612061,
"size": 7970,
"lmtime": 1774612020782,
"modified": false
},
"CronJobProcessor.php": {
@@ -3103,8 +2769,8 @@
},
"OrdersController.php": {
"type": "-",
"size": 33087,
"lmtime": 1774473628426,
"size": 34671,
"lmtime": 1774599151246,
"modified": false
},
"OrdersRepository.php": {
@@ -3433,9 +3099,9 @@
},
"ShopproApiClient.php": {
"type": "-",
"size": 9991,
"lmtime": 1772996784239,
"modified": true
"size": 12582,
"lmtime": 1774612664232,
"modified": false
},
"ShopProClient.php": {
"type": "-",
@@ -3475,8 +3141,8 @@
},
"ShopproOrderSyncStateRepository.php": {
"type": "-",
"size": 8941,
"lmtime": 0,
"size": 10418,
"lmtime": 1774611852363,
"modified": false
},
"ShopproPaymentStatusSyncService.php": {
@@ -3499,8 +3165,8 @@
},
"ShopproStatusSyncService.php": {
"type": "-",
"size": 2075,
"lmtime": 1773397552472,
"size": 9585,
"lmtime": 1774611928171,
"modified": false
}
},
@@ -5644,6 +5310,12 @@
"lmtime": 1772995312041,
"modified": false
},
"psd_personalize.py": {
"type": "-",
"size": 42084,
"lmtime": 1774652966314,
"modified": false
},
"resync_shoppro_6_once.php": {
"type": "-",
"size": 2930,
@@ -5856,12 +5528,6 @@
"phpmailer": {
"phpmailer": {}
}
},
"delivery-tab-bug.png": {
"type": "-",
"size": 124327,
"lmtime": 1774565855738,
"modified": false
}
}
},

View File

@@ -8,6 +8,7 @@
- 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 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`).
- Orkiestracja automatyzacji obsluguje chain events: akcja moze emitowac kolejne zdarzenie (`emitEvent`), a engine propaguje wspolny kontekst lancucha.
- Zabezpieczenia chain automation (dla obecnych i przyszlych eventow):
- limit glebokosci lancucha (`MAX_CHAIN_DEPTH`),
@@ -15,6 +16,10 @@
- limit historii wykonan w kontekście (`MAX_CHAIN_EXECUTIONS`).
- `ShipmentTrackingHandler` triggeruje automatyzacje tylko po zmianie `delivery_status` i przekazuje kontekst (`package_id`, `provider`, `delivery_status`, `delivery_status_raw`, `previous_status`).
- Kolejka wydruku ma akcje usuwania wpisu przez route `POST /settings/printing/jobs/delete` (CSRF + `OrderProAlerts.confirm`).
- Szablony e-mail obsluguja zmienne przesylki:
- `{{przesylka.numer}}` -> `shipment_packages.tracking_number` (najnowsza paczka zamowienia),
- `{{przesylka.link_sledzenia}}` -> `DeliveryStatus::trackingUrl(provider, tracking_number, carrier_id)`,
- fallback: gdy brak paczki lub tracking number, wartosci sa puste.
## Moduly aktywne
- `App\Modules\Auth`
@@ -43,6 +48,7 @@
- `GET /settings` (redirect do `/settings/users`)
- `GET /settings/database`
- `POST /settings/database/migrate`
- `GET /cron` (publiczny trigger crona HTTPS, autoryzacja tokenem)
- `GET /settings/statuses`
- `POST /settings/status-groups`
- `POST /settings/status-groups/update`
@@ -158,7 +164,7 @@
- `App\Modules\Accounting\AccountingController` (index — lista paragonow, export — XLSX)
- `App\Modules\Automation\AutomationController` (index, create, store, edit, update, destroy, toggleStatus)
- `App\Modules\Automation\AutomationRepository` (findAll, findById, create, update, delete, toggleActive, findActiveByEvent)
- `App\Modules\Automation\AutomationService` (trigger, evaluateConditions, executeActions — watcher/executor regul automatyzacji; flow: ReceiptController::store() -> trigger('receipt.created'), ShipmentController::create()/createManual() -> trigger('shipment.created', context), ShipmentTrackingHandler::handle() -> trigger('shipment.status_changed', context) -> ewaluacja warunkow -> akcje: EmailSendingService::send() / auto issue_receipt / update_shipment_status)
- `App\Modules\Automation\AutomationService` (trigger, evaluateConditions, executeActions — watcher/executor regul automatyzacji; flow: ReceiptController::store() -> trigger('receipt.created'), ShipmentController::create()/createManual() -> trigger('shipment.created', context), ShipmentTrackingHandler::handle() -> trigger('shipment.status_changed', context) -> ewaluacja warunkow -> akcje: EmailSendingService::send() / auto issue_receipt / update_shipment_status / update_order_status)
- `App\Modules\Shipments\ShipmentProviderInterface`
- `App\Modules\Shipments\ShipmentProviderRegistry`
- `App\Modules\Shipments\ApaczkaShipmentService`
@@ -294,8 +300,13 @@
## Przeplyw wykonania crona
- `bin/cron.php`:
- laduje aplikacje i uruchamia `CronRunner::run($limit)`.
- `GET /cron?token=...`:
- publiczny trigger uruchamiany z zewnetrznego crona HTTPS,
- waliduje token `CRON_PUBLIC_TOKEN`,
- uruchamia `CronRunner::run($limit)` z limitem opartym o `cron_web_limit`.
- `App\Core\Application::maybeRunCronOnWeb(Request): void`:
- przy wlaczonej opcji `cron_run_on_web=1` uruchamia `CronRunner` podczas requestu HTTP,
- pomija endpointy `/cron` i `/cron/*` (dedykowany trigger uruchamia cron recznie, bez podwojenia),
- stosuje throttling sesyjny i lock DB (`GET_LOCK`) zeby uniknac wielu rownoleglych workerow.
- `CronRunner`:
- dispatchuje due schedule z `cron_schedules` do `cron_jobs`,
@@ -322,6 +333,7 @@
- wybiera providera dynamicznie po `provider_code` i deleguje do `ShipmentProviderInterface::createShipment(...)`,
- po sukcesie tworzenia paczki triggeruje automatyzacje `shipment.created` z kontekstem paczki (`package_id`, `provider`, `tracking_number`, `package_status`, `delivery_status`),
- dla `apaczka` waliduje wymagane punkty odbioru/nadania wg definicji uslugi (`service_structure`) i przy bledzie wyceny zwraca rozszerzona diagnostyke parametrow,
- dla `apaczka` przy bledzie API niedostepnego dnia nadania (np. `Pickup not available for selected day` lub `you can't place an order today`) w trybie `COURIER` serwis automatycznie ponawia `order_send` z `pickup.date` przesunieta na kolejny dzien roboczy (max 7 przesuniec),
- `apaczka` uzupelnia i wysyla `contact_person` dla nadawcy (z `Ustawienia > Dane firmy`) i odbiorcy (fallback z danych zamowienia),
- `apaczka` ustawia jawnie `pickup.type` (`SELF`/`COURIER`) na podstawie uslugi i obecnosci `sender_point_id`; dla `COURIER` dopelnia tez `pickup.date`, `pickup.hours_from`, `pickup.hours_to`,
- dla uslug punktowych `apaczka` payload adresu zawiera aliasy identyfikatora punktu (`point`, `foreign_address_id`, `point_id`) dla nadania i odbioru,
@@ -548,3 +560,24 @@
- laduje formy dostawy wykryte w zamowieniach danej instancji (`orders.source=shoppro` + `orders.integration_id`),
- laduje uslugi dostawy z Allegro API (`delivery-services`) z fallbackiem na odswiezenie tokenu OAuth,
- zapisuje mapowanie: forma dostawy shopPRO -> usluga Allegro/InPost WZA.
## Przeplyw Ustawienia > Zadania automatyczne (aktualizacja 2026-03-28)
- `GET /settings/automation`:
- `AutomationController::index(Request): Response` renderuje taby `Ustawienia` i `Historia`.
- tab `Ustawienia` zawiera istniejacy CRUD regul automatyzacji.
- tab `Historia` pokazuje wpisy z `automation_execution_logs` z filtrami (`event_type`, `execution_status`, `rule_id`, `order_id`, `date_from`, `date_to`) i paginacja (`history_page`, 25/strona).
- `AutomationService::trigger(...)` zapisuje historie wykonania reguly:
- status `success` po wykonaniu akcji,
- status `failed` przy wyjatku podczas wykonania reguly,
- zapisywany kontrakt: `event_type`, `rule_id`, `rule_name`, `order_id`, `execution_status`, `result_message`, `executed_at`, `context_json`.
- Akcja `update_order_status`:
- konfiguracja reguly przechowuje `status_code`,
- wykonanie delegowane do `OrdersRepository::updateOrderStatus(...)` (wspolny flow historii statusow i activity logu),
- aktor zmiany: `system` / `Automatyzacja: <nazwa reguly>`.
- Retencja historii:
- cron job `automation_history_cleanup` wywoluje `AutomationHistoryCleanupHandler::handle(...)`,
- handler usuwa wpisy starsze niz N dni (domyslnie 30) przez `AutomationExecutionLogRepository::purgeOlderThanDays(...)`.
## Klasy (aktualizacja 2026-03-28)
- `App\Modules\Automation\AutomationExecutionLogRepository` (create, paginate, count, listEventTypes, purgeOlderThanDays).
- `App\Modules\Cron\AutomationHistoryCleanupHandler` (cleanup retencji historii automatyzacji).

View File

@@ -97,6 +97,13 @@ Migracje z prefiksem `ensure_` to migracje kompensujące — zostały dodane
- 2026-03-15: Dodano migracje `20260315_000056_create_email_logs_table.sql` — tabela logow wyslanych wiadomosci z FK do email_templates, email_mailboxes i indeksami na order_id, status, sent_at.
- 2026-03-16: Dodano migracje `20260316_000001_add_attachment1_to_email_templates.sql` — kolumna attachment_1 VARCHAR(50) w email_templates (typ zalacznika, np. 'receipt').
- 2026-03-17: Nowa zaleznosc `phpmailer/phpmailer` v7.0.2. Modul `App\Modules\Email` — wysylka e-mail z zamowien, resolwer zmiennych, generowanie zalacznikow PDF. Tabela `email_logs` wykorzystywana do logowania wysylek (bez nowych migracji).
- 2026-03-28: Rozszerzono automatyzacje o akcje `update_order_status` (zmiana statusu zamowienia) - bez zmian schematu (wykorzystuje istniejace `order_statuses`, `orders`, `order_status_history`, `order_activity_log`).
- 2026-03-28: Hotfix Apaczka bledow niedostepnego dnia nadania (`Pickup not available for selected day` oraz `you can't place an order today`) - bez zmian schematu (retry `order_send` z automatycznym przesuwaniem `pickup.date` dla `pickup.type=COURIER`).
- 2026-03-28: Dodano publiczny endpoint triggera crona HTTPS (`/cron`) z tokenem `CRON_PUBLIC_TOKEN` - bez zmian schematu bazy.
- 2026-03-28: Dodano migracje `20260328_000072_create_automation_execution_logs_table.sql`:
- nowa tabela `automation_execution_logs` (historia wykonan regul automatyzacji: co, kiedy, na jakim zamowieniu, wynik),
- indeksy pod filtrowanie po czasie/zdarzeniu/statusie/regule/zamowieniu,
- seed harmonogramu `cron_schedules` dla joba `automation_history_cleanup` (retencja historii starszej niz 30 dni).
## Tabele
@@ -474,6 +481,29 @@ Migracje z prefiksem `ensure_` to migracje kompensujące — zostały dodane
- Klucze obce:
- `auto_act_rule_fk`: `rule_id` -> `automation_rules.id` (ON DELETE CASCADE)
### `automation_execution_logs`
- Historia wykonan automatyzacji pokazywana w `Ustawienia > Zadania automatyczne > Historia`.
- Kolumny:
- `id` BIGINT UNSIGNED PK AUTO_INCREMENT
- `event_type` VARCHAR(64) NOT NULL — typ triggera (np. `receipt.created`, `shipment.created`)
- `rule_id` INT UNSIGNED NULL — FK do `automation_rules.id` (NULL gdy regula zostala usunieta)
- `rule_name` VARCHAR(128) NOT NULL — snapshot nazwy reguly w momencie wykonania
- `order_id` INT UNSIGNED NOT NULL — FK do `orders.id`
- `execution_status` VARCHAR(16) NOT NULL — wynik (`success`/`failed`)
- `result_message` VARCHAR(500) NULL — komunikat wykonania lub bledu
- `context_json` JSON NULL — zrzut kontekstu triggera (sanityzowany)
- `executed_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
- `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
- Indeksy:
- `auto_exec_logs_executed_idx` (`executed_at`)
- `auto_exec_logs_event_idx` (`event_type`)
- `auto_exec_logs_status_idx` (`execution_status`)
- `auto_exec_logs_rule_idx` (`rule_id`)
- `auto_exec_logs_order_idx` (`order_id`)
- Klucze obce:
- `auto_exec_logs_rule_fk`: `rule_id` -> `automation_rules.id` (`ON DELETE SET NULL`, `ON UPDATE CASCADE`)
- `auto_exec_logs_order_fk`: `order_id` -> `orders.id` (`ON DELETE CASCADE`, `ON UPDATE CASCADE`)
## Zasady aktualizacji
- Po kazdej migracji dopisz:
- nowe/zmienione tabele i kolumny,

View File

@@ -1,5 +1,67 @@
# Tech Changelog
## 2026-03-28 (Public HTTPS cron endpoint)
- Dodano publiczny endpoint triggera crona:
- `GET /cron?token=<CRON_PUBLIC_TOKEN>`
- dodatkowo kompatybilny wariant sciezki: `GET /cron/token=<CRON_PUBLIC_TOKEN>`.
- Token jest walidowany przez `hash_equals` i pochodzi z nowej zmiennej srodowiskowej `CRON_PUBLIC_TOKEN`.
- Endpoint uruchamia `CronRunner` z limitem z ustawienia `cron_web_limit`.
- `Application::maybeRunCronOnWeb(...)` ignoruje teraz sciezki `/cron` i `/cron/*`, aby uniknac podwojnego triggera.
- Zaktualizowano `.env.example` o `CRON_PUBLIC_TOKEN`.
## 2026-03-28 (Hotfix - Apaczka pickup day fallback)
- `ApaczkaShipmentService`:
- dodano automatyczny retry `order_send` dla bledu API `Pickup not available for selected day`,
- rozszerzono detekcje bledu o wariant komunikatu: `We're sorry, you can't place an order today. Change its date to another working day.`,
- fallback dotyczy tylko `pickup.type=COURIER`,
- kazdy retry przesuwa `pickup.date` na kolejny dzien roboczy (`normalizeCourierPickupDate`) i ponawia wysylke,
- limit fallbacku: do 7 kolejnych dni, potem zwracany jest oryginalny blad API.
## 2026-03-28 (Phase 49 - Automation History Tab, Plan 01 - rozszerzenie akcji)
- Rozszerzono automatyzacje o nowy typ akcji `update_order_status` (UI: `Zmiana statusu zamowienia`).
- `AutomationController`:
- `ALLOWED_ACTION_TYPES` zawiera `update_order_status`,
- waliduje i parsuje `order_status_code` tylko do aktywnych statusow z `order_statuses`.
- `AutomationRepository`:
- nowa metoda `listActiveOrderStatuses()` zwracajaca aktywne statusy (`code`, `name`) sortowane rosnaco po nazwie.
- `resources/views/automation/form.php` i `public/assets/js/modules/automation-form.js`:
- nowa opcja akcji z wyborem docelowego statusu zamowienia.
- `AutomationService`:
- nowy handler `handleUpdateOrderStatus(...)`,
- wykonanie zmiany przez `OrdersRepository::updateOrderStatus(...)` z aktorem systemowym `Automatyzacja: <nazwa reguly>`,
- fallback log aktywnosci `automation_order_status_failed` gdy zmiana nie powiedzie sie.
## 2026-03-28 (Phase 49 - Automation History Tab, Plan 01)
- `Ustawienia > Zadania automatyczne` (`/settings/automation`) rozdzielone na 2 taby:
- `Ustawienia` - obecne zarzadzanie regulami,
- `Historia` - log wykonan automatyzacji z filtrowaniem i paginacja.
- Dodano migracje `20260328_000072_create_automation_execution_logs_table.sql`:
- nowa tabela `automation_execution_logs` (event, regula, order, status, wynik, context, timestamp),
- indeksy pod filtry historii,
- seed harmonogramu crona `automation_history_cleanup` (co 24h).
- Nowe klasy backend:
- `AutomationExecutionLogRepository` - zapis/listowanie/paginacja/licznik historii + purge retencji,
- `AutomationHistoryCleanupHandler` - usuwanie wpisow starszych niz konfigurowalna liczba dni (domyslnie 30).
- `AutomationService::trigger(...)` zapisuje wpis historii per wykonana regula:
- `success` po poprawnym wykonaniu akcji,
- `failed` przy wyjatku w wykonaniu reguly.
- `AutomationController::index(...)` obsluguje filtry historii (`history_*`) i paginacje (`history_page`), zachowujac kompatybilnosc listy regul.
- UI historii wykorzystuje kompaktowy formularz filtrow i paginacje z zachowaniem aktywnych parametrow.
## 2026-03-28 (Phase 48 - Email Template Shipment Variables, Plan 01)
- Email templates (`/settings/email-templates`):
- dodano zmienne `{{przesylka.numer}}` i `{{przesylka.link_sledzenia}}` w `EmailTemplateController::VARIABLE_GROUPS`,
- rozszerzono `SAMPLE_DATA` do preview o przykladowy numer i URL sledzenia.
- `VariableResolver`:
- otrzymuje zaleznosc `ShipmentPackageRepository`,
- pobiera najnowsza paczke (`findLatestByOrderId(order_id)`),
- mapuje `przesylka.numer` i `przesylka.link_sledzenia`,
- link jest liczony przez `DeliveryStatus::trackingUrl(provider, tracking_number, carrier_id)`.
- DI:
- `routes/web.php` i `CronHandlerFactory` przekazuja `ShipmentPackageRepository` do `VariableResolver`.
- Zachowanie brzegowe:
- brak paczki lub brak numeru trackingowego nie psuje renderu - zmienne przesylki zwracaja pusty string.
## 2026-03-28 (Phase 47 - Shipment Creation Automation, Plan 01)
- Automatyzacja:
- dodano nowe zdarzenie `shipment.created` (UI: `Utworzenie przesylki`),
@@ -739,3 +801,4 @@

View File

@@ -226,3 +226,4 @@
47. [x] Zadania automatyczne: nowe zdarzenie Utworzenie przesylki uruchamiane od razu po utworzeniu paczki oraz nowa akcja Zmiana statusu przesylki.
48. [x] Szablony e-mail: dodane zmienne `{{przesylka.numer}}` i `{{przesylka.link_sledzenia}}` z linkiem zaleznym od kuriera/providera.

View File

@@ -19,6 +19,7 @@ return [
'cron' => [
'run_on_web_default' => Env::bool('CRON_RUN_ON_WEB', false),
'web_limit_default' => max(1, min(100, (int) Env::get('CRON_WEB_LIMIT', '5'))),
'public_token' => Env::get('CRON_PUBLIC_TOKEN', ''),
],
'view_path' => dirname(__DIR__) . '/resources/views',
'lang_path' => dirname(__DIR__) . '/resources/lang',

View File

@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS `automation_execution_logs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`event_type` VARCHAR(64) NOT NULL,
`rule_id` INT UNSIGNED DEFAULT NULL,
`rule_name` VARCHAR(128) NOT NULL,
`order_id` INT UNSIGNED NOT NULL,
`execution_status` VARCHAR(16) NOT NULL,
`result_message` VARCHAR(500) DEFAULT NULL,
`context_json` JSON DEFAULT NULL,
`executed_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `auto_exec_logs_executed_idx` (`executed_at`),
INDEX `auto_exec_logs_event_idx` (`event_type`),
INDEX `auto_exec_logs_status_idx` (`execution_status`),
INDEX `auto_exec_logs_rule_idx` (`rule_id`),
INDEX `auto_exec_logs_order_idx` (`order_id`),
CONSTRAINT `auto_exec_logs_rule_fk` FOREIGN KEY (`rule_id`) REFERENCES `automation_rules` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT `auto_exec_logs_order_fk` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO cron_schedules (job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at, created_at, updated_at)
VALUES ('automation_history_cleanup', 86400, 70, 1, JSON_OBJECT('days', 30), 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);

File diff suppressed because one or more lines are too long

View File

@@ -109,6 +109,20 @@
return html;
}
function buildOrderStatusActionConfig(namePrefix) {
var html = '<select class="form-control" name="' + namePrefix + '[order_status_code]">'
+ '<option value="">-- Wybierz docelowy status zamowienia --</option>';
(data.orderStatusOptions || []).forEach(function(statusOption) {
html += '<option value="' + escapeHtml(statusOption.code || '') + '">'
+ escapeHtml(statusOption.name || statusOption.code || '')
+ '</option>';
});
html += '</select>';
return html;
}
function addCondition() {
var idx = getNextIndex(conditionsContainer);
var namePrefix = 'conditions[' + idx + ']';
@@ -144,6 +158,7 @@
+ '<option value="send_email" selected>Wyslij e-mail</option>'
+ '<option value="issue_receipt">Wystaw paragon</option>'
+ '<option value="update_shipment_status">Zmiana statusu przesylki</option>'
+ '<option value="update_order_status">Zmiana statusu zamowienia</option>'
+ '</select>'
+ '<div class="automation-row__config">'
+ buildEmailActionConfig(namePrefix)
@@ -190,6 +205,10 @@
}
if (select.value === 'update_shipment_status') {
configDiv.innerHTML = buildShipmentStatusActionConfig(namePrefix);
return;
}
if (select.value === 'update_order_status') {
configDiv.innerHTML = buildOrderStatusActionConfig(namePrefix);
}
}

View File

@@ -757,7 +757,7 @@ return [
'status_sync_direction' => 'Kierunek synchronizacji statusow',
'status_sync_direction_allegro_to_orderpro' => 'Allegro -> orderPRO',
'status_sync_direction_orderpro_to_allegro' => 'orderPRO -> Allegro',
'status_sync_direction_hint' => 'Aktualnie aktywny jest kierunek Allegro -> orderPRO. Ustawienie orderPRO -> Allegro jest przygotowane pod kolejny etap.',
'status_sync_direction_hint' => 'Wybierz kierunek synchronizacji statusow pomiedzy Allegro i orderPRO.',
'status_sync_interval_minutes' => 'Interwal synchronizacji statusow (minuty)',
'status_sync_interval_hint' => 'Zakres: 1-1440 minut. Ustawienie zostanie uzyte przez zadanie synchronizacji statusow.',
'save' => 'Zapisz ustawienia',

View File

@@ -2404,7 +2404,7 @@ h4.section-title {
flex-direction: column;
border: 1px solid var(--c-border);
border-radius: 6px;
overflow: hidden;
overflow: visible;
}
.email-tpl-toolbar {
@@ -2424,7 +2424,7 @@ h4.section-title {
position: absolute;
top: 100%;
left: 0;
z-index: 50;
z-index: 300;
min-width: 260px;
max-height: 320px;
overflow-y: auto;

View File

@@ -63,3 +63,39 @@
margin: 0;
}
}
.automation-actions-cell {
white-space: nowrap;
}
.automation-inline-form {
display: inline;
}
.automation-history-filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
align-items: end;
.form-field {
margin: 0;
}
.field-label {
font-size: 12px;
margin-bottom: 4px;
}
.form-control {
min-height: 34px;
}
&__actions {
display: flex;
gap: 6px;
align-items: center;
justify-content: flex-start;
padding-bottom: 1px;
}
}

View File

@@ -30,6 +30,7 @@ $receiptDuplicatePolicyLabels = [
'allow_duplicates' => 'Wystawiaj kolejne paragony',
];
$shipmentStatusOptions = is_array($shipmentStatusOptions ?? null) ? $shipmentStatusOptions : [];
$orderStatusOptions = is_array($orderStatusOptions ?? null) ? $orderStatusOptions : [];
?>
<section class="card">
@@ -127,6 +128,7 @@ $shipmentStatusOptions = is_array($shipmentStatusOptions ?? null) ? $shipmentSta
<option value="send_email"<?= ((string) ($act['action_type'] ?? '')) === 'send_email' ? ' selected' : '' ?>>Wyslij e-mail</option>
<option value="issue_receipt"<?= ((string) ($act['action_type'] ?? '')) === 'issue_receipt' ? ' selected' : '' ?>>Wystaw paragon</option>
<option value="update_shipment_status"<?= ((string) ($act['action_type'] ?? '')) === 'update_shipment_status' ? ' selected' : '' ?>>Zmiana statusu przesylki</option>
<option value="update_order_status"<?= ((string) ($act['action_type'] ?? '')) === 'update_order_status' ? ' selected' : '' ?>>Zmiana statusu zamowienia</option>
</select>
<div class="automation-row__config">
<?php
@@ -168,6 +170,19 @@ $shipmentStatusOptions = is_array($shipmentStatusOptions ?? null) ? $shipmentSta
</option>
<?php endforeach; ?>
</select>
<?php elseif ($actionType === 'update_order_status'): ?>
<select class="form-control" name="actions[<?= $idx ?>][order_status_code]">
<option value="">-- Wybierz docelowy status zamowienia --</option>
<?php foreach ($orderStatusOptions as $statusOption): ?>
<?php
$statusCode = (string) ($statusOption['code'] ?? '');
$statusName = (string) ($statusOption['name'] ?? $statusCode);
?>
<option value="<?= $e($statusCode) ?>"<?= ((string) ($actConfig['status_code'] ?? '')) === $statusCode ? ' selected' : '' ?>>
<?= $e($statusName) ?>
</option>
<?php endforeach; ?>
</select>
<?php else: ?>
<select class="form-control" name="actions[<?= $idx ?>][template_id]">
<option value="">-- Wybierz szablon --</option>
@@ -217,7 +232,13 @@ window.AutomationFormData = {
receiptIssueDateModeLabels: <?= json_encode($receiptIssueDateModeLabels, JSON_UNESCAPED_UNICODE) ?>,
receiptDuplicatePolicies: <?= json_encode($receiptDuplicatePolicies, JSON_UNESCAPED_UNICODE) ?>,
receiptDuplicatePolicyLabels: <?= json_encode($receiptDuplicatePolicyLabels, JSON_UNESCAPED_UNICODE) ?>,
shipmentStatusOptions: <?= json_encode($shipmentStatusOptions, JSON_UNESCAPED_UNICODE) ?>
shipmentStatusOptions: <?= json_encode($shipmentStatusOptions, JSON_UNESCAPED_UNICODE) ?>,
orderStatusOptions: <?= json_encode(array_map(function($status) {
return [
'code' => (string) ($status['code'] ?? ''),
'name' => (string) ($status['name'] ?? '')
];
}, $orderStatusOptions), JSON_UNESCAPED_UNICODE) ?>
};
</script>
<script src="/assets/js/modules/automation-form.js"></script>

View File

@@ -1,11 +1,47 @@
<?php
$rules = is_array($rules ?? null) ? $rules : [];
$historyEntries = is_array($historyEntries ?? null) ? $historyEntries : [];
$historyFilters = is_array($historyFilters ?? null) ? $historyFilters : [];
$historyEventTypes = is_array($historyEventTypes ?? null) ? $historyEventTypes : [];
$historyRuleOptions = is_array($historyRuleOptions ?? null) ? $historyRuleOptions : [];
$historyPagination = is_array($historyPagination ?? null) ? $historyPagination : [];
$activeTab = (string) ($activeTab ?? 'settings');
$historyPage = max(1, (int) ($historyPagination['page'] ?? 1));
$historyTotalPages = max(1, (int) ($historyPagination['total_pages'] ?? 1));
$historyTotal = max(0, (int) ($historyPagination['total'] ?? 0));
$eventLabels = [
'receipt.created' => 'Utworzono paragon',
'shipment.created' => 'Utworzenie przesylki',
'shipment.status_changed' => 'Zmiana statusu przesylki',
];
$statusLabels = [
'success' => 'Sukces',
'failed' => 'Blad',
];
$historyFiltersDefault = [
'history_event_type' => (string) ($historyFilters['event_type'] ?? ''),
'history_status' => (string) ($historyFilters['execution_status'] ?? ''),
'history_rule_id' => (int) ($historyFilters['rule_id'] ?? 0),
'history_order_id' => (int) ($historyFilters['order_id'] ?? 0),
'history_date_from' => (string) ($historyFilters['date_from'] ?? ''),
'history_date_to' => (string) ($historyFilters['date_to'] ?? ''),
'tab' => 'history',
];
$buildHistoryUrl = static function (array $overrides = []) use ($historyFiltersDefault): string {
$params = array_merge($historyFiltersDefault, $overrides);
foreach ($params as $key => $value) {
if (is_int($value) && $value <= 0) {
$params[$key] = '';
}
}
return '/settings/automation?' . http_build_query($params);
};
?>
<section class="card">
@@ -13,7 +49,7 @@ $eventLabels = [
<h2 class="section-title">Zadania automatyczne</h2>
<a href="/settings/automation/create" class="btn btn--primary btn--sm">Dodaj zadanie</a>
</div>
<p class="muted mt-12">Regu&#322;y automatyzacji wykonywane po wyst&#261;pieniu zdarzenia.</p>
<p class="muted mt-8">Reguly automatyzacji wykonywane po wystapieniu zdarzenia.</p>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
@@ -21,82 +57,289 @@ $eventLabels = [
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
<?php endif; ?>
</section>
<section class="card mt-16">
<?php if (count($rules) === 0): ?>
<p class="muted mt-12">Brak zadan automatycznych. Kliknij &ldquo;Dodaj zadanie&rdquo; aby utworzyc pierwsza regule.</p>
<?php else: ?>
<nav class="content-tabs-nav mt-12" aria-label="Zakladki automatyzacji">
<button type="button" class="content-tab-btn<?= $activeTab === 'settings' ? ' is-active' : '' ?>" data-tab-target="automation-tab-settings">Ustawienia</button>
<button type="button" class="content-tab-btn<?= $activeTab === 'history' ? ' is-active' : '' ?>" data-tab-target="automation-tab-history">Historia</button>
</nav>
<div class="content-tab-panel<?= $activeTab === 'settings' ? ' is-active' : '' ?>" data-tab-panel="automation-tab-settings">
<?php if (count($rules) === 0): ?>
<p class="muted mt-12">Brak zadan automatycznych. Kliknij "Dodaj zadanie" aby utworzyc pierwsza regule.</p>
<?php else: ?>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>Nazwa</th>
<th>Zdarzenie</th>
<th>Warunkow</th>
<th>Akcji</th>
<th>Status</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
<?php foreach ($rules as $rule): ?>
<tr>
<td><?= $e((string) ($rule['name'] ?? '')) ?></td>
<td><?= $e($eventLabels[(string) ($rule['event_type'] ?? '')] ?? (string) ($rule['event_type'] ?? '')) ?></td>
<td><?= (int) ($rule['conditions_count'] ?? 0) ?></td>
<td><?= (int) ($rule['actions_count'] ?? 0) ?></td>
<td>
<?php if (((int) ($rule['is_active'] ?? 0)) === 1): ?>
<span class="badge badge--success">Aktywne</span>
<?php else: ?>
<span class="badge badge--muted">Nieaktywne</span>
<?php endif; ?>
</td>
<td class="automation-actions-cell">
<a href="/settings/automation/edit?id=<?= (int) ($rule['id'] ?? 0) ?>" class="btn btn--sm btn--secondary">Edytuj</a>
<form action="/settings/automation/duplicate" method="post" class="automation-inline-form">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="id" value="<?= (int) ($rule['id'] ?? 0) ?>">
<button type="submit" class="btn btn--sm btn--secondary">Duplikuj</button>
</form>
<form action="/settings/automation/toggle" method="post" class="automation-inline-form">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="id" value="<?= (int) ($rule['id'] ?? 0) ?>">
<button type="submit" class="btn btn--sm btn--secondary">
<?= ((int) ($rule['is_active'] ?? 0)) === 1 ? 'Dezaktywuj' : 'Aktywuj' ?>
</button>
</form>
<form action="/settings/automation/delete" method="post" class="automation-inline-form js-confirm-delete">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="id" value="<?= (int) ($rule['id'] ?? 0) ?>">
<button type="button" class="btn btn--sm btn--danger js-delete-btn">Usun</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<div class="content-tab-panel<?= $activeTab === 'history' ? ' is-active' : '' ?>" data-tab-panel="automation-tab-history">
<form method="get" action="/settings/automation" class="automation-history-filters mt-12">
<input type="hidden" name="tab" value="history">
<label class="form-field">
<span class="field-label">Zdarzenie</span>
<select class="form-control" name="history_event_type">
<option value="">Wszystkie</option>
<?php foreach ($historyEventTypes as $eventType): ?>
<?php $eventTypeString = (string) $eventType; ?>
<option value="<?= $e($eventTypeString) ?>"<?= $eventTypeString === (string) ($historyFilters['event_type'] ?? '') ? ' selected' : '' ?>>
<?= $e($eventLabels[$eventTypeString] ?? $eventTypeString) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="form-field">
<span class="field-label">Status</span>
<select class="form-control" name="history_status">
<option value="">Wszystkie</option>
<?php foreach ($statusLabels as $statusKey => $statusLabel): ?>
<option value="<?= $e($statusKey) ?>"<?= $statusKey === (string) ($historyFilters['execution_status'] ?? '') ? ' selected' : '' ?>>
<?= $e($statusLabel) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="form-field">
<span class="field-label">Regula</span>
<select class="form-control" name="history_rule_id">
<option value="0">Wszystkie</option>
<?php foreach ($historyRuleOptions as $ruleOption): ?>
<?php $ruleOptionId = (int) ($ruleOption['id'] ?? 0); ?>
<option value="<?= $ruleOptionId ?>"<?= $ruleOptionId === (int) ($historyFilters['rule_id'] ?? 0) ? ' selected' : '' ?>>
<?= $e((string) ($ruleOption['name'] ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="form-field">
<span class="field-label">ID zamowienia</span>
<input class="form-control" type="number" min="1" step="1" name="history_order_id" value="<?= (int) ($historyFilters['order_id'] ?? 0) > 0 ? $e((string) (int) ($historyFilters['order_id'] ?? 0)) : '' ?>">
</label>
<label class="form-field">
<span class="field-label">Data od</span>
<input class="form-control" type="date" name="history_date_from" value="<?= $e((string) ($historyFilters['date_from'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label">Data do</span>
<input class="form-control" type="date" name="history_date_to" value="<?= $e((string) ($historyFilters['date_to'] ?? '')) ?>">
</label>
<div class="automation-history-filters__actions">
<button type="submit" class="btn btn--primary btn--sm">Filtruj</button>
<a href="/settings/automation?tab=history" class="btn btn--secondary btn--sm">Wyczysc</a>
</div>
</form>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>Nazwa</th>
<th>Kiedy</th>
<th>Zdarzenie</th>
<th>Warunkow</th>
<th>Akcji</th>
<th>Regula</th>
<th>Zamowienie</th>
<th>Status</th>
<th>Akcje</th>
<th>Wynik</th>
</tr>
</thead>
<tbody>
<?php foreach ($rules as $rule): ?>
<?php if ($historyEntries === []): ?>
<tr>
<td><?= $e((string) ($rule['name'] ?? '')) ?></td>
<td><?= $e($eventLabels[(string) ($rule['event_type'] ?? '')] ?? (string) ($rule['event_type'] ?? '')) ?></td>
<td><?= (int) ($rule['conditions_count'] ?? 0) ?></td>
<td><?= (int) ($rule['actions_count'] ?? 0) ?></td>
<td>
<?php if (((int) ($rule['is_active'] ?? 0)) === 1): ?>
<span class="badge badge--success">Aktywne</span>
<?php else: ?>
<span class="badge badge--muted">Nieaktywne</span>
<?php endif; ?>
</td>
<td style="white-space:nowrap">
<a href="/settings/automation/edit?id=<?= (int) ($rule['id'] ?? 0) ?>" class="btn btn--sm btn--secondary">Edytuj</a>
<form action="/settings/automation/duplicate" method="post" style="display:inline">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="id" value="<?= (int) ($rule['id'] ?? 0) ?>">
<button type="submit" class="btn btn--sm btn--secondary">Duplikuj</button>
</form>
<form action="/settings/automation/toggle" method="post" style="display:inline">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="id" value="<?= (int) ($rule['id'] ?? 0) ?>">
<button type="submit" class="btn btn--sm btn--secondary">
<?= ((int) ($rule['is_active'] ?? 0)) === 1 ? 'Dezaktywuj' : 'Aktywuj' ?>
</button>
</form>
<form action="/settings/automation/delete" method="post" style="display:inline" class="js-confirm-delete">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="id" value="<?= (int) ($rule['id'] ?? 0) ?>">
<button type="button" class="btn btn--sm btn--danger js-delete-btn">Usun</button>
</form>
</td>
<td class="muted" colspan="6">Brak wpisow historii dla wybranych filtrow.</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<?php foreach ($historyEntries as $entry): ?>
<?php
$entryStatus = (string) ($entry['execution_status'] ?? '');
$entryOrderId = (int) ($entry['order_id'] ?? 0);
?>
<tr>
<td><?= $e((string) ($entry['executed_at'] ?? '')) ?></td>
<td><?= $e($eventLabels[(string) ($entry['event_type'] ?? '')] ?? (string) ($entry['event_type'] ?? '')) ?></td>
<td><?= $e((string) ($entry['rule_name'] ?? '')) ?></td>
<td>
<?php if ($entryOrderId > 0): ?>
<a href="/orders/<?= $entryOrderId ?>">#<?= $entryOrderId ?></a>
<?php else: ?>
<span class="muted">-</span>
<?php endif; ?>
</td>
<td>
<?php if ($entryStatus === 'success'): ?>
<span class="badge badge--success">Sukces</span>
<?php elseif ($entryStatus === 'failed'): ?>
<span class="badge badge--danger">Blad</span>
<?php else: ?>
<span class="badge badge--muted"><?= $e($entryStatus) ?></span>
<?php endif; ?>
</td>
<td><?= $e((string) ($entry['result_message'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if ($historyTotalPages > 1): ?>
<div class="table-list__footer">
<div class="pagination">
<a class="pagination__item<?= $historyPage <= 1 ? ' is-disabled' : '' ?>" href="<?= $e($buildHistoryUrl(['history_page' => 1])) ?>">&laquo;</a>
<a class="pagination__item<?= $historyPage <= 1 ? ' is-disabled' : '' ?>" href="<?= $e($buildHistoryUrl(['history_page' => max(1, $historyPage - 1)])) ?>">&lsaquo;</a>
<?php $startPage = max(1, $historyPage - 2); ?>
<?php $endPage = min($historyTotalPages, $historyPage + 2); ?>
<?php for ($page = $startPage; $page <= $endPage; $page++): ?>
<a class="pagination__item<?= $page === $historyPage ? ' is-active' : '' ?>" href="<?= $e($buildHistoryUrl(['history_page' => $page])) ?>">
<?= $e((string) $page) ?>
</a>
<?php endfor; ?>
<a class="pagination__item<?= $historyPage >= $historyTotalPages ? ' is-disabled' : '' ?>" href="<?= $e($buildHistoryUrl(['history_page' => min($historyTotalPages, $historyPage + 1)])) ?>">&rsaquo;</a>
<a class="pagination__item<?= $historyPage >= $historyTotalPages ? ' is-disabled' : '' ?>" href="<?= $e($buildHistoryUrl(['history_page' => $historyTotalPages])) ?>">&raquo;</a>
</div>
<div class="muted">Strona <?= $e((string) $historyPage) ?> z <?= $e((string) $historyTotalPages) ?>, wpisow: <?= $e((string) $historyTotal) ?></div>
</div>
<?php endif; ?>
</div>
</section>
<script>
document.addEventListener('DOMContentLoaded', function() {
var tabStorageKey = 'settings_automation_active_tab';
var tabs = document.querySelectorAll('[data-tab-target]');
var panels = document.querySelectorAll('[data-tab-panel]');
var tabsByTarget = {};
tabs.forEach(function(tab) {
var target = tab.getAttribute('data-tab-target');
if (target) {
tabsByTarget[target] = tab;
}
});
function activateTab(target, persist) {
if (!tabsByTarget[target]) {
return;
}
tabs.forEach(function(node) { node.classList.remove('is-active'); });
panels.forEach(function(panel) { panel.classList.remove('is-active'); });
tabsByTarget[target].classList.add('is-active');
var panel = document.querySelector('[data-tab-panel="' + target + '"]');
if (panel) {
panel.classList.add('is-active');
}
if (persist) {
try {
window.localStorage.setItem(tabStorageKey, target);
} catch (error) {
// Ignorujemy brak dostepu do localStorage.
}
}
}
var params = new URLSearchParams(window.location.search);
var explicitTab = params.get('tab');
var hasHistoryQuery = [
'history_event_type',
'history_status',
'history_rule_id',
'history_order_id',
'history_date_from',
'history_date_to',
'history_page'
].some(function(key) {
var value = params.get(key);
return value !== null && value !== '' && value !== '0';
});
if (explicitTab !== 'settings' && explicitTab !== 'history' && !hasHistoryQuery) {
try {
var savedTab = window.localStorage.getItem(tabStorageKey);
if (savedTab) {
activateTab(savedTab, false);
}
} catch (error) {
// Ignorujemy brak dostepu do localStorage.
}
} else if (explicitTab === 'history') {
activateTab('automation-tab-history', true);
} else if (explicitTab === 'settings') {
activateTab('automation-tab-settings', true);
}
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
var target = tab.getAttribute('data-tab-target');
activateTab(target, true);
});
});
document.querySelectorAll('.js-delete-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var form = this.closest('form');
if (window.OrderProAlerts && window.OrderProAlerts.confirm) {
if (window.OrderProAlerts && typeof window.OrderProAlerts.confirm === 'function') {
window.OrderProAlerts.confirm(
'Usuwanie zadania',
'Czy na pewno chcesz usunac to zadanie automatyczne?',
function() { form.submit(); }
);
} else {
if (confirm('Czy na pewno chcesz usunac to zadanie automatyczne?')) {
form.submit();
}
return;
}
form.submit();
});
});
});

View File

@@ -261,8 +261,8 @@ $orderproStatuses = is_array($orderproStatuses ?? null) ? $orderproStatuses : []
<option value="allegro_to_orderpro"<?= $statusSyncDirection === 'allegro_to_orderpro' ? ' selected' : '' ?>>
<?= $e($t('settings.allegro.settings.status_sync_direction_allegro_to_orderpro')) ?>
</option>
<option value="orderpro_to_allegro"<?= $statusSyncDirection === 'orderpro_to_allegro' ? ' selected' : '' ?> disabled>
<?= $e($t('settings.allegro.settings.status_sync_direction_orderpro_to_allegro')) ?> (wkrótce)
<option value="orderpro_to_allegro"<?= $statusSyncDirection === 'orderpro_to_allegro' ? ' selected' : '' ?>>
<?= $e($t('settings.allegro.settings.status_sync_direction_orderpro_to_allegro')) ?>
</option>
</select>
<span class="muted"><?= $e($t('settings.allegro.settings.status_sync_direction_hint')) ?></span>

View File

@@ -133,6 +133,7 @@ $attachmentTypes = is_array($attachmentTypes ?? null) ? $attachmentTypes : [];
<div class="mt-12">
<span class="field-label">Tresc wiadomosci *</span>
<p class="muted mt-4">Dostepne sa zmienne przesylki: <code>{{przesylka.numer}}</code> oraz <code>{{przesylka.link_sledzenia}}</code>.</p>
<div class="email-tpl-editor-wrap mt-4">
<div class="email-tpl-toolbar">
<div class="email-tpl-var-dropdown">

View File

@@ -6,6 +6,7 @@ use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Modules\Auth\AuthController;
use App\Modules\Auth\AuthMiddleware;
use App\Modules\Cron\CronHandlerFactory;
use App\Modules\Cron\CronRepository;
use App\Modules\Orders\OrdersController;
use App\Modules\Orders\OrderImportRepository;
@@ -50,6 +51,7 @@ use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Automation\AutomationController;
use App\Modules\Automation\AutomationRepository;
use App\Modules\Automation\AutomationService;
use App\Modules\Automation\AutomationExecutionLogRepository;
use App\Modules\Settings\CronSettingsController;
use App\Modules\Settings\DeliveryStatusMappingController;
use App\Modules\Settings\SettingsController;
@@ -229,14 +231,16 @@ return static function (Application $app): void {
$emailMailboxRepository
);
$automationRepository = new AutomationRepository($app->db());
$automationExecutionLogRepository = new AutomationExecutionLogRepository($app->db());
$automationController = new AutomationController(
$template,
$translator,
$auth,
$automationRepository,
$automationExecutionLogRepository,
$receiptConfigRepository
);
$variableResolver = new VariableResolver();
$variableResolver = new VariableResolver($shipmentPackageRepositoryForOrders);
$attachmentGenerator = new AttachmentGenerator($receiptRepository, $receiptConfigRepository, $template);
$emailSendingService = new EmailSendingService(
$app->db(),
@@ -248,6 +252,7 @@ return static function (Application $app): void {
);
$automationService = new AutomationService(
$automationRepository,
$automationExecutionLogRepository,
$emailSendingService,
new OrdersRepository($app->db()),
$companySettingsRepository,
@@ -316,11 +321,67 @@ return static function (Application $app): void {
);
$authMiddleware = new AuthMiddleware($auth);
$publicCronHandler = static function (Request $request) use ($app, $cronRepository): Response {
$token = trim((string) $request->input('token', ''));
if ($token === '') {
$token = trim((string) $request->input('tokenValue', ''));
if (str_starts_with($token, 'token=')) {
$token = substr($token, 6);
}
}
$expectedToken = trim((string) $app->config('app.cron.public_token', ''));
if ($expectedToken === '' || $token === '' || !hash_equals($expectedToken, $token)) {
return Response::json([
'ok' => false,
'message' => 'Unauthorized',
], 403);
}
try {
$limit = $cronRepository->getIntSetting(
'cron_web_limit',
(int) $app->config('app.cron.web_limit_default', 5),
1,
100
);
$factory = new CronHandlerFactory(
$app->db(),
(string) $app->config('app.integrations.secret', ''),
$app->basePath()
);
$runner = $factory->build($cronRepository, $app->logger());
$runner->run($limit);
return Response::json([
'ok' => true,
'message' => 'Cron executed',
'limit' => $limit,
'timestamp' => date(DATE_ATOM),
]);
} catch (\Throwable $exception) {
$app->logger()->error('Public cron endpoint failed', [
'message' => $exception->getMessage(),
'path' => $request->path(),
]);
$debug = (bool) $app->config('app.debug', false);
return Response::json([
'ok' => false,
'message' => 'Cron execution failed',
'error' => $debug ? $exception->getMessage() : null,
], 500);
}
};
$router->get('/health', static fn (Request $request): Response => Response::json([
'status' => 'ok',
'app' => (string) $app->config('app.name', 'orderPRO'),
'timestamp' => date(DATE_ATOM),
]));
$router->get('/cron', $publicCronHandler);
$router->get('/cron/{tokenValue}', $publicCronHandler);
$router->get('/', static function (Request $request) use ($auth): Response {
return $auth->check()

View File

@@ -219,7 +219,12 @@ final class Application
private function maybeRunCronOnWeb(Request $request): void
{
$path = $request->path();
if ($path === '/health' || str_starts_with($path, '/assets/')) {
if (
$path === '/health'
|| $path === '/cron'
|| str_starts_with($path, '/cron/')
|| str_starts_with($path, '/assets/')
) {
return;
}

View File

@@ -15,9 +15,10 @@ 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_ACTION_TYPES = ['send_email', 'issue_receipt', 'update_shipment_status'];
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'];
private const ALLOWED_RECEIPT_DUPLICATE_POLICIES = ['skip_if_exists', 'allow_duplicates'];
@@ -36,6 +37,7 @@ final class AutomationController
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly AutomationRepository $repository,
private readonly AutomationExecutionLogRepository $executionLogs,
private readonly ReceiptConfigRepository $receiptConfigs
) {
}
@@ -43,6 +45,15 @@ final class AutomationController
public function index(Request $request): Response
{
$rules = $this->repository->findAll();
$historyFilters = $this->extractHistoryFilters($request);
$historyPage = max(1, (int) $request->input('history_page', 1));
$historyTotal = $this->executionLogs->count($historyFilters);
$historyTotalPages = max(1, (int) ceil($historyTotal / self::HISTORY_PER_PAGE));
if ($historyPage > $historyTotalPages) {
$historyPage = $historyTotalPages;
}
$historyEntries = $this->executionLogs->paginate($historyFilters, $historyPage, self::HISTORY_PER_PAGE);
$activeTab = $this->resolveActiveTab($request, $historyFilters);
$html = $this->template->render('automation/index', [
'title' => 'Zadania automatyczne',
@@ -51,6 +62,17 @@ final class AutomationController
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'rules' => $rules,
'activeTab' => $activeTab,
'historyEntries' => $historyEntries,
'historyFilters' => $historyFilters,
'historyEventTypes' => array_values(array_unique(array_merge(self::ALLOWED_EVENTS, $this->executionLogs->listEventTypes()))),
'historyRuleOptions' => $this->repository->listRuleOptions(),
'historyPagination' => [
'page' => $historyPage,
'per_page' => self::HISTORY_PER_PAGE,
'total' => $historyTotal,
'total_pages' => $historyTotalPages,
],
'successMessage' => Flash::get('settings.automation.success', ''),
'errorMessage' => Flash::get('settings.automation.error', ''),
], 'layouts/app');
@@ -225,6 +247,7 @@ final class AutomationController
'receiptIssueDateModes' => self::ALLOWED_RECEIPT_ISSUE_DATE_MODES,
'receiptDuplicatePolicies' => self::ALLOWED_RECEIPT_DUPLICATE_POLICIES,
'shipmentStatusOptions' => self::SHIPMENT_STATUS_OPTIONS,
'orderStatusOptions' => $this->repository->listActiveOrderStatuses(),
'errorMessage' => Flash::get('settings.automation.error', ''),
], 'layouts/app');
@@ -425,6 +448,24 @@ final class AutomationController
return ['status_key' => $statusKey];
}
if ($type === 'update_order_status') {
$statusCode = trim((string) ($action['order_status_code'] ?? ''));
if ($statusCode === '') {
return null;
}
$availableCodes = array_map(
static fn (array $row): string => trim((string) ($row['code'] ?? '')),
$this->repository->listActiveOrderStatuses()
);
if (!in_array($statusCode, $availableCodes, true)) {
return null;
}
return ['status_code' => $statusCode];
}
return null;
}
@@ -452,4 +493,53 @@ final class AutomationController
return $result;
}
/**
* @return array{event_type:string,execution_status:string,rule_id:int,order_id:int,date_from:string,date_to:string}
*/
private function extractHistoryFilters(Request $request): array
{
return [
'event_type' => trim((string) $request->input('history_event_type', '')),
'execution_status' => trim((string) $request->input('history_status', '')),
'rule_id' => max(0, (int) $request->input('history_rule_id', 0)),
'order_id' => max(0, (int) $request->input('history_order_id', 0)),
'date_from' => trim((string) $request->input('history_date_from', '')),
'date_to' => trim((string) $request->input('history_date_to', '')),
];
}
/**
* @param array{event_type:string,execution_status:string,rule_id:int,order_id:int,date_from:string,date_to:string} $historyFilters
*/
private function resolveActiveTab(Request $request, array $historyFilters): string
{
$activeTab = trim((string) $request->input('tab', 'settings'));
if ($activeTab === 'history') {
return 'history';
}
if ((int) $request->input('history_page', 0) > 1) {
return 'history';
}
if ($this->hasHistoryFilters($historyFilters)) {
return 'history';
}
return 'settings';
}
/**
* @param array{event_type:string,execution_status:string,rule_id:int,order_id:int,date_from:string,date_to:string} $historyFilters
*/
private function hasHistoryFilters(array $historyFilters): bool
{
return $historyFilters['event_type'] !== ''
|| $historyFilters['execution_status'] !== ''
|| $historyFilters['rule_id'] > 0
|| $historyFilters['order_id'] > 0
|| $historyFilters['date_from'] !== ''
|| $historyFilters['date_to'] !== '';
}
}

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Modules\Automation;
use PDO;
final class AutomationExecutionLogRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @param array<string, mixed> $data
*/
public function create(array $data): void
{
$statement = $this->pdo->prepare(
'INSERT INTO automation_execution_logs (
event_type,
rule_id,
rule_name,
order_id,
execution_status,
result_message,
context_json,
executed_at,
created_at
) VALUES (
:event_type,
:rule_id,
:rule_name,
:order_id,
:execution_status,
:result_message,
:context_json,
:executed_at,
NOW()
)'
);
$statement->execute([
'event_type' => (string) ($data['event_type'] ?? ''),
'rule_id' => isset($data['rule_id']) ? (int) $data['rule_id'] : null,
'rule_name' => (string) ($data['rule_name'] ?? ''),
'order_id' => (int) ($data['order_id'] ?? 0),
'execution_status' => (string) ($data['execution_status'] ?? ''),
'result_message' => $this->trimNullable((string) ($data['result_message'] ?? '')),
'context_json' => $this->encodeJson($data['context'] ?? null),
'executed_at' => (string) ($data['executed_at'] ?? date('Y-m-d H:i:s')),
]);
}
/**
* @param array<string, mixed> $filters
* @return list<array<string, mixed>>
*/
public function paginate(array $filters, int $page, int $perPage): array
{
$safePage = max(1, $page);
$safePerPage = max(1, min(100, $perPage));
$offset = ($safePage - 1) * $safePerPage;
['where' => $whereSql, 'params' => $params] = $this->buildFilters($filters);
$sql = 'SELECT id, event_type, rule_id, rule_name, order_id, execution_status, result_message, context_json, executed_at
FROM automation_execution_logs
' . $whereSql . '
ORDER BY executed_at DESC, id DESC
LIMIT :limit OFFSET :offset';
$statement = $this->pdo->prepare($sql);
foreach ($params as $key => $value) {
$statement->bindValue(':' . $key, $value);
}
$statement->bindValue(':limit', $safePerPage, PDO::PARAM_INT);
$statement->bindValue(':offset', $offset, PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
foreach ($rows as &$row) {
$decoded = json_decode((string) ($row['context_json'] ?? ''), true);
$row['context_json'] = is_array($decoded) ? $decoded : null;
}
unset($row);
return $rows;
}
/**
* @param array<string, mixed> $filters
*/
public function count(array $filters): int
{
['where' => $whereSql, 'params' => $params] = $this->buildFilters($filters);
$statement = $this->pdo->prepare(
'SELECT COUNT(*) FROM automation_execution_logs ' . $whereSql
);
$statement->execute($params);
$value = $statement->fetchColumn();
return max(0, (int) $value);
}
public function purgeOlderThanDays(int $days): int
{
$safeDays = max(1, min(3650, $days));
$statement = $this->pdo->prepare(
'DELETE FROM automation_execution_logs WHERE executed_at < DATE_SUB(NOW(), INTERVAL :days DAY)'
);
$statement->bindValue(':days', $safeDays, PDO::PARAM_INT);
$statement->execute();
return $statement->rowCount();
}
/**
* @return list<string>
*/
public function listEventTypes(): array
{
$statement = $this->pdo->query(
'SELECT DISTINCT event_type FROM automation_execution_logs ORDER BY event_type ASC'
);
$rows = $statement->fetchAll(PDO::FETCH_COLUMN);
if (!is_array($rows)) {
return [];
}
return array_values(array_filter(array_map('strval', $rows), static fn (string $value): bool => $value !== ''));
}
/**
* @param array<string, mixed> $filters
* @return array{where:string,params:array<string,mixed>}
*/
private function buildFilters(array $filters): array
{
$where = [];
$params = [];
$eventType = trim((string) ($filters['event_type'] ?? ''));
if ($eventType !== '') {
$where[] = 'event_type = :event_type';
$params['event_type'] = $eventType;
}
$executionStatus = trim((string) ($filters['execution_status'] ?? ''));
if ($executionStatus !== '') {
$where[] = 'execution_status = :execution_status';
$params['execution_status'] = $executionStatus;
}
$ruleId = (int) ($filters['rule_id'] ?? 0);
if ($ruleId > 0) {
$where[] = 'rule_id = :rule_id';
$params['rule_id'] = $ruleId;
}
$orderId = (int) ($filters['order_id'] ?? 0);
if ($orderId > 0) {
$where[] = 'order_id = :order_id';
$params['order_id'] = $orderId;
}
$dateFrom = trim((string) ($filters['date_from'] ?? ''));
if ($dateFrom !== '') {
$where[] = 'DATE(executed_at) >= :date_from';
$params['date_from'] = $dateFrom;
}
$dateTo = trim((string) ($filters['date_to'] ?? ''));
if ($dateTo !== '') {
$where[] = 'DATE(executed_at) <= :date_to';
$params['date_to'] = $dateTo;
}
if ($where === []) {
return ['where' => '', 'params' => []];
}
return ['where' => 'WHERE ' . implode(' AND ', $where), 'params' => $params];
}
private function trimNullable(string $value): ?string
{
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
return mb_substr($trimmed, 0, 500);
}
private function encodeJson(mixed $data): ?string
{
if (!is_array($data) || $data === []) {
return null;
}
return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: null;
}
}

View File

@@ -23,7 +23,7 @@ final class AutomationRepository
(SELECT COUNT(*) FROM automation_conditions WHERE rule_id = r.id) AS conditions_count,
(SELECT COUNT(*) FROM automation_actions WHERE rule_id = r.id) AS actions_count
FROM automation_rules r
ORDER BY r.created_at DESC
ORDER BY r.name ASC, r.id DESC
';
$statement = $this->pdo->prepare($sql);
$statement->execute();
@@ -209,6 +209,34 @@ final class AutomationRepository
return is_array($rows) ? $rows : [];
}
/**
* @return list<array{id:int,name:string}>
*/
public function listRuleOptions(): array
{
$statement = $this->pdo->prepare(
'SELECT id, name FROM automation_rules ORDER BY name ASC'
);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return list<array{code:string,name:string}>
*/
public function listActiveOrderStatuses(): array
{
$statement = $this->pdo->prepare(
'SELECT code, name FROM order_statuses WHERE is_active = 1 ORDER BY name ASC, id ASC'
);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return list<array<string, mixed>>
*/

View File

@@ -30,6 +30,7 @@ final class AutomationService
public function __construct(
private readonly AutomationRepository $repository,
private readonly AutomationExecutionLogRepository $executionLogs,
private readonly EmailSendingService $emailService,
private readonly OrdersRepository $orders,
private readonly CompanySettingsRepository $companySettings,
@@ -74,11 +75,22 @@ final class AutomationService
$actions = is_array($rule['actions'] ?? null) ? $rule['actions'] : [];
$ruleName = (string) ($rule['name'] ?? '');
$ruleContext = $this->withExecution($context, $executionKey);
$ruleMatched = $this->evaluateConditions($conditions, $order, $ruleContext);
if ($this->evaluateConditions($conditions, $order, $ruleContext)) {
if ($ruleMatched) {
$this->executeActions($actions, $orderId, $ruleName, $ruleContext);
$this->logExecution($eventType, $ruleId, $ruleName, $orderId, 'success', 'Wykonano akcje automatyzacji', $ruleContext);
}
} catch (Throwable) {
} catch (Throwable $exception) {
$this->logExecution(
$eventType,
(int) ($rule['id'] ?? 0),
(string) ($rule['name'] ?? ''),
$orderId,
'failed',
$exception->getMessage(),
$context
);
// Blad jednej reguly nie blokuje kolejnych
}
}
@@ -195,6 +207,11 @@ final class AutomationService
if ($type === 'update_shipment_status') {
$this->handleUpdateShipmentStatus($config, $orderId, $ruleName, $context);
continue;
}
if ($type === 'update_order_status') {
$this->handleUpdateOrderStatus($config, $orderId, $ruleName);
}
}
}
@@ -427,6 +444,32 @@ final class AutomationService
);
}
/**
* @param array<string, mixed> $config
*/
private function handleUpdateOrderStatus(array $config, int $orderId, string $ruleName): void
{
$statusCode = trim((string) ($config['status_code'] ?? ''));
if ($statusCode === '') {
return;
}
$actorName = 'Automatyzacja: ' . $ruleName;
$updated = $this->orders->updateOrderStatus($orderId, $statusCode, 'system', $actorName);
if ($updated) {
return;
}
$this->orders->recordActivity(
$orderId,
'automation_order_status_failed',
$actorName . ' - nie udalo sie zmienic statusu zamowienia',
['target_status_code' => $statusCode],
'system',
$actorName
);
}
private function resolveStatusFromActionKey(string $statusKey): ?string
{
if ($statusKey === '' || !isset(self::SHIPMENT_STATUS_OPTION_MAP[$statusKey])) {
@@ -741,4 +784,84 @@ final class AutomationService
return uniqid('chain_', true);
}
}
/**
* @param array<string, mixed> $context
*/
private function logExecution(
string $eventType,
int $ruleId,
string $ruleName,
int $orderId,
string $status,
string $message,
array $context
): void {
if ($ruleId <= 0 || $orderId <= 0 || $ruleName === '') {
return;
}
try {
$this->executionLogs->create([
'event_type' => $eventType,
'rule_id' => $ruleId,
'rule_name' => $ruleName,
'order_id' => $orderId,
'execution_status' => $status,
'result_message' => mb_substr(trim($message), 0, 500),
'context' => $this->sanitizeContext($context),
'executed_at' => date('Y-m-d H:i:s'),
]);
} catch (Throwable) {
// Historia automatyzacji nie moze blokowac glownego flow.
}
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function sanitizeContext(array $context): array
{
$sanitized = [];
foreach ($context as $key => $value) {
if (is_scalar($value) || $value === null) {
$sanitized[(string) $key] = $value;
continue;
}
if (!is_array($value)) {
continue;
}
$sanitized[(string) $key] = $this->sanitizeArray($value, 2);
}
return $sanitized;
}
/**
* @param array<mixed> $value
* @return array<mixed>
*/
private function sanitizeArray(array $value, int $depth): array
{
if ($depth <= 0) {
return [];
}
$sanitized = [];
foreach ($value as $key => $item) {
if (is_scalar($item) || $item === null) {
$sanitized[$key] = $item;
continue;
}
if (is_array($item)) {
$sanitized[$key] = $this->sanitizeArray($item, $depth - 1);
}
}
return $sanitized;
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\Automation\AutomationExecutionLogRepository;
final class AutomationHistoryCleanupHandler
{
public function __construct(private readonly AutomationExecutionLogRepository $repository)
{
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function handle(array $payload): array
{
$days = max(1, (int) ($payload['days'] ?? 30));
$deletedCount = $this->repository->purgeOlderThanDays($days);
return [
'ok' => true,
'days' => $days,
'deleted_count' => $deletedCount,
];
}
}

View File

@@ -9,6 +9,7 @@ use App\Core\View\Template;
use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Automation\AutomationRepository;
use App\Modules\Automation\AutomationService;
use App\Modules\Automation\AutomationExecutionLogRepository;
use App\Modules\Email\AttachmentGenerator;
use App\Modules\Email\EmailSendingService;
use App\Modules\Email\VariableResolver;
@@ -163,6 +164,9 @@ final class CronHandlerFactory
new ShipmentPackageRepository($this->db),
$automationService
),
'automation_history_cleanup' => new AutomationHistoryCleanupHandler(
new AutomationExecutionLogRepository($this->db)
),
]
);
}
@@ -170,6 +174,7 @@ final class CronHandlerFactory
private function buildAutomationService(OrdersRepository $ordersRepository): AutomationService
{
$automationRepository = new AutomationRepository($this->db);
$executionLogRepository = new AutomationExecutionLogRepository($this->db);
$companySettingsRepository = new CompanySettingsRepository($this->db);
$emailTemplateRepository = new EmailTemplateRepository($this->db);
$emailMailboxRepository = new EmailMailboxRepository(
@@ -186,7 +191,7 @@ final class CronHandlerFactory
$ordersRepository,
$emailTemplateRepository,
$emailMailboxRepository,
new VariableResolver(),
new VariableResolver(new ShipmentPackageRepository($this->db)),
new AttachmentGenerator(
new ReceiptRepository($this->db),
new ReceiptConfigRepository($this->db),
@@ -196,6 +201,7 @@ final class CronHandlerFactory
return new AutomationService(
$automationRepository,
$executionLogRepository,
$emailService,
$ordersRepository,
$companySettingsRepository,

View File

@@ -4,8 +4,16 @@ declare(strict_types=1);
namespace App\Modules\Email;
use App\Modules\Shipments\DeliveryStatus;
use App\Modules\Shipments\ShipmentPackageRepository;
final class VariableResolver
{
public function __construct(
private readonly ShipmentPackageRepository $shipmentPackageRepository
) {
}
/**
* @param array<string, mixed> $order
* @param array<int, array<string, mixed>> $addresses
@@ -27,7 +35,7 @@ final class VariableResolver
$orderedAt = date('Y-m-d', $ts);
}
return [
$baseVariables = [
'zamowienie.numer' => (string) ($order['internal_order_number'] ?? $order['id'] ?? ''),
'zamowienie.numer_zewnetrzny' => (string) ($order['external_order_id'] ?? $order['source_order_id'] ?? ''),
'zamowienie.zrodlo' => ucfirst((string) ($order['source'] ?? '')),
@@ -45,6 +53,8 @@ final class VariableResolver
'firma.nazwa' => (string) ($companySettings['company_name'] ?? ''),
'firma.nip' => (string) ($companySettings['tax_number'] ?? ''),
];
return $baseVariables + $this->resolveShipmentVariables($order);
}
public function resolve(string $template, array $variableMap): string
@@ -70,4 +80,37 @@ final class VariableResolver
return null;
}
/**
* @param array<string, mixed> $order
* @return array<string, string>
*/
private function resolveShipmentVariables(array $order): array
{
$orderId = (int) ($order['id'] ?? 0);
if ($orderId <= 0) {
return [
'przesylka.numer' => '',
'przesylka.link_sledzenia' => '',
];
}
$latestPackage = $this->shipmentPackageRepository->findLatestByOrderId($orderId);
if (!is_array($latestPackage)) {
return [
'przesylka.numer' => '',
'przesylka.link_sledzenia' => '',
];
}
$trackingNumber = trim((string) ($latestPackage['tracking_number'] ?? ''));
$provider = trim((string) ($latestPackage['provider'] ?? ''));
$carrierId = trim((string) ($latestPackage['carrier_id'] ?? ''));
$trackingUrl = DeliveryStatus::trackingUrl($provider, $trackingNumber, $carrierId) ?? '';
return [
'przesylka.numer' => $trackingNumber,
'przesylka.link_sledzenia' => $trackingUrl,
];
}
}

View File

@@ -146,6 +146,31 @@ final class AllegroApiClient
return $this->postJson($url, $accessToken, $body);
}
/**
* @return array<string, mixed>
*/
public function updateCheckoutFormFulfillment(
string $environment,
string $accessToken,
string $checkoutFormId,
string $status
): array {
$safeId = rawurlencode(trim($checkoutFormId));
if ($safeId === '') {
throw new AllegroApiException('Brak ID zamowienia Allegro do aktualizacji statusu.');
}
$normalizedStatus = strtoupper(trim($status));
if ($normalizedStatus === '') {
throw new AllegroApiException('Brak statusu Allegro do aktualizacji.');
}
$url = rtrim($this->apiBaseUrl($environment), '/') . '/order/checkout-forms/' . $safeId . '/fulfillment';
return $this->putJson($url, $accessToken, [
'status' => $normalizedStatus,
]);
}
private function getCaBundlePath(): ?string
{
$envPath = (string) ($_ENV['CURL_CA_BUNDLE_PATH'] ?? '');
@@ -256,6 +281,71 @@ final class AllegroApiClient
return $json;
}
/**
* @param array<string, mixed> $body
* @return array<string, mixed>
*/
private function putJson(string $url, string $accessToken, array $body): array
{
$jsonBody = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$ch = curl_init($url);
if ($ch === false) {
throw new AllegroApiException('Nie udalo sie zainicjowac polaczenia z API Allegro.');
}
curl_setopt_array($ch, $this->withSslOptions([
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $jsonBody,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Accept: application/vnd.allegro.public.v1+json',
'Content-Type: application/vnd.allegro.public.v1+json',
'Authorization: Bearer ' . $accessToken,
],
]));
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
$ch = null;
if ($responseBody === false) {
throw new AllegroApiException('Blad polaczenia z API Allegro: ' . $curlError);
}
$json = json_decode((string) $responseBody, true);
if (!is_array($json)) {
throw new AllegroApiException('Nieprawidlowy JSON odpowiedzi API Allegro.');
}
if ($httpCode === 401) {
throw new AllegroApiException('ALLEGRO_HTTP_401');
}
if ($httpCode < 200 || $httpCode >= 300) {
$message = trim((string) ($json['message'] ?? ''));
$errors = is_array($json['errors'] ?? null) ? $json['errors'] : [];
if ($message === '' && $errors !== []) {
$parts = [];
foreach ($errors as $err) {
if (is_array($err)) {
$parts[] = trim((string) ($err['message'] ?? ($err['userMessage'] ?? '')));
}
}
$message = implode('; ', array_filter($parts));
}
if ($message === '') {
$message = 'Blad API Allegro.';
}
throw new AllegroApiException('API Allegro HTTP ' . $httpCode . ': ' . $message);
}
return $json;
}
/**
* @param array<string, mixed> $body
*/

View File

@@ -113,6 +113,45 @@ final class AllegroOrderSyncStateRepository
$this->upsertState($integrationId, $changes, true);
}
public function getLastStatusPushedAt(int $integrationId): ?string
{
if ($integrationId <= 0) {
return null;
}
$columns = $this->resolveColumns();
if (!$columns['has_table'] || !$columns['has_last_status_pushed_at']) {
return null;
}
try {
$statement = $this->pdo->prepare(
'SELECT last_status_pushed_at
FROM integration_order_sync_state
WHERE integration_id = :integration_id
LIMIT 1'
);
$statement->execute(['integration_id' => $integrationId]);
$value = $statement->fetchColumn();
} catch (Throwable) {
return null;
}
if (!is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
public function updateLastStatusPushedAt(int $integrationId, string $datetime): void
{
$this->upsertState($integrationId, [
'last_status_pushed_at' => trim($datetime),
]);
}
/**
* @param array<string, mixed> $changes
*/
@@ -148,6 +187,9 @@ final class AllegroOrderSyncStateRepository
'last_synced_updated_at' => $updatedAtColumn,
'last_synced_source_order_id' => $sourceOrderIdColumn,
];
if ($columns['has_last_status_pushed_at']) {
$columnMap['last_status_pushed_at'] = 'last_status_pushed_at';
}
foreach ($columnMap as $inputKey => $columnName) {
if (!array_key_exists($inputKey, $changes)) {
@@ -185,7 +227,8 @@ final class AllegroOrderSyncStateRepository
* has_table:bool,
* updated_at_column:?string,
* source_order_id_column:?string,
* has_last_success_at:bool
* has_last_success_at:bool,
* has_last_status_pushed_at:bool
* }
*/
private function resolveColumns(): array
@@ -199,6 +242,7 @@ final class AllegroOrderSyncStateRepository
'updated_at_column' => null,
'source_order_id_column' => null,
'has_last_success_at' => false,
'has_last_status_pushed_at' => false,
];
try {
@@ -243,6 +287,7 @@ final class AllegroOrderSyncStateRepository
}
$result['has_last_success_at'] = isset($available['last_success_at']);
$result['has_last_status_pushed_at'] = isset($available['last_status_pushed_at']);
$this->columns = $result;
return $result;

View File

@@ -124,4 +124,37 @@ final class AllegroStatusMappingRepository
return $mapped !== '' ? $mapped : null;
}
/**
* @return array<string, string> orderpro_status_code => allegro_status_code
*/
public function buildOrderproToAllegroMap(): array
{
$statement = $this->pdo->query(
'SELECT allegro_status_code, orderpro_status_code
FROM allegro_order_status_mappings
WHERE orderpro_status_code IS NOT NULL
AND orderpro_status_code <> ""
ORDER BY id ASC'
);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
$map = [];
foreach ($rows as $row) {
$orderproCode = strtolower(trim((string) ($row['orderpro_status_code'] ?? '')));
$allegroCode = strtolower(trim((string) ($row['allegro_status_code'] ?? '')));
if ($orderproCode === '' || $allegroCode === '') {
continue;
}
if (!isset($map[$orderproCode])) {
$map[$orderproCode] = $allegroCode;
}
}
return $map;
}
}

View File

@@ -19,6 +19,11 @@ final class AllegroStatusSyncService
public function __construct(
private readonly CronRepository $cronRepository,
private readonly AllegroOrderImportService $orderImportService,
private readonly AllegroApiClient $apiClient,
private readonly AllegroTokenManager $tokenManager,
private readonly AllegroStatusMappingRepository $statusMappings,
private readonly AllegroOrderSyncStateRepository $syncStateRepository,
private readonly AllegroIntegrationRepository $integrationRepository,
private readonly PDO $pdo
) {
}
@@ -37,19 +42,22 @@ final class AllegroStatusSyncService
}
if ($direction === self::DIRECTION_ORDERPRO_TO_ALLEGRO) {
return [
'ok' => false,
'direction' => $direction,
'processed' => 0,
'message' => 'Kierunek orderPRO -> Allegro nie jest jeszcze wdrozony.',
];
return $this->syncPushDirection();
}
return $this->syncPullDirection();
}
/**
* @return array<string, mixed>
*/
private function syncPullDirection(): array
{
$orders = $this->findOrdersNeedingStatusSync();
$result = [
'ok' => true,
'direction' => $direction,
'direction' => self::DIRECTION_ALLEGRO_TO_ORDERPRO,
'processed' => 0,
'failed' => 0,
'errors' => [],
@@ -57,6 +65,9 @@ final class AllegroStatusSyncService
foreach ($orders as $order) {
$sourceOrderId = (string) ($order['source_order_id'] ?? '');
if ($sourceOrderId === '') {
continue;
}
try {
$this->orderImportService->importSingleOrder($sourceOrderId, 'status_sync');
@@ -78,6 +89,149 @@ final class AllegroStatusSyncService
return $result;
}
/**
* @return array<string, mixed>
*/
private function syncPushDirection(): array
{
$integrationId = $this->integrationRepository->getActiveIntegrationId();
if ($integrationId <= 0) {
return [
'ok' => false,
'direction' => self::DIRECTION_ORDERPRO_TO_ALLEGRO,
'pushed' => 0,
'skipped' => 0,
'failed' => 0,
'message' => 'Brak aktywnej integracji Allegro.',
'errors' => [],
];
}
$reverseMap = $this->statusMappings->buildOrderproToAllegroMap();
if ($reverseMap === []) {
return [
'ok' => true,
'direction' => self::DIRECTION_ORDERPRO_TO_ALLEGRO,
'pushed' => 0,
'skipped' => 0,
'failed' => 0,
'message' => 'Brak mapowan statusow orderPRO -> Allegro.',
'errors' => [],
];
}
[$accessToken, $environment] = $this->tokenManager->resolveToken();
$lastStatusPushedAt = $this->syncStateRepository->getLastStatusPushedAt($integrationId);
$orders = $this->findOrdersForPush($integrationId, $lastStatusPushedAt);
if ($orders === []) {
return [
'ok' => true,
'direction' => self::DIRECTION_ORDERPRO_TO_ALLEGRO,
'pushed' => 0,
'skipped' => 0,
'failed' => 0,
'message' => 'Brak zamowien do synchronizacji statusow.',
'errors' => [],
];
}
$result = [
'ok' => true,
'direction' => self::DIRECTION_ORDERPRO_TO_ALLEGRO,
'pushed' => 0,
'skipped' => 0,
'failed' => 0,
'errors' => [],
];
$latestPushedChangeAt = null;
foreach ($orders as $order) {
$sourceOrderId = trim((string) ($order['source_order_id'] ?? ''));
$orderproStatusCode = strtolower(trim((string) ($order['orderpro_status_code'] ?? '')));
if ($sourceOrderId === '' || $orderproStatusCode === '') {
$result['skipped']++;
continue;
}
$allegroStatusCode = $reverseMap[$orderproStatusCode] ?? null;
if ($allegroStatusCode === null || trim($allegroStatusCode) === '') {
$result['skipped']++;
continue;
}
try {
$resolved = $this->pushStatusWith401Retry(
$environment,
$accessToken,
$sourceOrderId,
$allegroStatusCode
);
$environment = $resolved['environment'];
$accessToken = $resolved['token'];
$result['pushed']++;
$changeAt = trim((string) ($order['latest_change'] ?? ''));
if ($changeAt !== '' && ($latestPushedChangeAt === null || $changeAt > $latestPushedChangeAt)) {
$latestPushedChangeAt = $changeAt;
}
} catch (Throwable $exception) {
$result['failed']++;
$errors = is_array($result['errors']) ? $result['errors'] : [];
if (count($errors) < 20) {
$errors[] = [
'source_order_id' => $sourceOrderId,
'orderpro_status_code' => $orderproStatusCode,
'allegro_status_code' => $allegroStatusCode,
'error' => $exception->getMessage(),
];
}
$result['errors'] = $errors;
}
}
if ($latestPushedChangeAt !== null) {
$this->syncStateRepository->updateLastStatusPushedAt($integrationId, $latestPushedChangeAt);
}
return $result;
}
/**
* @return array<string, string>
*/
private function pushStatusWith401Retry(
string $environment,
string $accessToken,
string $checkoutFormId,
string $allegroStatusCode
): array {
try {
$this->apiClient->updateCheckoutFormFulfillment(
$environment,
$accessToken,
$checkoutFormId,
$allegroStatusCode
);
return ['environment' => $environment, 'token' => $accessToken];
} catch (Throwable $exception) {
if (!str_contains($exception->getMessage(), 'ALLEGRO_HTTP_401')) {
throw $exception;
}
}
[$refreshedToken, $refreshedEnvironment] = $this->tokenManager->resolveToken();
$this->apiClient->updateCheckoutFormFulfillment(
$refreshedEnvironment,
$refreshedToken,
$checkoutFormId,
$allegroStatusCode
);
return ['environment' => $refreshedEnvironment, 'token' => $refreshedToken];
}
/**
* @return array<int, array<string, mixed>>
*/
@@ -104,13 +258,53 @@ final class AllegroStatusSyncService
}
}
/**
* @return array<int, array<string, mixed>>
*/
private function findOrdersForPush(int $integrationId, ?string $lastStatusPushedAt): array
{
$sinceDate = $lastStatusPushedAt;
if ($sinceDate === null || trim($sinceDate) === '') {
$sinceDate = date('Y-m-d H:i:s', strtotime('-24 hours'));
}
try {
$statement = $this->pdo->prepare(
'SELECT
o.id,
o.source_order_id,
o.external_status_id AS orderpro_status_code,
MAX(h.changed_at) AS latest_change
FROM order_status_history h
INNER JOIN orders o ON o.id = h.order_id
WHERE o.source = :source
AND o.integration_id = :integration_id
AND h.change_source = :change_source
AND h.changed_at > :since_date
GROUP BY o.id, o.source_order_id, o.external_status_id
ORDER BY latest_change ASC
LIMIT ' . self::MAX_ORDERS_PER_RUN
);
$statement->execute([
'source' => IntegrationSources::ALLEGRO,
'integration_id' => $integrationId,
'change_source' => 'manual',
'since_date' => $sinceDate,
]);
return $statement->fetchAll(PDO::FETCH_ASSOC) ?: [];
} catch (Throwable) {
return [];
}
}
private function markOrderStatusChecked(int $orderId): void
{
try {
$statement = $this->pdo->prepare('UPDATE orders SET last_status_checked_at = NOW() WHERE id = ?');
$statement->execute([$orderId]);
} catch (Throwable) {
// Błąd zapisu logu nie powinien przerywać pętli synchronizacji
// Blad zapisu znacznika nie powinien przerywac petli synchronizacji.
}
}
}

View File

@@ -52,6 +52,13 @@ final class EmailTemplateController
'nip' => 'NIP',
],
],
'przesylka' => [
'label' => 'Przesylka',
'vars' => [
'numer' => 'Numer przesylki (tracking)',
'link_sledzenia' => 'Link sledzenia zalezny od kuriera',
],
],
];
private const ATTACHMENT_TYPES = [
@@ -75,6 +82,8 @@ final class EmailTemplateController
'adres.kraj' => 'PL',
'firma.nazwa' => 'Przykladowa Firma Sp. z o.o.',
'firma.nip' => '5271234567',
'przesylka.numer' => '123456789012345678901234',
'przesylka.link_sledzenia' => 'https://inpost.pl/sledzenie-przesylek?number=123456789012345678901234',
];
public function __construct(

View File

@@ -13,6 +13,8 @@ use Throwable;
final class ApaczkaShipmentService implements ShipmentProviderInterface
{
private const PICKUP_DATE_RETRY_DAYS = 7;
/**
* @var array<string, array{street:string,postal_code:string,city:string}>
*/
@@ -146,7 +148,7 @@ final class ApaczkaShipmentService implements ShipmentProviderInterface
]);
try {
$response = $this->apiClient->sendOrder($appId, $appSecret, $apiPayload);
$response = $this->sendOrderWithPickupFallback($appId, $appSecret, $apiPayload);
} catch (Throwable $exception) {
$errorMessage = $this->buildShipmentErrorMessage(
$exception,
@@ -179,6 +181,67 @@ final class ApaczkaShipmentService implements ShipmentProviderInterface
];
}
/**
* @param array<string, mixed> $apiPayload
* @return array<string, mixed>
*/
private function sendOrderWithPickupFallback(string $appId, string $appSecret, array &$apiPayload): array
{
$attempt = 0;
while (true) {
try {
return $this->apiClient->sendOrder($appId, $appSecret, $apiPayload);
} catch (Throwable $exception) {
if (
!$this->isPickupDateUnavailableError($exception)
|| !$this->shiftPickupDateToNextBusinessDay($apiPayload)
|| $attempt >= self::PICKUP_DATE_RETRY_DAYS
) {
throw $exception;
}
$attempt++;
}
}
}
private function isPickupDateUnavailableError(Throwable $exception): bool
{
$message = strtolower(trim($exception->getMessage()));
if ($message === '') {
return false;
}
return str_contains($message, 'pickup not available for selected day')
|| str_contains($message, "can\\u2019t place an order today")
|| str_contains($message, "can't place an order today")
|| str_contains($message, 'change its date to another working day');
}
/**
* @param array<string, mixed> $apiPayload
*/
private function shiftPickupDateToNextBusinessDay(array &$apiPayload): bool
{
$pickup = is_array($apiPayload['pickup'] ?? null) ? $apiPayload['pickup'] : null;
if ($pickup === null) {
return false;
}
$pickupType = strtoupper(trim((string) ($pickup['type'] ?? '')));
$pickupDate = trim((string) ($pickup['date'] ?? ''));
if ($pickupType !== 'COURIER' || preg_match('/^\d{4}-\d{2}-\d{2}$/', $pickupDate) !== 1) {
return false;
}
$nextDateTimestamp = strtotime('+1 day', strtotime($pickupDate));
if ($nextDateTimestamp === false) {
return false;
}
$apiPayload['pickup']['date'] = $this->normalizeCourierPickupDate(date('Y-m-d', $nextDateTimestamp));
return true;
}
/**
* @return array<string, mixed>
*/

View File

@@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Modules\Cron\CronRepository;
use App\Modules\Settings\AllegroApiClient;
use App\Modules\Settings\AllegroIntegrationRepository;
use App\Modules\Settings\AllegroOrderImportService;
use App\Modules\Settings\AllegroOrderSyncStateRepository;
use App\Modules\Settings\AllegroStatusMappingRepository;
use App\Modules\Settings\AllegroStatusSyncService;
use App\Modules\Settings\AllegroTokenManager;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class AllegroStatusSyncServiceTest extends TestCase
{
private CronRepository&MockObject $cronRepository;
private AllegroOrderImportService&MockObject $orderImportService;
private AllegroApiClient&MockObject $apiClient;
private AllegroTokenManager&MockObject $tokenManager;
private AllegroStatusMappingRepository&MockObject $statusMappings;
private AllegroOrderSyncStateRepository&MockObject $syncStateRepository;
private AllegroIntegrationRepository&MockObject $integrationRepository;
protected function setUp(): void
{
$this->cronRepository = $this->createMock(CronRepository::class);
$this->orderImportService = $this->createMock(AllegroOrderImportService::class);
$this->apiClient = $this->createMock(AllegroApiClient::class);
$this->tokenManager = $this->createMock(AllegroTokenManager::class);
$this->statusMappings = $this->createMock(AllegroStatusMappingRepository::class);
$this->syncStateRepository = $this->createMock(AllegroOrderSyncStateRepository::class);
$this->integrationRepository = $this->createMock(AllegroIntegrationRepository::class);
}
public function testPushDirectionProcessesMappedOrdersAndSkipsUnmapped(): void
{
$this->cronRepository
->method('getStringSetting')
->willReturn('orderpro_to_allegro');
$this->integrationRepository
->method('getActiveIntegrationId')
->willReturn(5);
$this->statusMappings
->method('buildOrderproToAllegroMap')
->willReturn([
'processing' => 'ready_for_processing',
]);
$this->tokenManager
->method('resolveToken')
->willReturn(['token-1', 'sandbox']);
$this->syncStateRepository
->method('getLastStatusPushedAt')
->willReturn(null);
$this->syncStateRepository
->expects($this->once())
->method('updateLastStatusPushedAt')
->with(5, '2026-03-28 10:10:00');
$this->apiClient
->expects($this->exactly(2))
->method('updateCheckoutFormFulfillment')
->with(
'sandbox',
'token-1',
$this->logicalOr('A1', 'A3'),
'ready_for_processing'
)
->willReturn([]);
$service = $this->createServiceWithPushRows([
['source_order_id' => 'A1', 'orderpro_status_code' => 'processing', 'latest_change' => '2026-03-28 10:00:00'],
['source_order_id' => 'A2', 'orderpro_status_code' => 'unknown', 'latest_change' => '2026-03-28 10:05:00'],
['source_order_id' => 'A3', 'orderpro_status_code' => 'processing', 'latest_change' => '2026-03-28 10:10:00'],
]);
$result = $service->sync();
$this->assertTrue($result['ok']);
$this->assertSame('orderpro_to_allegro', $result['direction']);
$this->assertSame(2, $result['pushed']);
$this->assertSame(1, $result['skipped']);
$this->assertSame(0, $result['failed']);
}
public function testPushDirectionReturnsEarlyWhenNoMappingsExist(): void
{
$this->cronRepository
->method('getStringSetting')
->willReturn('orderpro_to_allegro');
$this->integrationRepository
->method('getActiveIntegrationId')
->willReturn(5);
$this->statusMappings
->method('buildOrderproToAllegroMap')
->willReturn([]);
$this->apiClient
->expects($this->never())
->method('updateCheckoutFormFulfillment');
$service = $this->createServiceWithPushRows([]);
$result = $service->sync();
$this->assertTrue($result['ok']);
$this->assertSame('orderpro_to_allegro', $result['direction']);
$this->assertSame(0, $result['pushed']);
$this->assertSame(0, $result['failed']);
$this->assertStringContainsString('Brak mapowan', (string) ($result['message'] ?? ''));
}
public function testPushDirectionCollectsFailureAndContinuesProcessing(): void
{
$this->cronRepository
->method('getStringSetting')
->willReturn('orderpro_to_allegro');
$this->integrationRepository
->method('getActiveIntegrationId')
->willReturn(7);
$this->statusMappings
->method('buildOrderproToAllegroMap')
->willReturn([
'processing' => 'ready_for_processing',
]);
$this->tokenManager
->method('resolveToken')
->willReturn(['token-fail-test', 'sandbox']);
$this->syncStateRepository
->method('getLastStatusPushedAt')
->willReturn(null);
$this->syncStateRepository
->expects($this->once())
->method('updateLastStatusPushedAt')
->with(7, '2026-03-28 10:10:00');
$calls = 0;
$this->apiClient
->method('updateCheckoutFormFulfillment')
->willReturnCallback(function () use (&$calls): array {
$calls++;
if ($calls === 1) {
throw new RuntimeException('API Allegro HTTP 422');
}
return [];
});
$service = $this->createServiceWithPushRows([
['source_order_id' => 'X1', 'orderpro_status_code' => 'processing', 'latest_change' => '2026-03-28 10:00:00'],
['source_order_id' => 'X2', 'orderpro_status_code' => 'processing', 'latest_change' => '2026-03-28 10:10:00'],
]);
$result = $service->sync();
$this->assertTrue($result['ok']);
$this->assertSame(1, $result['pushed']);
$this->assertSame(1, $result['failed']);
$this->assertCount(1, $result['errors']);
$this->assertSame('X1', $result['errors'][0]['source_order_id'] ?? null);
}
public function testPushDirectionRetriesOnceAfter401(): void
{
$this->cronRepository
->method('getStringSetting')
->willReturn('orderpro_to_allegro');
$this->integrationRepository
->method('getActiveIntegrationId')
->willReturn(9);
$this->statusMappings
->method('buildOrderproToAllegroMap')
->willReturn([
'processing' => 'ready_for_processing',
]);
$this->tokenManager
->method('resolveToken')
->willReturnOnConsecutiveCalls(
['token-old', 'sandbox'],
['token-new', 'sandbox']
);
$this->syncStateRepository
->method('getLastStatusPushedAt')
->willReturn(null);
$this->syncStateRepository
->expects($this->once())
->method('updateLastStatusPushedAt')
->with(9, '2026-03-28 11:00:00');
$calls = 0;
$this->apiClient
->method('updateCheckoutFormFulfillment')
->willReturnCallback(function () use (&$calls): array {
$calls++;
if ($calls === 1) {
throw new RuntimeException('ALLEGRO_HTTP_401');
}
return [];
});
$service = $this->createServiceWithPushRows([
['source_order_id' => 'R1', 'orderpro_status_code' => 'processing', 'latest_change' => '2026-03-28 11:00:00'],
]);
$result = $service->sync();
$this->assertTrue($result['ok']);
$this->assertSame(1, $result['pushed']);
$this->assertSame(0, $result['failed']);
$this->assertSame(0, $result['skipped']);
$this->assertSame(2, $calls);
}
/**
* @param array<int, array<string, mixed>> $rows
*/
private function createServiceWithPushRows(array $rows): AllegroStatusSyncService
{
$statement = $this->createMock(PDOStatement::class);
$statement
->method('execute')
->willReturn(true);
$statement
->method('fetchAll')
->willReturn($rows);
$pdo = $this->createMock(PDO::class);
$pdo
->method('prepare')
->willReturn($statement);
return new AllegroStatusSyncService(
$this->cronRepository,
$this->orderImportService,
$this->apiClient,
$this->tokenManager,
$this->statusMappings,
$this->syncStateRepository,
$this->integrationRepository,
$pdo
);
}
}