# Conflicts:
#	.paul/PROJECT.md
#	.paul/ROADMAP.md
#	.paul/STATE.md
#	.paul/codebase/tech_changelog.md
#	resources/lang/pl.php
#	resources/views/shipments/prepare.php
#	routes/web.php
#	src/Modules/Settings/IntegrationsHubController.php
#	src/Modules/Shipments/ShipmentController.php
This commit is contained in:
2026-05-16 01:04:56 +02:00
50 changed files with 8670 additions and 15 deletions

View File

@@ -0,0 +1,292 @@
---
phase: 127-polkurier-integration-foundation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- database/migrations/20260514_000114_create_polkurier_integration_settings.sql
- src/Modules/Settings/PolkurierIntegrationRepository.php
- src/Modules/Settings/PolkurierApiClient.php
- src/Modules/Settings/PolkurierIntegrationController.php
- src/Modules/Settings/IntegrationsHubController.php
- resources/views/settings/integrations/polkurier.php
- routes/web.php
- .paul/codebase/db_schema.md
- .paul/codebase/architecture.md
- .paul/codebase/tech_changelog.md
autonomous: true
delegation: auto
---
<objective>
## Goal
Dodac fundament integracji z brokerem kurierskim polkurier.pl jako rownolegla alternatywe dla Apaczki: pojedyncza globalna konfiguracja w `/settings/integrations/polkurier` (szyfrowany Token API), karta w hubie integracji `/settings/integrations`, oraz realny test polaczenia z API polkuriera (wywolanie endpointu zwracajacego dane konta lub liste uslug).
## Purpose
Operator dostaje druga bramke kurierska oprocz Apaczki. Faza zamyka warstwe ustawien i testu polaczenia — tworzenie przesylek, etykiety, tracking i mapowania metod dostawy beda dolozone w kolejnych fazach (analogicznie do tego jak Phase 116/117 zamknely tylko ustawienia HostedSMS/SMSPLANET przed pelnym SMS-em). Apaczka i jej `ShipmentProviderInterface` zostaja niezmienione — polkurier dziala obok.
## Output
- Migracja DDL tworzaca `polkurier_integration_settings` (mirror `apaczka_integration_settings`).
- `PolkurierIntegrationRepository` szyfrujacy Token API przez `IntegrationSecretCipher` i zarzadzajacy pojedynczym rekordem `integrations.type='polkurier'` (id rekordu zalezne, nie wpisywane na sztywno).
- `PolkurierApiClient` realnie wywolujacy API polkuriera w trybie test (endpoint zwracajacy dane konta / liste uslug — wybor zgodnie z dokumentacja SDK ze strony bazy wiedzy polkuriera, decyzja na czas implementacji).
- `PolkurierIntegrationController` z routami `GET /settings/integrations/polkurier`, `POST .../save`, `POST .../test`.
- Wiersz "polkurier" w hubie `/settings/integrations` ze statusem konfiguracji, sekretu, aktywnosci i ostatniego testu.
- Aktualizacja dokumentow projektowych (db_schema, architecture, tech_changelog).
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Prior Art (wzorzec do skopiowania)
@src/Modules/Settings/ApaczkaIntegrationRepository.php
@src/Modules/Settings/ApaczkaApiClient.php
@src/Modules/Settings/ApaczkaIntegrationController.php
@src/Modules/Settings/HostedSmsIntegrationRepository.php
@src/Modules/Settings/HostedSmsIntegrationController.php
@src/Modules/Settings/SmsplanetIntegrationRepository.php
@src/Modules/Settings/SmsplanetIntegrationController.php
@src/Modules/Settings/IntegrationsHubController.php
@src/Modules/Settings/IntegrationSecretCipher.php
@database/migrations/20260512_000109_consolidate_fakturownia_to_single_instance.sql
## Codebase docs
@.paul/codebase/architecture.md
@.paul/codebase/db_schema.md
## Routy i widok wzorcowe
@routes/web.php
@resources/views/settings/integrations/apaczka.php
<clarifications>
- **Zakres MVP** — Jaki zakres ma pokryc pierwsza faza integracji polkurier.pl?
- Odpowiedz: Tylko fundament + test polaczenia (wzorzec faz 116/117).
- **Model konta** — Pojedyncza globalna instancja czy wieloinstancyjna?
- Odpowiedz: Single instance (jak Apaczka/InPost) — fixed `polkurier_integration_settings.id=1`, jeden rekord `integrations.type='polkurier'`.
- **Apaczka vs polkurier** — Zastapienie czy rownoleglosc?
- Odpowiedz: Obok Apaczki — oba dostawcy dzialaja, Apaczka netknieta, operator wybiera w kolejnych fazach (gdy `PolkurierShipmentService` zostanie dodany).
- **Paczkomaty / punkty odbioru** — Czy w tej fazie?
- Odpowiedz: Poza zakresem tej fazy. Operator potwierdzil "ma dzialac jak Apaczka"; obsluga punktow odbioru pojawi sie razem z `PolkurierShipmentService` w nastepnej fazie (tak jak Apaczka — receiver_point_id w shipment_packages).
</clarifications>
</context>
<acceptance_criteria>
## AC-1: Migracja tworzy single-instance tabele konfiguracji
```gherkin
Given XAMPP MySQL jest online i migracje sa zacommitowane
When operator uruchamia `php bin/migrate.php`
Then powstaje tabela `polkurier_integration_settings` z kolumnami: `id TINYINT UNSIGNED PK` (always 1), `integration_id INT UNSIGNED UNIQUE NULL FK -> integrations(id) CASCADE`, `api_token_encrypted TEXT NULL`, `environment ENUM('production','sandbox') NOT NULL DEFAULT 'production'`, `default_label_format VARCHAR(8) NOT NULL DEFAULT 'PDF'`, `created_at`, `updated_at`
And ponowne uruchomienie migracji jest no-op (`CREATE TABLE IF NOT EXISTS`)
```
## AC-2: Repozytorium szyfruje Token API i zarzadza pojedynczym rekordem integrations
```gherkin
Given migracja AC-1 wykonana
When operator zapisuje konfiguracje przez `PolkurierIntegrationRepository::saveSettings([api_token => 'XYZ', environment => 'production', is_active => 1])`
Then w `integrations` powstaje (lub zostaje zaktualizowany) jeden rekord `type='polkurier'`, `polkurier_integration_settings.id=1` ma uzupelnione `integration_id` i zaszyfrowane `api_token_encrypted`
And `getSettings()` zwraca rekord BEZ surowego tokena, jedynie z flaga `has_api_token: bool`
And `getCredentials()` zwraca odszyfrowany Token API tylko gdy konfiguracja jest kompletna i aktywna (`is_active=1`)
```
## AC-3: Endpoint testowy realnie wywoluje API polkuriera i zapisuje wynik
```gherkin
Given operator zapisal poprawny Token API
When operator klika "Testuj polaczenie" w `/settings/integrations/polkurier`
Then `PolkurierApiClient` wykonuje realne wywolanie HTTP do API polkuriera (endpoint nie pisany na sztywno w PLAN, wybierany przez implementatora z dokumentacji `Polkurier_WebService_API_1_1.pdf` preferowany endpoint typu "lista uslug" / "konto" zwracajacy dane bez tworzenia przesylki)
And `integrations.last_test_status / last_test_http_code / last_test_message / last_test_at` zostaja zaktualizowane przez `IntegrationsRepository::updateTestResult()`
And UI pokazuje czytelny komunikat (sukces albo blad z opisem) bez surowego dump-u JSON/XML
And brak zaszyfrowanego tokena w logach (`storage/logs/app.log` nie zawiera plaintext tokena nawet w przypadku bledu API)
```
## AC-4: Karta polkurier w hubie integracji
```gherkin
Given konfiguracja istnieje (kompletna albo niekompletna)
When operator otwiera `/settings/integrations`
Then widzi wiersz "polkurier" z statusem: skonfigurowana (tak/nie), token zapisany (tak/nie), aktywna (tak/nie), ostatni test (timestamp + ok/error)
And klikniecie wiersza prowadzi do `/settings/integrations/polkurier`
```
## AC-5: Apaczka i istniejace ShipmentProviderRegistry netkniete
```gherkin
Given Apaczka jest aktywna i zarejestrowana w `ShipmentProviderRegistry`
When polkurier zostaje dodany do hubu integracji
Then `ShipmentProviderRegistry` NIE rejestruje polkuriera (brak `PolkurierShipmentService` w tej fazie)
And tworzenie przesylek Apaczka dziala bez zmian
And `routes/web.php` nie modyfikuje wiring ApaczkaShipmentService/Tracking
```
## AC-6: Dokumentacja zaktualizowana
```gherkin
Given plan ukonczony
When operator otwiera `.paul/codebase/db_schema.md`
Then sekcja Integrations zawiera definicje `polkurier_integration_settings`
And `.paul/codebase/architecture.md` zawiera sekcje "Phase 127 - polkurier Integration Settings" z opisem repository, api client, controller, hub
And `.paul/codebase/tech_changelog.md` zawiera wpis chronologiczny z data i opisem co + dlaczego
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Migracja DB + PolkurierIntegrationRepository</name>
<files>
database/migrations/20260514_000114_create_polkurier_integration_settings.sql,
src/Modules/Settings/PolkurierIntegrationRepository.php
</files>
<action>
1) Migracja DDL `CREATE TABLE IF NOT EXISTS polkurier_integration_settings` zgodna z AC-1 (mirror `apaczka_integration_settings` + analogia do `hostedsms_integration_settings`/`smsplanet_integration_settings` w zakresie ENUM environment). InnoDB, utf8mb4_unicode_ci. FK `integration_id REFERENCES integrations(id) ON DELETE CASCADE`. Migracja MUSI byc idempotentna (re-run = no-op zgodnie z decyzja projektu: nigdy `SELECT 1;`, tylko DDL — patrz decision 2026-05-10 w PROJECT.md).
2) `PolkurierIntegrationRepository final class` w `src/Modules/Settings/`:
- konstruktor `(Medoo $db, IntegrationSecretCipher $cipher, IntegrationsRepository $integrations)`,
- `getSettings(): array` — JOIN `integrations` z `polkurier_integration_settings` po `integration_id`, zwraca `has_api_token: bool` (NIE plaintext), `environment`, `default_label_format`, `is_active`, `last_test_*`,
- `saveSettings(array $payload): void` — upsert: gdy brak rekordu `integrations.type='polkurier'`, twórz przez `IntegrationsRepository::ensureIntegration('polkurier', $name)`; gdy token jest pustym stringiem -> nie nadpisuj (BC z patternem fakturowni); inaczej zaszyfruj przez `IntegrationSecretCipher::encrypt()`. Walidacja serwerowa wymaganych pol.
- `getCredentials(): ?array` — zwraca `['api_token' => string, 'environment' => string, 'integration_id' => int]` TYLKO gdy `is_active=1` AND `api_token_encrypted IS NOT NULL`; inaczej `null`. Uzywany przez `PolkurierApiClient` i przyszly `PolkurierShipmentService`.
- `getIntegrationId(): ?int` — single source of truth dla przyszlych integracji (analogicznie do `FakturowniaIntegrationRepository`).
Avoid: tworzenia drugiego rekordu `integrations.type='polkurier'` (analogicznie do migracji konsolidacyjnej Fakturowni 20260512_000109 — single instance jest twardym kontraktem); pisania tokenu plaintext do logow; sklejania SQL stringiem (Medoo + prepared statements only).
</action>
<verify>
`php bin/migrate.php` -> brak bledow, `SHOW CREATE TABLE polkurier_integration_settings;` -> kolumny zgodne z AC-1.
`php -r "require 'bootstrap/app.php'; $r = $app->make(PolkurierIntegrationRepository::class); var_dump($r->getSettings());"` -> array z `has_api_token=false`.
</verify>
<done>AC-1 satisfied (migracja DDL idempotentna), AC-2 satisfied (repozytorium szyfruje token, zwraca has_api_token bool, getCredentials gating na is_active).</done>
</task>
<task type="auto">
<name>Task 2: PolkurierApiClient + Controller + widok formularza</name>
<files>
src/Modules/Settings/PolkurierApiClient.php,
src/Modules/Settings/PolkurierIntegrationController.php,
resources/views/settings/integrations/polkurier.php,
routes/web.php
</files>
<action>
1) `PolkurierApiClient final class` w `src/Modules/Settings/`:
- cURL klient z `SslCertificateResolver::resolve()` (zgodnie z patternem `FakturowniaApiClient`, `HostedSmsApiClient`, `SmsplanetApiClient`),
- PHP 8.5: ZAKAZ `curl_close()` (decision 2026-05-10 — wycieka `Deprecated` HTML przed JSON response),
- metoda `testConnection(string $apiToken, string $environment): array` zwracajaca `['ok' => bool, 'http_code' => int, 'message' => string]`,
- WYBOR endpointu testowego: implementator MUSI sprawdzic `Polkurier_WebService_API_1_1.pdf` (link w opisie API polkuriera, baza wiedzy artykul "interfejs api do pobrania") i wybrac endpoint nie tworzacy przesylki (preferencja: "lista uslug" / "dane konta" / "wycena testowa"). Wybor udokumentowac w naglowku klasy.
- przyszle stuby `createShipment()`, `downloadLabel()`, `trackShipment()`, `cancelShipment()` — rzucajace `RuntimeException("Not implemented in Phase 127")`; dolozone w kolejnych fazach.
2) `PolkurierIntegrationController final class` (mirror `HostedSmsIntegrationController` 1:1):
- routes: `GET /settings/integrations/polkurier` (`edit`), `POST /settings/integrations/polkurier/save` (CSRF `_token`), `POST /settings/integrations/polkurier/test` (CSRF `_token`),
- `test()` -> walidacja zapisanej konfiguracji -> `PolkurierApiClient::testConnection()` -> `IntegrationsRepository::updateTestResult()` -> Flash `Flash::push('success'|'danger', ...)` (Phase 120 pattern, NIE `Flash::set('polkurier.test')`),
- redirect przez `RedirectPathResolver`.
3) Widok `resources/views/settings/integrations/polkurier.php`:
- dziedziczy z `layouts/app.php`,
- formularz: token API (password input z placeholderem "Pozostaw puste aby nie zmieniac" gdy `has_api_token=true`), environment (select production/sandbox), domyslny format etykiety (PDF/ZPL/EPL), checkbox `is_active`, przycisk "Zapisz" i osobny "Testuj polaczenie",
- `_token` na obu formularzach (CSRF, nie `_csrf_token` — decision 2026-03-13),
- alerty wylacznie przez komponent `resources/views/components/alert.php` (Phase 120 contract — NIE inline `<div class="alert alert--*">`),
- potwierdzenia akcji destrukcyjnych (na przyszlosc) przez `window.OrderProAlerts.confirm({...})` options-object API (decision Phase 114/120),
- bez inline `<style>` — style przez `resources/scss/modules/_integrations.scss` jezeli czegokolwiek brakuje (CLAUDE.md: zero CSS w widokach).
4) `routes/web.php` — wpiac controller w sekcji Settings/Integrations (po Apaczka, przed HostedSMS dla porzadku alfabetycznego). DI: `new PolkurierIntegrationController($template, $translator, $auth, new PolkurierIntegrationRepository($app->db(), $cipher, $integrationsRepository), new PolkurierApiClient($timeoutSeconds = 15))`.
Avoid: kopiowania `apaczka.php` bez przegladu (Apaczka ma duzy formularz z domyslnymi wymiarami — niepotrzebne w MVP polkuriera); dodawania `PolkurierShipmentService` do `ShipmentProviderRegistry` (out of scope, AC-5 wymaga netknietego registry); modyfikacji `apaczka_integration_settings` lub `IntegrationsRepository` poza `updateTestResult()` (reuse, nie refactor).
</action>
<verify>
Build PHP `php -l src/Modules/Settings/PolkurierApiClient.php` i `php -l src/Modules/Settings/PolkurierIntegrationController.php` -> No syntax errors.
Recznie: zaloguj sie, otworz `/settings/integrations/polkurier` -> formularz renderuje sie bez bledow, alerty stylowane.
Zapis pustego tokenu -> blad walidacji. Zapis prawdziwego tokenu (dev konto polkurier jezeli operator ma) -> rekord w DB ma niepuste `api_token_encrypted`.
Klik "Testuj polaczenie" z prawdziwym tokenem -> `integrations.last_test_status='ok'` w DB; bledny token -> `last_test_status='error'` + zrozumialy komunikat w UI.
</verify>
<done>AC-3 satisfied (realne wywolanie API, zapis wyniku, brak plaintext tokena w logach); fundament UI/wiring na miejscu.</done>
</task>
<task type="auto">
<name>Task 3: Hub integracji + aktualizacja dokumentow projektowych</name>
<files>
src/Modules/Settings/IntegrationsHubController.php,
resources/views/settings/integrations/index.php,
.paul/codebase/db_schema.md,
.paul/codebase/architecture.md,
.paul/codebase/tech_changelog.md
</files>
<action>
1) `IntegrationsHubController`:
- dodaj parametr konstruktora `PolkurierIntegrationRepository $polkurier`,
- dodaj metode `buildPolkurierRow(): array` zwracajaca te same klucze co `buildApaczkaRow()` (`name`, `configured`, `has_secret`, `is_active`, `last_test_status`, `last_test_at`, `last_test_message`, `link`),
- wcisniecie wiersza do listy w `index()` (kolejnosc: po Apaczka, przed Allegro/inni — sprawdz aktualny porzadek w `index.php`),
- `routes/web.php` - rozszerzyc wiring kontrolera o instancje `PolkurierIntegrationRepository` (kompatybilnie z istniejacymi 5+ params).
2) `resources/views/settings/integrations/index.php`:
- jezeli widok generuje wiersze z tablicy zwracanej przez controller, ZADNA zmiana widoku nie jest potrzebna,
- jezeli widok ma hardkodowana liste rzedow (sprawdz przed edycja) — dodaj wiersz polkurier w tym samym wzorcu co Apaczka.
3) `.paul/codebase/db_schema.md` — sekcja Integrations, po `apaczka_integration_settings`:
- dodac pelna tabele kolumn `polkurier_integration_settings` (zgodnie z AC-1),
- oznaczenie `(Phase 127; fixed 1 row)` w naglowku tabeli,
- update licznika `Total tables: 62` i `Updated: 2026-05-14`.
4) `.paul/codebase/architecture.md`:
- dodac sekcje `## Phase 127 — polkurier Integration Settings` po `## Phase 117 - SMSPLANET Integration Settings`,
- opis `PolkurierIntegrationRepository`, `PolkurierApiClient`, `PolkurierIntegrationController`, integracja z `IntegrationsHubController`,
- zaznaczyc ze `ShipmentProviderRegistry` nie zostal zmodyfikowany (deferred do osobnej fazy).
5) `.paul/codebase/tech_changelog.md` — dopisac wpis chronologiczny z data 2026-05-14:
- co: fundament polkurier integration (settings + test polaczenia),
- dlaczego: alternatywa dla Apaczki na zyczenie operatora,
- referencja do Phase 127.
Avoid: dotykania PROJECT.md (Decisions / Validated Requirements) — to robi UNIFY, nie APPLY; modyfikacji ROADMAP.md (robi to /paul:plan w step update_state); zmiany schematu innych tabel.
</action>
<verify>
`/settings/integrations` -> widac wiersz polkurier z prawidlowym statusem konfiguracji.
`grep -c "polkurier_integration_settings" .paul/codebase/db_schema.md` -> co najmniej 1 trafienie.
`grep -c "Phase 127" .paul/codebase/architecture.md` -> co najmniej 1 trafienie.
Apaczka wiersz w hubie nadal sie renderuje (regresja zero — AC-5).
</verify>
<done>AC-4 satisfied (karta polkurier w hubie), AC-5 satisfied (Apaczka netknieta), AC-6 satisfied (dokumenty zaktualizowane).</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- `src/Modules/Shipments/*` — caly modul Shipments (registries, ApaczkaShipmentService, InpostShipmentService, ShipmentController). `PolkurierShipmentService` to osobna faza.
- `src/Modules/Settings/Apaczka*.php` — Apaczka netknieta.
- `database/migrations/*` istniejace pliki — tylko nowa migracja `20260514_000114_*`.
- `src/Modules/Settings/IntegrationSecretCipher.php` — reuse, zero refaktoru.
- `routes/web.php` istniejace routy — tylko dodajemy 3 nowe (polkurier GET/save/test).
- `resources/views/components/alert.php` — Phase 120 contract, zero modyfikacji.
## SCOPE LIMITS
- Brak `PolkurierShipmentService` (tworzenie przesylki) — kolejna faza.
- Brak `PolkurierTrackingService` (delivery polling) — kolejna faza po Shipment.
- Brak wpisow w `delivery_status_mappings` (provider='polkurier') — wymagaja realnego API tracking, do osobnej fazy.
- Brak mapowan metod dostawy w UI (`order_delivery_method -> polkurier service`) — wymagaja modelowania w osobnej fazie po analizie listy uslug API polkuriera.
- Brak zmian w `shipment_presets` schemacie ani UI presetow — presety beda potem.
- Brak migracji konsolidujacych z Apaczka — oba dostawcy zyja niezaleznie.
- Brak `ShipmentProviderRegistry::register('polkurier', ...)` — out of scope.
- Brak `.env` / `app_settings` flag globalnych — token siedzi tylko w `polkurier_integration_settings` (jak Apaczka/HostedSMS/SMSPLANET).
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php bin/migrate.php` przeszla bez bledow (operator manualnie po wdrozeniu — XAMPP online).
- [ ] `php -l` przeszedl dla wszystkich nowych plikow PHP (bez syntax errors).
- [ ] `/settings/integrations` renderuje wiersz polkurier obok Apaczki.
- [ ] `/settings/integrations/polkurier` formularz dziala: zapis, ponowne wczytanie, "Testuj polaczenie" zwraca rzeczywista odpowiedz API (operator wpisuje prawdziwy token).
- [ ] Apaczka konfiguracja `/settings/integrations/apaczka` dziala bez regresji.
- [ ] `ShipmentProviderRegistry` nie zna polkuriera (grep brak `polkurier` w `src/Modules/Shipments/ShipmentProviderRegistry.php`).
- [ ] Wszystkie acceptance criteria spelnione.
</verification>
<success_criteria>
- Single-instance globalna konfiguracja polkurier zapisuje sie i odczytuje (Token zaszyfrowany, `has_api_token` flag w UI).
- Realne wywolanie API polkuriera w trybie test zwraca status (ok/error) i jest widoczne w hubie i panelu integracji.
- Apaczka dziala bez regresji obok polkuriera.
- Dokumentacja codebase (`db_schema.md`, `architecture.md`, `tech_changelog.md`) zaktualizowana.
- Zaden plik z `boundaries.DO NOT CHANGE` nie zostal zmodyfikowany.
</success_criteria>
<output>
After completion, create `.paul/phases/127-polkurier-integration-foundation/127-01-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,201 @@
---
phase: 127-polkurier-integration-foundation
plan: 01
subsystem: integrations
tags: [polkurier, courier, shipment-broker, settings, integration-hub, php]
requires:
- phase: 116-hostedsms-integration-settings
provides: single-instance integration repository pattern (IntegrationsRepository::ensureIntegration + updateTestResult + IntegrationSecretCipher)
- phase: 120-alert-component-unification
provides: resources/views/components/alert.php contract for all settings views
provides:
- polkurier_integration_settings DB table (single-instance, fixed id=1)
- PolkurierIntegrationRepository (login + Token API, AES-encrypted)
- PolkurierApiClient with verified live test connection against apimetod=test_auth_api
- /settings/integrations/polkurier UI (form + Testuj polaczenie) + hub row
- Foundation for future PolkurierShipmentService / PolkurierTrackingService
affects:
- future polkurier-shipment-service phase (uses getCredentials + verified API client contract)
- future polkurier-tracking-service phase (delivery_status_mappings provider='polkurier')
tech-stack:
added: []
patterns:
- single-instance integration ctor (PDO $pdo, string $secret) mirror HostedSMS/SMSPLANET
- polkurier API contract: POST https://api.polkurier.pl/, JSON body {authorization:{login,token}, apimetod, data:{platform, platform_version}}, success when top-level status='success'
- error path: payload from "response" field of envelope (string or struct) — mirror SDK ErrorException($response->get('response'))
- strict Content-Type: application/json (no charset suffix — polkurier rejects)
key-files:
created:
- database/migrations/20260514_000114_create_polkurier_integration_settings.sql
- src/Modules/Settings/PolkurierIntegrationRepository.php
- src/Modules/Settings/PolkurierApiClient.php
- src/Modules/Settings/PolkurierIntegrationController.php
- resources/views/settings/polkurier.php
modified:
- routes/web.php
- src/Modules/Settings/IntegrationsHubController.php
- resources/lang/pl.php
- .paul/codebase/db_schema.md
- .paul/codebase/architecture.md
- .paul/codebase/tech_changelog.md
key-decisions:
- "polkurier startuje jako single-instance globalna konfiguracja (mirror Apaczka/HostedSMS/SMSPLANET) — operator ma jedno konto polkurier"
- "polkurier dziala obok Apaczki — ShipmentProviderRegistry netkniety; oba dostawcy zyja niezaleznie"
- "API polkuriera wymaga login + token w body authorization (zweryfikowane w SDK polkurier-sdk); kolumna login dodana mimo ze PLAN AC-1 jej nie wymagal"
- "Brak kolumny environment ENUM — polkurier ma jeden produkcyjny endpoint, sandbox nie istnieje"
- "Test polaczenia uzywa apimetod=test_auth_api (nie tworzy przesylki, nie kosztuje); sukces gdy top-level status='success'"
- "Content-Type MUSI byc dokladnie 'application/json' — polkurier odrzuca '; charset=UTF-8' suffix"
patterns-established:
- "polkurier API client: jeden POST endpoint, ResponseStatus::SUCCESS='success', tresc bledu w polu 'response' envelope'a — wzorzec dla wszystkich przyszlych metod (createShipment, getLabel, getStatus, cancelOrder, AvailableCarriers, etc.)"
- "Strict Content-Type bez charset suffix — pattern do reuse w innych integracjach jezeli odrzucaja parametry"
duration: ~45min
started: 2026-05-14T19:00:00Z
completed: 2026-05-14T19:45:00Z
---
# Phase 127 Plan 01: polkurier Integration Foundation — Summary
**polkurier.pl broker kurierski dostepny jako alternatywa dla Apaczki: pojedyncza globalna konfiguracja w `/settings/integrations/polkurier` z zaszyfrowanym Token API + loginem, realny test polaczenia przez `apimetod=test_auth_api` zweryfikowany na produkcyjnym koncie operatora (`Autoryzacja: 1`).**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~45min (incl. live API debugging) |
| Started | 2026-05-14T19:00:00Z |
| Completed | 2026-05-14T19:45:00Z |
| Tasks | 3 of 3 completed |
| Files created | 5 |
| Files modified | 6 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Migracja tworzy single-instance tabele konfiguracji | Pass (modified) | DDL idempotentny. **Modyfikacja:** kolumna `environment ENUM` pominieta (polkurier nie ma sandbox); dodana kolumna `login VARCHAR(190)` (polkurier wymaga login+token, nie samego tokena). |
| AC-2: Repozytorium szyfruje Token API i zarzadza pojedynczym rekordem integrations | Pass | `getSettings()` zwraca `has_api_token: bool`, `saveSettings()` szyfruje przez `IntegrationSecretCipher`, `getCredentials()` gates na `is_active=1`. |
| AC-3: Endpoint testowy realnie wywoluje API polkuriera i zapisuje wynik | Pass (live verified) | Operator potwierdzil: `Polaczenie z polkurier dziala. Autoryzacja: 1` (response z `apimetod=test_auth_api`). `IntegrationsRepository::updateTestResult()` zapisuje wynik. |
| AC-4: Karta polkurier w hubie integracji | Pass | `buildPolkurierRow()` w `IntegrationsHubController` wstawia wiersz po Apaczce (semantycznie sasiednie). |
| AC-5: Apaczka i istniejace ShipmentProviderRegistry netkniete | Pass | Zerowe modyfikacje w `src/Modules/Shipments/*` i `Apaczka*`. Grep `polkurier` w `ShipmentProviderRegistry.php` -> 0 trafien. |
| AC-6: Dokumentacja zaktualizowana | Pass | `db_schema.md` +1 tabela (62 total), `architecture.md` +sekcja Phase 127, `tech_changelog.md` +wpis z deviation. |
## Accomplishments
- polkurier.pl wpiety jako drugi broker kurierski (obok Apaczki) — fundament gotowy i zweryfikowany na zywym API operatora.
- Kontrakt API polkuriera zweryfikowany i udokumentowany w `architecture.md`: POST `https://api.polkurier.pl/`, JSON `{authorization:{login,token}, apimetod, data:{platform, platform_version}}`, sukces gdy `status='success'`, tresc bledu w polu `response` envelope'a.
- 4 buggi z pierwszego draftu naprawione live (3 podczas testow operatora) — finalna implementacja sprawdzona na realnym Token API.
## Task Commits
Commits jeszcze nie utworzone (czekaja na transition step). Calosc fazy 127 zostanie zacommitowana jako jeden `feat(127):` commit.
| Task | Commit | Type | Description |
|------|--------|------|-------------|
| Task 1: Migracja + Repository | (pending) | feat | DDL + PolkurierIntegrationRepository |
| Task 2: ApiClient + Controller + widok + routy | (pending) | feat | PolkurierApiClient + Controller + view + i18n + DI |
| Task 3: Hub + dokumentacja codebase | (pending) | feat | IntegrationsHubController buildPolkurierRow + db_schema/architecture/tech_changelog |
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `database/migrations/20260514_000114_create_polkurier_integration_settings.sql` | Created | DDL tabeli + seed `integrations.type='polkurier'` (idempotentny) |
| `src/Modules/Settings/PolkurierIntegrationRepository.php` | Created | Single-instance repo, szyfrowanie tokena, getCredentials z gating na is_active |
| `src/Modules/Settings/PolkurierApiClient.php` | Created | POST do api.polkurier.pl, testConnection z apimetod=test_auth_api, stuby createShipment/getLabel/getStatus/cancelOrder |
| `src/Modules/Settings/PolkurierIntegrationController.php` | Created | GET/save/test endpointy z CSRF, flash, RedirectPathResolver |
| `resources/views/settings/polkurier.php` | Created | Formularz konfiguracji + Test polaczenia, alerty przez komponent alert.php |
| `routes/web.php` | Modified | DI wiring (Repo+Controller) + 3 routy + ctor IntegrationsHubController |
| `src/Modules/Settings/IntegrationsHubController.php` | Modified | +param polkurier + buildPolkurierRow() + wstawienie wiersza po Apaczce |
| `resources/lang/pl.php` | Modified | settings.polkurier.* + providers.polkurier |
| `.paul/codebase/db_schema.md` | Modified | +tabela polkurier_integration_settings, 61->62 |
| `.paul/codebase/architecture.md` | Modified | +sekcja Phase 127 |
| `.paul/codebase/tech_changelog.md` | Modified | +wpis 2026-05-14 z deviation |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Kolumna `login VARCHAR(190)` w tabeli zamiast samego `api_token` | API polkuriera (zweryfikowane w SDK Auth.php/Request.php) wymaga login+token w body authorization, nie samego tokena | Wszystkie przyszle wywolania API musza miec login z `getCredentials()['login']` |
| Pominieta kolumna `environment ENUM('production','sandbox')` z PLAN AC-1 | polkurier nie ma osobnego srodowiska sandbox (jeden URL: https://api.polkurier.pl/) | YAGNI; jezeli polkurier doda sandbox, dolozymy migracja `ALTER TABLE ... ADD COLUMN` |
| Wykonanie planu inline zamiast delegated:auto z planu | Swiezy kontekst API research (Config/Auth/Methods z polkurier-sdk) — agent musialby ten research powtorzyc | Brak; boundaries i AC niezmienione, deviation udokumentowana |
| `Content-Type: application/json` (bez `; charset=UTF-8` suffix) | polkurier API zwraca `Content type must be: application/json` gdy header ma charset suffix | Pattern do reuse jezeli inne integracje sa rownie strict |
| `ResponseStatus::SUCCESS = 'success'` (nie `'ok'`) | Zweryfikowane w `src/Type/ResponseStatus.php` SDK polkuriera | Wszystkie przyszle metody API musza sprawdzac `status === 'success'` |
| Tresc bledu z pola `response` envelope'a (nie `error_message`) | SDK polkuriera rzuca `ErrorException($response->get('response'))` gdy status != success | Wzorzec parser bledu dla wszystkich przyszlych metod API |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 3 | Krytyczne — bez tych poprawek test polaczenia nie zwracalby `success` |
| Scope additions | 1 | Kolumna `login` w schemacie (poza zakresem AC-1 — wymagana przez kontrakt API) |
| Scope removals | 1 | Kolumna `environment` z AC-1 pominieta (YAGNI) |
| Execution mode | 1 | Plan: `delegation:auto`. Faktycznie: inline. Boundaries i AC niezmienione. |
| Deferred | 0 | Brak |
**Total impact:** Wszystkie deviacje wymuszone realnym kontraktem API polkuriera. Plan z chwili pisania bazowal na publicznym opisie API; szczegoly (login, status='success', strict Content-Type) wyplynely dopiero przy weryfikacji SDK i testach na zywym koncie operatora.
### Auto-fixed Issues
**1. [API contract] `status === 'ok'` -> `status === 'success'`**
- **Found during:** Live test po Task 2 (operator zglosil `Status: error`)
- **Issue:** Kod sprawdzal `$status === 'ok'`, ale `ResponseStatus::SUCCESS` w SDK polkuriera = `'success'`
- **Fix:** Zmiana porownania na `$status === 'success'`; parser bledu zaktualizowany do pobierania tresci z pola `response` envelope'a (mirror SDK ErrorException)
- **Files:** `src/Modules/Settings/PolkurierApiClient.php`
- **Verification:** Drugi test operatora — komunikat `Content type must be: application/json` (faktyczna tresc z polkuriera, nie generyczne `Status: error`)
- **Commit:** TBD (przy transition)
**2. [HTTP headers] Content-Type strict**
- **Found during:** Live test po fix #1 (operator zglosil `Content type must be: application/json`)
- **Issue:** Header `Content-Type: application/json; charset=UTF-8` — polkurier robi strict equality check i odrzuca suffix `; charset=UTF-8`
- **Fix:** Zmiana na `Content-Type: application/json` (sam mime, bez parametrow)
- **Files:** `src/Modules/Settings/PolkurierApiClient.php`
- **Verification:** Trzeci test operatora — `Polaczenie z polkurier dziala. Autoryzacja: 1` (sukces)
- **Commit:** TBD (przy transition)
**3. [Error reporting] Brak tresci bledu w komunikacie UI**
- **Found during:** Live test po Task 2 (`Status: error` bez detali)
- **Issue:** Komunikat fallback `'Status: ' . $status` byl nieczytelny; tresc bledu z polkuriera siedzi w polu `response` envelope'a, nie `error_message` top-level
- **Fix:** Parser bledu czyta `response` field (string albo zagniezdzona struktura `error_message/errorMessage/message/error`), z fallbackiem na top-level `error_message/message/error` i finalnie `Status: X (HTTP Y)`
- **Files:** `src/Modules/Settings/PolkurierApiClient.php`
- **Verification:** Fix #2 mozliwy tylko dzieki temu (operator zobaczyl `Content type must be: application/json` zamiast `Status: error`)
- **Commit:** TBD (przy transition)
### Deferred Items
Brak — kontrakt API operatora zweryfikowany, fundament zamkniety. Kolejne fazy (PolkurierShipmentService, PolkurierTrackingService) sa zaplanowane jako oddzielne, swiadomie poza zakresem 127 (PLAN boundaries `SCOPE LIMITS`).
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Operator: `Status: error` po pierwszym smoke | Fix #1 (status='success') + #3 (parser bledu) — operator widzi teraz realny komunikat polkuriera |
| Operator: `Content type must be: application/json` po fix #1 | Fix #2 (strict Content-Type bez charset suffix) |
| API research nieobecny przed planem | Pre-APPLY fetche SDK polkurier-sdk (Auth/Request/Methods/Config/ResponseStatus) — kontrakt zrekonstruowany przed implementacja |
## Next Phase Readiness
**Ready:**
- `PolkurierIntegrationRepository::getCredentials()` zwraca odszyfrowany `login + api_token + default_label_format` — gotowe do uzycia w `PolkurierShipmentService`.
- `PolkurierApiClient` ma zweryfikowany kontrakt POST (single endpoint, JSON body, status='success', error w `response`) + stuby `createShipment/getLabel/getStatus/cancelOrder` z `RuntimeException("Not implemented in Phase 127")` jako placeholder dla nastepnej fazy.
- 36 metod SDK polkuriera zidentyfikowanych: `AvailableCarriers`, `OrderValuationV2`, `CreateOrder`, `GetLabel`, `GetStatus`, `CancelOrder`, `InpostParcelMachines`, `PocztexPostOffices`, `Kurier48PostOffices`, `GetCourierPoint`, `Heartbeat`, etc. — gotowe do mapowania w kolejnych planach.
- Hub integracji pokazuje stan polkuriera obok Apaczki — operator widzi obie integracje rownolegle.
**Concerns:**
- Brak `PolkurierShipmentService` — operator nie moze jeszcze nadawac przesylek przez polkuriera. Zgodne z PLAN scope (`SCOPE LIMITS`).
- Brak mapowan metod dostawy `order_delivery_method -> polkurier service` — wymaga analizy listy uslug z `AvailableCarriers` API.
- Brak `delivery_status_mappings` dla `provider='polkurier'` — tracking polling rowniez deferred.
**Blockers:**
- Operator musi uruchomic `php bin/migrate.php` na zywej bazie (XAMPP) zeby zalozyc tabele i seed rekord `integrations.type='polkurier'`. AKTUALNIE migracja juz uruchomiona (test polaczenia dzialal, wiec rekord `integrations` istnieje).
---
*Phase: 127-polkurier-integration-foundation, Plan: 01*
*Completed: 2026-05-14*

View File

@@ -0,0 +1,392 @@
---
phase: 128-polkurier-shipment-service
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/Modules/Settings/PolkurierApiClient.php
- src/Modules/Shipments/PolkurierShipmentService.php
- src/Modules/Shipments/PolkurierTrackingService.php
- src/Modules/Shipments/ShipmentController.php
- src/Modules/Shipments/DeliveryStatus.php
- src/Modules/Cron/CronHandlerFactory.php
- routes/web.php
- resources/views/shipments/prepare.php
- database/migrations/20260514_000115_seed_polkurier_delivery_status_mappings.sql
- .paul/codebase/architecture.md
- .paul/codebase/db_schema.md
- .paul/codebase/tech_changelog.md
autonomous: false
delegation: off
---
<objective>
## Goal
Dostarczyc pelna integracje wysylkowa polkurier.pl: tworzenie paczek (createOrder), pobieranie etykiet (getLabel), tracking statusow przesylek (getStatus) oraz UI do nadawania paczek w `/orders/{id}/shipment/prepare` z dynamiczna lista przewoznikow z polkuriera i obsluga punktow odbioru (Paczkomaty InPost, ORLEN, Pocztex, Kurier48). Weryfikacja na zywych zamowieniach #114 i #115 z manualnym anulowaniem po teście.
## Purpose
Phase 127 dostarczyl fundament (settings + test_auth_api). Bez ShipmentService polkurier jest "wystawiony w hubie integracji ale niedzialajacy". Operator chce realnie nadawac paczki przez polkurier obok Apaczki — wspolny use case (DPD, UPS, GLS, InPost) z lepszymi cenami.
## Output
- 2 nowe klasy w `src/Modules/Shipments/` (`PolkurierShipmentService`, `PolkurierTrackingService`) implementujace odpowiednio `ShipmentProviderInterface` i `ShipmentTrackingInterface`
- Rozszerzony `PolkurierApiClient` z 6 nowymi metodami API
- Nowy panel "polkurier" w `prepare.php` + przelacznik JS
- Migracja seedujaca `delivery_status_mappings` (provider='polkurier')
- Architektura/schema/changelog zaktualizowane
- 2 paczki utworzone na zywym koncie polkurier (#114 i #115), zweryfikowane, recznie anulowane przez operatora
</objective>
<context>
<clarifications>
- **Zakres fazy** — Co dostarczamy w Phase 128?
→ Odpowiedz: Pelny zakres: ShipmentService + TrackingService + UI prepare + delivery_status_mappings (jedna duza faza zamiast dwoch).
- **Uslugi UI** — Jak prezentujemy uslugi polkurier w UI prepare?
→ Odpowiedz: Dynamiczna lista z API polkuriera (`get_available_carriers` lub odpowiednia metoda) — analog `ApaczkaApiClient::getServiceStructure`.
- **Paczkomaty** — Czy obslugujemy paczkomaty/punkty odbioru w Phase 128?
→ Odpowiedz: Pelne wsparcie wszystkich punktow (InpostParcelMachines, PocztexPostOffices, Kurier48PostOffices, ewentualnie ORLEN).
- **Tryb testu** — Jak testujemy na #114/#115?
→ Odpowiedz: Realny createOrder na zywym koncie + manualny cancelOrder po weryfikacji przez operatora w panelu polkurier (live test, ale bez wysylki).
</clarifications>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
@.paul/codebase/architecture.md
@.paul/codebase/db_schema.md
## Prior Work
@.paul/phases/127-polkurier-integration-foundation/127-01-SUMMARY.md
## Source Files (wzorzec Apaczka)
@src/Modules/Shipments/ApaczkaShipmentService.php
@src/Modules/Shipments/ApaczkaTrackingService.php
@src/Modules/Settings/ApaczkaApiClient.php
@src/Modules/Shipments/ShipmentProviderInterface.php
@src/Modules/Shipments/ShipmentTrackingInterface.php
@src/Modules/Shipments/ShipmentProviderRegistry.php
@src/Modules/Settings/PolkurierApiClient.php
@src/Modules/Settings/PolkurierIntegrationRepository.php
@src/Modules/Shipments/ShipmentController.php
@src/Modules/Cron/CronHandlerFactory.php
@resources/views/shipments/prepare.php
@routes/web.php
## External Reference
- Oficjalne polkurier SDK: https://github.com/Polkurier/polkurier-sdk (zweryfikowany kontrakt API w Phase 127)
- Klucze apimetod w SDK: `test_auth_api`, `new_order`, `get_label`, `get_status`, `cancel_order`, `get_available_carriers`, `get_parcel_machines`, `get_post_offices`, `get_carrier_info` (do potwierdzenia per SDK)
</context>
<acceptance_criteria>
## AC-1: PolkurierApiClient — pelny kontrakt API
```gherkin
Given globalna konfiguracja polkurier jest aktywna i token zwery (Phase 127)
When backend wywoluje `PolkurierApiClient::getAvailableCarriers()` z poprawnymi credentials
Then klient zwraca tablice przewoznikow (DPD/UPS/GLS/InPost/Pocztex) z polami: `carrier_id`, `name`, `service_code`, `supports_pickup_point` (bool), `weight_limits`, `cod_supported`
And `createShipment($payload)` zwraca tablice `{order_id: string, tracking_number: string, label_url: ?string, raw: array}` przy `status='success'`
And `getLabel($orderId, $format)` zwraca binarna zawartosc etykiety (PDF/ZPL/EPL zaleznie od `default_label_format`)
And `getStatus($orderId)` zwraca tablice `{status_code, status_name, status_date}`
And `cancelOrder($orderId)` zwraca `{ok: bool, message: string}`
And kazda metoda przy `status != 'success'` rzuca `RuntimeException` z trescia z pola `response`
```
## AC-2: PolkurierShipmentService implementuje ShipmentProviderInterface
```gherkin
Given `PolkurierShipmentService` jest zarejestrowany w `ShipmentProviderRegistry` jako `code()='polkurier'`
When `createShipment(int $orderId, array $formData)` jest wywolane z danymi zamowienia #114 lub #115
Then service buduje payload polkurier (sender z `company_settings`, receiver z order_addresses, paczka z formData), woła `PolkurierApiClient::createShipment()`, zapisuje wynik do `shipment_packages` (provider='polkurier', tracking_number, label_path po pobraniu, payload_json)
And `downloadLabel($packageId, $storagePath)` pobiera plik etykiety i aktualizuje `shipment_packages.label_path`
And `checkCreationStatus($packageId)` zwraca aktualny stan z `shipment_packages` (sync vs `getStatus()` API)
And `getDeliveryServices()` zwraca cache'owana liste przewoznikow z `getAvailableCarriers()` (per-request)
```
## AC-3: PolkurierTrackingService cron tracking
```gherkin
Given paczka z `provider='polkurier'` i `tracking_number` istnieje w `shipment_packages`
When `ShipmentTrackingHandler` (cron) wywoluje `PolkurierTrackingService::getDeliveryStatus($package)`
Then service woła `PolkurierApiClient::getStatus()`, parsuje surowy status i mapuje przez `delivery_status_mappings(provider='polkurier')` na znormalizowany status z `delivery_statuses`
And zwraca `{status: <normalized>, status_raw: <polkurier_code>, description: <polkurier_label>}`
And `supports('polkurier')` zwraca true
```
## AC-4: UI prepare.php panel polkurier
```gherkin
Given operator wszedl na `/orders/115/shipment/prepare`
When wybiera "polkurier" z dropdowna przewoznika
Then JS pokazuje panel `#shipment-polkurier-panel` z dynamicznym selectem uslug (zaladowanym z `getAvailableCarriers`)
And dla uslug `supports_pickup_point=true` pojawia sie selektor punktu odbioru z listą punktów odpowiedniego typu (InPost/Pocztex/Kurier48/ORLEN)
And ukryty input `provider_code` ustawia sie na `polkurier`
And submit formularza tworzy paczke przez `ShipmentController::store()` -> `ShipmentProviderRegistry::get('polkurier')->createShipment()`
```
## AC-5: delivery_status_mappings + /settings/delivery-statuses
```gherkin
Given migracja `20260514_000115_seed_polkurier_delivery_status_mappings.sql` wykonana
When operator otwiera `/settings/delivery-statuses` (tab 'mapping')
Then widoczne sa wpisy `provider='polkurier'` z surowymi statusami polkuriera mapowanymi na znormalizowane statusy z `delivery_statuses` (np. `nowa_paczka` -> `registered`, `w_doreczeniu` -> `out_for_delivery`, `doreczone` -> `delivered`, `anulowane` -> `cancelled`)
And `DeliveryStatus::trackingUrl('polkurier', $tracking, $carrierId)` zwraca poprawny link sledzenia (URL polkuriera lub bezposrednio przewoznika)
```
## AC-6: Live test na zamowieniach #114 i #115
```gherkin
Given operator ma aktywne konto polkurier (Phase 127 test "Autoryzacja: 1")
When operator nadaje paczki na #114 i #115 przez nowy panel UI
Then dla obu zamowien `shipment_packages` zawiera wiersz `provider='polkurier'`, `status='created'`, niepusty `tracking_number`, sciezke `label_path` do pobranej etykiety
And operator widzi etykiety jako PDF w `/orders/{id}` zakladka Przesylki
And operator recznie anuluje obie paczki w panelu polkurier.pl po weryfikacji (poza zakresem kodu manualna akcja w UI polkuriera)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: PolkurierApiClient — implementacja pelnego kontraktu API</name>
<files>src/Modules/Settings/PolkurierApiClient.php</files>
<action>
Zastapic stuby `createShipment/getLabel/getStatus/cancelOrder` realnymi implementacjami i dodac `getAvailableCarriers`, `getParcelMachines`, `getPostOffices`.
Wspolny helper `private function call(string $apimetod, array $data, string $login, string $apiToken): array`:
- Buduje payload `{authorization: {login, token}, apimetod, data}`.
- Wykorzystuje istniejacy `postJson()` (Phase 127).
- Parsuje envelope: jezeli `status === 'success'` zwraca `$decoded['response']` (array). W przeciwnym razie rzuca `RuntimeException` z trescia `response` (string albo zserializowany JSON).
- PHP 8.5: NIE wywolywac `curl_close()`.
Metody publiczne:
- `createShipment(string $login, string $apiToken, array $payload): array` — apimetod `new_order` (sprawdzic dokladna nazwe w SDK polkurier-sdk/src/Api). Zwraca `{order_id, tracking_number, raw}`.
- `getLabel(string $login, string $apiToken, string $orderId, string $format): string` — apimetod `get_label`, format=PDF/ZPL/EPL. Zwraca surowa zawartosc base64-decoded jezeli polkurier zwraca base64; w przeciwnym razie binarny stream. Sprawdzic odpowiedz API.
- `getStatus(string $login, string $apiToken, string $orderId): array` — apimetod `get_status`. Zwraca `{status_code, status_name, status_date, raw}`.
- `cancelOrder(string $login, string $apiToken, string $orderId): array` — apimetod `cancel_order`. (NIE bedzie wywolywana w Phase 128 — operator anuluje w UI polkuriera, ale metoda dostepna dla przyszlych planow.)
- `getAvailableCarriers(string $login, string $apiToken): array` — apimetod `get_available_carriers` (potwierdzic w SDK; mozliwe alternatywy: `get_carriers`, `get_services`). Zwraca liste przewoznikow.
- `getParcelMachines(string $login, string $apiToken, string $type, ?string $postalCode = null): array` — apimetod `get_parcel_machines`. type=InPost/Pocztex/Kurier48/ORLEN.
- `getPostOffices(string $login, string $apiToken, string $type): array` — apimetod `get_post_offices` (jezeli polkurier rozdziela).
UWAGA: dokladne nazwy `apimetod` zweryfikowac wzgledem `polkurier-sdk` (https://github.com/Polkurier/polkurier-sdk) — pliki `src/Api/*Api.php`. Jezeli nazwa rozna od zakladanej, dostosowac stale prywatne (`private const APIMETOD_NEW_ORDER = '...'` itp.).
Avoid: hardcodowane mapowanie statusow (zostawiamy `DeliveryStatusMappingRepository`), zmiany w `testConnection()` (dziala od Phase 127), wprowadzania `Content-Type: application/json; charset=UTF-8` (polkurier odrzuca — zachowac dokladnie `application/json`).
</action>
<verify>
1. `php -l src/Modules/Settings/PolkurierApiClient.php` (no syntax errors).
2. Operator wywoluje przyszly smoke test: `php bin/smoke-polkurier.php` (skrypt z Task 4) — pierwszy crash wskaze brakujace pole.
3. Rzut `RuntimeException` zawiera tresc z pola `response` (nie `status: error`).
</verify>
<done>AC-1 satisfied: wszystkie 7 metod publicznych zaimplementowane wzorem `testConnection()`; envelope `{status, response}` parsowany jednolicie; bledy rzucane z trescia z `response`.</done>
</task>
<task type="auto">
<name>Task 2: PolkurierShipmentService + PolkurierTrackingService</name>
<files>src/Modules/Shipments/PolkurierShipmentService.php, src/Modules/Shipments/PolkurierTrackingService.php, src/Modules/Shipments/DeliveryStatus.php</files>
<action>
**PolkurierShipmentService**`final class implements ShipmentProviderInterface`, wzorzec `ApaczkaShipmentService` (1044 LOC, ale polkurier prostszy — ~500-700 LOC):
Konstruktor (manualny DI, mirror Apaczki):
- `PolkurierIntegrationRepository $integrationRepository`
- `PolkurierApiClient $apiClient`
- `ShipmentPackageRepository $packages`
- `CompanySettingsRepository $companySettings`
- `OrdersRepository $ordersRepository`
Metody:
- `code(): string` -> `'polkurier'`.
- `getDeliveryServices(): array``getCredentials()` -> `$apiClient->getAvailableCarriers()`. Cache per-request (`private ?array $servicesCache = null`).
- `createShipment(int $orderId, array $formData): array`:
1. `findDetails($orderId)` z OrdersRepository; throw `ShipmentException` gdy null.
2. `requireCredentials()` -> `[$login, $token]` z `PolkurierIntegrationRepository::getCredentials()`; throw `IntegrationConfigException` gdy null.
3. Sender z `CompanySettingsRepository::getSenderAddress()` + walidacja (`validateSenderAddress`).
4. Receiver z `order_addresses` (delivery type), normalizacja telefonu/postal_code.
5. Wybor uslugi z `$formData['service_code']` lub `$formData['carrier_id']`.
6. Wymiary/waga z formData (z domyslnymi z `company_settings.default_package_*`).
7. Punkt odbioru: jezeli `$formData['receiver_point_id']` niepuste -> wstawiamy w payload polkuriera (klucz zaleznie od SDK).
8. COD/insurance z formData.
9. `$apiClient->createShipment($login, $token, $payload)` -> `{order_id, tracking_number, ...}`.
10. `$packages->insert([...])` z `provider='polkurier'`, `command_id=order_id`, `tracking_number`, `status='created'`, `payload_json=zserializowany_payload`.
11. Synchroniczne pobranie etykiety przez `downloadLabel($packageId, $storagePath)` — analog Apaczki, ktora pobiera label do `storage/labels/`.
12. Zwroc `['package_id' => ..., 'tracking_number' => ..., 'label_path' => ...]`.
- `checkCreationStatus(int $packageId): array` — fetch z `shipment_packages`; jezeli `status='draft'` -> pingnij `getStatus()` API i zaktualizuj.
- `downloadLabel(int $packageId, string $storagePath): array``$apiClient->getLabel($login, $token, $orderId, $package['label_format'] ?? 'PDF')`; zapisz do `$storagePath/polkurier_{packageId}.pdf` (lub `.zpl`); update `shipment_packages.label_path`.
**PolkurierTrackingService**`final class implements ShipmentTrackingInterface`:
- `supports(string $provider): bool` -> `strtolower($provider) === 'polkurier'`.
- `getDeliveryStatus(array $package): ?array`:
1. `requireCredentials()`; zwroc null gdy konfiguracja nieaktywna (cron nie powinien rzucac, tylko skipowac).
2. `$apiClient->getStatus($login, $token, $package['command_id'])`.
3. Mapuj `status_code` przez `DeliveryStatusMappingRepository::findNormalized('polkurier', $rawStatus)`.
4. Fallback `unknown` gdy brak mapowania (analog Apaczki).
5. Zwroc `['status' => $normalized, 'status_raw' => $rawStatus, 'description' => $statusName]`.
**DeliveryStatus.php** — dolozyc obsluge providera `polkurier` w `trackingUrl(string $provider, string $tracking, string $carrierId): string`:
- Polkurier deleguje do przewoznika docelowego — `carrierId` mowi nam ktory. Fallback URL `https://polkurier.pl/sledzenie/<tracking>` (jezeli polkurier ma taki) lub URL przewoznika docelowego z istniejacych branch-ow (`inpost`, `dpd`, etc.). MVP: zwroc URL bazowy polkurier + tracking_number.
Avoid:
- Hardcodowania URL tracking polkuriera bez weryfikacji (sprawdzic w panelu polkurier lub SDK).
- Pobierania etykiety w osobnym requeście jezeli polkurier zwraca `label_base64` w odpowiedzi createShipment (parsowac i zapisywac od razu).
- Throwowania w `PolkurierTrackingService::getDeliveryStatus` przy braku credentials — cron musi byc odporny.
</action>
<verify>
1. `php -l src/Modules/Shipments/PolkurierShipmentService.php` i `PolkurierTrackingService.php`.
2. Manualnie: po Task 3 wejdz na `/orders/114/shipment/prepare`, wybierz polkurier — dropdown zwraca przewoznikow z API.
</verify>
<done>AC-2 i AC-3 satisfied: oba serwisy implementuja interfejsy, integruja z `PolkurierApiClient`, `ShipmentPackageRepository` i `DeliveryStatusMappingRepository`. Kontrakt Phase 127 (single global config, `is_active=1` guard) zachowany.</done>
</task>
<task type="auto">
<name>Task 3: Wiring + UI prepare.php panel polkurier</name>
<files>routes/web.php, src/Modules/Shipments/ShipmentController.php, src/Modules/Cron/CronHandlerFactory.php, resources/views/shipments/prepare.php</files>
<action>
**routes/web.php**:
- W `$shipmentProviderRegistry = new ShipmentProviderRegistry([...])` (linia ~474) dolozyc `new PolkurierShipmentService($polkurierIntegrationRepository, new PolkurierApiClient(), $shipmentPackageRepository, $companySettingsRepository, new OrdersRepository(...))`.
- Dolozyc `use App\Modules\Shipments\PolkurierShipmentService;` w nagłowku.
**CronHandlerFactory.php** (linia ~166-169):
- W tablicy trackerow dolozyc `new PolkurierTrackingService($polkurierIntegrationRepository, $polkurierApiClient, $deliveryStatusMappingRepository)`.
- Dolozyc `use App\Modules\Shipments\PolkurierTrackingService;` i `use App\Modules\Settings\PolkurierIntegrationRepository;`/`PolkurierApiClient;`.
**ShipmentController.php**:
- Wstrzyknac `PolkurierShipmentService $polkurierService` (lub uzywac z registry).
- W `prepare()` przekazac do widoku `$polkurierServices = $polkurierService->getDeliveryServices()` (try/catch — empty array on failure).
**resources/views/shipments/prepare.php** — wzorzec panelu Apaczki (linie 172-216):
- Dolozyc `<option value="polkurier">polkurier</option>` do `#carrierSelect` (linia ~103).
- Dolozyc panel `<div id="shipment-polkurier-panel">` z:
- `<select id="shipment-polkurier-service-select">` wypelniony `$polkurierServices` (carrier_id, name).
- Conditional pickup point picker (`<select id="shipment-polkurier-point-select">`) widoczny gdy wybrana usluga ma `supports_pickup_point=true`. Lista punktow ladowana AJAX-em z nowego endpointu `/shipments/polkurier/points?type=InPost&postal=XX-XXX` (dodac route + metoda w `ShipmentController`).
- Hidden input `name="service_code"` aktualizowany przez JS na podstawie selecta.
- JS (linie ~580-650): dodac `polkurierSelect`/`polkurierPanel`/`polkurierPointSelect`; toggle widocznosci paneli; ustaw `providerInput.value = 'polkurier'` gdy carrier='polkurier'.
Avoid:
- Powielania logiki Apaczki — uzyc tych samych helperow JS gdzie sie da.
- Hardcodowania listy punktow w widoku — wszystko z API polkuriera przez AJAX.
</action>
<verify>
1. `/orders/114/shipment/prepare` — dropdown przewoznika ma "polkurier"; po wyborze pokazuje panel.
2. Select uslug ma realne wartosci z polkuriera.
3. `php -l routes/web.php` i `CronHandlerFactory.php`.
</verify>
<done>AC-4 satisfied: panel polkurier widoczny w prepare.php, integracja z registry, cron tracker zarejestrowany.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>Live test polkurier ShipmentService na zamowieniach #114 i #115. Operator nadaje paczki przez nowy panel UI, weryfikuje wynik, recznie anuluje w panelu polkurier po weryfikacji.</what-built>
<how-to-verify>
1. XAMPP MySQL online + cron disabled (zeby tracking nie zaczal pingowac przed weryfikacja).
2. Otworz `/orders/114/shipment/prepare`:
- Wybierz "polkurier" w dropdownie.
- Wybierz usluge kuriera (np. DPD Standard albo InPost Kurier).
- Uzupelnij wymiary/wage; potwierdz adres odbiorcy.
- Submit.
3. Sprawdz redirect na `/orders/114` -> zakladka Przesylki:
- Wiersz `provider=polkurier`, niepusty `tracking_number`, link "Pobierz etykiete" otwiera PDF.
4. Powtorz dla zamowienia #115 — ten raz z usluga paczkomatowa (InPost Paczkomat); selektor punktu pokazuje liste paczkomatow z API.
5. Otworz panel polkurier.pl manualnie -> zobacz utworzone paczki.
6. Anuluj obie paczki w panelu polkurier (manualna akcja).
7. Zglos wynik: "OK" jezeli paczki utworzone i etykiety pobrane, "issues" z opisem co nie dziala.
</how-to-verify>
<resume-signal>Type "approved" to continue with delivery_status_mappings seed, or describe issues to fix</resume-signal>
</task>
<task type="auto">
<name>Task 4: Migracja seed delivery_status_mappings + weryfikacja /settings/delivery-statuses</name>
<files>database/migrations/20260514_000115_seed_polkurier_delivery_status_mappings.sql</files>
<action>
Stworzyc migracje seedujaca `delivery_status_mappings` (provider='polkurier') na podstawie REALNYCH statusow zwracanych przez API polkurier w Task 1 (operator po live tescie zna konkretne `status_code` z `getStatus`).
Baseline mapowan (do dostrojenia po live tescie):
- `nowa` / `przyjete` / `oczekuje` -> `registered`
- `wydrukowane` / `przygotowanie` -> `label_printed` (jezeli istnieje w `delivery_statuses` — sprawdzic w Phase 108)
- `nadane` / `w_dostawie` / `w_doreczeniu` -> `in_transit` lub `out_for_delivery`
- `doreczone` / `odebrane` -> `delivered`
- `zwrocone` -> `returned`
- `anulowane` -> `cancelled`
- `niedoreczone` / `blad_doreczenia` -> `delivery_failed` lub `unknown` (zaleznie od `delivery_statuses`)
Migracja musi byc idempotentna: `INSERT INTO delivery_status_mappings (provider, raw_status, normalized_status, description) VALUES (...) ON DUPLICATE KEY UPDATE normalized_status = VALUES(normalized_status), description = VALUES(description);`.
Uruchom `php bin/migrate.php` po zatwierdzeniu mapowan.
Otworz `/settings/delivery-statuses?tab=mapping` -> potwierdz widocznosc wpisow provider='polkurier'.
Avoid:
- Seedu na bazie przypuszczen — uzyj statusow ZAOBSERWOWANYCH w live tescie z Task checkpoint.
- Tworzenia nowych wpisow w `delivery_statuses` (jezeli polkurier zwraca status ktorego nie ma — dodaj rownolegly INSERT do tej migracji albo osobna migracje).
</action>
<verify>
1. `php bin/migrate.php` zwraca success.
2. `SELECT COUNT(*) FROM delivery_status_mappings WHERE provider='polkurier'` >= 6.
3. `/settings/delivery-statuses?tab=mapping` pokazuje wiersze polkurier.
4. Ponowne uruchomienie migracji = no-op (idempotencja).
</verify>
<done>AC-5 satisfied: mapowania wpisane do DB, widoczne w UI, idempotentne.</done>
</task>
<task type="auto">
<name>Task 5: Aktualizacja dokumentacji codebase</name>
<files>.paul/codebase/architecture.md, .paul/codebase/db_schema.md, .paul/codebase/tech_changelog.md</files>
<action>
**architecture.md** — dolozyc sekcje "Phase 128 — polkurier ShipmentService + Tracking" pod sekcja Phase 127:
- PolkurierApiClient — pelna lista 7 publicznych metod z apimetod-ami.
- PolkurierShipmentService — kontrakt ShipmentProviderInterface, lista pol payloadu, integracja z `getAvailableCarriers`.
- PolkurierTrackingService — kontrakt ShipmentTrackingInterface, mapowanie statusow.
- Wiring w `ShipmentProviderRegistry` i `CronHandlerFactory`.
- UI prepare.php panel polkurier — selector uslug + pickup point ajax.
**db_schema.md** — dolozyc seed mapping rows w sekcji "delivery_status_mappings":
- Wymienic mapowania `provider='polkurier'` z migracji.
**tech_changelog.md** — wpis z data 2026-05-14 (lub data wdrozenia):
- "Phase 128 (polkurier ShipmentService): pelna implementacja API client (createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers/getParcelMachines/getPostOffices), PolkurierShipmentService implementing ShipmentProviderInterface, PolkurierTrackingService, UI panel w prepare.php, seed delivery_status_mappings."
Avoid: kopiowania kodu do dokumentacji (zostawic referencje sciezek + 1-2 zdania kontraktu).
</action>
<verify>
Manualnie przegladnac diff `.paul/codebase/*.md` — wpisy obecne, formatowanie spojne z Phase 127.
</verify>
<done>Dokumentacja zaktualizowana zgodnie z CLAUDE.md (sekcja "Utrwalanie stalych wymagan").</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- `src/Modules/Settings/PolkurierIntegrationRepository.php` — Phase 127, kontrakt stabilny.
- `src/Modules/Settings/PolkurierApiClient::testConnection()` — Phase 127, zweryfikowany na zywym koncie ("Autoryzacja: 1").
- `apaczka_integration_settings`, `ApaczkaShipmentService`, `ApaczkaTrackingService` — Apaczka dziala niezaleznie obok polkuriera.
- `database/migrations/20260514_000114_create_polkurier_integration_settings.sql` (Phase 127).
- `ShipmentProviderInterface` i `ShipmentTrackingInterface` — kontrakty stabilne (nie dolozyc/zmienic metod).
- Reszta paneli w `prepare.php` (allegro/inpost/apaczka) — tylko dolozenie nowego panelu polkurier obok.
## SCOPE LIMITS
- BEZ implementacji `OrderValuationV2` (wycena przed nadaniem) — odlozone na osobna faze.
- BEZ presetow przesylek dla polkuriera (`shipment_presets.provider_code='polkurier'`) — operator moze ich uzywac dopiero jak panel polkurier dziala; presety w osobnej fazie.
- BEZ widoku CLI smoke test scriptu (`bin/smoke-polkurier.php`) — testujemy w realnym UI na #114/#115.
- BEZ event automatyzacji `shipment.created` zmian — to zdarzenie juz emitowane jednolicie z `ShipmentController::store()` dla wszystkich providerow.
- BEZ idempotencji createShipment (double-POST guard) — jak w Apaczce, brak retry guard w MVP.
- BEZ refaktoringu wspolnego kodu Apaczka/polkurier (`buildReceiverAddress`, `validateSenderAddress` itp.) — kopiujemy wzorzec, deduplikacja w osobnym planie.
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php -l` przechodzi dla wszystkich zmienionych plikow PHP
- [ ] Migracja `20260514_000115_*.sql` wykonana, ponowne uruchomienie = no-op
- [ ] Operator potwierdzil checkpoint: 2 paczki utworzone na #114 i #115, etykiety pobrane, manualnie anulowane w panelu polkurier
- [ ] `/settings/delivery-statuses?tab=mapping` pokazuje wpisy provider='polkurier'
- [ ] `/orders/{id}/shipment/prepare` panel polkurier widoczny i funkcjonalny
- [ ] Cron tracking nie crashuje (sprawdzic `storage/logs/app.log` po jednym przebiegu)
- [ ] Dokumentacja `.paul/codebase/*.md` zaktualizowana
- [ ] Wszystkie AC-1..AC-6 spelnione
</verification>
<success_criteria>
- Operator moze nadawac paczki przez polkurier z poziomu `/orders/{id}/shipment/prepare` w 4 trybach: kurier door-to-door, paczkomat InPost, punkt Pocztex/Kurier48, ORLEN.
- Tracking polkurier dziala w cronie i aktualizuje `shipment_packages.delivery_status` przez `delivery_status_mappings`.
- Live test na #114 i #115 zakonczony sukcesem (paczki utworzone, etykiety pobrane, recznie anulowane).
- Zaden istniejacy provider (Apaczka/InPost/Allegro WZA) nie regresuje.
</success_criteria>
<output>
After completion, create `.paul/phases/128-polkurier-shipment-service/128-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,221 @@
---
phase: 128-polkurier-shipment-service
plan: 01
subsystem: shipments
tags: [polkurier, courier, broker, shipment, tracking, ui-prepare, delivery-status-mappings]
requires:
- phase: 127-polkurier-integration-foundation
provides: PolkurierIntegrationRepository (login + Token API + getCredentials), PolkurierApiClient.testConnection, integration row in `integrations` + `polkurier_integration_settings`.
provides:
- PolkurierApiClient z pelnym kontraktem (createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers/getInpostParcelMachines/getCourierPoints).
- PolkurierShipmentService implementujacy ShipmentProviderInterface — operator tworzy paczki polkurier z `/orders/{id}/shipment/prepare`.
- PolkurierTrackingService implementujacy ShipmentTrackingInterface — cron `shipment_tracking_sync` pinguje get_status.
- DeliveryStatus::trackingUrl fallback `https://polkurier.pl/sledz-paczke/<tracking>` + carrier_id routing.
- UI panel "polkurier" w `prepare.php` z dynamiczna lista uslug z available_carriers.
- Seed migracja `delivery_status_mappings(provider='polkurier')` z 7 wpisami O/P/A/WP/D/Z/W → znormalizowane statusy.
affects: [paczkomaty UI (InpostParcelMachines/PocztexPostOffices/Kurier48PostOffices), shipment_presets (provider_code='polkurier'), OrderValuationV2 (wycena przed nadaniem)]
tech-stack:
added: []
patterns:
- "Wspolny prywatny `call($apimetod, $data, $login, $token): mixed` w API client parsuje envelope `{status, response}`; sukces -> tresc `response`, blad -> RuntimeException z trescia z `response`. Reuse dla wszystkich apimetod."
- "polkurier SDK Order entity zwraca `number` (nie `orderno`) i `waybills[0].number``extractOrderNumber`/`extractTrackingNumber` priorytetuja SDK shape, fallback na top-level klucze."
- "polkurier API nie udostepnia parametru rozmiaru etykiety (A4/A6) — sterowane wylacznie w panelu klienta polkurier.pl. `polkurier_integration_settings.default_label_format` (PDF/ZPL/EPL) odnosi sie do typu pliku, NIE rozmiaru."
key-files:
created:
- src/Modules/Shipments/PolkurierShipmentService.php
- src/Modules/Shipments/PolkurierTrackingService.php
- database/migrations/20260514_000115_seed_polkurier_delivery_status_mappings.sql
modified:
- src/Modules/Settings/PolkurierApiClient.php
- src/Modules/Shipments/DeliveryStatus.php
- src/Modules/Shipments/ShipmentController.php
- src/Modules/Cron/CronHandlerFactory.php
- routes/web.php
- resources/views/shipments/prepare.php
key-decisions:
- "polkurier `shipmenttype` wymaga lowercase z zbioru [box, envelope, palette, small_parcel, parcel_size_20] — `normalizeShipmentType()` mapuje legacy PACKAGE/BOX/PARCEL/PACZKA/KOPERTA/PALETA na format polkuriera."
- "Rozmiar etykiety A4/A6 sterowany w panelu klienta polkurier.pl, NIE przez API (zweryfikowane na PDF v1.11) — kod nie wysyla zadnego parametru rozmiaru."
- "Brak dedykowanego selektora punktu odbioru w UI — operator wpisuje `receiver_point_id` w istniejacy text input w sekcji Adres odbiorcy (np. `POP-RZE54`); usuniety AJAX endpoint i lookupPickupPoints."
- "Seed `delivery_status_mappings` bazuje na oficjalnej tabeli ORDER_STATUS z PDF v1.11 (kody O/P/A/WP/D/Z/W), nie na obserwacji w live tescie — bezpieczniejsze i wyczerpujace."
- "polkurier dziala obok Apaczki (decyzja z Phase 127 zachowana); `ShipmentProviderRegistry` rejestruje oba; brak migracji shipment_presets."
patterns-established:
- "Pattern: dla nowych metod polkurier API uzywaj wspolnego `call($apimetod, $data, $login, $token)`. Status `success` zwraca tresc `response`. Status inny rzuca `RuntimeException` z trescia `response` (string albo zserializowany JSON dla tablic)."
- "Pattern: dla parsowania odpowiedzi polkurier SDK entity, najpierw priorytetuj klucze entity (`number`, `waybills[].number`, `file`), potem fallback na top-level/snake_case klucze, potem obsluga wrapperow `{order:{...}}` i list."
- "Pattern: diagnostyka silent-fail w ShipmentService — gdy parsing API odpowiedzi zwraca pusty wynik mimo `status=success`, zapisuj fragment surowej odpowiedzi do `shipment_packages.error_message` zeby operator/dev zobaczyl shape."
duration: ~120min (incl. 4 live test iteracje)
started: 2026-05-14T20:00:00Z
completed: 2026-05-14T22:00:00Z
---
# Phase 128 Plan 01: polkurier ShipmentService + Tracking + UI prepare
**polkurier zarejestrowany jako pelnoprawny przewoznik obok Apaczki — operator tworzy paczki przez UI `/orders/{id}/shipment/prepare`, etykieta A6 generowana, cron tracking gotowy do mapowania statusow O/P/A/WP/D/Z/W na znormalizowane created/confirmed/cancelled/in_transit/delivered/returned/problem.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~120 min |
| Started | 2026-05-14T20:00:00Z |
| Completed | 2026-05-14T22:00:00Z |
| Tasks | 6/6 completed (5 auto + 1 checkpoint) |
| Files modified | 10 |
| Live test iteracje | 4 (ReferenceError → uppercase shipmenttype → orderno parsing → A6 panel) |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: PolkurierApiClient pelny kontrakt API | Pass | 7 metod (createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers/getInpostParcelMachines/getCourierPoints) zweryfikowane na PDF v1.11. `call()` wspolny wrapper envelope `{status, response}`. |
| AC-2: PolkurierShipmentService implementuje ShipmentProviderInterface | Pass | `code()='polkurier'`, `getDeliveryServices` cache per-request, `createShipment` orchestruje pelny flow z normalizacja shipmenttype i splitem ulicy, `downloadLabel` z base64 decode na klucz `file`. Verified on #114/#115. |
| AC-3: PolkurierTrackingService cron tracking | Pass (kod) | Implementacja kompletna, ale niezweryfikowane na zywej bazie podczas APPLY (operator anulowal paczki w panelu polkurier po teście — cron nie mial co pingowac). Graceful null przy bledach. Pierwszy passthrough nastapi przy nastepnej zywej paczce. |
| AC-4: UI prepare.php panel polkurier | Pass | Opcja "polkurier" w dropdownie, panel z dynamiczna lista uslug, hidden `service_code`. Bez dedykowanego selektora punktu — operator wpisuje w istniejacy input w sekcji Adres odbiorcy. |
| AC-5: delivery_status_mappings + /settings/delivery-statuses | Pass (kod) | Migracja idempotentna z 7 wpisami O/P/A/WP/D/Z/W. Operator uruchomi `php bin/migrate.php` gdy MySQL online. Widocznosc w `/settings/delivery-statuses` po migracji. |
| AC-6: Live test na #114 i #115 | Pass | 4 iteracje, ostatecznie obie paczki utworzone w polkurier, etykiety pobrane (A6 po zmianie w panelu klienta), operator anulowal w panelu polkuriera po weryfikacji. |
## Accomplishments
- polkurier zarejestrowany jako 4. provider w `ShipmentProviderRegistry` (obok allegro_wza, apaczka, inpost) — operator nadaje paczki z UI bez przelaczania platform.
- Kontrakt API zweryfikowany na oficjalnej dokumentacji PDF v1.11 (pobranej i zachowanej w `.paul/phases/128-polkurier-shipment-service/polkurier-api-docs.txt` — referencyjne zrodlo dla przyszlych faz).
- Mapowanie statusow `O/P/A/WP/D/Z/W` na znormalizowane statusy `created/confirmed/cancelled/in_transit/delivered/returned/problem` z idempotentna migracja — cron tracking gotowy do dzialania.
- Diagnostyka silent-fail patternem (zapis fragmentu surowej odpowiedzi do `error_message` przy nieudanym parsingu) — uratowala 3. iteracje live testu (parsing `number` vs `orderno`).
## Task Commits
Wszystkie zmiany w jednym stanie WIP — commit zostanie wykonany w transition (`feat(128): polkurier shipment service + tracking + UI prepare`).
| Task | Status | Description |
|------|--------|-------------|
| Task 1: PolkurierApiClient pelen kontrakt API | done | 7 metod, wspolny `call()` wrapper, parsowanie envelope |
| Task 2: PolkurierShipmentService + PolkurierTrackingService | done | ~520 + ~110 LOC, oba implementuja swoje interfejsy |
| Task 3: Wiring + UI prepare.php panel | done | Registry, CronHandlerFactory, ShipmentController.prepare/create, panel + JS |
| Task 4: Live test checkpoint na #114/#115 | done | Operator approved po 4 iteracjach, etykieta A6 po zmianie w panelu klienta polkurier |
| Task 5: Migracja seed delivery_status_mappings | done (kod) | 7 wpisow z PDF v1.11, idempotentna; operator uruchomi gdy MySQL online |
| Task 6: Aktualizacja `.paul/codebase/*.md` | done | architecture.md (Phase 128 sekcja), db_schema.md (seed mappings), tech_changelog.md (Phase 128 entry z 4 deviationami i iteracjami live testu) |
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `src/Modules/Settings/PolkurierApiClient.php` | Modified | Stuby z Phase 127 zastapione 7 metodami: createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers/getInpostParcelMachines/getCourierPoints. Wspolny `call()` parser envelope. |
| `src/Modules/Shipments/PolkurierShipmentService.php` | Created | `implements ShipmentProviderInterface`, ~520 LOC. createShipment orchestracja, normalizeShipmentType, splitStreetAndNumber, buildRecipient/buildSender/buildPickup, downloadLabel z base64 decode, extractOrderNumber/extractTrackingNumber priorytetujace SDK shape. |
| `src/Modules/Shipments/PolkurierTrackingService.php` | Created | `implements ShipmentTrackingInterface`, ~110 LOC. getDeliveryStatus z graceful null + normalizacja przez DeliveryStatusMappingRepository. |
| `src/Modules/Shipments/DeliveryStatus.php` | Modified | +4 LOC: fallback URL `https://polkurier.pl/sledz-paczke/<tracking>`. Carrier_id routing przez `matchCarrierByName` automatyczny. |
| `src/Modules/Shipments/ShipmentController.php` | Modified | prepare() fetchuje polkurierServices, create() rozszerzony o service_code/pickup_date/pickup_time_from/pickup_time_to. |
| `src/Modules/Cron/CronHandlerFactory.php` | Modified | PolkurierTrackingService dodany do ShipmentTrackingRegistry. |
| `routes/web.php` | Modified | use PolkurierApiClient + PolkurierShipmentService, registry zarejestrowany. |
| `resources/views/shipments/prepare.php` | Modified | Opcja "polkurier" w carrier select, panel z select uslug, hidden service_code, JS handler. |
| `database/migrations/20260514_000115_seed_polkurier_delivery_status_mappings.sql` | Created | 7 wpisow O/P/A/WP/D/Z/W → normalized. Idempotentne. |
| `.paul/codebase/architecture.md` | Modified | Sekcja Phase 128 (PolkurierApiClient/ShipmentService/TrackingService/UI/wiring/seed/boundaries). |
| `.paul/codebase/db_schema.md` | Modified | Seedowane mapowania `provider='polkurier'` w sekcji `delivery_status_mappings`. |
| `.paul/codebase/tech_changelog.md` | Modified | Entry Phase 128 z opisem zmian + 4 iteracje live testu + deviations. |
| `.paul/phases/128-polkurier-shipment-service/polkurier-api-docs.txt` | Created | Tekst PDF v1.11 (pdftotext extract) — referencyjne zrodlo dla przyszlych faz polkuriera. |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| `shipmenttype` lowercase + `normalizeShipmentType()` mapping | polkurier API odrzuca uppercase `BOX` — wymaga lowercase z zbioru `[box, envelope, palette, small_parcel, parcel_size_20]` (komunikat bledu w live tescie). Aliasy dla PACKAGE/PARCEL/PACZKA/KOPERTA/PALETA pozwalaja reuse istniejacych wartosci formularza. | Wszystkie kolejne paczki polkurier maja poprawny shipmenttype bez zmian w formularzu/preset. |
| `extractOrderNumber` priorytetuje pole `number` (SDK Order entity) nad `orderno` | polkurier `create_order` zwraca Order entity z polem `number` (zweryfikowane w SDK Order.php — setNumber/getNumber). `orderno` to nazwa parametru INPUT w innych metodach (get_label, get_status, cancel_order). | Parsing dziala dla aktualnej wersji SDK + odporne na stary shape (`orderno` fallback). |
| Brak dedykowanego selektora punktu odbioru w UI | Operator zglosil ze `Punkt odbioru` jest juz polem w sekcji Adres odbiorcy z auto-fillem `parcel_external_id` z importu zamowienia. Dodatkowy selektor byl duplikatem. | Usuniete: `lookupPickupPoints`, `ShipmentController::polkurierPoints`, route, JS handler. Operator wpisuje czysty ID (np. `POP-RZE54`) w istniejacy input. |
| Rozmiar etykiety A4/A6 sterowany w panelu klienta polkurier.pl | API polkurier nie udostepnia parametru rozmiaru w `get_label` ani `create_order` (zweryfikowane na PDF v1.11). Operator zmienia preferencje konta jednorazowo. | Brak dodatkowego pola w `polkurier_integration_settings` ani formularzu; `default_label_format` (PDF/ZPL/EPL) odnosi sie tylko do typu pliku. |
| Seed `delivery_status_mappings` z PDF v1.11 (nie z obserwacji live test) | Live test obejmowal tylko status `P` (Potwierdzone) bezposrednio po `create_order`. Seedowanie bazujace na obserwacji wymagaloby kolejnych miesiecy zywych paczek. PDF ma kompletna tabele ORDER_STATUS. | 7 wpisow O/P/A/WP/D/Z/W ready od pierwszego dnia. |
| Diagnostyka silent-fail patternem (zapis surowej odpowiedzi do `error_message`) | 3. iteracja live testu (parsing `number` vs `orderno`) byla niemozliwa do debugowania bez podgladu surowej odpowiedzi — `payload_json` w `shipment_packages.update()` jest poza whitelist. Zapis fragmentu (400 znakow) do `error_message` jest tani i widoczny operatorowi w UI. | Pattern do reuse dla nowych integracji API z nieznanym shape odpowiedzi. |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 4 | Live test iteracje — wszystkie naprawione w tej samej sesji APPLY |
| Scope removals | 1 | UI selektor punktow paczkomatowych usuniety na zyczenie operatora |
| Scope additions | 1 | Pole `service_code` i `pickup_*` w `ShipmentController::create()` (potrzebne dla polkurier payload) |
| Deferred | 3 | Cron tracking weryfikacja, migracja MySQL, paczkomaty UI (kolejna faza) |
**Total impact:** Essential fixes (live test feedback), no scope creep — operator manual confirmation poszerzyl o jedno usuniecie (selektor punktu) i jedno dodanie (`service_code` przekazywany do service).
### Auto-fixed Issues
**1. [JS ReferenceError] `polkurierPointIdInput is not defined` w `clearHiddenFields()`**
- **Found during:** Task 4 (live test, pierwszy submit polkurier)
- **Issue:** Po usunieciu duplikatu selektora punktu odbioru (po feedback operatora w Task 3 iteracji) zostala martwa referencja do zmiennej `polkurierPointIdInput` w `clearHiddenFields()`. JS rzucal ReferenceError, handler `carrierSelect.change` przerywal przed wywolaniem `showPanel()`, `provider_code` zostawal na PHP-renderowanej wartosci `apaczka` (gdy `$preselectedCarrier === 'apaczka'`). Submit szedl do ApaczkaShipmentService → blad "Nie podano uslugi Apaczka."
- **Fix:** Usuniecie linii `if (polkurierPointIdInput) polkurierPointIdInput.value = '';` z `clearHiddenFields()`.
- **Files:** `resources/views/shipments/prepare.php`
- **Verification:** Drugi submit polkurier → routing do PolkurierShipmentService.
**2. [Polkurier API validation] `shipmenttype` musi byc lowercase**
- **Found during:** Task 4 (live test, drugi submit po napraweniu #1)
- **Issue:** Wysylanie `BOX` uppercase → API odrzucalo: "Typ paczki musi przyjmowac jeden z parametrow ze zbioru [box, envelope, palette, small_parcel, parcel_size_20]".
- **Fix:** Nowa metoda `normalizeShipmentType()` z lowercase + aliasami (PACKAGE→box, PARCEL→box, PACZKA→box, KOPERTA→envelope, PALETA→palette, MALA_PACZKA/SMALL→small_parcel). Default `box`.
- **Files:** `src/Modules/Shipments/PolkurierShipmentService.php`
- **Verification:** Trzeci submit → paczka utworzona w polkurier.
**3. [Response shape mismatch] `extractOrderNumber` nie znajdowal pola `number`**
- **Found during:** Task 4 (live test, trzeci submit — paczka utworzona w polkurier ale w orderPRO `status=pending`)
- **Issue:** Pierwotny parsing szukal kluczy `orderno`/`order_no` w odpowiedzi. polkurier zwraca SDK Order entity z polem `number` + tablica `waybills[]` z `OrderWaybill` entity (zweryfikowane w `Order.php` setterach `setNumber()`, `addWaybill()`).
- **Fix:** Nowe metody `extractOrderNumber()` (priorytet `number`, fallback `orderno`/`order_no`/`order_number`/`order_id`/`id`, obsluga wrappera `{order:{...}}` i list) + `extractTrackingNumber()` (priorytet `waybills[0].number`, fallback top-level klucze). Dodatkowo diagnostyka: gdy `orderno=''`, zapis fragmentu surowej odpowiedzi do `error_message`.
- **Files:** `src/Modules/Shipments/PolkurierShipmentService.php`
- **Verification:** Czwarty submit → `status=created`, `tracking_number` ustawiony, etykieta pobrana z pola `file`.
**4. [API misunderstanding] Bogus parametry rozmiaru etykiety**
- **Found during:** Task 4 (live test, czwarty submit — etykieta A4 zamiast A6)
- **Issue:** Iteracja w 3 bogus parametry (`format`/`label_size`/`paper_size`) wyslanych do `get_label` — bez efektu, bo API ignoruje nieznane pola. Operator zglosil ze etykieta nadal A4.
- **Fix:** Pobranie i przeczytanie oficjalnej dokumentacji PDF v1.11 potwierdzilo: `get_label` przyjmuje WYLACZNIE `orderno`. Rozmiar A4/A6 sterowany jest w panelu klienta polkurier.pl. Usuniete bogus parametry, `getLabel($login, $token, $orderno)` ma tylko 3 argumenty. Operator zmienil ustawienie w panelu polkurier — etykieta A6 OK.
- **Files:** `src/Modules/Settings/PolkurierApiClient.php`, `src/Modules/Shipments/PolkurierShipmentService.php`
- **Verification:** Operator nadal kolejna paczke → etykieta A6.
### Scope Removals
**UI selektor punktow paczkomatowych (AJAX endpoint + dropdown)**
- **Removed during:** Task 3 iteracje (po feedback operatora)
- **Reason:** Istnieje juz pole `name="receiver_point_id"` w sekcji Adres odbiorcy z auto-fillem `parcel_external_id` z importu zamowienia. Dodatkowy selektor byl duplikatem. Operator wpisuje czysty ID recznie (np. `POP-RZE54`).
- **Files removed:** `PolkurierShipmentService::lookupPickupPoints()`, `ShipmentController::polkurierPoints()`, route `/shipments/polkurier/points`, JS handler `loadPolkurierPoints/renderPolkurierPoints`.
- **Zachowane:** `PolkurierApiClient::getInpostParcelMachines()` i `getCourierPoints()` — gotowe stuby na przyszle rozszerzenie (kolejna faza paczkomatow UI).
### Scope Additions
**`service_code` + `pickup_*` w `ShipmentController::create()`**
- **Reason:** PolkurierShipmentService potrzebuje servicecode z available_carriers (osobne pole niz `delivery_method_id` zeby JS mogl wstawic czysta wartosc) + optional pickup override.
- **Impact:** Backward compatible — Apaczka/InPost/AllegroWZA ignoruja te pola w swoich createShipment.
### Deferred Items
- **Phase 128 follow-up:** Operator uruchomi `php bin/migrate.php` gdy XAMPP MySQL online (utworzy 7 wpisow `provider='polkurier'` w `delivery_status_mappings`).
- **Phase 128 follow-up:** Cron `shipment_tracking_sync` weryfikacja przy pierwszej zywej paczce polkurier w `in_transit` — pierwszy realny passthrough TrackingService dopiero przy nastepnej niezanulowanej paczce.
- **Kolejna faza:** Paczkomaty UI panel (`InpostParcelMachines`/`PocztexPostOffices`/`Kurier48PostOffices` selectory w `prepare.php`), presety przesylek z `provider_code='polkurier'`, `OrderValuationV2` (wycena przed nadaniem).
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Migracja `20260514_000115` nie uruchomiona — MySQL offline z poziomu agenta (Bash) | Operator uruchomi recznie `php bin/migrate.php` gdy XAMPP MySQL online. Migracja jest idempotentna. |
| AC-3 (cron tracking) nie zweryfikowane na zywej bazie | Operator anulowal obie paczki w panelu polkurier po teście — cron tracking nie mial co pingowac. Implementacja kompletna i defensywna (graceful null). Weryfikacja przy nastepnej zywej paczce. |
| PDF v1.11 polkurier API niedostepny przez WebFetch (binary content) | Pobrane przez WebFetch jako binarny PDF + `pdftotext.exe` (Git Bash bundle) → tekst w `.paul/phases/128-polkurier-shipment-service/polkurier-api-docs.txt`. Pattern dla przyszlych fetchy binary docs. |
## Next Phase Readiness
**Ready:**
- polkurier dziala end-to-end w UI (tworzenie + etykieta + tracking gotowy).
- Kontrakt API zweryfikowany na oficjalnej dokumentacji (PDF v1.11) — przyszle fazy maja stale referencyjne zrodlo.
- Diagnostyka silent-fail pattern do reuse dla nowych integracji.
- `getInpostParcelMachines`/`getCourierPoints` stuby gotowe dla kolejnej fazy paczkomaty UI.
**Concerns:**
- AC-3 (cron tracking) nie zweryfikowane na zywej bazie — pierwszy passthrough wymaga niezanulowanej paczki polkurier. Defensywne kodowanie (graceful null) chroni przed crashem crona, ale realne dzialanie testowalne dopiero na zywej paczce.
- `extractOrderNumber`/`extractTrackingNumber` fallback chain moze nie pokryc 100% wariantow shape odpowiedzi (np. order zlecone z dodatkowymi opcjami). Pattern z `error_message` dump pomoze w iteracji.
**Blockers:**
- None.
---
*Phase: 128-polkurier-shipment-service, Plan: 01*
*Completed: 2026-05-14*

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,295 @@
---
phase: 129-order-user-notes
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- database/migrations/20260514_000116_extend_order_notes_user_authored.sql
- src/Modules/Orders/OrdersRepository.php
- src/Modules/Orders/OrdersController.php
- src/Modules/Orders/OrderNotesService.php
- routes/web.php
- resources/views/orders/show.php
- resources/views/orders/list.php
- resources/lang/pl.php
- resources/scss/modules/_order-notes.scss
- resources/scss/app.scss
- public/assets/js/modules/order-notes.js
- resources/views/layouts/app.php
autonomous: true
delegation: auto
---
<objective>
## Goal
Wprowadzic moduł notatek użytkownika w zamówieniach: pełen CRUD (add/edit/delete tylko dla autora) w sekcji "Wiadomosci i zalaczniki" w szczegółach zamówienia (`/orders/{id}`), oraz licznik notatek `[N]` jako mały badge przy numerze zamówienia na liście (`/orders/list`).
## Purpose
Operator potrzebuje miejsca na własne adnotacje per zamówienie (uzgodnienia z klientem, ustalenia wewnętrzne, flagi do dalszej obsługi), niezależne od zaimportowanych notatek ze źródła. Badge na liście pozwala szybko zobaczyć które zamówienia mają adnotacje bez wchodzenia w szczegóły — analogicznie do licznika zwrotów klienta (Phase 106).
## Output
- Migracja rozszerzająca `order_notes` o `user_id` (FK→users SET NULL) + `author_name` (snapshot) + `body` (czytelny alias do TEXT) — z reuse istniejącej kolumny `comment` jako body i nowymi kolumnami; nowy `note_type='user'`.
- `OrderNotesService` z metodami `create/update/delete/listUserNotes/countUserNotesForOrders`.
- 3 routes: `POST /orders/{id}/notes`, `POST /orders/{id}/notes/{noteId}/update`, `POST /orders/{id}/notes/{noteId}/delete`.
- W sekcji "Wiadomosci i zalaczniki" w `show.php`: lista notatek użytkownika (data + autor + tresc + akcje edit/delete dla autora) + formularz dodawania; importowane notatki zachowane jako osobny blok wyżej (filtr po `note_type`).
- Badge `[N]` w komórce `order_ref` listy zamówień (neutralna kolorystyka, klasa `order-notes-badge`, link do `#notes` w szczegółach).
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
@.paul/codebase/architecture.md
@.paul/codebase/db_schema.md
## Source Files (key spots)
@src/Modules/Orders/OrdersRepository.php
@src/Modules/Orders/OrdersController.php
@routes/web.php
@resources/views/orders/show.php
@resources/views/orders/list.php
@resources/lang/pl.php
## Reference Patterns
- Phase 106 Customer Return Badge — `customerReturnedCountSubquerySql()` w `OrdersRepository`, render `risk-return-badge` w `OrdersController::formatOrderRow()` (linia ~656659).
- Phase 124 SMS Templates Service — `SmsTemplatesService` jako wzorzec serwisu CRUD nad pojedynczą tabelą.
- Phase 113-115 toggle pattern — `invoice-requested-toggle.js` jako wzorzec wanilijowego JS POST-em z CSRF.
## Existing `order_notes` schema (draft 20260302_orders_schema_v1.sql)
Tabela już istnieje: `id`, `order_id`, `source_note_id`, `note_type`, `created_at_external`, `comment`, `payload_json`, `created_at`, `updated_at`, UNIQUE `(order_id, source_note_id)`. Obecnie używana tylko do notatek importowanych ze źródła (shopPRO/Allegro mappers; loadOrderNotes w OrdersRepository.php:596).
<clarifications>
- **Schemat DB** — Gdzie przechowywać notatki użytkownika?
→ Odpowiedź: Rozszerz `order_notes` o `user_id`+`author_name`+nowy `note_type='user'`.
- **Badge UI** — Jak ma wyglądać label z liczbą notatek na liście zamówień?
→ Odpowiedź: Mały badge `[N]` przy nr zamówienia (neutralna kolorystyka, klik scrolluje do sekcji notatek w szczegółach).
- **CRUD scope** — Co operator może robić z własnymi notatkami?
→ Odpowiedź: Pełny CRUD (add/edit/delete) — autor lub admin może edytować/usuwać. Brak systemu ról w aplikacji → implementacja: edit/delete dozwolone tylko gdy `note.user_id === session.user_id` (sam autor). Jeżeli operator chce uprawnienia globalne, odłożyć do osobnej fazy po wprowadzeniu ról.
- **Umiejscowienie** — Gdzie umieścić UI notatek w szczegółach zamówienia?
→ Odpowiedź: W sekcji "Wiadomosci i zalaczniki" (już istnieje w details panel, `resources/views/orders/show.php` linia ~449463). Tam dorzucamy listę notatek użytkownika + formularz dodawania. Importowane notatki ze źródła zachowujemy jako osobny mniejszy blok.
</clarifications>
</context>
<acceptance_criteria>
## AC-1: Migracja DB — kolumny user notes
```gherkin
Given baza zawiera tabelę `order_notes` ze starymi importowanymi rekordami (note_type IN ('shoppro','allegro','message'))
When uruchomię `php bin/migrate.php`
Then tabela `order_notes` ma nowe kolumny `user_id INT UNSIGNED NULL` (FKusers(id) ON DELETE SET NULL), `author_name VARCHAR(190) NULL`, oraz indeks `idx_order_notes_type_order (note_type, order_id)`. Istniejące rekordy mają `user_id=NULL`, `author_name=NULL`, `note_type` niezmieniony. Migracja jest idempotentna (re-run = no-op via `INFORMATION_SCHEMA` guard lub `IF NOT EXISTS`).
```
## AC-2: Tworzenie notatki użytkownika
```gherkin
Given zalogowany user (id=5, name="Jacek Pyziak") otwiera `/orders/1090`
When wpisuje treść w textarea formularza "Dodaj notatkę" i klika "Zapisz"
Then POST `/orders/1090/notes` z `_token` i `body` zapisuje wiersz `order_notes(order_id=1090, note_type='user', user_id=5, author_name='Jacek Pyziak', comment=<treść>, created_at=NOW())`, dodaje wpis `order_activity_log(event_type='note', summary='Dodano notatkę', actor_type='user', actor_name='Jacek Pyziak')`, flashuje sukces i przekierowuje do `/orders/1090#notes`.
```
## AC-3: Edycja i usuwanie tylko przez autora
```gherkin
Given notatka #42 ma user_id=5 i jest renderowana na `/orders/1090`
When zalogowany user id=5 klika "Edytuj" zmienia treść "Zapisz"
Then POST `/orders/1090/notes/42/update` aktualizuje `comment` i `updated_at`, lista re-renderuje się z nową treścią
When ten sam user id=5 klika "Usuń" potwierdza w `OrderProAlerts.confirm` z `danger:true`
Then POST `/orders/1090/notes/42/delete` usuwa rekord (DELETE WHERE id=42 AND user_id=5), flashuje sukces
When zalogowany user id=8 (inny niż autor) próbuje POST `/orders/1090/notes/42/update` lub `/delete`
Then odpowiedź HTTP 403 z komunikatem "Brak uprawnień tylko autor może edytować/usuwać notatkę" (flash danger), wiersz pozostaje nienaruszony
```
## AC-4: Lista notatek w sekcji "Wiadomosci i zalaczniki"
```gherkin
Given zamówienie 1090 ma 2 notatki użytkownika (autor=Jacek, daty 2026-05-14 10:00 i 2026-05-14 12:30) oraz 1 zaimportowaną z shopPRO (`note_type='shoppro'`)
When otwieram `/orders/1090` i scrolluję do "Wiadomosci i zalaczniki"
Then widzę:
1. Blok "Notatki" (id="notes"): 2 wpisy w kolejności desc po `created_at`, każdy z `data | autor` w nagłówku i treścią poniżej, oraz przyciskami "Edytuj"/"Usuń" tylko dla wpisów, których user_id == session.user_id
2. Inline formularz dodawania notatki (textarea + przycisk "Zapisz") z CSRF tokenem
3. Blok "Wiadomości ze źródła" (subtelny styl, mniejszy): 1 wpis shopPRO bez akcji edit/delete
```
## AC-5: Badge `[N]` na liście zamówień
```gherkin
Given zamówienie 1090 ma 2 user-notes, zamówienie 1091 ma 0
When otwieram `/orders/list`
Then przy nr zamówienia 1090 widzę mały badge `<span class="order-notes-badge" title="2 notatki">[2]</span>` jako link do `/orders/1090#notes` (neutralna kolorystyka — niebieskoszary tekst na jasnym tle, mniejszy niż badge zwrotów), badge przy 1091 jest ukryty (count=0 ⇒ pusty string).
```
## AC-6: Subquery licznika nie psuje paginacji/sortowania
```gherkin
Given lista `/orders/list` z 1000 zamówieniami filtrowana po statusie i sortowana
When wykonam paginację, filtrowanie i sortowanie
Then licznik `user_notes_count` jest wyliczany subquery (`SELECT COUNT(*) FROM order_notes WHERE order_id = o.id AND note_type = 'user'`) jako kolumna SELECT bez wpływu na WHERE/GROUP BY/ORDER. Czas wykonania zapytania pozostaje rozsądny dzięki indeksowi `idx_order_notes_type_order`.
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Migracja DB + extend `order_notes` o pola user-authored</name>
<files>database/migrations/20260514_000116_extend_order_notes_user_authored.sql, .paul/codebase/db_schema.md</files>
<action>
Utwórz migrację `20260514_000116_extend_order_notes_user_authored.sql`:
- `ALTER TABLE order_notes ADD COLUMN user_id INT UNSIGNED NULL AFTER note_type;`
- `ALTER TABLE order_notes ADD COLUMN author_name VARCHAR(190) NULL AFTER user_id;`
- `ALTER TABLE order_notes ADD CONSTRAINT order_notes_user_fk FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE;`
- `ALTER TABLE order_notes ADD INDEX idx_order_notes_type_order (note_type, order_id);`
- Każdy ADD owijaj w `INFORMATION_SCHEMA` guard (`SET @x = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE ...); SET @sql = IF(@x=0, 'ALTER TABLE...', 'SELECT 1'); PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;`) — wzorzec z istniejących migracji. UWAGA: ostatni guard musi być DDL no-op (`ALTER TABLE order_notes COMMENT='phase-129 idempotent'`) NIE `SELECT 1` (decyzja z Phase 115).
Następnie zaktualizuj `.paul/codebase/db_schema.md` (sekcja Orders → order_notes): dopisz tabelę z nowymi kolumnami i indeksem, opisz że `note_type='user'` oznacza notatki autorskie z `user_id`/`author_name`, a stare `note_type IN ('shoppro','allegro','message')` to importowane.
Avoid: zmiany w `comment`/`payload_json`/`source_note_id` (ochrona istniejących importów). UNIQUE `(order_id, source_note_id)` zostaje — user notes mają source_note_id=NULL, więc MySQL traktuje każdy NULL jako unique row.
</action>
<verify>php bin/migrate.php → migration logged; `DESCRIBE order_notes;` pokazuje nowe kolumny i FK; re-run migracji = no-op (idempotent guard).</verify>
<done>AC-1 satisfied: kolumny dodane, FK aktywny, indeks utworzony, schema doc zaktualizowany.</done>
</task>
<task type="auto">
<name>Task 2: OrderNotesService + repository extension + routes + Controller actions</name>
<files>src/Modules/Orders/OrderNotesService.php, src/Modules/Orders/OrdersRepository.php, src/Modules/Orders/OrdersController.php, routes/web.php</files>
<action>
1) Utwórz `src/Modules/Orders/OrderNotesService.php` (final class):
- `__construct(\PDO $pdo)`
- `listUserNotes(int $orderId): array``SELECT id, user_id, author_name, comment AS body, created_at, updated_at FROM order_notes WHERE order_id = :order_id AND note_type = 'user' ORDER BY created_at DESC, id DESC`
- `listImportedNotes(int $orderId): array` — stary `loadOrderNotes` logic, ale z filtrem `note_type != 'user'`
- `create(int $orderId, int $userId, string $authorName, string $body): int` — INSERT + zwraca lastInsertId; po INSERT wywołaj `OrderActivityLogService::log(orderId, 'note', 'Dodano notatkę', actorName=$authorName)` jeśli serwis istnieje (jeśli nie — INSERT do `order_activity_log` bezpośrednio przez PDO; pattern z Phase 56 OrderPaymentsService).
- `update(int $noteId, int $userId, string $body): bool` — UPDATE WHERE id=:id AND user_id=:user_id, zwraca `$stmt->rowCount() > 0`. Rzut `RuntimeException` z kodem 403 gdy rowCount=0 (nieautoryzowany lub nie istnieje).
- `delete(int $noteId, int $userId): bool` — DELETE WHERE id=:id AND user_id=:user_id; analogiczna obsługa autoryzacji.
- Walidacja `body`: trim, nie pusty (min 1 znak), max 2000 znaków (TEXT). Throw `InvalidArgumentException` gdy pusty.
2) `OrdersRepository.php`:
- Dodaj prywatną metodę `userNotesCountSubquerySql(string $orderAlias): string` zwracającą string `(SELECT COUNT(*) FROM order_notes WHERE order_id = ' . $orderAlias . '.id AND note_type = \'user\')` (wzorzec z `customerReturnedCountSubquerySql`).
- W `fetchOrdersForList()` (i innych metodach budujących SELECT dla listy) dodaj kolumnę `... AS user_notes_count` obok `customer_returned_count`.
- W `getOrderDetails()` doloż `user_notes_count` i `user_notes_list` (przez OrderNotesService — wstrzyknij go w konstruktorze, lub wczytaj inline analogicznie do `loadOrderNotes`). Zachowaj `loadOrderNotes` jako `loadImportedOrderNotes` (rename) lub dorzuć nową metodę `loadUserOrderNotes` filtrującą po `note_type='user'`.
3) `OrdersController.php`:
- Dodaj prywatne `$orderNotesService` w konstruktorze.
- Metoda `storeNote(Request $request): Response` — pobierz orderId z `$request->input('id')` (pattern Phase 108), userId z sesji (`$_SESSION['user']['id']`), authorName z sesji (`$_SESSION['user']['name']`), `body` z `$request->input('body')`. Walidacja CSRF. Wywołaj `OrderNotesService::create()`. Flash success/error, redirect `/orders/{id}#notes`.
- Metoda `updateNote(Request $request): Response` — params `id` (order) i `noteId`. CSRF + user authorization (przez return z service). Redirect `/orders/{id}#notes`.
- Metoda `deleteNote(Request $request): Response` — analogicznie.
- W `formatOrderRow()` (linia ~656): dodaj wyliczenie `$userNotesCount = max(0, (int) ($row['user_notes_count'] ?? 0));` i `$notesBadge = $userNotesCount >= 1 ? ' <a href="/orders/' . $orderId . '#notes" class="order-notes-badge" title="' . $userNotesCount . ' notatek">[' . $userNotesCount . ']</a>' : '';` — wklej w `order_ref` HTML obok `$returnedBadge`.
4) `routes/web.php` (po linii ~595, blok orders):
```php
$router->post('/orders/{id}/notes', [$ordersController, 'storeNote'], [$authMiddleware]);
$router->post('/orders/{id}/notes/{noteId}/update', [$ordersController, 'updateNote'], [$authMiddleware]);
$router->post('/orders/{id}/notes/{noteId}/delete', [$ordersController, 'deleteNote'], [$authMiddleware]);
```
Wstrzyknięcie `OrderNotesService` analogicznie do innych serwisów (sprawdź jak `SmsConversationService` lub `OrderPaymentsService` są instancjonowane — pattern factory w `Application.php`/`CronHandlerFactory.php` lub bezpośrednie `new` w routes).
Avoid: sklejania SQL z input; pomijania CSRF; mieszania `comment` (legacy text imported) z nowym body — używamy tej samej kolumny, ale w service zawsze filtrujemy po `note_type='user'`.
</action>
<verify>
`php -l` na wszystkich zmienionych plikach; `composer dump-autoload` jeśli trzeba; smoke ręczny po deploy: POST `/orders/{X}/notes` z curl (sesja + CSRF) → 302 + nowy wiersz w `order_notes`; UPDATE/DELETE jako inny user → 403 + flash danger.
</verify>
<done>AC-2, AC-3, AC-6 satisfied: CRUD działa, autoryzacja po `user_id` egzekwowana, subquery count w listingu bez wpływu na paginację.</done>
</task>
<task type="auto">
<name>Task 3: UI — sekcja notatek w show.php, badge na list.php, JS edit modal, SCSS, i18n</name>
<files>resources/views/orders/show.php, resources/views/orders/list.php, resources/lang/pl.php, resources/scss/modules/_order-notes.scss, resources/scss/app.scss, public/assets/js/modules/order-notes.js, resources/views/layouts/app.php</files>
<action>
1) `resources/views/orders/show.php` (sekcja "Wiadomosci i zalaczniki", linia ~449463):
- Zmień blok renderowania na 2 sub-listy:
a) `<div id="notes" class="order-user-notes">` — header "Notatki", iteracja po `$userNotesList` (passed z controllera). Każda notatka: `<div class="order-event order-event--user">` z `<div class="order-event__head">data | autor</div>`, `<div class="order-event__body">body</div>`, oraz `<div class="order-event__actions">` z przyciskami `Edytuj` / `Usuń` widocznymi gdy `(int)($note['user_id'] ?? 0) === $currentUserId`. Przycisk "Usuń" jako `<form method="post" action="/orders/{id}/notes/{noteId}/delete">` + ukryty submit + JS handler wywołujący `OrderProAlerts.confirm({title:'Usunąć notatkę?', message:'Tej operacji nie można cofnąć.', danger:true, onConfirm: function(){ form.submit(); }})` (pattern options-object — decyzja Phase 114).
- Pod listą: formularz `<form method="post" action="/orders/{id}/notes">` z CSRF `_token`, `<textarea name="body" maxlength="2000" required>`, przycisk "Zapisz".
b) `<div class="order-imported-notes">` — header "Wiadomości ze źródła", iteracja po `$importedNotesList`, render jak dotychczas (bez akcji).
- W górze widoku pobierz `$userNotesList = is_array($userNotes ?? null) ? $userNotes : [];`, `$importedNotesList = is_array($importedNotes ?? null) ? $importedNotes : [];`, `$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);`.
2) `resources/views/orders/list.php` — `order_ref` HTML już jest generowany w controllerze przez `formatOrderRow()`, więc badge wleci automatycznie. Jeśli list.php gdziekolwiek inline renderuje order ref, sprawdź i nie dubluj.
3) `resources/lang/pl.php` — dodaj klucze w sekcji `orders.details`:
```php
'notes_user_title' => 'Notatki',
'notes_user_empty' => 'Brak notatek.',
'notes_user_add_placeholder' => 'Wpisz notatkę...',
'notes_user_save' => 'Zapisz',
'notes_user_edit' => 'Edytuj',
'notes_user_delete' => 'Usuń',
'notes_user_confirm_delete' => 'Usunąć notatkę?',
'notes_imported_title' => 'Wiadomości ze źródła',
'notes_forbidden' => 'Brak uprawnień — tylko autor może edytować/usuwać notatkę.',
'notes_created' => 'Notatka dodana.',
'notes_updated' => 'Notatka zaktualizowana.',
'notes_deleted' => 'Notatka usunięta.',
```
4) `resources/scss/modules/_order-notes.scss` — utwórz nowy moduł:
- `.order-user-notes`, `.order-imported-notes`, `.order-event--user`, `.order-event__actions` (flex, gap 8px), `.order-event__actions .btn-icon` (mały rozmiar).
- `.order-notes-badge` — neutralny styl (np. `background: #eef2ff; color: #4338ca; padding: 1px 6px; border-radius: 10px; font-size: 11px; font-weight: 600; text-decoration: none; margin-left: 4px;`). Hover: `background: #e0e7ff;`. Subtelniej niż `.risk-return-badge` (czerwony, alertowy).
- Formularz dodawania notatki: `.order-note-form textarea { width: 100%; min-height: 60px; }`.
Następnie dodaj `@use 'modules/order-notes';` w `resources/scss/app.scss`. Build SCSS lub powiedz operatorowi by uruchomił `npm run build:css` / `php tools/build-scss.php` (sprawdź jaki jest build pipeline w projekcie).
5) `public/assets/js/modules/order-notes.js` — wanilijowy JS:
- Inline edit: klik "Edytuj" zamienia `order-event__body` na textarea + przyciski "Zapisz"/"Anuluj"; "Zapisz" POST `fetch('/orders/'+orderId+'/notes/'+noteId+'/update', {method:'POST', body: new FormData(form)})` → jeśli OK, reload listy AJAX-em lub `location.reload()` (najprostsze, pattern z inline-status-change.js).
- Klik "Usuń" — `OrderProAlerts.confirm({title:'Usunąć notatkę?', message:'Tej operacji nie można cofnąć.', danger:true, confirmLabel:'Usuń', onConfirm: function(){ form.submit(); }})`.
- Idempotent guard: `if (window.__orderNotesInit) return; window.__orderNotesInit = true;` (pattern Phase 114 confirm-delete.js).
6) `resources/views/layouts/app.php` — załącz nowy moduł JS (pattern z `invoice-requested-toggle.js`):
```php
<script src="/assets/js/modules/order-notes.js?ver=<?= filemtime(...) ?: 0 ?>"></script>
```
Avoid: natywnego `confirm()` (zakaz CLAUDE.md); inline styles (zakaz CLAUDE.md — wszystko do SCSS); duplikowania renderowania importowanych notatek; ujawniania `user_id` w UI jeśli `users.id` jest wrażliwe (nie jest — to wewnętrzny ID).
</action>
<verify>
Otwórz `/orders/{X}` → widać sekcję "Notatki" + form dodawania; dodaj notatkę → pojawia się w liście z datą i autorem. Spróbuj edit cudzej notatki (z innym session.user_id) → przyciski edit/delete niewidoczne, próba POST → 403. Otwórz `/orders/list` → badge `[N]` widoczny przy zamówieniach z notatkami. Sprawdź czy `risk-return-badge` (Phase 106) nadal działa obok.
</verify>
<done>AC-4, AC-5 satisfied: UI sekcji notatek działa w show.php, badge w list.php widoczny, akcje edit/delete poprawnie ograniczone do autora.</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- `order_notes.comment`, `order_notes.source_note_id`, `order_notes.payload_json` (kontrakt importu z shopPRO/Allegro — Phase 79 i wcześniejsze).
- `OrdersRepository::replaceNotes()`/`loadOrderNotes()` semantyka dla importu — jeśli rename, zachowaj BC alias lub zaktualizuj wszystkie wywołania (delta-only import, Phase 112).
- `.risk-return-badge` SCSS i logika (Phase 106) — badge notatek to osobna klasa, nie modyfikujemy zwrotów.
- `OrderProAlerts.confirm` API — używamy options-object (Phase 114 decyzja).
## SCOPE LIMITS
- Brak mentions/@-tagowania userów.
- Brak załączników do notatek (tylko tekst).
- Brak edycji historii edycji notatki (audit log w `order_activity_log` ma tylko `Dodano/Edytowano/Usunięto notatkę` — bez before/after JSON).
- Brak globalnych uprawnień admin override — tylko autor edytuje/usuwa (system ról nie istnieje; odłożone do osobnej fazy).
- Brak filtrów listy zamówień po "ma/nie ma notatki" — można dodać w przyszłości.
- Brak emitowania eventu automatyzacji `note.created` — można dodać jako osobny plan jeśli operator chce.
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php bin/migrate.php` przechodzi bez błędu, re-run = no-op
- [ ] `php -l` na każdym zmienionym pliku PHP zwraca "No syntax errors"
- [ ] POST `/orders/{id}/notes` jako user A tworzy notatkę
- [ ] POST `/orders/{id}/notes/{noteId}/update` jako user A działa, jako user B zwraca 403
- [ ] POST `/orders/{id}/notes/{noteId}/delete` jako user A usuwa, jako user B 403
- [ ] `/orders/list` pokazuje badge `[N]` przy zamówieniach z notatkami, ukryty gdy N=0
- [ ] `/orders/{id}#notes` scrolluje do sekcji notatek
- [ ] Importowane notatki ze źródła (shopPRO/allegro) renderują się jako osobny blok bez przycisków edit/delete
- [ ] Badge zwrotów (Phase 106) działa obok badge'a notatek (oba widoczne dla zamówień z obojgiem)
- [ ] SCSS skompilowany do `public/assets/css/app.css`
- [ ] CSRF wymagany w każdym POST — brak tokenu = 419/403
- [ ] Brak natywnych `confirm()` w nowym JS — wszystko przez `OrderProAlerts.confirm`
</verification>
<success_criteria>
- AC-1..AC-6 spełnione
- Brak regresji w imporcie notatek shopPRO/Allegro (delta-only import z Phase 112 nadal działa, `replaceNotes` filtruje tylko `note_type != 'user'` jeśli musi)
- Czas wykonania `/orders/list` z subquery `user_notes_count` nie pogarsza się drastycznie (indeks `idx_order_notes_type_order` aktywny)
- `.paul/codebase/db_schema.md` i `.paul/codebase/architecture.md` zaktualizowane o nowy serwis i kolumny
- `.paul/codebase/tech_changelog.md` ma wpis dla Phase 129
</success_criteria>
<output>
After completion, create `.paul/phases/129-order-user-notes/129-01-SUMMARY.md` z:
- Krótki opis co zostało zbudowane
- Decisions (np. brak admin override → tylko autor edytuje)
- Files modified (lista z task'ów)
- Migration applied (numer + opis)
- Manual smoke checklist dla operatora (POST create, UPDATE as A, UPDATE as B → 403, DELETE, badge na liście, edit modal UX)
- Deferred / follow-up (event automatyzacji `note.created`, filtr listy "ma notatki", admin override po wprowadzeniu ról)
</output>

View File

@@ -0,0 +1,191 @@
---
phase: 129-order-user-notes
plan: 01
subsystem: orders
tags: [order-notes, crud, badge, audit-log, user-authored]
requires:
- phase: 106-customer-return-alert
provides: badge pattern (`risk-return-badge`) + subquery liczby per zamowienie
- phase: 114-accounting-configs-refactor
provides: `OrderProAlerts.confirm` options-object API
provides:
- Pelen CRUD notatek autorskich operatora per zamowienie (`note_type='user'`)
- Subquery `user_notes_count` + badge `[N]` na `/orders/list`
- Inline edit (toggle textarea) + delete z `OrderProAlerts.confirm`
affects:
- Przyszle fazy z eventem automatyzacji `note.created` lub admin override po wprowadzeniu rol
tech-stack:
added: []
patterns:
- "Reuse istniejacej tabeli przez nowy `note_type` zamiast tworzenia osobnej tabeli"
- "Autoryzacja CRUD przez `WHERE user_id = :user_id` + rowCount=0 ⇒ RuntimeException(403)"
key-files:
created:
- database/migrations/20260514_000116_extend_order_notes_user_authored.sql
- src/Modules/Orders/OrderNotesService.php
- resources/scss/modules/_order-notes.scss
- public/assets/js/modules/order-notes.js
modified:
- src/Modules/Orders/OrdersController.php
- src/Modules/Orders/OrdersRepository.php
- routes/web.php
- resources/views/orders/show.php
- resources/views/layouts/app.php
- resources/lang/pl.php
- resources/scss/app.scss
key-decisions:
- "Reuse `order_notes` przez `note_type='user'` zamiast osobnej tabeli (clarification #1)"
- "Badge neutralny `[N]` (indigo `#eef2ff/#4338ca`) — subtelniejszy niz `.risk-return-badge`"
- "Brak admin override — edit/delete tylko dla autora (brak systemu rol w aplikacji)"
- "Sekcja `#notes` w istniejacej karcie 'Wiadomosci i zalaczniki' — split na 'Notatki' (user) + 'Wiadomosci ze zrodla' (imported)"
patterns-established:
- "`userNotesCountSubquerySql($orderAlias)` — wzorzec dla COUNT-per-order subquery bez wplywu na ORDER BY/GROUP BY (analogiczny do `customerReturnedCountSubquerySql`)"
- "`OrderProAlerts.confirm` z `danger:true` + options-object API dla submit'u formularza DELETE (preventDefault + onConfirm submit)"
- "Migracje no-op zawsze jako DDL (`ALTER TABLE COMMENT`), nigdy `SELECT 1` (Phase 115 pattern)"
duration: ~30min
started: 2026-05-14T00:00:00Z
completed: 2026-05-14T00:00:00Z
---
# Phase 129 Plan 01: Order User Notes Summary
**Pelen CRUD notatek autorskich operatora w zamowieniach (extend `order_notes` o `user_id`/`author_name`/`note_type='user'`), z sekcja `#notes` w szczegolach i badge `[N]` na liscie zamowien.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~30min |
| Tasks | 3 of 3 completed |
| Files created | 5 |
| Files modified | 7 |
| AC pass rate | 6 of 6 (pending live smoke) |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Migracja DB — kolumny user notes | Pass (code) | Migracja `20260514_000116_*.sql` z `information_schema` guard + DDL no-op fallback. Aktywacja na zywej bazie: pending operator. |
| AC-2: Tworzenie notatki | Pass (code) | `OrderNotesService::create()` + `OrdersController::storeNote()` + `recordActivity('note', 'Dodano notatke')`. Redirect 302 → `/orders/{id}#notes`. |
| AC-3: Edycja/usuwanie tylko autor | Pass (code) | UPDATE/DELETE z `WHERE user_id = :user_id`; rowCount=0 ⇒ `RuntimeException(403)`. UI ukrywa przyciski gdy `note.user_id != session.user_id`. |
| AC-4: Lista w "Wiadomosci i zalaczniki" | Pass (code) | Sekcja `#notes` z 3 blokami (lista user notes → form dodawania → opcjonalny block "Wiadomosci ze zrodla"). |
| AC-5: Badge `[N]` na liscie zamowien | Pass (code) | `<a class="order-notes-badge" href="/orders/{id}#notes">[N]</a>` wstrzykniete w `order_ref` HTML; widoczne tylko gdy `user_notes_count >= 1`. |
| AC-6: Subquery liczy bez wplywu na paginacje | Pass (code) | `userNotesCountSubquerySql('o')` jako kolumna SELECT (NIE w WHERE/GROUP BY/ORDER). Wspierane indeksem `idx_order_notes_type_order (note_type, order_id)`. |
> **Live smoke pending**: migracja na zywym XAMPP MySQL + manualny test wieloosobowy (autor vs inny user) — udokumentowane w STATE.md follow-ups.
## Verification Results
```
$ C:/xampp/php/php.exe -l src/Modules/Orders/OrderNotesService.php
No syntax errors detected
$ C:/xampp/php/php.exe -l src/Modules/Orders/OrdersController.php
No syntax errors detected
$ C:/xampp/php/php.exe -l src/Modules/Orders/OrdersRepository.php
No syntax errors detected
$ C:/xampp/php/php.exe -l routes/web.php
No syntax errors detected
$ C:/xampp/php/php.exe -l resources/views/orders/show.php
No syntax errors detected
$ node --check public/assets/js/modules/order-notes.js
JS OK
$ npm run build:css
sass --style=compressed --no-source-map resources/scss/app.scss public/assets/css/app.css
(rebuilt successfully)
```
## Accomplishments
- **Reuse `order_notes` zamiast osobnej tabeli**: jedna tabela obsluguje 2 semantyki (`note_type IN ('shoppro','allegro','message')` = imported, `note_type='user'` = autorska). `UNIQUE (order_id, source_note_id)` nie blokuje user notes bo MySQL traktuje wiele NULL jako unique.
- **CRUD z autoryzacja DB-level**: UPDATE/DELETE filtruja po `user_id = :user_id` w SQL; `rowCount=0` rzuca 403 — eliminuje konieczność osobnego SELECT pre-check'a.
- **Badge widoczny od razu w liscie**: subquery `user_notes_count` ekspozuje liczbe w `paginate()`, badge wlozony w `toTableRow()` obok numeru zamowienia (klik scrolluje do `#notes`).
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `database/migrations/20260514_000116_extend_order_notes_user_authored.sql` | Created | ADD COLUMN user_id + author_name + FK → users + indeks (idempotentne) |
| `src/Modules/Orders/OrderNotesService.php` | Created | CRUD service nad `order_notes` (`note_type='user'`) z autoryzacja po user_id |
| `resources/scss/modules/_order-notes.scss` | Created | `.order-notes-badge`, `.order-user-notes`, `.order-event--user`, `.btn-link`, `.order-note-form` |
| `public/assets/js/modules/order-notes.js` | Created | Vanilla JS: inline edit toggle + `OrderProAlerts.confirm` na delete (idempotent guard) |
| `src/Modules/Orders/OrdersController.php` | Modified | Dodano `OrderNotesService` jako nullable dep + `storeNote/updateNote/deleteNote` + badge w `toTableRow()` |
| `src/Modules/Orders/OrdersRepository.php` | Modified | `userNotesCountSubquerySql()` + kolumna `user_notes_count` w paginate; `loadOrderNotes()` zawezone do `note_type <> 'user'` |
| `routes/web.php` | Modified | 3 nowe routes (POST notes/store|update|delete) + `OrderNotesService` instancjonowany + przekazany do `OrdersController` |
| `resources/views/orders/show.php` | Modified | Sekcja `#notes` rozbita na user-notes + form + imported-notes; per-note edit form (ukryty) |
| `resources/views/layouts/app.php` | Modified | `<script>` dla `order-notes.js` |
| `resources/lang/pl.php` | Modified | 9 nowych kluczy `orders.details.notes_user_*` + `notes_imported_title` |
| `resources/scss/app.scss` | Modified | `@use "modules/order-notes"` |
| `public/assets/css/app.css` | Modified | Rebuilt by `npm run build:css` |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Reuse `order_notes` z `note_type='user'` | Jedna tabela = mniej obiektow DB, prosciej testowac, UNIQUE NULL nie koliduje | Importowane notatki ze zrodla nadal dzialaja niezmienione; w `loadOrderNotes()` dorzucony filtr `note_type <> 'user'` |
| Brak admin override (tylko autor edit/delete) | Brak systemu rol w aplikacji (`grep is_admin\|role=` → 0 hits) | Operator ktory dodal notatke moze ja modyfikowac; admin override odlozony do osobnej fazy gdy beda role |
| Badge `[N]` w `order_ref` (NIE osobna kolumna) | Minimalny footprint w tabeli, spojnie z `risk-return-badge` (przy `buyer_name`) | Badge widoczny bez zmian w naglowkach tabeli `/orders/list` |
| Body limit 2000 znakow (`mb_strlen`) | TEXT moze przechowac wiecej, ale UX podpowiada krotkie notatki; spojnie z polem comment | Walidacja w `OrderNotesService::sanitizeBody()` — rzut `InvalidArgumentException` gdy przekroczenie |
| Migracja idempotentna z DDL no-op fallback | Decyzja z Phase 115/125 — `SELECT 1` powoduje SQLSTATE 2014 przy PDO unbuffered | Re-run migracji = no-op (`ALTER TABLE COMMENT`) bez bledu |
| Edit toggle (ukryty form per notatka) zamiast modala | Mniej UI ceremoniaiu, spojnie z istniejacym `order-event` layoutem | JS prosty (show/hide pary `js-order-note-body``js-order-note-edit-form`) |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 1 | Cosmetic naming |
| Scope additions | 0 | None |
| Deferred | 0 | None |
**Total impact:** Minimalne deviation — plan wykonany niemal 1:1.
### Auto-fixed Issues
**1. [Naming] `formatOrderRow` → `toTableRow`**
- **Found during:** Task 2 (badge w controllerze)
- **Issue:** Plan referowal do nieistniejacej metody `formatOrderRow()` w `OrdersController`
- **Fix:** Edycja `toTableRow()` (rzeczywista nazwa metody) — semantyka identyczna
- **Files:** `src/Modules/Orders/OrdersController.php`
- **Verification:** `grep -n "public function|toTableRow"` potwierdzilo `toTableRow` jako wlasciwa nazwa
- **Commit:** N/A (jeden Task 2 commit obejmie wszystkie zmiany)
### Deferred Items
None — plan executed exactly as written.
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Plan referowal `formatOrderRow()` (nieistniejacy) | Sprawdzono rzeczywiste metody przez `Grep "public function"``toTableRow` jest poprawna nazwa. Patch zaaplikowany. |
## Next Phase Readiness
**Ready:**
- Migracja czeka na operator (`php bin/migrate.php`).
- UI sekcji notatek + badge gotowe — manualny smoke test moze byc wykonany po migracji.
- Pattern `userNotesCountSubquerySql` + nullable `OrderNotesService` w `OrdersController` — gotowe do reuse w przyszlych phasach (np. event automatyzacji `note.created` lub admin override).
**Concerns:**
- Bez systemu rol nie ma admin override — jezeli operator chce zeby kazdy user mogl edytowac/usuwac kazda notatke, trzeba zmienic warunek w `OrderNotesService::update()/delete()` (usunac `AND user_id = :user_id`).
- Brak filtra "ma notatki" / "nie ma notatek" w liscie zamowien — kandydat na rozszerzenie jezeli operator zechce.
**Blockers:**
- None — plan wdrozony, smoke test po stronie operatora.
---
*Phase: 129-order-user-notes, Plan: 01*
*Completed: 2026-05-14*

View File

@@ -0,0 +1,238 @@
---
phase: 130-polkurier-delivery-status-mappings
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/Modules/Shipments/DeliveryStatus.php
- src/Modules/Settings/DeliveryStatusesController.php
- src/Modules/Settings/DeliveryStatusMappingController.php
- src/Modules/Shipments/DeliveryStatusMappingRepository.php
autonomous: true
delegation: off
---
<objective>
## Goal
Eksponuj `polkurier` w UI `/settings/delivery-statuses?tab=mapping`: dropdown providerów pokazuje pozycję "polkurier", 7 domyślnych mapowań (O/P/A/WP/D/Z/W) ładuje się z `DeliveryStatus::getDefaultMappings('polkurier')`, a badge "niezmapowane statusy" w menu zlicza również polkurier.
## Purpose
Phase 128 dodała `PolkurierShipmentService`/`PolkurierTrackingService` i seed migrację `delivery_status_mappings(provider='polkurier')`, ale UI mapowania pozostał hardcoded na 3 providerów (`inpost`/`apaczka`/`allegro_wza`). Operator nie ma jak zmapować/podejrzeć statusów polkuriera w panelu — kontrakt zamknięty od strony backendu, otwarty od strony UI. Bez tej fazy operator musi grzebać w SQL żeby zobaczyć/zmienić mapowania, co łamie wzorzec ustanowiony w Phase 108.
## Output
- `POLKURIER_MAP` + `POLKURIER_DESCRIPTIONS` w `DeliveryStatus.php` (7 wpisów) + rejestracja w `PROVIDER_MAPS`/`PROVIDER_DESCRIPTIONS`/`normalize()`/`description()` match.
- `'polkurier' => 'polkurier'` w `PROVIDERS` w obu kontrolerach (`DeliveryStatusesController`, `DeliveryStatusMappingController`).
- `'polkurier'` w pętli `countAllUnmappedForBadge()` w `DeliveryStatusMappingRepository`.
</objective>
<context>
<clarifications>
- **Źródło defaultów** — Skąd UI ma czerpać 7 domyślnych mapowań polkurier (O/P/A/WP/D/Z/W)?
→ Odpowiedź: Hardcoded w `DeliveryStatus.php` (POLKURIER_MAP + POLKURIER_DESCRIPTIONS, analogicznie do InPost/Apaczka/Allegro). DB seed migracji z Phase 128 nadal dostępny jako override.
- **Etykieta UI** — Jaką etykietę pokazać w dropdownie providerów na tabie Mapowanie?
→ Odpowiedź: `polkurier` (lowercase, spójne z hubem integracji Phase 127 i provider code w `shipment_packages.provider`).
- **Badge counter** — Czy badge 'niezmapowane statusy' w menu Ustawienia ma uwzględniać polkurier?
→ Odpowiedź: Tak — dodać `polkurier` do pętli `countAllUnmappedForBadge()`.
</clarifications>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
@.paul/codebase/architecture.md
@.paul/codebase/db_schema.md
## Source Files
@src/Modules/Shipments/DeliveryStatus.php
@src/Modules/Settings/DeliveryStatusesController.php
@src/Modules/Settings/DeliveryStatusMappingController.php
@src/Modules/Shipments/DeliveryStatusMappingRepository.php
@resources/views/settings/delivery-statuses.php
## Prior Work
@.paul/phases/128-polkurier-shipment-service/128-01-SUMMARY.md
@.paul/phases/108-delivery-status-management/108-02-SUMMARY.md
</context>
<acceptance_criteria>
## AC-1: Polkurier widoczny w dropdownie providerów
```gherkin
Given operator jest zalogowany i otwiera `/settings/delivery-statuses?tab=mapping`
When dropdown "Provider" jest rozwinięty
Then na liście widoczne są 4 pozycje: InPost, Apaczka, Allegro oraz polkurier
```
## AC-2: 7 domyślnych mapowań polkurier
```gherkin
Given operator wybiera "polkurier" w dropdownie providerów na tabie Mapowanie
When tabela mapowań się ładuje (bez uruchamiania migracji seed Phase 128)
Then widoczne jest dokładnie 7 wierszy z raw statusami: O, P, A, WP, D, Z, W
And każdy wiersz pokazuje znormalizowany status zgodny z dokumentacją polkurier v1.11
(Ocreated, Pconfirmed, Acancelled, WPin_transit, Ddelivered, Zreturned, Wproblem)
And każdy wiersz pokazuje opis PL (np. "Oczekuje na płatność", "Dostarczona")
And wiersze NIE są oznaczone jako "custom" (is_custom=false) to są defaulty z kodu
```
## AC-3: Badge "niezmapowane" zlicza polkurier
```gherkin
Given w `shipment_packages` istnieje wiersz z `provider='polkurier'` i `delivery_status_raw='X'`
And kod 'X' nie jest w domyślnych 7 ani w override'ach `delivery_status_mappings`
When sidebar Ustawień się renderuje (badge "niezmapowane")
Then licznik z `countAllUnmappedForBadge()` wzrasta o 1 z tytułu polkurier
```
## AC-4: Override DB nadpisuje hardcoded default
```gherkin
Given operator zapisuje override dla `provider='polkurier'`, `raw_status='D'` z `normalized_status='problem'`
When operator odświeża tab Mapowanie z `provider=polkurier`
Then wiersz "D" pokazuje normalized='problem' (z DB) zamiast 'delivered' (z kodu)
And wiersz jest oznaczony jako custom (is_custom=true)
```
## AC-5: Zero regresji dla istniejących providerów
```gherkin
Given operator otwiera `/settings/delivery-statuses?tab=mapping&provider=inpost`
When tabela się ładuje
Then liczba i treść wierszy InPost/Apaczka/Allegro pozostaje identyczna jak przed zmianami
And `DeliveryStatus::normalize('inpost', $raw)` zwraca te same wartości
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Hardcoded POLKURIER_MAP + POLKURIER_DESCRIPTIONS w DeliveryStatus.php</name>
<files>src/Modules/Shipments/DeliveryStatus.php</files>
<action>
Dodaj dwie nowe stałe klasowe analogicznie do `INPOST_MAP`/`INPOST_DESCRIPTIONS`:
```php
private const POLKURIER_MAP = [
'O' => self::CREATED,
'P' => self::CONFIRMED,
'A' => self::CANCELLED,
'WP' => self::IN_TRANSIT,
'D' => self::DELIVERED,
'Z' => self::RETURNED,
'W' => self::PROBLEM,
];
private const POLKURIER_DESCRIPTIONS = [
'O' => 'Oczekuje na płatność',
'P' => 'Potwierdzone, list wygenerowany',
'A' => 'Anulowane',
'WP' => 'W przewozie',
'D' => 'Dostarczona',
'Z' => 'Zwrot do nadawcy',
'W' => 'Wyjątek',
];
```
Następnie zarejestruj `'polkurier'` w trzech miejscach:
1. `PROVIDER_MAPS` (po `'allegro_wza'`) — `'polkurier' => self::POLKURIER_MAP,`
2. `PROVIDER_DESCRIPTIONS` (po `'allegro_wza'`) — `'polkurier' => self::POLKURIER_DESCRIPTIONS,`
3. `normalize()` match expression — dodaj `'polkurier' => self::POLKURIER_MAP,`
4. `description()` match expression — dodaj `'polkurier' => self::POLKURIER_DESCRIPTIONS,`
Treść 7 wpisów MUSI być identyczna z migracją Phase 128
(`database/migrations/20260514_000115_seed_polkurier_delivery_status_mappings.sql`).
To gwarantuje że jeśli operator odpali seed migrację po wdrożeniu, nie zmieni się żadne mapowanie
(default == DB override → `is_custom=true` ale ta sama wartość).
Avoid: zmiana kolejności/struktury INPOST/APACZKA/ALLEGRO_WZA — to złamałoby AC-5.
</action>
<verify>
php -r "require 'vendor/autoload.php'; var_export(\App\Modules\Shipments\DeliveryStatus::getDefaultMappings('polkurier'));"
# Oczekiwane: array z 7 kluczami (O/P/A/WP/D/Z/W), każdy z 'normalized' i 'description'.
</verify>
<done>AC-2, AC-5 satisfied: 7 defaultów polkurier z poprawnym normalized+description; existing providers nietknięte.</done>
</task>
<task type="auto">
<name>Task 2: Dodaj 'polkurier' do PROVIDERS w obu kontrolerach + badge counter</name>
<files>src/Modules/Settings/DeliveryStatusesController.php, src/Modules/Settings/DeliveryStatusMappingController.php, src/Modules/Shipments/DeliveryStatusMappingRepository.php</files>
<action>
1. `DeliveryStatusesController.php` (linie 22-26): dodaj `'polkurier' => 'polkurier',` jako 4. wpis w stałej `PROVIDERS`. Zachowaj kolejność: inpost, apaczka, allegro_wza, polkurier.
2. `DeliveryStatusMappingController.php` (linie 22-26): identyczna zmiana w analogicznej stałej `PROVIDERS`.
3. `DeliveryStatusMappingRepository.php` linia 158 — zmień:
```php
$providers = ['inpost', 'apaczka', 'allegro_wza'];
```
na:
```php
$providers = ['inpost', 'apaczka', 'allegro_wza', 'polkurier'];
```
Po tej zmianie `index()` w obu kontrolerach automatycznie zaakceptuje `?provider=polkurier`
(sprawdza `isset(self::PROVIDERS[$provider])`), pobierze defaulty z `DeliveryStatus::getDefaultMappings('polkurier')`
(Task 1), i scali z override'ami z `DeliveryStatusMappingRepository::listByProvider('polkurier')`.
Widok `resources/views/settings/delivery-statuses.php` iteruje po `$providers` (dropdown)
i nie wymaga zmian — automatycznie pokaże nową pozycję.
Avoid: dodanie polkurier w innej pozycji niż na końcu tablicy — może to zmienić default
(`$provider = 'inpost'` w fallback jest niezależny i bezpieczny, ale kolejność wpływa na render dropdownu).
</action>
<verify>
# 1. Sprawdź dropdown:
curl -s -b "session.cookie" https://orderpro.projectpro.pl/settings/delivery-statuses?tab=mapping | grep -c 'value="polkurier"'
# Oczekiwane: 1
# 2. Sprawdź że provider=polkurier renderuje 7 wierszy:
curl -s -b "session.cookie" 'https://orderpro.projectpro.pl/settings/delivery-statuses?tab=mapping&provider=polkurier' | grep -E 'raw_status.*(O|P|A|WP|D|Z|W)' | wc -l
# Oczekiwane: 7
# 3. Smoke regresji — InPost dalej działa:
curl -s -b "session.cookie" 'https://orderpro.projectpro.pl/settings/delivery-statuses?tab=mapping&provider=inpost' | grep -c 'value="inpost"'
# Oczekiwane: 1 (selected)
</verify>
<done>AC-1, AC-3, AC-4, AC-5 satisfied: dropdown pokazuje polkurier, 7 defaultów się renderuje, badge zlicza polkurier, override DB nadpisuje default, istniejące providery bez regresji.</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- `database/migrations/20260514_000115_seed_polkurier_delivery_status_mappings.sql` — migracja Phase 128 zostaje as-is; ten plan dubluje jej treść w kodzie ale NIE zmienia samej migracji (operator może ją odpalić lub nie — funkcjonalność niezależna).
- Stałe `INPOST_MAP`/`APACZKA_MAP`/`ALLEGRO_MAP`/`ALLEGRO_EDGE_MAP` w `DeliveryStatus.php` — żadnych edycji wartości lub kolejności.
- `PolkurierTrackingService` — kontrakt mapowania Phase 128 zostaje nietknięty; ten plan nie zmienia logiki normalizacji w runtime, tylko ekspozycję defaultów w UI.
- Schemat tabeli `delivery_status_mappings` — brak migracji w tym planie.
- `DeliveryStatus::trackingUrl()` (zawiera już branch `polkurier` z Phase 128) — nietknięte.
## SCOPE LIMITS
- Brak dodatkowych mapowań polkurier (np. nieudokumentowanych w v1.11) — tylko 7 oficjalnych kodów z dokumentacji.
- Brak osobnej zakładki/podstrony dla polkurier — reuse istniejącego tab `mapping` z dropdownem.
- Brak zmian w `PROJECT.md`/`ROADMAP.md` — to robi UNIFY.
- Brak migracji DB — defaulty z kodu, override z DB jak dla pozostałych providerów.
- Brak zmian w widoku `delivery-statuses.php` — dropdown iteruje po `$providers` z controllera.
</boundaries>
<verification>
Przed declared complete:
- [ ] `DeliveryStatus::getDefaultMappings('polkurier')` zwraca 7 wpisów z poprawnymi normalized+description (AC-2).
- [ ] Dropdown providerów w `/settings/delivery-statuses?tab=mapping` pokazuje 4 pozycje w kolejności InPost, Apaczka, Allegro, polkurier (AC-1).
- [ ] Selekcja `?provider=polkurier` ładuje 7 wierszy mapowań bez fatal errora (AC-2).
- [ ] Override DB (manual INSERT do `delivery_status_mappings` lub przez UI) zmienia `is_custom=true` dla wiersza (AC-4).
- [ ] `countAllUnmappedForBadge()` dla wstrzykniętego raw statusu `polkurier:XYZ` zwraca +1 (AC-3).
- [ ] Smoke regresji: `?provider=inpost`/`apaczka`/`allegro_wza` zwracają identyczną liczbę wierszy jak przed zmianą (AC-5).
- [ ] `php -l` przechodzi dla wszystkich 4 zmienionych plików.
</verification>
<success_criteria>
- 4 pliki zmodyfikowane (3 controllery + repo + DeliveryStatus.php — łącznie 4 fizyczne pliki, 5 punktów edycji).
- AC-1..AC-5 zweryfikowane.
- Brak zmian schematu DB.
- Phase 128 seed migration nie wymaga modyfikacji — pozostaje no-op (po Task 1 defaulty = wartości w migracji).
- Manual smoke na `/settings/delivery-statuses?tab=mapping&provider=polkurier` po deploy.
</success_criteria>
<output>
After completion, create `.paul/phases/130-polkurier-delivery-status-mappings/130-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,134 @@
---
phase: 130-polkurier-delivery-status-mappings
plan: 01
subsystem: ui
tags: [delivery-statuses, polkurier, mapping, settings]
requires:
- phase: 128-polkurier-shipment-service
provides: PolkurierTrackingService + delivery_status_mappings seed migration (DB-side override)
- phase: 108-delivery-status-management
provides: DeliveryStatus::PROVIDER_MAPS pattern + DeliveryStatusMappingController + view _delivery-status-mappings-content.php
provides:
- polkurier visible in Provider dropdown on /settings/delivery-statuses?tab=mapping
- 7 hardcoded default mappings for polkurier (O/P/A/WP/D/Z/W) in DeliveryStatus.php
- polkurier counted in countAllUnmappedForBadge() so menu badge reacts to unknown polkurier raw statuses
affects:
- future polkurier UI work (paczkomaty selector, presety przesylek)
- any future delivery provider additions (recipe established: 5 edit points)
tech-stack:
added: []
patterns:
- "Provider addition recipe: 1 const + 1 PROVIDER_MAPS + 1 PROVIDER_DESCRIPTIONS + 2 match arms + 2 PROVIDERS controller consts + 1 badge providers list = 5 edit points across 4 files"
key-files:
modified:
- src/Modules/Shipments/DeliveryStatus.php
- src/Modules/Settings/DeliveryStatusesController.php
- src/Modules/Settings/DeliveryStatusMappingController.php
- src/Modules/Shipments/DeliveryStatusMappingRepository.php
key-decisions:
- "Defaultowe mapowania polkurier hardcoded w DeliveryStatus.php (spojnie z InPost/Apaczka/Allegro)"
- "Etykieta dropdownu = 'polkurier' (lowercase, spojne z Phase 127 hub integracji)"
- "Badge counter uwzglednia polkurier (caly framework, nie wybiorczo)"
patterns-established:
- "Provider-addition checklist: trzy hardcoded providers (PROVIDER_MAPS/PROVIDER_DESCRIPTIONS + 2× normalize/description match) + dwa hardcoded controllery (PROVIDERS const) + jeden repo (badge providers list)"
duration: ~15min
started: 2026-05-14T18:00:00Z
completed: 2026-05-14T18:15:00Z
---
# Phase 130 Plan 01: polkurier delivery status mappings UI Summary
**polkurier widoczny w dropdownie `/settings/delivery-statuses?tab=mapping`, 7 oficjalnych kodow ORDER_STATUS (O/P/A/WP/D/Z/W) z dokumentacji v1.11 hardcoded jako defaults; badge "niezmapowane" w menu zlicza polkurier obok inpost/apaczka/allegro_wza.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15 min |
| Started | 2026-05-14T18:00:00Z |
| Completed | 2026-05-14T18:15:00Z |
| Tasks | 2/2 completed |
| Files modified | 4 source files |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: polkurier widoczny w dropdownie providerów | Pass | `PROVIDERS` w `DeliveryStatusesController` + `DeliveryStatusMappingController` zawiera 4 wpisy; widok `_delivery-status-mappings-content.php` iteruje po `$providersList` z controllera |
| AC-2: 7 domyślnych mapowań polkurier | Pass | Live test: `DeliveryStatus::getDefaultMappings('polkurier')` zwrócił 7 wpisów (O→created, P→confirmed, A→cancelled, WP→in_transit, D→delivered, Z→returned, W→problem) z poprawnymi opisami PL |
| AC-3: Badge "niezmapowane" zlicza polkurier | Pass | `DeliveryStatusMappingRepository::countAllUnmappedForBadge()` zmienił listę z `['inpost','apaczka','allegro_wza']` na `[..., 'polkurier']` |
| AC-4: Override DB nadpisuje hardcoded default | Pass | Logika `index()` w obu kontrolerach (niezmieniona) iteruje po `defaults` i nadpisuje `$overrideMap[$rawStatus]` z `delivery_status_mappings` — pattern identyczny jak dla inpost/apaczka/allegro_wza |
| AC-5: Zero regresji dla istniejących providerów | Pass | `INPOST_MAP`/`APACZKA_MAP`/`ALLEGRO_MAP`/`ALLEGRO_EDGE_MAP` nietknięte; `PROVIDER_MAPS`/`PROVIDER_DESCRIPTIONS` zachowują kolejność; `normalize()`/`description()` match dostały tylko jedną nową gałąź `polkurier` |
## Accomplishments
- Hardcoded `POLKURIER_MAP` + `POLKURIER_DESCRIPTIONS` w `DeliveryStatus.php` — 7 wpisów z oficjalnej dokumentacji polkurier API v1.11 (marzec 2026), zgodne wartości z migracją Phase 128 (`20260514_000115_seed_polkurier_delivery_status_mappings.sql`)
- 5 punktów edycji w 4 plikach (1 const definition + 2 PROVIDER_* + 2 match arms + 2× PROVIDERS controller + 1 badge providers list)
- Brak regresji: defaulty inpost/apaczka/allegro pozostały bit-for-bit identyczne; zero zmian w schemacie DB; zero zmian w widoku (dropdown auto-iteruje po providerach z controllera)
## Task Commits
Atomic per-task commit nie wykonany w trakcie APPLY — wszystkie 4 pliki źródłowe zostaną zacommitowane jako jeden commit fazowy `feat(130): polkurier delivery status mappings UI` w kroku transition (zgodnie z konwencją poprzednich faz v3.7).
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `src/Modules/Shipments/DeliveryStatus.php` | Modified (+25 linii) | Dodano `POLKURIER_MAP` (7 wpisów) + `POLKURIER_DESCRIPTIONS` (7 opisów PL) + rejestracja w `PROVIDER_MAPS`, `PROVIDER_DESCRIPTIONS`, oraz w match expressions `normalize()` / `description()` |
| `src/Modules/Settings/DeliveryStatusesController.php` | Modified (+1) | Dodano `'polkurier' => 'polkurier'` do stałej `PROVIDERS` (4 wpis) |
| `src/Modules/Settings/DeliveryStatusMappingController.php` | Modified (+1) | Identyczna zmiana w analogicznej stałej `PROVIDERS` |
| `src/Modules/Shipments/DeliveryStatusMappingRepository.php` | Modified (1 ↔) | `countAllUnmappedForBadge()`: lista providerów rozszerzona o `polkurier` |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| POLKURIER_MAP/DESCRIPTIONS hardcoded w DeliveryStatus.php zamiast tylko z DB seed | Spójność z inpost/apaczka/allegro_wza — wszyscy mają hardcoded defaults i opcjonalne DB overrides. UI tab `polkurier` działa od razu, niezależnie od tego czy operator uruchomił migrację Phase 128. | Migracja `20260514_000115_seed_polkurier_delivery_status_mappings.sql` z Phase 128 staje się no-op (DB override == default → render `is_custom=true` ale ta sama wartość). Można ją uruchomić lub nie. |
| Etykieta dropdownu = `polkurier` (lowercase) | Spójność z provider code w `shipment_packages.provider`, z hubem integracji Phase 127, z PROJECT.md decisions. | Następne integracje powinny używać tej samej konwencji (lowercase brand name). |
| Badge counter dodaje `polkurier` | Cały framework "niezmapowane raw statusy" powinien działać jednolicie dla wszystkich providerów obecnych w UI mapowania. | Operator zobaczy w badge'u nowy raw status polkuriera (gdyby pojawił się jakiś kod spoza udokumentowanych 7) — tak samo jak dla innych przewoźników. |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 0 | — |
| Scope additions | 0 | — |
| Deferred | 0 | — |
**Total impact:** Brak deviacji — plan wykonany 1:1.
### Deferred Items
Brak — plan wykonany dokładnie jak napisany.
## Issues Encountered
Brak — wszystkie 5 edycji zaaplikowane czysto, PHP lint przeszedł na 4 plikach, runtime test `getDefaultMappings('polkurier')` zwrócił oczekiwane 7 wpisów.
## Next Phase Readiness
**Ready:**
- Mapowanie polkurier w pełni widoczne w UI dla operatora — może podejrzeć i nadpisać każdy z 7 statusów.
- Badge "niezmapowane" zareaguje gdy polkurier zwróci nieudokumentowany raw status.
- Provider-addition recipe utrwalony — następny przewoźnik dodawany w 5 punktach edycji (4 pliki).
**Concerns:**
- Migracja Phase 128 (`20260514_000115_seed_polkurier_delivery_status_mappings.sql`) staje się no-op po wdrożeniu — może ją zostawić jako historyczny ślad albo (opcjonalnie, deferred do osobnej fazy cleanup) zamienić na `ALTER TABLE COMMENT` no-op. Nie blokuje niczego.
- Brak manualnego smoke na żywej bazie — operator musi otworzyć `/settings/delivery-statuses?tab=mapping&provider=polkurier` po deploy.
**Blockers:**
- None.
---
*Phase: 130-polkurier-delivery-status-mappings, Plan: 01*
*Completed: 2026-05-14*