feat(110): statistics summary

Phase 110 complete:
- add Statistics -> Podsumowanie page
- add monthly order count and value charts per integration plus total
- use Chart.js with table fallback and 04-2026 default history start
- update PAUL and DOCS technical documentation
This commit is contained in:
2026-04-28 22:47:14 +02:00
parent 1156ce046c
commit 0b4ffb7146
21 changed files with 2454 additions and 26 deletions

View File

@@ -12,8 +12,8 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
| Attribute | Value | | Attribute | Value |
|-----------|-------| |-----------|-------|
| Version | 3.3.0 | | Version | 3.4.0 |
| Status | v3.3 shipped - UI Filters complete | | Status | v3.4 shipped - Statistics Summary complete |
| Last Updated | 2026-04-28 | | Last Updated | 2026-04-28 |
## Requirements ## Requirements
@@ -113,6 +113,7 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
- [x] Idempotentna jednorazowa wysylka e-mail per zamowienie: tabela deduplikacji `automation_email_once_deliveries` (UNIQUE KEY rule_id+action_id+order_id), checkbox "Wyslij tylko raz" w konfiguracji akcji, markSent() tylko po sukcesie — Phase 107 - [x] Idempotentna jednorazowa wysylka e-mail per zamowienie: tabela deduplikacji `automation_email_once_deliveries` (UNIQUE KEY rule_id+action_id+order_id), checkbox "Wyslij tylko raz" w konfiguracji akcji, markSent() tylko po sukcesie — Phase 107
- [x] Delivery Status Management: tabela `delivery_statuses` z CRUD panelem `/settings/delivery-statuses`, `DeliveryStatus::setRepository()` z DB fallbackiem, integracja DB-driven w dropdownach automatyzacji (warunek shipment_status + akcja update_shipment_status), osobna podstrona formularza CRUD (BREAKING: drop backward compat dla starych grupowych kluczy automatyzacji) — Phase 108 - [x] Delivery Status Management: tabela `delivery_statuses` z CRUD panelem `/settings/delivery-statuses`, `DeliveryStatus::setRepository()` z DB fallbackiem, integracja DB-driven w dropdownach automatyzacji (warunek shipment_status + akcja update_shipment_status), osobna podstrona formularza CRUD (BREAKING: drop backward compat dla starych grupowych kluczy automatyzacji) — Phase 108
- [x] Checkbox dropdown multi-select filters: `/statistics/orders` korzysta z progresywnie ulepszanych selectow multiple z checkboxami, opcja "Wszystkie" i zachowanym kontraktem GET — Phase 109 - [x] Checkbox dropdown multi-select filters: `/statistics/orders` korzysta z progresywnie ulepszanych selectow multiple z checkboxami, opcja "Wszystkie" i zachowanym kontraktem GET — Phase 109
- [x] Podsumowanie statystyk: `Statystyki -> Podsumowanie` z miesiecznymi wykresami liczby i wartosci zamowien per integracja plus `Razem`, Chart.js i fallback tabelaryczny — Phase 110
### Deferred ### Deferred
@@ -121,7 +122,7 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
### Active (In Progress) ### Active (In Progress)
- [ ] (brak — v3.3 zakonczony, oczekiwanie na kolejny milestone) - [ ] (brak — v3.4 zakonczony, oczekiwanie na kolejny milestone)
### Planned (Next) ### Planned (Next)
@@ -198,6 +199,7 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API
| DeliveryStatus::setRepository() pattern: DB fallback dla static final class | Operator dodaje status w UI bez zmian kodu; `getAllOptions()`/`label()`/`getColor()` ladują z DB gdy repo ustawione, fallback na hardcoded ALL_STATUSES/LABEL_PL | 2026-04-27 | Active | | DeliveryStatus::setRepository() pattern: DB fallback dla static final class | Operator dodaje status w UI bez zmian kodu; `getAllOptions()`/`label()`/`getColor()` ladują z DB gdy repo ustawione, fallback na hardcoded ALL_STATUSES/LABEL_PL | 2026-04-27 | Active |
| Drop backward compat dla starych grupowych kluczy automatyzacji (Phase 108-02) | Kolizja semantyczna: stary `picked_up` mapował na `delivered`, nowy klucz DB `picked_up` to "Odebrana przez kuriera" — odwrotne końce cyklu. Hybrid evaluation by silently dawała wrong matches | 2026-04-27 | Active | | Drop backward compat dla starych grupowych kluczy automatyzacji (Phase 108-02) | Kolizja semantyczna: stary `picked_up` mapował na `delivered`, nowy klucz DB `picked_up` to "Odebrana przez kuriera" — odwrotne końce cyklu. Hybrid evaluation by silently dawała wrong matches | 2026-04-27 | Active |
| Path params w controllerach via `$request->input('id')` (nie jako argumenty metody) | Konwencja routera projektu: handler wywoływany z jednym argumentem `$request`, params siedzą jako attributes — `ReceiptController::show()` jako wzorzec | 2026-04-27 | Active | | Path params w controllerach via `$request->input('id')` (nie jako argumenty metody) | Konwencja routera projektu: handler wywoływany z jednym argumentem `$request`, params siedzą jako attributes — `ReceiptController::show()` jako wzorzec | 2026-04-27 | Active |
| Statistics Summary Chart.js CDN + start `2026-04-01` | Interaktywne wykresy bez zmiany build pipeline; historia podsumowania ma zaczynac sie od `04-2026` mimo starszych danych | 2026-04-28 | Active |
## Success Metrics ## Success Metrics
@@ -229,6 +231,6 @@ Quick Reference:
--- ---
*PROJECT.md — Updated when requirements or context change* *PROJECT.md — Updated when requirements or context change*
*Last updated: 2026-04-28 after v3.3 UI Filters milestone completion (Phase 109)* *Last updated: 2026-04-28 after v3.4 Statistics Summary milestone completion (Phase 110)*

View File

@@ -6,7 +6,7 @@ orderPRO to narzedzie do wielokanalowego zarzadzania sprzedaza. Projekt przechod
## Current Milestone ## Current Milestone
Brak aktywnego milestone - v3.3 zamkniety. Nastepny milestone do zaplanowania. Brak aktywnego milestone - v3.4 zamkniety. Nastepny milestone do zaplanowania.
## Next Milestone ## Next Milestone
@@ -19,6 +19,19 @@ Kandydaci w kolejce:
## Completed Milestones ## Completed Milestones
<details>
<summary>v3.4 Statistics Summary - 2026-04-28 (1 phase, 1 plan)</summary>
Dodano pierwsza pozycje `Statystyki -> Podsumowanie` z miesiecznymi wykresami liczby i wartosci zamowien. Kazda integracja ma osobna serie, a dodatkowa seria `Razem` sumuje miesiac. Domyslny start historii to `04-2026`.
| Phase | Name | Plans | Status |
|-------|------|-------|--------|
| 110 | Statistics Summary | 1/1 | Complete |
Archive: `.paul/phases/110-statistics-summary/`
</details>
<details> <details>
<summary>v3.3 UI Filters - 2026-04-28 (1 phase, 1 plan)</summary> <summary>v3.3 UI Filters - 2026-04-28 (1 phase, 1 plan)</summary>
@@ -441,4 +454,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
--- ---
*Roadmap created: 2026-03-12* *Roadmap created: 2026-03-12*
*Last updated: 2026-04-27 - v3.2 Delivery Status Management milestone closed (Phase 108, 2 plans)* *Last updated: 2026-04-28 - v3.4 Statistics Summary milestone closed (Phase 110)*

View File

@@ -5,42 +5,42 @@
See: .paul/PROJECT.md (updated 2026-04-28) See: .paul/PROJECT.md (updated 2026-04-28)
**Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami. **Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami.
**Current focus:** Brak aktywnego milestone - v3.3 zamkniety **Current focus:** Brak aktywnego milestone - v3.4 zamkniety
## Current Position ## Current Position
Milestone: v3.3 - COMPLETE (UI Filters) Milestone: v3.4 Statistics Summary - COMPLETE
Phase: 109 of 109 - COMPLETE Phase: 110 of 110 - COMPLETE
Plan: 109-01 - COMPLETE Plan: 110-01 - COMPLETE
Version: 3.3.0 Version: 3.4.0
Status: v3.3 shipped - gotowy do nastepnego milestone Status: v3.4 shipped - gotowy do nastepnego milestone
Last activity: 2026-04-28 - UNIFY Phase 109 / v3.3 milestone complete Last activity: 2026-04-28 - UNIFY Phase 110 / v3.4 milestone complete
Progress: Progress:
- Milestone v3.3: [##########] 100% (1/1 phases, 1/1 plans) - Milestone v3.4: [##########] 100% (1/1 phases, 1/1 plans)
## Loop Position ## Loop Position
Current loop state: Current loop state:
``` ```
v3.3 milestone: v3.4 milestone:
Phase 109 (Checkbox Multiselect Filters): Phase 110 (Statistics Summary):
Plan 109-01: PLAN done APPLY done UNIFY done Plan 110-01: PLAN done APPLY done UNIFY done
-> Phase 109 closed -> Phase 110 closed
-> v3.3 milestone closed -> v3.4 milestone closed
``` ```
## Session Continuity ## Session Continuity
Last session: 2026-04-28 Last session: 2026-04-28
Stopped at: v3.3 milestone closed Stopped at: v3.4 milestone closed
Next action: /paul:milestone - wybor i zaplanowanie nastepnego milestone Next action: /paul:milestone - wybor i zaplanowanie nastepnego milestone
Resume file: .paul/phases/109-checkbox-multiselect-filters/109-01-SUMMARY.md Resume file: .paul/phases/110-statistics-summary/110-01-SUMMARY.md
## Git State ## Git State
Last commit: feat(109): checkbox multiselect filters Last commit: feat(110): statistics summary
Branch: main Branch: main
Feature branches merged: none Feature branches merged: none
@@ -61,4 +61,16 @@ Feature branches merged: none
| Expected | Invoked | Notes | | Expected | Invoked | Notes |
|----------|---------|-------| |----------|---------|-------|
| sonar-scanner (required) | o | Wymagany po APPLY 108-01 i 108-02 odlozony | | sonar-scanner (required) | o | Wymagany po APPLY 108-01 i 108-02 - odlozony |
## Skill Audit (Phase 110)
| Expected | Invoked | Notes |
|----------|---------|-------|
| sonar-scanner (required) | yes | Skan uruchomiony po APPLY; raport wyslany do SonarQube. |
## Phase 110 Notes
- Local HTTP verification blocked by MySQL/XAMPP connection refused.
- PHPUnit not run: `composer` unavailable in PATH and `vendor/` absent.
- Sonar issue import to `DOCS/todo.md` not performed because SonarQube MCP/resources are unavailable in this session.

View File

@@ -2,6 +2,10 @@
## Co zrobiono ## Co zrobiono
- [Phase 110, Plan 01] Dodano `Statystyki -> Podsumowanie` z miesiecznymi wykresami liczby i wartosci zamowien.
- Wykresy korzystaja z Chart.js 4.4.8 CDN, maja serie per integracja oraz linie `Razem`.
- Ustawiono domyslny poczatek historii na `2026-04-01` (`04-2026`) mimo starszych danych.
- Na desktopie wykresy sa obok siebie, a pod nimi dwie tabele fallback rowniez obok siebie.
- [Phase 109, Plan 01] Wdrozono checkbox dropdown multi-select filters na `/statistics/orders`. - [Phase 109, Plan 01] Wdrozono checkbox dropdown multi-select filters na `/statistics/orders`.
- Zachowano kontrakt GET `channels[]` i `status_groups[]` przez synchronizacje z natywnym `<select multiple>`. - Zachowano kontrakt GET `channels[]` i `status_groups[]` przez synchronizacje z natywnym `<select multiple>`.
- Zaktualizowano `paul:plan`, aby korzystala z `.paul/codebase/architecture.md` i `.paul/codebase/db_schema.md`. - Zaktualizowano `paul:plan`, aby korzystala z `.paul/codebase/architecture.md` i `.paul/codebase/db_schema.md`.
@@ -13,11 +17,23 @@
- `.paul/ROADMAP.md` - `.paul/ROADMAP.md`
- `.paul/STATE.md` - `.paul/STATE.md`
- `.paul/codebase/architecture.md` - `.paul/codebase/architecture.md`
- `.paul/codebase/db_schema.md`
- `.paul/codebase/tech_changelog.md` - `.paul/codebase/tech_changelog.md`
- `.paul/phases/109-checkbox-multiselect-filters/109-01-PLAN.md` - `.paul/phases/109-checkbox-multiselect-filters/109-01-PLAN.md`
- `.paul/phases/109-checkbox-multiselect-filters/109-01-SUMMARY.md` - `.paul/phases/109-checkbox-multiselect-filters/109-01-SUMMARY.md`
- `.paul/phases/110-statistics-summary/110-01-PLAN.md`
- `.paul/phases/110-statistics-summary/110-01-SUMMARY.md`
- `DOCS/ARCHITECTURE.md`
- `DOCS/DB_SCHEMA.md`
- `DOCS/TECH_CHANGELOG.md`
- `public/assets/css/app.css` - `public/assets/css/app.css`
- `public/assets/js/modules/checkbox-multiselect.js` - `public/assets/js/modules/checkbox-multiselect.js`
- `public/assets/js/modules/statistics-summary-charts.js`
- `resources/lang/pl.php`
- `resources/scss/app.scss` - `resources/scss/app.scss`
- `resources/views/layouts/app.php` - `resources/views/layouts/app.php`
- `resources/views/statistics/orders.php` - `resources/views/statistics/orders.php`
- `resources/views/statistics/summary.php`
- `routes/web.php`
- `src/Modules/Statistics/OrdersStatisticsController.php`
- `src/Modules/Statistics/OrdersStatisticsRepository.php`

View File

@@ -42,7 +42,7 @@ HTTP Request
| **Settings** | 51+ | Integration controllers, OAuth clients, API clients, mappers | Allegro/shopPRO/Apaczka/InPost config, status mappings | | **Settings** | 51+ | Integration controllers, OAuth clients, API clients, mappers | Allegro/shopPRO/Apaczka/InPost config, status mappings |
| **Cron** | 12 | `CronRepository`, `CronHandlerFactory`, handler classes | Scheduled imports, syncs, token refresh | | **Cron** | 12 | `CronRepository`, `CronHandlerFactory`, handler classes | Scheduled imports, syncs, token refresh |
| **Printing** | 4 | `PrintApiController`, `PrintJobRepository`, `ApiKeyMiddleware` | REST API for Windows print client | | **Printing** | 4 | `PrintApiController`, `PrintJobRepository`, `ApiKeyMiddleware` | REST API for Windows print client |
| **Statistics** | 2 | `OrdersStatisticsController`, `OrdersStatisticsRepository` | Dashboard aggregates | | **Statistics** | 3 | `OrdersStatisticsController`, `OrdersStatisticsRepository`, `statistics-summary-charts.js` | Daily order statistics and monthly summary charts |
| **Info** | 1 | `InfoController` | Health check | | **Info** | 1 | `InfoController` | Health check |
## Frontend Enhancement Modules ## Frontend Enhancement Modules
@@ -51,9 +51,16 @@ HTTP Request
- Loaded globally from `resources/views/layouts/app.php`. - Loaded globally from `resources/views/layouts/app.php`.
- Enhances native `<select multiple data-checkbox-multiselect>` controls after `DOMContentLoaded`. - Enhances native `<select multiple data-checkbox-multiselect>` controls after `DOMContentLoaded`.
- Keeps the original select in the form, synchronizes option `selected` state, and preserves native GET/POST names such as `channels[]` and `status_groups[]`. - Keeps the original select in the form, synchronizes option `selected` state, and preserves native GET/POST names such as `channels[]` and `status_groups[]`.
- Used by `/statistics/orders` filters to display a compact trigger, checkbox dropdown, "Wszystkie" bulk toggle, and selected count. - Used by `/statistics/orders` and `/statistics/summary` filters to display a compact trigger, checkbox dropdown, "Wszystkie" bulk toggle, and selected count.
- Progressive enhancement: if JavaScript fails, the native multi-select remains visible. - Progressive enhancement: if JavaScript fails, the native multi-select remains visible.
### Statistics Summary Charts (`public/assets/js/modules/statistics-summary-charts.js`)
- Loaded globally from `resources/views/layouts/app.php` after Chart.js 4.4.8 CDN; activates only when `#js-statistics-summary-data` exists.
- Reads JSON produced by `OrdersStatisticsController::summary()` and renders two interactive Chart.js line charts on `/statistics/summary`.
- Chart 1 displays monthly order counts per selected integration plus a `Razem` line.
- Chart 2 displays monthly gross order values per selected integration plus a `Razem` line.
- The PHP view keeps table fallbacks under both charts, so the data remains visible if JavaScript fails.
## Key Data Flows ## Key Data Flows
### Order Lifecycle ### Order Lifecycle
@@ -61,6 +68,13 @@ HTTP Request
2. **Status update**`OrdersController::updateStatus()``OrdersRepository::updateStatus()` → automation check 2. **Status update**`OrdersController::updateStatus()``OrdersRepository::updateStatus()` → automation check
3. **Status sync** — Cron → `AllegroStatusSyncService` / `ShopproStatusSyncService` → carrier API 3. **Status sync** — Cron → `AllegroStatusSyncService` / `ShopproStatusSyncService` → carrier API
### Statistics Summary
1. **Request**`/statistics/summary``OrdersStatisticsController::summary()`
2. **Filters** — controller reuses statistics filter semantics: date range, `channels[]`, `status_groups[]`, default status groups excluding cancelled; default history starts at `2026-04-01`.
3. **Aggregation**`OrdersStatisticsRepository::aggregateByMonth()` groups existing `orders` rows by `YYYY-MM` and channel key, using the same effective date/channel/status/gross amount SQL helpers as the daily report.
4. **View model** — controller builds per-integration series and total series for order count and gross value charts.
5. **Render**`resources/views/statistics/summary.php` renders filters, chart JSON, two canvas targets, and table fallbacks.
### Shipment Flow ### Shipment Flow
1. **Create**`ShipmentController::create()``ShipmentProviderRegistry` → carrier `ShipmentService::createShipment()``ShipmentPackageRepository::insert()` 1. **Create**`ShipmentController::create()``ShipmentProviderRegistry` → carrier `ShipmentService::createShipment()``ShipmentPackageRepository::insert()`
2. **Track** — Cron `ShipmentTrackingHandler``ShipmentTrackingRegistry` → carrier tracking API → `ShipmentPackageRepository::updateDeliveryStatus()` 2. **Track** — Cron `ShipmentTrackingHandler``ShipmentTrackingRegistry` → carrier tracking API → `ShipmentPackageRepository::updateDeliveryStatus()`

View File

@@ -850,3 +850,14 @@ Default keys: `cron_run_on_web`, `cron_web_limit`, `gs1_api_login`, `gs1_prefix`
| Audit via JSON | `payload_json` snapshots in orders, shipments, receipts | | Audit via JSON | `payload_json` snapshots in orders, shipments, receipts |
| Migrations | `database/migrations/YYYYMMDD_NNNNNN_description.sql` | | Migrations | `database/migrations/YYYYMMDD_NNNNNN_description.sql` |
| Deferred indexes | `idx_order_addresses_order_type`, `idx_shipment_packages_order_delivery` — apply at >50k orders | | Deferred indexes | `idx_order_addresses_order_type`, `idx_shipment_packages_order_delivery` — apply at >50k orders |
## Reporting Usage
**Statistics Summary (`/statistics/summary`)** — no dedicated reporting tables.
- Reads existing `orders` rows and groups by month using the same effective order date used by `/statistics/orders`.
- Default summary history starts at April 2026 (`2026-04-01`), even if older rows exist.
- Splits series by channel key: Allegro as one series and each shopPRO integration by `orders.integration_id`.
- Uses `integrations.name` only for display labels when available.
- Filters by selected status groups through `order_status_groups` and `order_statuses`.
- Uses existing gross amount columns via `OrdersStatisticsRepository::grossAmountSql()`.
- No schema migration was introduced for Phase 110.

View File

@@ -1,5 +1,20 @@
# Technical Changelog # Technical Changelog
## 2026-04-28 - Phase 110 Plan 01: Statistics Summary
**Co zrobiono:**
- `/statistics/summary` - nowy widok podsumowania w menu `Statystyki -> Podsumowanie`.
- `OrdersStatisticsController::summary()` - buduje miesieczny view-model dla wykresow liczby i wartosci zamowien.
- `OrdersStatisticsRepository::aggregateByMonth()` - agreguje istniejace zamowienia po miesiacu i kanale/integracji.
- `public/assets/js/modules/statistics-summary-charts.js` - renderer dwoch interaktywnych wykresow liniowych oparty o Chart.js 4.4.8 CDN.
- `resources/views/statistics/summary.php` - filtry zgodne z raportem dziennym, dwa wykresy obok siebie na desktopie oraz dwie tabele fallback pod nimi.
- Domyslny poczatek historii ustawiony na `2026-04-01` (`04-2026`) mimo starszych danych.
**Dlaczego:**
- Operator potrzebuje szybkiego trendu miesiecznego przed przejsciem do szczegolowych dziennych statystyk.
- Wykresy uzywaja obecnych tabel `orders`, `integrations`, `order_status_groups` i `order_statuses`, wiec migracja DB nie jest potrzebna.
- Seria `Razem` jest liczona z tych samych danych co serie integracji, co ulatwia sprawdzenie sum miesiecznych.
## 2026-04-28 - Phase 109 Plan 01: Checkbox Multiselect Filters ## 2026-04-28 - Phase 109 Plan 01: Checkbox Multiselect Filters
**Co zrobiono:** **Co zrobiono:**

View File

@@ -0,0 +1,266 @@
---
phase: 110-statistics-summary
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/Modules/Statistics/OrdersStatisticsController.php
- src/Modules/Statistics/OrdersStatisticsRepository.php
- routes/web.php
- resources/views/layouts/app.php
- resources/views/statistics/summary.php
- resources/lang/pl.php
- public/assets/js/modules/statistics-summary-charts.js
- resources/scss/app.scss
- public/assets/css/app.css
- DOCS/ARCHITECTURE.md
- DOCS/DB_SCHEMA.md
- DOCS/TECH_CHANGELOG.md
- .paul/codebase/architecture.md
- .paul/codebase/db_schema.md
- .paul/codebase/tech_changelog.md
autonomous: false
delegation: off
---
<objective>
## Goal
Add a new first submenu item `Statystyki -> Podsumowanie` with two monthly line charts:
- monthly order count per integration plus a total line,
- monthly order value per integration plus a total line.
## Purpose
The operator needs a quick trend view before opening detailed daily order statistics. The summary should show how each sales integration contributes month by month and how the total business volume changes over time.
## Output
- New authenticated endpoint `/statistics/summary`.
- Sidebar order: `Podsumowanie` first, existing `Zamowienia` second.
- New summary view with compact filters and two charts.
- Backend monthly aggregation using existing orders data, grouped by integration/channel.
- Technical documentation updates. No database migration is expected.
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
@AGENTS.md
@.paul/codebase/architecture.md
@.paul/codebase/db_schema.md
## Prior Work
@.paul/phases/105-orders-statistics/105-01-SUMMARY.md
@.paul/phases/109-checkbox-multiselect-filters/109-01-SUMMARY.md
## Source Files
@src/Modules/Statistics/OrdersStatisticsController.php
@src/Modules/Statistics/OrdersStatisticsRepository.php
@resources/views/statistics/orders.php
@resources/views/layouts/app.php
@routes/web.php
@resources/lang/pl.php
@resources/scss/app.scss
@package.json
## Notes
- `DOCS/DB_SCHEMA.md` and `DOCS/ARCHITECTURE.md` are required by `AGENTS.md`, but they are currently missing in the repo. APPLY should create them or reconcile them from `.paul/codebase/*` before documenting this feature.
- Existing statistics code already has filter parsing patterns, channel labels, status group defaults, collation handling, and amount fallback logic. Reuse those rather than creating a separate query style.
- `SPECIAL-FLOWS.md` requires `sonar-scanner` after APPLY and before UNIFY; frontend-design is optional for this UI work.
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| sonar-scanner | required | After APPLY, before UNIFY | o |
| /frontend-design | optional | During chart UI implementation/review | o |
**BLOCKING:** `sonar-scanner` must be run before UNIFY unless the environment blocks it.
</skills>
<acceptance_criteria>
## AC-1: Sidebar Navigation
```gherkin
Given the user is authenticated
When the sidebar statistics section is visible
Then the first item under "Statystyki" is "Podsumowanie"
And the second item remains "Zamowienia"
And clicking "Podsumowanie" opens `/statistics/summary`
And the active state highlights "Podsumowanie" on that page
```
## AC-2: Monthly Order Count Chart
```gherkin
Given orders exist across multiple months and integrations
When the user opens `/statistics/summary`
Then the page shows an order count chart grouped by month
And each integration has its own line
And an additional "Razem" line sums all selected integrations per month
```
## AC-3: Monthly Order Value Chart
```gherkin
Given orders exist across multiple months and integrations
When the user opens `/statistics/summary`
Then the page shows an order value chart grouped by month
And each integration has its own line
And an additional "Razem" line sums all selected integrations per month
And values are displayed as money with two decimal places
```
## AC-4: Existing Statistics Semantics Reused
```gherkin
Given the user changes the date range, channel selection, or status group selection
When the summary page reloads
Then both charts use the same selected filters
And channel labels match existing statistics labels
And default status groups still exclude the cancelled group
```
## AC-5: Empty and Degraded States
```gherkin
Given the selected filters return no orders
When the user opens `/statistics/summary`
Then the page shows a clear empty state instead of broken charts
Given JavaScript fails to load
When the user opens `/statistics/summary`
Then the monthly data remains visible in an HTML table fallback
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Add monthly statistics backend and route</name>
<files>src/Modules/Statistics/OrdersStatisticsController.php, src/Modules/Statistics/OrdersStatisticsRepository.php, routes/web.php</files>
<action>
Extend the existing Statistics module with a new summary action:
- Add `summary(Request $request): Response` to `OrdersStatisticsController`.
- Reuse or extract existing filter helpers for date range, channel options, selected channels, status groups, selected status groups, and status-code resolution.
- Use a useful monthly default range: first day of January of the current year through the current date, unless GET filters are supplied.
- Add repository method `aggregateByMonth(string $dateFrom, string $dateTo, array $channels, array $statusCodes): array`.
- The query must reuse the existing effective date, channel, gross amount, and status SQL semantics from `aggregateByDay()`.
- Group rows by `DATE_FORMAT(effective_date, "%Y-%m")` and channel key.
- Build a controller view-model with ordered month labels, per-channel count series, per-channel value series, and total series for both charts.
- Add route `GET /statistics/summary` behind `AuthMiddleware` using the existing controller instance.
Avoid: no schema changes, no raw user input in SQL strings, no duplicate channel/status logic that can drift from `/statistics/orders`.
</action>
<verify>
- `php -l src/Modules/Statistics/OrdersStatisticsController.php`
- `php -l src/Modules/Statistics/OrdersStatisticsRepository.php`
- `php -l routes/web.php`
- Manual check that `/statistics/summary` returns HTTP 200 for an authenticated session.
</verify>
<done>AC-2, AC-3, and AC-4 satisfied at backend/view-model level.</done>
</task>
<task type="auto">
<name>Task 2: Add summary UI, charts, menu item, and translations</name>
<files>resources/views/layouts/app.php, resources/views/statistics/summary.php, resources/lang/pl.php, public/assets/js/modules/statistics-summary-charts.js, resources/scss/app.scss, public/assets/css/app.css</files>
<action>
Create the visible statistics summary experience:
- Add `Podsumowanie` translation keys under navigation and `statistics.summary.*`.
- In the sidebar statistics group, place `Podsumowanie` before `Zamowienia`, link it to `/statistics/summary`, and use `activeStatistics='summary'`.
- Create `resources/views/statistics/summary.php` with compact filters matching `/statistics/orders`: date from/to, channels multiselect, status groups multiselect, submit/reset actions.
- Render two chart panels: order count and order value. Each panel must include a `<canvas>` or SVG/chart container plus a table fallback with monthly rows and per-series columns.
- Add `public/assets/js/modules/statistics-summary-charts.js` to render the two charts from JSON embedded in the page after escaping through `json_encode(..., JSON_HEX_*)`.
- Keep chart rendering small and local. Do not add a package dependency unless implementation discovers existing project-approved chart tooling.
- Include the module in `resources/views/layouts/app.php` with `filemtime()` cache busting.
- Add compact SCSS in `resources/scss/app.scss`, then build `public/assets/css/app.css` with `npm run build:css`.
Avoid: no inline CSS in PHP views, no native `alert()`/`confirm()`, no decorative oversized dashboard layout. The page should stay dense and operational.
</action>
<verify>
- `php -l resources/views/layouts/app.php`
- `php -l resources/views/statistics/summary.php`
- `php -l resources/lang/pl.php`
- `npm run build:css`
- Browser/manual check confirms two non-empty charts render when data exists and fallback tables are present in the DOM.
</verify>
<done>AC-1, AC-2, AC-3, and AC-5 satisfied at UI level.</done>
</task>
<task type="auto">
<name>Task 3: Update technical documentation</name>
<files>DOCS/ARCHITECTURE.md, DOCS/DB_SCHEMA.md, DOCS/TECH_CHANGELOG.md, .paul/codebase/architecture.md, .paul/codebase/db_schema.md, .paul/codebase/tech_changelog.md</files>
<action>
Update documentation required by `AGENTS.md` and PAUL:
- If root `DOCS/` files are still missing, create `DOCS/ARCHITECTURE.md`, `DOCS/DB_SCHEMA.md`, and `DOCS/TECH_CHANGELOG.md` using the current `.paul/codebase/*` documents as the baseline where appropriate.
- Document the new `/statistics/summary` route, controller action, repository monthly aggregation method, chart frontend module, and sidebar change in architecture docs.
- Document that the feature uses existing `orders`, `integrations`, `order_status_groups`, and `order_statuses` tables without migration changes.
- Add a dated technical changelog entry explaining the new summary charts and why no DB schema change was needed.
- Keep `.paul/codebase/*` in sync with the same architecture/schema/changelog facts.
</action>
<verify>
- `git diff -- DOCS .paul/codebase` shows architecture/changelog updates and no false DB migration claim.
- Documentation mentions `/statistics/summary`, `aggregateByMonth()`, and `statistics-summary-charts.js`.
</verify>
<done>Documentation requirements from `AGENTS.md` and PAUL are satisfied.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>`Statystyki -> Podsumowanie` with two monthly charts</what-built>
<how-to-verify>
1. Open `/statistics/summary`.
2. Confirm the sidebar shows `Podsumowanie` as the first item under `Statystyki`.
3. Confirm chart 1 shows monthly order counts with one line per integration plus `Razem`.
4. Confirm chart 2 shows monthly order values with one line per integration plus `Razem`.
5. Change filters and confirm both charts update consistently.
</how-to-verify>
<resume-signal>Type "approved" to continue to UNIFY, or describe visual/data issues to fix.</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Import/synchronization modules for Allegro/shopPRO.
- Existing `/statistics/orders` route contract and GET parameter names.
- Database migrations unless a blocker proves the existing schema cannot support the charts.
- Runtime DB env behavior: do not wire `DB_HOST_REMOTE` into application runtime.
## SCOPE LIMITS
- This plan adds only summary charts for orders. It does not add exports, product charts, inventory charts, or accounting reports.
- Values chart uses the existing gross amount semantics from current order statistics unless the user explicitly asks for net values.
- Chart interactivity is limited to readable lines/legend/tooltips if implemented locally; advanced drill-down is out of scope.
- No new native alerts or confirms.
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php -l src/Modules/Statistics/OrdersStatisticsController.php`
- [ ] `php -l src/Modules/Statistics/OrdersStatisticsRepository.php`
- [ ] `php -l routes/web.php`
- [ ] `php -l resources/views/layouts/app.php`
- [ ] `php -l resources/views/statistics/summary.php`
- [ ] `php -l resources/lang/pl.php`
- [ ] `npm run build:css`
- [ ] Manual/browser check: `/statistics/summary` renders two charts and table fallbacks.
- [ ] Manual/browser check: sidebar order and active states are correct.
- [ ] Manual data check: monthly total equals sum of integration values for at least one month.
- [ ] Documentation updated in `DOCS/` and `.paul/codebase/`.
- [ ] `sonar-scanner` run before UNIFY, or blocker recorded if unavailable.
- [ ] All acceptance criteria met.
</verification>
<success_criteria>
- `Statystyki -> Podsumowanie` is the first statistics submenu item.
- `/statistics/summary` displays two monthly charts: order count and order value.
- Each chart includes separate integration series and a `Razem` series.
- Filters are consistent with existing order statistics behavior.
- No database schema change is introduced.
- Technical documentation is updated.
</success_criteria>
<output>
After completion, create `.paul/phases/110-statistics-summary/110-01-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,197 @@
---
phase: 110-statistics-summary
plan: 01
subsystem: statistics
tags: [statistics, charts, chartjs, monthly-summary, ui, reporting]
requires:
- phase: 105-orders-statistics
provides: daily statistics filters, channel semantics, status group filtering
- phase: 109-checkbox-multiselect-filters
provides: reusable checkbox multiselect filter enhancement
provides:
- /statistics/summary monthly summary page
- monthly order count chart per integration plus total
- monthly order value chart per integration plus total
- DOCS baseline synchronized from .paul/codebase
affects: [statistics, reporting, sidebar-navigation]
tech-stack:
added: [Chart.js 4.4.8 CDN]
patterns: [monthly statistics aggregation, Chart.js view-model, table fallback]
key-files:
created:
- resources/views/statistics/summary.php
- public/assets/js/modules/statistics-summary-charts.js
- DOCS/ARCHITECTURE.md
- DOCS/DB_SCHEMA.md
- DOCS/TECH_CHANGELOG.md
modified:
- src/Modules/Statistics/OrdersStatisticsController.php
- src/Modules/Statistics/OrdersStatisticsRepository.php
- routes/web.php
- resources/views/layouts/app.php
- resources/lang/pl.php
- resources/scss/app.scss
- public/assets/css/app.css
key-decisions:
- "Default summary history starts at 2026-04-01 even if older orders exist."
- "Charts use Chart.js 4.4.8 from CDN for interactive legend and tooltips."
- "Gross order value uses existing statistics gross amount semantics."
patterns-established:
- "Monthly statistics use the same channel/status/effective-date helpers as daily statistics."
- "Charts are backed by HTML table fallbacks."
duration: ~90min
started: 2026-04-28T22:20:00+02:00
completed: 2026-04-28T23:50:00+02:00
---
# Phase 110 Plan 01: Statistics Summary
Monthly statistics summary shipped with two interactive Chart.js line charts, compact filters, per-integration series, total series, and table fallbacks.
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~90min |
| Started | 2026-04-28T22:20:00+02:00 |
| Completed | 2026-04-28T23:50:00+02:00 |
| Tasks | 3 auto tasks + 1 human verification checkpoint |
| Files modified | 18 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Sidebar Navigation | Pass | `Podsumowanie` added first under `Statystyki`; `/statistics/summary` route registered with active state. |
| AC-2: Monthly Order Count Chart | Pass | Count chart model contains one dataset per selected integration plus `Razem`; rendered by Chart.js. |
| AC-3: Monthly Order Value Chart | Pass | Value chart model contains one dataset per selected integration plus `Razem`; values formatted as money in tooltips/table. |
| AC-4: Existing Statistics Semantics Reused | Pass | Controller reuses channel/status filter flow; repository reuses effective date/channel/status/gross SQL helpers. |
| AC-5: Empty and Degraded States | Pass | Empty state shown when no data; tables remain visible as fallback below charts. |
## Accomplishments
- Added authenticated `/statistics/summary` page with monthly order count and order gross value charts.
- Added `OrdersStatisticsRepository::aggregateByMonth()` using prepared parameters and existing statistics SQL helper semantics.
- Added `OrdersStatisticsController::summary()` and a chart/table view-model with total lines.
- Updated sidebar navigation, translations, SCSS, compiled CSS, and Chart.js frontend module.
- Created root `DOCS/` technical documentation required by `AGENTS.md` and synchronized it with `.paul/codebase/`.
## Task Commits
Task-level atomic commits were not used in this inline APPLY. A single phase commit is created during transition.
| Task | Commit | Type | Description |
|------|--------|------|-------------|
| Task 1: Add monthly statistics backend and route | phase commit | feat | Controller summary action, monthly aggregation, `/statistics/summary` route |
| Task 2: Add summary UI, charts, menu item, and translations | phase commit | feat | Summary view, sidebar link, Chart.js charts, SCSS/CSS |
| Task 3: Update technical documentation | phase commit | docs | `DOCS/` and `.paul/codebase/` updates |
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `src/Modules/Statistics/OrdersStatisticsController.php` | Modified | Added summary action, monthly default range from `2026-04-01`, chart/table view-model builders. |
| `src/Modules/Statistics/OrdersStatisticsRepository.php` | Modified | Added `aggregateByMonth()` with existing statistics SQL semantics. |
| `routes/web.php` | Modified | Registered authenticated `GET /statistics/summary`. |
| `resources/views/layouts/app.php` | Modified | Added `Podsumowanie` sidebar link and Chart.js/module scripts. |
| `resources/views/statistics/summary.php` | Created | Summary filters, two charts, and two fallback tables. |
| `resources/lang/pl.php` | Modified | Added navigation and `statistics.summary.*` translations. |
| `public/assets/js/modules/statistics-summary-charts.js` | Created | Chart.js renderer for count/value line charts. |
| `resources/scss/app.scss` | Modified | Added compact two-column desktop layout and chart/table styles. |
| `public/assets/css/app.css` | Modified | Compiled CSS output. |
| `DOCS/ARCHITECTURE.md` | Created | Root architecture docs baseline plus summary feature. |
| `DOCS/DB_SCHEMA.md` | Created | Root schema docs baseline plus reporting usage note. |
| `DOCS/TECH_CHANGELOG.md` | Created | Root technical changelog baseline plus phase 110 entry. |
| `.paul/codebase/architecture.md` | Modified | Documented route, controller flow, Chart.js module. |
| `.paul/codebase/db_schema.md` | Modified | Documented no-migration reporting usage. |
| `.paul/codebase/tech_changelog.md` | Modified | Added phase 110 technical changelog. |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Start history at `2026-04-01` | User requested `04-2026` as hard start despite older data. | Default summary excludes older months unless code changes later. |
| Use Chart.js 4.4.8 CDN | User requested an interactive JS library; project already uses CDN for Quill. | No npm dependency or build pipeline change. |
| Use gross order values | Existing statistics gross semantics are stable and already documented. | Value chart matches current order statistics totals. |
| Keep table fallback | Required degraded state and useful for verification. | Data remains visible without Chart.js/JS. |
## Deviations from Plan
| Type | Count | Impact |
|------|-------|--------|
| Scope adjustments from user feedback | 3 | Improved UI and changed default date semantics. |
| Environment blockers | 3 | Verification limits only; code/build checks passed. |
**Total impact:** Controlled deviations; feature outcome matches latest user request.
### Adjusted During APPLY
**Chart renderer changed to Chart.js**
- Found during user feedback after APPLY.
- Fix: Added Chart.js CDN and rewrote `statistics-summary-charts.js` to use Chart.js legends/tooltips.
- Verification: `node --check`, PHP lint, CSS build, Sonar scan.
**Desktop layout split**
- Found during user feedback after APPLY.
- Fix: Added two-column chart grid and two-column table grid from 1100px.
- Verification: PHP lint and CSS build.
**Default history start**
- Found during user feedback after APPLY.
- Fix: Set summary default `date_from` to `2026-04-01`.
- Verification: PHP lint.
### Deferred Items
- SonarQube issue import into `DOCS/todo.md` was not performed because no SonarQube MCP resources/tools are available in this Codex session.
- Full authenticated HTTP verification was not possible locally because MySQL/XAMPP refused the DB connection.
- PHPUnit was not run because `composer` is not available in PATH and `vendor/` is absent.
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Local bootstrap failed with MySQL connection refused. | Started PHP dev server anyway; documented DB blocker. |
| `composer test` unavailable. | Documented test blocker; ran available lint/build/static checks. |
| Sonar MCP unavailable. | Ran `sonar-scanner` successfully and documented inability to fetch issue details. |
## Verification Results
| Check | Result |
|-------|--------|
| `php -l src/Modules/Statistics/OrdersStatisticsController.php` | Pass |
| `php -l src/Modules/Statistics/OrdersStatisticsRepository.php` | Pass |
| `php -l routes/web.php` | Pass |
| `php -l resources/views/layouts/app.php` | Pass |
| `php -l resources/views/statistics/summary.php` | Pass |
| `php -l resources/lang/pl.php` | Pass |
| `node --check public/assets/js/modules/statistics-summary-charts.js` | Pass |
| `npm run build:css` | Pass |
| `sonar-scanner` | Pass; report uploaded to SonarQube |
| Local `/statistics/summary` HTTP 200 | Blocked by local MySQL connection refused |
| `composer test` / PHPUnit | Blocked: `composer` not in PATH and `vendor/` absent |
## Skill Audit
| Expected | Invoked | Notes |
|----------|---------|-------|
| `sonar-scanner` | yes | Ran successfully after APPLY changes. |
## Next Phase Readiness
**Ready:**
- Statistics summary page exists and is wired into sidebar navigation.
- Monthly aggregation pattern can be reused for future reporting charts.
- Root `DOCS/` docs now exist for future AGENTS.md-compliant changes.
**Concerns:**
- Chart.js is CDN-based; if offline operation becomes required, vendor locally or add npm build support.
- Local verification depends on XAMPP/MySQL being available.
- Sonar issue import still needs a working SonarQube MCP/tool path.
**Blockers:**
- None for closing Phase 110.
---
*Phase: 110-statistics-summary, Plan: 01*
*Completed: 2026-04-28*

175
DOCS/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,175 @@
# Architecture
## Request Flow
```
HTTP Request
→ public/index.php
→ bootstrap/app.php (loads config, registers PDO, services)
→ Application::boot() (loads routes/web.php)
→ Router::dispatch(Request) (matches URL, runs middleware pipeline)
→ [Middleware] (AuthMiddleware, ApiKeyMiddleware)
→ Controller::method() (parse input → call repository/service → render)
→ Template::render() (PHP native, layout composition)
→ Response::send()
```
## Layer Map
| Layer | Location | Responsibility |
|-------|----------|----------------|
| Entry | `public/index.php` | Bootstrap only |
| Routes | `routes/web.php` (581 lines) | All ~80 routes; manual DI wiring |
| Core | `src/Core/` (25 files) | Framework infrastructure |
| Controllers | `src/Modules/*/Controller.php` | Request parsing → response |
| Services | `src/Modules/*/Service.php` | Business logic |
| Repositories | `src/Modules/*/Repository.php` | PDO data access (34+ repos) |
| Views | `resources/views/` | PHP templates with `$e()` / `$t()` |
| Components | `resources/views/components/` | Reusable UI blocks |
| Frontend modules | `public/assets/js/modules/` | Small vanilla JS enhancements loaded by layout |
## Module Inventory (`src/Modules/`)
| Module | Files | Key Classes | Purpose |
|--------|-------|-------------|---------|
| **Auth** | 3 | `AuthController`, `AuthMiddleware`, `AuthService` | Login/logout, session |
| **Users** | 2 | `UserController`, `UserRepository` | User CRUD |
| **Orders** | 3 | `OrdersController` (1187 LOC), `OrdersRepository` (1221 LOC) | Order list, detail, status, payment, correlated subquery for return-risk |
| **Shipments** | 17 | `ShipmentController`, provider services + tracking services | Shipment creation, label download, tracking polling |
| **Accounting** | 5 | `AccountingController`, `ReceiptService`, `ReceiptRepository` | Receipts, invoices, PDF, Excel export |
| **Email** | 3 | `EmailSendingService`, `VariableResolver`, `AttachmentGenerator` | Template-based email with PDF attachments |
| **Automation** | 6 | `AutomationService` (834 LOC), `AutomationRepository`, `AutomationExecutionLogRepository` | Event→condition→action rules, email triggers |
| **Settings** | 51+ | Integration controllers, OAuth clients, API clients, mappers | Allegro/shopPRO/Apaczka/InPost config, status mappings |
| **Cron** | 12 | `CronRepository`, `CronHandlerFactory`, handler classes | Scheduled imports, syncs, token refresh |
| **Printing** | 4 | `PrintApiController`, `PrintJobRepository`, `ApiKeyMiddleware` | REST API for Windows print client |
| **Statistics** | 3 | `OrdersStatisticsController`, `OrdersStatisticsRepository`, `statistics-summary-charts.js` | Daily order statistics and monthly summary charts |
| **Info** | 1 | `InfoController` | Health check |
## Frontend Enhancement Modules
### Checkbox Multiselect (`public/assets/js/modules/checkbox-multiselect.js`)
- Loaded globally from `resources/views/layouts/app.php`.
- Enhances native `<select multiple data-checkbox-multiselect>` controls after `DOMContentLoaded`.
- Keeps the original select in the form, synchronizes option `selected` state, and preserves native GET/POST names such as `channels[]` and `status_groups[]`.
- Used by `/statistics/orders` and `/statistics/summary` filters to display a compact trigger, checkbox dropdown, "Wszystkie" bulk toggle, and selected count.
- Progressive enhancement: if JavaScript fails, the native multi-select remains visible.
### Statistics Summary Charts (`public/assets/js/modules/statistics-summary-charts.js`)
- Loaded globally from `resources/views/layouts/app.php` after Chart.js 4.4.8 CDN; activates only when `#js-statistics-summary-data` exists.
- Reads JSON produced by `OrdersStatisticsController::summary()` and renders two interactive Chart.js line charts on `/statistics/summary`.
- Chart 1 displays monthly order counts per selected integration plus a `Razem` line.
- Chart 2 displays monthly gross order values per selected integration plus a `Razem` line.
- The PHP view keeps table fallbacks under both charts, so the data remains visible if JavaScript fails.
## Key Data Flows
### Order Lifecycle
1. **Import** — Cron handler → API client → `OrderImportService``OrdersRepository::insertOrder()``AutomationService::executeForNewOrder()`
2. **Status update**`OrdersController::updateStatus()``OrdersRepository::updateStatus()` → automation check
3. **Status sync** — Cron → `AllegroStatusSyncService` / `ShopproStatusSyncService` → carrier API
### Statistics Summary
1. **Request**`/statistics/summary``OrdersStatisticsController::summary()`
2. **Filters** — controller reuses statistics filter semantics: date range, `channels[]`, `status_groups[]`, default status groups excluding cancelled; default history starts at `2026-04-01`.
3. **Aggregation**`OrdersStatisticsRepository::aggregateByMonth()` groups existing `orders` rows by `YYYY-MM` and channel key, using the same effective date/channel/status/gross amount SQL helpers as the daily report.
4. **View model** — controller builds per-integration series and total series for order count and gross value charts.
5. **Render**`resources/views/statistics/summary.php` renders filters, chart JSON, two canvas targets, and table fallbacks.
### Shipment Flow
1. **Create**`ShipmentController::create()``ShipmentProviderRegistry` → carrier `ShipmentService::createShipment()``ShipmentPackageRepository::insert()`
2. **Track** — Cron `ShipmentTrackingHandler``ShipmentTrackingRegistry` → carrier tracking API → `ShipmentPackageRepository::updateDeliveryStatus()`
### Receipt / Invoice
1. **Generate**`ReceiptController::store()``ReceiptService::generateReceipt()``ReceiptRepository::insert()` + Dompdf PDF
2. **Email**`EmailSendingService::send()``VariableResolver::resolve()``AttachmentGenerator::generatePdf()` → PHPMailer SMTP
### Automation Rules
1. **Setup**`AutomationController``AutomationRepository::insertRule()`
2. **Trigger**`AutomationService::executeForOrder()` → evaluates trigger (`order_status_changed`, `order_status_aged`) → runs action (send email, update status)
3. **Log**`AutomationExecutionLogRepository` tracks every run
### Cron Jobs
| Handler | Task |
|---------|------|
| `AllegroOrdersImportHandler` | Fetch new Allegro orders |
| `AllegroStatusSyncHandler` | Push status changes to Allegro |
| `AllegroTokenRefreshHandler` | OAuth token refresh (24h expiry) |
| `ShopproOrdersImportHandler` | Fetch new shopPRO orders |
| `ShopproStatusSyncHandler` | Push status to shopPRO |
| `ShopproPaymentStatusSyncHandler` | Sync payment statuses |
| `ShipmentTrackingHandler` | Poll carrier tracking APIs |
| `OrderStatusAgedHandler` | Trigger automation for stuck statuses |
| `AutomationHistoryCleanupHandler` | Purge old automation logs |
## Dependency Injection
Manual constructor injection in `routes/web.php` — no DI container library. Example:
```php
$ordersController = new OrdersController(
$template, $translator, $auth,
$app->orders(), $shipmentPackageRepository,
$receiptRepository, $receiptConfigRepository, ...
);
```
All production classes are `final` — prevents accidental inheritance.
## Directory Structure
```
bootstrap/ app.php (service wiring, config loading)
bin/ migrate.php, cron.php (CLI entry points)
config/ app.php, database.php
database/
migrations/ 84 SQL files (YYYYMMDD_NNNNNN_description.sql)
drafts/ WIP migrations
public/
index.php HTTP entry point
.htaccess Apache rewrite rules
assets/css/ Compiled CSS (app.css, login.css, modules/)
assets/js/ jquery-alerts.js, global-search.js, automation-form.js
resources/
views/ PHP templates by module + components/ layouts/
scss/ SCSS sources (app.scss, login.scss, modules/_*.scss)
modules/ jquery-alerts JS+SCSS source
lang/pl/ Polish translations
routes/
web.php All routes (581 lines)
src/
Core/ Framework (25 files)
Modules/ 13 feature modules (~200+ PHP files)
storage/
logs/ app.log
sessions/ PHP session files
cache/ PHPUnit cache, etc.
tests/
Unit/ PHPUnit tests (7+ service test files)
bootstrap.php PSR-4 autoloader for tests
```
## Phase 108 — Delivery Status Management
### DeliveryStatusRepository (`src/Modules/Shipments/DeliveryStatusRepository.php`)
- CRUD dla tabeli `delivery_statuses`
- Per-request static cache (`private static ?array $cache`)
- Blokuje edycję/usunięcie statusów systemowych (`is_system=1`)
- Blokuje usunięcie statusów używanych w `delivery_status_mappings` lub `shipment_packages`
### DeliveryStatusesController (`src/Modules/Settings/DeliveryStatusesController.php`)
- Panel `/settings/delivery-statuses`
- Dwie zakładki via `?tab=` param: `statuses` (CRUD) i `mapping` (embed mapowania)
- Wstrzykuje `DeliveryStatusRepository` i `DeliveryStatusMappingRepository`
### DeliveryStatus::setRepository() (dynamic loading)
- Wywoływane raz w `routes/web.php` po bootstrap
- `label()`, `getAllOptions()`, `getAllStatuses()`, `getColor()` ładują z DB gdy repo ustawione
- Fallback na hardcoded stałe gdy repo niedostępne
### AutomationController + AutomationService (Phase 108 Plan 02)
- `AutomationController::buildShipmentStatusOptions()` — buduje listę opcji `[key => ['label' => ...]]` z `DeliveryStatus::getAllOptions()` (DB-driven)
- Walidacja `shipment_status` warunku i `update_shipment_status` akcji w `parseConditionValue()`/`parseActionConfig()` używa `DeliveryStatus::getAllStatuses()`
- `AutomationService::evaluateShipmentStatusCondition()` — bezpośrednie porównanie kluczy DB (usunięto mapping grupowy `SHIPMENT_STATUS_OPTION_MAP`)
- `AutomationService::resolveStatusFromActionKey()` — bezpośredni klucz statusu z DB jako target
- BREAKING: stare reguły z grupowymi kluczami (`registered`, `courier_pickup`, `dropped_at_point`, `unclaimed`, `picked_up_return`) nie matchują się — operator musi je odtworzyć przy użyciu nowych kluczy DB

863
DOCS/DB_SCHEMA.md Normal file
View File

@@ -0,0 +1,863 @@
# Database Schema
**Updated:** 2026-04-28 | **Total tables:** 55 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci
---
## Auth / Users
**users** — System user accounts with authentication
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK, AUTO_INCREMENT |
| `name` | VARCHAR(120) | NO | |
| `email` | VARCHAR(190) | NO | UNIQUE |
| `password_hash` | VARCHAR(255) | NO | |
| `remember_token` | VARCHAR(255) | YES | SHA256 of cookie token |
| `created_at` | DATETIME | NO | DEFAULT CURRENT_TIMESTAMP |
---
## Products
**products** — Main product catalog
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `uuid` | CHAR(36) | NO | UNIQUE |
| `type` | ENUM('simple','variant_parent') | NO | DEFAULT 'simple' |
| `sku` | VARCHAR(128) | YES | UNIQUE |
| `ean` | VARCHAR(32) | YES | |
| `status` | TINYINT(1) | NO | DEFAULT 1 |
| `promoted` | TINYINT(1) | NO | DEFAULT 0 |
| `new_to_date` | DATE | YES | |
| `additional_message` | TINYINT(1) | NO | DEFAULT 0 |
| `additional_message_required` | TINYINT(1) | NO | DEFAULT 0 |
| `additional_message_text` | TEXT | YES | |
| `vat` | DECIMAL(5,2) | YES | |
| `weight` | DECIMAL(10,3) | YES | |
| `price_brutto` | DECIMAL(12,2) | NO | DEFAULT 0.00 |
| `price_brutto_promo` | DECIMAL(12,2) | YES | |
| `price_netto` | DECIMAL(12,2) | YES | |
| `price_netto_promo` | DECIMAL(12,2) | YES | |
| `quantity` | DECIMAL(12,3) | NO | DEFAULT 0.000 |
| `producer_id` | INT UNSIGNED | YES | |
| `producer_name` | VARCHAR(255) | YES | |
| `product_unit_id` | INT UNSIGNED | YES | |
| `custom_fields_json` | TEXT | YES | |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | ON UPDATE CURRENT_TIMESTAMP |
| `deleted_at` | DATETIME | YES | Soft delete |
Indexes: `products_status_idx`, `products_type_idx`, `products_updated_at_idx`, `products_ean_idx`
**product_translations** — Localized product names/descriptions
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `product_id` | INT UNSIGNED | NO | FK → products(id) CASCADE |
| `lang` | VARCHAR(8) | NO | |
| `name` | VARCHAR(255) | NO | |
| `short_description` | TEXT | YES | |
| `description` | LONGTEXT | YES | |
| `meta_title` | VARCHAR(255) | YES | |
| `meta_description` | VARCHAR(255) | YES | |
| `meta_keywords` | VARCHAR(255) | YES | |
| `seo_link` | VARCHAR(255) | YES | |
| `security_information` | MEDIUMTEXT | YES | GPSR data |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
UNIQUE: `(product_id, lang)`
**product_images** — Product image storage references
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `product_id` | INT UNSIGNED | NO | FK → products(id) CASCADE |
| `storage_path` | VARCHAR(255) | NO | |
| `alt` | VARCHAR(255) | YES | |
| `sort_order` | INT | NO | DEFAULT 0 |
| `is_main` | TINYINT(1) | NO | DEFAULT 0 |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
**product_categories** — Productcategory associations (m2m)
| Column | Type | Notes |
|--------|------|-------|
| `product_id` | INT UNSIGNED | FK → products(id) CASCADE |
| `category_id` | INT UNSIGNED | |
| `created_at` | DATETIME | |
PK: `(product_id, category_id)`
**product_variants** — Variants for `variant_parent` products
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `product_id` | INT UNSIGNED | NO | FK → products(id) CASCADE |
| `permutation_hash` | VARCHAR(191) | NO | |
| `sku` | VARCHAR(128) | YES | UNIQUE |
| `ean` | VARCHAR(32) | YES | |
| `status` | TINYINT(1) | NO | DEFAULT 1 |
| `stock_0_buy` | TINYINT(1) | NO | DEFAULT 0 |
| `price_brutto` | DECIMAL(12,2) | YES | |
| `price_netto` | DECIMAL(12,2) | YES | |
| `weight` | DECIMAL(10,3) | YES | |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
UNIQUE: `(product_id, permutation_hash)`
**product_variant_attributes** — Variantattribute value mapping
| Column | Type | Notes |
|--------|------|-------|
| `variant_id` | INT UNSIGNED | FK → product_variants(id) CASCADE |
| `attribute_id` | INT UNSIGNED | FK → attributes(id) RESTRICT |
| `value_id` | INT UNSIGNED | FK → attribute_values(id) RESTRICT |
| `created_at` | DATETIME | |
PK: `(variant_id, attribute_id)`
**attributes** — Attribute definitions (e.g., size, color)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `type` | TINYINT UNSIGNED | NO | DEFAULT 1 |
| `status` | TINYINT(1) | NO | DEFAULT 1 |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
**attribute_translations** — Localized attribute names
| Column | Type | Notes |
|--------|------|-------|
| `attribute_id` | INT UNSIGNED | FK → attributes(id) CASCADE |
| `lang` | VARCHAR(8) | |
| `name` | VARCHAR(255) | |
| `created_at` | DATETIME | |
| `updated_at` | DATETIME | |
PK: `(attribute_id, lang)`
**attribute_values** — Possible values per attribute
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `attribute_id` | INT UNSIGNED | NO | FK → attributes(id) CASCADE |
| `status` | TINYINT(1) | NO | DEFAULT 1 |
| `is_default` | TINYINT(1) | NO | DEFAULT 0 |
| `impact_on_price` | DECIMAL(12,2) | YES | |
| `sort_order` | INT | NO | DEFAULT 0 |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
**attribute_value_translations** — Localized value names
| Column | Type | Notes |
|--------|------|-------|
| `value_id` | INT UNSIGNED | FK → attribute_values(id) CASCADE |
| `lang` | VARCHAR(8) | |
| `name` | VARCHAR(255) | |
| `created_at` | DATETIME | |
| `updated_at` | DATETIME | |
PK: `(value_id, lang)`
**product_change_log** — Audit trail for product modifications
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `product_id` | INT UNSIGNED | NO | FK → products(id) CASCADE |
| `user_id` | INT UNSIGNED | YES | FK → users(id) SET NULL |
| `change_type` | VARCHAR(64) | NO | |
| `before_json` | JSON | YES | |
| `after_json` | JSON | YES | |
| `created_at` | DATETIME | NO | |
**sales_channels** — Selling channel definitions
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `code` | VARCHAR(64) | NO | UNIQUE |
| `name` | VARCHAR(128) | NO | |
| `type` | VARCHAR(64) | NO | |
| `status` | TINYINT(1) | NO | DEFAULT 1 |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
**product_channel_map** — Link products to external channel offers
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `product_id` | INT UNSIGNED | NO | FK → products(id) CASCADE |
| `channel_id` | INT UNSIGNED | NO | FK → sales_channels(id) CASCADE |
| `integration_id` | INT UNSIGNED | YES | FK → integrations(id) SET NULL |
| `external_product_id` | VARCHAR(128) | YES | |
| `external_variant_id` | VARCHAR(128) | YES | |
| `sync_state` | VARCHAR(32) | NO | DEFAULT 'not_linked' |
| `link_type` | VARCHAR(32) | NO | DEFAULT 'manual' |
| `link_status` | VARCHAR(32) | NO | DEFAULT 'active' |
| `confidence` | TINYINT UNSIGNED | YES | |
| `linked_at` | DATETIME | YES | |
| `unlinked_at` | DATETIME | YES | |
| `sync_meta_json` | JSON | YES | |
| `last_sync_at` | DATETIME | YES | |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
UNIQUE: `(product_id, channel_id, external_product_id, external_variant_id)`
**channel_offers** — External channel offer snapshots
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `integration_id` | INT UNSIGNED | NO | FK → integrations(id) CASCADE |
| `channel_id` | INT UNSIGNED | NO | FK → sales_channels(id) CASCADE |
| `external_product_id` | VARCHAR(128) | NO | |
| `external_variant_id` | VARCHAR(128) | YES | |
| `external_offer_id` | VARCHAR(128) | YES | |
| `name` | VARCHAR(255) | NO | |
| `sku` | VARCHAR(128) | YES | |
| `ean` | VARCHAR(32) | YES | |
| `price_brutto` | DECIMAL(12,2) | YES | |
| `quantity` | DECIMAL(12,3) | YES | |
| `currency` | VARCHAR(8) | YES | |
| `offer_status` | VARCHAR(32) | NO | DEFAULT 'active' |
| `source_updated_at` | DATETIME | YES | |
| `last_seen_at` | DATETIME | NO | |
| `payload_json` | JSON | YES | |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
UNIQUE: `(integration_id, external_product_id, external_variant_id)`
**product_link_events** — Audit log for product-channel linking changes
**product_link_alerts** — Alerts for product-channel link issues (ENUM status: `active`, `resolved`)
**product_integration_translations** — Integration-specific product description overrides
---
## Orders
**orders** — Imported orders from sales channels
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `internal_order_number` | VARCHAR(11) | YES | UNIQUE, auto-assigned |
| `integration_id` | INT UNSIGNED | NO | FK → integrations(id) CASCADE |
| `external_order_id` | VARCHAR(64) | NO | |
| `external_order_number` | VARCHAR(128) | YES | |
| `status` | VARCHAR(64) | YES | Internal status code |
| `currency` | CHAR(3) | YES | |
| `total_gross` | DECIMAL(12,2) | YES | |
| `total_net` | DECIMAL(12,2) | YES | |
| `total_with_tax` | DECIMAL(12,2) | YES | |
| `total_paid` | DECIMAL(12,2) | YES | |
| `buyer_email` | VARCHAR(190) | YES | |
| `buyer_name` | VARCHAR(190) | YES | |
| `buyer_phone` | VARCHAR(64) | YES | |
| `payment_method` | VARCHAR(128) | YES | |
| `payment_status` | VARCHAR(64) | YES | |
| `external_payment_type_id` | VARCHAR(128) | YES | |
| `delivery_method` | VARCHAR(128) | YES | |
| `delivery_price` | DECIMAL(12,2) | YES | |
| `delivery_tracking_number` | VARCHAR(128) | YES | |
| `notes` | TEXT | YES | |
| `external_created_at` | DATETIME | YES | |
| `external_updated_at` | DATETIME | YES | |
| `last_status_checked_at` | DATETIME | YES | |
| `payload_json` | JSON | YES | Full raw API payload |
| `fetched_at` | DATETIME | NO | |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
UNIQUE: `(integration_id, external_order_id)`
**order_items** — Line items within orders
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `order_id` | INT UNSIGNED | NO | FK → orders(id) CASCADE |
| `external_item_id` | VARCHAR(64) | YES | |
| `name` | VARCHAR(255) | NO | |
| `sku` | VARCHAR(128) | YES | |
| `ean` | VARCHAR(64) | YES | |
| `quantity` | DECIMAL(12,3) | NO | DEFAULT 0 |
| `price_gross` | DECIMAL(12,2) | YES | |
| `price_net` | DECIMAL(12,2) | YES | |
| `vat` | DECIMAL(6,2) | YES | |
| `personalization` | TEXT | YES | Customer personalization data for design generation |
| `project_generated` | TINYINT(1) | NO | DEFAULT 0 |
| `project_generated_at` | DATETIME | YES | |
| `payload_json` | JSON | YES | |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
**order_activity_log** — Event log for order state changes
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | BIGINT UNSIGNED | NO | PK |
| `order_id` | BIGINT UNSIGNED | NO | FK → orders(id) CASCADE |
| `event_type` | VARCHAR(32) | NO | |
| `summary` | VARCHAR(255) | NO | |
| `details_json` | JSON | YES | |
| `actor_type` | VARCHAR(16) | NO | DEFAULT 'system' |
| `actor_name` | VARCHAR(128) | YES | |
| `created_at` | DATETIME | NO | |
**order_payments** — Payments linked to orders
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `order_id` | INT UNSIGNED | NO | FK → orders(id) CASCADE |
| `source_payment_id` | VARCHAR(64) | YES | |
| `external_payment_id` | VARCHAR(64) | YES | |
| `payment_type_id` | VARCHAR(64) | NO | |
| `payment_date` | DATETIME | YES | |
| `amount` | DECIMAL(12,2) | YES | |
| `currency` | CHAR(3) | YES | |
| `comment` | VARCHAR(255) | YES | |
| `payload_json` | JSON | YES | |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
UNIQUE: `(order_id, source_payment_id)`
---
## Order Statuses
**order_status_groups** — Status group categories (e.g., "Nowe", "W realizacji")
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `name` | VARCHAR(120) | NO | |
| `code` | VARCHAR(64) | NO | UNIQUE |
| `color_hex` | CHAR(7) | NO | DEFAULT '#64748b' |
| `sort_order` | INT | NO | DEFAULT 0 |
| `is_active` | TINYINT(1) | NO | DEFAULT 1 |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
**order_statuses** — Individual statuses within groups
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `group_id` | INT UNSIGNED | NO | FK → order_status_groups(id) CASCADE |
| `name` | VARCHAR(120) | NO | |
| `code` | VARCHAR(64) | NO | UNIQUE |
| `sort_order` | INT | NO | DEFAULT 0 |
| `is_active` | TINYINT(1) | NO | DEFAULT 1 |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
**order_status_mappings** — Map external (e.g., shopPRO) statuses to internal ones
| Column | Type | Notes |
|--------|------|-------|
| `id` | INT UNSIGNED | PK |
| `integration_id` | INT UNSIGNED | FK → integrations(id) CASCADE |
| `shoppro_status_code` | VARCHAR(64) | |
| `shoppro_status_name` | VARCHAR(128) | |
| `orderpro_status_code` | VARCHAR(64) | |
| `created_at` | DATETIME | |
| `updated_at` | DATETIME | |
UNIQUE: `(integration_id, shoppro_status_code)`
**integration_order_sync_state** — Track order fetch progress per integration
**integration_order_status_sync_state** — Track status sync progress per integration and direction
---
## Shipments & Delivery
**shipment_packages** — Prepared shipments with tracking
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | BIGINT UNSIGNED | NO | PK |
| `order_id` | BIGINT UNSIGNED | NO | FK → orders(id) CASCADE |
| `provider` | VARCHAR(32) | NO | DEFAULT 'allegro_wza' |
| `delivery_method_id` | VARCHAR(128) | YES | |
| `credentials_id` | VARCHAR(128) | YES | |
| `command_id` | VARCHAR(64) | YES | |
| `shipment_id` | VARCHAR(64) | YES | |
| `tracking_number` | VARCHAR(128) | YES | |
| `status` | VARCHAR(32) | NO | DEFAULT 'draft' |
| `delivery_status` | VARCHAR(32) | NO | DEFAULT 'unknown' |
| `delivery_status_raw` | VARCHAR(128) | YES | Provider's original status string |
| `delivery_status_updated_at` | DATETIME | YES | |
| `carrier_id` | VARCHAR(64) | YES | |
| `package_type` | VARCHAR(16) | NO | DEFAULT 'PACKAGE' |
| `weight_kg` | DECIMAL(8,3) | YES | |
| `length_cm` | DECIMAL(8,1) | YES | |
| `width_cm` | DECIMAL(8,1) | YES | |
| `height_cm` | DECIMAL(8,1) | YES | |
| `insurance_amount` | DECIMAL(12,2) | YES | |
| `cod_amount` | DECIMAL(12,2) | YES | |
| `label_format` | VARCHAR(8) | NO | DEFAULT 'PDF' |
| `label_path` | VARCHAR(512) | YES | |
| `receiver_point_id` | VARCHAR(64) | YES | Parcel locker ID |
| `sender_point_id` | VARCHAR(64) | YES | |
| `reference_number` | VARCHAR(128) | YES | |
| `error_message` | TEXT | YES | |
| `payload_json` | JSON | YES | |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
Indexes: `shipment_packages_order_idx`, `shipment_packages_status_idx`, `shipment_packages_tracking_idx`, `idx_delivery_status`
**shipment_presets** — Predefined shipment configurations (saved templates)
| Column | Type | Notes |
|--------|------|-------|
| `id` | BIGINT UNSIGNED | PK |
| `name` | VARCHAR(100) | |
| `color` | VARCHAR(7) | DEFAULT '#3b82f6' |
| `carrier` | VARCHAR(32) | |
| `provider_code` | VARCHAR(32) | |
| `delivery_method_id` | VARCHAR(128) | |
| `credentials_id` | VARCHAR(128) | DEFAULT '' |
| `carrier_id` | VARCHAR(64) | DEFAULT '' |
| `package_type` | VARCHAR(16) | DEFAULT 'PACKAGE' |
| `length_cm` | DECIMAL(8,1) | DEFAULT 25.0 |
| `width_cm` | DECIMAL(8,1) | DEFAULT 20.0 |
| `height_cm` | DECIMAL(8,1) | DEFAULT 8.0 |
| `weight_kg` | DECIMAL(8,3) | DEFAULT 1.000 |
| `sender_point_id` | VARCHAR(64) | DEFAULT '' |
| `label_format` | VARCHAR(8) | DEFAULT 'PDF' |
| `sort_order` | INT UNSIGNED | DEFAULT 0 |
| `created_at` | TIMESTAMP | |
| `updated_at` | TIMESTAMP | |
**delivery_statuses** — Normalized delivery status definitions (Phase 108)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `key` | VARCHAR(50) | NO | UNIQUE |
| `label_pl` | VARCHAR(100) | NO | Polish label |
| `color` | VARCHAR(7) | NO | DEFAULT '#6c757d' |
| `sort_order` | TINYINT UNSIGNED | NO | DEFAULT 0 |
| `is_terminal` | TINYINT(1) | NO | DEFAULT 0 — marks final states |
| `is_system` | TINYINT(1) | NO | DEFAULT 0 — system-managed |
| `created_at` | DATETIME | NO | |
**delivery_status_mappings** — Map provider-specific raw statuses to normalized keys (Phase 108)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `provider` | VARCHAR(32) | NO | |
| `raw_status` | VARCHAR(64) | NO | |
| `normalized_status` | VARCHAR(32) | NO | FK ref → delivery_statuses.key |
| `description` | VARCHAR(255) | NO | DEFAULT '' |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
UNIQUE: `(provider, raw_status)`
---
## Integrations
**integrations** — Integration configurations for external services
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `type` | VARCHAR(32) | NO | e.g., 'allegro', 'shoppro', 'apaczka', 'inpost' |
| `name` | VARCHAR(128) | NO | |
| `base_url` | VARCHAR(255) | NO | |
| `api_key_encrypted` | TEXT | YES | AES-encrypted |
| `timeout_seconds` | SMALLINT UNSIGNED | NO | DEFAULT 10 |
| `is_active` | TINYINT(1) | NO | DEFAULT 1 |
| `orders_fetch_enabled` | TINYINT(1) | NO | DEFAULT 0 |
| `orders_fetch_start_date` | DATE | YES | |
| `order_status_sync_direction` | VARCHAR(32) | NO | DEFAULT 'shoppro_to_orderpro' |
| `last_test_status` | VARCHAR(16) | YES | |
| `last_test_http_code` | SMALLINT UNSIGNED | YES | |
| `last_test_message` | VARCHAR(255) | YES | |
| `last_test_at` | DATETIME | YES | |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
UNIQUE: `(type, name)`
**integration_test_logs** — API test results log
**allegro_integration_settings** — Allegro OAuth tokens and API config
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `integration_id` | INT UNSIGNED | YES | UNIQUE, FK → integrations(id) CASCADE |
| `environment` | VARCHAR(16) | NO | DEFAULT 'sandbox' |
| `client_id` | VARCHAR(128) | YES | |
| `client_secret_encrypted` | TEXT | YES | |
| `redirect_uri` | VARCHAR(255) | YES | |
| `orders_fetch_enabled` | TINYINT(1) | NO | DEFAULT 0 |
| `orders_fetch_start_date` | DATE | YES | |
| `access_token_encrypted` | MEDIUMTEXT | YES | AES-encrypted |
| `refresh_token_encrypted` | MEDIUMTEXT | YES | AES-encrypted |
| `token_type` | VARCHAR(32) | YES | |
| `token_scope` | VARCHAR(255) | YES | |
| `token_expires_at` | DATETIME | YES | |
| `connected_at` | DATETIME | YES | |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
**allegro_order_status_mappings** — Allegro status → internal status
| Column | Type | Notes |
|--------|------|-------|
| `id` | INT UNSIGNED | PK |
| `allegro_status_code` | VARCHAR(64) | UNIQUE |
| `allegro_status_name` | VARCHAR(120) | |
| `orderpro_status_code` | VARCHAR(64) | |
| `created_at` | DATETIME | |
| `updated_at` | DATETIME | |
**allegro_delivery_method_mappings** — Map order delivery method strings to Allegro services
| Column | Type | Notes |
|--------|------|-------|
| `id` | INT UNSIGNED | PK |
| `order_delivery_method` | VARCHAR(200) | UNIQUE |
| `allegro_delivery_method_id` | VARCHAR(128) | |
| `allegro_credentials_id` | VARCHAR(128) | |
| `allegro_carrier_id` | VARCHAR(128) | |
| `allegro_service_name` | VARCHAR(255) | |
| `created_at` | DATETIME | |
| `updated_at` | DATETIME | |
**apaczka_integration_settings** — Apaczka API credentials
| Column | Type | Notes |
|--------|------|-------|
| `id` | TINYINT UNSIGNED | PK (fixed 1 row) |
| `integration_id` | INT UNSIGNED | UNIQUE, FK → integrations(id) |
| `api_key_encrypted` | TEXT | |
| `created_at` | DATETIME | |
| `updated_at` | DATETIME | |
**inpost_integration_settings** — InPost ShipX settings
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | TINYINT UNSIGNED | NO | PK (fixed 1 row) |
| `integration_id` | INT UNSIGNED | YES | UNIQUE, FK → integrations(id) |
| `api_token_encrypted` | TEXT | YES | |
| `organization_id` | VARCHAR(50) | YES | |
| `environment` | ENUM('sandbox','production') | NO | DEFAULT 'sandbox' |
| `default_dispatch_method` | ENUM('pop','parcel_locker','courier') | NO | DEFAULT 'pop' |
| `default_dispatch_point` | VARCHAR(50) | YES | |
| `default_insurance` | DECIMAL(10,2) | YES | |
| `default_locker_size` | ENUM('small','medium','large') | NO | DEFAULT 'small' |
| `default_courier_length` | SMALLINT UNSIGNED | YES | DEFAULT 20 |
| `default_courier_width` | SMALLINT UNSIGNED | YES | DEFAULT 15 |
| `default_courier_height` | SMALLINT UNSIGNED | YES | DEFAULT 8 |
| `label_format` | ENUM('Pdf','Zpl','Epl') | NO | DEFAULT 'Pdf' |
| `weekend_delivery` | TINYINT(1) | NO | DEFAULT 0 |
| `auto_insurance_value` | TINYINT(1) | NO | DEFAULT 0 |
| `multi_parcel` | TINYINT(1) | NO | DEFAULT 0 |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
---
## Accounting / Receipts
**receipt_configs** — Receipt generation configurations
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `name` | VARCHAR(128) | NO | |
| `is_active` | TINYINT(1) | NO | DEFAULT 1 |
| `number_format` | VARCHAR(64) | NO | DEFAULT 'PAR/%N/%M/%Y' |
| `numbering_type` | ENUM('monthly','yearly') | NO | DEFAULT 'monthly' |
| `is_named` | TINYINT(1) | NO | DEFAULT 0 |
| `sale_date_source` | ENUM('order_date','payment_date','issue_date') | NO | DEFAULT 'issue_date' |
| `order_reference` | ENUM('none','orderpro','integration') | NO | DEFAULT 'none' |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
**receipts** — Generated receipts / invoices
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `order_id` | BIGINT UNSIGNED | NO | FK → orders(id) CASCADE |
| `config_id` | INT UNSIGNED | NO | FK → receipt_configs(id) RESTRICT |
| `receipt_number` | VARCHAR(64) | NO | UNIQUE |
| `issue_date` | DATETIME | NO | |
| `sale_date` | DATETIME | NO | |
| `seller_data_json` | JSON | NO | Snapshot of company data at issue time |
| `buyer_data_json` | JSON | YES | |
| `items_json` | JSON | NO | |
| `total_net` | DECIMAL(12,2) | NO | DEFAULT 0.00 |
| `total_gross` | DECIMAL(12,2) | NO | DEFAULT 0.00 |
| `order_reference_value` | VARCHAR(128) | YES | |
| `created_by` | INT UNSIGNED | YES | |
| `created_at` | DATETIME | NO | |
**receipt_number_counters** — Sequential numbering per config/period
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `config_id` | INT UNSIGNED | NO | FK → receipt_configs(id) CASCADE |
| `year` | SMALLINT UNSIGNED | NO | |
| `month` | TINYINT UNSIGNED | YES | NULL for yearly numbering |
| `last_number` | INT UNSIGNED | NO | DEFAULT 0 |
UNIQUE: `(config_id, year, month)`
---
## Email
**email_mailboxes** — SMTP mailbox configurations
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `name` | VARCHAR(100) | NO | |
| `smtp_host` | VARCHAR(255) | NO | |
| `smtp_port` | SMALLINT UNSIGNED | NO | DEFAULT 587 |
| `smtp_encryption` | ENUM('tls','ssl','none') | NO | DEFAULT 'tls' |
| `smtp_username` | VARCHAR(255) | NO | |
| `smtp_password_encrypted` | TEXT | NO | AES-encrypted |
| `sender_email` | VARCHAR(255) | NO | |
| `sender_name` | VARCHAR(200) | YES | |
| `html_layout` | LONGTEXT | YES | Wrapper HTML for all emails from this mailbox |
| `is_default` | TINYINT(1) | NO | DEFAULT 0 |
| `is_active` | TINYINT(1) | NO | DEFAULT 1 |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
**email_templates** — Email message templates with variable placeholders
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `name` | VARCHAR(200) | NO | |
| `subject` | VARCHAR(500) | NO | |
| `body_html` | TEXT | NO | Supports `{{variable}}` placeholders |
| `mailbox_id` | INT UNSIGNED | YES | FK → email_mailboxes(id) SET NULL |
| `attachment1` | LONGTEXT | YES | |
| `is_active` | TINYINT(1) | NO | DEFAULT 1 |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
**email_logs** — Sent email history
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | BIGINT UNSIGNED | NO | PK |
| `template_id` | INT UNSIGNED | YES | FK → email_templates(id) SET NULL |
| `mailbox_id` | INT UNSIGNED | YES | FK → email_mailboxes(id) SET NULL |
| `order_id` | INT UNSIGNED | YES | |
| `recipient_email` | VARCHAR(255) | NO | |
| `recipient_name` | VARCHAR(200) | YES | |
| `subject` | VARCHAR(500) | NO | |
| `body_html` | TEXT | NO | |
| `attachments_json` | JSON | YES | |
| `status` | ENUM('sent','failed','pending') | NO | DEFAULT 'pending' |
| `error_message` | TEXT | YES | |
| `sent_at` | DATETIME | YES | |
| `created_at` | DATETIME | NO | |
---
## Automation
**automation_rules** — Business rules for order event automation
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `name` | VARCHAR(128) | NO | |
| `event_type` | VARCHAR(64) | NO | e.g., 'order_status_changed', 'order_status_aged' |
| `is_active` | TINYINT(1) | NO | DEFAULT 1 |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
Index: `(event_type, is_active)`
**automation_conditions** — Conditions for automation rules
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `rule_id` | INT UNSIGNED | NO | FK → automation_rules(id) CASCADE |
| `condition_type` | VARCHAR(64) | NO | |
| `condition_value` | JSON | NO | |
| `sort_order` | SMALLINT UNSIGNED | NO | DEFAULT 0 |
**automation_actions** — Actions executed when rules trigger
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `rule_id` | INT UNSIGNED | NO | FK → automation_rules(id) CASCADE |
| `action_type` | VARCHAR(64) | NO | e.g., 'send_email', 'update_status', 'create_receipt' |
| `action_config` | JSON | NO | |
| `sort_order` | SMALLINT UNSIGNED | NO | DEFAULT 0 |
**automation_execution_logs** — Audit log for rule executions
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | BIGINT UNSIGNED | NO | PK |
| `event_type` | VARCHAR(64) | NO | |
| `rule_id` | INT UNSIGNED | YES | FK → automation_rules(id) SET NULL |
| `rule_name` | VARCHAR(128) | NO | Snapshot at execution time |
| `order_id` | INT UNSIGNED | NO | FK → orders(id) CASCADE |
| `execution_status` | VARCHAR(16) | NO | |
| `result_message` | VARCHAR(500) | YES | |
| `context_json` | JSON | YES | |
| `executed_at` | DATETIME | NO | |
| `created_at` | DATETIME | NO | |
**automation_email_once_deliveries** — Idempotency guard: email sent-once per rule+action+order (Phase 107)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | BIGINT UNSIGNED | NO | PK |
| `rule_id` | INT UNSIGNED | NO | FK → automation_rules(id) CASCADE |
| `action_id` | INT UNSIGNED | NO | FK → automation_actions(id) CASCADE |
| `order_id` | BIGINT UNSIGNED | NO | FK → orders(id) CASCADE |
| `created_at` | DATETIME | NO | |
UNIQUE: `(rule_id, action_id, order_id)`
---
## Print Queue
**print_api_keys** — API keys for remote print client authentication
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT | NO | PK |
| `name` | VARCHAR(128) | NO | |
| `key_hash` | VARCHAR(128) | NO | UNIQUE, SHA256 |
| `key_prefix` | VARCHAR(8) | NO | Shown in UI for identification |
| `is_active` | TINYINT(1) | NO | DEFAULT 1 |
| `last_used_at` | DATETIME | YES | |
| `created_at` | DATETIME | NO | |
**print_jobs** — Print queue for remote label printing
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `order_id` | BIGINT UNSIGNED | NO | |
| `package_id` | BIGINT UNSIGNED | NO | |
| `label_path` | VARCHAR(255) | NO | |
| `status` | ENUM('pending','printing','completed','failed') | NO | DEFAULT 'pending' |
| `created_by` | INT UNSIGNED | NO | |
| `created_at` | DATETIME | NO | |
| `completed_at` | DATETIME | YES | |
---
## Cron & Scheduling
**cron_jobs** — Individual cron job queue entries
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `job_type` | VARCHAR(80) | NO | |
| `status` | ENUM('pending','processing','completed','failed','cancelled') | NO | DEFAULT 'pending' |
| `priority` | TINYINT UNSIGNED | NO | DEFAULT 100 |
| `payload` | JSON | YES | |
| `result` | JSON | YES | |
| `attempts` | SMALLINT UNSIGNED | NO | DEFAULT 0 |
| `max_attempts` | SMALLINT UNSIGNED | NO | DEFAULT 3 |
| `last_error` | VARCHAR(500) | YES | |
| `scheduled_at` | DATETIME | NO | |
| `started_at` | DATETIME | YES | |
| `completed_at` | DATETIME | YES | |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
Index: `(status, priority, scheduled_at)`
**cron_schedules** — Recurring job definitions
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `job_type` | VARCHAR(80) | NO | UNIQUE |
| `interval_seconds` | INT UNSIGNED | NO | |
| `priority` | TINYINT UNSIGNED | NO | DEFAULT 100 |
| `max_attempts` | SMALLINT UNSIGNED | NO | DEFAULT 3 |
| `payload` | JSON | YES | |
| `enabled` | TINYINT(1) | NO | DEFAULT 1 |
| `last_run_at` | DATETIME | YES | |
| `next_run_at` | DATETIME | YES | |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
---
## Settings & Configuration
**app_settings** — Global key-value configuration store
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `setting_key` | VARCHAR(120) | NO | UNIQUE |
| `setting_value` | TEXT | YES | |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
Default keys: `cron_run_on_web`, `cron_web_limit`, `gs1_api_login`, `gs1_prefix`, `products_sku_format`
**company_settings** — Single-record seller/company configuration (always id=1)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | TINYINT UNSIGNED | NO | PK, always 1 |
| `company_name` | VARCHAR(200) | YES | |
| `person_name` | VARCHAR(200) | YES | |
| `street` | VARCHAR(200) | YES | |
| `city` | VARCHAR(128) | YES | |
| `postal_code` | VARCHAR(16) | YES | |
| `country_code` | CHAR(2) | NO | DEFAULT 'PL' |
| `phone` | VARCHAR(64) | YES | |
| `email` | VARCHAR(128) | YES | |
| `tax_number` | VARCHAR(64) | YES | NIP |
| `bank_account` | VARCHAR(64) | YES | |
| `bank_owner_name` | VARCHAR(200) | YES | |
| `contact_person_first_name` | VARCHAR(100) | YES | |
| `contact_person_last_name` | VARCHAR(100) | YES | |
| `contact_person_phone` | VARCHAR(64) | YES | |
| `contact_person_email` | VARCHAR(128) | YES | |
| `default_package_length_cm` | DECIMAL(8,1) | NO | DEFAULT 25.0 |
| `default_package_width_cm` | DECIMAL(8,1) | NO | DEFAULT 20.0 |
| `default_package_height_cm` | DECIMAL(8,1) | NO | DEFAULT 8.0 |
| `default_package_weight_kg` | DECIMAL(8,3) | NO | DEFAULT 1.000 |
| `default_label_format` | VARCHAR(8) | NO | DEFAULT 'PDF' |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
---
## Design Generation
**project_mappings** — Map product name patterns to graphic generation scripts
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | INT UNSIGNED | NO | PK |
| `product_name_pattern` | VARCHAR(255) | NO | Pattern matched against order_items.name |
| `script_name` | VARCHAR(255) | NO | Script filename in tools/generowanie/ |
| `output_dir` | VARCHAR(500) | YES | |
| `is_active` | TINYINT(1) | NO | DEFAULT 1 |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
---
## Schema Characteristics
| Property | Value |
|----------|-------|
| Engine | InnoDB (all tables) |
| Charset | utf8mb4_unicode_ci |
| Encrypted columns | `*_encrypted` suffix — AES via `IntegrationSecretCipher` |
| Soft deletes | `products.deleted_at` only |
| Audit via JSON | `payload_json` snapshots in orders, shipments, receipts |
| Migrations | `database/migrations/YYYYMMDD_NNNNNN_description.sql` |
| Deferred indexes | `idx_order_addresses_order_type`, `idx_shipment_packages_order_delivery` — apply at >50k orders |
## Reporting Usage
**Statistics Summary (`/statistics/summary`)** — no dedicated reporting tables.
- Reads existing `orders` rows and groups by month using the same effective order date used by `/statistics/orders`.
- Default summary history starts at April 2026 (`2026-04-01`), even if older rows exist.
- Splits series by channel key: Allegro as one series and each shopPRO integration by `orders.integration_id`.
- Uses `integrations.name` only for display labels when available.
- Filters by selected status groups through `order_status_groups` and `order_statuses`.
- Uses existing gross amount columns via `OrdersStatisticsRepository::grossAmountSql()`.
- No schema migration was introduced for Phase 110.

59
DOCS/TECH_CHANGELOG.md Normal file
View File

@@ -0,0 +1,59 @@
# Technical Changelog
## 2026-04-28 - Phase 110 Plan 01: Statistics Summary
**Co zrobiono:**
- `/statistics/summary` - nowy widok podsumowania w menu `Statystyki -> Podsumowanie`.
- `OrdersStatisticsController::summary()` - buduje miesieczny view-model dla wykresow liczby i wartosci zamowien.
- `OrdersStatisticsRepository::aggregateByMonth()` - agreguje istniejace zamowienia po miesiacu i kanale/integracji.
- `public/assets/js/modules/statistics-summary-charts.js` - renderer dwoch interaktywnych wykresow liniowych oparty o Chart.js 4.4.8 CDN.
- `resources/views/statistics/summary.php` - filtry zgodne z raportem dziennym, dwa wykresy obok siebie na desktopie oraz dwie tabele fallback pod nimi.
- Domyslny poczatek historii ustawiony na `2026-04-01` (`04-2026`) mimo starszych danych.
**Dlaczego:**
- Operator potrzebuje szybkiego trendu miesiecznego przed przejsciem do szczegolowych dziennych statystyk.
- Wykresy uzywaja obecnych tabel `orders`, `integrations`, `order_status_groups` i `order_statuses`, wiec migracja DB nie jest potrzebna.
- Seria `Razem` jest liczona z tych samych danych co serie integracji, co ulatwia sprawdzenie sum miesiecznych.
## 2026-04-28 - Phase 109 Plan 01: Checkbox Multiselect Filters
**Co zrobiono:**
- `public/assets/js/modules/checkbox-multiselect.js` - nowy vanilla JS enhancer dla natywnych `<select multiple data-checkbox-multiselect>`.
- `resources/views/layouts/app.php` - globalne podpiecie modulu z cache busting przez `filemtime()`.
- `resources/views/statistics/orders.php` - filtry `channels[]` i `status_groups[]` oznaczone do progresywnego ulepszenia bez zmiany nazw pol formularza.
- `resources/scss/app.scss` - kompaktowe style dropdownu z checkboxami i opcja "Wszystkie".
**Dlaczego:**
- Natywne selecty multiple byly malo czytelne i zajmowaly za duzo miejsca w filtrach statystyk.
- Zachowanie oryginalnego selecta w DOM utrzymuje obecny kontrakt GET i fallback bez JavaScript.
- Brak zmian w schemacie DB i logice agregacji statystyk.
> Chronologiczny log zmian technicznych — co i dlaczego.
## 2026-04-27 — Phase 108 Plan 02: Automation Dropdowns z DB
**Co zrobiono:**
- `AutomationController` — usunięto stałą `SHIPMENT_STATUS_OPTIONS` (8 grupowych kluczy)
- Dropdown statusów w warunku `shipment_status` i akcji `update_shipment_status` ładuje statusy z DB przez `DeliveryStatus::getAllOptions()`
- Walidacja w `parseConditionValue()` i `parseActionConfig()` używa `DeliveryStatus::getAllStatuses()`
- `AutomationService` — usunięto stałą `SHIPMENT_STATUS_OPTION_MAP`; ewaluacja `evaluateShipmentStatusCondition()` porównuje klucze bezpośrednio
- `resolveStatusFromActionKey()` — bezpośredni klucz statusu z DB jako target (zamiast pierwszego z grupy)
**Dlaczego:**
- Zamknięcie integracji z Plan 01 — operator dodaje status w `/settings/delivery-statuses` i jest on od razu dostępny w dropdownach automatyzacji bez deploymentu
- Eliminacja kolizji semantycznej: stary klucz grupowy `picked_up` mapował na `delivered` (paczka odebrana przez klienta), nowy klucz DB `picked_up` to "Odebrana przez kuriera" (od nadawcy)
- BREAKING: stare reguły z grupowymi kluczami (`registered`, `courier_pickup`, `dropped_at_point`, `unclaimed`, `picked_up_return`, oraz `picked_up`/`ready_for_pickup`/`cancelled` w starym znaczeniu) nie matchują — wymagają ręcznego odtworzenia z nowymi kluczami DB
## 2026-04-27 — Phase 108 Plan 01: Delivery Status Management
**Co zrobiono:**
- Tabela `delivery_statuses` z seedem 11 statusów (migracja `20260427_000103`)
- `DeliveryStatusRepository` — CRUD + per-request cache
- `DeliveryStatus.php` — dynamiczne ładowanie statusów z DB (`setRepository()`)
- Panel `/settings/delivery-statuses` z CRUD (zakładka "Statusy") i mapowaniem (zakładka "Mapowanie dostawy")
- Sidebar: "Statusy" → "Statusy zamówień", nowe "Statusy przesyłek" z badge niezmapowanych
- Badge przesyłek: inline CSS custom property `--status-color` dla niestandardowych statusów
**Dlaczego:**
- Dodanie nowego statusu wymagało zmiany kodu + deploymentu; teraz z UI
- Operator może definiować własne statusy znormalizowane bez ingerencji w kod

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,119 @@
(function () {
'use strict';
var palette = ['#2563eb', '#0f766e', '#c2410c', '#7c3aed', '#be123c', '#0369a1', '#4d7c0f', '#9333ea'];
var totalColor = '#111827';
function parseData() {
var node = document.getElementById('js-statistics-summary-data');
if (!node) return null;
try {
return JSON.parse(node.textContent || '{}');
} catch (error) {
return null;
}
}
function colorForSeries(item, index) {
return item.key === 'total' ? totalColor : palette[index % palette.length];
}
function moneyLabel(value) {
return Number(value || 0).toLocaleString('pl-PL', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
function datasetForSeries(item, index) {
var color = colorForSeries(item, index);
var isTotal = item.key === 'total';
return {
label: item.label || item.key || '',
data: item.values || [],
borderColor: color,
backgroundColor: color,
borderWidth: isTotal ? 3 : 2,
pointRadius: isTotal ? 4 : 3,
pointHoverRadius: isTotal ? 6 : 5,
tension: 0.25
};
}
function renderChart(container, chart) {
if (!window.Chart || !container || !chart || !Array.isArray(chart.labels) || !Array.isArray(chart.series)) return;
var canvas = container.querySelector('canvas');
if (!canvas) return;
var isMoney = chart.valueType === 'money';
var context = canvas.getContext('2d');
new window.Chart(context, {
type: 'line',
data: {
labels: chart.labels,
datasets: chart.series.map(datasetForSeries)
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
position: 'bottom',
labels: {
boxWidth: 18,
boxHeight: 3,
usePointStyle: false
}
},
tooltip: {
callbacks: {
label: function (item) {
var label = item.dataset.label ? item.dataset.label + ': ' : '';
return label + (isMoney ? moneyLabel(item.parsed.y) : Math.round(item.parsed.y));
}
}
}
},
scales: {
x: {
grid: {
display: false
}
},
y: {
beginAtZero: true,
ticks: {
callback: function (value) {
return isMoney ? moneyLabel(value) : value;
}
}
}
}
}
});
}
function init() {
var data = parseData();
if (!data) return;
document.querySelectorAll('[data-statistics-chart]').forEach(function (container) {
var key = container.getAttribute('data-statistics-chart');
renderChart(container, data[key]);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -24,6 +24,7 @@ return [
'orders' => 'Zamowienia', 'orders' => 'Zamowienia',
'orders_list' => 'Lista zamowien', 'orders_list' => 'Lista zamowien',
'statistics' => 'Statystyki', 'statistics' => 'Statystyki',
'statistics_summary' => 'Podsumowanie',
'statistics_orders' => 'Zamowienia', 'statistics_orders' => 'Zamowienia',
'marketplace' => 'Marketplace', 'marketplace' => 'Marketplace',
'cron' => 'Harmonogram', 'cron' => 'Harmonogram',
@@ -221,6 +222,29 @@ return [
], ],
], ],
'statistics' => [ 'statistics' => [
'summary' => [
'title' => 'Podsumowanie statystyk',
'description' => 'Miesieczne trendy ilosci i wartosci zamowien z podzialem na integracje.',
'empty' => 'Brak danych dla wybranych filtrow.',
'charts' => [
'orders_count' => 'Ilosc zamowien miesiecznie',
'orders_value' => 'Wartosc zamowien miesiecznie',
],
'filters' => [
'date_from' => 'Data od',
'date_to' => 'Data do',
'channels' => 'Kanaly sprzedazy',
'status_groups' => 'Grupy statusow',
],
'columns' => [
'month' => 'Miesiac',
'total' => 'Razem',
],
'actions' => [
'apply_filters' => 'Filtruj',
'reset_filters' => 'Wyczysc',
],
],
'orders' => [ 'orders' => [
'title' => 'Statystyki zamowien', 'title' => 'Statystyki zamowien',
'description' => 'Dzienne podsumowanie ilosci i kwot zamowien z podzialem na kanaly sprzedazy.', 'description' => 'Dzienne podsumowanie ilosci i kwot zamowien z podzialem na kanaly sprzedazy.',

View File

@@ -1219,6 +1219,74 @@ h4.section-title {
} }
} }
.statistics-summary-page {
padding: 10px;
}
.statistics-summary-section {
display: grid;
gap: 12px;
}
.statistics-summary-chart-grid,
.statistics-summary-table-grid {
display: grid;
gap: 12px;
}
@media (min-width: 1100px) {
.statistics-summary-chart-grid,
.statistics-summary-table-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
}
}
.statistics-summary-card {
padding: 10px;
}
.statistics-summary-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.statistics-summary-chart {
position: relative;
height: 320px;
}
.statistics-summary-chart canvas {
display: block;
width: 100%;
height: 100%;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #ffffff;
}
.statistics-summary-fallback {
margin-top: 0;
}
.statistics-summary-table {
min-width: 640px;
th,
td {
white-space: nowrap;
text-align: right;
}
th:first-child,
td:first-child {
text-align: left;
}
}
.orders-head { .orders-head {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;

View File

@@ -64,6 +64,9 @@
</svg> </svg>
</summary> </summary>
<div class="sidebar__group-links"> <div class="sidebar__group-links">
<a class="sidebar__sublink<?= $currentMenu === 'statistics' && $currentStatistics === 'summary' ? ' is-active' : '' ?>" href="/statistics/summary">
<?= $e($t('navigation.statistics_summary')) ?>
</a>
<a class="sidebar__sublink<?= $currentMenu === 'statistics' && $currentStatistics === 'orders' ? ' is-active' : '' ?>" href="/statistics/orders"> <a class="sidebar__sublink<?= $currentMenu === 'statistics' && $currentStatistics === 'orders' ? ' is-active' : '' ?>" href="/statistics/orders">
<?= $e($t('navigation.statistics_orders')) ?> <?= $e($t('navigation.statistics_orders')) ?>
</a> </a>
@@ -188,6 +191,8 @@
<script src="/assets/js/modules/jquery-alerts.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/jquery-alerts.js') ?: 0 ?>"></script> <script src="/assets/js/modules/jquery-alerts.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/jquery-alerts.js') ?: 0 ?>"></script>
<script src="/assets/js/modules/global-search.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/global-search.js') ?: 0 ?>"></script> <script src="/assets/js/modules/global-search.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/global-search.js') ?: 0 ?>"></script>
<script src="/assets/js/modules/checkbox-multiselect.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/checkbox-multiselect.js') ?: 0 ?>"></script> <script src="/assets/js/modules/checkbox-multiselect.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/checkbox-multiselect.js') ?: 0 ?>"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.8/dist/chart.umd.min.js"></script>
<script src="/assets/js/modules/statistics-summary-charts.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/statistics-summary-charts.js') ?: 0 ?>"></script>
<script> <script>
(function () { (function () {
var STORAGE_KEY = 'sidebarCollapsed'; var STORAGE_KEY = 'sidebarCollapsed';

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
$filters = is_array($filters ?? null) ? $filters : [];
$channelOptions = is_array($channelOptions ?? null) ? $channelOptions : [];
$statusGroupOptions = is_array($statusGroupOptions ?? null) ? $statusGroupOptions : [];
$summary = is_array($summary ?? null) ? $summary : [];
$selectedChannels = is_array($filters['selected_channels'] ?? null) ? $filters['selected_channels'] : [];
$selectedStatusGroups = is_array($filters['selected_status_groups'] ?? null) ? $filters['selected_status_groups'] : [];
$rows = is_array($summary['rows'] ?? null) ? $summary['rows'] : [];
$hasData = (bool) ($summary['hasData'] ?? false);
$countChart = is_array($summary['countChart'] ?? null) ? $summary['countChart'] : ['labels' => [], 'series' => []];
$valueChart = is_array($summary['valueChart'] ?? null) ? $summary['valueChart'] : ['labels' => [], 'series' => []];
$chartPayload = [
'count' => $countChart,
'value' => $valueChart,
];
$chartJson = json_encode(
$chartPayload,
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
);
if (!is_string($chartJson)) {
$chartJson = '{}';
}
$displayMonth = static function (string $month): string {
$date = DateTimeImmutable::createFromFormat('Y-m-d', $month . '-01');
if (!$date instanceof DateTimeImmutable) {
return $month;
}
return $date->format('m-Y');
};
?>
<section class="card statistics-summary-page">
<div class="statistics-orders-head">
<div>
<h2 class="section-title"><?= $e($t('statistics.summary.title')) ?></h2>
<p class="muted mt-12"><?= $e($t('statistics.summary.description')) ?></p>
</div>
</div>
<form method="get" action="/statistics/summary" class="statistics-orders-filters mt-16">
<label class="form-field">
<span class="field-label"><?= $e($t('statistics.summary.filters.date_from')) ?></span>
<input class="form-control" type="date" name="date_from" value="<?= $e((string) ($filters['date_from'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('statistics.summary.filters.date_to')) ?></span>
<input class="form-control" type="date" name="date_to" value="<?= $e((string) ($filters['date_to'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('statistics.summary.filters.channels')) ?></span>
<select class="form-control statistics-orders-multiselect js-checkbox-multiselect"
name="channels[]"
multiple
size="6"
data-checkbox-multiselect
data-all-label="Wszystkie"
data-empty-label="Nic nie wybrano"
data-selected-label-singular="zaznaczono"
data-selected-label-plural="zaznaczono">
<?php foreach ($channelOptions as $channelOption): ?>
<?php
$key = (string) ($channelOption['key'] ?? '');
$label = (string) ($channelOption['label'] ?? $key);
if ($key === '') {
continue;
}
?>
<option value="<?= $e($key) ?>"<?= in_array($key, $selectedChannels, true) ? ' selected' : '' ?>>
<?= $e($label) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('statistics.summary.filters.status_groups')) ?></span>
<select class="form-control statistics-orders-multiselect js-checkbox-multiselect"
name="status_groups[]"
multiple
size="6"
data-checkbox-multiselect
data-all-label="Wszystkie"
data-empty-label="Nic nie wybrano"
data-selected-label-singular="zaznaczono"
data-selected-label-plural="zaznaczono">
<?php foreach ($statusGroupOptions as $groupOption): ?>
<?php
$groupId = (int) ($groupOption['id'] ?? 0);
$groupName = (string) ($groupOption['name'] ?? '');
if ($groupId <= 0) {
continue;
}
?>
<option value="<?= $e((string) $groupId) ?>"<?= in_array($groupId, $selectedStatusGroups, true) ? ' selected' : '' ?>>
<?= $e($groupName) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<div class="form-field statistics-orders-filters__actions">
<span class="field-label">&nbsp;</span>
<div class="filters-actions">
<button type="submit" class="btn btn--primary"><?= $e($t('statistics.summary.actions.apply_filters')) ?></button>
<a href="/statistics/summary" class="btn btn--secondary"><?= $e($t('statistics.summary.actions.reset_filters')) ?></a>
</div>
</div>
</form>
</section>
<section class="statistics-summary-section mt-16">
<?php if (!$hasData): ?>
<div class="card statistics-summary-card">
<p class="muted"><?= $e($t('statistics.summary.empty')) ?></p>
</div>
<?php else: ?>
<div class="statistics-summary-chart-grid">
<article class="card statistics-summary-card">
<div class="statistics-summary-card__head">
<h3 class="section-title"><?= $e($t('statistics.summary.charts.orders_count')) ?></h3>
</div>
<div class="statistics-summary-chart" data-statistics-chart="count">
<canvas aria-label="<?= $e($t('statistics.summary.charts.orders_count')) ?>"></canvas>
</div>
</article>
<article class="card statistics-summary-card">
<div class="statistics-summary-card__head">
<h3 class="section-title"><?= $e($t('statistics.summary.charts.orders_value')) ?></h3>
</div>
<div class="statistics-summary-chart" data-statistics-chart="value">
<canvas aria-label="<?= $e($t('statistics.summary.charts.orders_value')) ?>"></canvas>
</div>
</article>
</div>
<div class="statistics-summary-table-grid mt-16">
<article class="card statistics-summary-card">
<div class="statistics-summary-card__head">
<h3 class="section-title"><?= $e($t('statistics.summary.charts.orders_count')) ?></h3>
</div>
<div class="table-wrap statistics-summary-fallback">
<table class="table statistics-summary-table">
<thead>
<tr>
<th><?= $e($t('statistics.summary.columns.month')) ?></th>
<?php foreach ($countChart['series'] ?? [] as $series): ?>
<th><?= $e((string) ($series['label'] ?? '')) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $rowIndex => $row): ?>
<tr>
<td><?= $e($displayMonth((string) ($row['month'] ?? ''))) ?></td>
<?php foreach ($countChart['series'] ?? [] as $series): ?>
<?php $values = is_array($series['values'] ?? null) ? $series['values'] : []; ?>
<td><?= $e((string) ((int) ($values[$rowIndex] ?? 0))) ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</article>
<article class="card statistics-summary-card">
<div class="statistics-summary-card__head">
<h3 class="section-title"><?= $e($t('statistics.summary.charts.orders_value')) ?></h3>
</div>
<div class="table-wrap statistics-summary-fallback">
<table class="table statistics-summary-table">
<thead>
<tr>
<th><?= $e($t('statistics.summary.columns.month')) ?></th>
<?php foreach ($valueChart['series'] ?? [] as $series): ?>
<th><?= $e((string) ($series['label'] ?? '')) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $rowIndex => $row): ?>
<tr>
<td><?= $e($displayMonth((string) ($row['month'] ?? ''))) ?></td>
<?php foreach ($valueChart['series'] ?? [] as $series): ?>
<?php $values = is_array($series['values'] ?? null) ? $series['values'] : []; ?>
<td><?= $e(number_format((float) ($values[$rowIndex] ?? 0), 2, '.', ' ')) ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</article>
</div>
<?php endif; ?>
</section>
<script type="application/json" id="js-statistics-summary-data"><?= $chartJson ?></script>

View File

@@ -442,6 +442,7 @@ return static function (Application $app): void {
$router->get('/users', static fn (Request $request): Response => Response::redirect('/settings/users'), [$authMiddleware]); $router->get('/users', static fn (Request $request): Response => Response::redirect('/settings/users'), [$authMiddleware]);
$router->get('/orders', static fn (Request $request): Response => Response::redirect('/orders/list'), [$authMiddleware]); $router->get('/orders', static fn (Request $request): Response => Response::redirect('/orders/list'), [$authMiddleware]);
$router->get('/orders/list', [$ordersController, 'index'], [$authMiddleware]); $router->get('/orders/list', [$ordersController, 'index'], [$authMiddleware]);
$router->get('/statistics/summary', [$ordersStatisticsController, 'summary'], [$authMiddleware]);
$router->get('/statistics/orders', [$ordersStatisticsController, 'index'], [$authMiddleware]); $router->get('/statistics/orders', [$ordersStatisticsController, 'index'], [$authMiddleware]);
$router->get('/orders/{id}', [$ordersController, 'show'], [$authMiddleware]); $router->get('/orders/{id}', [$ordersController, 'show'], [$authMiddleware]);
$router->post('/orders/{id}/status', [$ordersController, 'updateStatus'], [$authMiddleware]); $router->post('/orders/{id}/status', [$ordersController, 'updateStatus'], [$authMiddleware]);

View File

@@ -70,6 +70,41 @@ final class OrdersStatisticsController
return Response::html($html); return Response::html($html);
} }
public function summary(Request $request): Response
{
[$dateFrom, $dateTo] = $this->resolveSummaryDateRange($request);
$statusGroups = $this->repository->listStatusGroups();
$statusGroupOptions = $this->mapStatusGroupOptions($statusGroups);
$selectedStatusGroups = $this->resolveSelectedStatusGroups($request, $statusGroupOptions);
$channelOptions = $this->mapChannelOptions($this->repository->listChannelOptions());
$selectedChannels = $this->resolveSelectedChannels($request, $channelOptions);
$statusCodes = $this->repository->statusCodesByGroupIds($selectedStatusGroups);
$aggregated = $this->repository->aggregateByMonth($dateFrom, $dateTo, $selectedChannels, $statusCodes);
$summary = $this->buildSummary($dateFrom, $dateTo, $selectedChannels, $channelOptions, $aggregated);
$html = $this->template->render('statistics/summary', [
'title' => $this->translator->get('statistics.summary.title'),
'activeMenu' => 'statistics',
'activeStatistics' => 'summary',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'filters' => [
'date_from' => $dateFrom,
'date_to' => $dateTo,
'selected_channels' => $selectedChannels,
'selected_status_groups' => $selectedStatusGroups,
],
'channelOptions' => $channelOptions,
'statusGroupOptions' => $statusGroupOptions,
'summary' => $summary,
], 'layouts/app');
return Response::html($html);
}
/** /**
* @return array{0:string,1:string} * @return array{0:string,1:string}
*/ */
@@ -95,6 +130,31 @@ final class OrdersStatisticsController
return [$dateFrom, $dateTo]; return [$dateFrom, $dateTo];
} }
/**
* @return array{0:string,1:string}
*/
private function resolveSummaryDateRange(Request $request): array
{
$now = new DateTimeImmutable('now');
$defaultFrom = '2026-04-01';
$defaultTo = $now->format('Y-m-d');
$dateFrom = trim((string) $request->input('date_from', $defaultFrom));
$dateTo = trim((string) $request->input('date_to', $defaultTo));
if (!$this->isValidDate($dateFrom)) {
$dateFrom = $defaultFrom;
}
if (!$this->isValidDate($dateTo)) {
$dateTo = $defaultTo;
}
if ($dateFrom > $dateTo) {
[$dateFrom, $dateTo] = [$dateTo, $dateFrom];
}
return [$dateFrom, $dateTo];
}
private function isValidDate(string $date): bool private function isValidDate(string $date): bool
{ {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) { if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
@@ -298,6 +358,200 @@ final class OrdersStatisticsController
]; ];
} }
/**
* @param array<int, string> $selectedChannels
* @param array<int, array{key:string,label:string}> $channelOptions
* @param array<int, array{month:string,channel_key:string,orders_count:int,total_gross:float}> $aggregated
* @return array<string, mixed>
*/
private function buildSummary(
string $dateFrom,
string $dateTo,
array $selectedChannels,
array $channelOptions,
array $aggregated
): array {
$channelLabels = $this->channelLabels($channelOptions);
$rows = $this->emptySummaryRows($dateFrom, $dateTo, $selectedChannels);
foreach ($aggregated as $item) {
$month = (string) ($item['month'] ?? '');
$channelKey = (string) ($item['channel_key'] ?? '');
if ($month === '' || $channelKey === '' || !isset($rows[$month]['channels'][$channelKey])) {
continue;
}
$ordersCount = (int) ($item['orders_count'] ?? 0);
$totalGross = (float) ($item['total_gross'] ?? 0);
$rows[$month]['channels'][$channelKey] = [
'orders_count' => $ordersCount,
'total_gross' => $totalGross,
];
$rows[$month]['total_orders_count'] += $ordersCount;
$rows[$month]['total_gross'] += $totalGross;
}
return $this->summaryPayload($rows, $selectedChannels, $channelLabels);
}
/**
* @param array<int, array{key:string,label:string}> $channelOptions
* @return array<string, string>
*/
private function channelLabels(array $channelOptions): array
{
$labels = [];
foreach ($channelOptions as $option) {
$key = (string) ($option['key'] ?? '');
if ($key !== '') {
$labels[$key] = (string) ($option['label'] ?? $key);
}
}
return $labels;
}
/**
* @param array<int, string> $selectedChannels
* @return array<string, array{month:string,channels:array<string,array{orders_count:int,total_gross:float}>,total_orders_count:int,total_gross:float}>
*/
private function emptySummaryRows(string $dateFrom, string $dateTo, array $selectedChannels): array
{
$rows = [];
foreach ($this->monthRange($dateFrom, $dateTo) as $month) {
$rows[$month] = [
'month' => $month,
'channels' => $this->emptySummaryChannels($selectedChannels),
'total_orders_count' => 0,
'total_gross' => 0.0,
];
}
return $rows;
}
/**
* @param array<int, string> $selectedChannels
* @return array<string, array{orders_count:int,total_gross:float}>
*/
private function emptySummaryChannels(array $selectedChannels): array
{
$channels = [];
foreach ($selectedChannels as $channelKey) {
$channels[$channelKey] = [
'orders_count' => 0,
'total_gross' => 0.0,
];
}
return $channels;
}
/**
* @param array<string, array{month:string,channels:array<string,array{orders_count:int,total_gross:float}>,total_orders_count:int,total_gross:float}> $rows
* @param array<int, string> $selectedChannels
* @param array<string, string> $channelLabels
* @return array<string, mixed>
*/
private function summaryPayload(array $rows, array $selectedChannels, array $channelLabels): array
{
$months = array_keys($rows);
$labels = array_map(fn (string $month): string => $this->displayMonth($month), $months);
$countSeries = $this->summarySeries($rows, $selectedChannels, $channelLabels, 'orders_count');
$valueSeries = $this->summarySeries($rows, $selectedChannels, $channelLabels, 'total_gross');
$countSeries[] = $this->totalSeries($rows, 'Razem', 'total_orders_count');
$valueSeries[] = $this->totalSeries($rows, 'Razem', 'total_gross');
return [
'months' => $months,
'rows' => array_values($rows),
'hasData' => $this->summaryHasData($rows),
'countChart' => [
'labels' => $labels,
'series' => $countSeries,
'valueType' => 'number',
],
'valueChart' => [
'labels' => $labels,
'series' => $valueSeries,
'valueType' => 'money',
],
];
}
private function displayMonth(string $month): string
{
$date = DateTimeImmutable::createFromFormat('Y-m-d', $month . '-01');
if (!$date instanceof DateTimeImmutable) {
return $month;
}
return $date->format('m-Y');
}
/**
* @param array<string, array{channels:array<string,array{orders_count:int,total_gross:float}>}> $rows
* @param array<int, string> $selectedChannels
* @param array<string, string> $channelLabels
* @return array<int, array{key:string,label:string,values:array<int,int|float>}>
*/
private function summarySeries(array $rows, array $selectedChannels, array $channelLabels, string $metric): array
{
$series = [];
foreach ($selectedChannels as $channelKey) {
$values = [];
foreach ($rows as $row) {
$channelStats = $row['channels'][$channelKey] ?? [];
$values[] = $metric === 'orders_count'
? (int) ($channelStats[$metric] ?? 0)
: (float) ($channelStats[$metric] ?? 0);
}
$series[] = [
'key' => $channelKey,
'label' => $channelLabels[$channelKey] ?? $channelKey,
'values' => $values,
];
}
return $series;
}
/**
* @param array<string, array<string, mixed>> $rows
* @return array{key:string,label:string,values:array<int,int|float>}
*/
private function totalSeries(array $rows, string $label, string $metric): array
{
$values = [];
foreach ($rows as $row) {
$values[] = $metric === 'total_orders_count'
? (int) ($row[$metric] ?? 0)
: (float) ($row[$metric] ?? 0);
}
return [
'key' => 'total',
'label' => $label,
'values' => $values,
];
}
/**
* @param array<string, array{total_orders_count:int}> $rows
*/
private function summaryHasData(array $rows): bool
{
foreach ($rows as $row) {
if ((int) ($row['total_orders_count'] ?? 0) > 0) {
return true;
}
}
return false;
}
/** /**
* @param array<int, string> $channelKeys * @param array<int, string> $channelKeys
* @return array<string, array{orders_count:int,total_net:float,total_gross:float}> * @return array<string, array{orders_count:int,total_net:float,total_gross:float}>
@@ -333,6 +587,23 @@ final class OrdersStatisticsController
return $dates; return $dates;
} }
/**
* @return array<int, string>
*/
private function monthRange(string $dateFrom, string $dateTo): array
{
$start = (new DateTimeImmutable($dateFrom))->modify('first day of this month');
$end = (new DateTimeImmutable($dateTo))->modify('first day of next month');
$period = new DatePeriod($start, new DateInterval('P1M'), $end);
$months = [];
foreach ($period as $date) {
$months[] = $date->format('Y-m');
}
return $months;
}
/** /**
* @return array<int, string> * @return array<int, string>
*/ */

View File

@@ -238,6 +238,95 @@ final class OrdersStatisticsRepository
return $result; return $result;
} }
/**
* @param array<int, string> $channels
* @param array<int, string> $statusCodes
* @return array<int, array{month:string,channel_key:string,orders_count:int,total_gross:float}>
*/
public function aggregateByMonth(string $dateFrom, string $dateTo, array $channels, array $statusCodes): array
{
$channels = array_values(array_unique(array_filter(
array_map(static fn (string $item): string => trim($item), $channels),
static fn (string $item): bool => $item !== ''
)));
$statusCodes = array_values(array_unique(array_filter(
array_map(static fn (string $item): string => strtolower(trim($item)), $statusCodes),
static fn (string $item): bool => $item !== ''
)));
if ($channels === []) {
return [];
}
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$effectiveDateSql = $this->effectiveDateSql('o');
$channelSql = $this->channelSql('o');
$grossAmountSql = $this->grossAmountSql('o');
$rawStatusSql = $this->rawStatusSql('o');
[$channelInSql, $channelParams] = $this->buildStringInClause('ch', $channels);
$params = array_merge($channelParams, [
'date_from' => $dateFrom . ' 00:00:00',
'date_to' => $dateTo . ' 23:59:59',
]);
$statusFilterSql = '';
if ($statusCodes !== []) {
[$statusInSql, $statusParams] = $this->buildStringInClause('st', $statusCodes);
$statusFilterSql = ' AND ' . $effectiveStatusSql . ' IN (' . $statusInSql . ')';
$params = array_merge($params, $statusParams);
}
$monthSql = 'DATE_FORMAT(' . $effectiveDateSql . ', "%Y-%m")';
$sql = 'SELECT
' . $monthSql . ' AS month,
' . $channelSql . ' AS channel_key,
COUNT(*) AS orders_count,
SUM(' . $grossAmountSql . ') AS total_gross
FROM orders o
LEFT JOIN allegro_order_status_mappings asm
ON o.source = "allegro"
AND LOWER(' . $rawStatusSql . ') = asm.allegro_status_code
WHERE LOWER(COALESCE(o.source, "")) IN ("allegro", "shoppro")
AND ' . $effectiveDateSql . ' IS NOT NULL
AND ' . $effectiveDateSql . ' >= :date_from
AND ' . $effectiveDateSql . ' <= :date_to
AND ' . $channelSql . ' IN (' . $channelInSql . ')
' . $statusFilterSql . '
GROUP BY ' . $monthSql . ', ' . $channelSql . '
ORDER BY ' . $monthSql . ' ASC';
try {
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$month = trim((string) ($row['month'] ?? ''));
$channelKey = trim((string) ($row['channel_key'] ?? ''));
if ($month === '' || $channelKey === '') {
continue;
}
$result[] = [
'month' => $month,
'channel_key' => $channelKey,
'orders_count' => (int) ($row['orders_count'] ?? 0),
'total_gross' => (float) ($row['total_gross'] ?? 0),
];
}
return $result;
}
/** /**
* @param array<int, string> $channels * @param array<int, string> $channels
* @param array<int, string> $statusCodes * @param array<int, string> $statusCodes