feat(07-pre-expansion-fixes): complete phase 07 — milestone v0.2 done

Phase 7 complete (5 plans):
- 07-01: Performance (N+1→LEFT JOIN, static cache, DB indexes)
- 07-02: Stability (SSL verification, cron throttle DB, migration 000014b)
- 07-03: UX (orderpro_to_allegro disable, lista zamówień fixes, SSL hotfix)
- 07-04: Tests (12 unit tests for AllegroTokenManager + AllegroOrderImportService)
- 07-05: InPost ShipX API (natywny provider, workaround remap usunięty)

Additional fixes:
- 5 broken use-statements fixed across 4 files
- vendor/ excluded from ftp-kr auto-upload
- PHPUnit + dg/bypass-finals infrastructure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 00:37:21 +01:00
parent 62a68e9ec2
commit 5ab87a5a20
18 changed files with 1474 additions and 54 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,7 @@
node_modules/
vendor/
composer.phar
composer.lock
storage/logs/
storage/sessions/
storage/cache/

View File

@@ -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)*

View File

@@ -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
<details>
<summary>v0.2 Pre-Expansion Fixes — 2026-03-15 (1 phase, 5 plans)</summary>
| 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/`
</details>
<details>
<summary>v0.1 Initial Release — 2026-03-13 (6 phases, 15 plans)</summary>
@@ -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*

View File

@@ -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*

View File

@@ -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*

View File

@@ -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*

9
.vscode/ftp-kr.json vendored
View File

@@ -16,6 +16,13 @@
"/.claude",
".gitignore",
"/.scannerwork",
"/.paul"
"/.paul",
"/vendor",
"/node_modules",
"/composer.phar",
"/composer.lock",
"/tests",
"/phpunit.xml",
"/.plantuml"
]
}

View File

@@ -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": {

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -5,7 +5,7 @@ namespace App\Modules\Settings;
use DateInterval;
use DateTimeImmutable;
use AppCorexceptionsAllegroOAuthException;
use App\Core\Exceptions\AllegroOAuthException;
use Throwable;
final class AllegroTokenManager

View File

@@ -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

View File

@@ -0,0 +1,577 @@
<?php
declare(strict_types=1);
namespace App\Modules\Shipments;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Exceptions\ShipmentException;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\InpostIntegrationRepository;
use RuntimeException;
use Throwable;
final class InpostShipmentService implements ShipmentProviderInterface
{
private const API_BASE_PRODUCTION = 'https://api-shipx-pl.easypack24.net/v1';
private const API_BASE_SANDBOX = 'https://sandbox-api-shipx-pl.easypack24.net/v1';
public function __construct(
private readonly InpostIntegrationRepository $inpostRepository,
private readonly ShipmentPackageRepository $packages,
private readonly CompanySettingsRepository $companySettings,
private readonly OrdersRepository $ordersRepository
) {
}
public function code(): string
{
return 'inpost';
}
/**
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $formData
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>|null $body
* @return array<string, mixed>
*/
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<string, mixed>|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<string, mixed> $orderDetails
* @param array<string, mixed> $formData
* @return array<string, mixed>
*/
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<string, mixed> $sender
* @return array<string, mixed>
*/
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<string, mixed> $formData
* @param array<string, mixed> $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<string, mixed> $formData
* @param array<string, mixed> $settings
* @param array<string, mixed> $company
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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).');
}
}
}

View File

@@ -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);

View File

@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Core\Exceptions\AllegroApiException;
use App\Modules\Orders\OrderImportRepository;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\AllegroApiClient;
use App\Modules\Settings\AllegroIntegrationRepository;
use App\Modules\Settings\AllegroOrderImportService;
use App\Modules\Settings\AllegroStatusMappingRepository;
use App\Modules\Settings\AllegroTokenManager;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class AllegroOrderImportServiceTest extends TestCase
{
private AllegroIntegrationRepository&MockObject $integrationRepository;
private AllegroTokenManager&MockObject $tokenManager;
private AllegroApiClient&MockObject $apiClient;
private OrderImportRepository&MockObject $orders;
private AllegroStatusMappingRepository&MockObject $statusMappings;
private OrdersRepository&MockObject $ordersRepository;
private AllegroOrderImportService $service;
protected function setUp(): void
{
$this->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<string, mixed>
*/
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',
];
}
}

View File

@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Core\Exceptions\AllegroOAuthException;
use App\Modules\Settings\AllegroIntegrationRepository;
use App\Modules\Settings\AllegroOAuthClient;
use App\Modules\Settings\AllegroTokenManager;
use DateInterval;
use DateTimeImmutable;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class AllegroTokenManagerTest extends TestCase
{
private AllegroIntegrationRepository&MockObject $repository;
private AllegroOAuthClient&MockObject $oauthClient;
private AllegroTokenManager $manager;
protected function setUp(): void
{
$this->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]);
}
}

View File

@@ -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 = [