---
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>
