feat(127): polkurier integration foundation

Single-instance globalna konfiguracja polkurier.pl jako alternatywa
dla Apaczki: szyfrowany login + Token API, karta w hubie integracji
i realny test polaczenia przez apimetod=test_auth_api zweryfikowany
na zywym koncie operatora (Autoryzacja: 1).

ShipmentProviderRegistry netkniety - PolkurierShipmentService/
TrackingService w kolejnych fazach.

Kluczowe ustalenia kontraktu API (z SDK polkurier-sdk):
- POST https://api.polkurier.pl/ (jeden endpoint)
- JSON body: {authorization:{login,token}, apimetod, data}
- Sukces: top-level status === 'success' (nie 'ok')
- Blad: tresc w polu 'response' envelope'a
- Content-Type: application/json (strict, bez charset suffix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 11:43:11 +02:00
parent 541e61bf7d
commit 3443879f59
17 changed files with 1391 additions and 17 deletions

View File

@@ -13,8 +13,8 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
| Attribute | Value |
|-----------|-------|
| Version | 3.7.0-dev |
| Status | v3.7 in progress — Phases 113-125 shipped (Fakturownia + HostedSMS/SMSPLANET + Alert unify + receipt VAT + SMS templates + invoice_requested import fix) |
| Last Updated | 2026-05-13 (Phase 125 closed) |
| Status | v3.7 in progress — Phases 113-127 shipped (Fakturownia + HostedSMS/SMSPLANET + Alert unify + receipt VAT + SMS templates + invoice_requested import fix + invoice GUS mapping + polkurier foundation) |
| Last Updated | 2026-05-14 (Phase 127 closed) |
## Requirements
@@ -126,6 +126,7 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
- [x] Eksport XLSX paragonow w `/accounting`: nowe naglowki (Numer | Data wystawienia | Kwota brutto | Kwota netto | Stawka VAT | Kwota VAT) z osobnym wierszem per stawka VAT; `items_json` snapshot rozszerzony o `vat` per pozycja (z `order_items.tax_rate`, fallback 23.0); legacy fallback `net = brutto/1.23` — Phase 123
- [x] Szablony SMS: CRUD w `/settings/sms-templates` (name + body + is_active), wspolny `SmsVariableResolver` wydzielony z Email\\VariableResolver (placeholdery `{{zamowienie.*|kupujacy.*|adres.*|firma.*|przesylka.*}}`), dropdown "Wybierz szablon" w zakladce SMS na `/orders/{id}` wstawia rozwiniete zmienne do textarea (z `OrderProAlerts.confirm` przy nadpisaniu); stopka SMSPLANET dalej doklejana wylacznie przez `SmsConversationService::buildFinalOutboundBody()` (Phase 122 contract preserved) — Phase 124
- [x] Bugfix detekcji faktury przy imporcie: shopPRO order z `firm_nip` ustawia `invoice_requested=1` (mapper jako jedyne zrodlo heurystyki, sync service propaguje `aggregate['invoice_detected']`); Allegro rozszerzony o `naturalPerson=false`/`address.taxId`/`companyName` (wczesniej tylko `invoice.required`); usunieta legacy kolumna `orders.is_invoice` (Phase 115 dryft) + backfill 7 zamowien — Phase 125
- [x] Integracja polkurier.pl (fundament): pojedyncza globalna konfiguracja w `/settings/integrations/polkurier`, szyfrowany Token API + login, karta w hubie integracji obok Apaczki i realny test polaczenia przez `apimetod=test_auth_api` zweryfikowany na zywym koncie operatora; `ShipmentProviderRegistry` netkniety — `PolkurierShipmentService/TrackingService` w kolejnych fazach — Phase 127
### Deferred
@@ -237,6 +238,11 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API
| Flash dual API: `Flash::push(type, message)` (preferred, typed) + `Flash::set/get(key, value)` (BC) | Phase 120: layouty (app/auth/public) iterują `Flash::all()` automatycznie. Kontrolery mogą używać dowolnego API; legacy klucze są mapowane heurystyką (`.save/.created/.deleted/.toggled` → success, `error/fail/danger` → danger, `warning` → warning, reszta → info) | 2026-05-12 | Active |
| `OrderProAlerts.confirm` ZAWSZE options-object API + Alert component zawsze przez `include` | Phase 120 ustalil format komponentu z `extract` (locals `$type`, `$message`, `$dismissible`). Trusted HTML przez `$messageHtml` z `unset()` po użyciu (`isset` persiste w PHP `include` scope) | 2026-05-12 | Active |
| `$messageHtml` w alert component musi być `unset()` po każdym include | PHP `include` widzi zmienne kontekstu z extracted scope; bez `unset` kolejny include w tym samym widoku falszywie wykrywa `isset($messageHtml)`. Pattern dla wszystkich miejsc używających `$messageHtml` (4 widoki: invoice_form, receipt-create, printing, statistics/orders) | 2026-05-12 | Active |
| polkurier startuje jako jedna globalna konfiguracja (single-instance, mirror Apaczka/HostedSMS/SMSPLANET) z realnym testowym wywolaniem `apimetod=test_auth_api` | Operator ma jedno konto polkurier; fundament musi byc zweryfikowany na zywym API zanim dolozymy `PolkurierShipmentService` | 2026-05-14 | Active |
| polkurier wymaga `login + token` razem w body `authorization` (nie samego tokena) | Zweryfikowane w SDK polkurier-sdk (`Auth.php`/`Request.php`); kolumna `login VARCHAR(190)` w `polkurier_integration_settings` mimo ze PLAN tego nie wymagal — kontrakt API to dyktuje | 2026-05-14 | Active |
| polkurier API: top-level `status` === `'success'` (nie `'ok'`), tresc bledu w polu `response` envelope'a | `ResponseStatus::SUCCESS = 'success'` z `src/Type/ResponseStatus.php` SDK; bledy rzucane przez `ErrorException($response->get('response'))` w `PolkurierWebService.php`. Pattern dla wszystkich przyszlych metod polkurier API (`createShipment`, `getLabel`, `getStatus`, `cancelOrder`, etc.) | 2026-05-14 | Active |
| polkurier API odrzuca `Content-Type` z parametrem (`application/json; charset=UTF-8`) — wymagany dokladnie `application/json` | Strict equality check po stronie polkuriera; pattern do reuse jezeli inne integracje sa rownie strict | 2026-05-14 | Active |
| polkurier dziala obok Apaczki (nie zamiast) | Decyzja operatora — oba dostawcy zyja niezaleznie, `ShipmentProviderRegistry` rejestruje obu (Apaczka netknieta w Phase 127; polkurier dodany w nastepnej fazie razem z `PolkurierShipmentService`) | 2026-05-14 | Active |
## Success Metrics
@@ -268,6 +274,6 @@ Quick Reference:
---
*PROJECT.md — Updated when requirements or context change*
*Last updated: 2026-05-12 after Phase 120 (Alert Component Unification) closure; v3.7 milestone in progress*
*Last updated: 2026-05-14 after Phase 127 (polkurier Integration Foundation) closure; v3.7 milestone in progress*

View File

@@ -26,8 +26,12 @@ Wystawianie faktur dla klientow z NIP poprzez integracje z Fakturownia (app.fakt
| 124 | SMS Templates | 1/1 | Complete (2026-05-13; migration + manual SMS smoke pending operator) |
| 125 | invoice_requested Import Fix (shopPRO+Allegro NIP detection, drop legacy is_invoice column) | 1/1 | Complete (2026-05-13; migration + manual smoke pending operator) |
| 126 | Invoice GUS Field Mapping Fix (KRS-based heuristic: JDG → name do "Imię i nazwisko", spółka → "Nazwa firmy") | 1/1 | Complete (2026-05-13; manual smoke pending operator) |
| 127 | polkurier Integration Foundation (single-instance settings + Token API + realny test polaczenia; obok Apaczki) | 1/1 | Complete (2026-05-14; live API verified — `Autoryzacja: 1`) |
Planowane kolejne fazy v3.7 (kandydaci, do rozplanowania):
- polkurier ShipmentService (CreateOrder + GetLabel + OrderValuationV2 + AvailableCarriers mapping + UI mapowan metod dostawy + presety) — fundament 127 zweryfikowany
- polkurier TrackingService + `delivery_status_mappings` (provider='polkurier')
- polkurier paczkomaty (`InpostParcelMachines` / `PocztexPostOffices` / `Kurier48PostOffices` z SDK polkuriera)
- Eksport XLSX listy wystawionych faktur (analogicznie do paragonow)
- Idempotencja podwojnego POST do Fakturowni (INVOICE-IDEMP-115)
- Event automatyzacji `invoice.created` (jezeli operator chce wysylac faktury mailem)
@@ -508,4 +512,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
---
*Roadmap created: 2026-03-12*
*Last updated: 2026-05-13 - Phase 126 UNIFY closed*
*Last updated: 2026-05-14 - Phase 127 UNIFY closed (live API verified)*

View File

@@ -5,19 +5,19 @@
See: .paul/PROJECT.md (updated 2026-05-07)
**Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami.
**Current focus:** v3.7 Invoices + operational integrations - Phase 126 GUS field mapping fix complete (UNIFY closed).
**Current focus:** v3.7 Invoices + operational integrations - Phase 127 polkurier foundation UNIFY zakonczony, transition (commit + ROADMAP/PROJECT update) pending.
## Current Position
Milestone: v3.7 Invoices (Fakturownia integration) - In progress
Phase: 126 of TBD (Invoice GUS field mapping fix) - Complete
Plan: 126-01 complete (SUMMARY.md created)
Status: UNIFY complete, transition pending (commit + ROADMAP update)
Last activity: 2026-05-13 - Phase 126-01 UNIFY zakonczony
Phase: 127 of TBD (polkurier Integration Foundation) - Complete
Plan: 127-01 complete (SUMMARY.md created); live API verified (`Autoryzacja: 1`)
Status: UNIFY complete, transition pending (git commit + decisions in PROJECT.md)
Last activity: 2026-05-14 - Phase 127-01 UNIFY zakonczony, SUMMARY + changelog utworzone
Progress:
- Milestone v3.7: [##########] ~99% (Phase 113-126 complete; transition pending)
- Phase 126: [##########] 100%
- Milestone v3.7: [##########] ~99% (Phase 113-127 complete; transition pending)
- Phase 127: [##########] 100%
## Loop Position
@@ -29,9 +29,9 @@ PLAN -> APPLY -> UNIFY
## Session Continuity
Last session: 2026-05-13
Stopped at: Phase 126-01 UNIFY closed; SUMMARY.md created
Next action: Phase transition (commit + ROADMAP update), then manual smoke (AC-1) i wybor kolejnego kandydata v3.7
Last session: 2026-05-14
Stopped at: Phase 127-01 UNIFY closed; SUMMARY.md created
Next action: Phase transition (git commit `feat(127): polkurier integration foundation` + Decisions w PROJECT.md), potem wybor kolejnego kandydata v3.7 (np. PolkurierShipmentService albo invoice.created event)
Resume file: .paul/ROADMAP.md
## Pending parallel work
@@ -65,6 +65,9 @@ Branch: main (5 commits ahead of origin/main)
- Phase 125 follow-up: zaimportuj nowe zamowienie shopPRO z `firm_nip` (bez kluczy w 5-elementowej liscie wczesniejszej heurystyki) -> potwierdz ze UI w zakladce Platnosci pokazuje zaznaczony checkbox „Klient prosi o fakture" i widoczny przycisk „Wystaw fakture".
- Phase 121 transition note (rozwiązane): commit 360eef1 obejmuje Phase 121 i Phase 122 razem; per-faza hunk-split nie wykonany ze względu na nakładkowe modyfikacje plików.
- Phase 126 follow-up: manual smoke `/orders/1090/invoice/create` (JDG, NIP 5170167517) -> "Imie i nazwisko"="JACEK PYZIAK", "Nazwa firmy"="Project-Pro Pyziak Jacek" niezmieniona; drugi smoke na zamowieniu spolki z aktywnym KRS; `curl /api/nip/lookup?nip=5170167517` -> `data.is_jdg=true`.
- Phase 127 follow-up: zaplanowac kolejna faze polkurier — `PolkurierShipmentService` (CreateOrder + GetLabel + OrderValuationV2 + AvailableCarriers mapping + UI mapowan metod dostawy + presety przesylek) — fundament + zweryfikowany kontrakt API gotowy.
- Phase 127 follow-up: drugi krok — `PolkurierTrackingService` + wpisy w `delivery_status_mappings` (provider='polkurier').
- Phase 127 follow-up: po polkurier shipment service rozwazyc fazy paczkomaty (`InpostParcelMachines` / `PocztexPostOffices` / `Kurier48PostOffices` API juz dostepne w SDK polkuriera).
## Deferred to Next Milestones

View File

@@ -0,0 +1,28 @@
# 2026-05-14
## Co zrobiono
- [Phase 127, Plan 01] polkurier.pl Integration Foundation — pojedyncza globalna konfiguracja brokera kurierskiego polkurier (login + Token API zaszyfrowany przez `IntegrationSecretCipher`), karta w hubie integracji obok Apaczki, realny test polaczenia przez `apimetod=test_auth_api`. Zweryfikowane na zywym koncie operatora (`Autoryzacja: 1`).
- Task 1: Migracja DDL (`polkurier_integration_settings` + seed `integrations.type='polkurier'`) + `PolkurierIntegrationRepository` (single-instance, mirror HostedSMS/SMSPLANET).
- Task 2: `PolkurierApiClient` (POST do `https://api.polkurier.pl/`, JSON envelope `{authorization, apimetod, data}`) + `PolkurierIntegrationController` + widok formularza + 3 routy + i18n.
- Task 3: Wpiecie polkuriera do `IntegrationsHubController` (`buildPolkurierRow()`, kolejnosc: po Apaczce) + aktualizacja `.paul/codebase/{db_schema,architecture,tech_changelog}.md`.
- Auto-fix (live debugging): `status='success'` zamiast `'ok'` (ResponseStatus z SDK), `Content-Type: application/json` bez charset suffix (polkurier strict), parser bledu z pola `response` envelope'a.
- Scope deviation vs PLAN: kolumna `login` dodana (API wymaga login+token), kolumna `environment` pominieta (polkurier nie ma sandbox).
## Zmienione pliki
- `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`
- `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`
- `.paul/STATE.md`
- `.paul/ROADMAP.md`
- `.paul/phases/127-polkurier-integration-foundation/127-01-PLAN.md`
- `.paul/phases/127-polkurier-integration-foundation/127-01-SUMMARY.md`

View File

@@ -342,6 +342,42 @@ tests/
### IntegrationsHubController
- Dodaje wiersz SMSPLANET do `/settings/integrations` ze statusem konfiguracji, sekretu, aktywnosci i ostatniego testu.
## Phase 127 — polkurier Integration Settings
### Schema
- Tabela `polkurier_integration_settings` (fixed `id=1`, `integration_id INT UNSIGNED NULL UNIQUE FK -> integrations(id) CASCADE`, `login`, `api_token_encrypted`, `default_label_format`).
- Pojedynczy rekord `integrations.type='polkurier'`, `name='polkurier'`, `base_url='https://api.polkurier.pl/'` (mirror Apaczki/HostedSMS/SMSPLANET).
- Migracja `20260514_000114_create_polkurier_integration_settings.sql` jest idempotentna (`CREATE TABLE IF NOT EXISTS` + `INSERT ... ON DUPLICATE KEY UPDATE`).
### PolkurierIntegrationRepository (`src/Modules/Settings/PolkurierIntegrationRepository.php`)
- Konstruktor `(PDO $pdo, string $secret)` — buduje wewnetrznie `IntegrationsRepository` i `IntegrationSecretCipher` (mirror `HostedSmsIntegrationRepository`).
- `getSettings()` zwraca `login`, `default_label_format`, flage `has_api_token: bool` (NIE plaintext), `is_active`, `last_test_*`.
- `saveSettings($payload)` waliduje `login` (<=190 znakow) i `default_label_format` (PDF/ZPL/EPL), szyfruje Token API; gdy token w payloadzie jest pusty -> nie nadpisuje istniejacego (BC).
- `getCredentials()` zwraca odszyfrowany `login + api_token + default_label_format` TYLKO gdy `is_active=1` i token istnieje; inaczej `null`. Konsumowane przez `PolkurierApiClient::testConnection()` i przyszly `PolkurierShipmentService`.
- `getIntegrationId()` — single source of truth dla przyszlych modulow.
### PolkurierApiClient (`src/Modules/Settings/PolkurierApiClient.php`)
- Kontrakt API zweryfikowany na podstawie oficjalnego SDK (https://github.com/Polkurier/polkurier-sdk): jedno publiczne POST endpoint `https://api.polkurier.pl/`, JSON body `{"authorization": {"login", "token"}, "apimetod": "<method>", "data": {...}}`.
- `testConnection(login, apiToken)` wywoluje `apimetod="test_auth_api"` z `data={platform: 'orderPRO', platform_version: '1.0'}`; sukces gdy `status='ok'` lub `response.authorization` niepusta.
- cURL z `SslCertificateResolver::resolve()`, `CURLOPT_TIMEOUT=$timeoutSeconds` (default 15), `CURLOPT_SSL_VERIFYPEER=true`, `Content-Type: application/json`. PHP 8.5 compatible (brak `curl_close()`).
- Stuby `createShipment()`, `getLabel()`, `getStatus()`, `cancelOrder()` rzucaja `RuntimeException("Not implemented in Phase 127")` — dolozone w kolejnych fazach.
### PolkurierIntegrationController (`src/Modules/Settings/PolkurierIntegrationController.php`)
- Endpointy: `GET /settings/integrations/polkurier`, `POST /settings/integrations/polkurier/save`, `POST /settings/integrations/polkurier/test`.
- `test` realnie wywoluje API polkurier i zapisuje wynik w `integrations.last_test_*` przez `IntegrationsRepository::updateTestResult()`.
- Flash przez legacy `Flash::set('settings_success'|'settings_error'|'polkurier_test', ...)` — spojnie z HostedSMS/SMSPLANET; renderer flash w `layouts/app.php` (Phase 120) obsluguje BC mapping przez `Flash::all()`.
- Widok `resources/views/settings/polkurier.php` uzywa wylacznie komponentu `resources/views/components/alert.php` (Phase 120 contract).
### IntegrationsHubController (Phase 127 patch)
- Dodany parametr `PolkurierIntegrationRepository $polkurier`.
- Metoda `buildPolkurierRow()` zwraca te same klucze co `buildApaczkaRow()` (`provider`, `instance`, `authorization_status`, `secret_status`, `is_active`, `last_test_at`, `configure_url`).
- Wiersz polkurier wstawiony zaraz po Apaczka (sasiednio — semantycznie oba to brokery kurierskie).
### Boundaries / co NIE zostalo dotkniete
- `ShipmentProviderRegistry` i `src/Modules/Shipments/*``PolkurierShipmentService` nie istnieje w Phase 127. Tworzenie przesylek, etykiety, tracking i mapowania metod dostawy beda dodane w kolejnej fazie.
- `apaczka_integration_settings`, `ApaczkaShipmentService`, `ApaczkaTrackingService` — Apaczka netknieta, dziala rownolegle.
- `delivery_status_mappings` — brak nowych wpisow `provider='polkurier'` (dolozone razem z tracking service w kolejnej fazie).
## Phase 121 - SMSPLANET Conversation + Notifications
### SmsConversationService (`src/Modules/Sms/SmsConversationService.php`)

View File

@@ -1,6 +1,6 @@
# Database Schema
**Updated:** 2026-05-13 | **Total tables:** 61 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci
**Updated:** 2026-05-14 | **Total tables:** 62 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci
---
@@ -615,6 +615,21 @@ UNIQUE: `(integration_id)` - one global SMSPLANET settings row.
---
**polkurier_integration_settings** — polkurier.pl broker account credentials (Phase 127; fixed 1 row)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | TINYINT UNSIGNED | NO | PK, always 1 |
| `integration_id` | INT UNSIGNED | YES | UNIQUE, FK -> integrations(id) CASCADE |
| `login` | VARCHAR(190) | YES | polkurier login (e-mail z Panel Klienta) — wymagany razem z Token API w body requestu |
| `api_token_encrypted` | TEXT | YES | AES-encrypted Token API via `IntegrationSecretCipher` (z Panel Klienta -> Ustawienia -> Token API) |
| `default_label_format` | VARCHAR(8) | NO | DEFAULT 'PDF' (PDF/ZPL/EPL) — wykorzystany przez przyszly `PolkurierShipmentService` |
| `created_at` | DATETIME | NO | |
| `updated_at` | DATETIME | NO | |
UNIQUE: `(integration_id)` - one global polkurier settings row. Token zapisywany jest rownolegle do `integrations.api_key_encrypted` (mirror patternu HostedSMS/SMSPLANET).
---
**sms_messages** - SMSPLANET inbound/outbound conversation history (Phase 121): stores direction, provider, nullable `order_id BIGINT UNSIGNED`, original and normalized phone endpoints, SMS body, provider `message_id`, status, raw JSON payload, optional `created_by`, and timestamps. Indexes: `(order_id, created_at)`, normalized phone columns, and `(provider, message_id)`.
**notifications** - Global notification center (Phase 121): stores type, title, body, target URL, related order/SMS references, `read_at`, and `created_at`. Indexes support unread polling by `(read_at, created_at)` and relation lookups.

View File

@@ -1,5 +1,30 @@
# Technical Changelog
## 2026-05-14 - Phase 127 Plan 01: polkurier Integration Foundation
**Co zrobiono:**
- Nowa migracja `database/migrations/20260514_000114_create_polkurier_integration_settings.sql` — tabela `polkurier_integration_settings` (fixed `id=1`, FK do `integrations` CASCADE, kolumny: `login VARCHAR(190)`, `api_token_encrypted TEXT`, `default_label_format VARCHAR(8) DEFAULT 'PDF'`) + idempotentny seed rekordu `integrations.type='polkurier'`, `base_url='https://api.polkurier.pl/'`.
- `src/Modules/Settings/PolkurierIntegrationRepository.php` — single-instance repository (mirror `HostedSmsIntegrationRepository`): `getSettings()` zwraca `has_api_token: bool` zamiast plaintext, `saveSettings()` szyfruje Token API przez `IntegrationSecretCipher`, `getCredentials()` gatuje na `is_active=1`, `getIntegrationId()` jako single source of truth.
- `src/Modules/Settings/PolkurierApiClient.php` — POST do `https://api.polkurier.pl/` z JSON body `{authorization:{login,token}, apimetod, data}`. Endpoint test = `apimetod="test_auth_api"`. cURL z `SslCertificateResolver::resolve()`, PHP 8.5 compatible (brak `curl_close()`). Stuby createShipment/getLabel/getStatus/cancelOrder rzucaja RuntimeException — do implementacji w kolejnych fazach.
- `src/Modules/Settings/PolkurierIntegrationController.php` — endpointy `GET /settings/integrations/polkurier`, `POST .../save`, `POST .../test` (CSRF `_token`). `test` zapisuje wynik przez `IntegrationsRepository::updateTestResult()`.
- `resources/views/settings/polkurier.php` — formularz konfiguracji + przycisk realnego testu polaczenia. Wszystkie alerty przez komponent `resources/views/components/alert.php` (Phase 120 contract).
- `src/Modules/Settings/IntegrationsHubController.php` — dodany parametr `PolkurierIntegrationRepository $polkurier` i metoda `buildPolkurierRow()`; wiersz polkurier wstawiony zaraz po Apaczka.
- `routes/web.php` — DI wiring `PolkurierIntegrationRepository` + `PolkurierIntegrationController`, rozszerzony ctor `IntegrationsHubController`, 3 nowe routy `/settings/integrations/polkurier{,/save,/test}`.
- `resources/lang/pl.php` — sekcja `settings.polkurier.*` (title/description/fields/hints/token/status/actions/flash) + `settings.integrations_hub.providers.polkurier`.
- `.paul/codebase/db_schema.md` + `architecture.md` — opisy fazy 127.
**Dlaczego:**
- Operator dostaje drugiego brokera kurierskiego rownolegle z Apaczka (decyzja w `.paul/phases/127-polkurier-integration-foundation/127-01-PLAN.md`, clarifications).
- Single-instance bo polkurier to jedno konto operatora (mirror Apaczka/InPost/HostedSMS/SMSPLANET).
- Faza zamyka tylko warstwe ustawien + realny test (`apimetod=test_auth_api`); tworzenie przesylek, etykiety, tracking i mapowania metod dostawy beda w kolejnych fazach — analogicznie do tego jak Phase 116/117 zamknely tylko fundament HostedSMS/SMSPLANET.
**Deviation vs PLAN:**
- AC-1 wymagal kolumny `environment ENUM('production','sandbox')`. polkurier nie ma srodowiska sandbox (jeden produkcyjny endpoint `https://api.polkurier.pl/`), wiec kolumna `environment` zostala POMINIETA jako YAGNI.
- AC-1/AC-2 wymagaly tylko `api_token_encrypted`. polkurier API wymaga `login + token` razem w `authorization` (zweryfikowane w oficjalnym SDK https://github.com/Polkurier/polkurier-sdk — pliki `Auth.php`/`Request.php`/`Config.php`), wiec dodana kolumna `login VARCHAR(190)` z walidacja serwerowa.
- Plan deklarowal `delegation: auto` (sub-agents). Zadania wykonane inline z powodu swiezo zgromadzonego research o API polkuriera (Config/Auth/Request/Methods z SDK); spawn agentow powtorzylby ten research. Decyzja chroni kontekst i czas. Boundaries i acceptance criteria niezmienione.
**BREAKING:** brak.
## 2026-05-13 - Phase 126 Plan 01: Invoice GUS Field Mapping Fix (KRS heuristic)
**Co zrobiono:**

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,29 @@
CREATE TABLE IF NOT EXISTS `polkurier_integration_settings` (
`id` TINYINT UNSIGNED NOT NULL PRIMARY KEY,
`integration_id` INT UNSIGNED NULL,
`login` VARCHAR(190) NULL,
`api_token_encrypted` TEXT NULL,
`default_label_format` VARCHAR(8) NOT NULL DEFAULT 'PDF',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `polkurier_integration_settings_integration_unique` (`integration_id`),
CONSTRAINT `polkurier_integration_settings_integration_fk`
FOREIGN KEY (`integration_id`) REFERENCES `integrations` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `integrations` (`type`, `name`, `base_url`, `timeout_seconds`, `is_active`, `created_at`, `updated_at`)
VALUES ('polkurier', 'polkurier', 'https://api.polkurier.pl/', 15, 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE
`base_url` = VALUES(`base_url`),
`timeout_seconds` = VALUES(`timeout_seconds`),
`updated_at` = VALUES(`updated_at`);
INSERT INTO `polkurier_integration_settings` (`id`, `integration_id`, `created_at`, `updated_at`)
SELECT 1, `id`, NOW(), NOW()
FROM `integrations`
WHERE `type` = 'polkurier' AND `name` = 'polkurier'
LIMIT 1
ON DUPLICATE KEY UPDATE
`integration_id` = VALUES(`integration_id`),
`updated_at` = VALUES(`updated_at`);

View File

@@ -586,6 +586,7 @@ return [
'shoppro' => 'shopPRO',
'hostedsms' => 'HostedSMS',
'smsplanet' => 'SMSPLANET',
'polkurier' => 'polkurier.pl',
'shoppro_instances' => ':count instancji',
],
'status' => [
@@ -741,6 +742,47 @@ return [
'test_failed' => 'Nie udalo sie polaczyc z API Apaczka.',
],
],
'polkurier' => [
'title' => 'Integracja polkurier.pl',
'description' => 'Broker kurierski polkurier.pl - alternatywa dla Apaczki. Token API generujesz w Panel Klienta > Ustawienia > Token API.',
'config' => [
'title' => 'Konfiguracja API',
],
'test' => [
'title' => 'Test polaczenia',
'description' => 'Test realnie wywoluje metode test_auth_api w API polkurier (bez tworzenia przesylki).',
],
'fields' => [
'login' => 'Login (e-mail z panelu klienta)',
'api_token' => 'Token API',
'default_label_format' => 'Domyslny format etykiety',
'is_active' => 'Integracja aktywna',
],
'hints' => [
'login' => 'Login uzywany przy generowaniu Token API w Panel Klienta polkurier.',
],
'token' => [
'saved' => 'Token API jest zapisany. Pozostaw pole puste, aby nie zmieniac.',
'missing' => 'Brak zapisanego Token API.',
],
'status' => [
'token' => 'Token API',
'active' => 'Aktywna',
'saved' => 'zapisany',
'missing' => 'brak',
'last_test' => 'Ostatni test',
],
'actions' => [
'save' => 'Zapisz ustawienia polkurier',
'send_test' => 'Testuj polaczenie',
],
'flash' => [
'saved' => 'Ustawienia polkurier zostaly zapisane.',
'save_failed' => 'Nie udalo sie zapisac ustawien polkurier.',
'test_success' => 'Polaczenie z polkurier dziala. :message',
'test_failed' => 'Nie udalo sie polaczyc z API polkurier.',
],
],
'hostedsms' => [
'title' => 'Integracja HostedSMS',
'description' => 'Konfiguracja konta HostedSMS do wysylki SMS z orderPRO.',

View File

@@ -0,0 +1,110 @@
<?php
$settings = is_array($settings ?? null) ? $settings : [];
$login = trim((string) ($settings['login'] ?? ''));
$labelFormat = strtoupper(trim((string) ($settings['default_label_format'] ?? 'PDF'))) ?: 'PDF';
$hasToken = (bool) ($settings['has_api_token'] ?? false);
$isActive = (bool) ($settings['is_active'] ?? true);
$lastTestAt = trim((string) ($settings['last_test_at'] ?? ''));
$lastTestStatus = trim((string) ($settings['last_test_status'] ?? ''));
$lastTestMessage = trim((string) ($settings['last_test_message'] ?? ''));
$lastTestHttpCode = $settings['last_test_http_code'] ?? null;
$labelFormats = ['PDF', 'ZPL', 'EPL'];
?>
<section class="card">
<h2 class="section-title"><?= $e($t('settings.polkurier.title')) ?></h2>
<p class="muted mt-12"><?= $e($t('settings.polkurier.description')) ?></p>
<?php if (!empty($errorMessage)): ?>
<div class="mt-12"><?php $type='danger'; $message=(string) $errorMessage; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="mt-12"><?php $type='success'; $message=(string) $successMessage; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
<?php endif; ?>
<?php if (!empty($testMessage)): ?>
<div class="mt-12"><?php $type='info'; $message=(string) $testMessage; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
<?php endif; ?>
</section>
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('settings.polkurier.config.title')) ?></h3>
<div class="muted mt-12">
<?= $e($t('settings.polkurier.status.token')) ?>:
<strong><?= $e($hasToken ? $t('settings.polkurier.status.saved') : $t('settings.polkurier.status.missing')) ?></strong>
|
<?= $e($t('settings.polkurier.status.active')) ?>:
<strong><?= $e($isActive ? $t('settings.integrations_hub.active.yes') : $t('settings.integrations_hub.active.no')) ?></strong>
</div>
<form class="statuses-form mt-16" action="/settings/integrations/polkurier/save" method="post" novalidate>
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.polkurier.fields.login')) ?></span>
<input class="form-control" type="text" name="login" maxlength="190" value="<?= $e($login) ?>" required>
<span class="muted"><?= $e($t('settings.polkurier.hints.login')) ?></span>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.polkurier.fields.api_token')) ?></span>
<input class="form-control" type="password" name="api_token" autocomplete="new-password" placeholder="<?= $hasToken ? '********' : '' ?>" <?= $hasToken ? '' : 'required' ?>>
<span class="muted"><?= $e($hasToken ? $t('settings.polkurier.token.saved') : $t('settings.polkurier.token.missing')) ?></span>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.polkurier.fields.default_label_format')) ?></span>
<select class="form-control" name="default_label_format">
<?php foreach ($labelFormats as $fmt): ?>
<option value="<?= $e($fmt) ?>"<?= $fmt === $labelFormat ? ' selected' : '' ?>><?= $e($fmt) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="form-field form-field--inline">
<input type="checkbox" name="is_active" value="1"<?= $isActive ? ' checked' : '' ?>>
<span class="field-label"><?= $e($t('settings.polkurier.fields.is_active')) ?></span>
</label>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.polkurier.actions.save')) ?></button>
</div>
</form>
</section>
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('settings.polkurier.test.title')) ?></h3>
<p class="muted mt-12"><?= $e($t('settings.polkurier.test.description')) ?></p>
<form class="statuses-form mt-16" action="/settings/integrations/polkurier/test" method="post" novalidate>
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<div class="form-actions mt-16">
<button type="submit" class="btn btn--secondary"><?= $e($t('settings.polkurier.actions.send_test')) ?></button>
</div>
</form>
<?php if ($lastTestAt !== ''): ?>
<div class="mt-16">
<?php
$type = $lastTestStatus === 'ok' ? 'success' : 'danger';
$parts = [];
$parts[] = $e($t('settings.polkurier.status.last_test')) . ': ' . $e($lastTestAt);
if ($lastTestStatus !== '') {
$parts[] = '<span class="badge badge--' . ($lastTestStatus === 'ok' ? 'success' : 'muted') . '">' . $e(strtoupper($lastTestStatus)) . '</span>';
}
if ($lastTestHttpCode !== null) {
$parts[] = '<span class="badge badge--muted">HTTP ' . $e((string) $lastTestHttpCode) . '</span>';
}
if ($lastTestMessage !== '') {
$parts[] = $e($lastTestMessage);
}
$messageHtml = implode(' &middot; ', $parts);
$dismissible = false;
include dirname(__DIR__) . '/components/alert.php';
unset($messageHtml);
?>
</div>
<?php endif; ?>
</section>

View File

@@ -39,6 +39,9 @@ use App\Modules\Settings\InpostIntegrationController;
use App\Modules\Settings\InpostIntegrationRepository;
use App\Modules\Settings\IntegrationsHubController;
use App\Modules\Settings\IntegrationsRepository;
use App\Modules\Settings\PolkurierApiClient;
use App\Modules\Settings\PolkurierIntegrationController;
use App\Modules\Settings\PolkurierIntegrationRepository;
use App\Modules\Settings\SmsplanetApiClient;
use App\Modules\Settings\SmsplanetIntegrationController;
use App\Modules\Settings\SmsplanetIntegrationRepository;
@@ -217,6 +220,18 @@ return static function (Application $app): void {
new HostedSmsApiClient(),
new IntegrationsRepository($app->db())
);
$polkurierIntegrationRepository = new PolkurierIntegrationRepository(
$app->db(),
(string) $app->config('app.integrations.secret', '')
);
$polkurierIntegrationController = new PolkurierIntegrationController(
$template,
$translator,
$auth,
$polkurierIntegrationRepository,
new PolkurierApiClient(),
new IntegrationsRepository($app->db())
);
$smsplanetIntegrationRepository = new SmsplanetIntegrationRepository(
$app->db(),
(string) $app->config('app.integrations.secret', '')
@@ -251,7 +266,8 @@ return static function (Application $app): void {
$shopproIntegrationsRepository,
$fakturowniaIntegrationRepository,
$hostedSmsIntegrationRepository,
$smsplanetIntegrationRepository
$smsplanetIntegrationRepository,
$polkurierIntegrationRepository
);
$cronSettingsController = new CronSettingsController(
$template,
@@ -613,6 +629,9 @@ return static function (Application $app): void {
$router->get('/settings/integrations/hostedsms', [$hostedSmsIntegrationController, 'index'], [$authMiddleware]);
$router->post('/settings/integrations/hostedsms/save', [$hostedSmsIntegrationController, 'save'], [$authMiddleware]);
$router->post('/settings/integrations/hostedsms/test', [$hostedSmsIntegrationController, 'test'], [$authMiddleware]);
$router->get('/settings/integrations/polkurier', [$polkurierIntegrationController, 'index'], [$authMiddleware]);
$router->post('/settings/integrations/polkurier/save', [$polkurierIntegrationController, 'save'], [$authMiddleware]);
$router->post('/settings/integrations/polkurier/test', [$polkurierIntegrationController, 'test'], [$authMiddleware]);
$router->get('/settings/integrations/smsplanet', [$smsplanetIntegrationController, 'index'], [$authMiddleware]);
$router->post('/settings/integrations/smsplanet/save', [$smsplanetIntegrationController, 'save'], [$authMiddleware]);
$router->post('/settings/integrations/smsplanet/test', [$smsplanetIntegrationController, 'test'], [$authMiddleware]);

View File

@@ -24,7 +24,8 @@ final class IntegrationsHubController
private readonly ShopproIntegrationsRepository $shoppro,
private readonly FakturowniaIntegrationRepository $fakturownia,
private readonly HostedSmsIntegrationRepository $hostedSms,
private readonly SmsplanetIntegrationRepository $smsplanet
private readonly SmsplanetIntegrationRepository $smsplanet,
private readonly PolkurierIntegrationRepository $polkurier
) {
}
@@ -34,6 +35,7 @@ final class IntegrationsHubController
$this->buildAllegroRow('sandbox'),
$this->buildAllegroRow('production'),
$this->buildApaczkaRow(),
$this->buildPolkurierRow(),
$this->buildInpostRow(),
$this->buildShopproRow(),
$this->buildFakturowniaRow(),
@@ -224,6 +226,30 @@ final class IntegrationsHubController
];
}
/**
* @return array<string, mixed>
*/
private function buildPolkurierRow(): array
{
$settings = $this->polkurier->getSettings();
$isConfigured = !empty($settings['login']) && !empty($settings['has_api_token']);
return [
'provider' => $this->translator->get('settings.integrations_hub.providers.polkurier'),
'instance' => 'polkurier.pl',
'authorization_status' => $isConfigured
? $this->translator->get('settings.integrations_hub.status.configured')
: $this->translator->get('settings.integrations_hub.status.not_configured'),
'secret_status' => !empty($settings['has_api_token'])
? $this->translator->get('settings.integrations_hub.status.saved')
: $this->translator->get('settings.integrations_hub.status.missing'),
'is_active' => !empty($settings['is_active']),
'last_test_at' => trim((string) ($settings['last_test_at'] ?? '')),
'configure_url' => '/settings/integrations/polkurier',
'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'),
];
}
/**
* @return array<string, mixed>
*/

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Http\SslCertificateResolver;
use RuntimeException;
/**
* polkurier.pl Web Service API client (Phase 127).
*
* Kontrakt API zweryfikowany na podstawie oficjalnego SDK (https://github.com/Polkurier/polkurier-sdk):
* - Base URL: https://api.polkurier.pl/ (single endpoint, brak path per metoda)
* - HTTP POST, Content-Type: application/json
* - Body: {"authorization": {"login": "...", "token": "..."}, "apimetod": "<method_name>", "data": {...}}
* - Test polaczenia: apimetod = "test_auth_api"
* - Sukces: top-level "status" === "success" (ResponseStatus::SUCCESS w SDK), authorization w polu "response"
* - Blad: top-level "status" !== "success"; tresc bledu w polu "response" (string lub tablica)
*
* Stuby createShipment/getLabel/getStatus/cancelOrder dolozone w kolejnych fazach.
*/
final class PolkurierApiClient
{
private const API_URL = 'https://api.polkurier.pl/';
private const PLATFORM = 'orderPRO';
private const PLATFORM_VERSION = '1.0';
public function __construct(private readonly int $timeoutSeconds = 15)
{
}
/**
* @return array{ok: bool, http_code: int, message: string}
*/
public function testConnection(string $login, string $apiToken): array
{
$payload = [
'authorization' => [
'login' => trim($login),
'token' => trim($apiToken),
],
'apimetod' => 'test_auth_api',
'data' => [
'platform' => self::PLATFORM,
'platform_version' => self::PLATFORM_VERSION,
],
];
[$body, $httpCode, $curlError] = $this->postJson($payload);
if ($curlError !== null) {
return [
'ok' => false,
'http_code' => $httpCode,
'message' => 'Blad polaczenia: ' . $curlError,
];
}
$decoded = json_decode(ltrim($body, "\xEF\xBB\xBF \t\n\r\0\x0B"), true);
if (!is_array($decoded)) {
return [
'ok' => false,
'http_code' => $httpCode,
'message' => 'Niepoprawna odpowiedz JSON polkurier: ' . substr(trim(strip_tags($body)), 0, 180),
];
}
$status = strtolower(trim((string) ($decoded['status'] ?? '')));
$responseField = $decoded['response'] ?? null;
if ($httpCode >= 200 && $httpCode < 300 && $status === 'success') {
$authorization = '';
if (is_array($responseField)) {
$authorization = trim((string) ($responseField['authorization'] ?? ''));
}
$message = $authorization !== ''
? 'Autoryzacja: ' . $authorization
: 'Polaczenie OK (HTTP ' . $httpCode . ').';
return [
'ok' => true,
'http_code' => $httpCode,
'message' => $message,
];
}
// Error path: w SDK polkuriera (PolkurierWebService.php) gdy status != 'success',
// tresc bledu jest rzucana z $response->get('response') — czyli pole 'response' z envelope JSON.
// Pole 'response' moze byc stringiem (tekst bledu), tablica (struktura) lub null.
$errorMessage = '';
if (is_string($responseField)) {
$errorMessage = trim($responseField);
} elseif (is_array($responseField)) {
$errorMessage = trim((string) (
$responseField['error_message']
?? $responseField['errorMessage']
?? $responseField['message']
?? $responseField['error']
?? ''
));
if ($errorMessage === '') {
$jsonDump = json_encode($responseField, JSON_UNESCAPED_UNICODE);
if (is_string($jsonDump)) {
$errorMessage = substr($jsonDump, 0, 240);
}
}
}
if ($errorMessage === '') {
$errorMessage = trim((string) (
$decoded['error_message']
?? $decoded['errorMessage']
?? $decoded['message']
?? $decoded['error']
?? ''
));
}
if ($errorMessage === '') {
$errorMessage = $status !== '' ? 'Status: ' . $status . ' (HTTP ' . $httpCode . ')' : 'HTTP ' . $httpCode;
}
return [
'ok' => false,
'http_code' => $httpCode,
'message' => $errorMessage,
];
}
public function createShipment(): never
{
throw new RuntimeException('PolkurierApiClient::createShipment not implemented in Phase 127.');
}
public function getLabel(): never
{
throw new RuntimeException('PolkurierApiClient::getLabel not implemented in Phase 127.');
}
public function getStatus(): never
{
throw new RuntimeException('PolkurierApiClient::getStatus not implemented in Phase 127.');
}
public function cancelOrder(): never
{
throw new RuntimeException('PolkurierApiClient::cancelOrder not implemented in Phase 127.');
}
/**
* @param array<string, mixed> $payload
* @return array{0: string, 1: int, 2: ?string}
*/
private function postJson(array $payload): array
{
$ch = curl_init(self::API_URL);
if ($ch === false) {
return ['', 0, 'Nie udalo sie zainicjowac cURL.'];
}
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
return ['', 0, 'Nie udalo sie zakodowac payloadu JSON.'];
}
$options = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $encoded,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Content-Type: application/json',
'User-Agent: orderPRO/1.0',
],
];
$caPath = SslCertificateResolver::resolve();
if ($caPath !== null) {
$options[CURLOPT_CAINFO] = $caPath;
}
curl_setopt_array($ch, $options);
$rawBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
unset($ch);
if ($rawBody === false) {
return ['', $httpCode, $curlError !== '' ? $curlError : 'Brak odpowiedzi z API.'];
}
return [(string) $rawBody, $httpCode, null];
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Http\RedirectPathResolver;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use Throwable;
final class PolkurierIntegrationController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly PolkurierIntegrationRepository $repository,
private readonly PolkurierApiClient $apiClient,
private readonly IntegrationsRepository $integrations
) {
}
public function index(Request $request): Response
{
$html = $this->template->render('settings/polkurier', [
'title' => $this->translator->get('settings.polkurier.title'),
'activeMenu' => 'settings',
'activeSettings' => 'integrations',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'settings' => $this->repository->getSettings(),
'errorMessage' => (string) Flash::get('settings_error', ''),
'successMessage' => (string) Flash::get('settings_success', ''),
'testMessage' => (string) Flash::get('polkurier_test', ''),
], 'layouts/app');
return Response::html($html);
}
public function save(Request $request): Response
{
$redirectTo = $this->resolveRedirect($request);
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
try {
$this->repository->saveSettings([
'login' => (string) $request->input('login', ''),
'api_token' => (string) $request->input('api_token', ''),
'default_label_format' => (string) $request->input('default_label_format', 'PDF'),
'is_active' => $request->input('is_active', ''),
]);
Flash::set('settings_success', $this->translator->get('settings.polkurier.flash.saved'));
} catch (Throwable $exception) {
Flash::set(
'settings_error',
$this->translator->get('settings.polkurier.flash.save_failed') . ' ' . $exception->getMessage()
);
}
return Response::redirect($redirectTo);
}
public function test(Request $request): Response
{
$redirectTo = $this->resolveRedirect($request);
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect($redirectTo);
}
try {
$credentials = $this->repository->getCredentials();
if ($credentials === null) {
throw new IntegrationConfigException('Najpierw zapisz kompletna konfiguracje polkurier (login + Token API + aktywacja).');
}
$result = $this->apiClient->testConnection(
$credentials['login'],
$credentials['api_token']
);
$status = $result['ok'] ? 'ok' : 'fail';
$this->integrations->updateTestResult(
$credentials['integration_id'],
$status,
(int) $result['http_code'],
(string) $result['message']
);
if ($result['ok']) {
Flash::set('polkurier_test', $this->translator->get('settings.polkurier.flash.test_success', [
'message' => (string) $result['message'],
]));
} else {
Flash::set('settings_error', $this->translator->get('settings.polkurier.flash.test_failed') . ' ' . $result['message']);
}
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.polkurier.flash.test_failed') . ' ' . $exception->getMessage());
}
return Response::redirect($redirectTo);
}
private function resolveRedirect(Request $request): string
{
return RedirectPathResolver::resolve(
(string) $request->input('return_to', '/settings/integrations/polkurier'),
['/settings/integrations'],
'/settings/integrations/polkurier'
);
}
}

View File

@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Support\StringHelper;
use PDO;
use Throwable;
final class PolkurierIntegrationRepository
{
private const INTEGRATION_TYPE = 'polkurier';
private const INTEGRATION_NAME = 'polkurier';
private const INTEGRATION_BASE_URL = 'https://api.polkurier.pl/';
private readonly IntegrationsRepository $integrations;
private readonly IntegrationSecretCipher $cipher;
public function __construct(
private readonly PDO $pdo,
private readonly string $secret
) {
$this->integrations = new IntegrationsRepository($this->pdo);
$this->cipher = new IntegrationSecretCipher($this->secret);
}
/**
* @return array<string, mixed>
*/
public function getSettings(): array
{
$this->ensureRow();
$integrationId = $this->ensureBaseIntegration();
$row = $this->fetchRow();
$integration = $this->integrations->findById($integrationId);
$tokenEncrypted = $this->resolveTokenEncrypted($row, $integration);
return [
'integration_id' => $integrationId,
'login' => trim((string) ($row['login'] ?? '')),
'default_label_format' => trim((string) ($row['default_label_format'] ?? 'PDF')) ?: 'PDF',
'has_api_token' => $tokenEncrypted !== null && $tokenEncrypted !== '',
'is_active' => (int) ($integration['is_active'] ?? 1) === 1,
'last_test_status' => trim((string) ($integration['last_test_status'] ?? '')),
'last_test_http_code' => isset($integration['last_test_http_code']) ? (int) $integration['last_test_http_code'] : null,
'last_test_message' => trim((string) ($integration['last_test_message'] ?? '')),
'last_test_at' => trim((string) ($integration['last_test_at'] ?? '')),
'updated_at' => trim((string) ($row['updated_at'] ?? '')),
];
}
/**
* @param array<string, mixed> $payload
*/
public function saveSettings(array $payload): void
{
$this->ensureRow();
$integrationId = $this->ensureBaseIntegration();
$row = $this->fetchRow();
if ($row === null) {
throw new IntegrationConfigException('Brak rekordu konfiguracji polkurier.');
}
$login = trim((string) ($payload['login'] ?? ''));
if ($login === '' || strlen($login) > 190) {
throw new IntegrationConfigException('Podaj login polkurier (e-mail lub identyfikator z panelu klienta, maks. 190 znakow).');
}
$labelFormatRaw = strtoupper(trim((string) ($payload['default_label_format'] ?? 'PDF')));
$allowedFormats = ['PDF', 'ZPL', 'EPL'];
$labelFormat = in_array($labelFormatRaw, $allowedFormats, true) ? $labelFormatRaw : 'PDF';
$currentEncrypted = $this->resolveTokenEncrypted($row, $this->integrations->findById($integrationId));
$token = trim((string) ($payload['api_token'] ?? ''));
$nextEncrypted = $currentEncrypted;
if ($token !== '') {
$nextEncrypted = $this->cipher->encrypt($token);
}
if ($nextEncrypted === null || $nextEncrypted === '') {
throw new IntegrationConfigException('Podaj Token API polkurier (z Panel Klienta -> Ustawienia -> Token API).');
}
$statement = $this->pdo->prepare(
'UPDATE polkurier_integration_settings
SET login = :login,
api_token_encrypted = :api_token_encrypted,
default_label_format = :default_label_format,
updated_at = NOW()
WHERE id = 1'
);
$statement->execute([
'login' => $login,
'api_token_encrypted' => $nextEncrypted,
'default_label_format' => $labelFormat,
]);
$this->updateIntegrationActive($integrationId, !empty($payload['is_active']));
$this->integrations->updateApiKeyEncrypted($integrationId, $nextEncrypted);
}
/**
* @return array{integration_id: int, login: string, api_token: string, default_label_format: string}|null
*/
public function getCredentials(): ?array
{
$this->ensureRow();
$integrationId = $this->ensureBaseIntegration();
$row = $this->fetchRow();
if ($row === null) {
return null;
}
$integration = $this->integrations->findById($integrationId);
if (empty($integration) || (int) ($integration['is_active'] ?? 0) !== 1) {
return null;
}
$login = trim((string) ($row['login'] ?? ''));
$encrypted = $this->resolveTokenEncrypted($row, $integration);
if ($login === '' || $encrypted === null || $encrypted === '') {
return null;
}
$token = trim((string) $this->cipher->decrypt($encrypted));
if ($token === '') {
return null;
}
return [
'integration_id' => $integrationId,
'login' => $login,
'api_token' => $token,
'default_label_format' => trim((string) ($row['default_label_format'] ?? 'PDF')) ?: 'PDF',
];
}
public function getIntegrationId(): ?int
{
$integration = $this->integrations->findByTypeAndName(self::INTEGRATION_TYPE, self::INTEGRATION_NAME);
if ($integration === null) {
return null;
}
$id = (int) ($integration['id'] ?? 0);
return $id > 0 ? $id : null;
}
private function ensureBaseIntegration(): int
{
return $this->integrations->ensureIntegration(
self::INTEGRATION_TYPE,
self::INTEGRATION_NAME,
self::INTEGRATION_BASE_URL,
15,
true
);
}
private function ensureRow(): void
{
$integrationId = $this->ensureBaseIntegration();
$statement = $this->pdo->prepare(
'INSERT INTO polkurier_integration_settings (id, integration_id, created_at, updated_at)
VALUES (1, :integration_id, NOW(), NOW())
ON DUPLICATE KEY UPDATE integration_id = VALUES(integration_id), updated_at = VALUES(updated_at)'
);
$statement->execute(['integration_id' => $integrationId]);
}
/**
* @return array<string, mixed>|null
*/
private function fetchRow(): ?array
{
try {
$statement = $this->pdo->prepare('SELECT * FROM polkurier_integration_settings WHERE id = 1 LIMIT 1');
$statement->execute();
$row = $statement->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return null;
}
return is_array($row) ? $row : null;
}
/**
* @param array<string, mixed>|null $row
* @param array<string, mixed>|null $integration
*/
private function resolveTokenEncrypted(?array $row, ?array $integration): ?string
{
$settingsValue = trim((string) ($row['api_token_encrypted'] ?? ''));
if ($settingsValue !== '') {
return $settingsValue;
}
$baseValue = trim((string) ($integration['api_key_encrypted'] ?? ''));
return StringHelper::nullableString($baseValue);
}
private function updateIntegrationActive(int $integrationId, bool $isActive): void
{
$statement = $this->pdo->prepare(
'UPDATE integrations
SET is_active = :is_active,
updated_at = NOW()
WHERE id = :id AND type = :type'
);
$statement->execute([
'id' => $integrationId,
'type' => self::INTEGRATION_TYPE,
'is_active' => $isActive ? 1 : 0,
]);
}
}