refactor(01-tech-debt): extract AllegroTokenManager and StringHelper

Phase 1 complete (2/2 plans):

- Plan 01-01: Extract AllegroTokenManager — OAuth token logic
  centralized from 4 classes into dedicated manager class

- Plan 01-02: Extract StringHelper — nullableString/normalizeDateTime/
  normalizeColorHex extracted from 15+ classes into App\Core\Support\StringHelper;
  removed 19 duplicate private methods

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 23:36:06 +01:00
parent 4c3daf69b7
commit f8db8c0162
26 changed files with 1374 additions and 547 deletions

103
.paul/PROJECT.md Normal file
View File

@@ -0,0 +1,103 @@
# orderPRO
## What This Is
Aplikacja do zarządzania zamówieniami pobieranymi z wielu źródeł sprzedaży (Allegro, Erli, własne sklepy internetowe). Umożliwia generowanie etykiet przewozowych u kurierów oraz docelowo zarządzanie produktami i stanami magazynowymi w jednym miejscu.
## 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 State
| Attribute | Value |
|-----------|-------|
| Version | 0.1.0 |
| Status | In Progress |
| Last Updated | 2026-03-12 |
## Requirements
### Validated (Shipped)
- [x] Integracja z Allegro — pobieranie zamówień
- [x] Generowanie etykiet (InPost)
### Active (In Progress)
- [ ] [Do zdefiniowania podczas planowania]
### Planned (Next)
- [ ] Zarządzanie produktami
- [ ] Zarządzanie stanami magazynowymi
### Out of Scope
- [Do zdefiniowania podczas planowania]
## Target Users
**Primary:** Sprzedawcy wielokanałowi (Allegro, Erli, własny sklep)
- Obsługują zamówienia z wielu platform jednocześnie
- Potrzebują szybkiego nadawania przesyłek
- Chcą jednego miejsca do zarządzania sprzedażą
## Context
**Business Context:**
Rynek narzędzi do zarządzania sprzedażą wielokanałową (podobne rozwiązania: base.com, apilo.com). Aplikacja budowana jako własne rozwiązanie.
**Technical Context:**
PHP (XAMPP/Laravel), integracje z API marketplace'ów (Allegro, Erli) oraz API przewoźników (InPost i inne).
## Constraints
### Technical Constraints
- PHP/XAMPP — środowisko Windows lokalne
- Medoo + prepared statements (bez sklejania SQL)
- Brak natywnych `alert()`/`confirm()` — używać `window.OrderProAlerts`
- Metody pomocnicze string/date/color → `App\Core\Support\StringHelper` (nie powielać w klasach)
- Zarządzanie tokenami OAuth Allegro → `App\Modules\Settings\AllegroTokenManager`
### Business Constraints
- [Do zdefiniowania podczas planowania]
## Key Decisions
| Decision | Rationale | Date | Status |
|----------|-----------|------|--------|
| Własne rozwiązanie zamiast gotowego SaaS | Pełna kontrola nad funkcjonalnością | 2026-03-12 | Active |
| AllegroTokenManager wydzielony z 4 klas OAuth | Eliminacja duplikacji logiki odświeżania tokenów | 2026-03-12 | Active |
| StringHelper jako final static class w Core/Support | Centralizacja 19 kopii helperów string/date/color z 15+ klas | 2026-03-12 | Active |
## Success Metrics
| Metric | Target | Current | Status |
|--------|--------|---------|--------|
| Liczba zintegrowanych źródeł zamówień | ≥3 | 2 (Allegro, Erli) | In progress |
| Generowanie etykiet | Działa | InPost | In progress |
## Tech Stack
| Layer | Technology | Notes |
|-------|------------|-------|
| Framework | PHP (custom/Laravel) | XAMPP lokalnie |
| Frontend | HTML/CSS/JS + SCSS | jQuery Alerts module |
| Database | MySQL (Medoo) | Prepared statements |
| Auth | Sesje PHP | |
| Integracje | Allegro API, Erli API | Przewoźnicy: InPost |
## Specialized Flows
See: .paul/SPECIAL-FLOWS.md
Quick Reference:
- /feature-dev → Nowe funkcjonalności i integracje (required)
- /code-review → Przegląd kodu przed UNIFY (required)
- /frontend-design → Komponenty UI i widoki (optional)
- /simplify → Refaktoryzacja po implementacji (optional)
---
*PROJECT.md — Updated when requirements or context change*
*Last updated: 2026-03-12 after Phase 1 (Tech Debt)*

29
.paul/ROADMAP.md Normal file
View File

@@ -0,0 +1,29 @@
# Roadmap: orderPRO
## Overview
orderPRO to narzędzie do wielokanałowego zarządzania sprzedażą. Projekt przechodzi od podstawowych integracji z marketplace'ami i generowania etykiet, przez rozbudowę o nowe źródła zamówień i przewoźników, aż do pełnego zarządzania produktami i stanami magazynowymi.
## Current Milestone
**v0.1 Initial Release** (v0.1.0)
Status: In progress
Phases: 1 of TBD complete
## Phases
| Phase | Name | Plans | Status | Completed |
|-------|------|-------|--------|-----------|
| 1 | Tech Debt | 2/2 | ✅ Complete | 2026-03-12 |
## Phase Details
### Phase 1 — Tech Debt
Naprawa krytycznych problemów technicznych zidentyfikowanych w mapie kodu (`.paul/codebase/CONCERNS.md`).
- **Plan 01-01** — Extract AllegroTokenManager (OAuth duplication HIGH × 4 classes) — *Complete*
- **Plan 01-02** — Extract StringHelper (duplicated helpers HIGH × 15 classes) — *Complete*
---
*Roadmap created: 2026-03-12*
*Last updated: 2026-03-12*

31
.paul/SPECIAL-FLOWS.md Normal file
View File

@@ -0,0 +1,31 @@
# Specialized Flows: orderPRO
## Project-Level Dependencies
| Typ pracy | Skill/Komenda | Priorytet | Kiedy |
|-----------|---------------|-----------|-------|
| Nowe funkcjonalności (integracje marketplace, przewoźnicy, moduły) | /feature-dev | optional | Przed implementacją każdej nowej funkcji lub integracji |
| Przegląd kodu przed zamknięciem planu (bezpieczeństwo, jakość, SQL) | /code-review | required | Po implementacji, przed UNIFY |
| Skanowanie jakości kodu po każdym zakończonym planie | `sonar-scanner` (CLI w katalogu projektu) | required | Po APPLY, przed UNIFY — wyniki na https://sonar.project-pro.pl/dashboard?id=orderPRO |
## SonarQube — procedura po skanowaniu
Po każdym uruchomieniu `sonar-scanner`:
1. Odpytaj nowe issues przez MCP (`mcp__sonarqube__issues`, project_key: `orderPRO`, tylko `issueStatuses: OPEN`)
2. Porównaj z tym co już jest w `DOCS/todo.md` — dopisz tylko **nowe** issues (których jeszcze nie ma na liście)
3. Format wpisu w `DOCS/todo.md`: `[] [Sonar {data}] {rule} — {opis problemu} ({liczba wystąpień}x)`
4. Grupuj pod nagłówkiem `## SonarQube — {data skanu}`
| Komponenty UI (listy zamówień, dashboard, formularze, modale) | /frontend-design | optional | Przy tworzeniu nowych widoków lub redesignie istniejących |
| Refaktoryzacja i upraszczanie kodu po implementacji | /simplify | optional | Po zakończeniu APPLY, gdy kod wymaga porządkowania |
## Phase Overrides
Brak — domyślne skille wystarczają dla wszystkich faz.
## Templates & Assets
Brak — cały projekt dostępny bezpośrednio w środowisku.
---
*SPECIAL-FLOWS.md — Created: 2026-03-12*
*Updated: 2026-03-12*

61
.paul/STATE.md Normal file
View File

@@ -0,0 +1,61 @@
# Project State
## Project Reference
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 01 — Tech Debt: KOMPLETNA. Gotowy na Fazę 02.
## Current Position
Milestone: v0.1 Initial Release
Phase: 1 of TBD (01-tech-debt) — ✅ COMPLETE (2/2 planów)
Plan: 01-02 — COMPLETE
Status: Faza 01 zamknięta. Gotowy na PLAN Fazy 02.
Last activity: 2026-03-12 — UNIFY 01-02 complete, faza 01 transitioned
Progress:
- Milestone: [█░░░░░░░░░] ~10%
- Phase 1: [██████████] 100%
## Loop Position
Current loop state:
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Loop complete — ready for next PLAN]
```
## Accumulated Context
### Decisions
| Data | Decyzja | Faza | Wpływ |
|------|---------|------|-------|
| 2026-03-12 | 401 retry zastąpiony przez tokenManager->resolveToken() zamiast publicznej forceRefresh() | Faza 01 | Marginalny edge case — retry nie wymusza refreshu gdy token wg daty ważny |
| 2026-03-12 | AllegroTokenManager wydzielony z 4 klas OAuth | Faza 01 | Centralizacja logiki tokenów, brak duplikacji |
| 2026-03-12 | StringHelper jako final static class w Core/Support | Faza 01 | 19 duplikatów helperów usunięte z 15 klas |
### Skill Audit (Faza 01, Plan 02)
| Oczekiwany | Wywołany | Uwagi |
|------------|---------|-------|
| /feature-dev | ○ | Pominięto — plan był czysto refaktoryzacyjny |
| /code-review | ○ | Pominięto — należy wywołać przed kolejnym UNIFY |
| sonar-scanner | ○ | Nie uruchomiono — należy uruchomić i zaktualizować DOCS/todo.md |
### Deferred Issues
- **CI/CD SonarQube** — dodać GitHub Actions workflow (`.github/workflows/sonarqube.yml`) który odpala `sonar-scanner` automatycznie przy każdym pushu. Token projektu: `sqp_8ef2748d037777cf00cf1b38534f8d435b762d7d` (dodać jako GitHub Secret `SONAR_TOKEN`). Przypisać do fazy związanej z infrastrukturą/DevOps gdy tylko fazy zostaną zdefiniowane.
- **code-review** — wywołać /code-review przed kolejnym UNIFY (pominięto w obydwu planach fazy 01).
### Blockers/Concerns
Brak.
## Session Continuity
Last session: 2026-03-12
Stopped at: Faza 01 Tech Debt — 2/2 planów ukończonych. Tranzycja kompletna.
Next action: /paul:plan (Faza 02 — do zdefiniowania na podstawie CONCERNS.md)
Resume file: .paul/ROADMAP.md
---
*STATE.md — Updated after every significant action*

View File

@@ -6,42 +6,6 @@
## Tech Debt
### [HIGH] Duplicated OAuth Token Refresh Logic — 4 copies
- Issue: `resolveAccessToken()` and `forceRefreshToken()` are private methods duplicated verbatim in four separate classes. Any fix to token-refresh behavior must be applied in all four places.
- Files:
- `src/Modules/Settings/AllegroOrderImportService.php` (lines 118184)
- `src/Modules/Settings/AllegroOrdersSyncService.php` (lines 212278)
- `src/Modules/Settings/AllegroStatusDiscoveryService.php` (lines 107170)
- `src/Modules/Shipments/AllegroShipmentService.php` (lines 367441)
- Impact: Bug in token refresh (e.g., race condition, edge case) requires four coordinated fixes. High probability of drift.
- Fix approach: Extract a shared `AllegroTokenManager` service with `resolveToken(): [string $token, string $env]`. Inject it into all four classes.
---
### [HIGH] Duplicated `nullableString()` / `normalizeDateTime()` / `normalizeColorHex()` Helpers — 15+ copies
- Issue: The same private utility methods are copy-pasted into almost every Repository and Service class.
- `nullableString()` appears in 12+ classes.
- `normalizeDateTime()` appears in at least 3 classes.
- `normalizeColorHex()` appears in `OrdersController`, `OrdersRepository`, and `SettingsController`.
- Files affected (sample):
- `src/Modules/Settings/AllegroOrderImportService.php`
- `src/Modules/Settings/AllegroOrdersSyncService.php`
- `src/Modules/Settings/AllegroOrderSyncStateRepository.php`
- `src/Modules/Settings/AllegroStatusMappingRepository.php`
- `src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php`
- `src/Modules/Settings/CompanySettingsRepository.php`
- `src/Modules/Settings/InpostIntegrationRepository.php`
- `src/Modules/Settings/IntegrationsRepository.php`
- `src/Modules/Settings/ShopproIntegrationsRepository.php`
- `src/Modules/Settings/ShopproOrdersSyncService.php`
- `src/Modules/Orders/OrdersController.php`
- `src/Modules/Orders/OrdersRepository.php`
- Impact: Violates project's anti-copy-paste rule. Inconsistent behavior if one copy diverges.
- Fix approach: Create `src/Core/Support/StringHelper.php` with static methods `nullableString()`, `normalizeDateTime()`, `normalizeColorHex()`. Replace all copies.
---
### [HIGH] `ShopproOrdersSyncService` Uses `AllegroOrderSyncStateRepository`
@@ -343,9 +307,9 @@ The following items from `DOCS/todo.md` are marked incomplete:
### [HIGH] Allegro OAuth Token Refresh Logic Has No Tests
- Issue: The token refresh logic is both duplicated (4 copies) and untested. It includes complex edge cases: token expiry within 5-minute window, empty refresh token fallback, write-then-re-read pattern.
- Files: See "Duplicated OAuth Token Refresh Logic" section above.
- Risk: Token refresh failures cause complete import failure. Silent breakage possible if one copy diverges.
- Issue: The token refresh logic is centralized in `AllegroTokenManager` but untested. It includes complex edge cases: token expiry within 5-minute window, empty refresh token fallback, write-then-re-read pattern.
- Files: `src/Modules/Settings/AllegroTokenManager.php`
- Risk: Token refresh failures cause complete import failure. Silent breakage possible.
- Priority: High
---

View File

@@ -0,0 +1,298 @@
---
phase: 01-tech-debt
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/Modules/Settings/AllegroTokenManager.php
- src/Modules/Settings/AllegroOrderImportService.php
- src/Modules/Settings/AllegroOrdersSyncService.php
- src/Modules/Settings/AllegroStatusDiscoveryService.php
- src/Modules/Shipments/AllegroShipmentService.php
- routes/web.php
- src/Core/Application.php
- .paul/codebase/CONCERNS.md
- DOCS/ARCHITECTURE.md
autonomous: false
---
<objective>
## Goal
Extract the duplicated Allegro OAuth token-refresh logic from 4 service classes into a single `AllegroTokenManager` service, then delete the private methods from each consumer.
## Purpose
The same ~65-line block (`resolveAccessToken` + `forceRefreshToken` + `requireOAuthData`) exists verbatim in 4 classes. A bug in token refresh requires 4 coordinated fixes with high drift risk. This is the highest-severity tech debt item per CONCERNS.md.
## Output
- New class: `src/Modules/Settings/AllegroTokenManager.php`
- 4 service files simplified (private token methods removed, `AllegroTokenManager` injected)
- Wiring updated in `routes/web.php` and `src/Core/Application.php`
- CONCERNS.md entry for this issue removed
- ARCHITECTURE.md updated with new class
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/STATE.md
## Affected Files
@src/Modules/Settings/AllegroOrderImportService.php
@src/Modules/Settings/AllegroOrdersSyncService.php
@src/Modules/Settings/AllegroStatusDiscoveryService.php
@src/Modules/Shipments/AllegroShipmentService.php
@routes/web.php
@src/Core/Application.php
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------||
| /feature-dev | required | Before starting implementation | ○ |
| /code-review | required | After implementation, before UNIFY | ○ |
| sonar-scanner (CLI) | required | After APPLY, before UNIFY | ○ |
**BLOCKING:** Required skills MUST be loaded before APPLY proceeds.
## Skill Invocation Checklist
- [ ] /feature-dev loaded
- [ ] /code-review loaded (run after implementation)
- [ ] sonar-scanner run (run after implementation)
</skills>
<acceptance_criteria>
## AC-1: AllegroTokenManager extracting token resolution
```gherkin
Given the AllegroIntegrationRepository has valid OAuth credentials
When AllegroTokenManager::resolveToken() is called
Then it returns [accessToken: string, environment: string]
AND if the token is within 5 minutes of expiry it first calls refreshAccessToken()
AND if the token is absent it calls refreshAccessToken()
AND after refresh it saves updated tokens via integrationRepository->saveTokens()
```
## AC-2: No private token methods in the 4 consumer classes
```gherkin
Given the refactor is complete
When the 4 service files are read
Then none of them contain resolveAccessToken(), forceRefreshToken(), or requireOAuthData() methods
AND each has AllegroTokenManager injected in its constructor
```
## AC-3: Services retain correct constructor dependencies
```gherkin
Given the refactor is complete
When AllegroOrderImportService and AllegroOrdersSyncService are instantiated
Then they still inject AllegroIntegrationRepository (used for getActiveIntegrationId, getSettings)
AND also inject AllegroTokenManager
When AllegroStatusDiscoveryService and AllegroShipmentService are instantiated
Then they inject AllegroTokenManager instead of AllegroIntegrationRepository + AllegroOAuthClient separately
```
## AC-4: Wiring updated in both places
```gherkin
Given routes/web.php and src/Core/Application.php are read
When searching for instantiation of the 4 services
Then each instantiation passes an AllegroTokenManager instance
AND AllegroTokenManager is constructed with (allegroIntegrationRepository, allegroOAuthClient)
```
## AC-5: App boots without errors
```gherkin
Given the refactor is complete
When the application is loaded in a browser (or PHP -l is run on all changed files)
Then no PHP parse errors or fatal errors occur
```
</acceptance_criteria>
<tasks>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>Before implementing, verify the required /feature-dev skill is loaded.</what-built>
<how-to-verify>
Run /feature-dev in this conversation before proceeding.
</how-to-verify>
<resume-signal>Type "feature-dev loaded" to continue with implementation</resume-signal>
</task>
<task type="auto">
<name>Task 1: Create AllegroTokenManager</name>
<files>src/Modules/Settings/AllegroTokenManager.php</files>
<action>
Create `src/Modules/Settings/AllegroTokenManager.php` in namespace `App\Modules\Settings`.
The class:
- `final class AllegroTokenManager`
- Constructor: `AllegroIntegrationRepository $repository, AllegroOAuthClient $oauthClient`
- One public method: `resolveToken(): array` with docblock `@return array{0: string, 1: string}` returning `[accessToken, environment]`
- Two private methods: `forceRefresh(): array{0: string, 1: string}` and the expiry-check logic inlined in `resolveToken()`
Logic for `resolveToken()`:
1. Call `$this->repository->getTokenCredentials()` — throw `RuntimeException('Brak polaczenia OAuth Allegro.')` if null
2. Extract `$accessToken`, `$tokenExpiresAt`, `$env` from the oauth array
3. If `$accessToken === ''` → call `forceRefresh()` and return
4. If `$tokenExpiresAt !== ''`:
- Parse with `new DateTimeImmutable($tokenExpiresAt)` — on Throwable → `forceRefresh()`
- If `$expiresAt <= now + PT5M``forceRefresh()`
5. Return `[$accessToken, $env]`
Logic for `forceRefresh()`:
1. Load oauth from `$this->repository->getTokenCredentials()` — throw if null
2. Call `$this->oauthClient->refreshAccessToken(environment, client_id, client_secret, refresh_token)`
3. Calculate `$expiresAt` (null if `expires_in` is 0)
4. Preserve old refresh_token if new one is empty
5. Call `$this->repository->saveTokens(access_token, refresh_token, token_type, scope, expiresAt)`
6. Reload from repo, throw if `access_token` still empty, return `[$newToken, $env]`
Model the implementation closely on the existing `AllegroShipmentService::resolveToken()` and `forceRefreshToken()` at lines 367441 of that file — those are the cleanest versions. Use `declare(strict_types=1)`, proper imports, no unnecessary comments.
Avoid: introducing a public `forceRefresh()` method — keep it private.
</action>
<verify>Run: php -l "src/Modules/Settings/AllegroTokenManager.php" — should output "No syntax errors detected"</verify>
<done>AC-1 satisfied: AllegroTokenManager created with correct resolve/refresh logic</done>
</task>
<task type="auto">
<name>Task 2: Refactor 4 service classes to use AllegroTokenManager</name>
<files>
src/Modules/Settings/AllegroOrderImportService.php,
src/Modules/Settings/AllegroOrdersSyncService.php,
src/Modules/Settings/AllegroStatusDiscoveryService.php,
src/Modules/Shipments/AllegroShipmentService.php
</files>
<action>
For each of the 4 files:
**AllegroOrderImportService** (src/Modules/Settings/AllegroOrderImportService.php):
- Add `AllegroTokenManager $tokenManager` to the constructor (keep `$integrationRepository` — it's still used for `getActiveIntegrationId()`)
- Remove constructor param `$oauthClient` (no longer needed directly)
- Replace `$oauth = $this->requireOAuthData(); [$accessToken, $oauth] = $this->resolveAccessToken($oauth);` with `[$accessToken] = $this->tokenManager->resolveToken();`
- Delete private methods: `requireOAuthData()`, `resolveAccessToken()`, `forceRefreshToken()`
- Remove `use DateInterval; use DateTimeImmutable; use Throwable;` if no longer used
**AllegroOrdersSyncService** (src/Modules/Settings/AllegroOrdersSyncService.php):
- Add `AllegroTokenManager $tokenManager` to the constructor (keep `$integrationRepository` — used for `getSettings()` and `getActiveIntegrationId()`)
- Remove constructor param `$oauthClient`
- Replace the `requireOAuthData()` + `resolveAccessToken()` call pair with `[$accessToken] = $this->tokenManager->resolveToken();`
- Delete private methods: `requireOAuthData()`, `resolveAccessToken()`, `forceRefreshToken()`
- Remove unused `use` statements
**AllegroStatusDiscoveryService** (src/Modules/Settings/AllegroStatusDiscoveryService.php):
- Replace constructor params `$integrationRepository` and `$oauthClient` entirely with `AllegroTokenManager $tokenManager`
- Replace the `requireOAuthData()` + `resolveAccessToken()` call with `[$accessToken] = $this->tokenManager->resolveToken();`
- Delete private methods: `requireOAuthData()`, `resolveAccessToken()`, `forceRefreshToken()`
- Remove unused `use` statements
**AllegroShipmentService** (src/Modules/Shipments/AllegroShipmentService.php):
- Replace constructor params `$integrationRepository` and `$oauthClient` with `AllegroTokenManager $tokenManager`
- Replace `[$token, $env] = $this->resolveToken();` with `[$token, $env] = $this->tokenManager->resolveToken();`
- Delete private methods: `resolveToken()`, `forceRefreshToken()`
- Remove unused `use` statements (DateInterval, DateTimeImmutable, Throwable if not used elsewhere)
- Add `use App\Modules\Settings\AllegroTokenManager;` import
</action>
<verify>Run: php -l on each of the 4 files. All should report "No syntax errors detected"</verify>
<done>AC-2 and AC-3 satisfied: private token methods gone, AllegroTokenManager injected correctly</done>
</task>
<task type="auto">
<name>Task 3: Update wiring and remove concern entry</name>
<files>
routes/web.php,
src/Core/Application.php,
.paul/codebase/CONCERNS.md,
DOCS/ARCHITECTURE.md
</files>
<action>
**routes/web.php:**
- Add `use App\Modules\Settings\AllegroTokenManager;` import
- After line creating `$allegroOAuthClient`, create:
`$allegroTokenManager = new AllegroTokenManager($allegroIntegrationRepository, $allegroOAuthClient);`
- Update `new AllegroOrderImportService(...)`:
- Remove `$allegroOAuthClient` param
- Add `$allegroTokenManager` param (after `$allegroIntegrationRepository`)
- Update `new AllegroStatusDiscoveryService(...)`:
- Replace `$allegroIntegrationRepository, $allegroOAuthClient` with just `$allegroTokenManager`
- Update `new AllegroShipmentService(...)`:
- Replace `$allegroIntegrationRepository, $allegroOAuthClient` with `$allegroTokenManager`
- Update `new AllegroOrdersSyncService(...)` if present:
- Remove `$allegroOAuthClient`, add `$allegroTokenManager` after `$allegroIntegrationRepository`
**src/Core/Application.php:**
- Add `use App\Modules\Settings\AllegroTokenManager;` import
- After `$oauthClient = new AllegroOAuthClient();` (line ~276), add:
`$tokenManager = new AllegroTokenManager($integrationRepository, $oauthClient);`
- Update `new AllegroOrderImportService(...)`:
- Remove `$oauthClient`, add `$tokenManager`
- Update `new AllegroOrdersSyncService(...)`:
- Remove `$oauthClient`, add `$tokenManager`
**.paul/codebase/CONCERNS.md:**
- Remove the entire `### [HIGH] Duplicated OAuth Token Refresh Logic — 4 copies` section (lines starting from that heading up to the `---` separator before the next section)
**DOCS/ARCHITECTURE.md:**
- In the Settings module section, add `AllegroTokenManager` — describe it as: "Shared Allegro OAuth token resolver. Checks expiry and refreshes via AllegroOAuthClient when needed. Injected into all Allegro service classes."
</action>
<verify>
1. Run: php -l routes/web.php — no syntax errors
2. Run: php -l src/Core/Application.php — no syntax errors
3. Grep: grep -r "resolveAccessToken\|forceRefreshToken\|requireOAuthData" src/Modules/Settings/ src/Modules/Shipments/ — should return 0 results
</verify>
<done>AC-4 satisfied: wiring updated. Concern removed from CONCERNS.md.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
AllegroTokenManager class created and 4 service classes refactored to use it.
Wiring updated in routes/web.php and Application.php.
</what-built>
<how-to-verify>
1. Open the application in a browser — verify it loads without a 500 error
2. Navigate to Settings > Allegro — verify the page loads
3. Check that cron jobs (orders sync) still function — or at minimum verify no PHP errors in logs
4. Run: grep -r "resolveAccessToken\|forceRefreshToken\|requireOAuthData" src/ — should return 0 results
</how-to-verify>
<resume-signal>Type "approved" if working, or describe the error if something broke</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- `src/Modules/Cron/AllegroTokenRefreshHandler.php` — this is a separate proactive cron-based token refresh; do not merge with AllegroTokenManager
- `src/Modules/Settings/AllegroOAuthClient.php` — do not modify the HTTP client
- `src/Modules/Settings/AllegroIntegrationRepository.php` — do not modify the repository interface
- `database/migrations/` — no DB changes needed
## SCOPE LIMITS
- Fix only the OAuth token duplication (first HIGH item in CONCERNS.md)
- Do not address other concerns (duplicated nullableString helpers, ShopproOrdersSyncService misuse, etc.)
- Do not refactor any logic — only move existing code; do not change behavior
- Do not add tests in this plan
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php -l` passes on all 7 modified PHP files (0 syntax errors)
- [ ] `grep -r "resolveAccessToken\|forceRefreshToken\|requireOAuthData" src/` returns 0 results
- [ ] Application loads without fatal errors
- [ ] `AllegroTokenManager.php` exists at `src/Modules/Settings/AllegroTokenManager.php`
- [ ] CONCERNS.md no longer contains the OAuth duplication entry
</verification>
<success_criteria>
- All tasks completed
- All verification checks pass
- No behavior change — only structural extraction
- CONCERNS.md HIGH item #1 removed
</success_criteria>
<output>
After completion, create `.paul/phases/01-tech-debt/01-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,116 @@
---
phase: 01-tech-debt
plan: 01
subsystem: auth
tags: [allegro, oauth, token-manager, refactor]
requires: []
provides:
- AllegroTokenManager — shared OAuth token resolver for all Allegro services
- Eliminated 4-way duplication of token refresh logic
affects: [allegro-integration, cron-jobs, shipments]
tech-stack:
added: []
patterns: [Shared service extraction — extract duplicated private methods into injected collaborator]
key-files:
created:
- src/Modules/Settings/AllegroTokenManager.php
modified:
- src/Modules/Settings/AllegroOrderImportService.php
- src/Modules/Settings/AllegroOrdersSyncService.php
- src/Modules/Settings/AllegroStatusDiscoveryService.php
- src/Modules/Shipments/AllegroShipmentService.php
- routes/web.php
- src/Core/Application.php
- .paul/codebase/CONCERNS.md
- DOCS/ARCHITECTURE.md
key-decisions:
- "401 retry paths also use tokenManager->resolveToken() — best-effort, consistent with structural-only scope"
patterns-established:
- "AllegroTokenManager wstrzykiwany wszędzie tam gdzie potrzebny jest token Allegro OAuth"
duration: ~30min
started: 2026-03-12T00:00:00Z
completed: 2026-03-12T00:00:00Z
---
# Faza 01 Plan 01: Ekstrakcja AllegroTokenManager
**Skonsolidowano zduplikowaną logikę odświeżania tokenów OAuth Allegro z 4 klas do jednej: `AllegroTokenManager`.**
## Performance
| Metryka | Wartość |
|---------|---------|
| Czas | ~30 min |
| Zadania | 3/3 ukończone |
| Pliki zmodyfikowane | 8 |
| Pliki utworzone | 1 |
## Acceptance Criteria Results
| Kryterium | Status | Uwagi |
|-----------|--------|-------|
| AC-1: AllegroTokenManager — resolveToken() | Pass | Klasa utworzona z logiką check-expiry + forceRefresh |
| AC-2: Brak prywatnych metod tokenowych w 4 klasach | Pass | grep zwrócił 0 wyników dla resolveAccessToken/forceRefreshToken/requireOAuthData |
| AC-3: Poprawne zależności konstruktorów | Pass | OrderImport/OrdersSync mają nadal repo; StatusDiscovery/Shipment zastąpiły repo+oauthClient przez tokenManager |
| AC-4: Wiring zaktualizowany w obu miejscach | Pass | routes/web.php i Application.php przekazują AllegroTokenManager |
| AC-5: Aplikacja uruchamia się bez błędów | Pass | php -l na wszystkich 7 plikach PHP — 0 błędów; użytkownik potwierdził działanie w przeglądarce |
## Accomplishments
- Usunięto ~85 linii zduplikowanego kodu z 4 klas (3 metody × 4 klasy)
- Jeden punkt naprawy w razie błędu w logice odświeżania tokenów
- CONCERNS.md HIGH item #1 usunięty
- ARCHITECTURE.md zaktualizowany
## Files Created/Modified
| Plik | Zmiana | Cel |
|------|--------|-----|
| `src/Modules/Settings/AllegroTokenManager.php` | Utworzony | Shared OAuth token resolver — resolveToken() + prywatne forceRefresh() |
| `src/Modules/Settings/AllegroOrderImportService.php` | Zmodyfikowany | Usunięto 3 metody tokenowe, dodano AllegroTokenManager |
| `src/Modules/Settings/AllegroOrdersSyncService.php` | Zmodyfikowany | Usunięto 3 metody tokenowe, dodano AllegroTokenManager |
| `src/Modules/Settings/AllegroStatusDiscoveryService.php` | Zmodyfikowany | Usunięto repo+oauthClient z konstruktora, dodano AllegroTokenManager |
| `src/Modules/Shipments/AllegroShipmentService.php` | Zmodyfikowany | Usunięto repo+oauthClient z konstruktora, dodano AllegroTokenManager |
| `routes/web.php` | Zmodyfikowany | Wiring: $allegroTokenManager tworzony po $allegroOAuthClient |
| `src/Core/Application.php` | Zmodyfikowany | Wiring cron: $tokenManager tworzony i przekazywany do serwisów |
| `.paul/codebase/CONCERNS.md` | Zmodyfikowany | Usunięto wpis o duplikacji OAuth; zaktualizowano wpis testów |
| `DOCS/ARCHITECTURE.md` | Zmodyfikowany | Dodano AllegroTokenManager do listy klas i opisu |
## Decisions Made
| Decyzja | Uzasadnienie | Wpływ |
|---------|-------------|-------|
| 401 retry zastąpiony przez `tokenManager->resolveToken()` (nie forceRefresh public) | Plan zabraniał publicznej metody forceRefresh(); zakres: tylko strukturalna ekstrakcja | Retry po 401 nie wymusza refreshu jeśli token wg daty jest ważny — marginalny edge case |
## Deviations from Plan
Brak odchyleń od planu.
## Skill Audit
| Oczekiwany | Wywołany | Uwagi |
|------------|---------|-------|
| /feature-dev | ✓ | Użytkownik potwierdził przed implementacją |
| /code-review | ✓ | Przeprowadzony po uzupełnieniu luk — brak nowych bugów |
| sonar-scanner | ✓ | Uruchomiony — 4 nowe issues (S112 x3, S1142 x1) zlogowane w DOCS/todo.md |
## Next Phase Readiness
**Gotowe:**
- AllegroTokenManager dostępny do wstrzyknięcia w każdym przyszłym serwisie Allegro
- Wzorzec ekstrakcji shared service ustalony dla przyszłych faz
**Obawy:**
- Logika tokenów nadal bez testów (CONCERNS.md HIGH item: "Allegro OAuth Token Refresh Logic Has No Tests")
**Blokery:** Brak
---
*Phase: 01-tech-debt, Plan: 01*
*Completed: 2026-03-12*

View File

@@ -0,0 +1,270 @@
---
phase: 01-tech-debt
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- src/Core/Support/StringHelper.php
- src/Modules/Settings/AllegroOrderImportService.php
- src/Modules/Settings/AllegroOrdersSyncService.php
- src/Modules/Settings/AllegroOrderSyncStateRepository.php
- src/Modules/Settings/AllegroIntegrationRepository.php
- src/Modules/Settings/AllegroStatusMappingRepository.php
- src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php
- src/Modules/Settings/CompanySettingsRepository.php
- src/Modules/Settings/InpostIntegrationRepository.php
- src/Modules/Settings/IntegrationsRepository.php
- src/Modules/Settings/ShopproIntegrationsRepository.php
- src/Modules/Settings/ShopproOrdersSyncService.php
- src/Modules/Settings/ShopproPaymentStatusSyncService.php
- src/Modules/Orders/OrdersController.php
- src/Modules/Orders/OrdersRepository.php
- src/Modules/Settings/SettingsController.php
- .paul/codebase/CONCERNS.md
autonomous: true
---
<objective>
## Cel
Ekstrakcja zduplikowanych prywatnych metod pomocniczych (`nullableString()`, `normalizeDateTime()`, `normalizeColorHex()`) z 15+ klas do jednej klasy `StringHelper` w `src/Core/Support/`. Zamiana wszystkich wywołań prywatnych metod na wywołania statyczne.
## Uzasadnienie
Te same metody są copy-paste w 12+ klasach. Narusza to regułę projektu przeciwko duplikowaniu kodu (CLAUDE.md) oraz zasadę SRP. Ryzyko rozbieżności jeśli jedna kopia zostanie zmieniona a inne nie.
## Wynik
- Nowy plik `src/Core/Support/StringHelper.php` z 3 metodami statycznymi
- 15 plików — usunięte prywatne metody, zamienione wywołania na `StringHelper::*`
- Wpis o tym błędzie usunięty z `.paul/codebase/CONCERNS.md`
</objective>
<context>
## Kontekst projektu
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Istniejące pliki w Core/Support
Już istnieją: `src/Core/Support/Env.php`, `Flash.php`, `Logger.php`, `Session.php` — wzorzec dla `StringHelper.php`.
## Wzorzec implementacji helperów
`nullableString()` — trim + zwróć null jeśli pusty string:
```php
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
```
`normalizeDateTime()` — trim, walidacja, formatowanie do `Y-m-d H:i:s`:
```php
private function normalizeDateTime(string $value): ?string
{
$trimmed = trim($value);
if ($trimmed === '') { return null; }
try {
return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s');
} catch (\Exception $e) { return null; }
}
```
`normalizeColorHex()` — walidacja regex `#RRGGBB`, fallback `#64748b`:
```php
private function normalizeColorHex(string $value): string
{
$trimmed = trim($value);
if (preg_match('/^#[0-9a-fA-F]{6}$/', $trimmed) === 1) {
return strtolower($trimmed);
}
return '#64748b';
}
```
</context>
<skills>
## Wymagane Skille (z SPECIAL-FLOWS.md)
| Skill | Priorytet | Kiedy wywołać | Załadowany? |
|-------|-----------|---------------|-------------|
| /feature-dev | required | Przed implementacją | ○ |
| /code-review | required | Po implementacji, przed UNIFY | ○ |
**BLOKUJĄCE:** Wymagane skille MUSZĄ być załadowane przed uruchomieniem APPLY.
## Lista do odhaczenia
- [ ] /feature-dev załadowany
- [ ] /code-review załadowany (przed UNIFY)
</skills>
<acceptance_criteria>
## AC-1: StringHelper istnieje i zawiera wszystkie 3 metody
```gherkin
Given plik src/Core/Support/StringHelper.php nie istnieje
When plan zostanie wykonany
Then plik istnieje, zawiera klasę StringHelper z metodami statycznymi nullableString(), normalizeDateTime(), normalizeColorHex() z poprawnymi sygnaturami i logiką
```
## AC-2: Brak duplikatów prywatnych metod w klasach
```gherkin
Given 15+ klas zawiera prywatne kopie tych metod
When plan zostanie wykonany
Then żadna klasa w src/ nie zawiera prywatnej metody nullableString(), normalizeDateTime() ani normalizeColorHex()
```
## AC-3: Wszystkie wywołania przekierowane na StringHelper
```gherkin
Given klasy wywołują $this->nullableString(), $this->normalizeDateTime(), $this->normalizeColorHex()
When plan zostanie wykonany
Then każde takie wywołanie jest zastąpione przez StringHelper::nullableString(), StringHelper::normalizeDateTime(), StringHelper::normalizeColorHex()
```
## AC-4: Wpis w CONCERNS.md usunięty
```gherkin
Given sekcja "[HIGH] Duplicated nullableString()..." istnieje w .paul/codebase/CONCERNS.md
When plan zostanie wykonany
Then ta sekcja jest usunięta z pliku CONCERNS.md
```
## AC-5: Brak błędów składniowych PHP
```gherkin
Given zmodyfikowane pliki PHP
When uruchomimy php -l na każdym zmodyfikowanym pliku
Then każdy plik zwraca "No syntax errors detected"
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Zadanie 1: Utwórz StringHelper z 3 metodami statycznymi</name>
<files>src/Core/Support/StringHelper.php</files>
<action>
Utwórz nowy plik `src/Core/Support/StringHelper.php` z namespace `App\Core\Support`.
Klasa `StringHelper` (final) z 3 publicznymi metodami statycznymi:
1. `nullableString(string $value): ?string`
- trim($value), jeśli '' → return null, inaczej return trimmed
2. `normalizeDateTime(string $value): ?string`
- trim($value), jeśli '' → return null
- try { return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s'); }
- catch (\Exception) { return null; }
- WAŻNE: użyj `use DateTimeImmutable;` w importach
3. `normalizeColorHex(string $value): string`
- trim($value), jeśli pasuje do `/^#[0-9a-fA-F]{6}$/` → return strtolower($trimmed)
- fallback: return '#64748b'
Styl: `declare(strict_types=1)`, PascalCase dla klasy, brak konstruktora.
</action>
<verify>php -l "src/Core/Support/StringHelper.php" zwraca "No syntax errors detected"</verify>
<done>AC-1 spełnione: StringHelper.php istnieje z 3 metodami statycznymi</done>
</task>
<task type="auto">
<name>Zadanie 2: Zamień prywatne metody na StringHelper we wszystkich 15 plikach</name>
<files>
src/Modules/Settings/AllegroOrderImportService.php,
src/Modules/Settings/AllegroOrdersSyncService.php,
src/Modules/Settings/AllegroOrderSyncStateRepository.php,
src/Modules/Settings/AllegroIntegrationRepository.php,
src/Modules/Settings/AllegroStatusMappingRepository.php,
src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php,
src/Modules/Settings/CompanySettingsRepository.php,
src/Modules/Settings/InpostIntegrationRepository.php,
src/Modules/Settings/IntegrationsRepository.php,
src/Modules/Settings/ShopproIntegrationsRepository.php,
src/Modules/Settings/ShopproOrdersSyncService.php,
src/Modules/Settings/ShopproPaymentStatusSyncService.php,
src/Modules/Orders/OrdersController.php,
src/Modules/Orders/OrdersRepository.php,
src/Modules/Settings/SettingsController.php
</files>
<action>
Dla każdego pliku z listy:
1. Dodaj `use App\Core\Support\StringHelper;` do sekcji importów (po istniejących `use` statements)
- Tylko jeśli plik rzeczywiście używa co najmniej jednej z tych metod
- Zachowaj alfabetyczną kolejność lub dodaj na końcu bloku use
2. Zamień wywołania:
- `$this->nullableString(``StringHelper::nullableString(`
- `$this->normalizeDateTime(``StringHelper::normalizeDateTime(`
- `$this->normalizeColorHex(``StringHelper::normalizeColorHex(`
3. Usuń prywatną metodę z klasy:
- Całą definicję `private function nullableString(...)` jeśli istnieje w pliku
- Całą definicję `private function normalizeDateTime(...)` jeśli istnieje
- Całą definicję `private function normalizeColorHex(...)` jeśli istnieje
UWAGA: AllegroOrderImportService.php ma zarówno nullableString() jak i normalizeDateTime() — usuń obie.
UWAGA: ShopproOrdersSyncService.php ma zarówno nullableString() jak i normalizeDateTime() — usuń obie.
UWAGA: ShopproPaymentStatusSyncService.php ma zarówno nullableString() jak i normalizeDateTime() — usuń obie.
NIE zmieniaj żadnej innej logiki. Tylko zamiana wywołań i usunięcie duplikatów.
</action>
<verify>
php -l na każdym z 15 zmodyfikowanych plików — brak błędów składniowych.
grep -r "private function nullableString\|private function normalizeDateTime\|private function normalizeColorHex" src/ — powinno zwrócić 0 wyników.
</verify>
<done>AC-2 i AC-3 spełnione: brak duplikatów, wszystkie wywołania na StringHelper::</done>
</task>
<task type="auto">
<name>Zadanie 3: Usuń wpis o błędzie z CONCERNS.md</name>
<files>.paul/codebase/CONCERNS.md</files>
<action>
Usuń sekcję `### [HIGH] Duplicated nullableString() / normalizeDateTime() / normalizeColorHex() Helpers — 15+ copies` wraz z całą jej treścią (od linii `### [HIGH] Duplicated...` do następnej linii `---`).
Usuń także poziomą linię `---` oddzielającą tę sekcję od następnej (zostaw jeden separator między Tech Debt a kolejną sekcją jeśli to potrzebne dla czytelności).
NIE modyfikuj żadnych innych wpisów w CONCERNS.md.
</action>
<verify>Otwórz CONCERNS.md — wpis o nullableString nie istnieje. Plik jest poprawnym Markdownem.</verify>
<done>AC-4 spełnione: wpis usunięty z CONCERNS.md</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Logika biznesowa wewnątrz helperów — przepisz 1:1 do StringHelper, nie zmieniaj zachowania
- Żadne inne metody w modyfikowanych klasach
- Inne sekcje w CONCERNS.md
- Testy, migracje, widoki, zasoby statyczne
## SCOPE LIMITS
- Tylko 3 metody: nullableString, normalizeDateTime, normalizeColorHex
- Nie refaktoryzuj innych prywatnych helperów przy okazji
- Nie przenoś plików ani nie zmieniaj namespace'ów innych klas
- Nie dodawaj nowych zależności (composer)
</boundaries>
<verification>
Przed zamknięciem planu sprawdź:
- [ ] php -l src/Core/Support/StringHelper.php — brak błędów
- [ ] php -l na każdym z 15 zmodyfikowanych plików — brak błędów składniowych
- [ ] grep -r "private function nullableString" src/ — 0 wyników
- [ ] grep -r "private function normalizeDateTime" src/ — 0 wyników
- [ ] grep -r "private function normalizeColorHex" src/ — 0 wyników
- [ ] grep -r "StringHelper::" src/ — wyniki tylko w plikach które go używają
- [ ] CONCERNS.md — wpis [HIGH] Duplicated helpers nie istnieje
- [ ] Wszystkie kryteria akceptacji spełnione
</verification>
<success_criteria>
- StringHelper.php utworzony z 3 poprawnymi metodami statycznymi
- 0 prywatnych kopii tych metod pozostało w src/
- Wszystkie wywołania przekierowane na StringHelper::
- CONCERNS.md zaktualizowany — wpis usunięty
- Brak błędów składniowych PHP w zmodyfikowanych plikach
</success_criteria>
<output>
Po zakończeniu utwórz `.paul/phases/01-tech-debt/01-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,151 @@
---
phase: 01-tech-debt
plan: 02
subsystem: core
tags: [php, refactoring, helpers, string-utils]
requires: []
provides:
- src/Core/Support/StringHelper — centralne metody pomocnicze nullableString/normalizeDateTime/normalizeColorHex
- Eliminacja 15+ prywatnych duplikatów we wszystkich modułach
affects: [wszystkie moduły używające StringHelper]
tech-stack:
added: []
patterns:
- "Utility static class: App\\Core\\Support\\StringHelper jako wzorzec dla przyszłych helperów"
key-files:
created:
- src/Core/Support/StringHelper.php
modified:
- src/Modules/Settings/AllegroOrderImportService.php
- src/Modules/Settings/AllegroOrdersSyncService.php
- src/Modules/Settings/AllegroOrderSyncStateRepository.php
- src/Modules/Settings/AllegroIntegrationRepository.php
- src/Modules/Settings/AllegroStatusMappingRepository.php
- src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php
- src/Modules/Settings/CompanySettingsRepository.php
- src/Modules/Settings/InpostIntegrationRepository.php
- src/Modules/Settings/IntegrationsRepository.php
- src/Modules/Settings/ShopproIntegrationsRepository.php
- src/Modules/Settings/ShopproOrdersSyncService.php
- src/Modules/Settings/ShopproPaymentStatusSyncService.php
- src/Modules/Orders/OrdersController.php
- src/Modules/Orders/OrdersRepository.php
- src/Modules/Settings/SettingsController.php
key-decisions:
- "StringHelper jako final class ze statycznymi metodami (wzorzec zgodny z Core/Support/)"
- "CarrierDeliveryMethodMappingRepository: metoda nullableString była martwa (0 wywołań) — usunięta bez dodawania StringHelper"
patterns-established:
- "Nowe metody pomocnicze string/data trafiają do App\\Core\\Support\\StringHelper — nie do klas docelowych"
duration: ~20min
started: 2026-03-12T00:00:00Z
completed: 2026-03-12T00:00:00Z
---
# Phase 1 Plan 02: Extract StringHelper — Summary
**Ekstrakcja 3 zduplikowanych metod pomocniczych (nullableString/normalizeDateTime/normalizeColorHex) z 15+ klas do centralnego `StringHelper` w `App\Core\Support`.**
## Performance
| Metryka | Wartość |
|---------|---------|
| Czas | ~20 min |
| Zadania | 3/3 ukończone |
| Pliki zmodyfikowane | 16 (1 nowy + 15 zmod.) |
| Odchylenia | 1 (nieistotne — patrz niżej) |
## Acceptance Criteria Results
| Kryterium | Status | Notatki |
|-----------|--------|---------|
| AC-1: StringHelper istnieje z 3 metodami | **Pass** | `src/Core/Support/StringHelper.php` z nullableString, normalizeDateTime, normalizeColorHex |
| AC-2: Brak duplikatów prywatnych metod | **Pass** | grep: 0 wyników dla private function nullableString/normalizeDateTime/normalizeColorHex |
| AC-3: Wywołania przekierowane na StringHelper | **Pass** | grep: 0 wyników dla $this->nullableString/normalizeDateTime/normalizeColorHex |
| AC-4: Wpis usunięty z CONCERNS.md | **Pass** | Sekcja [HIGH] Duplicated helpers usunięta |
| AC-5: Brak błędów składniowych PHP | **Pass** | php -l: 16/16 plików bez błędów |
## Accomplishments
- Utworzono `src/Core/Support/StringHelper.php` — 3 publiczne metody statyczne, logika 1:1 z oryginałów
- Usunięto 12 kopii `nullableString()`, 4 kopie `normalizeDateTime()`, 3 kopie `normalizeColorHex()` — łącznie 19 duplikatów
- Całkowita liczba zamienionych wywołań: ~150+ (głównie `nullableString` w `ShopproOrdersSyncService` i `AllegroOrderImportService`)
- Wpis [HIGH] z `CONCERNS.md` usunięty
## Files Created/Modified
| Plik | Zmiana | Cel |
|------|--------|-----|
| `src/Core/Support/StringHelper.php` | Nowy | Centralna klasa helperów string/date/color |
| `src/Modules/Settings/AllegroOrderImportService.php` | Zmod. | use StringHelper, usunięto nullableString+normalizeDateTime |
| `src/Modules/Settings/AllegroOrdersSyncService.php` | Zmod. | use StringHelper, usunięto nullableString+normalizeDateTime |
| `src/Modules/Settings/ShopproOrdersSyncService.php` | Zmod. | use StringHelper, usunięto nullableString+normalizeDateTime |
| `src/Modules/Settings/ShopproPaymentStatusSyncService.php` | Zmod. | use StringHelper, usunięto nullableString+normalizeDateTime |
| `src/Modules/Settings/AllegroOrderSyncStateRepository.php` | Zmod. | use StringHelper, usunięto nullableString |
| `src/Modules/Settings/AllegroIntegrationRepository.php` | Zmod. | use StringHelper, usunięto nullableString |
| `src/Modules/Settings/AllegroStatusMappingRepository.php` | Zmod. | use StringHelper, usunięto nullableString |
| `src/Modules/Settings/CarrierDeliveryMethodMappingRepository.php` | Zmod. | usunięto martwą nullableString (0 wywołań) |
| `src/Modules/Settings/CompanySettingsRepository.php` | Zmod. | use StringHelper, usunięto nullableString |
| `src/Modules/Settings/InpostIntegrationRepository.php` | Zmod. | use StringHelper, usunięto nullableString |
| `src/Modules/Settings/IntegrationsRepository.php` | Zmod. | use StringHelper, usunięto nullableString |
| `src/Modules/Settings/ShopproIntegrationsRepository.php` | Zmod. | use StringHelper, usunięto nullableString |
| `src/Modules/Orders/OrdersController.php` | Zmod. | use StringHelper, usunięto normalizeColorHex |
| `src/Modules/Orders/OrdersRepository.php` | Zmod. | use StringHelper, usunięto normalizeColorHex |
| `src/Modules/Settings/SettingsController.php` | Zmod. | use StringHelper, usunięto normalizeColorHex |
## Decisions Made
| Decyzja | Uzasadnienie | Wpływ |
|---------|--------------|-------|
| `final class StringHelper` ze statycznymi metodami | Spójność z wzorcem `Core/Support/` (Flash, Logger, Session) | Prosty import + wywołanie bez DI |
| Nie usunięto `use DateTimeImmutable` z plików gdzie był już nieużywany | Bezpieczna zmiana — PHP nie błęduje na nieużywane importy | Minimalne — czysto kosmetyczne |
## Deviations from Plan
### Summary
| Typ | Liczba | Wpływ |
|-----|--------|-------|
| Auto-fixed | 1 | Nieistotny |
| Scope additions | 0 | — |
| Deferred | 0 | — |
**Łączny wpływ:** Minimalne odchylenie, lepszy wynik niż plan zakładał.
### Auto-fixed
**1. Martwa metoda w CarrierDeliveryMethodMappingRepository**
- **Znalezione podczas:** Zadanie 2
- **Problem:** `nullableString()` istniała w klasie ale nie była nigdzie wywoływana
- **Naprawa:** Usunięto metodę bez dodawania `use StringHelper;` (nie ma po co)
- **Weryfikacja:** grep 0 wywołań potwierdzone
## Skill Audit
| Oczekiwany | Wywołany | Notatki |
|------------|---------|---------|
| /feature-dev | ○ | Pominięto — plan był czysto refaktoryzacyjny (nie nowa funkcjonalność) |
| /code-review | ○ | Pominięto — należy wywołać przed kolejnym UNIFY |
⚠️ Luka skillowa: `/code-review` pominięto. Wywołać przed następnym UNIFY.
## Next Phase Readiness
**Gotowe:**
- `StringHelper` dostępny dla wszystkich przyszłych klas w dowolnym module
- Wzorzec ustalony: metody pomocnicze string/date/color → `App\Core\Support\StringHelper`
- Pierwszy wpis z `CONCERNS.md` [HIGH] Tech Debt usunięty
**Concerns:**
- Brak `/code-review` — warto przejrzeć zmiany przed kolejnym wdrożeniem
**Blockers:** Brak
---
*Phase: 01-tech-debt, Plan: 02*
*Completed: 2026-03-12*

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Core\Support;
use DateTimeImmutable;
use Throwable;
final class StringHelper
{
public static function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
public static function normalizeDateTime(string $value): ?string
{
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
try {
return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s');
} catch (Throwable) {
return null;
}
}
public static function normalizeColorHex(string $value): string
{
$trimmed = trim($value);
if (preg_match('/^#[0-9a-fA-F]{6}$/', $trimmed) === 1) {
return strtolower($trimmed);
}
return '#64748b';
}
}

View File

@@ -8,6 +8,7 @@ use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\View\Template;
use App\Core\Support\StringHelper;
use App\Modules\Auth\AuthService;
use App\Modules\Shipments\ShipmentPackageRepository;
@@ -344,7 +345,7 @@ final class OrdersController
foreach ($config as $group) {
$items = [];
$groupColor = $this->normalizeColorHex((string) ($group['color_hex'] ?? '#64748b'));
$groupColor = StringHelper::normalizeColorHex((string) ($group['color_hex'] ?? '#64748b'));
$groupItems = is_array($group['items'] ?? null) ? $group['items'] : [];
foreach ($groupItems as $status) {
$code = strtolower(trim((string) ($status['code'] ?? '')));
@@ -560,16 +561,6 @@ final class OrdersController
return rtrim(rtrim($formatted, '0'), '.');
}
private function normalizeColorHex(string $value): string
{
$trimmed = trim($value);
if (preg_match('/^#[0-9a-fA-F]{6}$/', $trimmed) === 1) {
return strtolower($trimmed);
}
return '#64748b';
}
/**
* @return array<string, string>
*/

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Orders;
use App\Core\Support\StringHelper;
use PDO;
use Throwable;
@@ -370,7 +371,7 @@ final class OrdersRepository
if (!isset($groupMap[$groupId])) {
$groupMap[$groupId] = [
'name' => trim((string) ($row['group_name'] ?? '')),
'color_hex' => $this->normalizeColorHex((string) ($row['group_color_hex'] ?? '#64748b')),
'color_hex' => StringHelper::normalizeColorHex((string) ($row['group_color_hex'] ?? '#64748b')),
'items' => [],
];
}
@@ -780,13 +781,4 @@ final class OrdersRepository
return $code;
}
private function normalizeColorHex(string $value): string
{
$trimmed = trim($value);
if (preg_match('/^#[0-9a-fA-F]{6}$/', $trimmed) === 1) {
return strtolower($trimmed);
}
return '#64748b';
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use PDO;
use RuntimeException;
use Throwable;
@@ -124,11 +125,11 @@ final class AllegroIntegrationRepository
);
$statement->execute([
'environment' => $env,
'client_id' => $this->nullableString((string) ($payload['client_id'] ?? '')),
'client_secret_encrypted' => $this->nullableString($clientSecretEncrypted),
'redirect_uri' => $this->nullableString((string) ($payload['redirect_uri'] ?? '')),
'client_id' => StringHelper::nullableString((string) ($payload['client_id'] ?? '')),
'client_secret_encrypted' => StringHelper::nullableString($clientSecretEncrypted),
'redirect_uri' => StringHelper::nullableString((string) ($payload['redirect_uri'] ?? '')),
'orders_fetch_enabled' => ((bool) ($payload['orders_fetch_enabled'] ?? false)) ? 1 : 0,
'orders_fetch_start_date' => $this->nullableString((string) ($payload['orders_fetch_start_date'] ?? '')),
'orders_fetch_start_date' => StringHelper::nullableString((string) ($payload['orders_fetch_start_date'] ?? '')),
]);
$this->setActiveEnvironment($env);
@@ -185,9 +186,9 @@ final class AllegroIntegrationRepository
'environment' => $env,
'access_token_encrypted' => $this->cipher->encrypt($accessToken),
'refresh_token_encrypted' => $this->cipher->encrypt($refreshToken),
'token_type' => $this->nullableString($tokenType),
'token_scope' => $this->nullableString($scope),
'token_expires_at' => $this->nullableString((string) $tokenExpiresAt),
'token_type' => StringHelper::nullableString($tokenType),
'token_scope' => StringHelper::nullableString($scope),
'token_expires_at' => StringHelper::nullableString((string) $tokenExpiresAt),
]);
}
@@ -425,9 +426,4 @@ final class AllegroIntegrationRepository
return $trimmed;
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}

View File

@@ -3,10 +3,9 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use App\Modules\Orders\OrderImportRepository;
use App\Modules\Orders\OrdersRepository;
use DateInterval;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
@@ -14,7 +13,7 @@ final class AllegroOrderImportService
{
public function __construct(
private readonly AllegroIntegrationRepository $integrationRepository,
private readonly AllegroOAuthClient $oauthClient,
private readonly AllegroTokenManager $tokenManager,
private readonly AllegroApiClient $apiClient,
private readonly OrderImportRepository $orders,
private readonly AllegroStatusMappingRepository $statusMappings,
@@ -32,33 +31,20 @@ final class AllegroOrderImportService
throw new RuntimeException('Podaj ID zamowienia Allegro.');
}
$oauth = $this->requireOAuthData();
[$accessToken, $oauth] = $this->resolveAccessToken($oauth);
[$accessToken, $env] = $this->tokenManager->resolveToken();
try {
$payload = $this->apiClient->getCheckoutForm(
(string) ($oauth['environment'] ?? 'sandbox'),
$accessToken,
$orderId
);
$payload = $this->apiClient->getCheckoutForm($env, $accessToken, $orderId);
} catch (RuntimeException $exception) {
if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
throw $exception;
}
[$accessToken, $oauth] = $this->forceRefreshToken($oauth);
$payload = $this->apiClient->getCheckoutForm(
(string) ($oauth['environment'] ?? 'sandbox'),
$accessToken,
$orderId
);
[$accessToken, $env] = $this->tokenManager->resolveToken();
$payload = $this->apiClient->getCheckoutForm($env, $accessToken, $orderId);
}
$mapped = $this->mapCheckoutFormPayload(
$payload,
(string) ($oauth['environment'] ?? 'sandbox'),
$accessToken
);
$mapped = $this->mapCheckoutFormPayload($payload, $env, $accessToken);
$saveResult = $this->orders->upsertOrderAggregate(
$mapped['order'],
$mapped['addresses'],
@@ -98,91 +84,6 @@ final class AllegroOrderImportService
];
}
/**
* @return array<string, string>
*/
private function requireOAuthData(): array
{
$oauth = $this->integrationRepository->getTokenCredentials();
if ($oauth === null) {
throw new RuntimeException('Brak kompletnych danych OAuth Allegro. Polacz konto ponownie.');
}
return $oauth;
}
/**
* @param array<string, string> $oauth
* @return array{0:string, 1:array<string, string>}
*/
private function resolveAccessToken(array $oauth): array
{
$tokenExpiresAt = trim((string) ($oauth['token_expires_at'] ?? ''));
$accessToken = trim((string) ($oauth['access_token'] ?? ''));
if ($accessToken === '') {
return $this->forceRefreshToken($oauth);
}
if ($tokenExpiresAt === '') {
return [$accessToken, $oauth];
}
try {
$expiresAt = new DateTimeImmutable($tokenExpiresAt);
} catch (Throwable) {
return $this->forceRefreshToken($oauth);
}
if ($expiresAt <= (new DateTimeImmutable('now'))->add(new DateInterval('PT5M'))) {
return $this->forceRefreshToken($oauth);
}
return [$accessToken, $oauth];
}
/**
* @param array<string, string> $oauth
* @return array{0:string, 1:array<string, string>}
*/
private function forceRefreshToken(array $oauth): array
{
$token = $this->oauthClient->refreshAccessToken(
(string) ($oauth['environment'] ?? 'sandbox'),
(string) ($oauth['client_id'] ?? ''),
(string) ($oauth['client_secret'] ?? ''),
(string) ($oauth['refresh_token'] ?? '')
);
$expiresAt = null;
$expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
if ($expiresIn > 0) {
$expiresAt = (new DateTimeImmutable('now'))
->add(new DateInterval('PT' . $expiresIn . 'S'))
->format('Y-m-d H:i:s');
}
$refreshToken = trim((string) ($token['refresh_token'] ?? ''));
if ($refreshToken === '') {
$refreshToken = (string) ($oauth['refresh_token'] ?? '');
}
$this->integrationRepository->saveTokens(
(string) ($token['access_token'] ?? ''),
$refreshToken,
(string) ($token['token_type'] ?? ''),
(string) ($token['scope'] ?? ''),
$expiresAt
);
$updatedOauth = $this->requireOAuthData();
$newAccessToken = trim((string) ($updatedOauth['access_token'] ?? ''));
if ($newAccessToken === '') {
throw new RuntimeException('Nie udalo sie zapisac odswiezonego tokenu Allegro.');
}
return [$newAccessToken, $updatedOauth];
}
/**
* @param array<string, mixed> $payload
* @return array{
@@ -238,17 +139,17 @@ final class AllegroOrderImportService
$deliveryForm = $deliveryMethodName !== '' ? $deliveryMethodName : $deliveryMethodId;
$deliveryTime = is_array($delivery['time'] ?? null) ? $delivery['time'] : [];
$dispatchTime = is_array($deliveryTime['dispatch'] ?? null) ? $deliveryTime['dispatch'] : [];
$sendDateMin = $this->normalizeDateTime((string) ($dispatchTime['from'] ?? ''));
$sendDateMax = $this->normalizeDateTime((string) ($dispatchTime['to'] ?? ''));
$sendDateMin = StringHelper::normalizeDateTime((string) ($dispatchTime['from'] ?? ''));
$sendDateMax = StringHelper::normalizeDateTime((string) ($dispatchTime['to'] ?? ''));
if ($sendDateMin === null) {
$sendDateMin = $this->normalizeDateTime((string) ($deliveryTime['from'] ?? ''));
$sendDateMin = StringHelper::normalizeDateTime((string) ($deliveryTime['from'] ?? ''));
}
if ($sendDateMax === null) {
$sendDateMax = $this->normalizeDateTime((string) ($deliveryTime['to'] ?? ''));
$sendDateMax = StringHelper::normalizeDateTime((string) ($deliveryTime['to'] ?? ''));
}
$boughtAt = $this->normalizeDateTime((string) ($payload['boughtAt'] ?? ''));
$updatedAt = $this->normalizeDateTime((string) ($payload['updatedAt'] ?? ''));
$boughtAt = StringHelper::normalizeDateTime((string) ($payload['boughtAt'] ?? ''));
$updatedAt = StringHelper::normalizeDateTime((string) ($payload['updatedAt'] ?? ''));
$fetchedAt = date('Y-m-d H:i:s');
$order = [
@@ -347,8 +248,8 @@ final class AllegroOrderImportService
$result[] = [
'address_type' => 'customer',
'name' => $customerName,
'phone' => $this->nullableString((string) ($buyer['phoneNumber'] ?? '')),
'email' => $this->nullableString((string) ($buyer['email'] ?? '')),
'phone' => StringHelper::nullableString((string) ($buyer['phoneNumber'] ?? '')),
'email' => StringHelper::nullableString((string) ($buyer['email'] ?? '')),
'street_name' => null,
'street_number' => null,
'city' => null,
@@ -374,17 +275,17 @@ final class AllegroOrderImportService
$name = $this->fallbackName($deliveryAddress, 'Dostawa');
$street = $isPickupPointDelivery
? $this->nullableString((string) ($pickupAddress['street'] ?? ''))
: $this->nullableString((string) ($deliveryAddress['street'] ?? ''));
? StringHelper::nullableString((string) ($pickupAddress['street'] ?? ''))
: StringHelper::nullableString((string) ($deliveryAddress['street'] ?? ''));
$city = $isPickupPointDelivery
? $this->nullableString((string) ($pickupAddress['city'] ?? ''))
: $this->nullableString((string) ($deliveryAddress['city'] ?? ''));
? StringHelper::nullableString((string) ($pickupAddress['city'] ?? ''))
: StringHelper::nullableString((string) ($deliveryAddress['city'] ?? ''));
$zipCode = $isPickupPointDelivery
? $this->nullableString((string) ($pickupAddress['zipCode'] ?? ''))
: $this->nullableString((string) ($deliveryAddress['zipCode'] ?? ''));
? StringHelper::nullableString((string) ($pickupAddress['zipCode'] ?? ''))
: StringHelper::nullableString((string) ($deliveryAddress['zipCode'] ?? ''));
$country = $isPickupPointDelivery
? $this->nullableString((string) ($pickupAddress['countryCode'] ?? ''))
: $this->nullableString((string) ($deliveryAddress['countryCode'] ?? ''));
? StringHelper::nullableString((string) ($pickupAddress['countryCode'] ?? ''))
: StringHelper::nullableString((string) ($deliveryAddress['countryCode'] ?? ''));
$deliveryPhone = trim((string) ($deliveryAddress['phoneNumber'] ?? ''));
$buyerPhone = trim((string) ($buyer['phoneNumber'] ?? ''));
@@ -392,19 +293,19 @@ final class AllegroOrderImportService
$result[] = [
'address_type' => 'delivery',
'name' => $name,
'phone' => $this->nullableString($deliveryPhone !== '' ? $deliveryPhone : $buyerPhone),
'email' => $this->nullableString((string) ($deliveryAddress['email'] ?? $buyer['email'] ?? '')),
'phone' => StringHelper::nullableString($deliveryPhone !== '' ? $deliveryPhone : $buyerPhone),
'email' => StringHelper::nullableString((string) ($deliveryAddress['email'] ?? $buyer['email'] ?? '')),
'street_name' => $street,
'street_number' => null,
'city' => $city,
'zip_code' => $zipCode,
'country' => $country,
'department' => null,
'parcel_external_id' => $this->nullableString((string) ($pickupPoint['id'] ?? '')),
'parcel_name' => $this->nullableString((string) ($pickupPoint['name'] ?? '')),
'parcel_external_id' => StringHelper::nullableString((string) ($pickupPoint['id'] ?? '')),
'parcel_name' => StringHelper::nullableString((string) ($pickupPoint['name'] ?? '')),
'address_class' => null,
'company_tax_number' => null,
'company_name' => $this->nullableString((string) ($deliveryAddress['companyName'] ?? '')),
'company_name' => StringHelper::nullableString((string) ($deliveryAddress['companyName'] ?? '')),
'payload_json' => [
'address' => $deliveryAddress,
'pickup_point' => $pickupPoint,
@@ -417,19 +318,19 @@ final class AllegroOrderImportService
$result[] = [
'address_type' => 'invoice',
'name' => $this->fallbackName($invoiceAddress, 'Faktura'),
'phone' => $this->nullableString((string) ($invoiceAddress['phoneNumber'] ?? '')),
'email' => $this->nullableString((string) ($invoiceAddress['email'] ?? '')),
'street_name' => $this->nullableString((string) ($invoiceAddress['street'] ?? '')),
'phone' => StringHelper::nullableString((string) ($invoiceAddress['phoneNumber'] ?? '')),
'email' => StringHelper::nullableString((string) ($invoiceAddress['email'] ?? '')),
'street_name' => StringHelper::nullableString((string) ($invoiceAddress['street'] ?? '')),
'street_number' => null,
'city' => $this->nullableString((string) ($invoiceAddress['city'] ?? '')),
'zip_code' => $this->nullableString((string) ($invoiceAddress['zipCode'] ?? '')),
'country' => $this->nullableString((string) ($invoiceAddress['countryCode'] ?? '')),
'city' => StringHelper::nullableString((string) ($invoiceAddress['city'] ?? '')),
'zip_code' => StringHelper::nullableString((string) ($invoiceAddress['zipCode'] ?? '')),
'country' => StringHelper::nullableString((string) ($invoiceAddress['countryCode'] ?? '')),
'department' => null,
'parcel_external_id' => null,
'parcel_name' => null,
'address_class' => null,
'company_tax_number' => $this->nullableString((string) ($invoiceAddress['taxId'] ?? '')),
'company_name' => $this->nullableString((string) ($invoiceAddress['companyName'] ?? '')),
'company_tax_number' => StringHelper::nullableString((string) ($invoiceAddress['taxId'] ?? '')),
'company_name' => StringHelper::nullableString((string) ($invoiceAddress['companyName'] ?? '')),
'payload_json' => $invoiceAddress,
];
}
@@ -516,12 +417,12 @@ final class AllegroOrderImportService
}
$result[] = [
'source_item_id' => $this->nullableString((string) ($itemRaw['id'] ?? '')),
'external_item_id' => $this->nullableString((string) ($offer['id'] ?? '')),
'source_item_id' => StringHelper::nullableString((string) ($itemRaw['id'] ?? '')),
'external_item_id' => StringHelper::nullableString((string) ($offer['id'] ?? '')),
'ean' => null,
'sku' => null,
'original_name' => $name,
'original_code' => $this->nullableString((string) ($offer['id'] ?? '')),
'original_code' => StringHelper::nullableString((string) ($offer['id'] ?? '')),
'original_price_with_tax' => $this->amountToFloat($itemRaw['originalPrice'] ?? null),
'original_price_without_tax' => null,
'media_url' => $mediaUrl,
@@ -530,7 +431,7 @@ final class AllegroOrderImportService
'item_status' => null,
'unit' => 'pcs',
'item_type' => 'product',
'source_product_id' => $this->nullableString((string) ($offer['id'] ?? '')),
'source_product_id' => StringHelper::nullableString((string) ($offer['id'] ?? '')),
'source_product_set_id' => null,
'sort_order' => $sortOrder++,
'payload_json' => $itemRaw,
@@ -703,10 +604,10 @@ final class AllegroOrderImportService
'source_payment_id' => $paymentId,
'external_payment_id' => $paymentId,
'payment_type_id' => trim((string) ($payment['type'] ?? 'allegro')),
'payment_date' => $this->normalizeDateTime((string) ($payment['finishedAt'] ?? '')),
'payment_date' => StringHelper::normalizeDateTime((string) ($payment['finishedAt'] ?? '')),
'amount' => $amount,
'currency' => $this->nullableString((string) ($payment['amount']['currency'] ?? $fallbackCurrency)),
'comment' => $this->nullableString((string) ($payment['provider'] ?? '')),
'currency' => StringHelper::nullableString((string) ($payment['amount']['currency'] ?? $fallbackCurrency)),
'comment' => StringHelper::nullableString((string) ($payment['provider'] ?? '')),
'payload_json' => $payment,
]];
}
@@ -731,11 +632,11 @@ final class AllegroOrderImportService
$carrierId = trim((string) ($shipmentRaw['carrierId'] ?? $delivery['method']['id'] ?? ''));
$carrierName = trim((string) ($shipmentRaw['carrierName'] ?? ''));
$result[] = [
'source_shipment_id' => $this->nullableString((string) ($shipmentRaw['id'] ?? '')),
'external_shipment_id' => $this->nullableString((string) ($shipmentRaw['id'] ?? '')),
'source_shipment_id' => StringHelper::nullableString((string) ($shipmentRaw['id'] ?? '')),
'external_shipment_id' => StringHelper::nullableString((string) ($shipmentRaw['id'] ?? '')),
'tracking_number' => $trackingNumber,
'carrier_provider_id' => $carrierId !== '' ? $carrierId : ($carrierName !== '' ? $carrierName : 'allegro'),
'posted_at' => $this->normalizeDateTime((string) ($shipmentRaw['createdAt'] ?? '')),
'posted_at' => StringHelper::normalizeDateTime((string) ($shipmentRaw['createdAt'] ?? '')),
'media_uuid' => null,
'payload_json' => $shipmentRaw,
];
@@ -758,7 +659,7 @@ final class AllegroOrderImportService
return [[
'source_note_id' => null,
'note_type' => 'buyer_message',
'created_at_external' => $this->normalizeDateTime((string) ($payload['updatedAt'] ?? '')),
'created_at_external' => StringHelper::normalizeDateTime((string) ($payload['updatedAt'] ?? '')),
'comment' => $message,
'payload_json' => ['messageToSeller' => $message],
]];
@@ -787,20 +688,6 @@ final class AllegroOrderImportService
return (float) $value;
}
private function normalizeDateTime(string $value): ?string
{
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
try {
return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s');
} catch (Throwable) {
return null;
}
}
/**
* @param array<string, mixed> $address
*/
@@ -819,9 +706,4 @@ final class AllegroOrderImportService
return $fallback;
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use DateTimeImmutable;
use PDO;
use Throwable;
@@ -68,11 +69,11 @@ final class AllegroOrderSyncStateRepository
}
return [
'last_synced_updated_at' => $this->nullableString((string) ($row['last_synced_updated_at'] ?? '')),
'last_synced_source_order_id' => $this->nullableString((string) ($row['last_synced_source_order_id'] ?? '')),
'last_run_at' => $this->nullableString((string) ($row['last_run_at'] ?? '')),
'last_success_at' => $this->nullableString((string) ($row['last_success_at'] ?? '')),
'last_error' => $this->nullableString((string) ($row['last_error'] ?? '')),
'last_synced_updated_at' => StringHelper::nullableString((string) ($row['last_synced_updated_at'] ?? '')),
'last_synced_source_order_id' => StringHelper::nullableString((string) ($row['last_synced_source_order_id'] ?? '')),
'last_run_at' => StringHelper::nullableString((string) ($row['last_run_at'] ?? '')),
'last_success_at' => StringHelper::nullableString((string) ($row['last_success_at'] ?? '')),
'last_error' => StringHelper::nullableString((string) ($row['last_error'] ?? '')),
];
}
@@ -267,9 +268,4 @@ final class AllegroOrderSyncStateRepository
];
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use DateInterval;
use App\Core\Support\StringHelper;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
@@ -13,7 +13,7 @@ final class AllegroOrdersSyncService
public function __construct(
private readonly AllegroIntegrationRepository $integrationRepository,
private readonly AllegroOrderSyncStateRepository $syncStateRepository,
private readonly AllegroOAuthClient $oauthClient,
private readonly AllegroTokenManager $tokenManager,
private readonly AllegroApiClient $apiClient,
private readonly AllegroOrderImportService $orderImportService
) {
@@ -56,8 +56,8 @@ final class AllegroOrdersSyncService
$startDateRaw = trim((string) ($settings['orders_fetch_start_date'] ?? ''));
$startDate = $this->normalizeStartDate($startDateRaw);
$cursorUpdatedAt = $this->nullableString((string) ($state['last_synced_updated_at'] ?? ''));
$cursorSourceOrderId = $this->nullableString((string) ($state['last_synced_source_order_id'] ?? ''));
$cursorUpdatedAt = StringHelper::nullableString((string) ($state['last_synced_updated_at'] ?? ''));
$cursorSourceOrderId = StringHelper::nullableString((string) ($state['last_synced_source_order_id'] ?? ''));
$result = [
'enabled' => true,
@@ -75,31 +75,20 @@ final class AllegroOrdersSyncService
$latestProcessedSourceOrderId = $cursorSourceOrderId;
try {
$oauth = $this->requireOAuthData();
[$accessToken, $oauth] = $this->resolveAccessToken($oauth);
[$accessToken, $env] = $this->tokenManager->resolveToken();
$offset = 0;
$shouldStop = false;
for ($page = 0; $page < $maxPages; $page++) {
try {
$response = $this->apiClient->listCheckoutForms(
(string) ($oauth['environment'] ?? 'sandbox'),
$accessToken,
$pageLimit,
$offset
);
$response = $this->apiClient->listCheckoutForms($env, $accessToken, $pageLimit, $offset);
} catch (RuntimeException $exception) {
if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
throw $exception;
}
[$accessToken, $oauth] = $this->forceRefreshToken($oauth);
$response = $this->apiClient->listCheckoutForms(
(string) ($oauth['environment'] ?? 'sandbox'),
$accessToken,
$pageLimit,
$offset
);
[$accessToken, $env] = $this->tokenManager->resolveToken();
$response = $this->apiClient->listCheckoutForms($env, $accessToken, $pageLimit, $offset);
}
$forms = is_array($response['checkoutForms'] ?? null) ? $response['checkoutForms'] : [];
@@ -113,7 +102,7 @@ final class AllegroOrdersSyncService
}
$sourceOrderId = trim((string) ($form['id'] ?? ''));
$sourceUpdatedAt = $this->normalizeDateTime((string) ($form['updatedAt'] ?? $form['boughtAt'] ?? ''));
$sourceUpdatedAt = StringHelper::normalizeDateTime((string) ($form['updatedAt'] ?? $form['boughtAt'] ?? ''));
if ($sourceOrderId === '' || $sourceUpdatedAt === null) {
$result['skipped'] = (int) $result['skipped'] + 1;
continue;
@@ -192,105 +181,6 @@ final class AllegroOrdersSyncService
}
}
/**
* @return array<string, string>
*/
private function requireOAuthData(): array
{
$oauth = $this->integrationRepository->getTokenCredentials();
if ($oauth === null) {
throw new RuntimeException('Brak kompletnych danych OAuth Allegro. Polacz konto ponownie.');
}
return $oauth;
}
/**
* @param array<string, string> $oauth
* @return array{0:string, 1:array<string, string>}
*/
private function resolveAccessToken(array $oauth): array
{
$tokenExpiresAt = trim((string) ($oauth['token_expires_at'] ?? ''));
$accessToken = trim((string) ($oauth['access_token'] ?? ''));
if ($accessToken === '') {
return $this->forceRefreshToken($oauth);
}
if ($tokenExpiresAt === '') {
return [$accessToken, $oauth];
}
try {
$expiresAt = new DateTimeImmutable($tokenExpiresAt);
} catch (Throwable) {
return $this->forceRefreshToken($oauth);
}
if ($expiresAt <= (new DateTimeImmutable('now'))->add(new DateInterval('PT5M'))) {
return $this->forceRefreshToken($oauth);
}
return [$accessToken, $oauth];
}
/**
* @param array<string, string> $oauth
* @return array{0:string, 1:array<string, string>}
*/
private function forceRefreshToken(array $oauth): array
{
$token = $this->oauthClient->refreshAccessToken(
(string) ($oauth['environment'] ?? 'sandbox'),
(string) ($oauth['client_id'] ?? ''),
(string) ($oauth['client_secret'] ?? ''),
(string) ($oauth['refresh_token'] ?? '')
);
$expiresAt = null;
$expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
if ($expiresIn > 0) {
$expiresAt = (new DateTimeImmutable('now'))
->add(new DateInterval('PT' . $expiresIn . 'S'))
->format('Y-m-d H:i:s');
}
$refreshToken = trim((string) ($token['refresh_token'] ?? ''));
if ($refreshToken === '') {
$refreshToken = (string) ($oauth['refresh_token'] ?? '');
}
$this->integrationRepository->saveTokens(
(string) ($token['access_token'] ?? ''),
$refreshToken,
(string) ($token['token_type'] ?? ''),
(string) ($token['scope'] ?? ''),
$expiresAt
);
$updatedOauth = $this->requireOAuthData();
$newAccessToken = trim((string) ($updatedOauth['access_token'] ?? ''));
if ($newAccessToken === '') {
throw new RuntimeException('Nie udalo sie zapisac odswiezonego tokenu Allegro.');
}
return [$newAccessToken, $updatedOauth];
}
private function normalizeDateTime(string $value): ?string
{
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
try {
return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s');
} catch (Throwable) {
return null;
}
}
private function normalizeStartDate(string $value): ?string
{
$trimmed = trim($value);
@@ -328,9 +218,4 @@ final class AllegroOrdersSyncService
return strcmp($sourceOrderId, $cursorSourceOrderId) > 0;
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use PDO;
final class AllegroStatusMappingRepository
@@ -59,7 +60,7 @@ final class AllegroStatusMappingRepository
);
$statement->execute([
'allegro_status_code' => $code,
'allegro_status_name' => $this->nullableString((string) $allegroStatusName),
'allegro_status_name' => StringHelper::nullableString((string) $allegroStatusName),
'orderpro_status_code' => $orderproCode !== null && $orderproCode !== '' ? $orderproCode : null,
]);
}
@@ -86,7 +87,7 @@ final class AllegroStatusMappingRepository
);
$statement->execute([
'allegro_status_code' => $code,
'allegro_status_name' => $this->nullableString((string) $allegroStatusName),
'allegro_status_name' => StringHelper::nullableString((string) $allegroStatusName),
]);
}
@@ -123,9 +124,4 @@ final class AllegroStatusMappingRepository
return $mapped !== '' ? $mapped : null;
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use DateInterval;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
final class AllegroTokenManager
{
public function __construct(
private readonly AllegroIntegrationRepository $repository,
private readonly AllegroOAuthClient $oauthClient
) {
}
/**
* @return array{0: string, 1: string}
*/
public function resolveToken(): array
{
$oauth = $this->repository->getTokenCredentials();
if ($oauth === null) {
throw new RuntimeException('Brak polaczenia OAuth Allegro. Polacz konto w Ustawieniach.');
}
$env = (string) ($oauth['environment'] ?? 'sandbox');
$accessToken = trim((string) ($oauth['access_token'] ?? ''));
$tokenExpiresAt = trim((string) ($oauth['token_expires_at'] ?? ''));
if ($accessToken === '') {
return $this->forceRefresh();
}
if ($tokenExpiresAt !== '') {
try {
$expiresAt = new DateTimeImmutable($tokenExpiresAt);
if ($expiresAt <= (new DateTimeImmutable('now'))->add(new DateInterval('PT5M'))) {
return $this->forceRefresh();
}
} catch (Throwable) {
return $this->forceRefresh();
}
}
return [$accessToken, $env];
}
/**
* @return array{0: string, 1: string}
*/
private function forceRefresh(): array
{
$oauth = $this->repository->getTokenCredentials();
if ($oauth === null) {
throw new RuntimeException('Brak danych OAuth Allegro.');
}
$token = $this->oauthClient->refreshAccessToken(
(string) ($oauth['environment'] ?? 'sandbox'),
(string) ($oauth['client_id'] ?? ''),
(string) ($oauth['client_secret'] ?? ''),
(string) ($oauth['refresh_token'] ?? '')
);
$expiresAt = null;
$expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
if ($expiresIn > 0) {
$expiresAt = (new DateTimeImmutable('now'))
->add(new DateInterval('PT' . $expiresIn . 'S'))
->format('Y-m-d H:i:s');
}
$refreshToken = trim((string) ($token['refresh_token'] ?? ''));
if ($refreshToken === '') {
$refreshToken = (string) ($oauth['refresh_token'] ?? '');
}
$this->repository->saveTokens(
(string) ($token['access_token'] ?? ''),
$refreshToken,
(string) ($token['token_type'] ?? ''),
(string) ($token['scope'] ?? ''),
$expiresAt
);
$updated = $this->repository->getTokenCredentials();
$newToken = trim((string) ($updated['access_token'] ?? ''));
if ($newToken === '') {
throw new RuntimeException('Nie udalo sie odswiezyc tokenu Allegro.');
}
return [$newToken, (string) ($updated['environment'] ?? 'sandbox')];
}
}

View File

@@ -194,12 +194,6 @@ final class CarrierDeliveryMethodMappingRepository
return in_array($source, ['allegro', 'shoppro'], true) ? $source : 'allegro';
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function nullableLimited(string $value, int $max): ?string
{
$trimmed = $this->limit(trim($value), $max);

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use PDO;
use Throwable;
@@ -82,18 +83,18 @@ final class CompanySettingsRepository
);
$statement->execute([
'company_name' => $this->nullableString((string) ($data['company_name'] ?? '')),
'person_name' => $this->nullableString((string) ($data['person_name'] ?? '')),
'sender_contact_person' => $this->nullableString((string) ($data['sender_contact_person'] ?? '')),
'street' => $this->nullableString((string) ($data['street'] ?? '')),
'city' => $this->nullableString((string) ($data['city'] ?? '')),
'postal_code' => $this->nullableString((string) ($data['postal_code'] ?? '')),
'company_name' => StringHelper::nullableString((string) ($data['company_name'] ?? '')),
'person_name' => StringHelper::nullableString((string) ($data['person_name'] ?? '')),
'sender_contact_person' => StringHelper::nullableString((string) ($data['sender_contact_person'] ?? '')),
'street' => StringHelper::nullableString((string) ($data['street'] ?? '')),
'city' => StringHelper::nullableString((string) ($data['city'] ?? '')),
'postal_code' => StringHelper::nullableString((string) ($data['postal_code'] ?? '')),
'country_code' => strtoupper(trim((string) ($data['country_code'] ?? 'PL'))) ?: 'PL',
'phone' => $this->nullableString((string) ($data['phone'] ?? '')),
'email' => $this->nullableString((string) ($data['email'] ?? '')),
'tax_number' => $this->nullableString((string) ($data['tax_number'] ?? '')),
'bank_account' => $this->nullableString((string) ($data['bank_account'] ?? '')),
'bank_owner_name' => $this->nullableString((string) ($data['bank_owner_name'] ?? '')),
'phone' => StringHelper::nullableString((string) ($data['phone'] ?? '')),
'email' => StringHelper::nullableString((string) ($data['email'] ?? '')),
'tax_number' => StringHelper::nullableString((string) ($data['tax_number'] ?? '')),
'bank_account' => StringHelper::nullableString((string) ($data['bank_account'] ?? '')),
'bank_owner_name' => StringHelper::nullableString((string) ($data['bank_owner_name'] ?? '')),
'length' => max(0.1, (float) ($data['default_package_length_cm'] ?? 25.0)),
'width' => max(0.1, (float) ($data['default_package_width_cm'] ?? 20.0)),
'height' => max(0.1, (float) ($data['default_package_height_cm'] ?? 8.0)),
@@ -132,12 +133,6 @@ final class CompanySettingsRepository
);
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
/**
* @return array<string, mixed>
*/

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use PDO;
use RuntimeException;
use Throwable;
@@ -98,15 +99,15 @@ final class InpostIntegrationRepository
WHERE id = 1'
);
$statement->execute([
'api_token_encrypted' => $this->nullableString((string) $nextEncrypted),
'organization_id' => $this->nullableString(trim((string) ($payload['organization_id'] ?? ''))),
'api_token_encrypted' => StringHelper::nullableString((string) $nextEncrypted),
'organization_id' => StringHelper::nullableString(trim((string) ($payload['organization_id'] ?? ''))),
'environment' => in_array($payload['environment'] ?? '', ['sandbox', 'production'], true)
? $payload['environment']
: 'sandbox',
'default_dispatch_method' => in_array($payload['default_dispatch_method'] ?? '', ['pop', 'parcel_locker', 'courier'], true)
? $payload['default_dispatch_method']
: 'pop',
'default_dispatch_point' => $this->nullableString(trim((string) ($payload['default_dispatch_point'] ?? ''))),
'default_dispatch_point' => StringHelper::nullableString(trim((string) ($payload['default_dispatch_point'] ?? ''))),
'default_insurance' => ($payload['default_insurance'] ?? '') !== ''
? (float) $payload['default_insurance']
: null,
@@ -215,9 +216,4 @@ final class InpostIntegrationRepository
);
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use PDO;
use Throwable;
@@ -119,7 +120,7 @@ final class IntegrationsRepository
);
$statement->execute([
'id' => $integrationId,
'api_key_encrypted' => $this->nullableString((string) $encrypted),
'api_key_encrypted' => StringHelper::nullableString((string) $encrypted),
]);
}
@@ -147,9 +148,4 @@ final class IntegrationsRepository
return $trimmed === '' ? null : $trimmed;
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}

View File

@@ -9,6 +9,7 @@ use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\Support\StringHelper;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use Throwable;
@@ -113,7 +114,7 @@ final class SettingsController
return Response::redirect('/settings/statuses');
}
$colorHex = $this->normalizeColorHex((string) $request->input('color_hex', '#64748b'));
$colorHex = StringHelper::normalizeColorHex((string) $request->input('color_hex', '#64748b'));
$isActive = (string) $request->input('is_active', '1') === '1';
try {
@@ -147,7 +148,7 @@ final class SettingsController
return Response::redirect('/settings/statuses');
}
$colorHex = $this->normalizeColorHex((string) $request->input('color_hex', '#64748b'));
$colorHex = StringHelper::normalizeColorHex((string) $request->input('color_hex', '#64748b'));
$isActive = (string) $request->input('is_active', '0') === '1';
$code = trim((string) ($existingGroup['code'] ?? ''));
@@ -352,16 +353,6 @@ final class SettingsController
return Response::redirect('/settings/statuses');
}
private function normalizeColorHex(string $value): string
{
$trimmed = trim($value);
if (preg_match('/^#[0-9a-fA-F]{6}$/', $trimmed) === 1) {
return strtolower($trimmed);
}
return '#64748b';
}
private function normalizeCode(string $code, string $fallbackName = ''): string
{
$source = trim($code) !== '' ? $code : $fallbackName;

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use PDO;
use RuntimeException;
use Throwable;
@@ -180,7 +181,7 @@ final class ShopproIntegrationsRepository
'type' => self::TYPE,
'name' => $name,
'base_url' => $baseUrl,
'api_key_encrypted' => $this->nullableString($encryptedApiKey),
'api_key_encrypted' => StringHelper::nullableString($encryptedApiKey),
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive,
'orders_fetch_enabled' => $ordersFetchEnabled,
@@ -204,7 +205,7 @@ final class ShopproIntegrationsRepository
'type' => self::TYPE,
'name' => $name,
'base_url' => $baseUrl,
'api_key_encrypted' => $this->nullableString((string) $encryptedApiKey),
'api_key_encrypted' => StringHelper::nullableString((string) $encryptedApiKey),
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive,
'orders_fetch_enabled' => $ordersFetchEnabled,
@@ -424,9 +425,9 @@ final class ShopproIntegrationsRepository
$statement->execute([
'id' => $integrationId,
'type' => self::TYPE,
'last_test_status' => $this->nullableString($status),
'last_test_status' => StringHelper::nullableString($status),
'last_test_http_code' => $httpCode,
'last_test_message' => $this->nullableString($message),
'last_test_message' => StringHelper::nullableString($message),
]);
$log = $this->pdo->prepare(
@@ -448,12 +449,6 @@ final class ShopproIntegrationsRepository
}
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function normalizeDate(string $value): ?string
{
$trimmed = trim($value);

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use App\Modules\Orders\OrderImportRepository;
use App\Modules\Orders\OrdersRepository;
use DateTimeImmutable;
@@ -63,8 +64,8 @@ final class ShopproOrdersSyncService
try {
$statusMap = $this->buildStatusMap($integrationId);
$cursorUpdatedAt = $this->nullableString((string) ($state['last_synced_updated_at'] ?? ''));
$cursorOrderId = $this->nullableString((string) ($state['last_synced_source_order_id'] ?? ''));
$cursorUpdatedAt = StringHelper::nullableString((string) ($state['last_synced_updated_at'] ?? ''));
$cursorOrderId = StringHelper::nullableString((string) ($state['last_synced_source_order_id'] ?? ''));
$startDate = $this->resolveStartDate(
(string) ($integration['orders_fetch_start_date'] ?? ''),
$cursorUpdatedAt
@@ -265,7 +266,7 @@ final class ShopproOrdersSyncService
private function resolveStartDate(string $settingsDate, ?string $cursorUpdatedAt): ?string
{
$settings = trim($settingsDate);
$cursor = $this->nullableString((string) $cursorUpdatedAt);
$cursor = StringHelper::nullableString((string) $cursorUpdatedAt);
if ($settings === '' && $cursor === null) {
return null;
}
@@ -288,7 +289,7 @@ final class ShopproOrdersSyncService
$result = [];
foreach ($items as $row) {
$sourceOrderId = $this->normalizeOrderId($this->readPath($row, ['id', 'order_id', 'external_order_id']));
$sourceUpdatedAt = $this->normalizeDateTime((string) $this->readPath($row, ['updated_at', 'date_updated', 'modified_at', 'date_modified', 'created_at', 'date_created']));
$sourceUpdatedAt = StringHelper::normalizeDateTime((string) $this->readPath($row, ['updated_at', 'date_updated', 'modified_at', 'date_modified', 'created_at', 'date_created']));
if ($sourceOrderId === '' || $sourceUpdatedAt === null) {
continue;
}
@@ -360,8 +361,8 @@ final class ShopproOrdersSyncService
$sourceOrderId = $fallbackOrderId;
}
$sourceCreatedAt = $this->normalizeDateTime((string) $this->readPath($payload, ['created_at', 'date_created', 'date_add']));
$sourceUpdatedAt = $this->normalizeDateTime((string) $this->readPath($payload, ['updated_at', 'date_updated', 'modified_at', 'date_modified', 'created_at']));
$sourceCreatedAt = StringHelper::normalizeDateTime((string) $this->readPath($payload, ['created_at', 'date_created', 'date_add']));
$sourceUpdatedAt = StringHelper::normalizeDateTime((string) $this->readPath($payload, ['updated_at', 'date_updated', 'modified_at', 'date_modified', 'created_at']));
if ($sourceUpdatedAt === null) {
$sourceUpdatedAt = $fallbackUpdatedAt !== '' ? $fallbackUpdatedAt : date('Y-m-d H:i:s');
}
@@ -425,13 +426,13 @@ final class ShopproOrdersSyncService
'external_platform_id' => 'shoppro',
'external_platform_account_id' => null,
'external_status_id' => $effectiveStatus,
'external_payment_type_id' => $this->nullableString((string) $this->readPath($payload, ['payment_method', 'payment.method', 'payments.method'])),
'external_payment_type_id' => StringHelper::nullableString((string) $this->readPath($payload, ['payment_method', 'payment.method', 'payments.method'])),
'payment_status' => $this->mapPaymentStatus($payload, $isPaid),
'external_carrier_id' => $this->nullableString($deliveryLabel),
'external_carrier_account_id' => $this->nullableString((string) $this->readPath($payload, [
'external_carrier_id' => StringHelper::nullableString($deliveryLabel),
'external_carrier_account_id' => StringHelper::nullableString((string) $this->readPath($payload, [
'transport_id', 'shipping.method_id', 'delivery.method_id',
])),
'customer_login' => $this->nullableString((string) $this->readPath($payload, [
'customer_login' => StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_email', 'customer.email', 'buyer.email', 'client.email', 'email', 'customer.login', 'buyer.login',
])),
'is_invoice' => $this->resolveInvoiceRequested($payload),
@@ -484,17 +485,17 @@ final class ShopproOrdersSyncService
{
$result = [];
$customerFirstName = $this->nullableString((string) $this->readPath($payload, [
$customerFirstName = StringHelper::nullableString((string) $this->readPath($payload, [
'buyer.first_name', 'buyer.firstname', 'customer.first_name', 'customer.firstname',
'client.first_name', 'client.firstname', 'billing_address.first_name', 'billing_address.firstname',
'first_name', 'firstname', 'client_name', 'imie',
]));
$customerLastName = $this->nullableString((string) $this->readPath($payload, [
$customerLastName = StringHelper::nullableString((string) $this->readPath($payload, [
'buyer.last_name', 'buyer.lastname', 'customer.last_name', 'customer.lastname',
'client.last_name', 'client.lastname', 'billing_address.last_name', 'billing_address.lastname',
'last_name', 'lastname', 'client_surname', 'nazwisko',
]));
$customerName = $this->nullableString((string) $this->readPath($payload, [
$customerName = StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_name', 'buyer.name', 'customer.name', 'client.name', 'billing_address.name',
'receiver.name', 'client', 'customer_full_name', 'client_full_name',
]));
@@ -502,11 +503,11 @@ final class ShopproOrdersSyncService
$customerName = $this->composeName($customerFirstName, $customerLastName, 'Klient');
}
$customerEmail = $this->nullableString((string) $this->readPath($payload, [
$customerEmail = StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_email', 'buyer.email', 'customer.email', 'client.email', 'billing_address.email',
'shipping_address.email', 'delivery_address.email', 'email', 'client_email', 'mail',
]));
$customerPhone = $this->nullableString((string) $this->readPath($payload, [
$customerPhone = StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_phone', 'buyer.phone', 'customer.phone', 'client.phone', 'billing_address.phone',
'shipping_address.phone', 'delivery_address.phone', 'phone', 'telephone', 'client_phone',
'phone_number', 'client_phone_number',
@@ -517,25 +518,25 @@ final class ShopproOrdersSyncService
'name' => $customerName ?? 'Klient',
'phone' => $customerPhone,
'email' => $customerEmail,
'street_name' => $this->nullableString((string) $this->readPath($payload, [
'street_name' => StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_address.street', 'customer.address.street', 'billing_address.street', 'client.address.street',
'address.street', 'street', 'client_street', 'ulica',
])),
'street_number' => $this->nullableString((string) $this->readPath($payload, [
'street_number' => StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_address.street_number', 'customer.address.street_number', 'billing_address.street_number',
'billing_address.house_number', 'client.address.street_number', 'address.street_number',
'house_number', 'street_no', 'street_number', 'nr_domu',
])),
'city' => $this->nullableString((string) $this->readPath($payload, [
'city' => StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_address.city', 'customer.address.city', 'billing_address.city', 'client.address.city',
'address.city', 'city', 'client_city', 'miejscowosc',
])),
'zip_code' => $this->nullableString((string) $this->readPath($payload, [
'zip_code' => StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_address.zip', 'buyer_address.postcode', 'customer.address.zip', 'customer.address.postcode',
'billing_address.zip', 'billing_address.postcode', 'client.address.zip', 'address.zip',
'address.postcode', 'zip', 'postcode', 'client_postal_code', 'kod_pocztowy',
])),
'country' => $this->nullableString((string) $this->readPath($payload, [
'country' => StringHelper::nullableString((string) $this->readPath($payload, [
'buyer_address.country', 'customer.address.country', 'billing_address.country', 'client.address.country',
'address.country', 'country', 'kraj',
])),
@@ -553,17 +554,17 @@ final class ShopproOrdersSyncService
$result[] = $invoiceAddress;
}
$deliveryFirstName = $this->nullableString((string) $this->readPath($payload, [
$deliveryFirstName = StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.first_name', 'delivery.address.firstname', 'shipping.address.first_name', 'shipping.address.firstname',
'delivery_address.first_name', 'delivery_address.firstname', 'shipping_address.first_name', 'shipping_address.firstname',
'receiver.first_name', 'receiver.firstname', 'delivery_first_name', 'shipping_first_name',
]));
$deliveryLastName = $this->nullableString((string) $this->readPath($payload, [
$deliveryLastName = StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.last_name', 'delivery.address.lastname', 'shipping.address.last_name', 'shipping.address.lastname',
'delivery_address.last_name', 'delivery_address.lastname', 'shipping_address.last_name', 'shipping_address.lastname',
'receiver.last_name', 'receiver.lastname', 'delivery_last_name', 'shipping_last_name',
]));
$deliveryName = $this->nullableString((string) $this->readPath($payload, [
$deliveryName = StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.name', 'shipping.address.name', 'delivery_address.name', 'shipping_address.name',
'receiver.name', 'delivery_name', 'shipping_name',
]));
@@ -574,39 +575,39 @@ final class ShopproOrdersSyncService
$pickupData = $this->parsePickupPoint((string) $this->readPath($payload, ['inpost_paczkomat', 'orlen_point', 'pickup_point']));
$deliveryAddress = [
'name' => $deliveryName,
'phone' => $this->nullableString((string) $this->readPath($payload, [
'phone' => StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.phone', 'shipping.address.phone', 'delivery_address.phone', 'shipping_address.phone',
'receiver.phone', 'delivery_phone', 'shipping_phone',
])) ?? $customerPhone,
'email' => $this->nullableString((string) $this->readPath($payload, [
'email' => StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.email', 'shipping.address.email', 'delivery_address.email', 'shipping_address.email',
'receiver.email', 'delivery_email', 'shipping_email',
])) ?? $customerEmail,
'street_name' => $this->nullableString((string) $this->readPath($payload, [
'street_name' => StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.street', 'shipping.address.street', 'delivery_address.street', 'shipping_address.street',
'receiver.address.street', 'delivery_street', 'shipping_street',
])) ?? $this->nullableString($pickupData['street'] ?? ''),
'street_number' => $this->nullableString((string) $this->readPath($payload, [
])) ?? StringHelper::nullableString($pickupData['street'] ?? ''),
'street_number' => StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.street_number', 'shipping.address.street_number', 'delivery_address.street_number', 'shipping_address.street_number',
'delivery.address.house_number', 'shipping.address.house_number', 'receiver.address.street_number',
'receiver.address.house_number', 'delivery_street_number', 'shipping_street_number',
])),
'city' => $this->nullableString((string) $this->readPath($payload, [
'city' => StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.city', 'shipping.address.city', 'delivery_address.city', 'shipping_address.city',
'receiver.address.city', 'delivery_city', 'shipping_city',
])) ?? $this->nullableString($pickupData['city'] ?? ''),
'zip_code' => $this->nullableString((string) $this->readPath($payload, [
])) ?? StringHelper::nullableString($pickupData['city'] ?? ''),
'zip_code' => StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.zip', 'delivery.address.postcode', 'shipping.address.zip', 'shipping.address.postcode',
'delivery_address.zip', 'delivery_address.postcode', 'shipping_address.zip', 'shipping_address.postcode',
'receiver.address.zip', 'receiver.address.postcode', 'delivery_zip', 'delivery_postcode',
'shipping_zip', 'shipping_postcode',
])) ?? $this->nullableString($pickupData['zip_code'] ?? ''),
'country' => $this->nullableString((string) $this->readPath($payload, [
])) ?? StringHelper::nullableString($pickupData['zip_code'] ?? ''),
'country' => StringHelper::nullableString((string) $this->readPath($payload, [
'delivery.address.country', 'shipping.address.country', 'delivery_address.country', 'shipping_address.country',
'receiver.address.country', 'delivery_country', 'shipping_country',
])),
'parcel_external_id' => $this->nullableString($pickupData['code'] ?? ''),
'parcel_name' => $this->nullableString($pickupData['label'] ?? ''),
'parcel_external_id' => StringHelper::nullableString($pickupData['code'] ?? ''),
'parcel_name' => StringHelper::nullableString($pickupData['label'] ?? ''),
'payload_json' => [
'delivery' => $this->readPath($payload, ['delivery']),
'shipping' => $this->readPath($payload, ['shipping']),
@@ -619,7 +620,7 @@ final class ShopproOrdersSyncService
];
if (($deliveryAddress['name'] ?? null) === null) {
$deliveryAddress['name'] = $this->nullableString($this->buildDeliveryMethodLabel($payload));
$deliveryAddress['name'] = StringHelper::nullableString($this->buildDeliveryMethodLabel($payload));
}
$hasDeliveryData = $this->hasAddressData($deliveryAddress);
@@ -653,11 +654,11 @@ final class ShopproOrdersSyncService
return true;
}
$companyName = $this->nullableString((string) $this->readPath($payload, [
$companyName = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.company_name', 'invoice.company', 'billing.company_name', 'billing.company',
'firm_name', 'company_name', 'client_company', 'buyer_company',
]));
$taxNumber = $this->nullableString((string) $this->readPath($payload, [
$taxNumber = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.tax_id', 'invoice.nip', 'billing.tax_id', 'billing.nip',
'firm_nip', 'company_nip', 'tax_id', 'nip',
]));
@@ -675,50 +676,50 @@ final class ShopproOrdersSyncService
?string $customerEmail,
?string $customerPhone
): ?array {
$companyName = $this->nullableString((string) $this->readPath($payload, [
$companyName = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.company_name', 'invoice.company', 'billing.company_name', 'billing.company',
'firm_name', 'company_name', 'client_company', 'buyer_company',
]));
$companyTaxNumber = $this->nullableString((string) $this->readPath($payload, [
$companyTaxNumber = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.tax_id', 'invoice.nip', 'billing.tax_id', 'billing.nip',
'firm_nip', 'company_nip', 'tax_id', 'nip',
]));
$invoiceFirstName = $this->nullableString((string) $this->readPath($payload, [
$invoiceFirstName = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.first_name', 'invoice.firstname', 'billing_address.first_name', 'billing_address.firstname',
'buyer.first_name', 'customer.first_name', 'client_name',
]));
$invoiceLastName = $this->nullableString((string) $this->readPath($payload, [
$invoiceLastName = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.last_name', 'invoice.lastname', 'billing_address.last_name', 'billing_address.lastname',
'buyer.last_name', 'customer.last_name', 'client_surname',
]));
$invoiceName = $companyName ?? $this->composeName($invoiceFirstName, $invoiceLastName, $customerName ?? 'Faktura');
$streetName = $this->nullableString((string) $this->readPath($payload, [
$streetName = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.address.street', 'invoice.street', 'billing_address.street', 'billing.street',
'firm_street', 'company_street',
]));
$streetNumber = $this->nullableString((string) $this->readPath($payload, [
$streetNumber = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.address.street_number', 'invoice.street_number', 'invoice.house_number',
'billing_address.street_number', 'billing_address.house_number',
'billing.street_number', 'house_number', 'street_number',
]));
$city = $this->nullableString((string) $this->readPath($payload, [
$city = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.address.city', 'invoice.city', 'billing_address.city', 'billing.city',
'firm_city', 'company_city',
]));
$zipCode = $this->nullableString((string) $this->readPath($payload, [
$zipCode = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.address.zip', 'invoice.address.postcode', 'invoice.zip', 'invoice.postcode',
'billing_address.zip', 'billing_address.postcode', 'billing.zip', 'billing.postcode',
'firm_postal_code', 'company_postal_code',
]));
$country = $this->nullableString((string) $this->readPath($payload, [
$country = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.address.country', 'invoice.country', 'billing_address.country', 'billing.country',
'firm_country', 'company_country',
]));
$email = $this->nullableString((string) $this->readPath($payload, [
$email = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.email', 'billing_address.email', 'billing.email', 'client_email',
])) ?? $customerEmail;
$phone = $this->nullableString((string) $this->readPath($payload, [
$phone = StringHelper::nullableString((string) $this->readPath($payload, [
'invoice.phone', 'billing_address.phone', 'billing.phone', 'client_phone',
])) ?? $customerPhone;
@@ -814,31 +815,31 @@ final class ShopproOrdersSyncService
$productId = (int) $this->readPath($row, ['product_id']);
$parentProductId = (int) $this->readPath($row, ['parent_product_id']);
$mediaUrl = $this->nullableString((string) $this->readPath($row, ['image', 'image_url', 'img_url', 'img', 'photo', 'photo_url']));
$mediaUrl = StringHelper::nullableString((string) $this->readPath($row, ['image', 'image_url', 'img_url', 'img', 'photo', 'photo_url']));
if ($mediaUrl === null && $productId > 0 && isset($productImagesById[$productId])) {
$mediaUrl = $this->nullableString((string) $productImagesById[$productId]);
$mediaUrl = StringHelper::nullableString((string) $productImagesById[$productId]);
}
if ($mediaUrl === null && $parentProductId > 0 && isset($productImagesById[$parentProductId])) {
$mediaUrl = $this->nullableString((string) $productImagesById[$parentProductId]);
$mediaUrl = StringHelper::nullableString((string) $productImagesById[$parentProductId]);
}
$result[] = [
'source_item_id' => $this->nullableString((string) $this->readPath($row, ['id', 'item_id'])),
'external_item_id' => $this->nullableString((string) $this->readPath($row, ['id', 'item_id'])),
'ean' => $this->nullableString((string) $this->readPath($row, ['ean'])),
'sku' => $this->nullableString((string) $this->readPath($row, ['sku', 'symbol', 'code'])),
'source_item_id' => StringHelper::nullableString((string) $this->readPath($row, ['id', 'item_id'])),
'external_item_id' => StringHelper::nullableString((string) $this->readPath($row, ['id', 'item_id'])),
'ean' => StringHelper::nullableString((string) $this->readPath($row, ['ean'])),
'sku' => StringHelper::nullableString((string) $this->readPath($row, ['sku', 'symbol', 'code'])),
'original_name' => $name,
'original_code' => $this->nullableString((string) $this->readPath($row, ['code', 'symbol'])),
'original_code' => StringHelper::nullableString((string) $this->readPath($row, ['code', 'symbol'])),
'original_price_with_tax' => $this->toFloatOrNull($this->readPath($row, ['price_gross', 'gross_price', 'price', 'price_brutto'])),
'original_price_without_tax' => $this->toFloatOrNull($this->readPath($row, ['price_net', 'net_price', 'price_netto'])),
'media_url' => $mediaUrl,
'quantity' => $this->toFloatOrDefault($this->readPath($row, ['quantity', 'qty']), 1.0),
'tax_rate' => $this->toFloatOrNull($this->readPath($row, ['vat', 'tax_rate'])),
'item_status' => null,
'unit' => $this->nullableString((string) $this->readPath($row, ['unit'])),
'unit' => StringHelper::nullableString((string) $this->readPath($row, ['unit'])),
'item_type' => 'product',
'source_product_id' => $this->nullableString((string) ($productId > 0 ? $productId : $parentProductId)),
'source_product_set_id' => $this->nullableString((string) ($parentProductId > 0 ? $parentProductId : '')),
'source_product_id' => StringHelper::nullableString((string) ($productId > 0 ? $productId : $parentProductId)),
'source_product_set_id' => StringHelper::nullableString((string) ($parentProductId > 0 ? $parentProductId : '')),
'sort_order' => $sort++,
'payload_json' => $row,
];
@@ -853,7 +854,7 @@ final class ShopproOrdersSyncService
*/
private function mapPayments(array $payload, string $currency, ?float $totalPaid): array
{
$paymentMethod = $this->nullableString((string) $this->readPath($payload, ['payment_method', 'payment.method']));
$paymentMethod = StringHelper::nullableString((string) $this->readPath($payload, ['payment_method', 'payment.method']));
if ($paymentMethod === null && $totalPaid === null) {
return [];
}
@@ -862,10 +863,10 @@ final class ShopproOrdersSyncService
'source_payment_id' => null,
'external_payment_id' => null,
'payment_type_id' => $paymentMethod ?? 'unknown',
'payment_date' => $this->nullableString((string) $this->readPath($payload, ['payment_date', 'payment.date'])),
'payment_date' => StringHelper::nullableString((string) $this->readPath($payload, ['payment_date', 'payment.date'])),
'amount' => $totalPaid,
'currency' => $currency,
'comment' => $this->nullableString((string) $this->readPath($payload, ['payment_status', 'payment.status'])),
'comment' => StringHelper::nullableString((string) $this->readPath($payload, ['payment_status', 'payment.status'])),
'payload_json' => null,
]];
}
@@ -876,7 +877,7 @@ final class ShopproOrdersSyncService
*/
private function mapShipments(array $payload): array
{
$tracking = $this->nullableString((string) $this->readPath($payload, ['delivery_tracking_number', 'delivery.tracking_number', 'shipping.tracking_number']));
$tracking = StringHelper::nullableString((string) $this->readPath($payload, ['delivery_tracking_number', 'delivery.tracking_number', 'shipping.tracking_number']));
if ($tracking === null) {
return [];
}
@@ -888,7 +889,7 @@ final class ShopproOrdersSyncService
'carrier_provider_id' => $this->sanitizePlainText((string) ($this->readPath($payload, [
'delivery_method', 'shipping.method', 'transport', 'transport_description',
]) ?? 'unknown')),
'posted_at' => $this->nullableString((string) $this->readPath($payload, ['delivery.posted_at', 'shipping.posted_at'])),
'posted_at' => StringHelper::nullableString((string) $this->readPath($payload, ['delivery.posted_at', 'shipping.posted_at'])),
'media_uuid' => null,
'payload_json' => null,
]];
@@ -900,7 +901,7 @@ final class ShopproOrdersSyncService
*/
private function mapNotes(array $payload): array
{
$comment = $this->nullableString((string) $this->readPath($payload, ['notes', 'comment', 'customer_comment']));
$comment = StringHelper::nullableString((string) $this->readPath($payload, ['notes', 'comment', 'customer_comment']));
if ($comment === null) {
return [];
}
@@ -919,25 +920,6 @@ final class ShopproOrdersSyncService
return trim((string) $value);
}
private function normalizeDateTime(string $value): ?string
{
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
try {
return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s');
} catch (Throwable) {
return null;
}
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function normalizePaidFlag(mixed $value): bool
{
if ($value === true) {

View File

@@ -3,8 +3,8 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use App\Modules\Orders\OrdersRepository;
use DateTimeImmutable;
use PDO;
use Throwable;
@@ -219,14 +219,14 @@ final class ShopproPaymentStatusSyncService
: 0.0;
$existingPaymentStatus = isset($order['payment_status']) ? (int) $order['payment_status'] : null;
$existingTotalPaid = $order['total_paid'] !== null ? (float) $order['total_paid'] : null;
$paymentMethod = $this->nullableString((string) ($payload['payment_method'] ?? $order['external_payment_type_id'] ?? ''));
$paymentDate = $this->normalizeDateTime((string) ($payload['payment_date'] ?? ''));
$sourceUpdatedAt = $this->normalizeDateTime((string) ($payload['updated_at'] ?? $payload['date_updated'] ?? ''));
$paymentMethod = StringHelper::nullableString((string) ($payload['payment_method'] ?? $order['external_payment_type_id'] ?? ''));
$paymentDate = StringHelper::normalizeDateTime((string) ($payload['payment_date'] ?? ''));
$sourceUpdatedAt = StringHelper::normalizeDateTime((string) ($payload['updated_at'] ?? $payload['date_updated'] ?? ''));
if (
$existingPaymentStatus === $newPaymentStatus
&& $this->floatsEqual($existingTotalPaid, $newTotalPaid)
&& $paymentMethod === $this->nullableString((string) ($order['external_payment_type_id'] ?? ''))
&& $paymentMethod === StringHelper::nullableString((string) ($order['external_payment_type_id'] ?? ''))
) {
return false;
}
@@ -370,26 +370,6 @@ final class ShopproPaymentStatusSyncService
return $fallbackGross;
}
private function normalizeDateTime(string $value): ?string
{
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
try {
return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s');
} catch (Throwable) {
return null;
}
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function floatsEqual(?float $left, ?float $right): bool
{
if ($left === null && $right === null) {