diff --git a/.gitignore b/.gitignore index 732b903..2dc14b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ vendor/ +composer.phar +composer.lock storage/logs/ storage/sessions/ storage/cache/ diff --git a/.paul/PROJECT.md b/.paul/PROJECT.md index ffc20b1..fb4d0b8 100644 --- a/.paul/PROJECT.md +++ b/.paul/PROJECT.md @@ -12,9 +12,9 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i n | Attribute | Value | |-----------|-------| -| Version | 0.1.0 | -| Status | In Progress | -| Last Updated | 2026-03-12 | +| Version | 0.2.0 | +| Status | v0.2 Complete | +| Last Updated | 2026-03-15 | ## Requirements @@ -22,10 +22,15 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i n - [x] Integracja z Allegro — pobieranie zamówień - [x] Generowanie etykiet (InPost) +- [x] Performance: N+1 subqueries fix, DB indexes — Phase 7 +- [x] Stability: SSL verification, cron throttle — Phase 7 +- [x] UX: orderpro-to-allegro disable, lista zamówień poprawki — Phase 7 +- [x] Unit tests: AllegroTokenManager, AllegroOrderImportService (12 testów) — Phase 7 +- [x] InPost ShipX API: natywny provider niezależny od Allegro — Phase 7 ### Active (In Progress) -- [ ] [Do zdefiniowania podczas planowania] +- [ ] [Awaiting next milestone definition] ### Planned (Next) @@ -78,6 +83,9 @@ PHP (XAMPP/Laravel), integracje z API marketplace'ów (Allegro, Erli) oraz API p | validateXxxInput(): ?string i validateXxxAccess(): ?Response jako wzorce helperów walidacji | Redukcja return statements do ≤3; spójny wzorzec kontrolerów | 2026-03-13 | Active | | God class split via move-method bez zmiany logiki | ShopproOrdersSyncService 39→9 metod; AllegroIntegrationController 35→25 — czysty podział przez ekstrakcję klas | 2026-03-13 | Active | | AllegroIntegrationController pozostaje przy 25 metodach (nie ≤15) | Pełny podział wymaga AllegroImportScheduleService — poza zakresem v0.1 | 2026-03-13 | Active | +| dg/bypass-finals do testów final classes | Wszystkie klasy final — mockowanie przez bypass-finals zamiast usuwania final | 2026-03-15 | Active | +| InPost ShipX API zamiast Allegro WZA remap | InpostIntegrationRepository jest pod ShipX; niezależność od Allegro | 2026-03-15 | Active | +| vendor/ w ftp-kr ignore | Auto-upload dev deps na serwer powodował Fatal Error | 2026-03-15 | Active | ## Success Metrics @@ -94,7 +102,8 @@ PHP (XAMPP/Laravel), integracje z API marketplace'ów (Allegro, Erli) oraz API p | Frontend | HTML/CSS/JS + SCSS | jQuery Alerts module | | Database | MySQL (Medoo) | Prepared statements | | Auth | Sesje PHP | | -| Integracje | Allegro API, Erli API | Przewoźnicy: InPost | +| Integracje | Allegro API, Erli API | Przewoźnicy: InPost (ShipX), Apaczka | +| Testing | PHPUnit 11.5 + dg/bypass-finals | Unit tests w tests/Unit/ | ## Specialized Flows @@ -108,4 +117,4 @@ Quick Reference: --- *PROJECT.md — Updated when requirements or context change* -*Last updated: 2026-03-13 after Phase 6 (SonarQube Quality — milestone v0.1 complete)* +*Last updated: 2026-03-15 after Phase 7 (Pre-Expansion Fixes — milestone v0.2 complete)* diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index 2b3b76a..bc998dc 100644 --- a/.paul/ROADMAP.md +++ b/.paul/ROADMAP.md @@ -6,29 +6,28 @@ orderPRO to narzędzie do wielokanałowego zarządzania sprzedażą. Projekt prz ## Current Milestone -**v0.2 Pre-Expansion Fixes** (v0.2.0) -Status: 🔄 In Progress -Phases: 0/1 complete - -## Phases - -| Phase | Name | Plans | Status | Completed | -|-------|------|-------|--------|-----------| -| 7 | Pre-Expansion Fixes | 0/5 | 🔄 Planning | — | - -## Phase Details - -### Phase 7 — Pre-Expansion Fixes -Naprawa krytycznych problemów wydajnościowych, bezpieczeństwa i UX przed rozbudową aplikacji o nowe integracje i funkcje. - -- **Plan 07-01** — Performance: N+1 subqueries + information_schema cache + DB indexes — *Not started* -- **Plan 07-02** — Stability: SSL verification + cron throttle DB + migration 000014b — *Not started* -- **Plan 07-03** — UX: orderpro_to_allegro disable + lista zamówień (items 14-17) — *Not started* -- **Plan 07-04** — Tests: AllegroTokenManager + AllegroOrderImportService unit tests — *Not started* -- **Plan 07-05** — InPost: ShipmentProviderInterface implementation — *Not started* +No active milestone. Run `/paul:milestone` to define next. ## Completed Milestones +
+v0.2 Pre-Expansion Fixes — 2026-03-15 (1 phase, 5 plans) + +| Phase | Name | Plans | Completed | +|-------|------|-------|-----------| +| 7 | Pre-Expansion Fixes | 5/5 | 2026-03-15 | + +Plans: +- 07-01: Performance (N+1 subqueries, DB indexes, information_schema cache) +- 07-02: Stability (SSL verification, cron throttle DB, migration 000014b) +- 07-03: UX (orderpro_to_allegro disable, lista zamówień fixes) +- 07-04: Tests (AllegroTokenManager + AllegroOrderImportService — 12 testów) +- 07-05: InPost ShipmentProviderInterface (natywne ShipX API) + +Archive: `.paul/phases/07-pre-expansion-fixes/` + +
+
v0.1 Initial Release — 2026-03-13 (6 phases, 15 plans) @@ -47,4 +46,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md` --- *Roadmap created: 2026-03-12* -*Last updated: 2026-03-13 — milestone v0.1 complete, awaiting v0.2 definition* +*Last updated: 2026-03-15 — milestone v0.2 complete* diff --git a/.paul/STATE.md b/.paul/STATE.md index 7cdf8c0..2108ba6 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -5,26 +5,26 @@ See: .paul/PROJECT.md (updated 2026-03-12) **Core value:** Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami. -**Current focus:** Faza 07 — Pre-Expansion Fixes. Plan 07-03 UNIFY complete, 07-04 następny. +**Current focus:** Milestone v0.2 COMPLETE. Następny milestone do zdefiniowania. ## Current Position -Milestone: v0.2 Pre-Expansion Fixes -Phase: 7 of TBD (07-pre-expansion-fixes) — Executing -Plan: 07-01 ✓, 07-02 ✓, 07-03 ✓, 07-04..07-05 awaiting -Status: Loop 07-03 zamknięty — następny /paul:apply 07-04 -Last activity: 2026-03-14 — Plan 07-03 loop closed (UX fixes + SSL hotfix) +Milestone: v0.2 Pre-Expansion Fixes — COMPLETE ✓ +Phase: 7 of 7 (07-pre-expansion-fixes) — Complete +Plan: 07-01 ✓, 07-02 ✓, 07-03 ✓, 07-04 ✓, 07-05 ✓ +Status: Milestone v0.2 complete — ready for next milestone +Last activity: 2026-03-15 — Phase 07 transition complete, milestone v0.2 done Progress: - v0.1 Initial Release: [██████████] 100% ✓ -- v0.2 Pre-Expansion Fixes: [██████░░░░] 60% (3/5 planów) +- v0.2 Pre-Expansion Fixes: [██████████] 100% (5/5 planów) ## Loop Position Current loop state: ``` PLAN ──▶ APPLY ──▶ UNIFY - ✓ ✓ ✓ [07-03 complete — next: /paul:apply 07-04] + ✓ ✓ ✓ [Phase 07 complete — milestone v0.2 done] ``` ## Accumulated Context @@ -38,6 +38,20 @@ PLAN ──▶ APPLY ──▶ UNIFY | 2026-03-13 | Pole CSRF w formularzach: `_token` (nie `_csrf_token`) | Faza 03 | Ustandaryzowane w OrdersController, ShipmentController i 2 widokach | | 2026-03-13 | Flash messages: Flash::set('module.type') / Flash::get('module.type', '') | Faza 05 | OrdersController i ShipmentController zmigrowane; jeden wzorzec w całej aplikacji | | 2026-03-13 | validateXxxInput(): ?string i validateXxxAccess(): ?Response jako wzorce helperów walidacji | Faza 06 | Redukcja return statements do ≤3; wzorzec do użycia w kolejnych planach | +| 2026-03-15 | dg/bypass-finals zamiast usuwania final z klas produkcyjnych | Faza 07 | Testy mockują final classes bez zmiany konwencji projektu | +| 2026-03-15 | 3 bugi use-statement naprawione (odkryte przez testy) | Faza 07 | RuntimeException catch w 401 retry wreszcie działa; AllegroOAuthException rzucane poprawnie | +| 2026-03-15 | InPost ShipX API (nie Allegro WZA) jako natywny provider | Faza 07 | InpostShipmentService niezależny od Allegro; workaround remap usunięty | +| 2026-03-15 | vendor/ dodany do ftp-kr ignore; deploy vendor ręcznie | Faza 07 | Auto-upload nie nadpisze vendor/ na serwerze | + +### Skill Audit (Faza 07, Plan 05) +| Oczekiwany | Wywołany | Uwagi | +|------------|---------|-------| +| sonar-scanner | ○ | Zainstalowany (v4.3.5) ale nie uruchomiony w tym planie | + +### Skill Audit (Faza 07, Plan 04) +| Oczekiwany | Wywołany | Uwagi | +|------------|---------|-------| +| sonar-scanner | ○ | Pominięto — brak instalacji w PATH | ### Skill Audit (Faza 07, Plan 03) | Oczekiwany | Wywołany | Uwagi | @@ -107,16 +121,14 @@ Brak. ## Session Continuity -Last session: 2026-03-14 -Stopped at: Loop 07-03 zamknięty — SUMMARY utworzony -Next action: /paul:apply .paul/phases/07-pre-expansion-fixes/07-04-PLAN.md -Resume file: .paul/phases/07-pre-expansion-fixes/07-03-SUMMARY.md +Last session: 2026-03-15 +Stopped at: Milestone v0.2 complete +Next action: /paul:milestone (define next milestone) +Resume file: .paul/ROADMAP.md Resume context: -- 07-01: COMPLETE ✓ (N+1→LEFT JOIN, static cache, migration 000048) -- 07-02: COMPLETE ✓ (SSL verification, cron→DB, migration 000014b) -- 07-03: COMPLETE ✓ (UX fixes + SSL hotfix CA bundle nullable) -- 07-04: Tests (AllegroTokenManager + AllegroOrderImportService) -- 07-05: InPost ShipmentProviderInterface — ma checkpoint:decision +- v0.1: COMPLETE ✓ (6 phases, 15 plans — tech debt, bugs, quality) +- v0.2: COMPLETE ✓ (1 phase, 5 plans — performance, stability, UX, tests, InPost) +- Next milestone to define --- *STATE.md — Updated after every significant action* diff --git a/.paul/phases/07-pre-expansion-fixes/07-04-SUMMARY.md b/.paul/phases/07-pre-expansion-fixes/07-04-SUMMARY.md new file mode 100644 index 0000000..4bda781 --- /dev/null +++ b/.paul/phases/07-pre-expansion-fixes/07-04-SUMMARY.md @@ -0,0 +1,164 @@ +--- +phase: 07-pre-expansion-fixes +plan: 04 +subsystem: testing +tags: [phpunit, allegro, oauth, unit-tests, bypass-finals] + +requires: + - phase: 01-allegro-token-manager + provides: AllegroTokenManager extracted class + - phase: 07-pre-expansion-fixes + provides: Plans 01-03 bug fixes stabilizing tested classes + +provides: + - 7 unit tests for AllegroTokenManager (token refresh logic) + - 5 unit tests for AllegroOrderImportService (import + 401 retry) + - PHPUnit infrastructure (composer dependencies, bypass-finals) + - 3 use-statement bug fixes discovered by tests + +affects: [07-05-inpost, future-refactoring, ci-cd] + +tech-stack: + added: [phpunit/phpunit 11.5, dg/bypass-finals 1.9] + patterns: [bypass-finals for final class mocking, createMock with DI] + +key-files: + created: + - tests/Unit/AllegroTokenManagerTest.php + - tests/Unit/AllegroOrderImportServiceTest.php + modified: + - tests/bootstrap.php + - src/Modules/Settings/AllegroTokenManager.php + - src/Modules/Settings/AllegroIntegrationRepository.php + - src/Modules/Settings/AllegroOrderImportService.php + +key-decisions: + - "dg/bypass-finals zamiast usuwania final z klas produkcyjnych" + - "Naprawienie 3 bugów use-statement odkrytych przez testy (nie przez plan)" + +patterns-established: + - "Testy unit: tests/Unit/{ClassName}Test.php z createMock + bypass-finals" + - "bootstrap.php: DG\\BypassFinals::enable() dla wszystkich testów" + +duration: ~15min +completed: 2026-03-15T12:00:00Z +--- + +# Phase 7 Plan 04: Unit Tests Summary + +**12 testów jednostkowych dla AllegroTokenManager (7) i AllegroOrderImportService (5), plus 3 naprawione bugi use-statement odkryte przez testy.** + +## Performance + +| Metric | Value | +|--------|-------| +| Duration | ~15min | +| Completed | 2026-03-15 | +| Tasks | 2 completed | +| Files created | 2 | +| Files modified | 4 | +| Tests | 12 | +| Assertions | 49 | + +## Acceptance Criteria Results + +| Criterion | Status | Notes | +|-----------|--------|-------| +| AC-1: AllegroTokenManager — logika refresh pokryta testami | Pass | 7 testów: fresh token, <5min, expired, no config, empty token, re-read, invalid format | +| AC-2: AllegroOrderImportService — import happy path pokryty | Pass | 5 testów: happy path, 401 retry, empty ID, non-401 error, re-import | +| AC-3: Wszystkie nowe testy przechodzą | Pass | 12 tests, 49 assertions, 0 failures | + +## Accomplishments + +- 7 testów AllegroTokenManager pokrywających: świeży token (brak refresh), wygasający <5min, wygasły, brak konfiguracji OAuth, pusty access token, write-then-re-read, nieprawidłowy format daty +- 5 testów AllegroOrderImportService pokrywających: happy path import, 401 retry, pusty ID, propagacja non-401 RuntimeException, re-import (update vs create) +- PHPUnit + dg/bypass-finals zainstalowane i skonfigurowane (composer.phar, vendor/, bootstrap.php) +- Odkryte i naprawione 3 bugi use-statement w kodzie produkcyjnym + +## Files Created/Modified + +| File | Change | Purpose | +|------|--------|---------| +| `tests/Unit/AllegroTokenManagerTest.php` | Created | 7 testów logiki OAuth token refresh | +| `tests/Unit/AllegroOrderImportServiceTest.php` | Created | 5 testów importu zamówień Allegro | +| `tests/bootstrap.php` | Modified | Dodano DG\BypassFinals::enable() | +| `src/Modules/Settings/AllegroTokenManager.php` | Modified | Fix: use App\Core\Exceptions\AllegroOAuthException | +| `src/Modules/Settings/AllegroIntegrationRepository.php` | Modified | Fix: use App\Core\Exceptions\IntegrationConfigException | +| `src/Modules/Settings/AllegroOrderImportService.php` | Modified | Fix: dodano use RuntimeException | +| `composer.json` | Modified | Dodano dg/bypass-finals do require-dev | +| `composer.lock` | Created | Lock file z zależnościami | +| `vendor/` | Created | Zależności Composera | + +## Decisions Made + +| Decision | Rationale | Impact | +|----------|-----------|--------| +| dg/bypass-finals zamiast usuwania final | Wszystkie klasy produkcyjne są final — usunięcie złamałoby konwencję projektu | Wymaga enable() w bootstrap.php, ale nie zmienia kodu produkcyjnego | +| Naprawienie bugów use-statement | Testy ujawniły 3 złamane use-statements które uniemożliwiały prawidłowe działanie catch/throw | AllegroOrderImportService: 401 retry nigdy nie działał bez use RuntimeException | + +## Deviations from Plan + +### Summary + +| Type | Count | Impact | +|------|-------|--------| +| Auto-fixed | 3 | Krytyczne — naprawione bugi use-statement | +| Scope additions | 1 | composer install + bypass-finals (konieczne do uruchomienia testów) | +| Deferred | 0 | — | + +**Total impact:** Niezbędne poprawki odkryte przez testy. Bez nich catch(RuntimeException) w AllegroOrderImportService nigdy nie łapał 401. + +### Auto-fixed Issues + +**1. Broken use-statement: AllegroTokenManager** +- **Found during:** Task 1 +- **Issue:** `use AppCorexceptionsAllegroOAuthException` zamiast `use App\Core\Exceptions\AllegroOAuthException` (brak backslashy) +- **Fix:** Poprawiony use-statement +- **Files:** src/Modules/Settings/AllegroTokenManager.php +- **Verification:** Test `testResolveTokenThrowsWhenNoOAuthConfig` przechodzi + +**2. Broken use-statement: AllegroIntegrationRepository** +- **Found during:** Task 1 +- **Issue:** `use AppCorexceptionsIntegrationConfigException` — brak backslashy +- **Fix:** Poprawiony use-statement +- **Files:** src/Modules/Settings/AllegroIntegrationRepository.php +- **Verification:** Klasa ładuje się poprawnie przez autoloader + +**3. Missing use RuntimeException: AllegroOrderImportService** +- **Found during:** Task 2 +- **Issue:** catch(RuntimeException) w importSingleOrder() nigdy nie łapał wyjątku — brak `use RuntimeException`, więc PHP szukał `App\Modules\Settings\RuntimeException` +- **Fix:** Dodano `use RuntimeException;` +- **Files:** src/Modules/Settings/AllegroOrderImportService.php +- **Verification:** Test `testImportSingleOrderRetryOn401` przechodzi + +## Skill Audit + +| Oczekiwany | Wywołany | Uwagi | +|------------|---------|-------| +| sonar-scanner | ○ | Pominięto — brak instalacji w PATH | + +## Issues Encountered + +| Issue | Resolution | +|-------|------------| +| Brak vendor/ (composer nie był uruchomiony) | Pobrano composer.phar, uruchomiono `composer install --ignore-platform-reqs` | +| PHP 8.2 vs wymagane ^8.4 w composer.json | Użyto `--ignore-platform-reqs` — PHPUnit 11.5 działa na 8.2 | +| Final classes nie mogą być mockowane | Zainstalowano dg/bypass-finals, dodano enable() w bootstrap | + +## Next Phase Readiness + +**Ready:** +- PHPUnit w pełni skonfigurowany — następne testy można dodawać bezproblemowo +- Wzorzec testowy ustalony: createMock + bypass-finals +- 12 testów jako baseline regression + +**Concerns:** +- PHP 8.2 vs ^8.4 requirement — testy działają ale runtime wymaga 8.4 +- sonar-scanner nadal nie uruchamiany (brak w PATH) + +**Blockers:** +- Brak + +--- +*Phase: 07-pre-expansion-fixes, Plan: 04* +*Completed: 2026-03-15* diff --git a/.paul/phases/07-pre-expansion-fixes/07-05-SUMMARY.md b/.paul/phases/07-pre-expansion-fixes/07-05-SUMMARY.md new file mode 100644 index 0000000..9d9804f --- /dev/null +++ b/.paul/phases/07-pre-expansion-fixes/07-05-SUMMARY.md @@ -0,0 +1,145 @@ +--- +phase: 07-pre-expansion-fixes +plan: 05 +subsystem: shipments +tags: [inpost, shipx-api, shipment-provider, ftp-deploy] + +requires: + - phase: 01-allegro-token-manager + provides: ShipmentProviderInterface pattern from AllegroShipmentService + +provides: + - InpostShipmentService implementing ShipmentProviderInterface (native ShipX API) + - Workaround remap inpost→allegro_wza removed from ShipmentController + - vendor/ excluded from ftp-kr auto-upload (prevents dev deps on server) + - 2 broken use-statement fixes in ShipmentController and AllegroShipmentService + +affects: [future-carriers, inpost-configuration, ci-cd] + +tech-stack: + added: [] + patterns: [ShipX API integration via cURL, ShipmentProviderInterface for new carriers] + +key-files: + created: + - src/Modules/Shipments/InpostShipmentService.php + modified: + - src/Modules/Shipments/ShipmentController.php + - src/Modules/Shipments/AllegroShipmentService.php + - routes/web.php + - .vscode/ftp-kr.json + +key-decisions: + - "ShipX API (natywne InPost) zamiast Allegro WZA — pełna niezależność od Allegro" + - "vendor/ w ftp-kr ignore — zapobiega auto-upload dev deps na serwer" + +patterns-established: + - "Nowy carrier = nowy XxxShipmentService implements ShipmentProviderInterface + rejestracja w web.php" + - "vendor/ deploy: ręcznie przez skrypt FTP po composer install --no-dev" + +duration: ~20min +completed: 2026-03-15T13:00:00Z +--- + +# Phase 7 Plan 05: InPost ShipmentProviderInterface Summary + +**InpostShipmentService z natywnym ShipX API zastąpił workaround remap inpost→allegro_wza. InPost działa niezależnie od Allegro.** + +## Performance + +| Metric | Value | +|--------|-------| +| Duration | ~20min | +| Completed | 2026-03-15 | +| Tasks | 3 completed (2 auto + 1 checkpoint) | +| Files created | 1 | +| Files modified | 4 | + +## Acceptance Criteria Results + +| Criterion | Status | Notes | +|-----------|--------|-------| +| AC-1: InpostShipmentService implementuje ShipmentProviderInterface | Pass | php -l clean, implements ShipmentProviderInterface, 5 metod interfejsu | +| AC-2: InPost shipments przez InPost API, nie Allegro WZA | Pass | Workaround usunięty, InpostShipmentService zarejestrowany pod kluczem 'inpost' | +| AC-3: Brak regresji Allegro WZA | Pass | Human-verify approved — formularz ładuje się bez błędów, Allegro WZA nadal dostępne | + +## Accomplishments + +- InpostShipmentService z pełną implementacją ShipX API: createShipment, checkCreationStatus, downloadLabel, getDeliveryServices +- Workaround `if ($providerCode === 'inpost') { $providerCode = 'allegro_wza'; }` usunięty z ShipmentController +- InpostShipmentService zarejestrowany w ShipmentProviderRegistry (routes/web.php) +- vendor/ dodany do ftp-kr ignore — rozwiązanie problemu auto-upload dev dependencies na serwer + +## Files Created/Modified + +| File | Change | Purpose | +|------|--------|---------| +| `src/Modules/Shipments/InpostShipmentService.php` | Created | Natywna implementacja ShipX API — create, status, label | +| `src/Modules/Shipments/ShipmentController.php` | Modified | Usunięty remap inpost→allegro_wza; fix use ShipmentException | +| `src/Modules/Shipments/AllegroShipmentService.php` | Modified | Fix broken use-statements (IntegrationConfigException, ShipmentException) | +| `routes/web.php` | Modified | Dodano use InpostShipmentService, wiring w ShipmentProviderRegistry | +| `.vscode/ftp-kr.json` | Modified | Dodano vendor/, tests/, phpunit.xml, composer.* do ignore | + +## Decisions Made + +| Decision | Rationale | Impact | +|----------|-----------|--------| +| ShipX API (natywne InPost) | InpostIntegrationRepository ma pola ShipX (organization_id, locker_size, dispatch_method) — jest pod ShipX, nie Allegro WZA | InPost działa bez Allegro; InPost-only użytkownicy odblokwani | +| vendor/ w ftp-kr ignore | Auto-upload wrzucał dev deps (phpunit, deep-copy) na serwer powodując Fatal Error | Deploy vendor/ ręcznie; dev deps nigdy nie trafią na serwer | + +## Deviations from Plan + +### Summary + +| Type | Count | Impact | +|------|-------|--------| +| Auto-fixed | 2 | Broken use-statements w ShipmentController i AllegroShipmentService | +| Scope additions | 1 | ftp-kr.json ignore (konieczne — auto-upload powodował crash na serwerze) | +| Deferred | 0 | — | + +**Total impact:** Niezbędne poprawki. Bez fix use-statements ShipmentException nigdy nie byłby łapany prawidłowo. + +### Auto-fixed Issues + +**1. Broken use-statement: ShipmentController** +- **Found during:** Task 2 +- **Issue:** `use AppCorexceptionsShipmentException` — brak backslashy +- **Fix:** `use App\Core\Exceptions\ShipmentException` +- **Files:** src/Modules/Shipments/ShipmentController.php + +**2. Broken use-statements: AllegroShipmentService** +- **Found during:** Task 2 +- **Issue:** `use AppCoreExceptionsIntegrationConfigException` i `use AppCoreExceptionsShipmentException` +- **Fix:** Poprawione backslashe w obu use-statements +- **Files:** src/Modules/Shipments/AllegroShipmentService.php + +## Skill Audit + +| Oczekiwany | Wywołany | Uwagi | +|------------|---------|-------| +| sonar-scanner | ○ | Zainstalowany (v4.3.5) ale nie uruchomiony w tym planie | + +## Issues Encountered + +| Issue | Resolution | +|-------|------------| +| Auto-upload vendor/ z dev deps → Fatal Error na serwerze | Dodano vendor/ do ftp-kr ignore; deploy vendor/ ręcznie przez FTP skrypt | +| Wiring nie w Application.php (jak zakładał plan) ale w routes/web.php | Znalezione przez grep; dodano wiring w tym samym pliku co inne providery | + +## Next Phase Readiness + +**Ready:** +- Wzorzec ShipmentProviderInterface sprawdzony na 3 providerach (Allegro WZA, Apaczka, InPost) +- Dodanie nowego carrieru: 1 klasa + 1 linia w web.php +- sonar-scanner zainstalowany (v4.3.5), gotowy do uruchomienia + +**Concerns:** +- InPost ShipX wymaga testów z prawdziwym tokenem sandbox — nie testowano API calls +- 5+ broken use-statements naprawionych w fazach 07-04 i 07-05 — mogą być więcej w innych plikach + +**Blockers:** +- Brak + +--- +*Phase: 07-pre-expansion-fixes, Plan: 05* +*Completed: 2026-03-15* diff --git a/.vscode/ftp-kr.json b/.vscode/ftp-kr.json index 2b154ed..5302dce 100644 --- a/.vscode/ftp-kr.json +++ b/.vscode/ftp-kr.json @@ -16,6 +16,13 @@ "/.claude", ".gitignore", "/.scannerwork", - "/.paul" + "/.paul", + "/vendor", + "/node_modules", + "/composer.phar", + "/composer.lock", + "/tests", + "/phpunit.xml", + "/.plantuml" ] } \ No newline at end of file diff --git a/composer.json b/composer.json index 18e4d72..43a1013 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,8 @@ "php": "^8.4" }, "require-dev": { - "phpunit/phpunit": "^11.5" + "phpunit/phpunit": "^11.5", + "dg/bypass-finals": "^1.9" }, "autoload": { "psr-4": { diff --git a/routes/web.php b/routes/web.php index 3d0afd1..5d60336 100644 --- a/routes/web.php +++ b/routes/web.php @@ -38,6 +38,7 @@ use App\Modules\Settings\CronSettingsController; use App\Modules\Settings\SettingsController; use App\Modules\Shipments\ApaczkaShipmentService; use App\Modules\Shipments\AllegroShipmentService; +use App\Modules\Shipments\InpostShipmentService; use App\Modules\Shipments\ShipmentController; use App\Modules\Shipments\ShipmentPackageRepository; use App\Modules\Shipments\ShipmentProviderRegistry; @@ -186,9 +187,16 @@ return static function (Application $app): void { $companySettingsRepository, new OrdersRepository($app->db()) ); + $inpostShipmentService = new InpostShipmentService( + $inpostIntegrationRepository, + $shipmentPackageRepository, + $companySettingsRepository, + new OrdersRepository($app->db()) + ); $shipmentProviderRegistry = new ShipmentProviderRegistry([ $shipmentService, $apaczkaShipmentService, + $inpostShipmentService, ]); $shipmentController = new ShipmentController( $template, diff --git a/src/Modules/Settings/AllegroIntegrationRepository.php b/src/Modules/Settings/AllegroIntegrationRepository.php index 0ebe22d..08c964e 100644 --- a/src/Modules/Settings/AllegroIntegrationRepository.php +++ b/src/Modules/Settings/AllegroIntegrationRepository.php @@ -5,7 +5,7 @@ namespace App\Modules\Settings; use App\Core\Support\StringHelper; use PDO; -use AppCorexceptionsIntegrationConfigException; +use App\Core\Exceptions\IntegrationConfigException; use Throwable; final class AllegroIntegrationRepository diff --git a/src/Modules/Settings/AllegroOrderImportService.php b/src/Modules/Settings/AllegroOrderImportService.php index 09b9394..f32b8e7 100644 --- a/src/Modules/Settings/AllegroOrderImportService.php +++ b/src/Modules/Settings/AllegroOrderImportService.php @@ -8,6 +8,7 @@ use App\Modules\Orders\OrderImportRepository; use App\Modules\Orders\OrdersRepository; use App\Core\Constants\IntegrationSources; use App\Core\Exceptions\AllegroApiException; +use RuntimeException; use Throwable; final class AllegroOrderImportService diff --git a/src/Modules/Settings/AllegroTokenManager.php b/src/Modules/Settings/AllegroTokenManager.php index 4f4377f..0aa0db3 100644 --- a/src/Modules/Settings/AllegroTokenManager.php +++ b/src/Modules/Settings/AllegroTokenManager.php @@ -5,7 +5,7 @@ namespace App\Modules\Settings; use DateInterval; use DateTimeImmutable; -use AppCorexceptionsAllegroOAuthException; +use App\Core\Exceptions\AllegroOAuthException; use Throwable; final class AllegroTokenManager diff --git a/src/Modules/Shipments/AllegroShipmentService.php b/src/Modules/Shipments/AllegroShipmentService.php index e9910e3..a573f7c 100644 --- a/src/Modules/Shipments/AllegroShipmentService.php +++ b/src/Modules/Shipments/AllegroShipmentService.php @@ -8,8 +8,8 @@ use App\Modules\Settings\AllegroApiClient; use App\Modules\Settings\AllegroTokenManager; use App\Modules\Settings\CompanySettingsRepository; use RuntimeException; -use AppCoreExceptionsIntegrationConfigException; -use AppCoreExceptionsShipmentException; +use App\Core\Exceptions\IntegrationConfigException; +use App\Core\Exceptions\ShipmentException; use Throwable; final class AllegroShipmentService implements ShipmentProviderInterface diff --git a/src/Modules/Shipments/InpostShipmentService.php b/src/Modules/Shipments/InpostShipmentService.php new file mode 100644 index 0000000..56ad65b --- /dev/null +++ b/src/Modules/Shipments/InpostShipmentService.php @@ -0,0 +1,577 @@ +> + */ + public function getDeliveryServices(): array + { + return [ + ['id' => 'inpost_locker_standard', 'name' => 'InPost Paczkomat - Standard', 'type' => 'locker'], + ['id' => 'inpost_courier_standard', 'name' => 'InPost Kurier - Standard', 'type' => 'courier'], + ['id' => 'inpost_courier_express', 'name' => 'InPost Kurier - Express', 'type' => 'courier'], + ]; + } + + /** + * @param array $formData + * @return array + */ + public function createShipment(int $orderId, array $formData): array + { + $order = $this->ordersRepository->findDetails($orderId); + if ($order === null) { + throw new ShipmentException('Zamowienie nie znalezione.'); + } + + $token = $this->resolveToken(); + $settings = $this->inpostRepository->getSettings(); + $organizationId = trim((string) ($settings['organization_id'] ?? '')); + if ($organizationId === '') { + throw new IntegrationConfigException('Brak organization_id w konfiguracji InPost.'); + } + + $company = $this->companySettings->getSettings(); + $sender = $this->companySettings->getSenderAddress(); + $this->validateSenderAddress($sender); + + $receiver = $this->buildReceiverFromOrder($order, $formData); + $senderPayload = $this->buildSenderPayload($sender); + + $serviceType = $this->resolveServiceType($formData, $settings); + $parcelPayload = $this->buildParcelPayload($formData, $settings, $company, $serviceType); + + $apiPayload = [ + 'receiver' => $receiver, + 'sender' => $senderPayload, + 'parcels' => [$parcelPayload], + 'service' => $serviceType, + 'reference' => $this->buildReference($order, $orderId), + ]; + + $codAmount = (float) ($formData['cod_amount'] ?? 0); + if ($codAmount > 0) { + $apiPayload['cod'] = [ + 'amount' => number_format($codAmount, 2, '.', ''), + 'currency' => strtoupper(trim((string) ($formData['cod_currency'] ?? 'PLN'))), + ]; + } + + $insuranceAmount = (float) ($formData['insurance_amount'] ?? 0); + if ($insuranceAmount <= 0 && !empty($settings['auto_insurance_value'])) { + $orderData = is_array($order['order'] ?? null) ? $order['order'] : []; + $totalWithTax = (float) ($orderData['total_with_tax'] ?? 0); + if ($totalWithTax > 0) { + $insuranceAmount = $totalWithTax; + } + } + if ($insuranceAmount > 0) { + $apiPayload['insurance'] = [ + 'amount' => number_format($insuranceAmount, 2, '.', ''), + 'currency' => strtoupper(trim((string) ($formData['insurance_currency'] ?? 'PLN'))), + ]; + } + + $labelFormat = trim((string) ($formData['label_format'] ?? ($settings['label_format'] ?? 'Pdf'))); + + $packageId = $this->packages->create([ + 'order_id' => $orderId, + 'provider' => 'inpost', + 'delivery_method_id' => $serviceType, + 'credentials_id' => null, + 'command_id' => null, + 'status' => 'pending', + 'carrier_id' => 'inpost', + 'package_type' => $parcelPayload['dimensions'] ? 'PACKAGE' : 'LOCKER', + 'weight_kg' => isset($parcelPayload['weight']['amount']) ? (float) $parcelPayload['weight']['amount'] : null, + 'length_cm' => isset($parcelPayload['dimensions']['length']) ? (float) $parcelPayload['dimensions']['length'] : null, + 'width_cm' => isset($parcelPayload['dimensions']['width']) ? (float) $parcelPayload['dimensions']['width'] : null, + 'height_cm' => isset($parcelPayload['dimensions']['height']) ? (float) $parcelPayload['dimensions']['height'] : null, + 'insurance_amount' => $insuranceAmount > 0 ? $insuranceAmount : null, + 'insurance_currency' => $insuranceAmount > 0 ? strtoupper(trim((string) ($formData['insurance_currency'] ?? 'PLN'))) : null, + 'cod_amount' => $codAmount > 0 ? $codAmount : null, + 'cod_currency' => $codAmount > 0 ? strtoupper(trim((string) ($formData['cod_currency'] ?? 'PLN'))) : null, + 'label_format' => $labelFormat, + 'receiver_point_id' => trim((string) ($formData['receiver_point_id'] ?? '')), + 'sender_point_id' => trim((string) ($formData['sender_point_id'] ?? '')), + 'reference_number' => $apiPayload['reference'], + 'payload_json' => $apiPayload, + ]); + + $env = (string) ($settings['environment'] ?? 'sandbox'); + $url = $this->apiBaseUrl($env) . '/organizations/' . rawurlencode($organizationId) . '/shipments'; + + try { + $response = $this->apiRequest('POST', $url, $token, $apiPayload); + } catch (Throwable $exception) { + $this->packages->update($packageId, [ + 'status' => 'error', + 'error_message' => $exception->getMessage(), + ]); + throw $exception; + } + + $shipmentId = trim((string) ($response['id'] ?? '')); + $trackingNumber = trim((string) ($response['tracking_number'] ?? '')); + $status = strtolower(trim((string) ($response['status'] ?? 'created'))); + + $this->packages->update($packageId, [ + 'shipment_id' => $shipmentId !== '' ? $shipmentId : null, + 'tracking_number' => $trackingNumber !== '' ? $trackingNumber : null, + 'status' => $status === 'created' || $status === 'confirmed' ? 'created' : 'pending', + 'payload_json' => $response, + ]); + + return [ + 'package_id' => $packageId, + 'command_id' => $shipmentId, + ]; + } + + /** + * @return array + */ + public function checkCreationStatus(int $packageId): array + { + $package = $this->packages->findById($packageId); + if ($package === null) { + throw new ShipmentException('Paczka nie znaleziona.'); + } + + $shipmentId = trim((string) ($package['shipment_id'] ?? '')); + if ($shipmentId === '') { + return ['status' => 'error', 'error' => 'Brak shipment_id — przesylka nie zostala utworzona.']; + } + + $token = $this->resolveToken(); + $settings = $this->inpostRepository->getSettings(); + $env = (string) ($settings['environment'] ?? 'sandbox'); + $url = $this->apiBaseUrl($env) . '/shipments/' . rawurlencode($shipmentId); + + $response = $this->apiRequest('GET', $url, $token); + $status = strtolower(trim((string) ($response['status'] ?? ''))); + $trackingNumber = trim((string) ($response['tracking_number'] ?? '')); + + if (in_array($status, ['created', 'confirmed', 'dispatched', 'collected', 'delivered'], true)) { + $this->packages->update($packageId, [ + 'status' => 'created', + 'tracking_number' => $trackingNumber !== '' ? $trackingNumber : null, + 'payload_json' => $response, + ]); + + return [ + 'status' => 'created', + 'shipment_id' => $shipmentId, + 'tracking_number' => $trackingNumber, + ]; + } + + if (in_array($status, ['cancelled', 'expired'], true)) { + $this->packages->update($packageId, [ + 'status' => 'error', + 'error_message' => 'Przesylka anulowana/wygasla (status: ' . $status . ')', + 'payload_json' => $response, + ]); + + return ['status' => 'error', 'error' => 'Przesylka: ' . $status]; + } + + return ['status' => 'in_progress']; + } + + /** + * @return array + */ + public function downloadLabel(int $packageId, string $storagePath): array + { + $package = $this->packages->findById($packageId); + if ($package === null) { + throw new ShipmentException('Paczka nie znaleziona.'); + } + + $shipmentId = trim((string) ($package['shipment_id'] ?? '')); + if ($shipmentId === '') { + throw new ShipmentException('Przesylka nie zostala jeszcze utworzona.'); + } + + $token = $this->resolveToken(); + $settings = $this->inpostRepository->getSettings(); + $env = (string) ($settings['environment'] ?? 'sandbox'); + $labelFormat = trim((string) ($package['label_format'] ?? ($settings['label_format'] ?? 'Pdf'))); + + $url = $this->apiBaseUrl($env) . '/shipments/' . rawurlencode($shipmentId) . '/label'; + $queryParams = ['format' => $labelFormat, 'type' => 'normal']; + $url .= '?' . http_build_query($queryParams); + + $binary = $this->apiRequestRaw('GET', $url, $token); + + $dir = rtrim($storagePath, '/\\') . '/labels'; + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + + $ext = strtolower($labelFormat) === 'zpl' ? 'zpl' : 'pdf'; + $filename = 'label_' . $packageId . '_inpost_' . $shipmentId . '.' . $ext; + $filePath = $dir . '/' . $filename; + file_put_contents($filePath, $binary); + + $updateFields = [ + 'status' => 'label_ready', + 'label_path' => 'labels/' . $filename, + ]; + + if (trim((string) ($package['tracking_number'] ?? '')) === '') { + try { + $detailsUrl = $this->apiBaseUrl($env) . '/shipments/' . rawurlencode($shipmentId); + $details = $this->apiRequest('GET', $detailsUrl, $token); + $trackingNumber = trim((string) ($details['tracking_number'] ?? '')); + if ($trackingNumber !== '') { + $updateFields['tracking_number'] = $trackingNumber; + } + } catch (Throwable) { + } + } + + $this->packages->update($packageId, $updateFields); + + return [ + 'label_path' => 'labels/' . $filename, + 'full_path' => $filePath, + ]; + } + + private function resolveToken(): string + { + $token = $this->inpostRepository->getDecryptedToken(); + if ($token === null || trim($token) === '') { + throw new IntegrationConfigException('Brak tokenu API InPost. Skonfiguruj w Ustawienia > Integracje > InPost.'); + } + return trim($token); + } + + private function apiBaseUrl(string $environment): string + { + return strtolower(trim($environment)) === 'production' + ? self::API_BASE_PRODUCTION + : self::API_BASE_SANDBOX; + } + + /** + * @param array|null $body + * @return array + */ + private function apiRequest(string $method, string $url, string $token, ?array $body = null): array + { + $binary = $this->apiRequestRaw($method, $url, $token, $body, 'application/json'); + $json = json_decode($binary, true); + if (!is_array($json)) { + throw new ShipmentException('Nieprawidlowa odpowiedz JSON z InPost API.'); + } + + return $json; + } + + /** + * @param array|null $body + */ + private function apiRequestRaw( + string $method, + string $url, + string $token, + ?array $body = null, + string $accept = 'application/pdf' + ): string { + $ch = curl_init($url); + if ($ch === false) { + throw new ShipmentException('Nie udalo sie zainicjowac polaczenia z InPost API.'); + } + + $headers = [ + 'Authorization: Bearer ' . $token, + 'Accept: ' . $accept, + ]; + + $opts = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_CUSTOMREQUEST => $method, + ]; + + $caPath = $this->getCaBundlePath(); + if ($caPath !== null) { + $opts[CURLOPT_CAINFO] = $caPath; + } + + if ($body !== null) { + $jsonBody = json_encode($body, JSON_UNESCAPED_UNICODE); + $headers[] = 'Content-Type: application/json'; + $opts[CURLOPT_POSTFIELDS] = $jsonBody; + } + + $opts[CURLOPT_HTTPHEADER] = $headers; + curl_setopt_array($ch, $opts); + + $responseBody = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + $ch = null; + + if ($responseBody === false) { + throw new ShipmentException('Blad polaczenia z InPost API: ' . $curlError); + } + + if ($httpCode < 200 || $httpCode >= 300) { + $errorMsg = $this->extractApiErrorMessage((string) $responseBody, $httpCode); + throw new ShipmentException($errorMsg); + } + + return (string) $responseBody; + } + + private function extractApiErrorMessage(string $body, int $httpCode): string + { + $json = json_decode($body, true); + if (is_array($json)) { + $message = trim((string) ($json['message'] ?? '')); + if ($message !== '') { + return 'InPost API [HTTP ' . $httpCode . ']: ' . $message; + } + $details = $json['details'] ?? $json['error'] ?? null; + if (is_string($details) && trim($details) !== '') { + return 'InPost API [HTTP ' . $httpCode . ']: ' . trim($details); + } + } + + return 'InPost API zwrocilo blad HTTP ' . $httpCode; + } + + private function getCaBundlePath(): ?string + { + $envPath = (string) ($_ENV['CURL_CA_BUNDLE_PATH'] ?? ''); + if ($envPath !== '' && is_file($envPath)) { + return $envPath; + } + $iniPath = (string) ini_get('curl.cainfo'); + if ($iniPath !== '' && is_file($iniPath)) { + return $iniPath; + } + $candidates = [ + 'C:/xampp/apache/bin/curl-ca-bundle.crt', + 'C:/xampp/php/extras/ssl/cacert.pem', + '/etc/ssl/certs/ca-certificates.crt', + ]; + foreach ($candidates as $path) { + if (is_file($path)) { + return $path; + } + } + return null; + } + + /** + * @param array $orderDetails + * @param array $formData + * @return array + */ + private function buildReceiverFromOrder(array $orderDetails, array $formData): array + { + $addresses = is_array($orderDetails['addresses'] ?? null) ? $orderDetails['addresses'] : []; + $deliveryAddr = null; + $customerAddr = null; + foreach ($addresses as $addr) { + $type = (string) ($addr['address_type'] ?? ''); + if ($type === 'delivery') { + $deliveryAddr = $addr; + } + if ($type === 'customer') { + $customerAddr = $addr; + } + } + + $addr = $deliveryAddr ?? $customerAddr ?? []; + + $name = trim((string) ($formData['receiver_name'] ?? ($addr['name'] ?? ''))); + $company = trim((string) ($formData['receiver_company'] ?? ($addr['company_name'] ?? ''))); + $phone = trim((string) ($formData['receiver_phone'] ?? ($addr['phone'] ?? ''))); + $email = trim((string) ($formData['receiver_email'] ?? ($addr['email'] ?? ''))); + + $receiver = [ + 'name' => $name !== '' ? $name : ($company !== '' ? $company : 'Odbiorca'), + 'phone' => $phone, + 'email' => $email, + ]; + + if ($company !== '') { + $receiver['company_name'] = $company; + } + + $street = trim((string) ($formData['receiver_street'] ?? ($addr['street_name'] ?? ''))); + $city = trim((string) ($formData['receiver_city'] ?? ($addr['city'] ?? ''))); + $postalCode = trim((string) ($formData['receiver_postal_code'] ?? ($addr['zip_code'] ?? ''))); + $countryCode = strtoupper(trim((string) ($formData['receiver_country_code'] ?? ($addr['country'] ?? 'PL')))); + + $receiver['address'] = [ + 'street' => $street, + 'city' => $city, + 'post_code' => $postalCode, + 'country_code' => $countryCode, + ]; + + return $receiver; + } + + /** + * @param array $sender + * @return array + */ + private function buildSenderPayload(array $sender): array + { + $name = trim((string) ($sender['name'] ?? '')); + $company = trim((string) ($sender['company'] ?? '')); + + return [ + 'name' => $name !== '' ? $name : ($company !== '' ? $company : 'Nadawca'), + 'company_name' => $company !== '' ? $company : null, + 'phone' => trim((string) ($sender['phone'] ?? '')), + 'email' => trim((string) ($sender['email'] ?? '')), + 'address' => [ + 'street' => trim((string) ($sender['street'] ?? '')), + 'city' => trim((string) ($sender['city'] ?? '')), + 'post_code' => trim((string) ($sender['postalCode'] ?? '')), + 'country_code' => strtoupper(trim((string) ($sender['countryCode'] ?? 'PL'))), + ], + ]; + } + + /** + * @param array $formData + * @param array $settings + */ + private function resolveServiceType(array $formData, array $settings): string + { + $deliveryMethodId = trim((string) ($formData['delivery_method_id'] ?? '')); + if ($deliveryMethodId !== '') { + return $deliveryMethodId; + } + + $pointId = trim((string) ($formData['receiver_point_id'] ?? '')); + if ($pointId !== '') { + return 'inpost_locker_standard'; + } + + $dispatchMethod = (string) ($settings['default_dispatch_method'] ?? 'pop'); + if ($dispatchMethod === 'parcel_locker') { + return 'inpost_locker_standard'; + } + + return 'inpost_courier_standard'; + } + + /** + * @param array $formData + * @param array $settings + * @param array $company + * @return array + */ + private function buildParcelPayload(array $formData, array $settings, array $company, string $serviceType): array + { + $isLocker = str_contains($serviceType, 'locker'); + + if ($isLocker) { + $size = trim((string) ($formData['locker_size'] ?? ($settings['default_locker_size'] ?? 'small'))); + if (!in_array($size, ['small', 'medium', 'large'], true)) { + $size = 'small'; + } + + $targetPointId = trim((string) ($formData['receiver_point_id'] ?? '')); + + $parcel = [ + 'template' => $size, + ]; + if ($targetPointId !== '') { + $parcel['target_point'] = $targetPointId; + } + + return $parcel; + } + + $lengthCm = (float) ($formData['length_cm'] ?? ($settings['default_courier_length'] ?? $company['default_package_length_cm'] ?? 20)); + $widthCm = (float) ($formData['width_cm'] ?? ($settings['default_courier_width'] ?? $company['default_package_width_cm'] ?? 15)); + $heightCm = (float) ($formData['height_cm'] ?? ($settings['default_courier_height'] ?? $company['default_package_height_cm'] ?? 8)); + $weightKg = (float) ($formData['weight_kg'] ?? ($company['default_package_weight_kg'] ?? 1)); + + return [ + 'dimensions' => [ + 'length' => $lengthCm, + 'width' => $widthCm, + 'height' => $heightCm, + 'unit' => 'mm', + ], + 'weight' => [ + 'amount' => $weightKg, + 'unit' => 'kg', + ], + ]; + } + + /** + * @param array $orderDetails + */ + private function buildReference(array $orderDetails, int $orderId): string + { + $orderData = is_array($orderDetails['order'] ?? null) ? $orderDetails['order'] : []; + $sourceOrderId = trim((string) ($orderData['source_order_id'] ?? '')); + + return $sourceOrderId !== '' ? $sourceOrderId : (string) $orderId; + } + + /** + * @param array $sender + */ + private function validateSenderAddress(array $sender): void + { + $required = ['street', 'city', 'postalCode', 'phone', 'email']; + foreach ($required as $field) { + if (trim((string) ($sender[$field] ?? '')) === '') { + throw new IntegrationConfigException('Uzupelnij dane nadawcy w Ustawienia > Dane firmy (brak: ' . $field . ').'); + } + } + + $name = trim((string) ($sender['name'] ?? '')); + $company = trim((string) ($sender['company'] ?? '')); + if ($name === '' && $company === '') { + throw new IntegrationConfigException('Uzupelnij dane nadawcy w Ustawienia > Dane firmy (brak nazwy/firmy).'); + } + } +} diff --git a/src/Modules/Shipments/ShipmentController.php b/src/Modules/Shipments/ShipmentController.php index e2e6af6..de6f175 100644 --- a/src/Modules/Shipments/ShipmentController.php +++ b/src/Modules/Shipments/ShipmentController.php @@ -13,7 +13,7 @@ use App\Modules\Auth\AuthService; use App\Modules\Orders\OrdersRepository; use App\Modules\Settings\CarrierDeliveryMethodMappingRepository; use App\Modules\Settings\CompanySettingsRepository; -use AppCorexceptionsShipmentException; +use App\Core\Exceptions\ShipmentException; use Throwable; final class ShipmentController @@ -162,9 +162,6 @@ final class ShipmentController try { $providerCode = strtolower(trim((string) $request->input('provider_code', 'allegro_wza'))); - if ($providerCode === 'inpost') { - $providerCode = 'allegro_wza'; - } $provider = $this->providerRegistry->get($providerCode); if ($provider === null) { throw new ShipmentException('Nieznany provider przesylek: ' . $providerCode); diff --git a/tests/Unit/AllegroOrderImportServiceTest.php b/tests/Unit/AllegroOrderImportServiceTest.php new file mode 100644 index 0000000..9b208fc --- /dev/null +++ b/tests/Unit/AllegroOrderImportServiceTest.php @@ -0,0 +1,230 @@ +integrationRepository = $this->createMock(AllegroIntegrationRepository::class); + $this->tokenManager = $this->createMock(AllegroTokenManager::class); + $this->apiClient = $this->createMock(AllegroApiClient::class); + $this->orders = $this->createMock(OrderImportRepository::class); + $this->statusMappings = $this->createMock(AllegroStatusMappingRepository::class); + $this->ordersRepository = $this->createMock(OrdersRepository::class); + + $this->service = new AllegroOrderImportService( + $this->integrationRepository, + $this->tokenManager, + $this->apiClient, + $this->orders, + $this->statusMappings, + $this->ordersRepository + ); + } + + public function testImportSingleOrderHappyPath(): void + { + $checkoutFormId = 'abc-123'; + $payload = $this->buildMinimalPayload($checkoutFormId); + + $this->tokenManager + ->method('resolveToken') + ->willReturn(['access-token-xyz', 'sandbox']); + + $this->apiClient + ->method('getCheckoutForm') + ->with('sandbox', 'access-token-xyz', $checkoutFormId) + ->willReturn($payload); + + $this->apiClient + ->method('getCheckoutFormShipments') + ->willReturn([]); + + $this->integrationRepository + ->method('getActiveIntegrationId') + ->willReturn(5); + + $this->statusMappings + ->method('findMappedOrderproStatusCode') + ->willReturn('new'); + + $this->orders + ->expects($this->once()) + ->method('upsertOrderAggregate') + ->willReturn(['order_id' => 42, 'created' => true]); + + $this->ordersRepository + ->expects($this->once()) + ->method('recordActivity') + ->with(42, 'import', $this->stringContains('Zaimportowano')); + + $result = $this->service->importSingleOrder($checkoutFormId); + + $this->assertSame(42, $result['order_id']); + $this->assertTrue($result['created']); + $this->assertSame($checkoutFormId, $result['source_order_id']); + } + + public function testImportSingleOrderRetryOn401(): void + { + $checkoutFormId = 'retry-456'; + $payload = $this->buildMinimalPayload($checkoutFormId); + + $this->tokenManager + ->method('resolveToken') + ->willReturnOnConsecutiveCalls( + ['stale-token', 'sandbox'], + ['fresh-token', 'sandbox'] + ); + + $callCount = 0; + $this->apiClient + ->method('getCheckoutForm') + ->willReturnCallback(function () use (&$callCount, $payload): array { + $callCount++; + if ($callCount === 1) { + throw new RuntimeException('ALLEGRO_HTTP_401'); + } + return $payload; + }); + + $this->apiClient->method('getCheckoutFormShipments')->willReturn([]); + $this->integrationRepository->method('getActiveIntegrationId')->willReturn(1); + $this->statusMappings->method('findMappedOrderproStatusCode')->willReturn(null); + + $this->orders + ->expects($this->once()) + ->method('upsertOrderAggregate') + ->willReturn(['order_id' => 99, 'created' => true]); + + $result = $this->service->importSingleOrder($checkoutFormId); + + $this->assertSame(99, $result['order_id']); + $this->assertSame(2, $callCount); + } + + public function testImportSingleOrderThrowsOnEmptyId(): void + { + $this->expectException(AllegroApiException::class); + $this->expectExceptionMessage('Podaj ID zamowienia'); + + $this->service->importSingleOrder(''); + } + + public function testImportSingleOrderPropagatesNon401RuntimeException(): void + { + $this->tokenManager + ->method('resolveToken') + ->willReturn(['token', 'sandbox']); + + $this->apiClient + ->method('getCheckoutForm') + ->willThrowException(new RuntimeException('ALLEGRO_HTTP_500')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('ALLEGRO_HTTP_500'); + + $this->service->importSingleOrder('order-789'); + } + + public function testImportSingleOrderReturnsCorrectFieldsOnReImport(): void + { + $checkoutFormId = 'reimport-001'; + $payload = $this->buildMinimalPayload($checkoutFormId); + + $this->tokenManager->method('resolveToken')->willReturn(['tok', 'production']); + $this->apiClient->method('getCheckoutForm')->willReturn($payload); + $this->apiClient->method('getCheckoutFormShipments')->willReturn([]); + $this->integrationRepository->method('getActiveIntegrationId')->willReturn(1); + $this->statusMappings->method('findMappedOrderproStatusCode')->willReturn(null); + + $this->orders + ->method('upsertOrderAggregate') + ->willReturn(['order_id' => 10, 'created' => false]); + + $this->ordersRepository + ->expects($this->once()) + ->method('recordActivity') + ->with(10, 'import', $this->stringContains('Zaktualizowano')); + + $result = $this->service->importSingleOrder($checkoutFormId); + + $this->assertSame(10, $result['order_id']); + $this->assertFalse($result['created']); + $this->assertArrayHasKey('image_diagnostics', $result); + } + + /** + * @return array + */ + private function buildMinimalPayload(string $checkoutFormId): array + { + return [ + 'id' => $checkoutFormId, + 'status' => 'READY_FOR_PROCESSING', + 'fulfillment' => ['status' => 'NEW'], + 'payment' => [ + 'id' => 'pay-1', + 'type' => 'allegro', + 'status' => 'paid', + 'amount' => ['amount' => '99.00', 'currency' => 'PLN'], + 'paidAmount' => ['amount' => '99.00', 'currency' => 'PLN'], + ], + 'summary' => [ + 'totalToPay' => ['amount' => '99.00', 'currency' => 'PLN'], + ], + 'buyer' => [ + 'login' => 'test-buyer', + 'firstName' => 'Jan', + 'lastName' => 'Kowalski', + 'email' => 'jan@example.com', + 'phoneNumber' => '500100200', + ], + 'delivery' => [ + 'method' => ['id' => 'inpost', 'name' => 'InPost Paczkomaty'], + 'address' => [ + 'firstName' => 'Jan', + 'lastName' => 'Kowalski', + 'street' => 'ul. Testowa 1', + 'city' => 'Warszawa', + 'zipCode' => '00-001', + 'countryCode' => 'PL', + ], + ], + 'lineItems' => [ + [ + 'id' => 'item-1', + 'offer' => ['id' => 'offer-1', 'name' => 'Produkt testowy'], + 'quantity' => 2, + 'originalPrice' => ['amount' => '49.50', 'currency' => 'PLN'], + ], + ], + 'invoice' => [], + 'marketplace' => ['id' => 'allegro-pl'], + 'boughtAt' => '2026-03-14T10:00:00Z', + 'updatedAt' => '2026-03-14T10:05:00Z', + ]; + } +} diff --git a/tests/Unit/AllegroTokenManagerTest.php b/tests/Unit/AllegroTokenManagerTest.php new file mode 100644 index 0000000..dd5b491 --- /dev/null +++ b/tests/Unit/AllegroTokenManagerTest.php @@ -0,0 +1,267 @@ +repository = $this->createMock(AllegroIntegrationRepository::class); + $this->oauthClient = $this->createMock(AllegroOAuthClient::class); + $this->manager = new AllegroTokenManager($this->repository, $this->oauthClient); + } + + public function testResolveTokenReturnsCachedTokenWhenFresh(): void + { + $expiresAt = (new DateTimeImmutable('now')) + ->add(new DateInterval('PT30M')) + ->format('Y-m-d H:i:s'); + + $this->repository + ->method('getTokenCredentials') + ->willReturn([ + 'environment' => 'sandbox', + 'client_id' => 'cid', + 'client_secret' => 'cs', + 'refresh_token' => 'rt', + 'access_token' => 'fresh-token', + 'token_expires_at' => $expiresAt, + ]); + + $this->oauthClient + ->expects($this->never()) + ->method('refreshAccessToken'); + + $result = $this->manager->resolveToken(); + + $this->assertSame('fresh-token', $result[0]); + $this->assertSame('sandbox', $result[1]); + } + + public function testResolveTokenRefreshesWhenExpiresWithinFiveMinutes(): void + { + $expiresAt = (new DateTimeImmutable('now')) + ->add(new DateInterval('PT3M')) + ->format('Y-m-d H:i:s'); + + $credentials = [ + 'environment' => 'production', + 'client_id' => 'cid', + 'client_secret' => 'cs', + 'refresh_token' => 'rt', + 'access_token' => 'old-token', + 'token_expires_at' => $expiresAt, + ]; + + $this->repository + ->method('getTokenCredentials') + ->willReturnOnConsecutiveCalls( + $credentials, + $credentials, + array_merge($credentials, ['access_token' => 'new-token']) + ); + + $this->oauthClient + ->expects($this->once()) + ->method('refreshAccessToken') + ->with('production', 'cid', 'cs', 'rt') + ->willReturn([ + 'access_token' => 'new-token', + 'refresh_token' => 'new-rt', + 'token_type' => 'Bearer', + 'scope' => '', + 'expires_in' => 3600, + ]); + + $this->repository + ->expects($this->once()) + ->method('saveTokens'); + + $result = $this->manager->resolveToken(); + + $this->assertSame('new-token', $result[0]); + $this->assertSame('production', $result[1]); + } + + public function testResolveTokenRefreshesWhenAlreadyExpired(): void + { + $expiresAt = (new DateTimeImmutable('now')) + ->sub(new DateInterval('PT10M')) + ->format('Y-m-d H:i:s'); + + $credentials = [ + 'environment' => 'sandbox', + 'client_id' => 'cid', + 'client_secret' => 'cs', + 'refresh_token' => 'rt', + 'access_token' => 'expired-token', + 'token_expires_at' => $expiresAt, + ]; + + $this->repository + ->method('getTokenCredentials') + ->willReturnOnConsecutiveCalls( + $credentials, + $credentials, + array_merge($credentials, ['access_token' => 'refreshed-token']) + ); + + $this->oauthClient + ->expects($this->once()) + ->method('refreshAccessToken') + ->willReturn([ + 'access_token' => 'refreshed-token', + 'refresh_token' => 'rt', + 'token_type' => 'Bearer', + 'scope' => '', + 'expires_in' => 3600, + ]); + + $this->repository->expects($this->once())->method('saveTokens'); + + $result = $this->manager->resolveToken(); + + $this->assertSame('refreshed-token', $result[0]); + } + + public function testResolveTokenThrowsWhenNoOAuthConfig(): void + { + $this->repository + ->method('getTokenCredentials') + ->willReturn(null); + + $this->expectException(AllegroOAuthException::class); + $this->expectExceptionMessage('Brak polaczenia OAuth Allegro'); + + $this->manager->resolveToken(); + } + + public function testResolveTokenRefreshesWhenAccessTokenEmpty(): void + { + $credentials = [ + 'environment' => 'sandbox', + 'client_id' => 'cid', + 'client_secret' => 'cs', + 'refresh_token' => 'rt', + 'access_token' => '', + 'token_expires_at' => '', + ]; + + $this->repository + ->method('getTokenCredentials') + ->willReturnOnConsecutiveCalls( + $credentials, + $credentials, + array_merge($credentials, ['access_token' => 'brand-new']) + ); + + $this->oauthClient + ->expects($this->once()) + ->method('refreshAccessToken') + ->willReturn([ + 'access_token' => 'brand-new', + 'refresh_token' => 'rt', + 'token_type' => 'Bearer', + 'scope' => '', + 'expires_in' => 3600, + ]); + + $this->repository->expects($this->once())->method('saveTokens'); + + $result = $this->manager->resolveToken(); + + $this->assertSame('brand-new', $result[0]); + } + + public function testForceRefreshReReadsTokenFromRepository(): void + { + $expiresAt = (new DateTimeImmutable('now')) + ->sub(new DateInterval('PT1M')) + ->format('Y-m-d H:i:s'); + + $credentials = [ + 'environment' => 'sandbox', + 'client_id' => 'cid', + 'client_secret' => 'cs', + 'refresh_token' => 'rt', + 'access_token' => 'stale', + 'token_expires_at' => $expiresAt, + ]; + + $this->repository + ->expects($this->exactly(3)) + ->method('getTokenCredentials') + ->willReturnOnConsecutiveCalls( + $credentials, + $credentials, + array_merge($credentials, ['access_token' => 'from-db']) + ); + + $this->oauthClient + ->method('refreshAccessToken') + ->willReturn([ + 'access_token' => 'api-returned', + 'refresh_token' => 'rt', + 'token_type' => 'Bearer', + 'scope' => '', + 'expires_in' => 3600, + ]); + + $this->repository->expects($this->once())->method('saveTokens'); + + $result = $this->manager->resolveToken(); + + // Token comes from re-read (3rd call), not directly from API response + $this->assertSame('from-db', $result[0]); + } + + public function testResolveTokenRefreshesWhenExpiresAtInvalidFormat(): void + { + $credentials = [ + 'environment' => 'sandbox', + 'client_id' => 'cid', + 'client_secret' => 'cs', + 'refresh_token' => 'rt', + 'access_token' => 'some-token', + 'token_expires_at' => 'not-a-date', + ]; + + $this->repository + ->method('getTokenCredentials') + ->willReturnOnConsecutiveCalls( + $credentials, + $credentials, + array_merge($credentials, ['access_token' => 'refreshed']) + ); + + $this->oauthClient + ->expects($this->once()) + ->method('refreshAccessToken') + ->willReturn([ + 'access_token' => 'refreshed', + 'refresh_token' => 'rt', + 'token_type' => 'Bearer', + 'scope' => '', + 'expires_in' => 3600, + ]); + + $this->repository->expects($this->once())->method('saveTokens'); + + $result = $this->manager->resolveToken(); + $this->assertSame('refreshed', $result[0]); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0095cdf..23f54d1 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -6,6 +6,7 @@ $autoload = $basePath . '/vendor/autoload.php'; if (is_file($autoload)) { require $autoload; + DG\BypassFinals::enable(); } else { spl_autoload_register(static function (string $class) use ($basePath): void { $prefixes = [