Files
orderPRO/.paul/codebase/tech_changelog.md
Jacek Pyziak c758ec7c92 feat(126): invoice GUS field mapping fix (JDG/KRS heuristic)
MfWhitelistApiClient.lookupByNip() exposes is_jdg/krs from MF Biala Lista.
InvoiceController.nipLookup propagates is_jdg in JSON response.
invoice_form.php JS conditionally targets buyer_name (JDG) or
buyer_company_name (spolka z KRS); second field keeps zamowienie pre-fill.

Fixes apparent field swap on /orders/{id}/invoice/create after GUS lookup
for JDG (sole trader) where MF returns natural person in subject.name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:29:55 +02:00

412 lines
42 KiB
Markdown

# Technical Changelog
## 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