---
phase: 66-allegro-delivery-tracking
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/Modules/Shipments/AllegroTrackingService.php
  - src/Modules/Shipments/DeliveryStatus.php
  - src/Modules/Cron/ShipmentTrackingHandler.php
autonomous: true
delegation: auto
---

<objective>
## Goal
Zaimplementować śledzenie statusu przesyłek Allegro Delivery (numery A-*) przez publiczne edge API Allegro. Aktualnie `AllegroTrackingService` zwraca `null` dla przesyłek nie-InPost. Po zmianie — pobiera statusy z `https://edge.allegro.pl/ad/tracking?packageNo={nr}` i normalizuje je do istniejącego systemu `DeliveryStatus`.

## Purpose
Przesyłki Allegro Delivery (One Kurier, DPD via Allegro, itp.) nie mają śledzenia w orderPRO. Użytkownicy muszą ręcznie sprawdzać status na allegro.pl. Ta zmiana automatyzuje ten proces.

## Output
- `AllegroTrackingService` pobiera statusy z edge API dla przesyłek non-InPost
- `DeliveryStatus` ma rozszerzony mapping Allegro o statusy z edge API (opisy PL)
- Rate limiting: max 1 request na minutę per przesyłka (w cron handler)
</objective>

<context>
## Project Context
@.paul/PROJECT.md
@.paul/STATE.md

## Source Files
@src/Modules/Shipments/AllegroTrackingService.php
@src/Modules/Shipments/DeliveryStatus.php
@src/Modules/Cron/ShipmentTrackingHandler.php

## API Research
Edge API endpoint (publiczny, bez autoryzacji):
- URL: `https://edge.allegro.pl/ad/tracking?packageNo={trackingNumber}`
- Header: `Accept: application/vnd.allegro.internal.v1+json`
- Response: `{"status": [{"eventTimestamp": "ISO8601", "description": "Opis PL"}]}`
- Ostatni element tablicy = aktualny status
- Opisy są po polsku, np.:
  - "Przesyłka została przygotowana przez nadawcę"
  - "Przesyłka została nadana"
  - "Przesyłka została podjęta z punktu przez kuriera"
  - "Przesyłka została odebrana przez kuriera"
  - "Kurier przekazał przesyłkę do magazynu"
</context>

<acceptance_criteria>

## AC-1: Allegro Delivery tracking zwraca status
```gherkin
Given przesyłka z provider=allegro_wza i tracking_number zaczynający się od "A"
When cron tracking handler odpytuje AllegroTrackingService
Then serwis pobiera status z edge.allegro.pl/ad/tracking
And zwraca znormalizowany status + opis po polsku
```

## AC-2: Mapowanie opisów na znormalizowane statusy
```gherkin
Given odpowiedź z edge API zawiera description np. "Przesyłka została nadana"
When AllegroTrackingService przetwarza odpowiedź
Then mapuje opis na raw status (np. "shipped") i normalizuje przez DeliveryStatus
And status_raw zawiera oryginalny opis z API
```

## AC-3: Rate limiting — max 1 request/min
```gherkin
Given cron handler przetwarza wiele przesyłek allegro_wza
When odpytuje edge API
Then między kolejnymi requestami do edge.allegro.pl czeka minimum 60 sekund
And inne providery (InPost, Apaczka) nie są objęte tym limitem
```

## AC-4: Fallback InPost nadal działa
```gherkin
Given przesyłka z provider=allegro_wza i carrier_id zawierający "inpost"
When cron tracking handler odpytuje AllegroTrackingService
Then serwis nadal używa InPost API (nie edge API)
And zachowanie jest identyczne jak przed zmianą
```

</acceptance_criteria>

<tasks>

<task type="auto">
  <name>Task 1: Rozszerzenie DeliveryStatus o mapowanie Allegro edge API</name>
  <files>src/Modules/Shipments/DeliveryStatus.php</files>
  <action>
    Dodaj nową mapę `ALLEGRO_EDGE_MAP` i `ALLEGRO_EDGE_DESCRIPTIONS` do DeliveryStatus.

    Edge API zwraca opisy po polsku (nie kody). Trzeba mapować opisy na wewnętrzne klucze, a potem na znormalizowane statusy.

    Nowa mapa `ALLEGRO_EDGE_MAP` (klucz = slug z opisu, wartość = normalized status):
    ```php
    private const ALLEGRO_EDGE_MAP = [
        'przygotowana_przez_nadawce' => self::CREATED,
        'nadana' => self::CONFIRMED,
        'podjeta_z_punktu' => self::IN_TRANSIT,
        'odebrana_przez_kuriera' => self::IN_TRANSIT,
        'przekazana_do_magazynu' => self::IN_TRANSIT,
        'w_sortowni' => self::IN_TRANSIT,
        'w_doreceniu' => self::OUT_FOR_DELIVERY,
        'gotowa_do_odbioru' => self::READY_FOR_PICKUP,
        'dostarczona' => self::DELIVERED,
        'doreczona' => self::DELIVERED,
        'zwrocona' => self::RETURNED,
        'anulowana' => self::CANCELLED,
        'problem' => self::PROBLEM,
    ];
    ```

    Nowa mapa `ALLEGRO_EDGE_DESCRIPTIONS` — klucz = slug, wartość = oryginalny opis PL (identyczny jak z API).

    Dodaj nowy provider `allegro_edge` do:
    - `PROVIDER_MAPS` array
    - `PROVIDER_DESCRIPTIONS` array
    - match w `normalize()` i `description()`

    Dodaj statyczną metodę `slugifyAllegroDescription(string $description): string` — konwertuje opis PL na slug:
    1. Usuwa prefiks "Przesyłka została " / "Kurier "
    2. Bierze główne słowo kluczowe
    3. Zamienia polskie znaki na ASCII
    4. Zamienia spacje na podkreślenia
    5. Zwraca lowercase slug

    WAŻNE: Metoda musi obsługiwać nieznane opisy — jeśli slug nie istnieje w mapie, zwróć sam slug jako raw_status i self::UNKNOWN jako normalized.
  </action>
  <verify>Klasa DeliveryStatus kompiluje się bez błędów. Nowe stałe ALLEGRO_EDGE_MAP i ALLEGRO_EDGE_DESCRIPTIONS istnieją. Provider 'allegro_edge' jest obsługiwany w normalize() i description().</verify>
  <done>AC-2 satisfied: mapowanie opisów na znormalizowane statusy zdefiniowane.</done>
</task>

<task type="auto">
  <name>Task 2: Implementacja fetchAllegroEdgeStatus w AllegroTrackingService</name>
  <files>src/Modules/Shipments/AllegroTrackingService.php</files>
  <action>
    Zmodyfikuj `AllegroTrackingService::getDeliveryStatus()` aby obsługiwał przesyłki Allegro Delivery (non-InPost):

    1. W metodzie `getDeliveryStatus()`, po bloku `if (str_contains(... 'inpost'))`, zamiast `return null` dodaj:
       ```php
       return $this->fetchAllegroEdgeStatus($trackingNumber);
       ```

    2. Dodaj nową prywatną metodę `fetchAllegroEdgeStatus(string $trackingNumber): ?array`:
       ```php
       private function fetchAllegroEdgeStatus(string $trackingNumber): ?array
       {
           try {
               $url = 'https://edge.allegro.pl/ad/tracking?packageNo=' . rawurlencode($trackingNumber);
               $response = $this->edgeApiRequest($url);
               
               $statuses = $response['status'] ?? [];
               if (!is_array($statuses) || $statuses === []) {
                   return null;
               }
               
               // Ostatni element = najnowszy status
               $latest = end($statuses);
               $description = trim((string) ($latest['description'] ?? ''));
               if ($description === '') {
                   return null;
               }
               
               $slug = DeliveryStatus::slugifyAllegroDescription($description);
               
               return [
                   'status' => DeliveryStatus::normalize('allegro_edge', $slug),
                   'status_raw' => $description,
                   'description' => $description,
               ];
           } catch (Throwable) {
               return null;
           }
       }
       ```

    3. Dodaj nową prywatną metodę `edgeApiRequest(string $url): array` — osobna od istniejącej `apiRequest()` bo nie wymaga autoryzacji:
       ```php
       private function edgeApiRequest(string $url): array
       {
           $ch = curl_init($url);
           if ($ch === false) {
               return [];
           }
           
           $opts = [
               CURLOPT_RETURNTRANSFER => true,
               CURLOPT_TIMEOUT => 15,
               CURLOPT_CONNECTTIMEOUT => 5,
               CURLOPT_SSL_VERIFYPEER => true,
               CURLOPT_SSL_VERIFYHOST => 2,
               CURLOPT_HTTPHEADER => [
                   'Accept: application/vnd.allegro.internal.v1+json',
                   'Content-Type: application/vnd.allegro.internal.v1+json',
               ],
           ];
           
           $caPath = $this->getCaBundlePath();
           if ($caPath !== null) {
               $opts[CURLOPT_CAINFO] = $caPath;
           }
           
           curl_setopt_array($ch, $opts);
           $body = curl_exec($ch);
           $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
           $ch = null;
           
           if ($body === false || $httpCode < 200 || $httpCode >= 300) {
               return [];
           }
           
           $json = json_decode((string) $body, true);
           return is_array($json) ? $json : [];
       }
       ```

    NIE zmieniaj istniejącej metody `fetchInpostStatus()` ani `apiRequest()` — to osobne ścieżki.
  </action>
  <verify>AllegroTrackingService kompiluje się. Metoda getDeliveryStatus nie zwraca null dla non-InPost przesyłek (wywołuje fetchAllegroEdgeStatus). Metoda edgeApiRequest wysyła Accept: application/vnd.allegro.internal.v1+json.</verify>
  <done>AC-1 satisfied: Allegro Delivery tracking pobiera status z edge API. AC-4 satisfied: InPost path bez zmian.</done>
</task>

<task type="auto">
  <name>Task 3: Rate limiting w ShipmentTrackingHandler dla Allegro edge API</name>
  <files>src/Modules/Cron/ShipmentTrackingHandler.php</files>
  <action>
    Dodaj rate limiting do `ShipmentTrackingHandler::handle()` — max 1 request na 60 sekund do edge.allegro.pl.

    1. Dodaj stałą:
       ```php
       private const ALLEGRO_EDGE_RATE_LIMIT_SECONDS = 60;
       ```

    2. W metodzie `handle()`, przed pętlą `foreach`, dodaj zmienną śledzącą czas ostatniego requestu Allegro edge:
       ```php
       $lastAllegroEdgeRequestTime = 0.0;
       ```

    3. Wewnątrz pętli `foreach`, PO uzyskaniu `$service` a PRZED wywołaniem `$service->getDeliveryStatus()`, dodaj sprawdzenie:
       ```php
       if ($provider === 'allegro_wza') {
           $carrierId = strtolower(trim((string) ($package['carrier_id'] ?? '')));
           $isInpost = str_contains($carrierId, 'inpost') || str_contains($carrierId, 'paczkomat');
           
           if (!$isInpost) {
               $elapsed = microtime(true) - $lastAllegroEdgeRequestTime;
               if ($elapsed < self::ALLEGRO_EDGE_RATE_LIMIT_SECONDS) {
                   $sleepTime = (int) ceil(self::ALLEGRO_EDGE_RATE_LIMIT_SECONDS - $elapsed);
                   sleep($sleepTime);
               }
               // Zaraz po sleep (lub bez), przed wywołaniem getDeliveryStatus:
               $lastAllegroEdgeRequestTime = microtime(true);
           }
       }
       ```

    WAŻNE:
    - Rate limit dotyczy TYLKO requestów do edge.allegro.pl (non-InPost allegro_wza)
    - InPost i Apaczka requestów NIE ograniczaj
    - sleep() w cronie jest OK — to background process
  </action>
  <verify>ShipmentTrackingHandler kompiluje się. Stała ALLEGRO_EDGE_RATE_LIMIT_SECONDS = 60 istnieje. Kod rate limitingu jest w pętli foreach przed getDeliveryStatus dla allegro_wza non-inpost.</verify>
  <done>AC-3 satisfied: max 1 request/min do edge.allegro.pl.</done>
</task>

</tasks>

<boundaries>

## DO NOT CHANGE
- src/Modules/Shipments/InpostTrackingService.php
- src/Modules/Shipments/ApaczkaTrackingService.php
- src/Modules/Shipments/ShipmentTrackingInterface.php
- src/Modules/Shipments/ShipmentTrackingRegistry.php
- src/Modules/Shipments/ShipmentPackageRepository.php
- src/Modules/Cron/CronHandlerFactory.php
- Istniejące mapy INPOST_MAP, APACZKA_MAP w DeliveryStatus
- Istniejąca metoda fetchInpostStatus() w AllegroTrackingService

## SCOPE LIMITS
- Nie tworzymy nowych klas — rozszerzamy istniejące
- Nie zmieniamy schematu DB — delivery_status_raw już obsługuje dowolne stringi
- Nie dodajemy UI — tracking UI już istnieje i działa z dowolnym statusem
- Nie tworzymy unit testów w tym planie

</boundaries>

<verification>
Before declaring plan complete:
- [ ] DeliveryStatus::normalize('allegro_edge', 'nadana') zwraca 'confirmed'
- [ ] DeliveryStatus::slugifyAllegroDescription('Przesyłka została nadana') zwraca slug mapowany na status
- [ ] AllegroTrackingService::getDeliveryStatus() dla non-InPost allegro_wza wywołuje edge API
- [ ] AllegroTrackingService::getDeliveryStatus() dla InPost allegro_wza nadal używa InPost API
- [ ] ShipmentTrackingHandler ma rate limit 60s między requestami edge.allegro.pl
- [ ] Żadne chronione pliki nie zostały zmodyfikowane
- [ ] PHP syntax check: `php -l` na każdym zmienionym pliku
</verification>

<success_criteria>
- Wszystkie 3 taski auto completed
- Przesyłki Allegro Delivery (A-numery) mają automatyczny tracking statusu
- Rate limit chroni przed blokadą przez Allegro
- Istniejący tracking InPost i Apaczka bez zmian
</success_criteria>

<output>
After completion, create `.paul/phases/66-allegro-delivery-tracking/66-01-SUMMARY.md`
</output>
