From aeb6fb6df295eab9ce9f827c5364357186b19640 Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Wed, 20 May 2026 15:33:33 +0200 Subject: [PATCH] update --- .paul/STATE.md | 12 + .paul/changelog/2026-05-20.md | 14 + .paul/codebase/architecture.md | 2 +- .paul/codebase/quality_risks.md | 2 +- .paul/codebase/tech_changelog.md | 36 + .paul/codebase/tooling_status.md | 17 +- .../PLAN.md | 331 ++++++++ .../SUMMARY.md | 110 +++ .../Accounting/DelegatedInvoiceIssuer.php | 253 ++++++ .../FakturowniaInvoicePayloadBuilder.php | 84 ++ .../Accounting/InvoiceMetadataResolver.php | 84 ++ src/Modules/Accounting/InvoiceService.php | 730 ++---------------- .../Accounting/InvoiceSnapshotBuilder.php | 223 ++++++ src/Modules/Accounting/LocalInvoiceIssuer.php | 66 ++ 14 files changed, 1273 insertions(+), 691 deletions(-) create mode 100644 .paul/plans/20260520-1500-refactor-invoice-service/PLAN.md create mode 100644 .paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md create mode 100644 src/Modules/Accounting/DelegatedInvoiceIssuer.php create mode 100644 src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php create mode 100644 src/Modules/Accounting/InvoiceMetadataResolver.php create mode 100644 src/Modules/Accounting/InvoiceSnapshotBuilder.php create mode 100644 src/Modules/Accounting/LocalInvoiceIssuer.php diff --git a/.paul/STATE.md b/.paul/STATE.md index a571f87..d8a5347 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -4,6 +4,18 @@ **Ostatnia aktualizacja:** 2026-05-20 ## Aktywna praca +UNIFY zakonczony dla `.paul/plans/20260520-1500-refactor-invoice-service/`. Petla zamknieta. `InvoiceService.php` 762 -> 118 lin. (84% redukcji). Wydzielono 5 nowych klas w `src/Modules/Accounting/`: `InvoiceMetadataResolver` (84), `InvoiceSnapshotBuilder` (223), `FakturowniaInvoicePayloadBuilder` (84), `LocalInvoiceIssuer` (66, deviation — symetria do delegated), `DelegatedInvoiceIssuer` (253). Zero zmian publicznego kontraktu (konstruktor 6-arg, `issue(array)`, statyk `extractBuyerTaxNumber` jako forwarder). Konsumenci niezmienieni: `InvoiceController`, `AccountingModule`, `tests/Unit/FakturowniaInvoiceIdempotencyTest.php`. `php -l` zielony 7/7. Deviation: incydentalna naprawa pre-existing bugu — `LocalInvoiceIssuer::issue` ma poprawne 14 paramow vs oryginalny `issueLocal` 15 paramow w sygnaturze (sciezka local rzucala `ArgumentCountError`). SUMMARY: `.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md`. + +``` +PLAN ──▶ APPLY ──▶ UNIFY + ✓ ✓ ✓ [Loop complete — gotowe na nastepny PLAN] +``` + +⚠ **Do weryfikacji recznej (vendor/phpunit niedostepny w sesji):** +1. `vendor/bin/phpunit tests/Unit/FakturowniaInvoiceIdempotencyTest.php` — 3/3 scenariusze idempotencji musza przejsc bez modyfikacji testu (primary gate). +2. Smoke `POST /orders/{id}/invoice/create` dla konfiguracji delegowanej (Fakturownia) i lokalnej (sciezka local teraz wlasciwie dziala po naprawie sygnatury). + +## Poprzednia praca UNIFY zakonczony dla `.paul/plans/20260520-1128-storage-cleanup-cron/`. Petla zamknieta. Dodano cron handler `storage_cleanup` (`src/Modules/Cron/StorageCleanupHandler.php`, ~140 lin.) usuwajacy pliki >30 dni z `storage/{labels,sessions,tmp,logs,cache}`. `storage/data/` i `storage/logs/app.log` chronione. Migracja seed `20260520_000119_seed_storage_cleanup_cron.sql` (interval 86400s, payload `{"days":30}`, priority 80). Konfiguracja: `STORAGE_CLEANUP_DAYS=30` w `.env.example` (fallback). Offline smoke test 7/7 OK; reczny UAT DB-side wymagany. SUMMARY: `.paul/plans/20260520-1128-storage-cleanup-cron/SUMMARY.md`. ``` diff --git a/.paul/changelog/2026-05-20.md b/.paul/changelog/2026-05-20.md index 854ca30..19f7a93 100644 --- a/.paul/changelog/2026-05-20.md +++ b/.paul/changelog/2026-05-20.md @@ -4,6 +4,10 @@ - [Plan 20260520-1128-storage-cleanup-cron] Dodano cron handler `storage_cleanup` usuwajacy pliki >30 dni z `storage/{labels,sessions,tmp,logs,cache}`. `storage/data/` i `storage/logs/app.log` chronione. Migracja seed `cron_schedules` (interval 86400s, payload `{"days":30}`). Konfiguracja przez `STORAGE_CLEANUP_DAYS` env (fallback). Log wykonania do `storage/logs/app.log`. - Offline smoke test 7/7 OK (handler logic). Reczny UAT DB-side wymagany (DB niedostepna w sesji). +- [Plan 20260520-1500-refactor-invoice-service] Dekompozycja `src/Modules/Accounting/InvoiceService.php` z 762 do 118 lin. (84% redukcji). Wydzielono 5 nowych klas w `src/Modules/Accounting/`: `InvoiceMetadataResolver` (84), `InvoiceSnapshotBuilder` (223, ze statyka `extractBuyerTaxNumber`), `FakturowniaInvoicePayloadBuilder` (84, z komentarzami HTTP 422), `LocalInvoiceIssuer` (66), `DelegatedInvoiceIssuer` (253, idempotentna sciezka Fakturowni). +- Zero zmian publicznego kontraktu: konstruktor 6-arg, `issue(array): array`, statyk `InvoiceService::extractBuyerTaxNumber()` jako forwarder. `InvoiceController`, `AccountingModule`, `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` niezmienione. +- Incydentalna naprawa pre-existing bugu: oryginalny `issueLocal()` mial 15 paramow w sygnaturze vs 14 przekazywanych — sciezka local rzucala `ArgumentCountError`. `LocalInvoiceIssuer::issue` ma poprawne 14 paramow. +- `php -l` zielony dla 7 plikow. Reczny UAT (vendor/phpunit niedostepny): `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` + smoke `POST /orders/{id}/invoice/create`. ## Zmienione pliki @@ -12,9 +16,19 @@ - `database/migrations/20260520_000119_seed_storage_cleanup_cron.sql` - `.env.example` - `CLAUDE.md` +- `src/Modules/Accounting/InvoiceService.php` +- `src/Modules/Accounting/InvoiceMetadataResolver.php` +- `src/Modules/Accounting/InvoiceSnapshotBuilder.php` +- `src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php` +- `src/Modules/Accounting/LocalInvoiceIssuer.php` +- `src/Modules/Accounting/DelegatedInvoiceIssuer.php` - `.paul/codebase/architecture.md` - `.paul/codebase/db_schema.md` +- `.paul/codebase/quality_risks.md` - `.paul/codebase/tech_changelog.md` +- `.paul/codebase/tooling_status.md` - `.paul/plans/20260520-1128-storage-cleanup-cron/PLAN.md` - `.paul/plans/20260520-1128-storage-cleanup-cron/SUMMARY.md` +- `.paul/plans/20260520-1500-refactor-invoice-service/PLAN.md` +- `.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md` - `.paul/STATE.md` diff --git a/.paul/codebase/architecture.md b/.paul/codebase/architecture.md index 5331f2e..30a141a 100644 --- a/.paul/codebase/architecture.md +++ b/.paul/codebase/architecture.md @@ -66,7 +66,7 @@ Korzysci wzgledem poprzedniego monolitycznego `routes/web.php` (859 lin.): | Orders | `src/Modules/Orders/` | zamowienia, notatki, import, statusy | | Statistics | `src/Modules/Statistics/` | raporty zamowien (slim Controller + `OrdersStatisticsFilters` + `OrdersStatisticsTableBuilder` + `OrdersStatisticsSummaryBuilder` + Repository) | | Settings/Allegro | `src/Modules/Settings/Allegro*.php` | integracja Allegro: slim `AllegroIntegrationController` + `AllegroIntegrationViewModel` + `AllegroOAuthFlowService` + `AllegroImportScheduleService` + `AllegroImportImageWarningFormatter` + `AllegroSaveSettingsValidator` + `AllegroOrderImportService` + `AllegroStatusDiscoveryService` + `AllegroStatusMappingController` + `AllegroDeliveryMappingController` + repozytoria (`AllegroIntegrationRepository`, `AllegroStatusMappingRepository`, `AllegroPullStatusMappingRepository`) + klienci (`AllegroApiClient`, `AllegroOAuthClient`, `AllegroTokenManager`). | -| Accounting | `src/Modules/Accounting/` | paragony, faktury, eksport ksiegowy | +| Accounting | `src/Modules/Accounting/` | paragony, faktury (slim fasada `InvoiceService` + `InvoiceMetadataResolver` resolvery dat/oid + `InvoiceSnapshotBuilder` seller/buyer/items + statyka `extractBuyerTaxNumber` + `FakturowniaInvoicePayloadBuilder` payload API + `LocalInvoiceIssuer` sciezka lokalna + `DelegatedInvoiceIssuer` idempotentna sciezka Fakturowni), eksport ksiegowy | | Shipments | `src/Modules/Shipments/` | etykiety, tracking, presets paczek; slownik statusow dostawy jako fasada `DeliveryStatus` + `DeliveryStatusProviderMap` (mapy dostawcow) + `AllegroDescriptionGuesser` (heurystyki opisow) + `DeliveryTrackingUrlBuilder` (URL sledzenia) | | Printing | `src/Modules/Printing/` | API drukowania etykiet (klient Windows) | | Email | `src/Modules/Email/` | SMTP, wysylka maili | diff --git a/.paul/codebase/quality_risks.md b/.paul/codebase/quality_risks.md index 95a4405..9542e28 100644 --- a/.paul/codebase/quality_risks.md +++ b/.paul/codebase/quality_risks.md @@ -19,7 +19,7 @@ Konwencja (`CLAUDE.md`): funkcja/klasa zwykle do 30-50 linii, max 3 poziomy zagn | `src/Modules/Settings/AllegroOrderImportService.php` | 834 | duzy import service. | | `src/Modules/Automation/AutomationService.php` | 818 | silnik regul — kandydat na pipeline-strategy. | | `src/Modules/Shipments/PolkurierShipmentService.php` | 776 | | -| `src/Modules/Accounting/InvoiceService.php` | 762 | | +| ~~`src/Modules/Accounting/InvoiceService.php`~~ | ~~762~~ -> 118 | ✅ Zrefaktorowane 2026-05-20 — fasada (118) + 5 wspolpracownikow: `InvoiceMetadataResolver` (84), `InvoiceSnapshotBuilder` (223), `FakturowniaInvoicePayloadBuilder` (84), `LocalInvoiceIssuer` (66), `DelegatedInvoiceIssuer` (253). Zero zmian kontraktu (konstruktor 6-arg, `issue()`, statyk `extractBuyerTaxNumber`). Patrz `.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md`. | | ~~`src/Modules/Automation/AutomationController.php`~~ | ~~677~~ -> 221 | ✅ Zrefaktorowane 2026-05-20 — wydzielono `AutomationRequestParser` (422), `AutomationFormViewModel` (80), `AutomationHistoryFilters` (58); szablon `destroy/duplicate/toggleStatus` zlozony do `runIdAction()`. Zero zmian kontraktu HTTP/widokow/DB. Patrz `.paul/plans/20260520-1400-refactor-automation-controller/SUMMARY.md`. | | ~~`src/Modules/Shipments/DeliveryStatus.php`~~ | ~~657~~ -> 170 | ✅ Zrefaktorowane 2026-05-19 — fasada zachowujaca pelny kontrakt + wydzielono `DeliveryStatusProviderMap` (~330), `AllegroDescriptionGuesser` (~150), `DeliveryTrackingUrlBuilder` (~85). Zero zmian w 20 plikach konsumentow. Patrz `.paul/plans/20260519-1730-refactor-delivery-status/SUMMARY.md`. | | ~~`src/Modules/Settings/AllegroIntegrationController.php`~~ | ~~653~~ -> 223 | ✅ Zrefaktorowane 2026-05-19 — wydzielono `AllegroIntegrationViewModel` (61), `AllegroOAuthFlowService` (94), `AllegroImportScheduleService` (179), `AllegroImportImageWarningFormatter` (69), `AllegroSaveSettingsValidator` (48). Patrz `.paul/plans/20260519-1600-refactor-allegro-integration-controller/SUMMARY.md`. | diff --git a/.paul/codebase/tech_changelog.md b/.paul/codebase/tech_changelog.md index b1941cc..f29b148 100644 --- a/.paul/codebase/tech_changelog.md +++ b/.paul/codebase/tech_changelog.md @@ -2,6 +2,42 @@ Chronologiczny log zmian technicznych (co i dlaczego). Najnowsze na gorze. +## 2026-05-20 — Dekompozycja `InvoiceService` (762 -> 118 lin., fasada + 5 wspolpracownikow) + +### Co +- `src/Modules/Accounting/InvoiceService.php` zredukowany z 762 do 118 lin. (84% redukcji) — slim fasada zachowujaca pelny publiczny kontrakt. +- Wydzielono 5 nowych klas w `src/Modules/Accounting/`: + - `InvoiceMetadataResolver` (84) — resolvery dat (`resolveIssueDate`, `resolveSaleDate`, `resolvePaymentDueDate`) + `resolveOrderReference` + `resolveFakturowniaOid`. + - `InvoiceSnapshotBuilder` (223) — `buildSellerSnapshot`, `buildBuyerSnapshot`, `buildItemsSnapshot` + publiczna statyka `extractBuyerTaxNumber` (+ prywatne helpery `taxNumberPaths`, `digValue`). + - `FakturowniaInvoicePayloadBuilder` (84) — `build()` z zachowanymi komentarzami "UWAGA: seller_* CELOWO pominiete" i "department_id celowo pominiete" (HTTP 422 ograniczenia API Fakturowni). + - `LocalInvoiceIssuer` (66) — lokalna sciezka wystawienia (insertLocal + nextLocalNumber). + - `DelegatedInvoiceIssuer` (253) — idempotentna sciezka Fakturowni: `findByConfigAndExternalOid -> isIssuedDelegatedInvoice -> findRemoteInvoiceByOid -> insertDelegatedPending -> createInvoice -> finalizeDelegatedExternal`. +- `InvoiceService::extractBuyerTaxNumber()` pozostaje jako publiczny statyczny forwarder do `InvoiceSnapshotBuilder::extractBuyerTaxNumber()` — `InvoiceController.php:97` dzialalo bez zmian. + +### Dlaczego +- `InvoiceService` byl monolitem (`quality_risks.md`: 762 lin., kandydat do podzialu) lacacym 6 odpowiedzialnosci: resolvery, buildery, payload API, sciezka local, idempotentna sciezka delegated. Naruszal regule `CLAUDE.md` (klasa zwykle do 30-50 lin.). +- Wzorzec sprawdzony w projekcie (DeliveryStatus 657->170, AutomationController 677->221, AllegroIntegrationController 653->223): slim fasada + wspolpracownicy. + +### Zachowane gwarancje +- Konstruktor `InvoiceService(6 zaleznosci)` bez zmian — `AccountingModule` (wpis `accounting.invoices.service`) i `tests/Unit/FakturowniaInvoiceIdempotencyTest.php::createService()` dzialaja bez modyfikacji. +- Sygnatura `issue(array $params): array` i ksztalt zwracanej tablicy (`invoice_id, invoice_number, total_gross, mode`) bez zmian. +- Statyk `InvoiceService::extractBuyerTaxNumber()` zachowany jako forwarder. +- Payload Fakturowni 1:1 (celowy brak `seller_*` i `department_id` udokumentowany w nowej klasie). +- Test `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` — zero modyfikacji (primary gate, wymaga `vendor/phpunit` do uruchomienia). + +### Obserwacja (pre-existing bug, NIE adresowane w tym planie) +- Oryginalny `issueLocal()` mial w sygnaturze 15 parametrow (w tym nieuzywany `$externalOid`), a wywolanie z `issue()` przekazywalo 14 argumentow. Sciezka local (config `is_delegated=0`) wywolalaby `ArgumentCountError` przy pierwszej probie. Ze wzgledu na zero zglosen tego defekt nie byl zauwazony. +- Podczas refaktoru `LocalInvoiceIssuer::issue()` ma poprawne 14 parametrow (brak vestigialnego `$externalOid`) i jest wywolywane spojnie. To incydentalna naprawa — sciezka local przestaje rzucac ArgumentCountError, ale w testach nie ma jej pokrycia, wiec wlasciwy regression test pozostaje do dolozenia w osobnym planie. + +### Weryfikacja w sesji +- `php -l` zielone dla 7 plikow (5 nowych klas + zmieniona fasada + 2 konsumenci `InvoiceController`, `AccountingModule`). +- Reflection: `InvoiceMetadataResolver` ma 5 publicznych metod, `FakturowniaInvoicePayloadBuilder::build` istnieje. +- `grep` potwierdza zachowanie wolan `InvoiceService::extractBuyerTaxNumber(...)` i `new InvoiceService(...)` w nieztkniete plikach. +- `vendor/phpunit` niedostepny -> uruchomienie testu idempotencji do recznej weryfikacji. + +### Plan +- `.paul/plans/20260520-1500-refactor-invoice-service/` + ## 2026-05-20 — Cron `storage_cleanup` (retencja 30 dni dla katalogu `storage/`) ### Co diff --git a/.paul/codebase/tooling_status.md b/.paul/codebase/tooling_status.md index 694c480..2fe2faa 100644 --- a/.paul/codebase/tooling_status.md +++ b/.paul/codebase/tooling_status.md @@ -1,7 +1,20 @@ # Tooling Status -**Timestamp:** 2026-05-19 -**Tryb skanu:** full (`/paul:map-codebase`) +**Timestamp:** 2026-05-20 (last post-apply: refactor InvoiceService) +**Tryb skanu:** full (`/paul:map-codebase` 2026-05-19) + post-apply (2026-05-20) + +## Post-apply 2026-05-20 — refactor `InvoiceService` + +- Plan: `.paul/plans/20260520-1500-refactor-invoice-service/` +- `php -l` (XAMPP `C:\xampp\php\php.exe`) na 7 plikach modulu `Accounting/` -> No syntax errors. +- Reflection probe na `InvoiceMetadataResolver` (5 publicznych metod) i `FakturowniaInvoicePayloadBuilder::build` -> OK. +- `vendor/phpunit` NIEDOSTEPNY -> uruchomienie `FakturowniaInvoiceIdempotencyTest` przelozone na reczna weryfikacje. +- codebase-memory-mcp: re-index nie uruchomiony (zmiany w 1 module — odswiezenie grafu przy nastepnym `$paul-map-codebase`). +- jscpd / ast-grep: nadal disabled by policy. + +--- + + ## Status narzedzi diff --git a/.paul/plans/20260520-1500-refactor-invoice-service/PLAN.md b/.paul/plans/20260520-1500-refactor-invoice-service/PLAN.md new file mode 100644 index 0000000..9982b3f --- /dev/null +++ b/.paul/plans/20260520-1500-refactor-invoice-service/PLAN.md @@ -0,0 +1,331 @@ +--- +plan_id: 20260520-1500-refactor-invoice-service +title: Dekompozycja InvoiceService (762 lin.) na fasade + 4 wspolpracownikow +storage: plan-first +legacy_phase: null +created: 2026-05-20T15:00:00+02:00 +status: planned +type: execute +autonomous: true +delegation: auto +files_modified: + - src/Modules/Accounting/InvoiceService.php + - src/Modules/Accounting/InvoiceMetadataResolver.php + - src/Modules/Accounting/InvoiceSnapshotBuilder.php + - src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php + - src/Modules/Accounting/DelegatedInvoiceIssuer.php + - src/Modules/Accounting/AccountingModule.php + - .paul/codebase/architecture.md + - .paul/codebase/quality_risks.md + - .paul/codebase/tech_changelog.md +quality_radar: ok +--- + + +## Cel +Zredukowac `src/Modules/Accounting/InvoiceService.php` z 762 lin. do ~150 lin. (fasada) przez wydzielenie spojnych odpowiedzialnosci do 4 nowych klas w `src/Modules/Accounting/`. + +## Po co +Plik to dzis monolit lacacy 6 niezaleznych odpowiedzialnosci: resolvery (data wystawienia/sprzedazy/termin platnosci/referencja zamowienia/oid), buildery snapshotow (seller/buyer/items + ekstrakcja NIP), builder payloadu Fakturowni, sciezka "local" oraz idempotentna sciezka "delegated" do Fakturowni. Klasa ma 23 metody (1 publiczna `issue`, 1 publiczna statyczna `extractBuyerTaxNumber`, 21 prywatnych), wykracza poza limit z `CLAUDE.md` (klasa zwykle do 30-50 lin., metoda taksamo). Wzorzec sprawdzony juz w projekcie: `DeliveryStatus` (657 -> 170, fasada + 3 klasy), `AutomationController` (677 -> 221, slim + 3 wspolpracownicy), `AllegroIntegrationController` (653 -> 223). + +## Wynik +- `InvoiceService` jako slim fasada (~140-160 lin.): konstruktor + `issue()` + statyczne `extractBuyerTaxNumber()` jako forwarder. +- 4 nowe klasy: + - `InvoiceMetadataResolver` (~110 lin.) — `resolveIssueDate`, `resolveSaleDate`, `resolvePaymentDueDate`, `resolveOrderReference`, `resolveFakturowniaOid`. + - `InvoiceSnapshotBuilder` (~230 lin.) — `buildSellerSnapshot`, `buildBuyerSnapshot`, `buildItemsSnapshot`, publiczna statyczna `extractBuyerTaxNumber` + prywatne `taxNumberPaths`, `digValue`. + - `FakturowniaInvoicePayloadBuilder` (~80 lin.) — `build()` (zachowuje komentarze "UWAGA seller_*/department_id celowo pominiete"). + - `DelegatedInvoiceIssuer` (~190 lin.) — pelna sciezka delegowana (idempotencja, lookup remote, attach, finalize) + `buildDelegatedInvoiceData`. +- Zero zmian w kontrakcie publicznym `InvoiceService` (konstruktor, sygnatura `issue()`, sygnatura `extractBuyerTaxNumber()`). +- Zero zmian w `InvoiceController`, `AccountingModule` (konstruktor), tabeli `invoices`, payloadach Fakturowni i wynikach `issue()`. +- `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` przechodzi bez modyfikacji (primary gate idempotencji). + + + +## Project Docs +@.paul/PROJECT.md +@.paul/STATE.md +@.paul/codebase/architecture.md +@.paul/codebase/db_schema.md +@.paul/codebase/impact_map.md +@.paul/codebase/quality_risks.md + +## Source Files +@src/Modules/Accounting/InvoiceService.php +@src/Modules/Accounting/InvoiceController.php +@src/Modules/Accounting/InvoiceRepository.php +@src/Modules/Accounting/InvoiceIssueException.php +@src/Modules/Accounting/AccountingModule.php +@src/Modules/Settings/FakturowniaApiClient.php +@src/Modules/Settings/FakturowniaIntegrationRepository.php +@tests/Unit/FakturowniaInvoiceIdempotencyTest.php + + + +- Zachowujemy `InvoiceService::extractBuyerTaxNumber()` jako publiczna statyczna fasade -> deleguje do `InvoiceSnapshotBuilder::extractBuyerTaxNumber()`. Powod: `InvoiceController.php:97` wola `InvoiceService::extractBuyerTaxNumber($order, $buyerAddress)`, zmiana wymagalaby edycji kontrolera; trzymanie forwardera daje refaktor "zero zmian zewnetrznych". +- Sygnatura konstruktora `InvoiceService` pozostaje bez zmian — `AccountingModule` (wpis `accounting.invoices.service`) i `tests/Unit/FakturowniaInvoiceIdempotencyTest.php::createService()` montuja 6 zaleznosci w obecnej kolejnosci. +- 4 nowe klasy buduje fasada w konstruktorze (poor man's DI, spojnie z reszta projektu); nie dodajemy ich do `ServiceRegistry` — sa wewnetrznymi szczegolami `InvoiceService`. + + + +## Quality Radar + +**Status:** ok +**Tryb:** plan (impact scan przez codebase-memory-mcp na podstawie `.paul/codebase/quality_risks.md` z 2026-05-19) +**Tools:** codebase-memory-mcp (search_graph + Grep weryfikacja konsumentow); jscpd/ast-grep wylaczone polityka (`.paul/config.md`). + +## Obszary dotkniete + +- `src/Modules/Accounting/` — wszystkie zmiany lokalne dla modulu Accounting. +- `src/Modules/Accounting/InvoiceController.php` — TYLKO odczyt (uzywa `new InvoiceService(...)` posrednio przez DI oraz statyki `InvoiceService::extractBuyerTaxNumber`). Bez modyfikacji. +- `src/Modules/Accounting/AccountingModule.php` — bez zmian sygnatury, tylko ewentualne `use`-y nowych klas nie sa potrzebne (fasada buduje wspolpracownikow sama). +- `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` — bez modyfikacji (primary gate). +- `.paul/codebase/architecture.md`, `quality_risks.md`, `tech_changelog.md` — aktualizacja dokumentacji. + +## Duplicate / Hardcoded Risks + +- `quality_risks.md` wymienia `InvoiceService.php` (762) jako kandydata do podzialu — adresowane w Task 1-5. +- Powtarzajacy sie wzorzec `(string)($x['key'] ?? '')` i `trim(...)` w `buildBuyerSnapshot` — celowo nie ujednolicamy w tym planie (zaden test ani wymaganie tego nie wymusza; ryzyko regresji > zysk). Deferred. +- `buildSellerSnapshot` i `buildBuyerSnapshot` produkuja podobne tablice kluczy (`company_name`/`street`/`city`/...) — to dwa rozne kontrakty (seller pelny, buyer ze snapshotem nullowalnym). Nie laczymy. + +## Explicit Deferrals + +- Pelne pokrycie testami `InvoiceService::issue()` sciezki "local" — out of scope; istniejacy `FakturowniaInvoiceIdempotencyTest` pokrywa sciezke delegated (3 scenariusze idempotencji) i pozostaje primary gate. +- Refaktor `InvoiceController.php` (728 lin.) i `InvoiceRepository.php` — osobne plany. +- Eliminacja statycznej fasady `InvoiceService::extractBuyerTaxNumber` (migracja wolan w `InvoiceController`) — osobny mikroplan jezeli zechcemy posprzatac, wstecznie nieblokujace. + + + +Brak SPECIAL-FLOWS.md w `.paul/` — sekcja pominieta. + + + + +## AC-1: Fasada `InvoiceService` zachowuje publiczny kontrakt +```gherkin +Given istniejacy `InvoiceService` z konstruktorem 6 zaleznosci, publiczna `issue(array $params): array` i statyczna `public static extractBuyerTaxNumber(array $order, ?array $buyerAddress): string` +When zakonczy sie refaktor +Then `InvoiceController.php:97` (`InvoiceService::extractBuyerTaxNumber(...)`) i `AccountingModule.php:66-73` (`new InvoiceService(repo, configs, companies, orders, fakturownia, fakturowniaApi)`) dzialaja bez zmian +And `tests/Unit/FakturowniaInvoiceIdempotencyTest.php::createService()` zwraca dzialajaca instancje +And `php -l src/Modules/Accounting/InvoiceService.php` zwraca `No syntax errors` +``` + +## AC-2: 4 nowe klasy wyodrebnione w `src/Modules/Accounting/` +```gherkin +Given dekompozycja na fasade + 4 wspolpracownikow +When sprawdzimy katalog `src/Modules/Accounting/` +Then istnieja pliki: + - `InvoiceMetadataResolver.php` (~110 lin., 5 metod resolver*) + - `InvoiceSnapshotBuilder.php` (~230 lin., 3 buildery + statyka `extractBuyerTaxNumber` + 2 helpery prywatne) + - `FakturowniaInvoicePayloadBuilder.php` (~80 lin., 1 metoda `build()`) + - `DelegatedInvoiceIssuer.php` (~190 lin., orchestracja sciezki delegated) +And kazdy plik `declare(strict_types=1)`, `final class`, `namespace App\Modules\Accounting` +And `php -l` dla kazdego pliku zwraca `No syntax errors` +``` + +## AC-3: `InvoiceService` zredukowany do <=160 lin. +```gherkin +Given pierwotne 762 lin. +When zakonczy sie refaktor +Then `wc -l src/Modules/Accounting/InvoiceService.php` zwraca <= 160 +And klasa zawiera tylko: konstruktor, `issue()`, statyczna `extractBuyerTaxNumber()` jako forwarder +``` + +## AC-4: Test idempotencji Fakturowni przechodzi bez zmian +```gherkin +Given `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` (3 scenariusze: pending insert + remote create, lookup remote przed call, retry po Throwable z findRemote) +When uruchomimy test (gdy vendor/phpunit dostepny) lub zweryfikujemy reka logike `issueDelegated` +Then logika idempotencji `findByConfigAndExternalOid -> isIssuedDelegatedInvoice -> findRemoteInvoiceByOid -> insertDelegatedPending -> createInvoice -> finalizeDelegatedExternal` jest zachowana 1:1 +And plik testowy NIE zmienia sie (zero modyfikacji) +``` + +## AC-5: Dokumentacja zaktualizowana +```gherkin +Given refaktor zmienia strukture modulu Accounting +When zakonczy sie praca +Then `.paul/codebase/architecture.md` (tabela "Moduly domenowe", wiersz `Accounting`) wymienia nowe klasy +And `.paul/codebase/quality_risks.md` (wpis InvoiceService.php) jest oznaczony `~~~~ -> ` ze wskazaniem SUMMARY +And `.paul/codebase/tech_changelog.md` zawiera wpis z data 2026-05-20 i krotkim opisem +``` + + + + + + + Task 1: Wyodrebnij InvoiceMetadataResolver (resolvery dat i referencji) + src/Modules/Accounting/InvoiceMetadataResolver.php + + Utworz `src/Modules/Accounting/InvoiceMetadataResolver.php`: + - `declare(strict_types=1)`, `namespace App\Modules\Accounting`, `final class InvoiceMetadataResolver`. + - Skopiuj 5 metod z `InvoiceService` jako PUBLICZNE (sygnatura 1:1, ciala bez zmian): + - `resolveIssueDate(string $override): string` + - `resolveSaleDate(array $config, array $order, array $payments, string $issueDate): string` + - `resolvePaymentDueDate(string $issueDate, int $paymentToDays): string` + - `resolveOrderReference(array $config, array $order): string` + - `resolveFakturowniaOid(int $orderId, array $order): string` + - Brak konstruktora (bezstanowa klasa) lub pusty publiczny `__construct()` — wybierz wariant bezkonstruktorowy. + - Zachowaj wszystkie PHPDoc i komentarze z oryginalu. + + `php -l src/Modules/Accounting/InvoiceMetadataResolver.php` -> No syntax errors; `wc -l` ~100-120. + AC-2, AC-3. + + + + Task 2: Wyodrebnij InvoiceSnapshotBuilder (seller/buyer/items + ekstrakcja NIP) + src/Modules/Accounting/InvoiceSnapshotBuilder.php + + Utworz `src/Modules/Accounting/InvoiceSnapshotBuilder.php`: + - `declare(strict_types=1)`, `namespace App\Modules\Accounting`, `final class InvoiceSnapshotBuilder`. + - Konstruktor: `public function __construct(private readonly \App\Modules\Settings\CompanySettingsRepository $companySettings) {}`. + - Publiczne metody (sygnatury 1:1 z `InvoiceService`): + - `buildSellerSnapshot(): array` (uzywa wstrzyknietego `companySettings`). + - `buildBuyerSnapshot(array $order, array $addresses, array $params): ?array`. + - `buildItemsSnapshot(array $items, array $order): array` (zwraca `['items', 'total_gross', 'total_net']`). + - `public static function extractBuyerTaxNumber(array $order, ?array $buyerAddress): string` (bez zmian). + - `private static function taxNumberPaths(): array`, `private static function digValue(array $arr, array $path): mixed` — przeniesione 1:1. + - W `buildBuyerSnapshot` zachowaj wywolanie `self::extractBuyerTaxNumber(...)`. + - Zachowaj wszystkie PHPDoc-i. + + `php -l src/Modules/Accounting/InvoiceSnapshotBuilder.php` -> No syntax errors; `wc -l` ~220-240. + AC-2, AC-3. + + + + Task 3: Wyodrebnij FakturowniaInvoicePayloadBuilder + src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php + + Utworz `src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php`: + - `declare(strict_types=1)`, `namespace App\Modules\Accounting`, `final class FakturowniaInvoicePayloadBuilder`. + - Bezstanowa: brak konstruktora. + - Publiczna metoda `build(string $kind, string $issueDate, string $saleDate, string $paymentDueDate, array $sellerSnapshot, ?array $buyerSnapshot, array $itemsSnapshot, string $externalOid, string $orderReference, int $paymentToDays, string $departmentId): array` — cialo 1:1 z `InvoiceService::buildFakturowniaPayload`. + - **KRYTYCZNE: zachowaj komentarze "UWAGA: seller_* pola CELOWO pominiete..." i "department_id celowo pominiete..."** (~25 lin. komentarzy w sumie). Te komentarze dokumentuja realne ograniczenia API Fakturowni (HTTP 422) — bez nich kolejny refaktor moze "naprawic" je w zly sposob. + - Zachowaj `unset($paymentToDays, $sellerSnapshot);` i `unset($departmentId);` — sygnalizuja celowe odrzucenie parametrow. + + `php -l src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php` -> No syntax errors; `wc -l` ~80-100; grep -c "CELOWO" >= 1. + AC-2, AC-3. + + + + Task 4: Wyodrebnij DelegatedInvoiceIssuer (idempotentna sciezka Fakturowni) + src/Modules/Accounting/DelegatedInvoiceIssuer.php + + Utworz `src/Modules/Accounting/DelegatedInvoiceIssuer.php`: + - `declare(strict_types=1)`, `namespace App\Modules\Accounting`, `final class DelegatedInvoiceIssuer`. + - Konstruktor: `__construct(private readonly InvoiceRepository $invoices, private readonly \App\Modules\Settings\FakturowniaIntegrationRepository $fakturownia, private readonly \App\Modules\Settings\FakturowniaApiClient $fakturowniaApi, private readonly FakturowniaInvoicePayloadBuilder $payloadBuilder)`. + - Publiczna metoda `issue(int $orderId, int $configId, array $config, string $issueDate, string $saleDate, string $paymentDueDate, array $sellerSnapshot, ?array $buyerSnapshot, array $itemsSnapshot, float $totalNet, float $totalGross, string $externalOid, string $orderReference, string $kind, ?int $createdBy): array` — cialo z `InvoiceService::issueDelegated` (linie 175-273), zamieniajac: + - `$this->fakturownia` -> `$this->fakturownia` (juz pole), + - `$this->buildFakturowniaPayload(...)` -> `$this->payloadBuilder->build(...)`, + - `$this->fakturowniaApi->createInvoice(...)` -> `$this->fakturowniaApi->createInvoice(...)` (pole), + - `$this->invoices->...` -> `$this->invoices->...` (pole). + - Prywatne metody przeniesione 1:1: `buildDelegatedInvoiceData`, `findRemoteInvoiceByOid`, `attachRemoteInvoice`, `finalizeDelegatedInvoice`, `isIssuedDelegatedInvoice`, `resultFromLocalDelegatedInvoice`. + - Zachowaj logike rzucania `InvoiceIssueException` (komunikat z propozycja "Sprobuj ponownie - orderPRO uzyje tego samego oid..."). + + `php -l src/Modules/Accounting/DelegatedInvoiceIssuer.php` -> No syntax errors; `wc -l` ~180-200; grep "InvoiceIssueException" >= 4 (4 miejsca rzucania). + AC-2, AC-3, AC-4. + + + + Task 5: Przepisz InvoiceService na slim fasade + src/Modules/Accounting/InvoiceService.php + + Przepisz `src/Modules/Accounting/InvoiceService.php` (~150 lin.): + - Zachowaj `declare(strict_types=1)`, `namespace App\Modules\Accounting`, `final class InvoiceService`. + - **Konstruktor BEZ ZMIAN**: `__construct(InvoiceRepository $invoices, InvoiceConfigRepository $invoiceConfigs, CompanySettingsRepository $companySettings, OrdersRepository $orders, FakturowniaIntegrationRepository $fakturownia, FakturowniaApiClient $fakturowniaApi)`. + - W ciele konstruktora zbuduj wspolpracownikow: + ``` + $this->metadata = new InvoiceMetadataResolver(); + $this->snapshots = new InvoiceSnapshotBuilder($companySettings); + $payloadBuilder = new FakturowniaInvoicePayloadBuilder(); + $this->delegated = new DelegatedInvoiceIssuer($invoices, $fakturownia, $fakturowniaApi, $payloadBuilder); + ``` + - Pola prywatne dla nadal uzywanych zaleznosci: `$invoices`, `$invoiceConfigs`, `$orders` + nowe `$metadata`, `$snapshots`, `$delegated`. + - `public function issue(array $params): array` — orchestracja (sygnatura + zwrot 1:1): + 1. walidacja `config` (`invoiceConfigs->findById` + `is_active`) — rzuca `InvoiceIssueException`, + 2. lookup `orders->findDetails` — rzuca `InvoiceIssueException`, + 3. wywola `$this->metadata->resolveIssueDate/...SaleDate/...PaymentDueDate/...OrderReference/...FakturowniaOid`, + 4. wywola `$this->snapshots->buildSellerSnapshot/...BuyerSnapshot` + destrukturyzacja `buildItemsSnapshot`, + 5. galez `$isDelegated` -> `$this->delegated->issue(...)` z pelnym zestawem 15 argumentow, + 6. galez local -> wywola lokalna prywatna `issueLocal(...)` (zachowana w fasadzie jako 1 metoda ~50 lin., bo nie wymaga osobnej klasy — wstrzykuje tylko `$this->invoices`). + - `private function issueLocal(...): array` — 1:1 z oryginalu. + - `public static function extractBuyerTaxNumber(array $order, ?array $buyerAddress): string { return InvoiceSnapshotBuilder::extractBuyerTaxNumber($order, $buyerAddress); }` — forwarder zachowujacy publiczna statyczna API. + - Usun z fasady wszystkie pozostale prywatne metody (przeniesione do wspolpracownikow). + - Zachowaj PHPDoc na `issue()` (kontrakt `array{order_id, config_id, buyer_*, ...}` -> `array{invoice_number, total_gross, mode, invoice_id}`). + + `php -l src/Modules/Accounting/InvoiceService.php` -> No syntax errors; `wc -l` <= 160; grep "public function" -> 2 (issue + statyk); grep "private function" -> 1 (issueLocal). + AC-1, AC-3. + + + + Task 6: Weryfikacja integracyjna (controller + module + test + smoke) + src/Modules/Accounting/InvoiceController.php, src/Modules/Accounting/AccountingModule.php, tests/Unit/FakturowniaInvoiceIdempotencyTest.php + + NIE modyfikuj tych plikow. Wykonaj weryfikacje: + 1. `php -l` na 5 plikach: `InvoiceService.php` + 4 nowe klasy + `InvoiceController.php` + `AccountingModule.php` (lint = primary gate w sesji bez DB). + 2. `grep -n "InvoiceService::extractBuyerTaxNumber" src/Modules/Accounting/InvoiceController.php` — potwierdzic, ze wolanie statycznej `extractBuyerTaxNumber` nadal kompiluje (zwraca string z `InvoiceSnapshotBuilder`). + 3. `grep -n "new InvoiceService" src/Modules/Accounting/AccountingModule.php tests/Unit/FakturowniaInvoiceIdempotencyTest.php` — potwierdzic ze konstruktor 6-argumentowy nadal jest poprawny. + 4. Reflection-check (gdy `php` dostepne): `php -r "$r = new ReflectionClass('App\\Modules\\Accounting\\InvoiceService'); var_dump($r->getConstructor()->getNumberOfParameters());"` — oczekiwane 6 (autoload trzeba zaladowac przez `vendor/autoload.php` jezeli istnieje). + 5. Jezeli `vendor/phpunit` jest dostepny: `vendor/bin/phpunit tests/Unit/FakturowniaInvoiceIdempotencyTest.php` — 3/3 OK. W przeciwnym razie zapisz w SUMMARY jako "do weryfikacji recznej". + 6. Manualny przeglad sciezki controlowanej: czy w `InvoiceController::create` (POST /orders/{id}/invoice/create) zwracane przez `issue()` `invoice_id/invoice_number/total_gross/mode` sa konsumowane bez zmian. + + 5/5 plikow `php -l` -> No syntax errors; konsumenci `extractBuyerTaxNumber` i `new InvoiceService(...)` niezmienieni. + AC-1, AC-4. + + + + Task 7: Aktualizacja dokumentacji `.paul/codebase/` + .paul/codebase/architecture.md, .paul/codebase/quality_risks.md, .paul/codebase/tech_changelog.md + + 1. `architecture.md` — w tabeli "Moduly domenowe", wiersz `Accounting`, rozszerz opis o: + `slim `InvoiceService` (fasada) + `InvoiceMetadataResolver` (resolvery dat/oid) + `InvoiceSnapshotBuilder` (seller/buyer/items + extractBuyerTaxNumber) + `FakturowniaInvoicePayloadBuilder` (payload API) + `DelegatedInvoiceIssuer` (idempotentna sciezka Fakturowni)`. + 2. `quality_risks.md` — wpis `| src/Modules/Accounting/InvoiceService.php | 762 | |` przerob na: + `| ~~src/Modules/Accounting/InvoiceService.php~~ | ~~762~~ -> | ✅ Zrefaktorowane 2026-05-20 — fasada + 4 wspolpracownikow. Patrz .paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md. |`. + 3. `tech_changelog.md` — dopisz wpis 2026-05-20 z 3-4 punktami: dekompozycja, zachowanie publicznego kontraktu, test idempotencji bez zmian, wzorzec spojny z DeliveryStatus/Automation/Allegro. + + `grep -c "InvoiceMetadataResolver" .paul/codebase/architecture.md` >= 1; `grep "20260520-1500-refactor-invoice-service" .paul/codebase/quality_risks.md` >= 1. + AC-5. + + + + + +## Nie zmieniac +- Sygnatury konstruktora `InvoiceService` (6 zaleznosci w obecnej kolejnosci). +- Sygnatury `InvoiceService::issue(array $params): array` i ksztaltu zwracanej tablicy `{invoice_id, invoice_number, total_gross, mode}`. +- Sygnatury statycznej `InvoiceService::extractBuyerTaxNumber(array $order, ?array $buyerAddress): string`. +- Pliku `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` (primary gate — zero modyfikacji). +- Pliku `src/Modules/Accounting/InvoiceController.php` (728 lin., osobny kandydat na refaktor — out of scope). +- Pliku `src/Modules/Accounting/InvoiceRepository.php` (zero zmian). +- `src/Modules/Accounting/AccountingModule.php` w zakresie wpisu `accounting.invoices.service` (zero zmian w `new InvoiceService(...)`). +- Schematu tabeli `invoices` i payloadu wysylanego do Fakturowni (`buildFakturowniaPayload` -> `FakturowniaInvoicePayloadBuilder::build` musi byc 1:1; w szczegolnosci celowy brak `seller_*` i `department_id`). +- Komunikatow `InvoiceIssueException` (wlacznie z tekstem "Sprobuj ponownie - orderPRO uzyje tego samego oid..."). + +## Scope limits +- Refaktor `InvoiceController` — out of scope (osobny plan). +- Refaktor `InvoiceRepository` — out of scope. +- Dodawanie nowych testow jednostkowych dla wyodrebnionych klas — out of scope (deferred do osobnego planu jezeli sie zdecydujemy). +- Ujednolicenie wzorca `(string)($x[..] ?? '')` / `trim(...)` — celowo nie ruszamy. +- Migracja `InvoiceController` z `InvoiceService::extractBuyerTaxNumber` na bezposrednie wywolanie nowej klasy — osobny mikroplan. + + + +- [ ] `php -l` zwraca `No syntax errors` dla 5 plikow: `InvoiceService.php`, `InvoiceMetadataResolver.php`, `InvoiceSnapshotBuilder.php`, `FakturowniaInvoicePayloadBuilder.php`, `DelegatedInvoiceIssuer.php`. +- [ ] `wc -l src/Modules/Accounting/InvoiceService.php` <= 160. +- [ ] `grep -n "public function" src/Modules/Accounting/InvoiceService.php` zwraca tylko `__construct`, `issue`, `extractBuyerTaxNumber` (statyk). +- [ ] `grep -n "InvoiceService::extractBuyerTaxNumber" src/Modules/Accounting/InvoiceController.php` — wystepuje 1x, niezmienione. +- [ ] `grep -n "new InvoiceService(" src/Modules/Accounting/AccountingModule.php tests/Unit/FakturowniaInvoiceIdempotencyTest.php` — 2 wystapienia, oba z 6 argumentami. +- [ ] Reflection: `InvoiceService::class` ma konstruktor o 6 parametrach. +- [ ] (Opcjonalnie, gdy `vendor/phpunit` dostepny) `vendor/bin/phpunit tests/Unit/FakturowniaInvoiceIdempotencyTest.php` -> 3/3 OK. +- [ ] Quality Radar ryzyko `InvoiceService.php 762 lin.` przeniesione do "Zrefaktorowane" w `quality_risks.md`. + + + +- [ ] Wszystkie AC (AC-1..AC-5) spelnione. +- [ ] Verification checklist pozytywna (z wyjatkiem PHPUnit jezeli vendor niedostepny — wtedy zapisac jako "do weryfikacji recznej" w SUMMARY). +- [ ] Dokumentacja `.paul/codebase/` zaktualizowana. +- [ ] Brak modyfikacji w `InvoiceController.php`, `InvoiceRepository.php`, `tests/Unit/FakturowniaInvoiceIdempotencyTest.php`. +- [ ] Petla zamknieta przez UNIFY z SUMMARY.md. + + + +SUMMARY.md path: `.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md` + diff --git a/.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md b/.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md new file mode 100644 index 0000000..b5d2df9 --- /dev/null +++ b/.paul/plans/20260520-1500-refactor-invoice-service/SUMMARY.md @@ -0,0 +1,110 @@ +--- +plan_id: 20260520-1500-refactor-invoice-service +title: Dekompozycja InvoiceService (762 -> 118 lin., fasada + 5 wspolpracownikow) +completed: 2026-05-20T15:45:00+02:00 +storage: plan-first +quality_radar: degraded +--- + +# Summary: Dekompozycja `InvoiceService` + +## Objective + +Zredukowac `src/Modules/Accounting/InvoiceService.php` z 762 lin. monolitu lacacego 6 odpowiedzialnosci do slim fasady (~150) + wspolpracownikow w `src/Modules/Accounting/`, zachowujac pelny publiczny kontrakt (konstruktor 6-arg, `issue(array)`, statyk `extractBuyerTaxNumber`). + +## What Was Built + +| Obszar | Wynik | +|---|---| +| Fasada `InvoiceService` | 762 -> **118 lin.** (84% redukcji), 1 publiczna metoda + 1 statyk forwarder, deleguje do 5 wspolpracownikow | +| `InvoiceMetadataResolver` (NOWA, 84 lin.) | 5x `resolve*` (issueDate / saleDate / paymentDueDate / orderReference / fakturowniaOid), bezstanowa | +| `InvoiceSnapshotBuilder` (NOWA, 223 lin.) | seller/buyer/items snapshots + publiczna statyka `extractBuyerTaxNumber` + prywatne helpery `taxNumberPaths`/`digValue`, zalezy od `CompanySettingsRepository` | +| `FakturowniaInvoicePayloadBuilder` (NOWA, 84 lin.) | `build()` z zachowanymi komentarzami "CELOWO/celowo pominiete" (seller_*, department_id — HTTP 422 ograniczenia API) | +| `LocalInvoiceIssuer` (NOWA, 66 lin.) | sciezka lokalna (`nextLocalNumber` + `insertLocal`), zalezy od `InvoiceRepository` — dodana ponad plan dla AC-3 (symetria do delegated) | +| `DelegatedInvoiceIssuer` (NOWA, 253 lin.) | idempotentna sciezka Fakturowni: `findByConfigAndExternalOid -> isIssuedDelegatedInvoice -> findRemoteInvoiceByOid -> insertDelegatedPending -> createInvoice -> finalizeDelegatedExternal`, 7x rzucenie `InvoiceIssueException` | + +## Files Modified + +- `src/Modules/Accounting/InvoiceService.php` — przepisany na fasade (118 lin.). +- `src/Modules/Accounting/InvoiceMetadataResolver.php` — nowy (84). +- `src/Modules/Accounting/InvoiceSnapshotBuilder.php` — nowy (223). +- `src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php` — nowy (84). +- `src/Modules/Accounting/LocalInvoiceIssuer.php` — nowy (66, deviation). +- `src/Modules/Accounting/DelegatedInvoiceIssuer.php` — nowy (253). +- `.paul/codebase/architecture.md` — wpis `Accounting` rozszerzony o nowe klasy. +- `.paul/codebase/quality_risks.md` — wpis `InvoiceService.php` oznaczony jako zrefaktorowany. +- `.paul/codebase/tech_changelog.md` — nowy wpis 2026-05-20 (na gorze). +- `.paul/codebase/tooling_status.md` — sekcja post-apply. + +**Bez modyfikacji (boundary):** `InvoiceController.php`, `AccountingModule.php`, `InvoiceRepository.php`, `tests/Unit/FakturowniaInvoiceIdempotencyTest.php`. + +## Acceptance Criteria Results + +| Criterion | Status | Evidence | +|---|---|---| +| AC-1: Fasada zachowuje publiczny kontrakt | ✅ Pass | `php -l` zielony; `grep` potwierdza `InvoiceController.php:97` (`InvoiceService::extractBuyerTaxNumber`) i `AccountingModule.php:66` (`new InvoiceService(...)`) niezmienione; test idempotencji `createService()` montuje 6-arg konstruktor bez zmian | +| AC-2: 4 nowe klasy wyodrebnione | ✅ Pass (+1) | Wszystkie 4 zaplanowane + dodatkowa `LocalInvoiceIssuer` (deviation dla AC-3). `php -l` zielony dla 5/5 nowych klas | +| AC-3: `InvoiceService` <= 160 lin. | ✅ Pass | 118 lin. (poniżej celu z duzym zapasem) | +| AC-4: Test idempotencji bez zmian | ⏳ Pending UAT | `tests/Unit/FakturowniaInvoiceIdempotencyTest.php` zero modyfikacji; logika `issueDelegated` przeniesiona 1:1 do `DelegatedInvoiceIssuer::issue()`. Faktyczne uruchomienie testu wymaga `vendor/phpunit` (niedostepne w sesji) | +| AC-5: Dokumentacja zaktualizowana | ✅ Pass | `architecture.md`, `quality_risks.md`, `tech_changelog.md`, `tooling_status.md` zaktualizowane | + +## Verification Results + +| Check | Result | Notes | +|---|---|---| +| `php -l src/Modules/Accounting/InvoiceService.php` | ✅ Pass | No syntax errors | +| `php -l` x 4 nowe klasy + `LocalInvoiceIssuer` | ✅ Pass | 5/5 No syntax errors | +| `php -l InvoiceController.php` + `AccountingModule.php` | ✅ Pass | konsumenci kompiluja sie z nowym kontraktem | +| Reflection probe (`InvoiceMetadataResolver`, `FakturowniaInvoicePayloadBuilder`) | ✅ Pass | bezstanowe klasy ladowalne bez autoload | +| `wc -l src/Modules/Accounting/InvoiceService.php` | ✅ Pass | 118 lin. (cel <=160) | +| `grep -c "CELOWO" FakturowniaInvoicePayloadBuilder.php` | ✅ Pass | komentarz dot. HTTP 422 zachowany | +| `grep "InvoiceService::extractBuyerTaxNumber" InvoiceController.php` | ✅ Pass | 1 trafienie, niezmienione | +| `grep "new InvoiceService(" AccountingModule.php tests/...` | ✅ Pass | 2 trafienia, oba 6-arg | +| `vendor/bin/phpunit tests/Unit/FakturowniaInvoiceIdempotencyTest.php` | ⏳ Skipped | vendor/phpunit niedostepny — do recznego UAT | +| Smoke `POST /orders/{id}/invoice/create` | ⏳ Skipped | DB niedostepna w sesji — do recznego UAT | + +## Quality Radar Results + +**Status:** degraded (cz. tools niedostepne w sesji APPLY/UNIFY) + +- **codebase-memory-mcp:** uzyte w PLAN (search_graph, lokalizacja konsumentow). Re-index po refaktorze nie uruchomiony — graf zostanie odswiezony przy nastepnym `$paul-map-codebase`. +- **jscpd / ast-grep:** disabled by policy (`.paul/config.md`). +- **vendor/phpunit:** niedostepne w sesji. +- **php (XAMPP):** dostepne, lint zielony dla 7 plikow. + +### Risks delta + +- **Resolved:** `InvoiceService.php` (762 lin.) — usuniety z listy >500 lin. w `quality_risks.md`. +- **New:** brak nowych ryzyk. Wzorzec dekompozycji spojny z poprzednimi refaktorami (DeliveryStatus, AutomationController, AllegroIntegrationController). +- **Deferred:** + - Pelne testy jednostkowe dla `InvoiceMetadataResolver`, `InvoiceSnapshotBuilder`, `LocalInvoiceIssuer` — out of scope. + - Migracja `InvoiceController.php` z `InvoiceService::extractBuyerTaxNumber` na bezposrednie wolanie `InvoiceSnapshotBuilder::extractBuyerTaxNumber` — wstecznie kompatybilny forwarder wystarcza. + - Refaktor `InvoiceController.php` (293 lin. po niniejszych zmianach — przedtem podobnie) i `InvoiceRepository.php` — osobne plany. + +## Deviations + +1. **Dodano 5-ta klase `LocalInvoiceIssuer` (poza planem):** Plan zakladal 4 nowe klasy + `issueLocal` jako prywatna metoda w fasadzie. Po pierwszej iteracji fasada miala 190 lin. (powyzej celu AC-3 <=160). Wyciagniecie `issueLocal` do osobnej klasy `LocalInvoiceIssuer` (66 lin.) dalo symetrie do `DelegatedInvoiceIssuer` i zejscie do 118 lin. fasady. Pozytywny side-effect: identyczna kolejnosc parametrow w obu issuerach ulatwia czytelnosc. + +2. **Incydentalna naprawa pre-existing bugu:** Oryginalny `InvoiceService::issueLocal()` mial 15 parametrow w sygnaturze (w tym nieuzywany `string $externalOid` na pozycji 12), a wywolanie z `issue()` przekazywalo 14 argumentow — co dawalo `ArgumentCountError` przy kazdej probie wystawienia faktury w trybie lokalnym (`is_delegated=0`). Refaktor wprowadzil `LocalInvoiceIssuer::issue` z prawidlowymi 14 parametrami (brak vestigialnego `externalOid`) i spojnym wywolaniem z fasady. Sciezka local teraz wlasciwie dziala, ale nie ma jej w testach — wlasciwy regression test do dolozenia w osobnym planie. **Risk:** uzytkownicy ktorzy probowali wystawic fakture lokalnie (rzadko — wiekszosc uzywa Fakturowni) dotad dostawali blad; teraz dostana faktyczne wystawienie. Sygnalizujemy w changelogu. + +3. **Quality Radar `quality_radar: degraded` zamiast `ok`** zadeklarowane w frontmatter SUMMARY: w sesji UNIFY nie uruchamialismy reindexu codebase-memory-mcp i nie mielismy dostepu do PHPUnit. Lint + reflection wystarczaja do potwierdzenia kontraktu, ale automatyczna walidacja idempotencji nie zostala wykonana. + +## Key Decisions / Patterns + +- **Slim fasada z wstrzyknieciem przez konstruktor:** `InvoiceService` jako orchestrator + 5 wspolpracownikow budowanych w konstruktorze (poor man's DI, brak rejestracji w `ServiceRegistry` — to wewnetrzne szczegoly fasady). Spojne z `AutomationController` (3 wspolpracownikow zarejestrowanych w DI) i `DeliveryStatus` (fasada z prywatnymi mapami). +- **Statyczny forwarder dla wstecznej kompatybilnosci:** `InvoiceService::extractBuyerTaxNumber()` -> `InvoiceSnapshotBuilder::extractBuyerTaxNumber()` zachowuje 1 wolanie z `InvoiceController.php:97` bez modyfikacji kontrolera. Wzorzec do uzycia kiedy migracja konsumenta nie miesci sie w scope refactoru. +- **Komentarze "CELOWO/celowo pominiete" przeniesione 1:1** do `FakturowniaInvoicePayloadBuilder` — to dokumentacja realnych ograniczen API Fakturowni (HTTP 422), nieintuicyjna bez kontekstu. Bez nich kolejny refaktor moglby "naprawic" zachowanie w zly sposob. +- **Preserve, then improve (z 1 wyjatkiem):** ciala metod skopiowane 1:1 (z wyjatkiem buga w sygnaturze `issueLocal`, ktory nie mial jak byc "skopiowany 1:1" bo wywolanie nie pasowalo do deklaracji). Decyzja: poprawic deklaracje sygnatury (lepiej dziala niz nie dziala), odnotowac jako observation. + +## Follow-up + +1. **Reczny UAT (priorytet 1):** + - `vendor/bin/phpunit tests/Unit/FakturowniaInvoiceIdempotencyTest.php` — 3/3 idempotencji. + - Smoke: `POST /orders/{id}/invoice/create` dla konfiguracji `is_delegated=1` (Fakturownia) — sprawdzic, ze faktura jest tworzona i `external_invoice_id`/`external_pdf_url` zapisane. + - Smoke: `POST /orders/{id}/invoice/create` dla konfiguracji `is_delegated=0` (lokalna) — teraz po naprawie sygnatury powinno dzialac. +2. **Commit:** sugerowane podsumowanie `refactor(accounting): dekompozycja InvoiceService 762 -> 118 lin., fasada + 5 wspolpracownikow`. +3. **Kolejny `$paul-plan` z `quality_risks.md`:** + - `src/Modules/Settings/ShopproIntegrationsController.php` (1076 lin.) — kandydat na rozbicie wzorem Allegro. + - `src/Modules/Shipments/ApaczkaShipmentService.php` (1044 lin.). + - `src/Modules/Orders/OrdersController.php` (1490 lin.) — najwiekszy, ale wymaga dyskusji o granicach (notes / payments / invoice toggle). +4. **Regression test dla `LocalInvoiceIssuer`** — osobny mikroplan jezeli sciezka local ma byc oficjalnie wspierana. diff --git a/src/Modules/Accounting/DelegatedInvoiceIssuer.php b/src/Modules/Accounting/DelegatedInvoiceIssuer.php new file mode 100644 index 0000000..1e68fbc --- /dev/null +++ b/src/Modules/Accounting/DelegatedInvoiceIssuer.php @@ -0,0 +1,253 @@ + $config + * @param array $sellerSnapshot + * @param array|null $buyerSnapshot + * @param list> $itemsSnapshot + * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int} + * @throws InvoiceIssueException + */ + public function issue( + int $orderId, + int $configId, + array $config, + string $issueDate, + string $saleDate, + string $paymentDueDate, + array $sellerSnapshot, + ?array $buyerSnapshot, + array $itemsSnapshot, + float $totalNet, + float $totalGross, + string $externalOid, + string $orderReference, + string $kind, + ?int $createdBy + ): array { + $integrationId = (int) ($config['integration_id'] ?? 0); + if ($integrationId <= 0) { + throw new InvoiceIssueException('Konfiguracja delegowana nie wskazuje konta Fakturowni.'); + } + + $account = $this->fakturownia->findByIntegrationId($integrationId); + if ($account === null) { + throw new InvoiceIssueException('Globalna konfiguracja Fakturowni nie istnieje (id=' . $integrationId . ').'); + } + + $prefix = trim((string) ($account['account_prefix'] ?? '')); + if ($prefix === '') { + throw new InvoiceIssueException('Globalna konfiguracja Fakturowni nie ma ustawionego prefiksu (subdomeny).'); + } + + $apiToken = $this->fakturownia->getDecryptedToken($integrationId); + if ($apiToken === null || $apiToken === '') { + throw new InvoiceIssueException('Brak tokenu API dla konta Fakturownia.'); + } + + $payload = $this->payloadBuilder->build( + $kind, + $issueDate, + $saleDate, + $paymentDueDate, + $sellerSnapshot, + $buyerSnapshot, + $itemsSnapshot, + $externalOid, + $orderReference, + (int) ($config['payment_to_days'] ?? 7), + (string) ($account['department_id'] ?? '') + ); + + $apiSettings = [ + 'account_prefix' => $prefix, + 'api_token' => $apiToken, + ]; + $pendingData = $this->buildDelegatedInvoiceData( + $orderId, + $configId, + $issueDate, + $saleDate, + $paymentDueDate, + $sellerSnapshot, + $buyerSnapshot, + $itemsSnapshot, + $totalNet, + $totalGross, + $externalOid, + $kind, + $createdBy + ); + + $local = $this->invoices->findByConfigAndExternalOid($configId, $externalOid); + if ($this->isIssuedDelegatedInvoice($local)) { + return $this->resultFromLocalDelegatedInvoice($local); + } + + $remote = $this->findRemoteInvoiceByOid($apiSettings, $externalOid); + if ($remote !== null) { + return $this->attachRemoteInvoice($local, $pendingData, $remote); + } + + $invoiceId = $local !== null + ? (int) ($local['id'] ?? 0) + : $this->invoices->insertDelegatedPending($pendingData); + + try { + $response = $this->fakturowniaApi->createInvoice($apiSettings, $payload); + } catch (Throwable $e) { + $remote = $this->findRemoteInvoiceByOid($apiSettings, $externalOid); + if ($remote !== null) { + return $this->finalizeDelegatedInvoice($invoiceId, $remote); + } + + $message = 'Fakturownia: ' . $e->getMessage(); + $this->invoices->markDelegatedExternalFailed($invoiceId, $message); + throw new InvoiceIssueException($message . ' Spróbuj ponownie - orderPRO użyje tego samego oid i najpierw sprawdźi Fakturownie.'); + } + + return $this->finalizeDelegatedInvoice($invoiceId, $response); + } + + /** + * @param array $sellerSnapshot + * @param array|null $buyerSnapshot + * @param list> $itemsSnapshot + * @return array + */ + private function buildDelegatedInvoiceData( + int $orderId, + int $configId, + string $issueDate, + string $saleDate, + string $paymentDueDate, + array $sellerSnapshot, + ?array $buyerSnapshot, + array $itemsSnapshot, + float $totalNet, + float $totalGross, + string $externalOid, + string $kind, + ?int $createdBy + ): array { + return [ + 'order_id' => $orderId, + 'config_id' => $configId, + 'issue_date' => $issueDate, + 'sale_date' => $saleDate, + 'payment_due_date' => $paymentDueDate, + 'seller_data_json' => json_encode($sellerSnapshot, JSON_UNESCAPED_UNICODE), + 'buyer_data_json' => $buyerSnapshot !== null ? json_encode($buyerSnapshot, JSON_UNESCAPED_UNICODE) : null, + 'items_json' => json_encode($itemsSnapshot, JSON_UNESCAPED_UNICODE), + 'total_net' => number_format($totalNet, 2, '.', ''), + 'total_gross' => number_format($totalGross, 2, '.', ''), + 'external_oid' => $externalOid, + 'kind' => $kind, + 'created_by' => $createdBy, + ]; + } + + /** + * @param array $settings + * @return array{id: string, number: string, view_url: string, pdf_url: string, raw: array}|null + */ + private function findRemoteInvoiceByOid(array $settings, string $externalOid): ?array + { + try { + return $this->fakturowniaApi->findInvoiceByOid($settings, $externalOid); + } catch (Throwable) { + return null; + } + } + + /** + * @param array|null $local + * @param array $pendingData + * @param array{id: string, number: string, view_url: string, pdf_url: string, raw: array} $remote + * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int} + */ + private function attachRemoteInvoice(?array $local, array $pendingData, array $remote): array + { + $invoiceId = $local !== null + ? (int) ($local['id'] ?? 0) + : $this->invoices->insertDelegatedPending($pendingData); + + return $this->finalizeDelegatedInvoice($invoiceId, $remote); + } + + /** + * @param array{id: string, number: string, view_url: string, pdf_url: string, raw: array} $remote + * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int} + */ + private function finalizeDelegatedInvoice(int $invoiceId, array $remote): array + { + $externalId = trim((string) ($remote['id'] ?? '')); + $externalNumber = trim((string) ($remote['number'] ?? '')); + $externalPdfUrl = trim((string) ($remote['pdf_url'] ?? $remote['view_url'] ?? '')); + + if ($externalId === '' || $externalNumber === '') { + throw new InvoiceIssueException('Fakturownia zwróciła niekompletną odpowiedź (brak id/number).'); + } + + $this->invoices->finalizeDelegatedExternal($invoiceId, [ + 'invoice_number' => $externalNumber, + 'external_invoice_id' => $externalId, + 'external_pdf_url' => $externalPdfUrl !== '' ? $externalPdfUrl : null, + ]); + + $invoice = $this->invoices->findById($invoiceId); + $totalGross = is_array($invoice) ? (float) ($invoice['total_gross'] ?? 0) : 0.0; + + return [ + 'invoice_id' => $invoiceId, + 'invoice_number' => $externalNumber, + 'total_gross' => number_format($totalGross, 2, '.', ''), + 'mode' => 'delegated', + ]; + } + + /** + * @param array|null $invoice + */ + private function isIssuedDelegatedInvoice(?array $invoice): bool + { + if ($invoice === null) { + return false; + } + + return trim((string) ($invoice['external_invoice_id'] ?? '')) !== '' + && trim((string) ($invoice['invoice_number'] ?? '')) !== '' + && (string) ($invoice['external_status'] ?? 'issued') !== 'pending_external'; + } + + /** + * @param array $invoice + * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int} + */ + private function resultFromLocalDelegatedInvoice(array $invoice): array + { + return [ + 'invoice_id' => (int) ($invoice['id'] ?? 0), + 'invoice_number' => (string) ($invoice['invoice_number'] ?? ''), + 'total_gross' => number_format((float) ($invoice['total_gross'] ?? 0), 2, '.', ''), + 'mode' => 'delegated', + ]; + } +} diff --git a/src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php b/src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php new file mode 100644 index 0000000..e5cd995 --- /dev/null +++ b/src/Modules/Accounting/FakturowniaInvoicePayloadBuilder.php @@ -0,0 +1,84 @@ + $sellerSnapshot + * @param array|null $buyerSnapshot + * @param list> $itemsSnapshot + * @return array + */ + public function build( + string $kind, + string $issueDate, + string $saleDate, + string $paymentDueDate, + array $sellerSnapshot, + ?array $buyerSnapshot, + array $itemsSnapshot, + string $externalOid, + string $orderReference, + int $paymentToDays, + string $departmentId + ): array { + $issueDay = substr($issueDate, 0, 10); + $saleDay = substr($saleDate, 0, 10); + $dueDay = substr($paymentDueDate, 0, 10); + + // UWAGA: seller_* pola CELOWO pominięte. Konta Fakturowni z podwyzszonym + // poziomem zabezpieczen interpretuja roznice w seller_name/tax_no/bank + // jako proba "utworzenia nowego dzialu" i odrzucaja request HTTP 422 + // ("Poziom zabezpieczenia przed zmiana konta bankowego nie pozwala na + // utworzenie dzialu"). Fakturownia uzywa wtedy danych sprzedawcy + // zarejestrowanych na koncie (użytkownik IS sprzedawca w Fakturowni). + // Lokalny snapshot `seller_data_json` w tabeli `invoices` zachowuje + // dane orderPRO dla audytu — niezalezne od tego co poszlo do Fakturowni. + $invoice = [ + 'kind' => $kind !== '' ? $kind : 'vat', + 'oid' => $externalOid, + 'issue_date' => $issueDay, + 'sell_date' => $saleDay, + 'payment_to' => $dueDay, + 'positions' => array_map(static function (array $item): array { + return [ + 'name' => (string) ($item['name'] ?? ''), + 'tax' => (float) ($item['vat'] ?? 23), + 'total_price_gross' => number_format((float) ($item['total_gross'] ?? 0), 2, '.', ''), + 'quantity' => (float) ($item['quantity'] ?? 1), + ]; + }, $itemsSnapshot), + ]; + unset($paymentToDays, $sellerSnapshot); + + if ($buyerSnapshot !== null) { + $buyerName = trim((string) ($buyerSnapshot['company_name'] ?? '')); + if ($buyerName === '') { + $buyerName = trim((string) ($buyerSnapshot['name'] ?? '')); + } + $invoice['buyer_name'] = $buyerName; + $invoice['buyer_tax_no'] = (string) ($buyerSnapshot['tax_number'] ?? ''); + $invoice['buyer_street'] = (string) ($buyerSnapshot['street'] ?? ''); + $invoice['buyer_post_code'] = (string) ($buyerSnapshot['postal_code'] ?? ''); + $invoice['buyer_city'] = (string) ($buyerSnapshot['city'] ?? ''); + $invoice['buyer_email'] = (string) ($buyerSnapshot['email'] ?? ''); + } + + $descriptionReference = $orderReference !== '' ? $orderReference : $externalOid; + if ($descriptionReference !== '') { + $invoice['additional_info_desc'] = 'Zamówienie: ' . $descriptionReference; + } + + // department_id celowo pominięte — konta Fakturowni z podwyzszonym + // poziomem zabezpieczen odrzucaja ten parametr przez API (HTTP 422 + // "Poziom zabezpieczenia ... nie pozwala na utworzenie dzialu"). + // Fakturownia uzywa wtedy domyslnego dzialu konta. + unset($departmentId); + + return $invoice; + } +} diff --git a/src/Modules/Accounting/InvoiceMetadataResolver.php b/src/Modules/Accounting/InvoiceMetadataResolver.php new file mode 100644 index 0000000..54957cc --- /dev/null +++ b/src/Modules/Accounting/InvoiceMetadataResolver.php @@ -0,0 +1,84 @@ + $config + * @param array $order + * @param list> $payments + */ + public function resolveSaleDate(array $config, array $order, array $payments, string $issueDate): string + { + $source = (string) ($config['sale_date_source'] ?? 'issue_date'); + + if ($source === 'order_date') { + $ordered = trim((string) ($order['external_created_at'] ?? $order['ordered_at'] ?? '')); + if ($ordered !== '') { + $ts = strtotime($ordered); + return $ts !== false ? date('Y-m-d H:i:s', $ts) : $issueDate; + } + } + + if ($source === 'payment_date' && $payments !== []) { + $payment = $payments[0] ?? []; + $payDate = trim((string) ($payment['payment_date'] ?? '')); + if ($payDate !== '') { + $ts = strtotime($payDate); + return $ts !== false ? date('Y-m-d H:i:s', $ts) : $issueDate; + } + } + + return $issueDate; + } + + public function resolvePaymentDueDate(string $issueDate, int $paymentToDays): string + { + $ts = strtotime($issueDate); + if ($ts === false) { + $ts = time(); + } + return date('Y-m-d 00:00:00', $ts + max(0, $paymentToDays) * 86400); + } + + /** + * @param array $config + * @param array $order + */ + public function resolveOrderReference(array $config, array $order): string + { + $ref = (string) ($config['order_reference'] ?? 'none'); + + if ($ref === 'orderpro') { + return (string) ($order['internal_order_number'] ?? ''); + } + if ($ref === 'integration') { + return (string) ($order['external_order_number'] ?? $order['external_order_id'] ?? ''); + } + return ''; + } + + /** + * @param array $order + */ + public function resolveFakturowniaOid(int $orderId, array $order): string + { + $internalNumber = trim((string) ($order['internal_order_number'] ?? '')); + if ($internalNumber !== '') { + return $internalNumber; + } + + return 'orderpro-' . $orderId; + } +} diff --git a/src/Modules/Accounting/InvoiceService.php b/src/Modules/Accounting/InvoiceService.php index cd38827..aeacc31 100644 --- a/src/Modules/Accounting/InvoiceService.php +++ b/src/Modules/Accounting/InvoiceService.php @@ -8,18 +8,31 @@ use App\Modules\Settings\CompanySettingsRepository; use App\Modules\Settings\FakturowniaApiClient; use App\Modules\Settings\FakturowniaIntegrationRepository; use App\Modules\Settings\InvoiceConfigRepository; -use Throwable; final class InvoiceService { + private readonly InvoiceMetadataResolver $metadata; + private readonly InvoiceSnapshotBuilder $snapshots; + private readonly LocalInvoiceIssuer $local; + private readonly DelegatedInvoiceIssuer $delegated; + public function __construct( - private readonly InvoiceRepository $invoices, + InvoiceRepository $invoices, private readonly InvoiceConfigRepository $invoiceConfigs, - private readonly CompanySettingsRepository $companySettings, + CompanySettingsRepository $companySettings, private readonly OrdersRepository $orders, - private readonly FakturowniaIntegrationRepository $fakturownia, - private readonly FakturowniaApiClient $fakturowniaApi + FakturowniaIntegrationRepository $fakturownia, + FakturowniaApiClient $fakturowniaApi ) { + $this->metadata = new InvoiceMetadataResolver(); + $this->snapshots = new InvoiceSnapshotBuilder($companySettings); + $this->local = new LocalInvoiceIssuer($invoices); + $this->delegated = new DelegatedInvoiceIssuer( + $invoices, + $fakturownia, + $fakturowniaApi, + new FakturowniaInvoicePayloadBuilder() + ); } /** @@ -59,704 +72,47 @@ final class InvoiceService $addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : []; $payments = is_array($details['payments'] ?? null) ? $details['payments'] : []; - $issueDate = $this->resolveIssueDate((string) ($params['issue_date_override'] ?? '')); - $saleDate = $this->resolveSaleDate($config, $order, $payments, $issueDate); - $paymentDueDate = $this->resolvePaymentDueDate($issueDate, (int) ($config['payment_to_days'] ?? 7)); - $orderReference = $this->resolveOrderReference($config, $order); - $externalOid = $this->resolveFakturowniaOid($orderId, $order); + $issueDate = $this->metadata->resolveIssueDate((string) ($params['issue_date_override'] ?? '')); + $saleDate = $this->metadata->resolveSaleDate($config, $order, $payments, $issueDate); + $paymentDueDate = $this->metadata->resolvePaymentDueDate($issueDate, (int) ($config['payment_to_days'] ?? 7)); + $orderReference = $this->metadata->resolveOrderReference($config, $order); + $externalOid = $this->metadata->resolveFakturowniaOid($orderId, $order); - $sellerSnapshot = $this->buildSellerSnapshot(); - $buyerSnapshot = $this->buildBuyerSnapshot($order, $addresses, $params); - ['items' => $itemsSnapshot, 'total_gross' => $totalGross, 'total_net' => $totalNet] = $this->buildItemsSnapshot($items, $order); + $sellerSnapshot = $this->snapshots->buildSellerSnapshot(); + $buyerSnapshot = $this->snapshots->buildBuyerSnapshot($order, $addresses, $params); + ['items' => $itemsSnapshot, 'total_gross' => $totalGross, 'total_net' => $totalNet] = $this->snapshots->buildItemsSnapshot($items, $order); $kind = trim((string) ($config['default_kind'] ?? 'vat')) ?: 'vat'; - $isDelegated = (int) ($config['is_delegated'] ?? 0) === 1; + $createdBy = $params['created_by'] ?? null; - if ($isDelegated) { - return $this->issueDelegated( - $orderId, - $configId, - $config, - $issueDate, - $saleDate, - $paymentDueDate, - $sellerSnapshot, - $buyerSnapshot, - $itemsSnapshot, - $totalNet, - $totalGross, - $externalOid, - $orderReference, - $kind, - $params['created_by'] ?? null + if ((int) ($config['is_delegated'] ?? 0) === 1) { + return $this->delegated->issue( + $orderId, $configId, $config, + $issueDate, $saleDate, $paymentDueDate, + $sellerSnapshot, $buyerSnapshot, $itemsSnapshot, + $totalNet, $totalGross, + $externalOid, $orderReference, $kind, $createdBy ); } - return $this->issueLocal( - $orderId, - $configId, - $config, - $issueDate, - $saleDate, - $paymentDueDate, - $sellerSnapshot, - $buyerSnapshot, - $itemsSnapshot, - $totalNet, - $totalGross, - $orderReference, - $kind, - $params['created_by'] ?? null + return $this->local->issue( + $orderId, $configId, $config, + $issueDate, $saleDate, $paymentDueDate, + $sellerSnapshot, $buyerSnapshot, $itemsSnapshot, + $totalNet, $totalGross, + $orderReference, $kind, $createdBy ); } /** - * @param array $config - * @param array $sellerSnapshot - * @param array|null $buyerSnapshot - * @param list> $itemsSnapshot - * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int} - */ - private function issueLocal( - int $orderId, - int $configId, - array $config, - string $issueDate, - string $saleDate, - string $paymentDueDate, - array $sellerSnapshot, - ?array $buyerSnapshot, - array $itemsSnapshot, - float $totalNet, - float $totalGross, - string $externalOid, - string $orderReference, - string $kind, - ?int $createdBy - ): array { - $invoiceNumber = $this->invoices->nextLocalNumber( - $configId, - (string) ($config['number_format'] ?? 'FV/%N/%M/%Y'), - (string) ($config['numbering_type'] ?? 'monthly') - ); - - $invoiceId = $this->invoices->insertLocal([ - 'order_id' => $orderId, - 'config_id' => $configId, - 'invoice_number' => $invoiceNumber, - 'issue_date' => $issueDate, - 'sale_date' => $saleDate, - 'payment_due_date' => $paymentDueDate, - 'seller_data_json' => json_encode($sellerSnapshot, JSON_UNESCAPED_UNICODE), - 'buyer_data_json' => $buyerSnapshot !== null ? json_encode($buyerSnapshot, JSON_UNESCAPED_UNICODE) : null, - 'items_json' => json_encode($itemsSnapshot, JSON_UNESCAPED_UNICODE), - 'total_net' => number_format($totalNet, 2, '.', ''), - 'total_gross' => number_format($totalGross, 2, '.', ''), - 'order_reference_value' => $orderReference !== '' ? $orderReference : null, - 'kind' => $kind, - 'created_by' => $createdBy, - ]); - - return [ - 'invoice_id' => $invoiceId, - 'invoice_number' => $invoiceNumber, - 'total_gross' => number_format($totalGross, 2, '.', ''), - 'mode' => 'local', - ]; - } - - /** - * @param array $config - * @param array $sellerSnapshot - * @param array|null $buyerSnapshot - * @param list> $itemsSnapshot - * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int} - */ - private function issueDelegated( - int $orderId, - int $configId, - array $config, - string $issueDate, - string $saleDate, - string $paymentDueDate, - array $sellerSnapshot, - ?array $buyerSnapshot, - array $itemsSnapshot, - float $totalNet, - float $totalGross, - string $orderReference, - string $kind, - ?int $createdBy - ): array { - $integrationId = (int) ($config['integration_id'] ?? 0); - if ($integrationId <= 0) { - throw new InvoiceIssueException('Konfiguracja delegowana nie wskazuje konta Fakturowni.'); - } - - $account = $this->fakturownia->findByIntegrationId($integrationId); - if ($account === null) { - throw new InvoiceIssueException('Globalna konfiguracja Fakturowni nie istnieje (id=' . $integrationId . ').'); - } - - $prefix = trim((string) ($account['account_prefix'] ?? '')); - if ($prefix === '') { - throw new InvoiceIssueException('Globalna konfiguracja Fakturowni nie ma ustawionego prefiksu (subdomeny).'); - } - - $apiToken = $this->fakturownia->getDecryptedToken($integrationId); - if ($apiToken === null || $apiToken === '') { - throw new InvoiceIssueException('Brak tokenu API dla konta Fakturownia.'); - } - - $payload = $this->buildFakturowniaPayload( - $kind, - $issueDate, - $saleDate, - $paymentDueDate, - $sellerSnapshot, - $buyerSnapshot, - $itemsSnapshot, - $externalOid, - $orderReference, - (int) ($config['payment_to_days'] ?? 7), - (string) ($account['department_id'] ?? '') - ); - - $apiSettings = [ - 'account_prefix' => $prefix, - 'api_token' => $apiToken, - ]; - $pendingData = $this->buildDelegatedInvoiceData( - $orderId, - $configId, - $issueDate, - $saleDate, - $paymentDueDate, - $sellerSnapshot, - $buyerSnapshot, - $itemsSnapshot, - $totalNet, - $totalGross, - $externalOid, - $kind, - $createdBy - ); - - $local = $this->invoices->findByConfigAndExternalOid($configId, $externalOid); - if ($this->isIssuedDelegatedInvoice($local)) { - return $this->resultFromLocalDelegatedInvoice($local); - } - - $remote = $this->findRemoteInvoiceByOid($apiSettings, $externalOid); - if ($remote !== null) { - return $this->attachRemoteInvoice($local, $pendingData, $remote); - } - - $invoiceId = $local !== null - ? (int) ($local['id'] ?? 0) - : $this->invoices->insertDelegatedPending($pendingData); - - try { - $response = $this->fakturowniaApi->createInvoice($apiSettings, $payload); - } catch (Throwable $e) { - $remote = $this->findRemoteInvoiceByOid($apiSettings, $externalOid); - if ($remote !== null) { - return $this->finalizeDelegatedInvoice($invoiceId, $remote); - } - - $message = 'Fakturownia: ' . $e->getMessage(); - $this->invoices->markDelegatedExternalFailed($invoiceId, $message); - throw new InvoiceIssueException($message . ' Spróbuj ponownie - orderPRO użyje tego samego oid i najpierw sprawdźi Fakturownie.'); - } - - return $this->finalizeDelegatedInvoice($invoiceId, $response); - } - - private function resolveIssueDate(string $override): string - { - $override = trim($override); - if ($override !== '' && strtotime($override) !== false) { - return date('Y-m-d H:i:s', (int) strtotime($override)); - } - return date('Y-m-d H:i:s'); - } - - /** - * @param array $config - * @param array $order - * @param list> $payments - */ - private function resolveSaleDate(array $config, array $order, array $payments, string $issueDate): string - { - $source = (string) ($config['sale_date_source'] ?? 'issue_date'); - - if ($source === 'order_date') { - $ordered = trim((string) ($order['external_created_at'] ?? $order['ordered_at'] ?? '')); - if ($ordered !== '') { - $ts = strtotime($ordered); - return $ts !== false ? date('Y-m-d H:i:s', $ts) : $issueDate; - } - } - - if ($source === 'payment_date' && $payments !== []) { - $payment = $payments[0] ?? []; - $payDate = trim((string) ($payment['payment_date'] ?? '')); - if ($payDate !== '') { - $ts = strtotime($payDate); - return $ts !== false ? date('Y-m-d H:i:s', $ts) : $issueDate; - } - } - - return $issueDate; - } - - private function resolvePaymentDueDate(string $issueDate, int $paymentToDays): string - { - $ts = strtotime($issueDate); - if ($ts === false) { - $ts = time(); - } - return date('Y-m-d 00:00:00', $ts + max(0, $paymentToDays) * 86400); - } - - /** - * @param array $config - * @param array $order - */ - private function resolveOrderReference(array $config, array $order): string - { - $ref = (string) ($config['order_reference'] ?? 'none'); - - if ($ref === 'orderpro') { - return (string) ($order['internal_order_number'] ?? ''); - } - if ($ref === 'integration') { - return (string) ($order['external_order_number'] ?? $order['external_order_id'] ?? ''); - } - return ''; - } - - /** - * @param array $order - */ - private function resolveFakturowniaOid(int $orderId, array $order): string - { - $internalNumber = trim((string) ($order['internal_order_number'] ?? '')); - if ($internalNumber !== '') { - return $internalNumber; - } - - return 'orderpro-' . $orderId; - } - - /** - * @return array - */ - private function buildSellerSnapshot(): array - { - $seller = $this->companySettings->getSettings(); - - return [ - 'company_name' => $seller['company_name'] ?? '', - 'tax_number' => $seller['tax_number'] ?? '', - 'street' => $seller['street'] ?? '', - 'city' => $seller['city'] ?? '', - 'postal_code' => $seller['postal_code'] ?? '', - 'phone' => $seller['phone'] ?? '', - 'email' => $seller['email'] ?? '', - 'bank_account' => $seller['bank_account'] ?? '', - 'bdo_number' => $seller['bdo_number'] ?? '', - 'regon' => $seller['regon'] ?? '', - 'court_register' => $seller['court_register'] ?? '', - ]; - } - - /** - * @param array $order - * @param list> $addresses - * @param array $params - * @return array|null - */ - private function buildBuyerSnapshot(array $order, array $addresses, array $params): ?array - { - $byType = []; - foreach ($addresses as $addr) { - $type = (string) ($addr['address_type'] ?? ''); - if ($type !== '' && !isset($byType[$type])) { - $byType[$type] = $addr; - } - } - $buyerAddress = $byType['invoice'] ?? $byType['customer'] ?? null; - - $autoTaxNumber = self::extractBuyerTaxNumber($order, $buyerAddress); - - $manualTax = trim((string) ($params['buyer_tax_number'] ?? '')); - $manualName = trim((string) ($params['buyer_name'] ?? '')); - $manualCompany = trim((string) ($params['buyer_company_name'] ?? '')); - $manualStreet = trim((string) ($params['buyer_street'] ?? '')); - $manualCity = trim((string) ($params['buyer_city'] ?? '')); - $manualPostal = trim((string) ($params['buyer_postal_code'] ?? '')); - $manualEmail = trim((string) ($params['buyer_email'] ?? '')); - - $name = $manualName !== '' - ? $manualName - : trim((string) ($buyerAddress['name'] ?? '')); - $company = $manualCompany !== '' - ? $manualCompany - : trim((string) ($buyerAddress['company_name'] ?? '')); - $street = $manualStreet !== '' - ? $manualStreet - : trim(((string) ($buyerAddress['street_name'] ?? '')) . ' ' . ((string) ($buyerAddress['street_number'] ?? ''))); - $city = $manualCity !== '' - ? $manualCity - : trim((string) ($buyerAddress['city'] ?? '')); - $postal = $manualPostal !== '' - ? $manualPostal - : trim((string) ($buyerAddress['zip_code'] ?? '')); - $email = $manualEmail !== '' - ? $manualEmail - : trim((string) ($buyerAddress['email'] ?? $order['buyer_email'] ?? '')); - $taxNumber = $manualTax !== '' ? $manualTax : $autoTaxNumber; - - if ($name === '' && $company === '' && $taxNumber === '' && $street === '') { - return null; - } - - return [ - 'name' => $name, - 'company_name' => $company, - 'tax_number' => $taxNumber, - 'street' => $street, - 'city' => $city, - 'postal_code' => $postal, - 'phone' => trim((string) ($buyerAddress['phone'] ?? '')), - 'email' => $email, - ]; - } - - /** - * Extract NIP from various payload locations (Allegro, shopPRO, Erli). + * Forwarder do `InvoiceSnapshotBuilder::extractBuyerTaxNumber()` — zachowany dla + * kompatybilnosci ze statycznym wolaniem z `InvoiceController`. * * @param array $order * @param array|null $buyerAddress */ public static function extractBuyerTaxNumber(array $order, ?array $buyerAddress): string { - if ($buyerAddress !== null) { - $candidate = trim((string) ($buyerAddress['company_tax_number'] ?? '')); - if ($candidate !== '') { - return $candidate; - } - } - - $payload = $order['payload_json'] ?? null; - if (is_string($payload) && $payload !== '') { - $decoded = json_decode($payload, true); - if (is_array($decoded)) { - foreach (self::taxNumberPaths() as $path) { - $value = self::digValue($decoded, $path); - if (is_string($value) && trim($value) !== '') { - return trim($value); - } - } - } - } - - return ''; - } - - /** - * @return list> - */ - private static function taxNumberPaths(): array - { - return [ - ['invoice', 'address', 'taxId'], - ['invoice', 'taxId'], - ['invoice', 'nip'], - ['buyer', 'tax_number'], - ['buyer', 'nip'], - ['client', 'nip'], - ['client', 'tax_number'], - ['nip'], - ['tax_number'], - ]; - } - - /** - * @param array $arr - * @param list $path - */ - private static function digValue(array $arr, array $path): mixed - { - $cur = $arr; - foreach ($path as $key) { - if (!is_array($cur) || !array_key_exists($key, $cur)) { - return null; - } - $cur = $cur[$key]; - } - return $cur; - } - - /** - * @param list> $items - * @param array $order - * @return array{items: list>, total_gross: float, total_net: float} - */ - private function buildItemsSnapshot(array $items, array $order): array - { - $itemsSnapshot = []; - $totalGross = 0.0; - $totalNet = 0.0; - - foreach ($items as $item) { - $qty = (float) ($item['quantity'] ?? 0); - $price = $item['original_price_with_tax'] !== null ? (float) $item['original_price_with_tax'] : (float) ($item['price_gross'] ?? 0); - $vat = (float) ($item['vat'] ?? 23); - $lineGross = $qty * $price; - $lineNet = $vat > 0 ? round($lineGross / (1 + $vat / 100), 2) : $lineGross; - $totalGross += $lineGross; - $totalNet += $lineNet; - - $itemsSnapshot[] = [ - 'name' => $item['original_name'] ?? $item['name'] ?? '', - 'quantity' => $qty, - 'price_gross' => $price, - 'price_net' => $vat > 0 ? round($price / (1 + $vat / 100), 2) : $price, - 'vat' => $vat, - 'total_gross' => round($lineGross, 2), - 'total_net' => round($lineNet, 2), - 'sku' => $item['sku'] ?? '', - 'ean' => $item['ean'] ?? '', - ]; - } - - $deliveryPrice = (float) ($order['delivery_price'] ?? 0); - if ($deliveryPrice > 0) { - $deliveryVat = 23.0; - $deliveryNet = round($deliveryPrice / (1 + $deliveryVat / 100), 2); - $totalGross += $deliveryPrice; - $totalNet += $deliveryNet; - $itemsSnapshot[] = [ - 'name' => 'Koszt wysyłki', - 'quantity' => 1.0, - 'price_gross' => $deliveryPrice, - 'price_net' => $deliveryNet, - 'vat' => $deliveryVat, - 'total_gross' => round($deliveryPrice, 2), - 'total_net' => round($deliveryNet, 2), - 'sku' => '', - 'ean' => '', - ]; - } - - return [ - 'items' => $itemsSnapshot, - 'total_gross' => round($totalGross, 2), - 'total_net' => round($totalNet, 2), - ]; - } - - /** - * Build Fakturownia API payload (https://app.fakturownia.pl/api). - * - * @param array $sellerSnapshot - * @param array|null $buyerSnapshot - * @param list> $itemsSnapshot - * @return array - */ - private function buildFakturowniaPayload( - string $kind, - string $issueDate, - string $saleDate, - string $paymentDueDate, - array $sellerSnapshot, - ?array $buyerSnapshot, - array $itemsSnapshot, - string $externalOid, - string $orderReference, - int $paymentToDays, - string $departmentId - ): array { - $issueDay = substr($issueDate, 0, 10); - $saleDay = substr($saleDate, 0, 10); - $dueDay = substr($paymentDueDate, 0, 10); - - // UWAGA: seller_* pola CELOWO pominięte. Konta Fakturowni z podwyzszonym - // poziomem zabezpieczen interpretuja roznice w seller_name/tax_no/bank - // jako proba "utworzenia nowego dzialu" i odrzucaja request HTTP 422 - // ("Poziom zabezpieczenia przed zmiana konta bankowego nie pozwala na - // utworzenie dzialu"). Fakturownia uzywa wtedy danych sprzedawcy - // zarejestrowanych na koncie (użytkownik IS sprzedawca w Fakturowni). - // Lokalny snapshot `seller_data_json` w tabeli `invoices` zachowuje - // dane orderPRO dla audytu — niezalezne od tego co poszlo do Fakturowni. - $invoice = [ - 'kind' => $kind !== '' ? $kind : 'vat', - 'oid' => $externalOid, - 'issue_date' => $issueDay, - 'sell_date' => $saleDay, - 'payment_to' => $dueDay, - 'positions' => array_map(static function (array $item): array { - return [ - 'name' => (string) ($item['name'] ?? ''), - 'tax' => (float) ($item['vat'] ?? 23), - 'total_price_gross' => number_format((float) ($item['total_gross'] ?? 0), 2, '.', ''), - 'quantity' => (float) ($item['quantity'] ?? 1), - ]; - }, $itemsSnapshot), - ]; - unset($paymentToDays, $sellerSnapshot); - - if ($buyerSnapshot !== null) { - $buyerName = trim((string) ($buyerSnapshot['company_name'] ?? '')); - if ($buyerName === '') { - $buyerName = trim((string) ($buyerSnapshot['name'] ?? '')); - } - $invoice['buyer_name'] = $buyerName; - $invoice['buyer_tax_no'] = (string) ($buyerSnapshot['tax_number'] ?? ''); - $invoice['buyer_street'] = (string) ($buyerSnapshot['street'] ?? ''); - $invoice['buyer_post_code'] = (string) ($buyerSnapshot['postal_code'] ?? ''); - $invoice['buyer_city'] = (string) ($buyerSnapshot['city'] ?? ''); - $invoice['buyer_email'] = (string) ($buyerSnapshot['email'] ?? ''); - } - - $descriptionReference = $orderReference !== '' ? $orderReference : $externalOid; - if ($descriptionReference !== '') { - $invoice['additional_info_desc'] = 'Zamówienie: ' . $descriptionReference; - } - - // department_id celowo pominięte — konta Fakturowni z podwyzszonym - // poziomem zabezpieczen odrzucaja ten parametr przez API (HTTP 422 - // "Poziom zabezpieczenia ... nie pozwala na utworzenie dzialu"). - // Fakturownia uzywa wtedy domyslnego dzialu konta. - unset($departmentId); - - return $invoice; - } - - /** - * @param array $sellerSnapshot - * @param array|null $buyerSnapshot - * @param list> $itemsSnapshot - * @return array - */ - private function buildDelegatedInvoiceData( - int $orderId, - int $configId, - string $issueDate, - string $saleDate, - string $paymentDueDate, - array $sellerSnapshot, - ?array $buyerSnapshot, - array $itemsSnapshot, - float $totalNet, - float $totalGross, - string $externalOid, - string $kind, - ?int $createdBy - ): array { - return [ - 'order_id' => $orderId, - 'config_id' => $configId, - 'issue_date' => $issueDate, - 'sale_date' => $saleDate, - 'payment_due_date' => $paymentDueDate, - 'seller_data_json' => json_encode($sellerSnapshot, JSON_UNESCAPED_UNICODE), - 'buyer_data_json' => $buyerSnapshot !== null ? json_encode($buyerSnapshot, JSON_UNESCAPED_UNICODE) : null, - 'items_json' => json_encode($itemsSnapshot, JSON_UNESCAPED_UNICODE), - 'total_net' => number_format($totalNet, 2, '.', ''), - 'total_gross' => number_format($totalGross, 2, '.', ''), - 'external_oid' => $externalOid, - 'kind' => $kind, - 'created_by' => $createdBy, - ]; - } - - /** - * @param array $settings - * @return array{id: string, number: string, view_url: string, pdf_url: string, raw: array}|null - */ - private function findRemoteInvoiceByOid(array $settings, string $externalOid): ?array - { - try { - return $this->fakturowniaApi->findInvoiceByOid($settings, $externalOid); - } catch (Throwable) { - return null; - } - } - - /** - * @param array|null $local - * @param array $pendingData - * @param array{id: string, number: string, view_url: string, pdf_url: string, raw: array} $remote - * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int} - */ - private function attachRemoteInvoice(?array $local, array $pendingData, array $remote): array - { - $invoiceId = $local !== null - ? (int) ($local['id'] ?? 0) - : $this->invoices->insertDelegatedPending($pendingData); - - return $this->finalizeDelegatedInvoice($invoiceId, $remote); - } - - /** - * @param array{id: string, number: string, view_url: string, pdf_url: string, raw: array} $remote - * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int} - */ - private function finalizeDelegatedInvoice(int $invoiceId, array $remote): array - { - $externalId = trim((string) ($remote['id'] ?? '')); - $externalNumber = trim((string) ($remote['number'] ?? '')); - $externalPdfUrl = trim((string) ($remote['pdf_url'] ?? $remote['view_url'] ?? '')); - - if ($externalId === '' || $externalNumber === '') { - throw new InvoiceIssueException('Fakturownia zwróciła niekompletną odpowiedź (brak id/number).'); - } - - $this->invoices->finalizeDelegatedExternal($invoiceId, [ - 'invoice_number' => $externalNumber, - 'external_invoice_id' => $externalId, - 'external_pdf_url' => $externalPdfUrl !== '' ? $externalPdfUrl : null, - ]); - - $invoice = $this->invoices->findById($invoiceId); - $totalGross = is_array($invoice) ? (float) ($invoice['total_gross'] ?? 0) : 0.0; - - return [ - 'invoice_id' => $invoiceId, - 'invoice_number' => $externalNumber, - 'total_gross' => number_format($totalGross, 2, '.', ''), - 'mode' => 'delegated', - ]; - } - - /** - * @param array|null $invoice - */ - private function isIssuedDelegatedInvoice(?array $invoice): bool - { - if ($invoice === null) { - return false; - } - - return trim((string) ($invoice['external_invoice_id'] ?? '')) !== '' - && trim((string) ($invoice['invoice_number'] ?? '')) !== '' - && (string) ($invoice['external_status'] ?? 'issued') !== 'pending_external'; - } - - /** - * @param array $invoice - * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int} - */ - private function resultFromLocalDelegatedInvoice(array $invoice): array - { - return [ - 'invoice_id' => (int) ($invoice['id'] ?? 0), - 'invoice_number' => (string) ($invoice['invoice_number'] ?? ''), - 'total_gross' => number_format((float) ($invoice['total_gross'] ?? 0), 2, '.', ''), - 'mode' => 'delegated', - ]; + return InvoiceSnapshotBuilder::extractBuyerTaxNumber($order, $buyerAddress); } } diff --git a/src/Modules/Accounting/InvoiceSnapshotBuilder.php b/src/Modules/Accounting/InvoiceSnapshotBuilder.php new file mode 100644 index 0000000..0b5ed04 --- /dev/null +++ b/src/Modules/Accounting/InvoiceSnapshotBuilder.php @@ -0,0 +1,223 @@ + + */ + public function buildSellerSnapshot(): array + { + $seller = $this->companySettings->getSettings(); + + return [ + 'company_name' => $seller['company_name'] ?? '', + 'tax_number' => $seller['tax_number'] ?? '', + 'street' => $seller['street'] ?? '', + 'city' => $seller['city'] ?? '', + 'postal_code' => $seller['postal_code'] ?? '', + 'phone' => $seller['phone'] ?? '', + 'email' => $seller['email'] ?? '', + 'bank_account' => $seller['bank_account'] ?? '', + 'bdo_number' => $seller['bdo_number'] ?? '', + 'regon' => $seller['regon'] ?? '', + 'court_register' => $seller['court_register'] ?? '', + ]; + } + + /** + * @param array $order + * @param list> $addresses + * @param array $params + * @return array|null + */ + public function buildBuyerSnapshot(array $order, array $addresses, array $params): ?array + { + $byType = []; + foreach ($addresses as $addr) { + $type = (string) ($addr['address_type'] ?? ''); + if ($type !== '' && !isset($byType[$type])) { + $byType[$type] = $addr; + } + } + $buyerAddress = $byType['invoice'] ?? $byType['customer'] ?? null; + + $autoTaxNumber = self::extractBuyerTaxNumber($order, $buyerAddress); + + $manualTax = trim((string) ($params['buyer_tax_number'] ?? '')); + $manualName = trim((string) ($params['buyer_name'] ?? '')); + $manualCompany = trim((string) ($params['buyer_company_name'] ?? '')); + $manualStreet = trim((string) ($params['buyer_street'] ?? '')); + $manualCity = trim((string) ($params['buyer_city'] ?? '')); + $manualPostal = trim((string) ($params['buyer_postal_code'] ?? '')); + $manualEmail = trim((string) ($params['buyer_email'] ?? '')); + + $name = $manualName !== '' + ? $manualName + : trim((string) ($buyerAddress['name'] ?? '')); + $company = $manualCompany !== '' + ? $manualCompany + : trim((string) ($buyerAddress['company_name'] ?? '')); + $street = $manualStreet !== '' + ? $manualStreet + : trim(((string) ($buyerAddress['street_name'] ?? '')) . ' ' . ((string) ($buyerAddress['street_number'] ?? ''))); + $city = $manualCity !== '' + ? $manualCity + : trim((string) ($buyerAddress['city'] ?? '')); + $postal = $manualPostal !== '' + ? $manualPostal + : trim((string) ($buyerAddress['zip_code'] ?? '')); + $email = $manualEmail !== '' + ? $manualEmail + : trim((string) ($buyerAddress['email'] ?? $order['buyer_email'] ?? '')); + $taxNumber = $manualTax !== '' ? $manualTax : $autoTaxNumber; + + if ($name === '' && $company === '' && $taxNumber === '' && $street === '') { + return null; + } + + return [ + 'name' => $name, + 'company_name' => $company, + 'tax_number' => $taxNumber, + 'street' => $street, + 'city' => $city, + 'postal_code' => $postal, + 'phone' => trim((string) ($buyerAddress['phone'] ?? '')), + 'email' => $email, + ]; + } + + /** + * Extract NIP from various payload locations (Allegro, shopPRO, Erli). + * + * @param array $order + * @param array|null $buyerAddress + */ + public static function extractBuyerTaxNumber(array $order, ?array $buyerAddress): string + { + if ($buyerAddress !== null) { + $candidate = trim((string) ($buyerAddress['company_tax_number'] ?? '')); + if ($candidate !== '') { + return $candidate; + } + } + + $payload = $order['payload_json'] ?? null; + if (is_string($payload) && $payload !== '') { + $decoded = json_decode($payload, true); + if (is_array($decoded)) { + foreach (self::taxNumberPaths() as $path) { + $value = self::digValue($decoded, $path); + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + } + } + } + + return ''; + } + + /** + * @return list> + */ + private static function taxNumberPaths(): array + { + return [ + ['invoice', 'address', 'taxId'], + ['invoice', 'taxId'], + ['invoice', 'nip'], + ['buyer', 'tax_number'], + ['buyer', 'nip'], + ['client', 'nip'], + ['client', 'tax_number'], + ['nip'], + ['tax_number'], + ]; + } + + /** + * @param array $arr + * @param list $path + */ + private static function digValue(array $arr, array $path): mixed + { + $cur = $arr; + foreach ($path as $key) { + if (!is_array($cur) || !array_key_exists($key, $cur)) { + return null; + } + $cur = $cur[$key]; + } + return $cur; + } + + /** + * @param list> $items + * @param array $order + * @return array{items: list>, total_gross: float, total_net: float} + */ + public function buildItemsSnapshot(array $items, array $order): array + { + $itemsSnapshot = []; + $totalGross = 0.0; + $totalNet = 0.0; + + foreach ($items as $item) { + $qty = (float) ($item['quantity'] ?? 0); + $price = $item['original_price_with_tax'] !== null ? (float) $item['original_price_with_tax'] : (float) ($item['price_gross'] ?? 0); + $vat = (float) ($item['vat'] ?? 23); + $lineGross = $qty * $price; + $lineNet = $vat > 0 ? round($lineGross / (1 + $vat / 100), 2) : $lineGross; + $totalGross += $lineGross; + $totalNet += $lineNet; + + $itemsSnapshot[] = [ + 'name' => $item['original_name'] ?? $item['name'] ?? '', + 'quantity' => $qty, + 'price_gross' => $price, + 'price_net' => $vat > 0 ? round($price / (1 + $vat / 100), 2) : $price, + 'vat' => $vat, + 'total_gross' => round($lineGross, 2), + 'total_net' => round($lineNet, 2), + 'sku' => $item['sku'] ?? '', + 'ean' => $item['ean'] ?? '', + ]; + } + + $deliveryPrice = (float) ($order['delivery_price'] ?? 0); + if ($deliveryPrice > 0) { + $deliveryVat = 23.0; + $deliveryNet = round($deliveryPrice / (1 + $deliveryVat / 100), 2); + $totalGross += $deliveryPrice; + $totalNet += $deliveryNet; + $itemsSnapshot[] = [ + 'name' => 'Koszt wysyłki', + 'quantity' => 1.0, + 'price_gross' => $deliveryPrice, + 'price_net' => $deliveryNet, + 'vat' => $deliveryVat, + 'total_gross' => round($deliveryPrice, 2), + 'total_net' => round($deliveryNet, 2), + 'sku' => '', + 'ean' => '', + ]; + } + + return [ + 'items' => $itemsSnapshot, + 'total_gross' => round($totalGross, 2), + 'total_net' => round($totalNet, 2), + ]; + } +} diff --git a/src/Modules/Accounting/LocalInvoiceIssuer.php b/src/Modules/Accounting/LocalInvoiceIssuer.php new file mode 100644 index 0000000..81c55fe --- /dev/null +++ b/src/Modules/Accounting/LocalInvoiceIssuer.php @@ -0,0 +1,66 @@ + $config + * @param array $sellerSnapshot + * @param array|null $buyerSnapshot + * @param list> $itemsSnapshot + * @return array{invoice_number: string, total_gross: string, mode: string, invoice_id: int} + */ + public function issue( + int $orderId, + int $configId, + array $config, + string $issueDate, + string $saleDate, + string $paymentDueDate, + array $sellerSnapshot, + ?array $buyerSnapshot, + array $itemsSnapshot, + float $totalNet, + float $totalGross, + string $orderReference, + string $kind, + ?int $createdBy + ): array { + $invoiceNumber = $this->invoices->nextLocalNumber( + $configId, + (string) ($config['number_format'] ?? 'FV/%N/%M/%Y'), + (string) ($config['numbering_type'] ?? 'monthly') + ); + + $invoiceId = $this->invoices->insertLocal([ + 'order_id' => $orderId, + 'config_id' => $configId, + 'invoice_number' => $invoiceNumber, + 'issue_date' => $issueDate, + 'sale_date' => $saleDate, + 'payment_due_date' => $paymentDueDate, + 'seller_data_json' => json_encode($sellerSnapshot, JSON_UNESCAPED_UNICODE), + 'buyer_data_json' => $buyerSnapshot !== null ? json_encode($buyerSnapshot, JSON_UNESCAPED_UNICODE) : null, + 'items_json' => json_encode($itemsSnapshot, JSON_UNESCAPED_UNICODE), + 'total_net' => number_format($totalNet, 2, '.', ''), + 'total_gross' => number_format($totalGross, 2, '.', ''), + 'order_reference_value' => $orderReference !== '' ? $orderReference : null, + 'kind' => $kind, + 'created_by' => $createdBy, + ]); + + return [ + 'invoice_id' => $invoiceId, + 'invoice_number' => $invoiceNumber, + 'total_gross' => number_format($totalGross, 2, '.', ''), + 'mode' => 'local', + ]; + } +}