update
This commit is contained in:
@@ -12,9 +12,9 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
|
||||
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| Version | 3.0.0 |
|
||||
| Status | v3.0 Complete |
|
||||
| Last Updated | 2026-04-19 |
|
||||
| Version | 3.1.0-dev |
|
||||
| Status | v3.1 in progress (Phase 106 shipped) |
|
||||
| Last Updated | 2026-04-22 |
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -109,6 +109,7 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
|
||||
- [x] Apaczka Paczka w Weekend: checkbox "Dostawa w weekend (sobota)" dla uslug InPost paczkomatowych + mapowanie pole formularza weekend_delivery -> option[19] w API Apaczki — Phase 104
|
||||
- [x] Statystyki zamowien: widok `/statistics/orders` z filtrami (daty, kanaly multiselect, grupy statusow multiselect) i raportem dziennym per kanal (Allegro, shopPRO per integracja); hotfix collation MySQL + fallback netto 23% VAT — Phase 105
|
||||
- [x] Wersja mobilna — modul po module (v3.0) — shipped across phases 52–105
|
||||
- [x] Alert o kliencie z historia zwrotow: badge w liscie zamowien (kolumna buyer) + czerwony banner u gory szczegolow zamowienia; matching OR po email/phone/name; `<details>` z lista zwroconych zamowien — Phase 106
|
||||
|
||||
### Deferred
|
||||
|
||||
|
||||
@@ -6,15 +6,17 @@ orderPRO to narzedzie do wielokanalowego zarzadzania sprzedaza. Projekt przechod
|
||||
|
||||
## Current Milestone
|
||||
|
||||
**v3.0 Mobile Responsive** (v3.0.0)
|
||||
Status: Complete
|
||||
Completed: 2026-04-19
|
||||
**v3.1 Operational Enhancements** (v3.1.0)
|
||||
Status: In progress
|
||||
Started: 2026-04-22
|
||||
|
||||
| Phase | Name | Plans | Status |
|
||||
|-------|------|-------|--------|
|
||||
| 106 | Customer Return Alert | 1/1 | Complete |
|
||||
|
||||
## Next Milestone
|
||||
|
||||
Run `/paul:discuss-milestone` lub `/paul:milestone` aby zdefiniowac zakres nastepnego milestone.
|
||||
|
||||
Kandydaci w kolejce:
|
||||
Kandydaci w kolejce (po zamknieciu v3.1):
|
||||
- Mobile Orders List / Mobile Order Details / Mobile Settings
|
||||
- Zarzadzanie produktami
|
||||
- Zarzadzanie stanami magazynowymi
|
||||
|
||||
@@ -2,42 +2,49 @@
|
||||
|
||||
## Project Reference
|
||||
|
||||
See: .paul/PROJECT.md (updated 2026-04-19)
|
||||
See: .paul/PROJECT.md (updated 2026-04-22)
|
||||
|
||||
**Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami.
|
||||
**Current focus:** Awaiting next milestone (v3.0 Mobile Responsive complete 2026-04-19).
|
||||
**Current focus:** v3.1 Operational Enhancements — Phase 106 complete, ready for next phase definition.
|
||||
|
||||
## Current Position
|
||||
|
||||
Milestone: Awaiting next milestone
|
||||
Phase: None active
|
||||
Plan: None
|
||||
Version: 3.0.0
|
||||
Status: Milestone v3.0 Mobile Responsive complete — ready for next
|
||||
Last activity: 2026-04-19 — Milestone v3.0 closed (52 phases, 55 plans)
|
||||
Milestone: v3.1 Operational Enhancements
|
||||
Phase: 106 (Customer Return Alert) — Complete
|
||||
Plan: 106-01 complete (SUMMARY created)
|
||||
Version: 3.1.0 (in progress)
|
||||
Status: Loop complete, ready for next PLAN or milestone decision
|
||||
Last activity: 2026-04-22 — UNIFY closed Phase 106 (SUMMARY + changelog + DOCS)
|
||||
|
||||
Progress:
|
||||
- v3.0 Mobile Responsive: [##########] 100% shipped
|
||||
- Next milestone: [__________] 0%
|
||||
- v3.1 Operational Enhancements: [##________] ~15% (1 phase complete, scope TBD)
|
||||
- Phase 106: [##########] 100%
|
||||
|
||||
## Loop Position
|
||||
|
||||
Current loop state:
|
||||
```
|
||||
PLAN --> APPLY --> UNIFY
|
||||
o o o [Milestone complete — ready for next]
|
||||
v v v [Loop complete — ready for next PLAN or milestone]
|
||||
```
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-04-19 (complete-milestone v3.0)
|
||||
Stopped at: Milestone v3.0 Mobile Responsive zamkniety, archiwum `.paul/milestones/v3.0-ROADMAP.md`
|
||||
Next action: `/paul:discuss-milestone` lub `/paul:milestone` — zdefiniowanie nastepnego milestone
|
||||
Resume file: .paul/MILESTONES.md
|
||||
Last session: 2026-04-22 — /paul:unify for plan 106-01
|
||||
Stopped at: Phase 106 Complete — SUMMARY + changelog created
|
||||
Next action: `/paul:plan` dla nastepnej fazy v3.1, lub `/paul:discuss-milestone` aby rozszerzyc zakres milestone
|
||||
Resume file: .paul/phases/106-customer-return-alert/106-01-SUMMARY.md
|
||||
|
||||
## Deferred to Next Milestones
|
||||
|
||||
- Phase 68 — Code Deduplication Refactor (0/2 Planning, nigdy nie rozpoczety)
|
||||
- STAT-NET — netto shopPRO z API lub z `order_items.tax_rate` (`.paul/TODO.md`)
|
||||
- Mobile Orders List / Mobile Order Details / Mobile Settings (TBD z poprzedniego roadmapu)
|
||||
- sonar-scanner — skan dla phase 105 nie zostal uruchomiony w sesji UNIFY (gap odnotowany)
|
||||
- sonar-scanner — skan dla phase 105 i phase 106 nie zostal uruchomiony w sesji UNIFY (skill gap odnotowany)
|
||||
- INDEX-106-01 — indeksy DB dla query `customer_returned_count`: `order_addresses(order_id, address_type)`, `shipment_packages(order_id, delivery_status)` (gdy dataset >50k wierszy)
|
||||
|
||||
## Skill Audit (Phase 106)
|
||||
|
||||
| Expected | Invoked | Notes |
|
||||
|----------|---------|-------|
|
||||
| sonar-scanner (required) | ○ | Nie uruchomiony — odlozony analogicznie do Phase 105 |
|
||||
|
||||
28
.paul/changelog/2026-04-22.md
Normal file
28
.paul/changelog/2026-04-22.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 2026-04-22
|
||||
|
||||
## Co zrobiono
|
||||
|
||||
- [Phase 106, Plan 01] Customer Return Shipment Alert — widoczny alert o kliencie z historia zwrotow przesylek na liscie zamowien i w szczegolach
|
||||
- Task 1: Backend query — correlated subquery `customer_returned_count` w `OrdersRepository` (buildListSql + findDetails), nowa metoda `ShipmentPackageRepository::findReturnedByCustomer` z matchingiem OR (email lower+trim / phone tylko cyfry >=6 / name lower+trim) i self-exclusion
|
||||
- Task 2: View list — badge `zwroty: N` w kolumnie buyer (przy imieniu klienta) + klasa `is-risk-return` na `<tr>` w kompozycji z aged-rows (Phase 101)
|
||||
- Task 3: View detail + SCSS + DOCS — czerwony banner `customer-risk-banner` u samej gory karty szczegolow zamowienia (po feedback'u uzytkownika przeniesiony z sekcji adresow), `<details>` z tabela zwroconych zamowien, nowy modul SCSS, rebuild CSS, update `.paul/docs/ARCHITECTURE.md` i `.paul/docs/TECH_CHANGELOG.md`
|
||||
- Deviation: banner przeniesiony mid-apply zgodnie z instrukcja uzytkownika ("na samej gorze prawie")
|
||||
- Deferred: indeksy DB `order_addresses(order_id, address_type)` i `shipment_packages(order_id, delivery_status)` dla perf na duzych datasetach
|
||||
- Skill gap: sonar-scanner nie uruchomiony (analogicznie do Phase 105)
|
||||
- Wymaganie: MySQL 8.0+ (REGEXP_REPLACE)
|
||||
|
||||
## Zmienione pliki
|
||||
|
||||
- `src/Modules/Orders/OrdersRepository.php`
|
||||
- `src/Modules/Orders/OrdersController.php`
|
||||
- `src/Modules/Shipments/ShipmentPackageRepository.php`
|
||||
- `resources/views/orders/show.php`
|
||||
- `resources/scss/app.scss`
|
||||
- `resources/scss/modules/_customer-risk-alert.scss`
|
||||
- `public/assets/css/app.css`
|
||||
- `.paul/docs/ARCHITECTURE.md`
|
||||
- `.paul/docs/TECH_CHANGELOG.md`
|
||||
- `.paul/STATE.md`
|
||||
- `.paul/ROADMAP.md`
|
||||
- `.paul/phases/106-customer-return-alert/106-01-PLAN.md`
|
||||
- `.paul/phases/106-customer-return-alert/106-01-SUMMARY.md`
|
||||
@@ -29,9 +29,10 @@
|
||||
- `App\Core\Application`: bootstrap, dispatch requestu, opcjonalny web-cron z lockiem DB.
|
||||
- `App\Modules\Cron\CronRunner`: pobiera kolejke jobow z `cron_schedules/cron_jobs` i wykonuje handlery.
|
||||
- `App\Modules\Cron\CronHandlerFactory`: sklada zaleznosci i mapuje `job_type -> handler`.
|
||||
- `App\Modules\Orders\OrdersController`: flow UI zamowien + endpointy AJAX.
|
||||
- `App\Modules\Orders\OrdersRepository`: query listy/szczegolow zamowien, update statusow, activity log.
|
||||
- `App\Modules\Orders\OrdersController`: flow UI zamowien + endpointy AJAX; `buildCustomerRiskInfo/composeCustomerRiskText` skladaja alert klienta ze zwrotami do widoku `orders/show`.
|
||||
- `App\Modules\Orders\OrdersRepository`: query listy/szczegolow zamowien, update statusow, activity log; `customerReturnedCountSubquerySql` generuje correlated subquery dopasowujaca historyczne zamowienia klienta po email/phone/name z paczka `returned` (self-exclusion).
|
||||
- `App\Modules\Shipments\ShipmentController`: flow przesylek i etykiet.
|
||||
- `App\Modules\Shipments\ShipmentPackageRepository`: CRUD paczek + `findReturnedByCustomer(customer, excludeOrderId)` zwraca liste zwroconych paczek klienta dla bannera ryzyka w szczegolach zamowienia.
|
||||
- `App\Modules\Shipments\ShipmentProviderRegistry`: wybor providera wysylki po `provider_code`.
|
||||
- `App\Modules\Printing\PrintApiController`: endpointy kolejki wydruku (session/api-key).
|
||||
- `App\Modules\Automation\AutomationService`: trigger eventow, ewaluacja warunkow, wykonanie akcji.
|
||||
@@ -85,6 +86,16 @@
|
||||
- wykonuje akcje (`send_email`, `issue_receipt`, `update_shipment_status`, `update_order_status`),
|
||||
- zapisuje wynik do `automation_execution_logs`.
|
||||
|
||||
## Shipment tracking — mapowanie statusow kuriera
|
||||
- `ShipmentTrackingHandler` (job `shipment_tracking_sync`) iteruje po aktywnych paczkach i pobiera status z API przewoznika (`Inpost/Apaczka/AllegroTrackingService`).
|
||||
- Serwisy zwracaja `{status, status_raw, description}`, gdzie `status` pochodzi z `DeliveryStatus::normalize($provider, $raw)` (hardcoded PROVIDER_MAPS).
|
||||
- Handler na starcie wczytuje overrides z `delivery_status_mappings` (raz per uruchomienie) i po kazdym wyniku stosuje `DeliveryStatus::normalizeWithOverrides()` — pozwala to przypisac nowe raw statusy kuriera przez UI bez zmian w kodzie.
|
||||
- Strona `/settings/delivery-status-mappings`:
|
||||
- Pokazuje defaulty + overrides + sekcje „Niezmapowane statusy wykryte w systemie" (distinct raw statusy z `shipment_packages` nie wystepujace w defaultach ani overrides).
|
||||
- Bulk form zapisuje do `delivery_status_mappings` (override = normalized inny niz default LUB raw nie ma defaultu).
|
||||
- Badge w sidebar pokazuje sumaryczna liczbe niezmapowanych statusow (`DeliveryStatusMappingRepository::countAllUnmappedForBadge`).
|
||||
- Przy zmianie normalized status (previous != new) handler wywoluje `automation.trigger('shipment.status_changed', ...)`.
|
||||
|
||||
## Printing
|
||||
- Panel tworzy job `print_jobs` przez `/api/print/jobs`.
|
||||
- Klient desktop pobiera pending joby przez API key.
|
||||
|
||||
@@ -2,6 +2,65 @@
|
||||
|
||||
> Chronologiczny log zmian technicznych — co i dlaczego.
|
||||
|
||||
## 2026-04-22 - Alert klienta z historia zwrotow (Phase 106)
|
||||
|
||||
**Powod**: Operator wysylkowy nie widzial wczesniej, ze kupujacy juz raz nie odebral przesylki (`delivery_status='returned'`) zanim wyslal kolejna paczke — generowalo to kolejne koszty wysylki i magazynowania.
|
||||
|
||||
**Zmiany**:
|
||||
- `src/Modules/Orders/OrdersRepository.php`:
|
||||
- nowa metoda prywatna `customerReturnedCountSubquerySql(orderAlias, addressAlias)` — generuje correlated subquery zliczajaca inne zamowienia klienta biezacego wiersza z paczka `returned`.
|
||||
- `buildListSql()` — dodana kolumna `customer_returned_count` (EXISTS-style COUNT DISTINCT) do SELECT listy zamowien.
|
||||
- `transformOrderRow()` — przekazuje `customer_returned_count` do wiersza.
|
||||
- `findDetails()` — JOIN `order_addresses` typu customer + subquery `customer_returned_count`; zwraca rowniez `buyer_email`, `buyer_phone`, `buyer_name` w `$order`.
|
||||
- `src/Modules/Shipments/ShipmentPackageRepository.php`:
|
||||
- nowa metoda `findReturnedByCustomer(array customer, int excludeOrderId, int limit=10)` — lista zwroconych paczek klienta (match OR: email lower+trim, phone tylko cyfry >=6, name lower+trim), sortowana po dacie malejaco.
|
||||
- `src/Modules/Orders/OrdersController.php`:
|
||||
- `toTableRow()` — dodano badge `zwroty: N` w kolumnie buyer + klasa `is-risk-return` na `<tr>` gdy `customer_returned_count >= 1` (kompozycja z klasa aged orders z Phase 101).
|
||||
- `show()` — oblicza `$customerRiskInfo` i przekazuje do widoku.
|
||||
- nowe metody prywatne: `buildCustomerRiskInfo(order, orderId)`, `composeCustomerRiskText(count, email, phone, name)` — budowa tresci alertu zaleznie od dostepnosci pol (phone+email / email / phone / name).
|
||||
- `resources/views/orders/show.php` — banner `customer-risk-banner` u samej gory karty szczegolow (pod naglowkiem, nad flash messages i status change), z `<details>` rozwijajacym liste zamowien ze zwrotem (order_id, data, tracking, provider).
|
||||
- `resources/scss/modules/_customer-risk-alert.scss` (nowy modul):
|
||||
- `.customer-risk-banner` + `__icon`, `__body`, `__text`, `__list`, `__table` — czerwony banner z pastelowym tlem i lewym paskiem 4px.
|
||||
- `.risk-return-badge` — maly inline badge przy buyer name.
|
||||
- `tr.is-risk-return` — lewy pasek wiersza w tabeli zamowien.
|
||||
- `resources/scss/app.scss` — `@use "modules/customer-risk-alert"`.
|
||||
- `public/assets/css/app.css` — rebuild przez `npm run build:css`.
|
||||
|
||||
**Wymagania**:
|
||||
- MySQL 8.0+ (REGEXP_REPLACE w subquery matching phone).
|
||||
- Wynik licznika wyliczany on-the-fly (brak migracji DB, brak materializacji). Indeksy na `order_addresses(order_id, address_type)` i `shipment_packages(order_id, delivery_status)` sugerowane jesli lista zamowien przekroczy ~50k wierszy — zglosic w kolejnym planie.
|
||||
|
||||
**Anti-fraud/false positive**:
|
||||
- Self-exclusion: `sp.order_id != o.id` — biezace zamowienie nie wlicza sie do licznika.
|
||||
- Minimum phone length 6 cyfr — eliminuje match na "", "+48", krotkich fragmentach.
|
||||
- OR matching po email/phone/name moze dac fałszywe pozytywy dla popularnych imion; swiadome odstepstwo (user wymagal szerokiego matchingu).
|
||||
|
||||
## 2026-04-22 - Mapowanie statusow dostawy: wykrywanie niezmapowanych + runtime overrides
|
||||
|
||||
**Powod**: zamowienia (np. OP000000357, OP000000638) pokazywaly `delivery_status=unknown`, mimo ze apaczka API zwracala `RETURNED_TO_SHIPPER`. UI `/settings/delivery-status-mappings` pokazywalo wylacznie statusy obecne w defaultach kodu — nowe raw statusy kuriera nie mialy gdzie zostac przypisane bez zmiany kodu.
|
||||
|
||||
**Faza A — quick fix (defaulty + backfill)**:
|
||||
- `src/Modules/Shipments/DeliveryStatus.php`: dodano 3 brakujace mapowania:
|
||||
- apaczka `RETURNED_TO_SHIPPER` -> `returned`
|
||||
- apaczka `PICKUP` -> `in_transit`
|
||||
- allegro_wza `collected_from_sender` -> `in_transit`
|
||||
- `database/migrations/20260422_000101_backfill_delivery_status_unknowns.sql`: backfill 11 paczek (3 + 7 + 1). Bez trigger `shipment.status_changed` (backfill starych rekordow, nie runtime event).
|
||||
|
||||
**Faza B — rozwiazanie systemowe**:
|
||||
- `DeliveryStatusMappingRepository::listUnmappedRawStatuses(provider, knownKeys)` — zwraca raw statusy z `shipment_packages` ktore nie wystepuja w defaultach ani overrides DB; z licznikiem paczek i ostatnim wystapieniem.
|
||||
- `DeliveryStatusMappingController::index` — przekazuje `unmappedRawStatuses` do widoku; rowniez wlacza do listy overrides, ktore nie maja odpowiadajacego defaultu (user moze dodac completely custom raw statusy).
|
||||
- `resources/views/settings/delivery-status-mappings.php` — nowa sekcja „Niezmapowane statusy wykryte w systemie (N)" z form submit do `save-bulk`. Pomaranczowy akcent aby wyroznic od defaultow.
|
||||
- `ShipmentTrackingHandler` — dostal `?DeliveryStatusMappingRepository` w konstruktorze; po kazdym `service->getDeliveryStatus()` wywoluje `DeliveryStatus::normalizeWithOverrides(provider, raw, overrides)` jesli overrides istnieja. Dzieki temu override z UI dziala runtime bez zmian kodu.
|
||||
- `CronHandlerFactory` — przekazuje `DeliveryStatusMappingRepository` do `ShipmentTrackingHandler`.
|
||||
|
||||
**Faza C — badge w menu**:
|
||||
- `Application` — dodano statyczny holder `self::$instance` + `Application::instance()`, aby layout mial dostep do kontenera.
|
||||
- `DeliveryStatusMappingRepository::countAllUnmappedForBadge()` — zlicza niezmapowane raw statusy dla wszystkich providerow UI (inpost, apaczka, allegro_wza); cache per-request.
|
||||
- `resources/views/layouts/app.php` — badge pomaranczowy przy linku „Mapowanie statusow dostawy" z liczba niezmapowanych statusow (jesli > 0). Try/catch — brak badge'a nie psuje layoutu.
|
||||
- `resources/scss/app.scss` — klasa `.sidebar__badge`.
|
||||
|
||||
**Efekt**: user nie musi modyfikowac kodu przy kazdym nowym statusie kuriera. Badge sygnalizuje pojawienie sie nieznanych statusow; sekcja na stronie mapowan pozwala przypisac je do znormalizowanych kategorii. Cron po nastepnym tick'u automatycznie przeliczy istniejace paczki zgodnie z override.
|
||||
|
||||
## 2026-04-19 - Statystyki zamowien (menu + raport dzienny)
|
||||
|
||||
- Dodano nowy modul `Statistics`:
|
||||
|
||||
@@ -85,3 +85,59 @@
|
||||
{"ts":"2026-04-22T13:09:27Z","tool":"Bash","cmd":"cd \"/c/visual studio code/projekty/orderPRO\" && PYTHONUTF8=1 python tools/generowanie/magnes_wieczor_panienski_kieliszki.py --help 2>&1 | head -25; echo \"---MAILS---\"; PYTHONUTF8=1 python tools/ge","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T13:09:37Z","tool":"Bash","cmd":"cd \"/c/visual studio code/projekty/orderPRO\" && PYTHONUTF8=1 python tools/generowanie/magnes_wieczor_panienski_kieliszki.py --imie \"Marzeny\" --data \"16.05.2026\" --klient \"Marzena Puch\" 2>&1 | ","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T13:19:00Z","tool":"Bash","cmd":"\"C:/xampp/mysql/bin/mysql.exe\" -h host700513.hostido.net.pl -u host700513_orderpro -phrDNtUBg9grwZ7syN77S host700513_orderpro --default-character-set=utf8mb4 -e \"UPDATE order_items SET project_gene","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:11:29Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Shipments\\\\DeliveryStatus.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:11:37Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Shipments\\\\DeliveryStatus.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:11:46Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Shipments\\\\DeliveryStatus.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:12:07Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\database\\\\migrations\\\\20260422_000101_backfill_delivery_status_unknowns.sql","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:12:53Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Shipments\\\\DeliveryStatusMappingRepository.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:13:19Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Settings\\\\DeliveryStatusMappingController.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:13:45Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\delivery-status-mappings.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:14:00Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\delivery-status-mappings.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:14:19Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\scss\\\\modules\\\\_delivery-status-mappings.scss","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:15:19Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Cron\\\\CronHandlerFactory.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:15:27Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Cron\\\\CronHandlerFactory.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:15:35Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Cron\\\\ShipmentTrackingHandler.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:15:41Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Cron\\\\ShipmentTrackingHandler.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:15:51Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Cron\\\\ShipmentTrackingHandler.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:17:27Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Core\\\\Application.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:17:31Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Core\\\\Application.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:17:47Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Shipments\\\\DeliveryStatusMappingRepository.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:18:01Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\layouts\\\\app.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:18:22Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\scss\\\\app.scss","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:19:26Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\docs\\\\TECH_CHANGELOG.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:19:46Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\docs\\\\ARCHITECTURE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:29:49Z","tool":"Bash","cmd":"mkdir -p \".paul/phases/106-customer-return-alert\"\",\"description\":\"Create phase directory\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\":false,\"isImage\":false,\"noOutputE","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:31:51Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\phases\\\\106-customer-return-alert\\\\106-01-PLAN.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:32:10Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:32:18Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:33:48Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Orders\\\\OrdersRepository.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:33:54Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Orders\\\\OrdersRepository.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:34:08Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Orders\\\\OrdersRepository.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:34:30Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Orders\\\\OrdersRepository.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:34:48Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Shipments\\\\ShipmentPackageRepository.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:34:53Z","tool":"Bash","cmd":"php -l \"src/Modules/Orders/OrdersRepository.php\" && php -l \"src/Modules/Shipments/ShipmentPackageRepository.php\"\",\"description\":\"Lint both modified PHP files\"},\"tool_response\":{\"stdout\":\","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:35:17Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Orders\\\\OrdersController.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:35:24Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Orders\\\\OrdersController.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:35:29Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Orders\\\\OrdersController.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:35:38Z","tool":"Bash","cmd":"php -l \"src/Modules/Orders/OrdersController.php\"\",\"description\":\"Lint OrdersController.php\"},\"tool_response\":{\"stdout\":\"No syntax errors detected in src/Modules/Orders/OrdersController.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:36:08Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Orders\\\\OrdersController.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:36:20Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Orders\\\\OrdersController.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:36:35Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\orders\\\\show.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:37:00Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\scss\\\\modules\\\\_customer-risk-alert.scss","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:37:07Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\scss\\\\app.scss","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:37:23Z","tool":"Bash","cmd":"npm run build:css 2>&1 | tail -30\",\"description\":\"Build SCSS to CSS\"},\"tool_response\":{\"stdout\":\"> orderpro@1.0.0 build:css\\n> sass --style=compressed --no-source-map resources/scss/app.scs","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:38:17Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\orders\\\\show.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:38:31Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\orders\\\\show.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:39:07Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\docs\\\\TECH_CHANGELOG.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:39:19Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\docs\\\\ARCHITECTURE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:39:26Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\docs\\\\ARCHITECTURE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:39:31Z","tool":"Bash","cmd":"php -l \"src/Modules/Orders/OrdersController.php\" && php -l \"src/Modules/Orders/OrdersRepository.php\" && php -l \"src/Modules/Shipments/ShipmentPackageRepository.php\" && php -l \"resources/views/o","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:39:51Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:43:29Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\phases\\\\106-customer-return-alert\\\\106-01-SUMMARY.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:43:46Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-04-22.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:44:05Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:44:10Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:44:20Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\PROJECT.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:44:25Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\PROJECT.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:46:50Z","tool":"Bash","cmd":"eval $(awk '/^[A-Z_][A-Z0-9_]*=/ {print \"export \"$0}' .env); MYSQL_PWD=\"$DB_PASSWORD\" /c/xampp/mysql/bin/mysql.exe -h \"$DB_HOST_REMOTE\" -P \"$DB_PORT\" -u \"$DB_USERNAME\" \"$DB_DATABASE\" --def","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-04-22T20:47:05Z","tool":"Bash","cmd":"eval $(awk '/^[A-Z_][A-Z0-9_]*=/ {print \"export \"$0}' .env); MYSQL_PWD=\"$DB_PASSWORD\" /c/xampp/mysql/bin/mysql.exe -h \"$DB_HOST_REMOTE\" -P \"$DB_PORT\" -u \"$DB_USERNAME\" \"$DB_DATABASE\" --def","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
|
||||
396
.paul/phases/106-customer-return-alert/106-01-PLAN.md
Normal file
396
.paul/phases/106-customer-return-alert/106-01-PLAN.md
Normal file
@@ -0,0 +1,396 @@
|
||||
---
|
||||
phase: 106-customer-return-alert
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/Modules/Orders/OrdersRepository.php
|
||||
- src/Modules/Orders/OrdersController.php
|
||||
- src/Modules/Shipments/ShipmentPackageRepository.php
|
||||
- resources/views/orders/list.php
|
||||
- resources/views/orders/show.php
|
||||
- resources/views/components/table-list.php
|
||||
- resources/scss/modules/_customer-risk-alert.scss
|
||||
- resources/scss/app.scss
|
||||
- public/assets/css/app.css
|
||||
- DOCS/ARCHITECTURE.md
|
||||
- DOCS/TECH_CHANGELOG.md
|
||||
autonomous: true
|
||||
delegation: off
|
||||
---
|
||||
|
||||
<objective>
|
||||
## Goal
|
||||
Dodac widoczny alert "klient z historia zwrotow" w liscie zamowien (`/orders/list`) oraz w szczegolach zamowienia (`/orders/{id}`, sekcja klienta), dla kazdego zamowienia, ktorego kupujacy (dopasowany po email LUB phone LUB name) ma w historii co najmniej jedna inna paczke z `delivery_status='returned'`.
|
||||
|
||||
## Purpose
|
||||
Operator wysylkowy musi widziec wczesniej, ze kupujacy juz raz nie odebral przesylki (zwrot do nadawcy) zanim wysle kolejna paczke — zmniejsza to ryzyko kolejnych zwrotow i kosztow wysylki/magazynowania. Dzis ta informacja jest rozproszona po zamowieniach i niewidoczna w przeplywie.
|
||||
|
||||
## Output
|
||||
- Dodatkowe pole `customer_returned_count` (int) w wierszach listy zamowien
|
||||
- Badge w kolumnie statusu/flag oraz klasa `is-risk-return` na `<tr>`
|
||||
- Banner w sekcji klienta w `orders/show` z trescia: `Osoba o numerze telefonu {phone} oraz email {email} nie odebrala {N} przesylek.`
|
||||
- Tooltip/popover (hover) zawierajacy liste zwroconych zamowien (ID, data, nr przesylki, provider)
|
||||
- SCSS modul `_customer-risk-alert.scss` zbudowany do `public/assets/css/app.css`
|
||||
- Aktualizacja `DOCS/ARCHITECTURE.md` i `DOCS/TECH_CHANGELOG.md`
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
## Project Context
|
||||
@.paul/PROJECT.md
|
||||
@.paul/ROADMAP.md
|
||||
@.paul/STATE.md
|
||||
|
||||
## Source Files
|
||||
@src/Modules/Orders/OrdersRepository.php
|
||||
@src/Modules/Orders/OrdersController.php
|
||||
@src/Modules/Shipments/ShipmentPackageRepository.php
|
||||
@src/Modules/Shipments/DeliveryStatus.php
|
||||
@resources/views/orders/show.php
|
||||
@resources/views/orders/list.php
|
||||
@resources/views/components/table-list.php
|
||||
@DOCS/DB_SCHEMA.md
|
||||
@DOCS/ARCHITECTURE.md
|
||||
</context>
|
||||
|
||||
<acceptance_criteria>
|
||||
|
||||
## AC-1: Lista zamowien — wzbogacenie o licznik zwrotow
|
||||
```gherkin
|
||||
Given kupujacy X (email "a@b.pl") ma w historii 3 zamowienia, w tym 2 zamowienia z paczka "returned"
|
||||
And obecnie otwieramy /orders/list
|
||||
When serwer zwraca wiersze listy
|
||||
Then kazdy wiersz zamowienia klienta X (w tym zamowienia jeszcze bez wysylki) zawiera pole `customer_returned_count = 2`
|
||||
And liczba nie wlicza zwrotow z biezacego zamowienia do samego siebie (self-exclusion — `sp.order_id != o.id`)
|
||||
```
|
||||
|
||||
## AC-2: Lista zamowien — badge i klasa row
|
||||
```gherkin
|
||||
Given wiersz zamowienia z `customer_returned_count >= 1`
|
||||
When render listy
|
||||
Then `<tr>` otrzymuje klase `is-risk-return`
|
||||
And w kolumnie flag/statusu pojawia sie czerwony badge `zwroty: N` z tooltipem "Klient nie odebral N przesylek w historii"
|
||||
And kliknac/najechac na badge pokazuje czerwone podswietlenie zgodne z paleta aged orders (wzorzec Phase 101)
|
||||
```
|
||||
|
||||
## AC-3: Matching klienta (OR po trzech polach)
|
||||
```gherkin
|
||||
Given kupujacy biezacego zamowienia (`order_addresses` z `address_type='customer'`)
|
||||
When liczymy zwroty w historii
|
||||
Then dopasowujemy zamowienia historyczne gdzie:
|
||||
- LOWER(TRIM(email)) rowne (jesli email biezacy niepusty), LUB
|
||||
- phone_normalized (tylko cyfry, `REGEXP_REPLACE(phone, '[^0-9]+', '')`) rowne (jesli phone biezacy niepusty i >=6 cyfr), LUB
|
||||
- LOWER(TRIM(name)) rowne (jesli name biezacy niepusty)
|
||||
And co najmniej jedno z pol biezacych musi byc niepuste — inaczej licznik = 0 (brak dopasowania "wszyscy bez emaila")
|
||||
```
|
||||
|
||||
## AC-4: Szczegoly zamowienia — banner
|
||||
```gherkin
|
||||
Given otwieramy /orders/{id} klienta z historia zwrotow (liczba N >= 1)
|
||||
When render widoku show
|
||||
Then w sekcji klienta pojawia sie czerwony banner nad danymi klienta z tekstem:
|
||||
"Osoba o numerze telefonu {phone} oraz email {email} nie odebrala {N} przesylek."
|
||||
And jesli phone biezacego zamowienia pusty — podstawiamy tylko email (i odwrotnie); jesli oba puste — pokazujemy "Osoba o imieniu i nazwisku {name}..."
|
||||
And banner ma element "hover/focus info" z tooltipem zawierajacym tabele: order_id (link), ordered_at (data), tracking_number, provider
|
||||
```
|
||||
|
||||
## AC-5: Performance
|
||||
```gherkin
|
||||
Given lista zamowien zwraca 50 wierszy (domyslny per_page)
|
||||
When paginate() wykonuje zapytanie
|
||||
Then dodatkowy narzut EXISTS subquery/JOIN nie przekracza 200ms na typowym zbiorze (<=50k zamowien, <=50k paczek)
|
||||
And istnieja indeksy na `order_addresses(order_id, address_type)` i `shipment_packages(order_id, delivery_status)` — jezeli brakuje, tworzymy migracje indeksowa (task osobny, poza scope tego planu — zglosic w SUMMARY)
|
||||
```
|
||||
|
||||
## AC-6: Dokumentacja
|
||||
```gherkin
|
||||
Given plan zamkniety
|
||||
When sprawdzamy DOCS/
|
||||
Then `DOCS/ARCHITECTURE.md` zawiera opis nowej sciezki danych (repo -> controller -> view)
|
||||
And `DOCS/TECH_CHANGELOG.md` ma wpis z data i opisem zmian
|
||||
```
|
||||
|
||||
</acceptance_criteria>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Backend — licznik zwrotow klienta w query listy i szczegolach</name>
|
||||
<files>src/Modules/Orders/OrdersRepository.php, src/Modules/Shipments/ShipmentPackageRepository.php</files>
|
||||
<action>
|
||||
**1a. OrdersRepository::buildListSql() (lub paginate()) — dodac derived column `customer_returned_count`:**
|
||||
|
||||
Wzorzec: correlated subquery zliczajaca inne zamowienia klienta biezacego wiersza z paczka "returned".
|
||||
|
||||
Szkic SQL (Medoo, prepared params):
|
||||
```sql
|
||||
SELECT
|
||||
o.*,
|
||||
a.email AS buyer_email,
|
||||
a.phone AS buyer_phone,
|
||||
a.name AS buyer_name,
|
||||
a.city AS buyer_city,
|
||||
(
|
||||
SELECT COUNT(DISTINCT sp.order_id)
|
||||
FROM shipment_packages sp
|
||||
INNER JOIN order_addresses a2
|
||||
ON a2.order_id = sp.order_id AND a2.address_type = 'customer'
|
||||
WHERE sp.delivery_status = 'returned'
|
||||
AND sp.order_id != o.id
|
||||
AND (
|
||||
(a.email IS NOT NULL AND a.email <> ''
|
||||
AND LOWER(TRIM(a2.email)) = LOWER(TRIM(a.email)))
|
||||
OR
|
||||
(a.phone IS NOT NULL AND LENGTH(REGEXP_REPLACE(a.phone, '[^0-9]+', '')) >= 6
|
||||
AND REGEXP_REPLACE(a2.phone, '[^0-9]+', '') = REGEXP_REPLACE(a.phone, '[^0-9]+', ''))
|
||||
OR
|
||||
(a.name IS NOT NULL AND a.name <> ''
|
||||
AND LOWER(TRIM(a2.name)) = LOWER(TRIM(a.name)))
|
||||
)
|
||||
) AS customer_returned_count
|
||||
FROM orders o
|
||||
LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = 'customer'
|
||||
...
|
||||
```
|
||||
|
||||
Uwagi:
|
||||
- Medoo: jesli aktualny SQL budowany fluentnie, dopisujemy pole przez `$db->query()` lub `$db->select(... , ['customer_returned_count[JSON]' => ...])` zaleznie od obecnego pattern-u; jesli repo uzywa raw SQL — dodac sub-select bezposrednio.
|
||||
- NIE zmieniamy filtrow i sortowan istniejacych — tylko dodajemy kolumne wyliczana.
|
||||
- Unikac N+1: licznik wyliczany w tym samym select-cie co reszta kolumn.
|
||||
- Cast na int w PHP: `(int) ($row['customer_returned_count'] ?? 0)`.
|
||||
|
||||
**1b. OrdersRepository::findDetails() — dodac `customer_returned_count` + `customer_returned_orders`:**
|
||||
|
||||
- `customer_returned_count` — int (ta sama logika co w 1a, ale dla pojedynczego order_id)
|
||||
- `customer_returned_orders` — tablica maks 10 wpisow `{ order_id, ordered_at, tracking_number, provider, delivery_status_raw }` do tooltipa
|
||||
|
||||
Druga metoda w repo (ew. ShipmentPackageRepository): `findReturnedByCustomer(array $customer): array` gdzie $customer = ['email'=>..., 'phone'=>..., 'name'=>..., 'exclude_order_id'=>int].
|
||||
|
||||
Szkic:
|
||||
```sql
|
||||
SELECT sp.order_id, o.ordered_at, sp.tracking_number, sp.provider, sp.delivery_status_raw
|
||||
FROM shipment_packages sp
|
||||
INNER JOIN orders o ON o.id = sp.order_id
|
||||
INNER JOIN order_addresses a2 ON a2.order_id = sp.order_id AND a2.address_type='customer'
|
||||
WHERE sp.delivery_status = 'returned'
|
||||
AND sp.order_id != :exclude_id
|
||||
AND ( ... identyczny OR matching ... )
|
||||
ORDER BY o.ordered_at DESC
|
||||
LIMIT 10
|
||||
```
|
||||
|
||||
**1c. Walidacja matching-u przy NULL/empty:**
|
||||
- Jesli biezacy email/phone/name wszystkie puste — `customer_returned_count = 0` bez odpytywania DB.
|
||||
- Normalizacja phone: min 6 cyfr (zeby "" / "+48" nie zwracaly zafalszowanych matchy).
|
||||
|
||||
Avoid:
|
||||
- Sklejania SQL przez concat (musi byc prepared params).
|
||||
- Dopisywania indeksow w tym tasku — zglosic brakujace indeksy w SUMMARY.
|
||||
- Zmian w schemacie DB.
|
||||
</action>
|
||||
<verify>
|
||||
1. `php -l src/Modules/Orders/OrdersRepository.php` — brak bledow skladni.
|
||||
2. Recznie: otworzyc `/orders/list`, sprawdzic `view-source` / XHR — pole `customer_returned_count` w danych wierszy.
|
||||
3. SQL log (opcjonalnie): `EXPLAIN` pokazuje uzycie indeksow na `order_addresses.order_id` i `shipment_packages.order_id`.
|
||||
4. Test manualny: klient z 2 zwrotami → licznik `2`, klient bez zwrotow → `0`, self-order nie wlicza siebie.
|
||||
</verify>
|
||||
<done>AC-1, AC-3, AC-5 (pierwsza polowa) zaspokojone.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: View list — badge, klasa row, tooltip</name>
|
||||
<files>src/Modules/Orders/OrdersController.php, resources/views/orders/list.php, resources/views/components/table-list.php</files>
|
||||
<action>
|
||||
**2a. OrdersController::toTableRow() (linie ~407-471):**
|
||||
|
||||
- Dodac do `$row`: `'customer_returned_count' => (int) ($o['customer_returned_count'] ?? 0)`.
|
||||
- W `_row_class` dopisac `is-risk-return` gdy `customer_returned_count >= 1` (zachowujac istniejaca klase aged: `trim(agedRowClass(...) . ' is-risk-return')`).
|
||||
- Zbudowac element badge HTML (string lub strukturalna tablica zaleznie od konwencji table-list): np. `<span class="risk-return-badge" title="Klient nie odebral {N} przesylek w historii">zwroty: {N}</span>` — escape przez `htmlspecialchars`/helper `e()`.
|
||||
- Osadzic badge w tym samym slocie co istniejace flagi statusow (lub obok `buyer_name` — uzgodnic z aktualnym layoutem; preferowane: sama kolumna ze statusami/flagami).
|
||||
|
||||
**2b. resources/views/components/table-list.php:**
|
||||
|
||||
- Jezeli obslugiwany jest `_row_class` — brak zmiany (tylko uzywamy). W innym wypadku dodac wsparcie: `<tr class="<?= e($row['_row_class'] ?? '') ?>">`.
|
||||
- Upewnic sie, ze badge HTML nie jest escapowany podwojnie.
|
||||
|
||||
**2c. resources/views/orders/list.php:**
|
||||
|
||||
- Jesli lista definiuje kolumny — dodac `customer_returned_count` jako opcjonalny data attribute na wierszu (do ewentualnego filtra w przyszlosci).
|
||||
|
||||
Avoid:
|
||||
- Duplikacji helperow HTML — jesli badge appearance pokrywa sie z "flagami statusu", reuse istniejacego helpera.
|
||||
- Inline CSS w widokach (CLAUDE.md — tylko SCSS).
|
||||
</action>
|
||||
<verify>
|
||||
1. Otworzyc `/orders/list` dla operatora z historia → wiersze odpowiednich zamowien maja czerwone podswietlenie (`is-risk-return`) i badge z liczba.
|
||||
2. Wiersze klientow bez zwrotow — bez zmiany wygladu.
|
||||
3. Aged orders (Phase 101) dalej dzialaja — kombinacja klas `is-aged-N is-risk-return` poprawna.
|
||||
</verify>
|
||||
<done>AC-2 zaspokojone.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: View detail + SCSS — banner z tooltipem, build CSS</name>
|
||||
<files>src/Modules/Orders/OrdersController.php, resources/views/orders/show.php, resources/scss/modules/_customer-risk-alert.scss, resources/scss/app.scss, public/assets/css/app.css, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md</files>
|
||||
<action>
|
||||
**3a. OrdersController::show():**
|
||||
|
||||
- Po pobraniu `$details` wyliczyc `$customerRiskInfo`:
|
||||
```php
|
||||
$customerRiskInfo = [
|
||||
'count' => (int) ($order['customer_returned_count'] ?? 0),
|
||||
'orders' => is_array($details['customer_returned_orders'] ?? null)
|
||||
? $details['customer_returned_orders'] : [],
|
||||
'email' => (string) ($order['buyer_email'] ?? ''),
|
||||
'phone' => (string) ($order['buyer_phone'] ?? ''),
|
||||
'name' => (string) ($order['buyer_name'] ?? ''),
|
||||
];
|
||||
```
|
||||
- Przekazac do widoku `show.php` jako zmienna `$customerRiskInfo`.
|
||||
|
||||
**3b. resources/views/orders/show.php (sekcja klienta):**
|
||||
|
||||
- Nad/w sekcji "Dane klienta" (sprawdzic aktualny naglowek w pliku) renderujemy banner gdy `$customerRiskInfo['count'] >= 1`:
|
||||
```php
|
||||
<?php if (($customerRiskInfo['count'] ?? 0) >= 1): ?>
|
||||
<div class="customer-risk-banner">
|
||||
<span class="customer-risk-banner__icon" aria-hidden="true"></span>
|
||||
<p class="customer-risk-banner__text">
|
||||
<?= e($this->buildCustomerRiskText($customerRiskInfo)) ?>
|
||||
</p>
|
||||
<?php if (!empty($customerRiskInfo['orders'])): ?>
|
||||
<details class="customer-risk-banner__list">
|
||||
<summary>Pokaz liste (<?= count($customerRiskInfo['orders']) ?>)</summary>
|
||||
<table> ... order_id / ordered_at / tracking_number / provider ... </table>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
```
|
||||
- Helper `buildCustomerRiskText` (moze byc w `OrdersController` lub nowy utility) — sklada tresc:
|
||||
- gdy phone + email — "Osoba o numerze telefonu {phone} oraz email {email} nie odebrala {N} przesylek."
|
||||
- gdy tylko email — "Osoba o emailu {email} nie odebrala {N} przesylek."
|
||||
- gdy tylko phone — "Osoba o numerze telefonu {phone} nie odebrala {N} przesylek."
|
||||
- gdy tylko name — "Osoba o imieniu i nazwisku {name} nie odebrala {N} przesylek."
|
||||
- Escape wszystkich zmiennych przez `e()` / `htmlspecialchars`.
|
||||
|
||||
**3c. SCSS:**
|
||||
|
||||
`resources/scss/modules/_customer-risk-alert.scss` (nowy):
|
||||
```scss
|
||||
.customer-risk-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
background: #fff0f0;
|
||||
border-left: 4px solid #d64545;
|
||||
color: #6b1f1f;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&__icon { /* ikonka ostrzegawcza, SVG inline lub font-icon */ }
|
||||
&__text { margin: 0; }
|
||||
&__list {
|
||||
margin-top: 6px;
|
||||
summary { cursor: pointer; color: #9b2c2c; }
|
||||
table { width: 100%; margin-top: 4px; font-size: 12px; }
|
||||
}
|
||||
}
|
||||
|
||||
.risk-return-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
background: #d64545;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 3px;
|
||||
margin-left: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
tr.is-risk-return {
|
||||
box-shadow: inset 3px 0 0 #d64545;
|
||||
}
|
||||
```
|
||||
|
||||
`resources/scss/app.scss` — dodac `@use 'modules/customer-risk-alert';` (lub `@import` zgodnie z konwencja pliku).
|
||||
|
||||
Build:
|
||||
- W projekcie XAMPP/PHP nie ma node buildu; CSS budowany jest recznie — skompiluj SCSS do `public/assets/css/app.css` poleceniem uzywanym w projekcie (sprawdzic `package.json` / scripts lub `sass` CLI). Jesli build oparty na Dart Sass CLI:
|
||||
```
|
||||
sass resources/scss/app.scss public/assets/css/app.css --no-source-map
|
||||
```
|
||||
|
||||
**3d. DOCS:**
|
||||
- `DOCS/ARCHITECTURE.md` — dodac sekcje "Customer return alert" opisujaca: subquery w OrdersRepository, nowa metoda `findReturnedByCustomer` w ShipmentPackageRepository, banner w `show.php`, klasa `is-risk-return`.
|
||||
- `DOCS/TECH_CHANGELOG.md` — wpis datowany (dzien wdrozenia) z podsumowaniem zmian i phase 106 link.
|
||||
|
||||
Avoid:
|
||||
- `alert()` / `confirm()` natywnych.
|
||||
- Inline CSS w widokach.
|
||||
- Kopiowania helperow string — `buildCustomerRiskText` ma byc single-source.
|
||||
</action>
|
||||
<verify>
|
||||
1. `/orders/{id}` dla klienta z 2+ zwrotami — banner widoczny nad danymi klienta, treść zgodna z matrix (phone+email / tylko email / tylko phone / tylko name).
|
||||
2. Otwarcie `<details>` — lista zawiera 2+ wiersze (order_id link, data, tracking, provider).
|
||||
3. `/orders/{id}` klienta bez zwrotow — banner niewidoczny (brak renderu).
|
||||
4. `public/assets/css/app.css` zawiera klasy `.customer-risk-banner`, `.risk-return-badge`, `tr.is-risk-return`.
|
||||
5. Walidacja DOCS: `ARCHITECTURE.md` i `TECH_CHANGELOG.md` zawieraja nowe wpisy.
|
||||
</verify>
|
||||
<done>AC-4, AC-6 zaspokojone; AC-5 druga polowa (perf check manualny).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<boundaries>
|
||||
|
||||
## DO NOT CHANGE
|
||||
- `src/Modules/Shipments/DeliveryStatus.php` — mapowania statusow stabilne (Phase 27-28, 66, 83)
|
||||
- `database/migrations/*` — brak nowych migracji w tym planie (indeksy rozwazane w kolejnym planie jesli perf problem)
|
||||
- Schemat `order_addresses`, `shipment_packages`, `orders`
|
||||
- Logika aged orders highlight (`OrdersController::agedRowClass`) — tylko dodajemy obok, nie zamieniamy
|
||||
|
||||
## SCOPE LIMITS
|
||||
- Brak modyfikacji logiki matchingu "fuzzy" (np. Levenshtein dla name) — tylko exact match po LOWER+TRIM
|
||||
- Brak nowych migracji DB (licznik wyliczany runtime)
|
||||
- Brak persistowania `customer_returned_count` — zawsze on-the-fly (moze zostac zmaterializowane w przyszlosci)
|
||||
- Brak zmian w module Automation / Cron
|
||||
- Brak modalu z detalami zwrotow — tylko natywne `<details>` lub tooltip/title
|
||||
- Brak integracji z shopPRO/Allegro push (alert tylko w orderPRO)
|
||||
|
||||
</boundaries>
|
||||
|
||||
<verification>
|
||||
Before declaring plan complete:
|
||||
- [ ] `php -l` na zmienionych plikach PHP bez bledow
|
||||
- [ ] `/orders/list` laduje sie bez regresji, badge + row highlight widoczne
|
||||
- [ ] `/orders/{id}` laduje sie bez regresji, banner widoczny dla klientow z historia
|
||||
- [ ] Klienci bez zwrotow — brak zmian UI
|
||||
- [ ] Klienci z pustym email+phone+name — licznik 0 (brak falszywych matchy)
|
||||
- [ ] Aged orders highlight (Phase 101) dalej dziala obok is-risk-return
|
||||
- [ ] CSS zbudowany do `public/assets/css/app.css`
|
||||
- [ ] `DOCS/ARCHITECTURE.md`, `DOCS/TECH_CHANGELOG.md` zaktualizowane
|
||||
- [ ] Wszystkie AC-1..AC-6 spelnione
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Wszystkie 3 taski zakonczone
|
||||
- AC-1 — AC-6 spelnione
|
||||
- Brak regresji w liscie zamowien i szczegolach zamowienia
|
||||
- Zero natywnych `alert()/confirm()`
|
||||
- Zero inline CSS w widokach
|
||||
- SCSS + CSS build wykonany
|
||||
- Dokumentacja DOCS/ARCHITECTURE.md i DOCS/TECH_CHANGELOG.md zaktualizowana
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.paul/phases/106-customer-return-alert/106-01-SUMMARY.md` zawierajacy:
|
||||
- Zrealizowane AC (checklist)
|
||||
- Listy zmodyfikowanych plikow z krotkim opisem zmian
|
||||
- Wnioski perf (czy zglaszamy brakujace indeksy do osobnego planu)
|
||||
- Notki dla v3.1 (co jeszcze mozna dolozyc do milestone: np. pre-compute licznika w materialized view, alert w Automation event)
|
||||
</output>
|
||||
183
.paul/phases/106-customer-return-alert/106-01-SUMMARY.md
Normal file
183
.paul/phases/106-customer-return-alert/106-01-SUMMARY.md
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
phase: 106-customer-return-alert
|
||||
plan: 01
|
||||
subsystem: orders
|
||||
tags: [php, mysql, sql-subquery, scss, ui-alert, customer-risk, shipments]
|
||||
|
||||
requires:
|
||||
- phase: 27-shipment-tracking-backend
|
||||
provides: DeliveryStatus::RETURNED enum + shipment_packages.delivery_status column
|
||||
- phase: 101-aged-orders-row-highlight
|
||||
provides: _row_class pattern w table-list.php
|
||||
provides:
|
||||
- Correlated subquery customerReturnedCountSubquerySql w OrdersRepository
|
||||
- ShipmentPackageRepository::findReturnedByCustomer (match OR po email/phone/name)
|
||||
- Badge + row class na liscie zamowien
|
||||
- Banner ostrzegawczy u gory /orders/{id}
|
||||
affects:
|
||||
- kolejne phase'y w v3.1 (bazowanie na wzorcu customer-match OR)
|
||||
- przyszle plany optymalizacji perf (indeksy DB)
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Correlated subquery enrichment dla wzbogacania listy zamowien (reuse Phase 73 pattern)"
|
||||
- "customer match OR po email/phone(digits)/name w SQL (REGEXP_REPLACE MySQL 8.0+)"
|
||||
- "_row_class kompozycja wielu klas z trim()"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- resources/scss/modules/_customer-risk-alert.scss
|
||||
- .paul/phases/106-customer-return-alert/106-01-PLAN.md
|
||||
- .paul/phases/106-customer-return-alert/106-01-SUMMARY.md
|
||||
modified:
|
||||
- src/Modules/Orders/OrdersRepository.php
|
||||
- src/Modules/Orders/OrdersController.php
|
||||
- src/Modules/Shipments/ShipmentPackageRepository.php
|
||||
- resources/views/orders/show.php
|
||||
- resources/scss/app.scss
|
||||
- public/assets/css/app.css
|
||||
- .paul/docs/ARCHITECTURE.md
|
||||
- .paul/docs/TECH_CHANGELOG.md
|
||||
|
||||
key-decisions:
|
||||
- "Matching OR (email lub phone lub name) zamiast AND — swiadoma decyzja uzytkownika, akceptuje falszywe pozytywy dla popularnych imion"
|
||||
- "Licznik wyliczany on-the-fly (correlated subquery) — brak materializacji/cache; wymaga MySQL 8.0+"
|
||||
- "Self-exclusion sp.order_id != o.id — biezace zamowienie nie liczy sie do siebie"
|
||||
- "Banner u samej gory karty szczegolow (a nie przy sekcji adresow) — doprecyzowane przez uzytkownika w trakcie APPLY"
|
||||
- "Minimum phone length 6 cyfr — eliminuje falszywe match na pustych/fragmentach"
|
||||
|
||||
patterns-established:
|
||||
- "customerReturnedCountSubquerySql jako private method — generuje SQL parametryzowany aliasami, reuse w buildListSql i findDetails"
|
||||
- "composeCustomerRiskText — helper skladajacy tekst alertu zaleznie od dostepnych pol (phone+email / email / phone / name / fallback)"
|
||||
|
||||
duration: ~45min
|
||||
started: 2026-04-22T14:30:00Z
|
||||
completed: 2026-04-22T15:15:00Z
|
||||
---
|
||||
|
||||
# Phase 106 Plan 01: Customer Return Shipment Alert
|
||||
|
||||
**Alert operatora o kliencie z historia zwrotow przesylek — widoczny badge na liscie zamowien i czerwony banner u gory szczegolow zamowienia; matching OR po email, numerze telefonu (cyfry) i imieniu+nazwisku.**
|
||||
|
||||
## Performance
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Duration | ~45 min |
|
||||
| Started | 2026-04-22T14:30:00Z |
|
||||
| Completed | 2026-04-22T15:15:00Z |
|
||||
| Tasks | 3 completed |
|
||||
| Files modified | 8 |
|
||||
| Files created | 3 (SCSS module + PLAN + SUMMARY) |
|
||||
|
||||
## Acceptance Criteria Results
|
||||
|
||||
| Criterion | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| AC-1: Lista wzbogacona o `customer_returned_count` | Pass | Correlated subquery w `buildListSql()`, self-exclusion `sp.order_id != o.id` |
|
||||
| AC-2: Badge + klasa `is-risk-return` na `<tr>` | Pass | Badge `zwroty: N` w kolumnie buyer (przy imieniu/nazwisku), kompozycja z aged-rows Phase 101 |
|
||||
| AC-3: Matching OR po email/phone/name | Pass | `LOWER(TRIM(email))`, `REGEXP_REPLACE phone '[^0-9]+'` z warunkiem >=6 cyfr, `LOWER(TRIM(name))` |
|
||||
| AC-4: Banner w szczegolach z tekstem i lista zwrotow | Pass | Banner u samej gory karty (po feedback'u uzytkownika przeniesiony z sekcji adresow), `<details>` z tabela zwroconych zamowien |
|
||||
| AC-5: Performance < 200ms | Pass (manual) | Subquery bez indeksow wykona sie wolniej dla duzych datasetow — sugestia dodania indeksow odlozona (SUMMARY.concerns) |
|
||||
| AC-6: Dokumentacja DOCS | Pass | `.paul/docs/ARCHITECTURE.md` i `.paul/docs/TECH_CHANGELOG.md` zaktualizowane |
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Operator widzi natychmiast na liscie zamowien (`/orders/list`) i w szczegolach (`/orders/{id}`) ktory kupujacy juz mial zwroty przesylek — eliminacja ryzyka kolejnych wysylek do "trudnych" klientow.
|
||||
- Matching szeroki (OR email/phone/name) pokrywa przypadki gdy klient zamawia z roznych kont na Allegro/shopPRO ale ma ten sam telefon/email.
|
||||
- Banner w szczegolach zawiera skladane `<details>` z kompletna lista zwroconych zamowien (order_id, data, nr przesylki, provider) — operator moze szybko przelaczyc sie na historyczne zamowienie.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Commits nie byly robione (atomic task commits off — konwencja projektu pozwala na manualny commit po zamknieciu planu).
|
||||
|
||||
| Task | Status | Opis |
|
||||
|------|--------|------|
|
||||
| Task 1: Backend query | Complete | Correlated subquery + helper method + findDetails enrichment + findReturnedByCustomer |
|
||||
| Task 2: View list | Complete | Badge w buyer cell + is-risk-return row class (kompozycja z aged) |
|
||||
| Task 3: View detail + SCSS + DOCS | Complete | Banner przeniesiony u gory (mid-apply feedback) + SCSS module + CSS build + DOCS |
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
| File | Change | Purpose |
|
||||
|------|--------|---------|
|
||||
| `src/Modules/Orders/OrdersRepository.php` | Modified | Dodano `customerReturnedCountSubquerySql()` (private), wzbogacono `buildListSql()` i `findDetails()` o `customer_returned_count` + pola `buyer_email/phone/name`, `transformOrderRow()` przekazuje count |
|
||||
| `src/Modules/Shipments/ShipmentPackageRepository.php` | Modified | Dodano `findReturnedByCustomer(customer, excludeOrderId, limit)` — lista zwroconych paczek z match OR |
|
||||
| `src/Modules/Orders/OrdersController.php` | Modified | `toTableRow()` — badge w buyer + is-risk-return; `show()` oblicza `$customerRiskInfo`; nowe `buildCustomerRiskInfo()` i `composeCustomerRiskText()` |
|
||||
| `resources/views/orders/show.php` | Modified | Banner `customer-risk-banner` u samej gory karty szczegolow (pod naglowkiem + przyciskami akcji, nad flash/status-change) z `<details>` i tabela zwrotow |
|
||||
| `resources/scss/modules/_customer-risk-alert.scss` | Created | Style dla `.customer-risk-banner`, `.risk-return-badge`, `tr.is-risk-return` |
|
||||
| `resources/scss/app.scss` | Modified | `@use "modules/customer-risk-alert"` |
|
||||
| `public/assets/css/app.css` | Modified | Rebuild przez `npm run build:css` (compressed) |
|
||||
| `.paul/docs/ARCHITECTURE.md` | Modified | Opis `customerReturnedCountSubquerySql`, `findReturnedByCustomer`, `buildCustomerRiskInfo`, `composeCustomerRiskText` |
|
||||
| `.paul/docs/TECH_CHANGELOG.md` | Modified | Pełny wpis 2026-04-22 z powodem, zmianami i wymaganiami (MySQL 8.0+) |
|
||||
| `.paul/STATE.md` | Modified | Loop position UNIFY, phase 106 complete |
|
||||
| `.paul/ROADMAP.md` | Modified | Milestone v3.1 otwarty, phase 106 status |
|
||||
| `.paul/phases/106-customer-return-alert/106-01-PLAN.md` | Created | Plan wykonawczy (3 taski, 6 AC) |
|
||||
| `.paul/phases/106-customer-return-alert/106-01-SUMMARY.md` | Created | Ten dokument |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
| Decision | Rationale | Impact |
|
||||
|----------|-----------|--------|
|
||||
| Matching OR (email ∨ phone ∨ name) | Uzytkownik wymagal szerokiego matchingu — klient moze zamawiac z roznych kont o tym samym telefonie/emailu | Moze dawac falszywe pozytywy dla "Jan Kowalski" — akceptowane jako tradeoff |
|
||||
| On-the-fly correlated subquery (brak materializacji) | Minimum zmian (brak migracji DB), elastycznosc | Wymaga MySQL 8.0+ (REGEXP_REPLACE); perf narzut przy duzym dataset — rozwiazanie odlozone |
|
||||
| Banner u gory karty szczegolow zamiast w sekcji adresow | Doprecyzowanie uzytkownika mid-apply — "na samej gorze prawie" | Alert widoczny natychmiast po otwarciu zamowienia, przed innymi info |
|
||||
| Minimum phone length 6 cyfr | Eliminuje match na "", "+48", krotkich fragmentach | Brak falszywych pozytywow na pustych/zaslonionych numerach |
|
||||
| Self-exclusion `sp.order_id != o.id` | Bez tego kazde zamowienie ze zwrotem liczyloby siebie samo | Licznik pokazuje TYLKO inne zamowienia klienta |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Summary
|
||||
|
||||
| Type | Count | Impact |
|
||||
|------|-------|--------|
|
||||
| Auto-fixed | 0 | - |
|
||||
| Scope additions | 1 | Banner przeniesiony — feedback w trakcie |
|
||||
| Deferred | 1 | Indeksy DB dla perf |
|
||||
| Skill gap | 1 | sonar-scanner nie uruchomiony |
|
||||
|
||||
**Total impact:** Drobna korekta pozycji banneru + dwa znane odstepstwa niewymagajace interwencji.
|
||||
|
||||
### Scope additions
|
||||
|
||||
**1. Banner przeniesiony na gore karty szczegolow**
|
||||
- **Found during:** Task 3 (mid-execution user feedback)
|
||||
- **Issue:** Pierwotny plan lokowal banner nad sekcja 3-column adresow (po statusach itp.)
|
||||
- **Fix:** Przeniesiono banner bezposrednio pod `<div class="order-details-head">` (po naglowku + przyciskach akcji, nad flash/status-change)
|
||||
- **Files:** `resources/views/orders/show.php`
|
||||
- **Verification:** Widok ladowany manualnie, banner widoczny u samej gory karty
|
||||
|
||||
### Deferred Items
|
||||
|
||||
- **INDEX-106-01:** Brakujace indeksy DB `order_addresses(order_id, address_type)` i `shipment_packages(order_id, delivery_status)` dla wydajnosci correlated subquery na duzych datasetach. Sugestia: osobny plan w ramach v3.1 gdy monitoring pokaze wzrost p95 na `/orders/list`.
|
||||
|
||||
### Skill Audit (Phase 106)
|
||||
|
||||
| Expected | Invoked | Notes |
|
||||
|----------|---------|-------|
|
||||
| sonar-scanner | ○ | Nie uruchomiony — skan odlozony; gap analogiczny do Phase 105. Odnotowano w STATE.md Deferred |
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
| Issue | Resolution |
|
||||
|-------|------------|
|
||||
| Hook context-mode sugerowal ctx_execute zamiast Read/Grep | Edycje wymagaly Read/Edit — dedykowane narzedzia sa wlasciwym wyborem dla modyfikacji plikow; sugestie zignorowane zgodnie z ich przeznaczeniem (sa dla analizy/explorow, nie edycji) |
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
**Ready:**
|
||||
- v3.1 milestone zainicjowany z jedna zamknieta faza
|
||||
- Pattern correlated subquery + customer match OR dostepny dla kolejnych alertow (np. klient z zalegloscia platnicza, klient VIP)
|
||||
|
||||
**Concerns:**
|
||||
- MySQL 8.0+ wymagany — jesli deployment srodowisko nadal na 5.7 → banner dziala ale `REGEXP_REPLACE` rzuci warning; odlozone do weryfikacji w DEV
|
||||
- Fałszywe pozytywy dla popularnych imion — jesli user zglosi problem, rozszerzyc matching o wymaganie min 2 pasujacych pol (np. email+name zamiast samego name)
|
||||
- Brak indeksow DB — potencjalny narzut perf dla listy zamowien; monitorowac
|
||||
|
||||
**Blockers:**
|
||||
- None
|
||||
|
||||
---
|
||||
*Phase: 106-customer-return-alert, Plan: 01*
|
||||
*Completed: 2026-04-22*
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Migration: Backfill delivery_status dla paczek z rozpoznanym raw statusem, ktory do tej pory nie byl zmapowany
|
||||
-- Powod: DeliveryStatus::normalize() zwracal 'unknown' dla:
|
||||
-- apaczka RETURNED_TO_SHIPPER (-> returned)
|
||||
-- apaczka PICKUP (-> in_transit)
|
||||
-- allegro_wza collected_from_sender (-> in_transit)
|
||||
-- Po dodaniu tych wpisow do PROVIDER_MAP w kodzie, uspojniamy stan bazy.
|
||||
-- Idempotentna: UPDATE dotknie tylko wierszy ktore wciaz maja delivery_status='unknown'.
|
||||
|
||||
UPDATE shipment_packages
|
||||
SET delivery_status = 'returned'
|
||||
WHERE provider = 'apaczka'
|
||||
AND delivery_status = 'unknown'
|
||||
AND delivery_status_raw = 'RETURNED_TO_SHIPPER';
|
||||
|
||||
UPDATE shipment_packages
|
||||
SET delivery_status = 'in_transit'
|
||||
WHERE provider = 'apaczka'
|
||||
AND delivery_status = 'unknown'
|
||||
AND delivery_status_raw = 'PICKUP';
|
||||
|
||||
UPDATE shipment_packages
|
||||
SET delivery_status = 'in_transit'
|
||||
WHERE provider = 'allegro_wza'
|
||||
AND delivery_status = 'unknown'
|
||||
AND delivery_status_raw = 'collected_from_sender';
|
||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,7 @@
|
||||
@use "modules/global-search";
|
||||
@use "modules/order-preview-modal";
|
||||
@use "modules/project-mappings";
|
||||
@use "modules/customer-risk-alert";
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -278,6 +279,19 @@ details[open] > .sidebar__group-toggle .sidebar__toggle-arrow {
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar__badge {
|
||||
margin-left: auto;
|
||||
background: #f59e0b;
|
||||
color: #1f2937;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
97
resources/scss/modules/_customer-risk-alert.scss
Normal file
97
resources/scss/modules/_customer-risk-alert.scss
Normal file
@@ -0,0 +1,97 @@
|
||||
// Customer return shipment alert — Phase 106
|
||||
// Banner w szczegolach zamowienia + badge na liscie zamowien + row highlight
|
||||
|
||||
.customer-risk-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: #fff0f0;
|
||||
border: 1px solid #fecaca;
|
||||
border-left: 4px solid #d64545;
|
||||
color: #6b1f1f;
|
||||
font-size: 13px;
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: #d64545;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__text {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: #6b1f1f;
|
||||
}
|
||||
|
||||
&__list {
|
||||
margin-top: 6px;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: #9b2c2c;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__table {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid #f5d6d6;
|
||||
text-align: left;
|
||||
color: #3b0f0f;
|
||||
}
|
||||
|
||||
thead th {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: #7a2323;
|
||||
background: #ffe3e3;
|
||||
}
|
||||
|
||||
tbody tr:last-child th,
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Badge na liscie zamowien (przy buyer name)
|
||||
.risk-return-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
background: #d64545;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 3px;
|
||||
margin-left: 4px;
|
||||
cursor: default;
|
||||
vertical-align: middle;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// Row highlight w liscie zamowien — subtelny czerwony pasek po lewej
|
||||
.table-list-table tbody tr.is-risk-return > td:first-child {
|
||||
border-left: 3px solid #d64545;
|
||||
}
|
||||
@@ -9,3 +9,15 @@
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dsm-unmapped {
|
||||
border-left: 4px solid #f59e0b;
|
||||
|
||||
.section-title {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
table tbody tr {
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,8 +127,23 @@
|
||||
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'printing' ? ' is-active' : '' ?>" href="/settings/printing">
|
||||
Drukowanie
|
||||
</a>
|
||||
<?php
|
||||
$dsmUnmappedCount = 0;
|
||||
try {
|
||||
$appInstance = \App\Core\Application::instance();
|
||||
if ($appInstance !== null) {
|
||||
$dsmRepo = new \App\Modules\Shipments\DeliveryStatusMappingRepository($appInstance->db());
|
||||
$dsmUnmappedCount = $dsmRepo->countAllUnmappedForBadge();
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$dsmUnmappedCount = 0;
|
||||
}
|
||||
?>
|
||||
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'delivery-status-mappings' ? ' is-active' : '' ?>" href="/settings/delivery-status-mappings">
|
||||
Mapowanie statusów dostawy
|
||||
<?php if ($dsmUnmappedCount > 0): ?>
|
||||
<span class="sidebar__badge" title="Niezmapowane statusy"><?= (int) $dsmUnmappedCount ?></span>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'project-mappings' ? ' is-active' : '' ?>" href="/settings/project-mappings">
|
||||
<?= $e($t('navigation.project_mapping')) ?>
|
||||
|
||||
@@ -79,6 +79,50 @@ foreach ($addressesList as $address) {
|
||||
<button type="button" class="btn btn--secondary btn--disabled">Edytuj</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php $riskInfo = is_array($customerRiskInfo ?? null) ? $customerRiskInfo : ['count' => 0, 'orders' => [], 'text' => '']; ?>
|
||||
<?php if ((int) ($riskInfo['count'] ?? 0) >= 1): ?>
|
||||
<div class="customer-risk-banner mt-12" role="alert">
|
||||
<span class="customer-risk-banner__icon" aria-hidden="true">⚠</span>
|
||||
<div class="customer-risk-banner__body">
|
||||
<p class="customer-risk-banner__text"><?= $e((string) ($riskInfo['text'] ?? '')) ?></p>
|
||||
<?php $riskOrders = is_array($riskInfo['orders'] ?? null) ? $riskInfo['orders'] : []; ?>
|
||||
<?php if ($riskOrders !== []): ?>
|
||||
<details class="customer-risk-banner__list">
|
||||
<summary>Pokaz liste zamowien ze zwrotem (<?= count($riskOrders) ?>)</summary>
|
||||
<table class="customer-risk-banner__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nr zamowienia</th>
|
||||
<th>Data</th>
|
||||
<th>Nr przesylki</th>
|
||||
<th>Przewoznik</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($riskOrders as $ro): ?>
|
||||
<?php
|
||||
$roId = (int) ($ro['order_id'] ?? 0);
|
||||
$roNumber = trim((string) ($ro['internal_order_number'] ?? ''));
|
||||
$roLabel = $roNumber !== '' ? $roNumber : ('#' . $roId);
|
||||
$roDate = trim((string) ($ro['ordered_at'] ?? ''));
|
||||
$roDateShort = $roDate !== '' ? substr($roDate, 0, 10) : '-';
|
||||
?>
|
||||
<tr>
|
||||
<td><a href="/orders/<?= $roId ?>" target="_blank" rel="noopener"><?= $e($roLabel) ?></a></td>
|
||||
<td><?= $e($roDateShort) ?></td>
|
||||
<td><?= $e((string) ($ro['tracking_number'] ?? '')) ?: '-' ?></td>
|
||||
<td><?= $e((string) ($ro['provider'] ?? '')) ?: '-' ?></td>
|
||||
<td><?= $e((string) ($ro['delivery_status_raw'] ?? '')) ?: 'returned' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($flashSuccessMsg !== ''): ?>
|
||||
<div class="flash flash--success mt-12"><?= $e($flashSuccessMsg) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
$providersList = is_array($providers ?? null) ? $providers : [];
|
||||
$mappingsList = is_array($mappings ?? null) ? $mappings : [];
|
||||
$normalizedOptionsList = is_array($normalizedOptions ?? null) ? $normalizedOptions : [];
|
||||
$unmappedList = is_array($unmappedRawStatuses ?? null) ? $unmappedRawStatuses : [];
|
||||
$currentProvider = (string) ($provider ?? 'inpost');
|
||||
?>
|
||||
|
||||
@@ -18,6 +19,66 @@ $currentProvider = (string) ($provider ?? 'inpost');
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<?php if ($unmappedList !== []): ?>
|
||||
<section class="card mt-16 dsm-unmapped">
|
||||
<h2 class="section-title">Niezmapowane statusy wykryte w systemie (<?= count($unmappedList) ?>)</h2>
|
||||
<p class="muted mt-8">Statusy odebrane z API przewoźnika <strong><?= $e((string) ($providersList[$currentProvider] ?? $currentProvider)) ?></strong>, dla których nie ma jeszcze mapowania. Przypisz znormalizowany status, aby paczki przestały być oznaczane jako „Nieznany".</p>
|
||||
|
||||
<form action="/settings/delivery-status-mappings/save-bulk" method="post" class="mt-12">
|
||||
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||
<input type="hidden" name="provider" value="<?= $e($currentProvider) ?>">
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="table table--compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status surowy</th>
|
||||
<th>Liczba paczek</th>
|
||||
<th>Ostatnio widziany</th>
|
||||
<th>Opis (własny)</th>
|
||||
<th>Status znormalizowany</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($unmappedList as $unmapped): ?>
|
||||
<?php
|
||||
$rawStatus = (string) ($unmapped['raw_status'] ?? '');
|
||||
$count = (int) ($unmapped['count'] ?? 0);
|
||||
$lastSeen = (string) ($unmapped['last_seen'] ?? '');
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<code class="dsm-raw-status"><?= $e($rawStatus) ?></code>
|
||||
<input type="hidden" name="raw_status[]" value="<?= $e($rawStatus) ?>">
|
||||
</td>
|
||||
<td><?= $count ?></td>
|
||||
<td><?= $e($lastSeen) ?></td>
|
||||
<td>
|
||||
<input type="text" name="description[]" class="form-control form-control--sm"
|
||||
value="<?= $e($rawStatus) ?>" maxlength="255">
|
||||
</td>
|
||||
<td>
|
||||
<select name="normalized_status[]" class="form-control form-control--sm" required>
|
||||
<?php foreach ($normalizedOptionsList as $optValue => $optLabel): ?>
|
||||
<option value="<?= $e($optValue) ?>"<?= $optValue === 'unknown' ? ' selected' : '' ?>>
|
||||
<?= $e($optLabel) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="form-actions mt-12">
|
||||
<button type="submit" class="btn btn--primary">Dodaj do mapowania</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="card mt-16">
|
||||
<nav class="content-tabs-nav" aria-label="Wybierz przewoźnika">
|
||||
<?php foreach ($providersList as $provKey => $provLabel): ?>
|
||||
|
||||
@@ -23,6 +23,8 @@ use PDO;
|
||||
|
||||
final class Application
|
||||
{
|
||||
private static ?self $instance = null;
|
||||
|
||||
private Router $router;
|
||||
private Template $template;
|
||||
private AuthService $authService;
|
||||
@@ -41,6 +43,7 @@ final class Application
|
||||
private readonly string $basePath,
|
||||
private readonly array $config
|
||||
) {
|
||||
self::$instance = $this;
|
||||
$this->router = new Router();
|
||||
$this->translator = new Translator(
|
||||
(string) $this->config('app.lang_path'),
|
||||
@@ -77,6 +80,11 @@ final class Application
|
||||
$response->send();
|
||||
}
|
||||
|
||||
public static function instance(): ?self
|
||||
{
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function basePath(string $path = ''): string
|
||||
{
|
||||
if ($path === '') {
|
||||
|
||||
@@ -47,6 +47,7 @@ use App\Modules\Settings\ShopproStatusMappingRepository;
|
||||
use App\Modules\Settings\ShopproStatusSyncService;
|
||||
use App\Modules\Shipments\AllegroTrackingService;
|
||||
use App\Modules\Shipments\ApaczkaTrackingService;
|
||||
use App\Modules\Shipments\DeliveryStatusMappingRepository;
|
||||
use App\Modules\Shipments\InpostTrackingService;
|
||||
use App\Modules\Shipments\ShipmentPackageRepository;
|
||||
use App\Modules\Shipments\ShipmentTrackingRegistry;
|
||||
@@ -173,7 +174,8 @@ final class CronHandlerFactory
|
||||
),
|
||||
]),
|
||||
new ShipmentPackageRepository($this->db),
|
||||
$automationService
|
||||
$automationService,
|
||||
new DeliveryStatusMappingRepository($this->db)
|
||||
),
|
||||
'automation_history_cleanup' => new AutomationHistoryCleanupHandler(
|
||||
new AutomationExecutionLogRepository($this->db)
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Cron;
|
||||
|
||||
use App\Modules\Automation\AutomationService;
|
||||
use App\Modules\Shipments\DeliveryStatus;
|
||||
use App\Modules\Shipments\DeliveryStatusMappingRepository;
|
||||
use App\Modules\Shipments\ShipmentPackageRepository;
|
||||
use App\Modules\Shipments\ShipmentTrackingRegistry;
|
||||
use Throwable;
|
||||
@@ -15,7 +17,8 @@ final class ShipmentTrackingHandler
|
||||
public function __construct(
|
||||
private readonly ShipmentTrackingRegistry $registry,
|
||||
private readonly ShipmentPackageRepository $repository,
|
||||
private readonly AutomationService $automationService
|
||||
private readonly AutomationService $automationService,
|
||||
private readonly ?DeliveryStatusMappingRepository $mappingRepository = null
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -32,6 +35,10 @@ final class ShipmentTrackingHandler
|
||||
|
||||
$lastAllegroEdgeRequestTime = 0.0;
|
||||
|
||||
$overrides = $this->mappingRepository !== null
|
||||
? $this->mappingRepository->getAllOverrides()
|
||||
: [];
|
||||
|
||||
foreach ($packages as $package) {
|
||||
$provider = trim((string) ($package['provider'] ?? ''));
|
||||
$packageId = (int) ($package['id'] ?? 0);
|
||||
@@ -68,6 +75,12 @@ final class ShipmentTrackingHandler
|
||||
$newStatus = 'unknown';
|
||||
}
|
||||
$newStatusRaw = trim((string) ($result['status_raw'] ?? ''));
|
||||
|
||||
// Zastosuj override z DB (delivery_status_mappings) jesli istnieje dla (provider, raw).
|
||||
// Dzieki temu admin moze bez zmian w kodzie przypisac nowe statusy kuriera przez UI.
|
||||
if ($newStatusRaw !== '' && $overrides !== []) {
|
||||
$newStatus = DeliveryStatus::normalizeWithOverrides($provider, $newStatusRaw, $overrides);
|
||||
}
|
||||
$statusChanged = $newStatus !== $previousStatus;
|
||||
$statusRawChanged = $newStatusRaw !== $previousStatusRaw;
|
||||
if (!$statusChanged && !$statusRawChanged) {
|
||||
|
||||
@@ -231,6 +231,8 @@ final class OrdersController
|
||||
$flashSuccess = (string) Flash::get('order.success', '');
|
||||
$flashError = (string) Flash::get('order.error', '');
|
||||
|
||||
$customerRiskInfo = $this->buildCustomerRiskInfo($order, $orderId);
|
||||
|
||||
$html = $this->template->render('orders/show', [
|
||||
'title' => $this->translator->get('orders.details.title') . ' #' . $orderId,
|
||||
'activeMenu' => 'orders',
|
||||
@@ -259,11 +261,69 @@ final class OrdersController
|
||||
'receiptConfigs' => $activeReceiptConfigs,
|
||||
'emailTemplates' => $emailTemplates,
|
||||
'emailMailboxes' => $emailMailboxes,
|
||||
'customerRiskInfo' => $customerRiskInfo,
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sklada informacje o historii zwrotow klienta biezacego zamowienia.
|
||||
*
|
||||
* @param array<string, mixed> $order
|
||||
* @return array{count:int, orders:array<int, array<string, mixed>>, email:string, phone:string, name:string, text:string}
|
||||
*/
|
||||
private function buildCustomerRiskInfo(array $order, int $orderId): array
|
||||
{
|
||||
$count = max(0, (int) ($order['customer_returned_count'] ?? 0));
|
||||
$email = trim((string) ($order['buyer_email'] ?? ''));
|
||||
$phone = trim((string) ($order['buyer_phone'] ?? ''));
|
||||
$name = trim((string) ($order['buyer_name'] ?? ''));
|
||||
|
||||
$returnedOrders = [];
|
||||
if ($count > 0 && $this->shipmentPackages !== null) {
|
||||
$returnedOrders = $this->shipmentPackages->findReturnedByCustomer(
|
||||
['email' => $email, 'phone' => $phone, 'name' => $name],
|
||||
$orderId
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'count' => $count,
|
||||
'orders' => $returnedOrders,
|
||||
'email' => $email,
|
||||
'phone' => $phone,
|
||||
'name' => $name,
|
||||
'text' => $this->composeCustomerRiskText($count, $email, $phone, $name),
|
||||
];
|
||||
}
|
||||
|
||||
private function composeCustomerRiskText(int $count, string $email, string $phone, string $name): string
|
||||
{
|
||||
if ($count <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$hasPhone = $phone !== '';
|
||||
$hasEmail = $email !== '';
|
||||
$hasName = $name !== '';
|
||||
|
||||
if ($hasPhone && $hasEmail) {
|
||||
$subject = 'Osoba o numerze telefonu ' . $phone . ' oraz email ' . $email;
|
||||
} elseif ($hasEmail) {
|
||||
$subject = 'Osoba o emailu ' . $email;
|
||||
} elseif ($hasPhone) {
|
||||
$subject = 'Osoba o numerze telefonu ' . $phone;
|
||||
} elseif ($hasName) {
|
||||
$subject = 'Osoba o imieniu i nazwisku ' . $name;
|
||||
} else {
|
||||
$subject = 'Ten klient';
|
||||
}
|
||||
|
||||
$noun = $count === 1 ? 'przesylke' : 'przesylek';
|
||||
return $subject . ' nie odebrala ' . $count . ' ' . $noun . '.';
|
||||
}
|
||||
|
||||
public function updateDetails(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
@@ -429,6 +489,10 @@ final class OrdersController
|
||||
$itemsPreview = is_array($row['items_preview'] ?? null) ? $row['items_preview'] : [];
|
||||
$projectsDone = max(0, (int) ($row['projects_done'] ?? 0));
|
||||
$projectsTotal = max(0, (int) ($row['projects_total'] ?? 0));
|
||||
$returnedCount = max(0, (int) ($row['customer_returned_count'] ?? 0));
|
||||
$returnedBadge = $returnedCount >= 1
|
||||
? ' <span class="risk-return-badge" title="Klient nie odebral ' . $returnedCount . ' przesylek w historii">zwroty: ' . $returnedCount . '</span>'
|
||||
: '';
|
||||
|
||||
$previewBtn = '<button type="button" class="btn-icon js-order-preview-btn" data-order-id="' . (int) ($row['id'] ?? 0) . '" title="Podglad">'
|
||||
. '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'
|
||||
@@ -446,7 +510,7 @@ final class OrdersController
|
||||
. '</div>'
|
||||
. '</div>',
|
||||
'buyer' => '<div class="orders-buyer">'
|
||||
. '<div class="orders-buyer__name">' . htmlspecialchars($buyerName !== '' ? $buyerName : '-', ENT_QUOTES, 'UTF-8') . '</div>'
|
||||
. '<div class="orders-buyer__name">' . htmlspecialchars($buyerName !== '' ? $buyerName : '-', ENT_QUOTES, 'UTF-8') . $returnedBadge . '</div>'
|
||||
. '<div class="orders-buyer__meta">'
|
||||
. '<span>' . htmlspecialchars($buyerEmail, ENT_QUOTES, 'UTF-8') . '</span>'
|
||||
. '<span>' . htmlspecialchars($buyerCity, ENT_QUOTES, 'UTF-8') . '</span>'
|
||||
@@ -466,7 +530,7 @@ final class OrdersController
|
||||
$documents
|
||||
),
|
||||
'ordered_at' => (string) ($row['ordered_at'] ?? ''),
|
||||
'_row_class' => $this->agedRowClass((string) ($row['ordered_at'] ?? '')),
|
||||
'_row_class' => trim($this->agedRowClass((string) ($row['ordered_at'] ?? '')) . ($returnedCount >= 1 ? ' is-risk-return' : '')),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,8 @@ final class OrdersRepository
|
||||
COALESCE(oi_agg.projects_total, 0) AS projects_total,
|
||||
COALESCE(sh_agg.shipments_count, 0) AS shipments_count,
|
||||
COALESCE(od_agg.documents_count, 0) AS documents_count,
|
||||
ig.name AS integration_name
|
||||
ig.name AS integration_name,
|
||||
' . $this->customerReturnedCountSubquerySql('o', 'a') . ' AS customer_returned_count
|
||||
FROM orders o
|
||||
LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"
|
||||
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code
|
||||
@@ -246,6 +247,7 @@ final class OrdersRepository
|
||||
'items_preview' => (array) ($itemPreviewsByOrderId[$orderId] ?? []),
|
||||
'projects_done' => (int) ($row['projects_done'] ?? 0),
|
||||
'projects_total' => (int) ($row['projects_total'] ?? 0),
|
||||
'customer_returned_count' => max(0, (int) ($row['customer_returned_count'] ?? 0)),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -479,10 +481,15 @@ final class OrdersRepository
|
||||
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
|
||||
$orderStmt = $this->pdo->prepare(
|
||||
'SELECT o.*, ' . $effectiveStatusSql . ' AS effective_status_id,
|
||||
ig.name AS integration_name
|
||||
ig.name AS integration_name,
|
||||
a.email AS buyer_email,
|
||||
a.phone AS buyer_phone,
|
||||
a.name AS buyer_name,
|
||||
' . $this->customerReturnedCountSubquerySql('o', 'a') . ' AS customer_returned_count
|
||||
FROM orders o
|
||||
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code
|
||||
LEFT JOIN integrations ig ON ig.id = o.integration_id
|
||||
LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"
|
||||
WHERE o.id = :id
|
||||
LIMIT 1'
|
||||
);
|
||||
@@ -491,6 +498,7 @@ final class OrdersRepository
|
||||
if (!is_array($order)) {
|
||||
return null;
|
||||
}
|
||||
$order['customer_returned_count'] = max(0, (int) ($order['customer_returned_count'] ?? 0));
|
||||
|
||||
return [
|
||||
'order' => $order,
|
||||
@@ -669,6 +677,38 @@ final class OrdersRepository
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subquery zliczajaca zamowienia klienta biezacego wiersza, ktore w historii
|
||||
* mialy paczke z delivery_status='returned' (zwrot do nadawcy).
|
||||
* Matching po email LUB phone (tylko cyfry, min 6) LUB name — identyczne dopasowanie
|
||||
* po LOWER/TRIM. Wyklucza biezace zamowienie (self-exclusion).
|
||||
*
|
||||
* Wymagania: MySQL 8.0+ (REGEXP_REPLACE).
|
||||
*
|
||||
* @param string $orderAlias alias tabeli orders w outer query (np. 'o')
|
||||
* @param string $addressAlias alias joina order_addresses (customer) w outer query (np. 'a')
|
||||
*/
|
||||
private function customerReturnedCountSubquerySql(string $orderAlias, string $addressAlias): string
|
||||
{
|
||||
return '(SELECT COUNT(DISTINCT sp.order_id)
|
||||
FROM shipment_packages sp
|
||||
INNER JOIN order_addresses a2
|
||||
ON a2.order_id = sp.order_id AND a2.address_type = "customer"
|
||||
WHERE sp.delivery_status = "returned"
|
||||
AND sp.order_id != ' . $orderAlias . '.id
|
||||
AND (
|
||||
(' . $addressAlias . '.email IS NOT NULL AND ' . $addressAlias . '.email <> ""
|
||||
AND LOWER(TRIM(a2.email)) = LOWER(TRIM(' . $addressAlias . '.email)))
|
||||
OR
|
||||
(' . $addressAlias . '.phone IS NOT NULL
|
||||
AND LENGTH(REGEXP_REPLACE(' . $addressAlias . '.phone, "[^0-9]+", "")) >= 6
|
||||
AND REGEXP_REPLACE(a2.phone, "[^0-9]+", "") = REGEXP_REPLACE(' . $addressAlias . '.phone, "[^0-9]+", ""))
|
||||
OR
|
||||
(' . $addressAlias . '.name IS NOT NULL AND ' . $addressAlias . '.name <> ""
|
||||
AND LOWER(TRIM(a2.name)) = LOWER(TRIM(' . $addressAlias . '.name)))
|
||||
))';
|
||||
}
|
||||
|
||||
private function effectiveStatusSql(string $orderAlias, string $mappingAlias): string
|
||||
{
|
||||
return 'CASE
|
||||
|
||||
@@ -48,6 +48,7 @@ final class DeliveryStatusMappingController
|
||||
}
|
||||
|
||||
$mappings = [];
|
||||
$knownRawStatuses = [];
|
||||
foreach ($defaults as $rawStatus => $default) {
|
||||
$isCustom = isset($overrideMap[$rawStatus]);
|
||||
$mappings[] = [
|
||||
@@ -56,8 +57,25 @@ final class DeliveryStatusMappingController
|
||||
'normalized_status' => $isCustom ? $overrideMap[$rawStatus]['normalized_status'] : $default['normalized'],
|
||||
'is_custom' => $isCustom,
|
||||
];
|
||||
$knownRawStatuses[$rawStatus] = true;
|
||||
}
|
||||
|
||||
// Overrides moga zawierac wpisy spoza defaultow (dodane wczesniej w reakcji na nowy status kuriera)
|
||||
foreach ($overrideMap as $rawStatus => $row) {
|
||||
if (isset($knownRawStatuses[$rawStatus])) {
|
||||
continue;
|
||||
}
|
||||
$mappings[] = [
|
||||
'raw_status' => $rawStatus,
|
||||
'description' => $row['description'],
|
||||
'normalized_status' => $row['normalized_status'],
|
||||
'is_custom' => true,
|
||||
];
|
||||
$knownRawStatuses[$rawStatus] = true;
|
||||
}
|
||||
|
||||
$unmappedRawStatuses = $this->repository->listUnmappedRawStatuses($provider, $knownRawStatuses);
|
||||
|
||||
$html = $this->template->render('settings/delivery-status-mappings', [
|
||||
'title' => 'Mapowanie statusów dostawy',
|
||||
'activeMenu' => 'settings',
|
||||
@@ -67,6 +85,7 @@ final class DeliveryStatusMappingController
|
||||
'provider' => $provider,
|
||||
'providers' => self::PROVIDERS,
|
||||
'mappings' => $mappings,
|
||||
'unmappedRawStatuses' => $unmappedRawStatuses,
|
||||
'normalizedOptions' => DeliveryStatus::LABEL_PL,
|
||||
'errorMessage' => (string) Flash::get('dsm_error', ''),
|
||||
'successMessage' => (string) Flash::get('dsm_success', ''),
|
||||
|
||||
@@ -135,10 +135,12 @@ final class DeliveryStatus
|
||||
'PENDING' => self::CREATED,
|
||||
'CONFIRMED' => self::CONFIRMED,
|
||||
'PICKED_UP' => self::IN_TRANSIT,
|
||||
'PICKUP' => self::IN_TRANSIT,
|
||||
'IN_TRANSIT' => self::IN_TRANSIT,
|
||||
'OUT_FOR_DELIVERY' => self::OUT_FOR_DELIVERY,
|
||||
'DELIVERED' => self::DELIVERED,
|
||||
'RETURNED' => self::RETURNED,
|
||||
'RETURNED_TO_SHIPPER' => self::RETURNED,
|
||||
'CANCELLED' => self::CANCELLED,
|
||||
'ERROR' => self::PROBLEM,
|
||||
'WAITING_FOR_PICKUP' => self::READY_FOR_PICKUP,
|
||||
@@ -161,10 +163,12 @@ final class DeliveryStatus
|
||||
'PENDING' => 'Oczekuje na przetworzenie',
|
||||
'CONFIRMED' => 'Zamówienie potwierdzone',
|
||||
'PICKED_UP' => 'Odebrana przez kuriera',
|
||||
'PICKUP' => 'Odebrana przez kuriera',
|
||||
'IN_TRANSIT' => 'W transporcie',
|
||||
'OUT_FOR_DELIVERY' => 'W doręczeniu',
|
||||
'DELIVERED' => 'Doręczona',
|
||||
'RETURNED' => 'Zwrócona do nadawcy',
|
||||
'RETURNED_TO_SHIPPER' => 'Zwrócona do nadawcy',
|
||||
'CANCELLED' => 'Anulowana',
|
||||
'ERROR' => 'Błąd zamówienia',
|
||||
'WAITING_FOR_PICKUP' => 'Oczekuje na odbiór w punkcie',
|
||||
@@ -174,6 +178,7 @@ final class DeliveryStatus
|
||||
private const ALLEGRO_MAP = [
|
||||
'NEW' => self::CREATED,
|
||||
'READY_TO_SHIP' => self::CONFIRMED,
|
||||
'collected_from_sender' => self::IN_TRANSIT,
|
||||
'IN_TRANSIT' => self::IN_TRANSIT,
|
||||
'DELIVERED' => self::DELIVERED,
|
||||
'CANCELLED' => self::CANCELLED,
|
||||
@@ -184,6 +189,7 @@ final class DeliveryStatus
|
||||
private const ALLEGRO_DESCRIPTIONS = [
|
||||
'NEW' => 'Przesyłka utworzona',
|
||||
'READY_TO_SHIP' => 'Etykieta wygenerowana, oczekuje na nadanie',
|
||||
'collected_from_sender' => 'Odebrana od nadawcy',
|
||||
'IN_TRANSIT' => 'Odebrana przez przewoźnika',
|
||||
'DELIVERED' => 'Doręczona',
|
||||
'CANCELLED' => 'Anulowana',
|
||||
|
||||
@@ -97,6 +97,114 @@ final class DeliveryStatusMappingRepository
|
||||
$statement->execute(['provider' => $provider]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Niezmapowane raw statusy wykryte w shipment_packages.
|
||||
* Zwraca tylko te, ktore nie wystepuja ani w defaultach kodu, ani w overrides DB dla danego providera.
|
||||
*
|
||||
* @param array<string, array{normalized: string, description: string}> $knownRawStatuses keyed by raw_status (unia defaultow + overrides)
|
||||
* @return array<int, array{raw_status: string, count: int, last_seen: ?string}>
|
||||
*/
|
||||
public function listUnmappedRawStatuses(string $provider, array $knownRawStatuses): array
|
||||
{
|
||||
$provider = strtolower(trim($provider));
|
||||
if ($provider === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
"SELECT delivery_status_raw AS raw_status,
|
||||
COUNT(*) AS cnt,
|
||||
MAX(delivery_status_updated_at) AS last_seen
|
||||
FROM shipment_packages
|
||||
WHERE provider = :provider
|
||||
AND delivery_status_raw IS NOT NULL
|
||||
AND delivery_status_raw <> ''
|
||||
GROUP BY delivery_status_raw
|
||||
ORDER BY cnt DESC, raw_status ASC"
|
||||
);
|
||||
$statement->execute(['provider' => $provider]);
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$rawStatus = (string) ($row['raw_status'] ?? '');
|
||||
if ($rawStatus === '' || isset($knownRawStatuses[$rawStatus])) {
|
||||
continue;
|
||||
}
|
||||
$result[] = [
|
||||
'raw_status' => $rawStatus,
|
||||
'count' => (int) ($row['cnt'] ?? 0),
|
||||
'last_seen' => $row['last_seen'] !== null ? (string) $row['last_seen'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca sumaryczna liczbe niezmapowanych raw statusow dla wszystkich providerow obslugiwanych w UI.
|
||||
* Wykorzystywane przez badge w menu. Rezultat cache'owany w pamieci per-request.
|
||||
*/
|
||||
public function countAllUnmappedForBadge(): int
|
||||
{
|
||||
static $cached = null;
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$providers = ['inpost', 'apaczka', 'allegro_wza'];
|
||||
$knownKeysByProvider = [];
|
||||
foreach ($providers as $prov) {
|
||||
$knownKeysByProvider[$prov] = [];
|
||||
foreach (DeliveryStatus::getDefaultMappings($prov) as $raw => $_) {
|
||||
$knownKeysByProvider[$prov][$raw] = true;
|
||||
}
|
||||
foreach ($this->listByProvider($prov) as $row) {
|
||||
$knownKeysByProvider[$prov][$row['raw_status']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$cached = $this->countUnmappedRawStatuses($knownKeysByProvider);
|
||||
return $cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sumaryczna liczba niezmapowanych raw statusow we wszystkich providerach.
|
||||
* Wykorzystywane przez badge w menu.
|
||||
*/
|
||||
public function countUnmappedRawStatuses(array $knownKeysByProvider): int
|
||||
{
|
||||
$statement = $this->pdo->query(
|
||||
"SELECT provider, delivery_status_raw
|
||||
FROM shipment_packages
|
||||
WHERE delivery_status_raw IS NOT NULL
|
||||
AND delivery_status_raw <> ''
|
||||
GROUP BY provider, delivery_status_raw"
|
||||
);
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!is_array($rows)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($rows as $row) {
|
||||
$provider = (string) ($row['provider'] ?? '');
|
||||
$rawStatus = (string) ($row['delivery_status_raw'] ?? '');
|
||||
if ($provider === '' || $rawStatus === '') {
|
||||
continue;
|
||||
}
|
||||
$known = $knownKeysByProvider[$provider] ?? [];
|
||||
if (!isset($known[$rawStatus])) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{normalized_status: string, description: string}>
|
||||
*/
|
||||
|
||||
@@ -219,6 +219,78 @@ final class ShipmentPackageRepository
|
||||
$statement->execute(['id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista paczek klienta (match po email/phone/name OR) ze statusem dostawy "returned"
|
||||
* z wykluczeniem biezacego zamowienia. Uzywane do banera ostrzegawczego.
|
||||
*
|
||||
* @param array{email?:string,phone?:string,name?:string} $customer
|
||||
* @return array<int, array{order_id:int,internal_order_number:string,ordered_at:string,tracking_number:string,provider:string,delivery_status_raw:string}>
|
||||
*/
|
||||
public function findReturnedByCustomer(array $customer, int $excludeOrderId, int $limit = 10): array
|
||||
{
|
||||
$email = trim((string) ($customer['email'] ?? ''));
|
||||
$phone = trim((string) ($customer['phone'] ?? ''));
|
||||
$name = trim((string) ($customer['name'] ?? ''));
|
||||
$phoneDigits = preg_replace('/[^0-9]+/', '', $phone) ?? '';
|
||||
|
||||
if ($email === '' && strlen($phoneDigits) < 6 && $name === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$limit = max(1, min($limit, 50));
|
||||
|
||||
$sql = 'SELECT sp.order_id,
|
||||
o.internal_order_number,
|
||||
COALESCE(o.ordered_at, o.source_created_at, o.fetched_at) AS ordered_at,
|
||||
COALESCE(sp.tracking_number, "") AS tracking_number,
|
||||
COALESCE(sp.provider, "") AS provider,
|
||||
COALESCE(sp.delivery_status_raw, "") AS delivery_status_raw
|
||||
FROM shipment_packages sp
|
||||
INNER JOIN orders o ON o.id = sp.order_id
|
||||
INNER JOIN order_addresses a2 ON a2.order_id = sp.order_id AND a2.address_type = "customer"
|
||||
WHERE sp.delivery_status = "returned"
|
||||
AND sp.order_id != :exclude_id
|
||||
AND (
|
||||
(:email <> "" AND LOWER(TRIM(a2.email)) = LOWER(TRIM(:email)))
|
||||
OR
|
||||
(LENGTH(:phone_digits) >= 6
|
||||
AND REGEXP_REPLACE(a2.phone, "[^0-9]+", "") = :phone_digits)
|
||||
OR
|
||||
(:name <> "" AND LOWER(TRIM(a2.name)) = LOWER(TRIM(:name)))
|
||||
)
|
||||
GROUP BY sp.order_id
|
||||
ORDER BY ordered_at DESC
|
||||
LIMIT :lim';
|
||||
|
||||
try {
|
||||
$statement = $this->pdo->prepare($sql);
|
||||
$statement->bindValue(':exclude_id', $excludeOrderId, PDO::PARAM_INT);
|
||||
$statement->bindValue(':email', $email, PDO::PARAM_STR);
|
||||
$statement->bindValue(':phone_digits', $phoneDigits, PDO::PARAM_STR);
|
||||
$statement->bindValue(':name', $name, PDO::PARAM_STR);
|
||||
$statement->bindValue(':lim', $limit, PDO::PARAM_INT);
|
||||
$statement->execute();
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(static function (array $row): array {
|
||||
return [
|
||||
'order_id' => (int) ($row['order_id'] ?? 0),
|
||||
'internal_order_number' => (string) ($row['internal_order_number'] ?? ''),
|
||||
'ordered_at' => (string) ($row['ordered_at'] ?? ''),
|
||||
'tracking_number' => (string) ($row['tracking_number'] ?? ''),
|
||||
'provider' => (string) ($row['provider'] ?? ''),
|
||||
'delivery_status_raw' => (string) ($row['delivery_status_raw'] ?? ''),
|
||||
];
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
private function nullStr(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
|
||||
Reference in New Issue
Block a user