Files
orderPRO/.paul/codebase/tech_changelog.md
Jacek Pyziak 27df08e661 feat(130): polkurier delivery status mappings UI
Phase 130 complete (1 plan):
- POLKURIER_MAP + POLKURIER_DESCRIPTIONS w DeliveryStatus.php (7 wpisow O/P/A/WP/D/Z/W z dokumentacji v1.11)
- 'polkurier' w PROVIDERS w DeliveryStatusesController + DeliveryStatusMappingController
- countAllUnmappedForBadge() zlicza polkurier
- Defaulty hardcoded w kodzie (spojnie z InPost/Apaczka/Allegro); migracja Phase 128 staje sie no-op
- Zero zmian w widoku (_delivery-status-mappings-content.php auto-iteruje po providerach)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:17:48 +02:00

523 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Technical Changelog
## 2026-05-14 - Phase 130 Plan 01: polkurier delivery status mappings UI
**Co zrobiono:**
- `src/Modules/Shipments/DeliveryStatus.php` — nowe stałe `POLKURIER_MAP` i `POLKURIER_DESCRIPTIONS` z 7 oficjalnymi kodami ORDER_STATUS z dokumentacji polkurier API v1.11 (`O``created`, `P``confirmed`, `A``cancelled`, `WP``in_transit`, `D``delivered`, `Z``returned`, `W``problem`). Wartości identyczne z migracją Phase 128 (`20260514_000115_seed_polkurier_delivery_status_mappings.sql`).
- `src/Modules/Shipments/DeliveryStatus.php` — rejestracja `'polkurier' => self::POLKURIER_MAP` w `PROVIDER_MAPS` (po `'allegro_edge'`), analogicznie w `PROVIDER_DESCRIPTIONS`, oraz w match expressions `normalize()`/`description()`. `getDefaultMappings('polkurier')` zwraca 7 wpisów.
- `src/Modules/Settings/DeliveryStatusesController.php` + `DeliveryStatusMappingController.php` — stałe `PROVIDERS` rozszerzone z 3 do 4 wpisów: `'polkurier' => 'polkurier'` (lowercase, spójne z Phase 127).
- `src/Modules/Shipments/DeliveryStatusMappingRepository.php``countAllUnmappedForBadge()`: lista providerów rozszerzona z `['inpost', 'apaczka', 'allegro_wza']` do `['inpost', 'apaczka', 'allegro_wza', 'polkurier']`. Badge "niezmapowane statusy" w menu Ustawień reaguje teraz na nieznane raw statusy polkuriera.
- View `_delivery-status-mappings-content.php` automatycznie iteruje po `$providersList` z controllera — żadnych zmian w widoku nie trzeba.
**Dlaczego:**
- Phase 128 zaseed-owała DB override (`delivery_status_mappings` 7 wpisów) ale UI mapowania pozostał hardcoded na 3 providerów. Operator nie miał jak zmapować/podejrzeć statusów polkuriera w panelu.
- Defaultowe mapowania hardcoded w kodzie (nie tylko z DB) — spójność z InPost/Apaczka/Allegro (wszyscy mają hardcoded fallback). UI działa od razu, niezależnie czy operator uruchomił migrację Phase 128.
- Pattern `provider addition`: 5 punktów edycji w 4 plikach (1 const definition + 2 PROVIDER_* + 2 match arms + 2× PROVIDERS controller + 1 badge providers list) — checklist do reuse dla następnych przewoźników.
**Side-effects:**
- Migracja `20260514_000115_seed_polkurier_delivery_status_mappings.sql` (Phase 128) staje się no-op po wdrożeniu Phase 130 — DB override == hardcoded default → render `is_custom=true` ale ta sama wartość. Migracja może być uruchomiona lub nie.
**Files modified:**
- `src/Modules/Shipments/DeliveryStatus.php`
- `src/Modules/Settings/DeliveryStatusesController.php`
- `src/Modules/Settings/DeliveryStatusMappingController.php`
- `src/Modules/Shipments/DeliveryStatusMappingRepository.php`
- `.paul/codebase/tech_changelog.md` (this entry)
---
## 2026-05-14 - Phase 129 Plan 01: Order User Notes module
**Co zrobiono:**
- Migracja `database/migrations/20260514_000116_extend_order_notes_user_authored.sql``order_notes` rozszerzona o `user_id INT UNSIGNED NULL` (FK → `users(id)` ON DELETE SET NULL), `author_name VARCHAR(190) NULL`, oraz indeks `idx_order_notes_type_order (note_type, order_id)`. Wszystkie ADD owinięte w `INFORMATION_SCHEMA` guard z DDL no-op fallback (`ALTER TABLE COMMENT`) — pattern Phase 115/125.
- `src/Modules/Orders/OrderNotesService.php` — nowy serwis CRUD nad `order_notes` z `note_type='user'`. Metody: `listUserNotes`, `listImportedNotes`, `countUserNotes`, `findById`, `create`, `update`, `delete`. Autoryzacja przez `WHERE user_id = :user_id` w UPDATE/DELETE — rowCount=0 ⇒ rzut `RuntimeException(code=403)`. Walidacja `body`: trim, niepuste, ≤ 2000 znakow.
- `src/Modules/Orders/OrdersRepository.php` — dodany `userNotesCountSubquerySql($orderAlias)` (subquery `COUNT(*) FROM order_notes WHERE note_type='user'`) używany w `paginateSql()` jako kolumna `user_notes_count`. `loadOrderNotes()` zawężony do `note_type <> 'user'` (importowane ze źródła). `transformOrderRow()` ekspozuje `user_notes_count`.
- `src/Modules/Orders/OrdersController.php` — nowa opcjonalna zależność `OrderNotesService` w konstruktorze (na końcu, nullable, BC-safe). 3 metody: `storeNote`, `updateNote`, `deleteNote` (każda CSRF + sesja + try/catch `RuntimeException`/`InvalidArgumentException`; rejestruje `order_activity_log event_type='note'` przez `OrdersRepository::recordActivity`). `toTableRow()` renderuje `<a class="order-notes-badge" href="/orders/{id}#notes">[N]</a>` obok numeru zamówienia gdy `user_notes_count > 0`. `show()` pobiera `userNotes` + `currentUserId` i przekazuje do widoku.
- `routes/web.php` — 3 nowe route'y `POST /orders/{id}/notes`, `POST /orders/{id}/notes/{noteId}/update`, `POST /orders/{id}/notes/{noteId}/delete`. `OrderNotesService` instancjonowany przed `new OrdersController(...)` i przekazany ostatnim argumentem.
- `resources/views/orders/show.php` — sekcja "Wiadomości i załączniki" przebudowana na 3 bloki: (1) `<div class="order-user-notes" id="notes">` z listą notatek (data · autor) + akcjami edit/delete dla autora + inline formularz dodawania, (2) ukryty `order-note-edit-form` per notatka rozwijany przez JS, (3) opcjonalny blok "Wiadomości ze źródła" gdy `$notesList !== []` (importowane, bez akcji).
- `resources/lang/pl.php` — 10 nowych kluczy `orders.details.notes_user_*` / `notes_imported_title` (UI labels w PL).
- `resources/scss/modules/_order-notes.scss` (nowy) + `@use` w `app.scss``.order-notes-badge` (niebieskoszary `#eef2ff/#4338ca`), `.order-user-notes`, `.order-event--user` (lewa krawędź `#6366f1`), `.order-imported-notes` (opacity 0.75), `.btn-link`, `.order-note-form`, `.order-note-edit-form`. CSS przebudowany via `npm run build:css`.
- `public/assets/js/modules/order-notes.js` (nowy) + `<script>` w `layouts/app.php` — wanilijowy JS: klik "Edytuj" toggle'uje `js-order-note-body``js-order-note-edit-form`, klik "Anuluj" wraca, submit formularza DELETE przechwycony i potwierdzany przez `OrderProAlerts.confirm({title, message, danger:true, onConfirm})` (options-object API z decyzji Phase 114). Idempotent guard `window.__orderNotesInit` + `dataset.bound`.
- `.paul/codebase/db_schema.md` — sekcja `order_notes` rozszerzona o pełne kolumny + notatkę Phase 129-01 (note_type='user' vs imported).
**Dlaczego:**
- Operator potrzebował miejsca na własne adnotacje per zamówienie (uzgodnienia z klientem, ustalenia wewnętrzne) niezależne od notatek importowanych z shopPRO/Allegro. Bez badge'a na liście trzeba by wchodzić w każde zamówienie żeby sprawdzić czy ma notatki.
- Reuse istniejącej tabeli `order_notes` (Plan clarification #1) zamiast nowej tabeli — mniej obiektów DB, jeden punkt zarządzania, semantyka rozróżniona przez `note_type`. UNIQUE `(order_id, source_note_id)` nadal działa bo MySQL traktuje wiele NULL jako unique.
- Brak admin override (Plan clarification #3 z dopiskiem): aplikacja nie ma systemu ról (`grep -rn "is_admin|role=" src/Modules/Auth` zwrócił 0 trafień). Autoryzacja przez `note.user_id = session.user_id` — operator który dodał notatkę edytuje/usuwa, inni widzą ale nie modyfikują. Pełen admin-override odłożony do osobnej fazy po wprowadzeniu ról.
- Indeks `idx_order_notes_type_order (note_type, order_id)` zapewnia że subquery `user_notes_count` w paginacji `/orders/list` nie degraduje performance przy rosnącej liczbie notatek (Phase 106 pattern dla `customer_returned_count`).
**BREAKING:** brak — wszystkie zmiany BC. `loadOrderNotes()` teraz zwraca tylko `note_type <> 'user'`, ale nikt poza `findDetails()` jej nie używa, a sekcja widoku zachowuje wstecznie kompatybilne `$notesList` z importowanych notatek (osobny blok pod nową sekcją "Notatki").
---
## 2026-05-14 - Phase 128 Plan 01: polkurier ShipmentService + Tracking + UI prepare
**Co zrobiono:**
- `src/Modules/Settings/PolkurierApiClient.php` — pelen kontrakt API: `createShipment` (`apimetod=create_order`), `getLabel` (`get_label`), `getStatus` (`get_status`), `cancelOrder` (`cancel_order`), `getAvailableCarriers` (`available_carriers`), `getInpostParcelMachines` (`inpost_parcel_machines`), `getCourierPoints` (`get_courier_point`). Wspolny prywatny `call($apimetod, $data, $login, $token): mixed` parsuje envelope `{status, response}`; sukces -> zwraca `response`, blad -> rzuca `RuntimeException` z trescia z `response` (string albo zserializowany JSON dla tablic). Kontrakt zweryfikowany na oficjalnej dokumentacji PDF v1.11 (marzec 2026) — pobrana z `https://www.polkurier.pl/files/download/api_documentation_pdf`, zachowana w `.paul/phases/128-polkurier-shipment-service/polkurier-api-docs.txt`.
- `src/Modules/Shipments/PolkurierShipmentService.php``final class implements ShipmentProviderInterface` (`code()='polkurier'`). Pelen flow `createShipment($orderId, $formData)`: walidacja credentials/sender, `normalizeShipmentType()` mapuje `package_type` (PACKAGE/BOX/...) na zbior polkuriera `[box,envelope,palette,small_parcel,parcel_size_20]` (lowercase wymagane przez API — odkryte podczas live testu), `splitStreetAndNumber()` rozdziela ulice regexem na `street`/`housenumber`/`flatnumber`, `buildRecipient` z payload `order_addresses` + override z formularza, `buildPickup` z domyslnym `nextBusinessDay()` + 10:00-16:00, `COD` z bank account z `company_settings`. Po sukcesie API: `extractOrderNumber` (priorytet `number` z SDK Order entity), `extractTrackingNumber` (priorytet `waybills[0].number` z OrderWaybill entity), synchroniczna proba `downloadLabel`. Diagnostyka: gdy `orderno=''`, zapisuje fragment surowej odpowiedzi do `shipment_packages.error_message`.
- `src/Modules/Shipments/PolkurierTrackingService.php``final class implements ShipmentTrackingInterface`. `getDeliveryStatus($package)` woła `get_status`, parsuje `status_code`, mapuje przez `DeliveryStatus::normalizeWithOverrides('polkurier', ...)` z `delivery_status_mappings`. Graceful: null przy braku credentials/wyjatku API/braku `status_code`.
- `src/Modules/Shipments/DeliveryStatus.php` — fallback URL sledzenia dla `provider='polkurier'`: `https://polkurier.pl/sledz-paczke/<tracking>`. Carrier_id routing (DPD/UPS/GLS/InPost/Pocztex) dziala automatycznie przez istniejacy `matchCarrierByName()` (carrier_id ustawiany na servicecode z polkuriera, np. "INPOST" lub "DPD" — substring match).
- `routes/web.php``new PolkurierShipmentService(...)` w `ShipmentProviderRegistry`. Import `App\Modules\Settings\PolkurierApiClient` + `App\Modules\Shipments\PolkurierShipmentService`.
- `src/Modules/Cron/CronHandlerFactory.php``new PolkurierTrackingService(new PolkurierApiClient(), new PolkurierIntegrationRepository($this->db, $this->integrationSecret), new DeliveryStatusMappingRepository($this->db))` w `ShipmentTrackingRegistry` w handlerze `shipment_tracking_sync`.
- `src/Modules/Shipments/ShipmentController.php``prepare()` fetchuje `polkurierServices` przez registry i przekazuje do widoku. `create()` rozszerzony o pola `service_code`/`pickup_date`/`pickup_time_from`/`pickup_time_to` przekazywane do `createShipment()`.
- `resources/views/shipments/prepare.php` — opcja "polkurier" w `#shipment-carrier-select`, panel `#shipment-polkurier-panel` z `<select id="shipment-polkurier-select">` (lista uslug z `available_carriers`), hidden `name="service_code"`, JS toggle (`showPanel`, `syncPolkurierFields`). Brak dedykowanego selektora punktu odbioru — operator wpisuje `receiver_point_id` w istniejacym text inpucie w sekcji Adres odbiorcy (np. `POP-RZE54`).
- Nowa migracja `database/migrations/20260514_000115_seed_polkurier_delivery_status_mappings.sql` — 7 wpisow `provider='polkurier'`: `O``created`, `P``confirmed`, `A``cancelled`, `WP``in_transit`, `D``delivered`, `Z``returned`, `W``problem`. Idempotentne (`ON DUPLICATE KEY UPDATE`). Kody z oficjalnej tabeli ORDER_STATUS PDF v1.11.
- `.paul/codebase/architecture.md` + `db_schema.md` — opisy fazy 128.
**Dlaczego:**
- Phase 127 dostarczyl fundament (settings + test_auth_api). Bez ShipmentService polkurier byl tylko `wystawiony w hubie integracji ale niedzialajacy`. Operator chcial realnie nadawac paczki przez polkurier obok Apaczki — szczegolnie dla DPD/UPS/GLS/InPost gdzie polkurier oferuje lepsze ceny.
- Pelny zakres (ShipmentService + TrackingService + UI prepare + delivery_status_mappings) w jednej fazie zgodnie z decyzja operatora z planu (clarifications, `delegation: off`, `autonomous: false` z checkpointem live testu na #114/#115).
**Live test iteracje (zarejestrowane podczas APPLY):**
1. Pierwszy submit polkurier → "Blad tworzenia przesylki: Nie podano uslugi Apaczka." Przyczyna: `ReferenceError` na zmiennej `polkurierPointIdInput` (pozostalej po usunieciu duplikatu selektora punktu) w `clearHiddenFields()` → handler `carrierSelect.change` crashowal przed `showPanel()`, `provider_code` zostawal na PHP-renderowanej wartosci `apaczka`. Fix: usuniecie martwej referencji.
2. Drugi submit → "Blad tworzenia przesylki: polkurier create_order: Typ paczki musi przyjmowac jeden z parametrow ze zbioru [box, envelope, palette, small_parcel, parcel_size_20]". Przyczyna: wysylanie `BOX` uppercase. Fix: `normalizeShipmentType()` z lowercase + aliasami.
3. Trzeci submit → utworzona w polkurier, ale w orderPRO `status=pending` (brak orderno w parsing). Przyczyna: shape odpowiedzi `create_order` zwraca `Order` entity z polem `number` (nie `orderno`). Fix: `extractOrderNumber` z priorytetem `number` + fallback list + obsluga wrappera `{order:{...}}`. Etykieta poprawnie parsowana z pola `file` (response GetLabel.php).
4. Czwarty test → etykieta A4 zamiast A6. Iteracja w bogus parametry (`format`/`label_size`/`paper_size`) wyslane do `get_label` — bez efektu, bo API ignoruje. Pobranie oficjalnej dokumentacji PDF potwierdzilo: `get_label` przyjmuje WYLACZNIE `orderno`. Rozmiar A4 vs A6 sterowany jest w panelu klienta polkurier.pl (Ustawienia konta → Preferencje etykiet), NIE przez API. Operator zmienil w panelu — etykieta A6 OK.
**Deviation vs PLAN:**
- Plan deklarowal `delegation: off` — wykonane inline. Tasks 1-3 napisane przez orchestrator, checkpoint i Task 5/6 inline. Bez sub-agentow ze wzgledu na potrzebe szybkich iteracji po live test feedback (4 iteracje przed sukcesem) — spawn agentow zdublowalby koszt research z PDF.
- Plan deklarowal AJAX endpoint `/shipments/polkurier/points` + UI selector punktow paczkomatowych (Task 3 action). USUNIETY po feedback operatora — istnieje juz pole "Punkt odbioru" w sekcji Adres odbiorcy, operator wpisuje ID recznie (np. `POP-RZE54`). Usuniety: `PolkurierShipmentService::lookupPickupPoints()`, `ShipmentController::polkurierPoints()`, route, JS handler.
- AC-3 (TrackingService cron) dostarczony, ale niezweryfikowany na zywej bazie podczas APPLY (operator anulowal paczki w panelu polkurier po teście — cron tracking nie mial co pingowac). Dziala defensywnie (graceful null przy bledach), pierwszy realny passthrough nastapi przy nastepnej zywej paczce.
- Plan zakladal seedowanie mapowan po zaobserwowaniu realnych statusow w live tescie. Seed wykonany bazujac na oficjalnej tabeli ORDER_STATUS z PDF v1.11 (kody O/P/A/WP/D/Z/W) zamiast obserwacji — bezpieczniejsze i wyczerpujace.
- AC-1 wzmiankowal getAvailableCarriers/getParcelMachines/getPostOffices. `getInpostParcelMachines` i `getCourierPoints` zaimplementowane jako stuby na przyszle rozszerzenie UI, ale nie uzywane przez aktualny UI (operator wpisuje punkt recznie). `getPostOffices` POMINIETY — brak dedykowanej metody w SDK (jest tylko `inpost_parcel_machines` per courier, `pocztex_post_offices`, `kurier48_post_offices` — zlozenie tego w UI panel paczkomatow odlozone na kolejna faze).
**Follow-up:**
- Operator musi uruchomic migracje gdy XAMPP MySQL online: `php bin/migrate.php` (utworzy 7 wpisow `provider='polkurier'`).
- Po pierwszej zywej paczce w `in_transit` — weryfikacja crona `shipment_tracking_sync` (1x ping API polkuriera, zapis `delivery_status` + `delivery_status_raw` w `shipment_packages`).
- Kolejne fazy v3.7: paczkomaty UI panel (`InpostParcelMachines`/`PocztexPostOffices`/`Kurier48PostOffices`), presety przesylek z `provider_code='polkurier'`, `OrderValuationV2` (wycena przed nadaniem).
## 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:**
- Bugfix `/orders/{id}/invoice/create`: po kliknieciu "Pobierz z GUS" wartosci "Imie i nazwisko" i "Nazwa firmy" sprawialy wrazenie zamienionych dla JDG. Root cause: MF Biala Lista dla JDG zwraca w `subject.name` osobe fizyczna (np. "JACEK PYZIAK"), a JS bezwarunkowo wpisywal te wartosc do `#buyer_company_name` — pole "Imie i nazwisko" zostawalo z pre-fillem z `order_addresses.name`, ktory dla JDG czesto trzyma pelna nazwe firmy (np. "Project-Pro Pyziak Jacek").
- `MfWhitelistApiClient::lookupByNip()` — dodane do return array pola `krs: string` i `is_jdg: bool` (true gdy `subject.krs` jest puste). Pozostaly kontrakt (`name`, `tax_no`, address, regon, status_vat, raw) bez zmian.
- `InvoiceController::nipLookup` — propaguje `is_jdg` w JSON response `/api/nip/lookup` jako `data.is_jdg`.
- JS w `resources/views/accounting/invoice_form.php` — wybor pola docelowego dla `d.company_name` zalezy od `d.is_jdg`:
- `is_jdg=true` -> `#buyer_name` (Imie i nazwisko)
- `is_jdg=false` -> `#buyer_company_name` (Nazwa firmy)
Drugie pole nie jest tkniete — operator zachowuje pre-fill z zamowienia. Pola adresowe (street/postal_code/city) nadpisywane jak dotychczas.
**Dlaczego:**
- MF Biala Lista nie eksponuje "nazwy firmy" dla JDG (pole `name` to wlasciciel — osoba fizyczna). Pre-fill z `order_addresses.name` jest dla JDG bardziej wartosciowy (zawiera pelna nazwe firmy z zamowienia) niz MF `name`, wiec nie powinien byc nadpisany. Dla spolki (krs!=null) MF `name` to legal name — wlasciwe miejsce to "Nazwa firmy".
**Key decision:**
- Heurystyka JDG = `subject.krs === ''` (sygnal z MF). Pattern do reuse w innych miejscach jesli pojawia sie inny formularz oparty o NIP lookup.
**Files modified:**
- `src/Core/Http/MfWhitelistApiClient.php`
- `src/Modules/Accounting/InvoiceController.php`
- `resources/views/accounting/invoice_form.php`
**Pending verification:**
- AC-1: smoke `/orders/1090/invoice/create` (JDG, NIP 5170167517) -> "Imie i nazwisko"="JACEK PYZIAK", "Nazwa firmy"="Project-Pro Pyziak Jacek" niezmieniona.
- AC-2: smoke dla NIP spolki z aktywnym KRS -> "Nazwa firmy" otrzymuje legal name, "Imie i nazwisko" niezmienione.
- AC-3: `curl /api/nip/lookup?nip=5170167517` -> `data.is_jdg=true`.
---
## 2026-05-13 - Phase 125 Plan 01: invoice_requested Import Fix
**Co zrobiono:**
- Bugfix #1089: shopPRO order z `firm_nip` (bez kluczy z 5-elementowej listy `shouldRequestInvoice`) nie ustawial `invoice_requested=1` przy imporcie. Mapper wykrywal poprawnie (`is_invoice` przez heurystyke NIP), ale `ShopproOrdersSyncService::shouldRequestInvoice` mial wezsza liste kluczy -> UI w zakladce Platnosci wyswietlal odznaczony checkbox.
- `ShopproOrderMapper::mapOrderAggregate()` zwraca teraz top-level klucz `invoice_detected` (wynik `resolveInvoiceRequested($payload)`). Klucz `is_invoice` usuniety z tablicy `order` (nie odpowiada juz zadnej kolumnie DB).
- `ShopproOrdersSyncService::importOne()` propaguje `!empty($aggregate['invoice_detected'])` do `setInvoiceRequested(true)` zamiast wlasnej heurystyki. Stara metoda `shouldRequestInvoice` usunieta (zastapiona heurystyka mappera — zero duplikacji).
- `AllegroOrderImportService::shouldRequestInvoice($payload)` (nowa prywatna metoda) — rozszerza detekcje o `invoice.naturalPerson === false`, `invoice.address.taxId`, `invoice.companyName`/`invoice.address.company.name`. Wczesniej tylko `invoice.required` -> analogiczna luka jak shopPRO dla klientow Allegro z NIP bez `required=true`.
- Migracja `20260513_000113_drop_orders_is_invoice_and_backfill_invoice_requested.sql`:
- Idempotentny guard przez `information_schema.COLUMNS` + prepared statements (DDL no-op gdy kolumna juz nie istnieje, pattern z Key Decision 2026-05-10).
- Backfill: `UPDATE orders SET invoice_requested=1 WHERE is_invoice=1 AND invoice_requested=0` (7 zamowien na produkcji, w tym #1089).
- DROP COLUMN `orders.is_invoice` (legacy z Phase 115, dryft wzgledem `invoice_requested`).
- `OrderImportRepository::insertOrder()` SQL — usuniety `is_invoice` z kolumn INSERT i `:is_invoice` z VALUES. `orderParams()` — usunieta linia mapowania. Docstring `updateOrderDelta()` (Phase 112) — usunieta wzmianka.
- `OrdersRepository` — usuniety `o.is_invoice` z SELECT (`paginate` query) i `transformOrderRow()` hydrate (klucz `is_invoice` nie wystepuje juz w zwracanych row'ach).
**Dlaczego:**
- Bug #1089: zamowienie shopPRO z fakturowymi danymi firmowymi mialo `is_invoice=1` (mapper) ale `invoice_requested=0` (sync service). UI pokazywal odznaczony checkbox -> przycisk "Wystaw fakture" niedostepny -> operator musial recznie klikac toggle.
- Dryft: Phase 115 zostawila dwie kolumny dla tej samej semantyki (`is_invoice` legacy + `invoice_requested` nowy). Dwie sciezki detekcji (mapper vs. sync service) mialy rozna szerokosc heurystyki -> systematyczny rozjazd dla shopPRO orders z `firm_name`/`firm_nip`.
- Fix architekturalny: jedno zrodlo prawdy (`invoice_requested`), jedna heurystyka per zrodlo (mapper dla shopPRO, prywatna metoda dla Allegro).
**BREAKING:**
- Kolumna `orders.is_invoice` przestaje istniec po migracji `20260513_000113_*`. Wewnetrzny kontrakt — nie wystepuje w API ani odpowiedziach JSON. Jezeli ktokolwiek (skrypt operatora, raport custom) czytal `is_invoice` z DB -> przelaczyc na `invoice_requested`.
- `ShopproOrderMapper::mapOrderAggregate()` zwraca teraz dodatkowy top-level klucz `invoice_detected` (boolean). Klucz `is_invoice` znika z podtablicy `order` (nie odpowiada juz kolumnie DB).
## 2026-05-12 - Phase 124 Plan 01: SMS Templates
**Co zrobiono:**
- Nowa tabela `sms_templates(id, name, body, is_active, created_at, updated_at)` + indeks `(is_active, name)` — migracja `20260512_000112_create_sms_templates.sql` (DDL).
- `Sms\SmsTemplateRepository` z minimalnym CRUD (`listAll/listActive/findById/save/delete/toggleStatus`); walidacja name+body w `save()`.
- `Sms\SmsVariableResolver` (wydzielony z `Email\VariableResolver`) — wspolna logika `buildVariableMap` + `resolve` dla Email i SMS (placeholdery `{{zamowienie.*|kupujacy.*|adres.*|firma.*|przesylka.*}}`).
- `Email\VariableResolver` zrefaktorowany na fasade — konstruktor opcjonalnie przyjmuje SmsVariableResolver; metody publiczne deleguja. `EmailSendingService` bez zmian.
- `Settings\SmsTemplateController` + widoki `settings/sms-templates.php` (lista) + `settings/sms-templates-form.php` (CRUD form z paleta zmiennych po prawej, licznikiem znakow, walidacja maxlength 918).
- 7 nowych rout: `GET/POST /settings/sms-templates`, `/create`, `/edit`, `/save`, `/delete`, `/toggle`, `/variables`.
- Dropdown "Wybierz szablon" w zakladce SMS na `/orders/{id}` (renderowany tylko gdy istnieja aktywne szablony) -> JS module `sms-template-picker.js` fetchuje `GET /orders/{id}/sms/template?template_id=N` i wkleja rozwiniete body do textarea. Przy niepustej textarea pyta przez `OrderProAlerts.confirm({...})` (options-object API).
- `OrdersController::smsTemplate()` — nowy endpoint JSON; rozszerzony konstruktor o `?SmsTemplateRepository`, `?SmsVariableResolver`, `?CompanySettingsRepository` (default null = BC).
- Sidebar Ustawien rozszerzony o link "Szablony SMS" (active state na `currentSettings === 'sms-templates'`).
- Nowy SCSS partial `modules/_sms-templates.scss` (paleta zmiennych, licznik znakow). Import w `app.scss`.
- Tlumaczenia `orders.details.sms.template_picker(_placeholder)` w `resources/lang/pl.php`.
**Dlaczego:**
- Operator wysyla powtarzalne SMS-y (numer sledzenia, przypomnienie o platnosci, prosba o opinie). Szablony eliminuja recznie wpisywanie tekstu i tracking number, redukujac wysylke do dropdown + ewentualnej korekty.
- Wspolny VariableResolver bo dokladnie te same placeholdery sa potrzebne w Email i SMS (DRY); zachowanie kontraktu `Email\VariableResolver` jako fasady = zero ryzyka regresji w EmailSendingService.
- Stopka SMSPLANET pozostaje doklejana wylacznie przez `SmsConversationService::buildFinalOutboundBody()` (Phase 122) — nie duplikujemy jej w szablonach, walidacja 918 znakow obowiazuje na finalnej tresci.
**Migracja:**
- `php bin/migrate.php` po wlaczeniu MySQL — utworzy `sms_templates`.
- Operator po wdrozeniu tworzy szablony manualnie z `/settings/sms-templates`.
**BREAKING:** brak. `OrdersController` ctor: nowe params optional. `Email\VariableResolver` ctor: nowy opcjonalny drugi argument (default null = self-construct SmsVariableResolver).
## 2026-05-12 - Phase 123 Plan 01: Receipts Export VAT Breakdown
**Co zrobiono:**
- `ReceiptService::buildItemsSnapshot()` zapisuje `vat` (procent) per pozycja w `items_json` (z `order_items.tax_rate`, fallback 23.0). Pozycja "Koszt wysylki" dostaje `vat=23.0`.
- `AccountingController::export()`: nowe naglowki XLSX `Numer | Data wystawienia | Kwota brutto | Kwota netto | Stawka VAT | Kwota VAT` (usunieto `Data sprzedazy`, `Konfiguracja`, `Nr zamowienia`, `Nr referencyjny`).
- Eksport emituje osobny wiersz na kazda stawke VAT wystepujaca w paragonie (multi-rate breakdown z grupowania `items_json` po `vat`).
- Legacy paragony (snapshot bez `vat`) zwracaja jeden wiersz ze stawka 23%, `Kwota netto = total_net`, `Kwota VAT = total_gross - total_net`.
- Dodany prywatny helper `AccountingController::buildVatBreakdown()` + `formatVatRate()` (np. 23.0 -> "23%", 7.5 -> "7.5%").
**Dlaczego:**
- Ksiegowy potrzebuje arkusza z rozbiciem VAT per stawka do zaczytania do ksiegowosci. Stary eksport zawieral pola operacyjne (data sprzedazy, konfiguracja, ref) bez podstawowych pol VAT.
**Weryfikacja:**
- `php -l` na obu plikach OK; manualny eksport XLSX dla mieszanej listy paragonow po wdrozeniu.
## 2026-05-12 - SMSPLANET Inbound Webhook Fix
**Co zrobiono:**
- Poprawiono inbound SMSPLANET: dopasowanie po telefonie uzywa `order_addresses.phone`, a nie nieistniejacego w produkcji `orders.buyer_phone`.
- Dodano GET dla `/webhooks/smsplanet/inbound` obok POST, dekodowanie formatu 2WAY `message=<JSON>`, odpowiedz plain `OK` po sukcesie i odporniejsze scalanie JSON body z parametrami requestu.
**Dlaczego:**
- Publiczny POST webhooka zwracal 422 przez blad SQL `Unknown column 'o.buyer_phone'`, wiec odpowiedzi SMS nie byly zapisywane.
## 2026-05-12 - Phase 122 Plan 01: SMSPLANET Default SMS Footer
**Co zrobiono:**
- Dodano migracje `20260512_000111_smsplanet_default_footer.sql` z kolumna `smsplanet_integration_settings.default_footer`.
- Rozszerzono konfiguracje SMSPLANET o opcjonalna stopke SMS z limitem 300 znakow.
- Testowa wysylka oraz SMS z zamowienia dopinaja stopke przez pusta linie, waliduja finalna tresc w limicie 918 znakow i zapisuja finalne body w historii rozmowy.
**Dlaczego:**
- Operator ma utrzymywac jeden wspolny podpis firmy bez recznego kopiowania go do kazdej wiadomosci SMS.
**Weryfikacja:**
- Do uzupelnienia po APPLY.
## 2026-05-12 - Phase 121 Plan 01: SMSPLANET Conversation + Notifications
**Co zrobiono:**
- Dodano migracje `20260512_000110_smsplanet_conversation_notifications.sql` z tabelami `sms_messages`, `notifications` oraz polami `sender_mode` i `sender_phone`.
- Dodano backend `Sms` i `Notifications`, publiczny webhook SMSPLANET, zakladke SMS w zamowieniu, centrum powiadomien, topbar badge i polling JS.
- Usunieto tymczasowy override testowego nadawcy SMSPLANET; API uzywa wybranego trybu nadawcy.
- Poprawiono migracje po pierwszej probie na bazie: rzeczywiste `orders.id` ma typ `BIGINT UNSIGNED`, wiec `sms_messages.order_id` i `notifications.related_order_id` tez musza miec `BIGINT UNSIGNED`.
**Dlaczego:**
- Operator ma prowadzic dwukierunkowa rozmowe SMSPLANET w szczegolach zamowienia i widziec nowe odpowiedzi klientow globalnie.
**Weryfikacja:**
- `php -l` PASS dla nowych/zmienionych PHP.
- `npm run build:css` PASS.
- Migracja PASS przez techniczne polaczenie `DB_HOST_REMOTE`; manualne smoke testy UI/webhook nadal wymagaja sesji w aplikacji.
- `sonar-scanner` niedostepny w PATH.
## 2026-05-12 - Phase 120 Plan 01: Alert Component Unification
**Co zrobiono:**
- Dodano komponent `resources/views/components/alert.php` (params: `$type` info|success|warning|danger, `$message`/`$messageHtml`, `$dismissible`, `$role`) renderujacy `.alert` z inline SVG ikona, body i opcjonalnym przyciskiem dismiss.
- SCSS `_ui-components.scss`: `.alert` zmieniony na flex z `.alert__icon`/`.alert__body`/`.alert__dismiss`; dodany brakujacy wariant `.alert--info` (#eff6ff/#bfdbfe/#1e3a8a); dodany wrapper `.alerts-stack`.
- Vanilla JS `public/assets/js/modules/alert-dismiss.js` z idempotent guardem i delegated handlerem na `[data-alert-dismiss]`.
- `Flash` rozszerzony o `push(string $type, string $message): void` i `all(): array` z BC dla `set/get``all()` konsumuje typowana kolejke + skanuje legacy `_flash` z heurystyka klucza (error/danger/fail → danger, warning → warning, success/.save/.created/.deleted/.toggled → success, reszta → info).
- Layouty `app.php`, `auth.php`, `public.php` na poczatku content area iteruja `Flash::all()` przez komponent w `.alerts-stack` i ladja `alert-dismiss.js`.
- Migracja 36 widokow (34 z planu + `orders/show.php` + `shipments/prepare.php`): inline `<div class="alert alert--TYPE">` zastapione `include components/alert.php`; `.flash--error`/`.flash--success` w starych widokach takze przeniesione na komponent.
**Dlaczego:**
- Po teste polaczenia Fakturowni alert `OK (HTTP 200)` mial klase `alert--info`, ale `.alert--info` nie istnial — efekt: czarny tekst na bialym tle bez ikony. Zglosenie operatora po Phase 118.
- Wiele wzorcow alertow w 36 widokach roznilo sie szczegolami (mt-12/role/struktura), brak komponentu wielokrotnego uzytku. Po Phase 120 jeden plik (`components/alert.php`) jest jedynym renderem markupu alert + centralny renderer flash w 3 layoutach przyjmuje przyszle `Flash::push()` bez koniecznosci powtarzania kodu w widokach.
**Boundaries (zachowane):**
- Kontrolery NIE zmieniane — `Flash::set/get` BC; wzorce `Flash::set('module.key', '...')` + view local `$flashXxx` dzialaja jak dotychczas (widok renderuje przez komponent).
- Modul `resources/modules/jquery-alerts` (dialogi `OrderProAlerts`) niezmieniany — osobny system.
- `email-mailboxes.php` JS-generowane alerty AJAX testu SMTP — pozostawione bez zmian (uzywaja `.alert` SCSS, brak markupu komponentu — out of scope).
- `.flash--*` SCSS nie usuniety (deferred cleanup) — widoki juz go nie uzywaja.
- Build SCSS → CSS poza zakresem; `public/assets/css/app.css` musi byc zregenerowany lokalnie po wdrozeniu.
## 2026-05-12 - Phase 119 Plan 01: Re-import total_paid Protection
**Co zrobiono:**
- `OrderImportRepository::updateOrderDelta()` przebudowane na dynamiczny SQL builder: `total_paid` i `is_canceled_by_buyer` są dołączane do UPDATE warunkowo. Gdy `payment_status` w bazie == `payment_status` z payloadu źródła, `total_paid` NIE jest aktualizowany. `is_canceled_by_buyer` jest pomijany w tej samej sytuacji, chyba że źródło flaguje anulowanie (`$cancelledBySource=true`) — wtedy zawsze wpisywane.
- `upsertOrderAggregate()` wylicza `$paymentStatusUnchanged` przed wywołaniem delty i propaguje wraz z `$cancelledBySource`.
- Test PHPUnit `tests/Unit/OrderImportRepositoryTest.php` z 3 scenariuszami (preserve / transition / cancel propagation). Uruchomienie odroczone — `vendor/` nieobecne w środowisku, składnia PHP zweryfikowana przez `php -l`.
**Dlaczego:**
- Incydent zamówienia #976: operator usunął 2 pozycje Girlanda i zwrócił klientowi 28,00 PLN, obniżając `total_paid` ze 119,00 na 91,00. Audyt re-importu (Phase 112-01 delta-only) wykazał, że istniejące `updateOrderDelta` nadpisywało `total_paid` z payloadu źródła przy każdym wywołaniu, jeśli identical-payload guard nie zadziałał. Ręczne korekty kwoty były ulotne.
**Boundaries (zachowane):**
- `paymentTransition` (Phase 111) i `statusOverwriteAllowed` (Phase 62) działają bez zmian.
- Cancel propagation (`$cancelledBySource` override) z Phase 112-01 nadal wymusza `status_code='anulowane'` i — od Phase 119 — `is_canceled_by_buyer=1` nawet gdy `payment_status` stabilne.
- Identical-payload no-op guard (Phase 112-01) nadal pierwsza linia obrony — Phase 119 dotyczy tylko ścieżki gdy guard nie zadziałał (payload się różni, ale `payment_status` stabilne).
## 2026-05-12 - Phase 118 Plan 01: Fakturownia Single Instance
**Co zrobiono:**
- Dodano migracje `20260512_000109_fakturownia_single_instance.sql`, ktora wybiera aktywna instancje Fakturowni, przepina delegowane `invoice_configs.integration_id` na jeden globalny rekord i usuwa nadmiarowe konta Fakturowni po przepieciu zaleznosci.
- Przebudowano `FakturowniaIntegrationRepository` na jedna globalna konfiguracje (`getSettings`, `saveSettings`, `getIntegrationId`, `getCredentials`) z kompatybilnym `findAll()` zwracajacym jeden element.
- Uproszczono `FakturowniaIntegrationController` i widok `/settings/integrations/fakturownia` do pojedynczego formularza konfiguracji i testu polaczenia.
- Hub integracji pokazuje Fakturownie jako jedna instancje, bez licznika kont.
- Zapis delegowanej konfiguracji faktury ustawia `invoice_configs.integration_id` na globalny rekord Fakturowni; UI konfiguracji faktury nie pokazuje juz selecta kont.
**BREAKING / migracja:**
- Po migracji nie ma juz wielu kont Fakturowni. Jesli baza miala wiele rekordow `integrations.type='fakturownia'`, zachowany zostaje aktywny rekord (fallback: uzywany przez konfiguracje faktur, potem najnizsze id), a pozostale sa usuwane.
## 2026-05-12 - Phase 117 Plan 01: SMSPLANET Integration Settings + Test SMS
**Co zrobiono:**
- Dodano migracje `20260512_000108_create_smsplanet_integration_settings.sql` z pojedyncza konfiguracja `smsplanet_integration_settings` i bazowym wpisem `integrations` typu `smsplanet`.
- Dodano `SmsplanetIntegrationRepository` z obsluga metod autoryzacji `token` oraz `key_password` i szyfrowaniem sekretow przez `IntegrationSecretCipher`.
- Dodano `SmsplanetApiClient` dla SMSPLANET (`POST https://api2.smsplanet.pl/sms`) z obsluga Bearer token oraz `key` + `password`.
- Dodano `SmsplanetIntegrationController` i trasy `/settings/integrations/smsplanet`, `/save`, `/test`.
- Dodano widok `resources/views/settings/smsplanet.php` z konfiguracja i realna wysylka testowego SMS z edytowalna trescia oraz panelem ostatniego testu (`OK`, HTTP, `messageId`).
- Dodano SMSPLANET do hubu integracji `/settings/integrations`.
- Poprawiono import `IntegrationSecretCipher`, aby rzucal istniejacy `App\Core\Exceptions\IntegrationConfigException`.
**Dlaczego:**
- Operator potrzebuje drugiej bramki SMS analogicznej do HostedSMS, ale bez uruchamiania jeszcze automatyzacji lub historii wysylek.
- SMSPLANET wspiera dwa warianty autoryzacji, wiec konfiguracja przechowuje wszystkie sekrety w formie szyfrowanej i waliduje wymagania zalezne od wyboru operatora.
- Test uzywa rzeczywistej wysylki, bo celem tej fazy jest potwierdzenie realnej sciezki API.
## 2026-05-12 - Phase 116 Plan 01: HostedSMS Integration Settings + Test SMS
**Co zrobiono:**
- Dodano migracje `20260512_000107_create_hostedsms_integration_settings.sql` z pojedyncza konfiguracja `hostedsms_integration_settings` i bazowym wpisem `integrations` typu `hostedsms`.
- Dodano `HostedSmsIntegrationRepository` z szyfrowaniem hasla przez `IntegrationSecretCipher`.
- Dodano `HostedSmsApiClient` dla HostedSMS SimpleAPI (`POST https://api.hostedsms.pl/SimpleApi`).
- Dodano `HostedSmsIntegrationController` i trasy `/settings/integrations/hostedsms`, `/save`, `/test`.
- Dodano widok `resources/views/settings/hostedsms.php` z konfiguracja i realna wysylka testowego SMS z edytowalna trescia oraz czytelnym panelem ostatniego testu (`OK`, HTTP, MessageId).
- Dodano HostedSMS do hubu integracji `/settings/integrations`.
**Dlaczego:**
- Operator potrzebuje najpierw zapisac dane HostedSMS i sprawdzic realna wysylke SMS, zanim integracja zostanie wykorzystana w automatyzacjach lub komunikacji z klientami.
- Test uzywa rzeczywistej wysylki, bo SimpleAPI nie udostepnia osobnego endpointu ping/test.
- Haslo nie jest ujawniane po zapisie; UI pokazuje tylko status zapisanego sekretu.
## 2026-05-10 - Phase 115 Plan 01: Wystawianie faktury z zamowienia
**Co zrobiono:**
- **GUS lookup (Task 3b):** `FakturowniaApiClient::lookupClientByNip()` — GET `/clients/gus.json?nip=...&api_token=...` z walidacja NIP (10 cyfr) i parsowaniem `{name, tax_no, street, post_code, city, country}`. `InvoiceController::gusLookup()` jako AJAX endpoint `GET /api/fakturownia/gus-lookup?nip=...&config_id=...` — resolwer konta Fakturownia (preferuje `invoice_configs.integration_id` gdy delegated, fallback na pierwsze aktywne konto type='fakturownia'). `invoice_form.php` ma przycisk "Pobierz z GUS" obok pola NIP — vanilla JS fetch + auto-fill `buyer_company_name/street/postal_code/city`. Czyni "wystaw fakture" jednoklikowym po wpisaniu NIP. `InvoiceController` ctor rozszerzony o 2 deps (`FakturowniaIntegrationRepository`, `FakturowniaApiClient`).
- `InvoiceRepository` (nowy) — CRUD dla `invoices`: `findByOrderId`, `findById`, `insertLocal`, `insertDelegated`, `nextLocalNumber` (atomowy INSERT ON DUPLICATE KEY UPDATE na `invoice_number_counters`), `paginate` z filtrami search/config/mode/date.
- `InvoiceService` (nowy) — orchestrator wystawiania:
- `issue()` rozgalezia na `issueLocal()` (numer lokalny + Dompdf-ready snapshot) lub `issueDelegated()` (POST do Fakturowni PRZED INSERT lokalnym; na sukcesie zapis `external_invoice_id`/`external_pdf_url`/`invoice_number` z odpowiedzi).
- Static `extractBuyerTaxNumber()` parsuje NIP z roznych sciezek payload_json (Allegro `invoice.address.taxId`, shopPRO `buyer.tax_number` i pokrewne).
- Snapshot pattern (snapshoty seller/buyer/items z Phase 8 wzorca) + obliczanie netto/brutto z VAT per pozycja.
- `buildFakturowniaPayload()` mapuje na format `https://app.fakturownia.pl/api`.
- `InvoiceIssueException` (nowy) — typed exception dla bledow biznesowych.
- `FakturowniaApiClient` rozszerzony — nowe `createInvoice()` (POST `/invoices.json` z body `{api_token, invoice}`, parsuje response na `id/number/view_url/pdf_url/raw`) i `buildPdfUrl()` (helper bez fetcha, do redirect 302). Stary stub `downloadPdf()` zastapiony.
- `InvoiceController` (nowy) — endpointy dla zamowienia (`create`, `store`, `show`, `pdf`) i lista `issuedList`. PDF: lokalna -> Dompdf inline (mirror `ReceiptController::pdf`); delegowana -> redirect 302 na `external_pdf_url`. Walidacja `invoice_requested=1` przed otwarciem formularza.
- `OrdersController::toggleInvoiceRequested` (nowy) — AJAX endpoint POST `/orders/{id}/invoice-requested/toggle` z CSRF + `recordActivity('invoice_requested_changed')`.
- `OrdersRepository::setInvoiceRequested(int, bool)` (nowy) — UPDATE `orders.invoice_requested`.
- `OrdersController::show()` rozszerzone — przekazuje `invoices` + `invoiceConfigs` do widoku przez 2 nowe optional ctor params (`InvoiceRepository`, `InvoiceConfigRepository`).
- `AllegroOrderImportService::importSingleOrder` — przy `wasCreated=true` i `payload.invoice.required` -> ustawia `orders.invoice_requested=1` (delta-only re-import nie nadpisuje manualnej flagi).
- `ShopproOrdersSyncService::shouldRequestInvoice()` (nowy) — flexible parser sprawdzajacy `wants_invoice`, `invoice_required`, `invoice.required`, `buyer.wants_invoice`, `buyer.invoice` w raw payload. Dziala tylko przy pierwszym imporcie.
- 4 nowe widoki:
- `resources/views/accounting/invoice_form.php` — formularz wystawiania (config select z label "Lokalnie/Fakturownia: {prefix}", NIP prefilled z payload, manualne nadpisanie buyer fields, podglad pozycji + sprzedawca; confirm dialog dla powtornego wystawienia uzywa `OrderProAlerts.confirm` z options-object API).
- `resources/views/accounting/invoice_preview.php` — HTML preview faktury z badgem trybu (Lokalnie/Fakturownia: {prefix}) + dual-button PDF.
- `resources/views/accounting/invoice_pdf.php` — Dompdf template "FAKTURA VAT" z parties section, items table (netto/brutto/VAT per pozycja), termin platnosci, konto bankowe.
- `resources/views/accounting/invoices_issued_list.php` — lista wystawionych faktur z filtrami (search/config/mode/date_from/date_to) + paginacja + badge Tryb (Lokalnie / Fakturownia: {prefix}).
- `resources/views/orders/show.php` — header dostal `data-invoice-button-wrap` z przyciskiem "Wystaw fakture" (visible tylko gdy `invoice_requested=1`); pod headerem checkbox `data-invoice-requested-toggle` (auto-bound przez `invoice-requested-toggle.js`); pod tabem "Documents" nowa sekcja "Faktury" z badgem Tryb i akcjami Podglad/PDF.
- `resources/views/settings/accounting.php` — link "Faktury wystawione" w karcie Faktury.
- `public/assets/js/modules/invoice-requested-toggle.js` (nowy) — vanilla JS, AJAX POST z `_token` przy `change`, rollback checkbox state przy bledzie, optimistic show/hide `data-invoice-button-wrap`. Idempotent guard `data-bound`.
- `resources/views/layouts/app.php` — rejestracja `invoice-requested-toggle.js` z cache-busting.
- 6 nowych routes: `POST /orders/{id}/invoice-requested/toggle`, `GET /orders/{id}/invoice/create`, `POST /orders/{id}/invoice/store`, `GET /orders/{id}/invoice/{invoiceId}`, `GET /orders/{id}/invoice/{invoiceId}/pdf`, `GET /settings/accounting/invoices/issued`. Wszystkie pod `AuthMiddleware`.
- DI wiring w `routes/web.php`: `InvoiceRepository`, `InvoiceService`, `InvoiceController`. `OrdersController` instantiation rozszerzona o 2 params (`$invoiceRepository`, `$invoiceConfigRepository`).
- Docs: `architecture.md` (nowa sekcja "Phase 115"), `tech_changelog.md`, `todo.md` (INVOICE-IDEMP-115).
**Dlaczego:**
- Plan zatwierdzony jako pelny vertical slice — Phase 113 (DB) + 114 (configs CRUD) byly fundamentem; bez wystawiania z zamowienia funkcja byla bezuzyteczna operacjonalnie.
- Kolejnosc "POST do Fakturowni najpierw, INSERT lokalny po sukcesie" (per user decision) gwarantuje ze nie ma orphan rows w `invoices` gdy API padnie. Trade-off: brak idempotencji przy double-POST -> notatka INVOICE-IDEMP-115.
- Auto-set `invoice_requested` z importu pozwala operatorowi nie myslec o flagi dla typowych przypadkow (Allegro `invoice.required=true`, shopPRO klienci wpisujacy NIP). Manualny toggle dla edge cases / korekty.
- Dual PDF flow (Dompdf lokalny / redirect 302 do Fakturowni) — operator dostaje "natywny" PDF z Fakturowni dla delegowanych (ten sam co ksiegowy widzi w Fakturowni), brak ryzyka rozjazdu wygladu/numeracji.
- `is_delegated=0` config zachowany jako pelny tryb lokalny — dla scenariuszy gdzie operator nie chce/nie ma konta Fakturowni.
**BREAKING:** Brak. `OrdersController` ctor dostal 2 NEW optional params (default null) — backwards compatible.
**Szczegolne uwagi:**
- `invoice-config-form.js` (Phase 114) i `invoice-requested-toggle.js` (Phase 115) — dwa rozne moduly, oba w `layouts/app.php`. Nazwa "invoice-*" ale rozne kontekstowo (jeden formularz config, drugi zamowienie).
- `OrderProAlerts.confirm` w `invoice_form.php` uzywa options-object API zgodnie z memory (Phase 114 fix).
- Migracje no-op nie potrzebne — Phase 113 dostarczyla `orders.invoice_requested`, `invoice_*` tabele i `fakturownia_integration_settings`.
- Skill audit: brak required skills dla tego planu.
---
## 2026-05-10 - Phase 114 Plan 01: Accounting Configs Refactor
**Co zrobiono:**
- Migracja `20260511_000107_seed_default_invoice_config.sql` - idempotentny seed `Domyslny VAT` config (format `FV/%N/%M/%Y`, monthly, lokalna numeracja, payment_to_days=7) przez `NOT EXISTS` guard.
- `InvoiceConfigRepository` - pelne CRUD `invoice_configs` z walidacja serwerowa wszystkich pol + krytyczna regula delegacji: `is_delegated=1` wymaga `integration_id` z `integrations.type='fakturownia'`. `delete()` pre-checkuje `invoices` zeby zwrocic PL komunikat zamiast SQLSTATE.
- `InvoiceConfigController` - index/edit/save/toggle/delete dla `/settings/accounting/invoices`. Flash `accounting.invoices.save/.error`. Edycja na osobnej podstronie.
- `ReceiptConfigController` refactor - rozdzielenie `index()` na `hub()` + `list()` + nowa `edit()`. Save/toggle/delete redirectuja na `/settings/accounting/receipts` (nie hub).
- 4 nowe widoki:
- `accounting.php` (REFAKTOR) - hub-rozdroze z 2 kartami Paragony/Faktury (usunieta lista i formularz inline).
- `accounting-receipts.php` - lista paragonow w stylu spojnym z fakturami (`table.table + badge`).
- `accounting-receipt-edit.php` - formularz paragonu na osobnej podstronie.
- `accounting-invoices.php` - lista faktur (7 kolumn z dodatkami Tryb, Konto Fakturowni).
- `accounting-invoice-edit.php` - formularz faktury z conditional `integration_id` select.
- `invoice-config-form.js` - vanilla JS toggle dla `is_delegated` checkbox -> show/hide `integration_id` select wrapper + dynamiczny `required`.
- `layouts/app.php` - rejestracja nowego modulu JS `invoice-config-form.js` (z cache-busting `?ver=mtime`).
- Routy: 12 nowych endpointow ksiegowosci (6 receipts + 6 invoices) + 3 legacy aliasy starych `/settings/accounting/save|toggle|delete`.
- Docs: `db_schema.md` (notka o seed), `architecture.md` (nowa sekcja "Phase 114"), tech_changelog.
**Dlaczego:**
- Phase 113 dostarczyl tabele `invoice_configs` - bez UI/CRUD nie da sie operacjonalnie wystawiac faktur. Phase 115 (wystawianie faktury z zamowienia) wymaga gotowych invoice_configs.
- Edycja paragonu pod tabela na `/settings/accounting` (stary uklad) miala 2 problemy: dlugi scroll przy wielu configach + brak miejsca na rosnacy formularz faktury (z conditional fields). Refaktor na osobne podstrony rozwiazuje oba.
- Seed `Domyslny VAT` - operator od razu po deployment moze wystawiac faktury bez recznego skonfigurowania (zerowy onboarding).
- Legacy aliasy `/settings/accounting/save|toggle|delete` - istniejace formularze w cache'u przegladarki / bookmarki / zewnetrzne narzedzia (jesli sa) dalej dzialaja bez 404.
- JS toggle - operator nie zaznaczy delegacji bez wybrania konta (UX + serwerowa walidacja jako backup).
**BREAKING:**
- Brak. Stare endpointy zachowane jako aliasy. Stary widok `/settings/accounting` to teraz hub z linkami zamiast listy - operator wie ze trzeba kliknac "Zarzadzaj paragonami".
---
## 2026-05-10 - Phase 113 Plan 01: Fakturownia Integration Foundation
**Co zrobiono:**
- Migracje SQL:
- `20260510_000104_create_invoices_tables.sql` - cztery nowe tabele: `invoice_configs`, `invoices`, `invoice_number_counters`, `fakturownia_integration_settings` (multi-account, `integration_id UNIQUE FK` -> `integrations`).
- `20260510_000105_add_invoice_requested_to_orders.sql` - `orders.invoice_requested TINYINT(1) NOT NULL DEFAULT 0` + index `idx_orders_invoice_requested`.
- `20260510_000106_seed_fakturownia_integration_type.sql` - no-op placeholder dokumentujacy uznanie `integrations.type='fakturownia'` jako oficjalnie wspieranego.
- `FakturowniaIntegrationRepository` - CRUD kont Fakturowni z resolved encryption (`integrations.api_key_encrypted` jako zrodlo prawdy, `settings.api_token_encrypted` jako cache). `findAll/findByIntegrationId/save/delete/getDecryptedToken`.
- `FakturowniaApiClient::testConnection()` - GET `https://{prefix}.fakturownia.pl/account.json?api_token=...` z cURL + `SslCertificateResolver`. `createInvoice`/`downloadPdf` jako STUB-y rzucajace `RuntimeException` (do implementacji w kolejnym planie).
- `IntegrationsRepository::updateTestResult()` - nowa publiczna metoda do zapisu `last_test_status / last_test_http_code / last_test_message / last_test_at`. Wykorzystywana przez `FakturowniaIntegrationController::test()`.
- `FakturowniaIntegrationController` - lista (`/settings/integrations/fakturownia`), edycja (`/edit`, `/new`), save, test, delete. CSRF via `_token`, flash `fakturownia.save/.test/.error`.
- Widoki `resources/views/settings/fakturownia.php` (lista z badge'ami) i `resources/views/settings/fakturownia-edit.php` (form: name, account_prefix, api_token, department_id, default_kind, default_payment_to_days, is_active).
- `IntegrationsHubController::buildFakturowniaRow()` - karta Fakturowni w hubie `/settings/integrations` z agregowanym statusem wszystkich kont.
- Routy w `routes/web.php` (`get /index`, `get /new`, `get /edit`, `post /save`, `post /test`, `post /delete`) + DI wiring (`$fakturowniaIntegrationRepository`, `$fakturowniaApiClient`, `$fakturowniaIntegrationController`).
- Dokumentacja: `db_schema.md` (sekcja "Invoices" + `fakturownia_integration_settings` + kolumna `orders.invoice_requested`, total tables 55 -> 59), `architecture.md` (sekcja "Phase 113").
**Dlaczego:**
- v3.7 Invoices wprowadza wystawianie faktur dla klientow wymagajacych dokumentu z NIP (clarifications: `orders.invoice_requested` z importera + manual override). Bez fundamentu DB i konfiguracji konta Fakturowni zaden kolejny plan v3.7 (CRUD configs, wystawianie, lista) nie ma sensu.
- Multi-account przez `integrations.type='fakturownia'` zachowuje spojnosc z Allegro/shopPRO (rozne instancje) i pozwala na rozne konta Fakturowni dla roznych marek/oddzialow.
- `is_delegated` flag w `invoice_configs` umozliwia w przyszlym planie dwa tryby: lokalna numeracja+PDF dompdf (default) lub delegacja do Fakturowni (numer+PDF z API).
- STUB-y `createInvoice/downloadPdf` celowo rzucaja exception zamiast "TODO" - kazda przedwczesna probaba uzycia rzuci jasny blad zamiast cichego no-op.
- `IntegrationsRepository::updateTestResult()` jest reusable - przyszle integracje (np. kolejne API) beda mogly korzystac z tej samej metody zamiast inline UPDATE.
**BREAKING:**
- Brak zmian breaking. `IntegrationsHubController` ma nowy parametr konstruktora (`FakturowniaIntegrationRepository`) - wszystkie miejsca wywolania zaktualizowane.
---
## 2026-05-07 - Phase 112 Plan 01: Re-import Data Protection
**Co zrobiono:**
- `OrderImportRepository::upsertOrderAggregate` - rozdzielono sciezke `created` (pierwszy import) od `else` (re-import). `replaceAddresses`, `replaceItems`, `replaceNotes`, `replacePayments`, `replaceShipments`, `replaceStatusHistory` wywolywane sa teraz wylacznie przy pierwszym imporcie. Logika `paymentTransition` / `statusOverwriteAllowed` (Phase 111) i preservacja `status_code` (Phase 62) bez zmian.
- `OrderImportRepository::updateOrderDelta()` - nowa prywatna metoda zastepujaca `updateOrder()`. Aktualizuje wylacznie `status_code`, `payment_status`, `total_paid`, `is_canceled_by_buyer`, `source_updated_at`, `payload_json`, `fetched_at`, `updated_at`. Pozostale kolumny zamowienia nie sa nadpisywane przez re-import.
- Propagacja anulowania: gdy `is_canceled_by_buyer=1` ze zrodla LUB zmapowany pull `status_code='anulowane'` (Phase 75/83), wymuszone jest `orders.status_code='anulowane'` niezaleznie od `statusOverwriteAllowed`.
- Identical-payload guard: porownanie znormalizowanego `payload_json` (nowy vs aktualny w DB). Identyczny payload + brak `paymentTransition`/`statusOverwriteAllowed`/`cancelledBySource` -> commit transakcji bez wywolywania UPDATE; `fetched_at` i `updated_at` pozostaja niezmienione.
- `OrderImportRepository::getCurrentOrderState()` - rozszerzenie `getCurrentStatusAndPaymentStatus()` o kolumne `payload_json` (jeden SELECT zamiast dwoch).
- `OrderImportRepository::normalizePayloadJson()` - nowy helper deserializujacy/reserializujacy payload do porownywalnej formy (`JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES`).
**Dlaczego:**
- Bug case #882: po re-imporcie zamowienia produkty w `/orders/{id}` wyswietlaly badge "Brak projektu" mimo wczesniej wygenerowanych projektow PSD. Przyczyna: `replaceItems()` wykonywal DELETE+INSERT na `order_items` przy kazdym re-imporcie, co (a) zerowalo flagi `project_generated`/`project_generated_at` (Phase 97) na default 0/NULL i (b) zmienialo `order_items.id`, co lamie referencje skryptu batch `tools/generowanie/_batch_run.sh` (`UPDATE ... WHERE id IN (...)`).
- Phase 111 dodala emisje `payment.status_changed` przy re-imporcie z tranzycja platnosci, wiec re-import istniejacych zamowien stal sie regularny - problem #882 stal sie regresja systemowa, nie krawedziem.
- Zamowienia (orderPRO jako narzedzie zarzadzania) sa edytowane w aplikacji, nie w zrodle (Allegro/shopPRO). Re-import ze zrodla powinien aktualizowac wylacznie stan platnosci, anulowanie i znaczniki synchronizacji - reszta jest stanem lokalnym.
- Identical-payload guard eliminuje niepotrzebne write'y do binloga/replikacji oraz sztuczne odswiezanie `updated_at` przy cyklicznym imporcie tych samych zamowien.
**BREAKING:**
- Brak zmian breaking dla istniejacego API/UI/automatyzacji. Wewnetrznie usunieto `updateOrder()` i `getCurrentStatusAndPaymentStatus()` (oba private) - referencje zewnetrzne nie istnialy.
- Backfill zamowien z resetowanymi flagami `project_generated` (np. #882) wykonywany recznie przez operatora poza zakresem planu.
## 2026-05-05 - Phase 111 Plan 01: Payment Transition Event
**Co zrobiono:**
- `OrderImportRepository::upsertOrderAggregate` - rozszerzona detekcja `payment_transition`. Teraz porownuje poprzedni `payment_status` z nowym (warunek `0/1 -> 2`) zamiast polegac wylacznie na `status_code='nieoplacone'`. Logika preservacji status_code z Phase 62 (`statusOverwriteAllowed`) zostala wydzielona jako osobna decyzja.
- `OrderImportRepository::getCurrentStatusAndPaymentStatus()` - nowa metoda pomocnicza zastepujaca `getCurrentStatus()`, zwraca i status_code, i payment_status w jednym SELECT.
- `AllegroOrderImportService::importSingleOrder` - dodaje emit `payment.status_changed` gdy `payment_transition && !$wasCreated`.
- `ShopproOrdersSyncService::importOneOrder` - analogiczny emit `payment.status_changed`.
- `bin/backfill_payment_transition_111.php` - jednorazowy CLI dla zamowien z `payment_status=2 && status_code='nieoplacone'` (allegro + shoppro), idempotentny, wzorzec z Phase 98.
**Dlaczego:**
- Zamowienie #864 (Allegro) zaimportowane 10s po zlozeniu, gdy Allegro jeszcze nie potwierdzilo platnosci. Re-import 2 minuty pozniej zaktualizowal payment_status na 2, ale `order.imported` jest gated przez `$wasCreated` (Phase 98), wiec automatyzacja "Zmien status na w realizacji (allegro)" nigdy nie odpalila.
- Allegro nie mial odpowiednika `ShopproPaymentStatusSyncService`, wiec tranzycja platnosci znikala cicho. ShopPRO mial analogiczna luke w `ShopproOrdersSyncService` (flaga `payment_transition` byla wykrywana, ale nie emitowala eventu).
- Regula automatyzacji #7 (`payment.status_changed` -> `update_order_status` na `w_realizacji`) nie ma warunku integration_id, wiec po wyemitowaniu eventu obejmie zarowno Allegro jak i shopPRO.
- Idempotencja zalatwiona przez logike repo: po pierwszej tranzycji DB ma `payment_status=2`, kolejny re-import widzi old=2/new=2 i `payment_transition=false`. Brak duplikatow eventow.
## 2026-04-28 - Phase 110 Plan 01: Statistics Summary
**Co zrobiono:**
- `/statistics/summary` - nowy widok podsumowania w menu `Statystyki -> Podsumowanie`.
- `OrdersStatisticsController::summary()` - buduje miesieczny view-model dla wykresow liczby i wartosci zamowien.
- `OrdersStatisticsRepository::aggregateByMonth()` - agreguje istniejace zamowienia po miesiacu i kanale/integracji.
- `public/assets/js/modules/statistics-summary-charts.js` - renderer dwoch interaktywnych wykresow liniowych oparty o Chart.js 4.4.8 CDN.
- `resources/views/statistics/summary.php` - filtry zgodne z raportem dziennym, dwa wykresy obok siebie na desktopie oraz dwie tabele fallback pod nimi.
- Domyslny poczatek historii ustawiony na `2026-04-01` (`04-2026`) mimo starszych danych.
**Dlaczego:**
- Operator potrzebuje szybkiego trendu miesiecznego przed przejsciem do szczegolowych dziennych statystyk.
- Wykresy uzywaja obecnych tabel `orders`, `integrations`, `order_status_groups` i `order_statuses`, wiec migracja DB nie jest potrzebna.
- Seria `Razem` jest liczona z tych samych danych co serie integracji, co ulatwia sprawdzenie sum miesiecznych.
## 2026-04-28 - Phase 109 Plan 01: Checkbox Multiselect Filters
**Co zrobiono:**
- `public/assets/js/modules/checkbox-multiselect.js` - nowy vanilla JS enhancer dla natywnych `<select multiple data-checkbox-multiselect>`.
- `resources/views/layouts/app.php` - globalne podpiecie modulu z cache busting przez `filemtime()`.
- `resources/views/statistics/orders.php` - filtry `channels[]` i `status_groups[]` oznaczone do progresywnego ulepszenia bez zmiany nazw pol formularza.
- `resources/scss/app.scss` - kompaktowe style dropdownu z checkboxami i opcja "Wszystkie".
**Dlaczego:**
- Natywne selecty multiple byly malo czytelne i zajmowaly za duzo miejsca w filtrach statystyk.
- Zachowanie oryginalnego selecta w DOM utrzymuje obecny kontrakt GET i fallback bez JavaScript.
- Brak zmian w schemacie DB i logice agregacji statystyk.
> Chronologiczny log zmian technicznych — co i dlaczego.
## 2026-04-27 — Phase 108 Plan 02: Automation Dropdowns z DB
**Co zrobiono:**
- `AutomationController` — usunięto stałą `SHIPMENT_STATUS_OPTIONS` (8 grupowych kluczy)
- Dropdown statusów w warunku `shipment_status` i akcji `update_shipment_status` ładuje statusy z DB przez `DeliveryStatus::getAllOptions()`
- Walidacja w `parseConditionValue()` i `parseActionConfig()` używa `DeliveryStatus::getAllStatuses()`
- `AutomationService` — usunięto stałą `SHIPMENT_STATUS_OPTION_MAP`; ewaluacja `evaluateShipmentStatusCondition()` porównuje klucze bezpośrednio
- `resolveStatusFromActionKey()` — bezpośredni klucz statusu z DB jako target (zamiast pierwszego z grupy)
**Dlaczego:**
- Zamknięcie integracji z Plan 01 — operator dodaje status w `/settings/delivery-statuses` i jest on od razu dostępny w dropdownach automatyzacji bez deploymentu
- Eliminacja kolizji semantycznej: stary klucz grupowy `picked_up` mapował na `delivered` (paczka odebrana przez klienta), nowy klucz DB `picked_up` to "Odebrana przez kuriera" (od nadawcy)
- BREAKING: stare reguły z grupowymi kluczami (`registered`, `courier_pickup`, `dropped_at_point`, `unclaimed`, `picked_up_return`, oraz `picked_up`/`ready_for_pickup`/`cancelled` w starym znaczeniu) nie matchują — wymagają ręcznego odtworzenia z nowymi kluczami DB
## 2026-04-27 — Phase 108 Plan 01: Delivery Status Management
**Co zrobiono:**
- Tabela `delivery_statuses` z seedem 11 statusów (migracja `20260427_000103`)
- `DeliveryStatusRepository` — CRUD + per-request cache
- `DeliveryStatus.php` — dynamiczne ładowanie statusów z DB (`setRepository()`)
- Panel `/settings/delivery-statuses` z CRUD (zakładka "Statusy") i mapowaniem (zakładka "Mapowanie dostawy")
- Sidebar: "Statusy" → "Statusy zamówień", nowe "Statusy przesyłek" z badge niezmapowanych
- Badge przesyłek: inline CSS custom property `--status-color` dla niestandardowych statusów
**Dlaczego:**
- Dodanie nowego statusu wymagało zmiany kodu + deploymentu; teraz z UI
- Operator może definiować własne statusy znormalizowane bez ingerencji w kod