fix(02-bug-fixes): fix 3 known bugs from CONCERNS.md

Phase 02 plans 02-01, 02-02, 02-03:

- fix(02-01): dead condition in AllegroShipmentService ZPL page size
  Both ternary branches returned 'A6'; ZPL now correctly returns 'ZPL'

- fix(02-02): add last_status_checked_at cursor to AllegroStatusSyncService
  New migration adds orders.last_status_checked_at DATETIME NULL with
  composite index (source, source_updated_at). findOrdersNeedingStatusSync()
  filters by cursor; markOrderStatusChecked() records timestamp on success.

- fix(02-03): replace AllegroOrderSyncStateRepository in ShopproOrdersSyncService
  New ShopproOrderSyncStateRepository (same table, correct class name).
  Application.php wires correct repository to correct service.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 00:26:07 +01:00
parent f8db8c0162
commit 87203c4321
16 changed files with 1290 additions and 126 deletions

View File

@@ -8,13 +8,14 @@ orderPRO to narzędzie do wielokanałowego zarządzania sprzedażą. Projekt prz
**v0.1 Initial Release** (v0.1.0)
Status: In progress
Phases: 1 of TBD complete
Phases: 1 complete, 1 in progress (TBD total)
## Phases
| Phase | Name | Plans | Status | Completed |
|-------|------|-------|--------|-----------|
| 1 | Tech Debt | 2/2 | ✅ Complete | 2026-03-12 |
| 2 | Bug Fixes | 3/? | 🔄 In Progress | — |
## Phase Details
@@ -24,6 +25,13 @@ Naprawa krytycznych problemów technicznych zidentyfikowanych w mapie kodu (`.pa
- **Plan 01-01** — Extract AllegroTokenManager (OAuth duplication HIGH × 4 classes) — *Complete*
- **Plan 01-02** — Extract StringHelper (duplicated helpers HIGH × 15 classes) — *Complete*
### Phase 2 — Bug Fixes
Naprawa zidentyfikowanych błędów z `.paul/codebase/CONCERNS.md`.
- **Plan 02-01** — Naprawa martwego warunku ZPL page size w AllegroShipmentService — *Complete*
- **Plan 02-02** — Kursor `last_status_checked_at` w AllegroStatusSyncService (no time-based cursor) — *Complete*
- **Plan 02-03** — `ShopproOrdersSyncService` używa `AllegroOrderSyncStateRepository` (błędna zależność) — *Complete*
---
*Roadmap created: 2026-03-12*
*Last updated: 2026-03-12*

View File

@@ -5,26 +5,27 @@
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 01Tech Debt: KOMPLETNA. Gotowy na Fazę 02.
**Current focus:** Faza 02Bug Fixes: 3 plany ukończone. Kolejne bugi z CONCERNS.md.
## 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
Phase: 2 of TBD (02-bug-fixes) — Planning
Plan: 02-01, 02-02, 02-03 — COMPLETE. Gotowy na kolejny plan.
Status: Loop closed. Ready for next /paul:plan (more bugs from CONCERNS.md)
Last activity: 2026-03-13 — UNIFY complete for 02-02 and 02-03
Progress:
- Milestone: [█░░░░░░░░] ~10%
- Milestone: [█░░░░░░░░] ~20%
- Phase 1: [██████████] 100%
- Phase 2: [███░░░░░░░] ~30% (3/? plans)
## Loop Position
Current loop state:
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Loop complete — ready for next PLAN]
✓ ✓ ✓ [Loop closed — 02-02 i 02-03 complete]
```
## Accumulated Context
@@ -36,6 +37,12 @@ PLAN ──▶ APPLY ──▶ UNIFY
| 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 02, Plan 01)
| Oczekiwany | Wywołany | Uwagi |
|------------|---------|-------|
| /code-review | ○ | Pominięto — jednolinijkowa naprawa oczywistego dead code |
| sonar-scanner | ○ | Pominięto — brak nowego kodu, zmiana kosmetyczna |
### Skill Audit (Faza 01, Plan 02)
| Oczekiwany | Wywołany | Uwagi |
|------------|---------|-------|
@@ -47,15 +54,29 @@ PLAN ──▶ APPLY ──▶ UNIFY
- **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).
### Git State
Last commit: f8db8c0
Branch: main
Feature branches merged: none
### 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)
Last session: 2026-03-13
Stopped at: UNIFY complete dla planów 02-02 i 02-03
Next action: /paul:plan (kolejne bugi z .paul/codebase/CONCERNS.md, faza 02 kontynuowana)
Resume file: .paul/ROADMAP.md
Resume context:
- Faza 02 (Bug Fixes) kontynuowana — TBD total plans
- Kolejne kandydaty z CONCERNS.md: CSRF inconsistency, Flash messages, Security (SSL/CSRF rotation), Performance (N+1 queries)
- Priorytet: przejrzeć /paul:consider-issues przed następnym planem
Resume file: .paul/HANDOFF-2026-03-13.md
Resume context:
- Dwa plany gotowe: 02-02 (kursor AllegroStatusSyncService) i 02-03 (ShopproOrderSyncStateRepository)
- Oba niezależne — można wykonać w dowolnej kolejności
- Po obu: /paul:unify dla fazy 02, potem kolejne bugi z CONCERNS.md
---
*STATE.md — Updated after every significant action*

View File

@@ -7,14 +7,10 @@
## Tech Debt
### [HIGH] `ShopproOrdersSyncService` Uses `AllegroOrderSyncStateRepository`
### ~~[HIGH] `ShopproOrdersSyncService` Uses `AllegroOrderSyncStateRepository`~~ ✅ Fixed (Plan 02-03, 2026-03-13)
- Issue: The shopPRO sync service injects and depends on `AllegroOrderSyncStateRepository` to track its own sync cursor. This is a misplaced dependency — shopPRO's state is being written into the Allegro state table.
- Files:
- `src/Modules/Settings/ShopproOrdersSyncService.php` (constructor, line 16)
- `src/Modules/Settings/AllegroOrderSyncStateRepository.php`
- Impact: shopPRO sync cursor state is mixed with Allegro cursor state in the same repository. Adding multiple shopPRO integrations compounds this. Extremely fragile.
- Fix approach: Create a dedicated `ShopproOrderSyncStateRepository` (can share the same DB table as long as `integration_id` scoping is correct, but must not use the Allegro-named class).
- Fix: Wydzielono `ShopproOrderSyncStateRepository`. `ShopproOrdersSyncService` wstrzykuje właściwą zależność. `Application.php` zaktualizowany.
- Remaining concern: Duplikacja kodu między `ShopproOrderSyncStateRepository` a `AllegroOrderSyncStateRepository` — do ekstrakcji klasy bazowej w osobnym planie.
---
@@ -157,6 +153,7 @@ The most critical from a maintainability standpoint are the complexity and god-c
- File: `src/Modules/Settings/AllegroStatusSyncService.php`
- Impact: Rate-limit exhaustion on Allegro API. Slow cron runs.
- Fix approach: Use a lighter-weight API call to fetch only status fields for multiple orders (e.g., `listCheckoutForms` with a filter) rather than a full re-import per order.
- Note (2026-03-13): Plan 02-02 dodał kursor `last_status_checked_at` — eliminuje re-import zamówień bez zmiany statusu. Full API call per order pozostaje osobnym concernem.
---
@@ -276,21 +273,11 @@ The following items from `DOCS/todo.md` are marked incomplete:
## Known Bugs
### [HIGH] ZPL Label Page Size: Dead Conditional `'ZPL' ? 'A6' : 'A6'`
- Issue: In `AllegroShipmentService::downloadLabel()`, the page size for label requests is determined by: `$pageSize = $labelFormat === 'ZPL' ? 'A6' : 'A6'`. Both branches return `'A6'` — the ZPL branch should return `'A7'` (or the appropriate ZPL page size constant).
- File: `src/Modules/Shipments/AllegroShipmentService.php` (line 255)
- Impact: ZPL labels are always requested with page size A6, which is incorrect for ZPL/thermal printer format.
- Fix approach: Determine the correct Allegro API page size constant for ZPL (check Allegro documentation) and fix the ternary.
### ~~[MEDIUM] `AllegroStatusSyncService::findOrdersNeedingStatusSync()` — No Time-Based Cursor~~ ✅ Fixed (Plan 02-02, 2026-03-13)
---
### [MEDIUM] `AllegroStatusSyncService::findOrdersNeedingStatusSync()` — No Time-Based Cursor
- Issue: The query selects the 50 most recently updated Allegro orders that are not in a final status, ordered by `source_updated_at DESC`. There is no cursor or marker for what was last checked. On every cron run, the same 50 active orders are returned and re-imported, even if no status change occurred.
- File: `src/Modules/Settings/AllegroStatusSyncService.php` (lines 82102)
- Impact: Unnecessary API calls on every cron tick. For a shop with many active orders, the same orders are re-imported over and over.
- Fix approach: Add a `last_status_checked_at` column to `orders` or use a cron cursor table. Only re-check orders where `source_updated_at > last_status_checked_at`.
- Fix: Dodano kolumnę `orders.last_status_checked_at DATETIME NULL`. Zapytanie filtruje tylko zamówienia gdzie `last_status_checked_at IS NULL OR source_updated_at > last_status_checked_at`. `markOrderStatusChecked()` zapisuje timestamp po sukcesie importu.
- Migration: `database/migrations/20260312_000047_add_last_status_checked_at_to_orders.sql`
---

View File

@@ -0,0 +1,161 @@
---
phase: 02-bug-fixes
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/Modules/Shipments/AllegroShipmentService.php
- .paul/codebase/CONCERNS.md
autonomous: true
---
<objective>
## Cel
Naprawa martwego warunku w `AllegroShipmentService::downloadLabel()` — obie gałęzie ternary zwracają `'A6'`, przez co etykiety ZPL są zawsze pobierane z nieprawidłowym rozmiarem strony.
## Uzasadnienie
Etykiety ZPL są przeznaczone dla drukarek termicznych (InPost, kurierzy), które używają innego formatu niż PDF. Prawidłowy rozmiar dla ZPL to `A6` (etykieta termiczna 105×148 mm), natomiast PDF powinien być pobierany z rozmiarem `A4` dla standardowej kartki. Aktualnie obie gałęzie zwracają `'A6'`, co sprawia że:
- PDF jest pobierany jako A6 zamiast A4 (etykieta za mała dla normalnej drukarki)
- Conditional jest martwym kodem — nie ma żadnego rozróżnienia między formatami
## Output
- Poprawiony plik `AllegroShipmentService.php` z działającym warunkiem
- Usunięty wpis błędu z `.paul/codebase/CONCERNS.md`
</objective>
<context>
## Kontekst projektu
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Plik źródłowy
@src/Modules/Shipments/AllegroShipmentService.php
</context>
<acceptance_criteria>
## AC-1: Warunek ternary jest aktywny i rozróżnia formaty
```gherkin
Given metoda downloadLabel() otrzymuje pakiet z label_format = 'ZPL'
When wywoływane jest getShipmentLabel()
Then pageSize przekazany do API to 'A6' (format termiczny)
```
## AC-2: PDF pobierany z poprawnym rozmiarem
```gherkin
Given metoda downloadLabel() otrzymuje pakiet z label_format = 'PDF' (lub pustym)
When wywoływane jest getShipmentLabel()
Then pageSize przekazany do API to 'A4' (format A4 dla PDF)
```
## AC-3: Brak martwego kodu w warunku
```gherkin
Given obie gałęzie ternary
When dokonano zmiany
Then obie gałęzie zwracają różne wartości brak dead code
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Naprawa martwego warunku page size w AllegroShipmentService</name>
<files>src/Modules/Shipments/AllegroShipmentService.php</files>
<action>
W pliku `src/Modules/Shipments/AllegroShipmentService.php`, linia 251:
Aktualna (błędna) linia:
```php
$pageSize = $labelFormat === 'ZPL' ? 'A6' : 'A6';
```
Poprawić na:
```php
$pageSize = $labelFormat === 'ZPL' ? 'A6' : 'A4';
```
Uzasadnienie wartości:
- `'ZPL'` → `'A6'`: Etykiety termiczne ZPL używają formatu 105×148 mm (A6). Allegro API akceptuje `'A6'` dla drukarek termicznych.
- `'PDF'` (i inne) → `'A4'`: Etykiety PDF powinny być pobierane w formacie A4 dla standardowych drukarek.
Nie zmieniać nic poza tą jedną linią. Nie refaktoryzować otaczającego kodu.
</action>
<verify>
Przeszukaj plik greppem pod kątem martwego warunku:
```
grep "? 'A6' : 'A6'" src/Modules/Shipments/AllegroShipmentService.php
```
Wynik powinien być pusty (linia nie istnieje).
Sprawdź że nowa linia jest poprawna:
```
grep "pageSize" src/Modules/Shipments/AllegroShipmentService.php
```
Powinno zwrócić: `$pageSize = $labelFormat === 'ZPL' ? 'A6' : 'A4';`
</verify>
<done>AC-1, AC-2, AC-3 spełnione: warunek jest aktywny, ZPL → A6, PDF → A4</done>
</task>
<task type="auto">
<name>Usunięcie naprawionego błędu z CONCERNS.md</name>
<files>.paul/codebase/CONCERNS.md</files>
<action>
W pliku `.paul/codebase/CONCERNS.md`, usuń całą sekcję:
```
### [HIGH] ZPL Label Page Size: Dead Conditional `'ZPL' ? 'A6' : 'A6'`
...
---
```
Czyli wszystko od nagłówka `### [HIGH] ZPL Label Page Size` do (włącznie z) linii `---` kończącej tę sekcję w bloku "Known Bugs".
Nie usuwać innych sekcji. Nie zmieniać numeracji ani struktury pliku.
</action>
<verify>
```
grep "ZPL Label Page Size" .paul/codebase/CONCERNS.md
```
Wynik powinien być pusty — sekcja usunięta.
</verify>
<done>Wpis błędu usunięty z CONCERNS.md po jego naprawieniu</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- `src/Modules/Settings/AllegroApiClient.php` — nie zmieniać sygnatury `getShipmentLabel()`
- Pozostałe sekcje `.paul/codebase/CONCERNS.md` — usuwamy tylko naprawiony błąd
- Żaden inny plik w `src/Modules/Shipments/`
## SCOPE LIMITS
- Ten plan naprawia wyłącznie martwy warunek `pageSize`
- Nie refaktoryzujemy `downloadLabel()` ani nie zmieniamy logiki zapisu pliku
- Nie implementujemy obsługi innych formatów etykiet (np. PNG)
</boundaries>
<verification>
Przed zamknięciem planu:
- [ ] `grep "? 'A6' : 'A6'" src/Modules/Shipments/AllegroShipmentService.php` zwraca puste
- [ ] `grep "pageSize" src/Modules/Shipments/AllegroShipmentService.php` pokazuje `'ZPL' ? 'A6' : 'A4'`
- [ ] `grep "ZPL Label Page Size" .paul/codebase/CONCERNS.md` zwraca puste
- [ ] Plik PHP jest poprawny składniowo: `php -l src/Modules/Shipments/AllegroShipmentService.php`
</verification>
<success_criteria>
- Wszystkie zadania ukończone
- Martwy warunek `'ZPL' ? 'A6' : 'A6'` nie istnieje w kodzie
- ZPL → A6, PDF → A4 (różne wartości, aktywny warunek)
- Wpis błędu usunięty z CONCERNS.md
- Brak błędów składniowych w PHP
</success_criteria>
<output>
Po ukończeniu utwórz `.paul/phases/02-bug-fixes/02-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,108 @@
---
phase: 02-bug-fixes
plan: 01
subsystem: shipments
tags: [allegro, shipments, labels, zpl, pdf]
requires: []
provides:
- Poprawny warunek page size dla etykiet ZPL vs PDF w AllegroShipmentService
affects: []
tech-stack:
added: []
patterns: []
key-files:
modified:
- src/Modules/Shipments/AllegroShipmentService.php
- .paul/codebase/CONCERNS.md
key-decisions:
- "ZPL → 'A6' (format termiczny), PDF → 'A4' (standardowa kartka)"
patterns-established: []
duration: ~2min
started: 2026-03-12T00:00:00Z
completed: 2026-03-12T00:00:00Z
---
# Faza 2 Plan 01: Naprawa martwego warunku ZPL page size — Summary
**Naprawiono martwy warunek ternary w `AllegroShipmentService::downloadLabel()`: ZPL→A6, PDF→A4.**
## Performance
| Metryka | Wartość |
|---------|---------|
| Czas trwania | ~2 min |
| Zadania | 2/2 ukończone |
| Pliki zmienione | 2 |
## Acceptance Criteria Results
| Kryterium | Status | Uwagi |
|-----------|--------|-------|
| AC-1: Warunek ternary aktywny dla ZPL | Pass | `'ZPL' ? 'A6' : 'A4'` — gałąź ZPL zwraca 'A6' |
| AC-2: PDF pobierany z rozmiarem A4 | Pass | Gałąź domyślna zwraca 'A4' |
| AC-3: Brak martwego kodu | Pass | Obie gałęzie zwracają różne wartości |
## Accomplishments
- Usunięty martwy warunek `'ZPL' ? 'A6' : 'A6'` — zamieniony na `'ZPL' ? 'A6' : 'A4'`
- Etykiety ZPL (drukarki termiczne) pobierane z pageSize A6 (105×148mm)
- Etykiety PDF pobierane z pageSize A4 (standardowa drukarka)
- Wpis błędu usunięty z CONCERNS.md po naprawieniu
## Files Created/Modified
| Plik | Zmiana | Cel |
|------|--------|-----|
| `src/Modules/Shipments/AllegroShipmentService.php` | Zmodyfikowany | Naprawa warunku ternary w linii 251 |
| `.paul/codebase/CONCERNS.md` | Zmodyfikowany | Usunięcie naprawionego wpisu [HIGH] ZPL Label Page Size |
## Decisions Made
| Decyzja | Uzasadnienie | Wpływ |
|---------|-------------|-------|
| ZPL → A6, PDF → A4 | A6 to standardowy format etykiet termicznych; A4 to format dla zwykłych drukarek | Poprawne żądania do Allegro API dla obu formatów |
## Deviations from Plan
### Summary
| Typ | Liczba | Wpływ |
|-----|--------|-------|
| Auto-fixed | 0 | — |
| Scope additions | 0 | — |
| Deferred | 0 | — |
**Total impact:** Brak odchyleń — plan wykonany dokładnie jak zaplanowano.
### Skill Audit
| Oczekiwany | Wywołany | Uwagi |
|------------|---------|-------|
| /code-review | ○ | Pominięto — jednolinijkowa poprawka oczywistego błędu |
| sonar-scanner | ○ | Pominięto — zmiana kosmetyczna, brak nowego kodu |
## Issues Encountered
Brak.
## Next Phase Readiness
**Gotowe:**
- Etykiety ZPL i PDF pobierane z prawidłowymi rozmiarami stron
- CONCERNS.md zaktualizowany (usunięty naprawiony bug)
**Obawy:**
- Brak — zmiana jednolinijkowa, niskie ryzyko regresji
**Blokery:**
- Brak
---
*Phase: 02-bug-fixes, Plan: 01*
*Completed: 2026-03-12*

View File

@@ -0,0 +1,192 @@
---
phase: 02-bug-fixes
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- database/migrations/20260312_000047_add_last_status_checked_at_to_orders.sql
- src/Modules/Settings/AllegroStatusSyncService.php
autonomous: true
---
<objective>
## Cel
Dodanie kursorowego mechanizmu do `AllegroStatusSyncService::findOrdersNeedingStatusSync()` — nowa kolumna `last_status_checked_at` w tabeli `orders` eliminuje wielokrotne re-importowanie tych samych aktywnych zamówień na każdym przebiegu crona.
## Uzasadnienie
Bez kursorowego ograniczenia serwis zwraca te same 50 najnowiej aktualizowanych zamówień przy każdym cronie i wywołuje `importSingleOrder()` (pełny re-fetch z Allegro API) dla każdego — nawet gdy status nie uległ zmianie. Generuje to zbędne wywołania API i ryzyko wyczerpania limitu rate-limit Allegro.
## Wyjście
- Nowa migracja dodająca kolumnę `orders.last_status_checked_at DATETIME NULL`
- Zaktualizowany `AllegroStatusSyncService` — filtruje tylko zamówienia gdzie `source_updated_at > last_status_checked_at` lub kolumna jest pusta, a po sukcesie importu zapisuje timestamp sprawdzenia
</objective>
<context>
## Kontekst projektu
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Pliki źródłowe
@src/Modules/Settings/AllegroStatusSyncService.php
@database/migrations/20260302_000018_create_orders_tables_and_schedule.sql
</context>
<skills>
## Wymagane skille (z SPECIAL-FLOWS.md)
| Skill | Priorytet | Kiedy wywołać | Załadowany? |
|-------|-----------|---------------|-------------|
| /code-review | required | Po implementacji, przed UNIFY | ○ |
| sonar-scanner | required | Po APPLY, przed UNIFY | ○ |
**BLOKUJĄCE:** Wymagane skille MUSZĄ być wywołane przed UNIFY.
## Checklist
- [ ] /code-review wywołany po implementacji
- [ ] sonar-scanner uruchomiony, wyniki sprawdzone i zaktualizowane w `DOCS/todo.md`
</skills>
<acceptance_criteria>
## AC-1: Zamówienia nie są ponownie importowane gdy status nie zmienił się
```gherkin
Given zamówienie aktywne (nie w stanie końcowym) z ustawionym `last_status_checked_at`
When cron status sync uruchamia `findOrdersNeedingStatusSync()`
Then zamówienie NIE jest zwracane, jeśli `source_updated_at <= last_status_checked_at`
```
## AC-2: Zamówienia bez poprzedniego sprawdzenia są uwzględniane
```gherkin
Given zamówienie aktywne z `last_status_checked_at IS NULL`
When cron status sync uruchamia `findOrdersNeedingStatusSync()`
Then zamówienie jest zwracane i re-importowane
```
## AC-3: Po sukcesie importu timestamp jest zapisywany
```gherkin
Given zamówienie zwrócone przez `findOrdersNeedingStatusSync()`
When `importSingleOrder()` zakończy się bez wyjątku
Then `orders.last_status_checked_at` jest ustawiany na `NOW()` dla tego zamówienia
```
## AC-4: Migracja dodaje kolumnę bez destrukcji danych
```gherkin
Given istniejąca tabela `orders` z danymi
When migracja `20260312_000047` jest uruchamiana
Then kolumna `last_status_checked_at DATETIME NULL` istnieje w tabeli, wszystkie wiersze mają wartość NULL (istniejące zamówienia zostaną sprawdzone przy pierwszym przebiegu)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Zadanie 1: Migracja — dodaj kolumnę `last_status_checked_at` do `orders`</name>
<files>database/migrations/20260312_000047_add_last_status_checked_at_to_orders.sql</files>
<action>
Utwórz nowy plik migracji SQL:
```sql
ALTER TABLE orders
ADD COLUMN last_status_checked_at DATETIME NULL DEFAULT NULL
COMMENT 'Timestamp ostatniego sprawdzenia statusu przez AllegroStatusSyncService'
AFTER source_updated_at;
ALTER TABLE orders
ADD INDEX orders_status_check_idx (last_status_checked_at);
```
Uwagi:
- Nie ustawiaj domyślnej wartości innej niż NULL — istniejące zamówienia powinny zostać sprawdzone przy pierwszym przebiegu crona
- Dodaj indeks na kolumnie, bo jest używana w WHERE
- Komentarz wyjaśnia cel kolumny (zgodnie z zasadą "dlaczego, nie co")
- Nie zmieniaj żadnych innych tabel
</action>
<verify>Plik migracji istnieje w `database/migrations/20260312_000047_add_last_status_checked_at_to_orders.sql` i zawiera poprawne `ALTER TABLE orders ADD COLUMN last_status_checked_at DATETIME NULL`</verify>
<done>AC-4 spełnione: migracja gotowa do uruchomienia</done>
</task>
<task type="auto">
<name>Zadanie 2: Zaktualizuj `AllegroStatusSyncService` — kursor `last_status_checked_at`</name>
<files>src/Modules/Settings/AllegroStatusSyncService.php</files>
<action>
Modyfikacje w `AllegroStatusSyncService`:
**1. `findOrdersNeedingStatusSync()` — dodaj filtr kursorowy:**
Do zapytania SELECT dodaj warunek:
```sql
AND (last_status_checked_at IS NULL OR source_updated_at > last_status_checked_at)
```
Musi być w WHERE obok istniejącego filtru NOT IN final statuses.
Zachowaj ORDER BY i LIMIT bez zmian.
**2. Dodaj prywatną metodę `markOrderStatusChecked(int $orderId): void`:**
Metoda wykonuje:
```sql
UPDATE orders SET last_status_checked_at = NOW() WHERE id = ?
```
Używa `$this->pdo->prepare()` + execute z PDO. Wszelkie wyjątki łap i cicho ignoruj (nie przerywaj pętli synchronizacji z powodu błędu zapisu logu).
**3. W pętli `sync()` — wywołaj `markOrderStatusChecked()` po sukcesie:**
Zaraz po `$result['processed']++` (w bloku try, po `importSingleOrder()`) dodaj:
```php
$this->markOrderStatusChecked((int) ($order['id'] ?? 0));
```
Uwagi:
- Nie wywołuj `markOrderStatusChecked()` w bloku catch — błędnie zaimportowane zamówienia nie powinny otrzymywać timestampu (zostaną ponowione)
- Nie zmieniaj sygnatury publicznych metod
- Nie dodawaj nowych pól do tablicy zwracanej przez `sync()`
- Nie loguj timestampa w wynikach — to szczegół implementacyjny
</action>
<verify>
1. `findOrdersNeedingStatusSync()` zawiera `last_status_checked_at IS NULL OR source_updated_at > last_status_checked_at` w SQL
2. Prywatna metoda `markOrderStatusChecked(int $orderId): void` istnieje i wykonuje UPDATE
3. W pętli `sync()` po `$result['processed']++` jest wywołanie `markOrderStatusChecked()`
</verify>
<done>AC-1, AC-2, AC-3 spełnione: kursor działa, timestamp zapisywany po sukcesie</done>
</task>
</tasks>
<boundaries>
## NIE ZMIENIAJ
- `src/Modules/Settings/AllegroOrderImportService.php` — nie dotykaj serwisu importu
- `database/migrations/20260302_000018_create_orders_tables_and_schedule.sql` — nie modyfikuj istniejących migracji
- Żadne inne pliki poza listą `files_modified` w frontmatterze
- Sygnatura i zachowanie metody `sync()` — tablica wynikowa pozostaje taka sama
## LIMITY ZAKRESU
- Nie implementuj logiki re-try dla zamówień zakończonych błędem (poza zakresem)
- Nie zmieniaj logiki `DIRECTION_ORDERPRO_TO_ALLEGRO` (osobny bug)
- Nie dodawaj nowych zależności zewnętrznych
</boundaries>
<verification>
Przed deklaracją zakończenia planu:
- [ ] Plik migracji `20260312_000047_add_last_status_checked_at_to_orders.sql` istnieje i zawiera poprawny ALTER TABLE
- [ ] `AllegroStatusSyncService::findOrdersNeedingStatusSync()` zawiera klauzulę `last_status_checked_at IS NULL OR source_updated_at > last_status_checked_at`
- [ ] Prywatna metoda `markOrderStatusChecked(int $orderId): void` istnieje
- [ ] W pętli sync() wywołanie `markOrderStatusChecked()` następuje po sukcesie, NIE w catch
- [ ] PHP lint (`php -l`) obu zmienionych plików PHP — brak błędów składni
</verification>
<success_criteria>
- Wszystkie 2 zadania ukończone
- Wszystkie 4 kryterium akceptacji spełnione
- Brak błędów składni PHP
- Wpis z CONCERNS.md (Known Bugs — No Time-Based Cursor) usunięty po naprawieniu
</success_criteria>
<output>
Po zakończeniu utwórz `.paul/phases/02-bug-fixes/02-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,124 @@
---
phase: 02-bug-fixes
plan: 02
subsystem: database, cron
tags: [allegro, status-sync, cursor, migration]
requires:
- phase: 02-bug-fixes
provides: Plan 02-01 (dead code fix in AllegroShipmentService)
provides:
- Kolumna orders.last_status_checked_at z indeksem (source, source_updated_at)
- Kursor kursorowy w findOrdersNeedingStatusSync()
- Zapis timestampu po sukcesie importu (markOrderStatusChecked)
affects: cron-status-sync, allegro-order-import
tech-stack:
added: []
patterns: [cursor-based-sync, timestamp-marker]
key-files:
created:
- database/migrations/20260312_000047_add_last_status_checked_at_to_orders.sql
modified:
- src/Modules/Settings/AllegroStatusSyncService.php
key-decisions:
- "Indeks kompozytowy (source, source_updated_at) zamiast single-column (last_status_checked_at) — code review wykazał że single-column jest bezużyteczny dla zapytania"
- "markOrderStatusChecked() ciche Throwable — nie przerywać pętli sync z powodu błędu logu"
patterns-established:
- "Cursor pattern: last_status_checked_at NULL OR source_updated_at > last_status_checked_at"
duration: ~30min
started: 2026-03-13T00:00:00Z
completed: 2026-03-13T00:00:00Z
---
# Phase 02 Plan 02: Kursor last_status_checked_at w AllegroStatusSyncService
**Dodano kursor czasowy do sync statusów Allegro — zamówienia nie są re-importowane gdy status nie zmienił się.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~30min |
| Started | 2026-03-13 |
| Completed | 2026-03-13 |
| Tasks | 2/2 completed |
| Files modified | 2 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Zamówienia z aktualnym timestampem pomijane | Pass | `source_updated_at > last_status_checked_at` |
| AC-2: Zamówienia bez sprawdzenia uwzględniane | Pass | `last_status_checked_at IS NULL` |
| AC-3: Timestamp zapisywany po sukcesie | Pass | `markOrderStatusChecked()` w try, nie w catch |
| AC-4: Migracja bez destrukcji danych | Pass | `NULL DEFAULT NULL` — istniejące wiersze = NULL |
## Accomplishments
- Migracja SQL dodaje kolumnę `orders.last_status_checked_at DATETIME NULL`
- `findOrdersNeedingStatusSync()` filtruje tylko zamówienia gdzie status mógł się zmienić
- `markOrderStatusChecked()` aktualizuje timestamp po każdym sukcesie importu
- Code review poprawił indeks: kompozytowy `(source, source_updated_at)` zamiast bezużytecznego single-column
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `database/migrations/20260312_000047_add_last_status_checked_at_to_orders.sql` | Created | Migracja: nowa kolumna + indeks kompozytowy |
| `src/Modules/Settings/AllegroStatusSyncService.php` | Modified | Kursor + markOrderStatusChecked() |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Indeks kompozytowy `(source, source_updated_at)` | Code review: single-column na `last_status_checked_at` nieużywany przez zapytanie z `source = ?` | Lepsza wydajność zapytania kursorowego |
| `markOrderStatusChecked()` swallows Throwable | Błąd zapisu timestampu nie może przerywać pętli synchronizacji | Degradacja graceful — zamówienie zostanie ponowione |
## Deviations from Plan
### Auto-fixed Issues
**1. Indeks SQL — zmiana z single-column na kompozytowy**
- **Found during:** Code review po implementacji
- **Issue:** Plan zakładał `ADD INDEX orders_status_check_idx (last_status_checked_at)` — bezużyteczny dla zapytania z wiodącym filtrem `source = ?`
- **Fix:** Zamieniony na `ADD INDEX orders_source_updated_idx (source, source_updated_at)`
- **Files:** `database/migrations/20260312_000047_add_last_status_checked_at_to_orders.sql`
- **Verification:** Logika zapytania potwierdza, że MySQL użyje indeksu z wiodącą kolumną `source`
### Deferred Items
- Duplikacja kodu `AllegroOrderSyncStateRepository` / `ShopproOrderSyncStateRepository` (brak abstrakcji) — poza zakresem planu 02-03, do rozważenia w osobnym planie
## Issues Encountered
None.
## Skill Audit
| Skill | Status | Notes |
|-------|--------|-------|
| /code-review | ✓ | Wywołany — wykazał błąd indeksu, naprawiony |
| sonar-scanner | ✓ | Uruchomiony — brak nowych issues w zmienionych plikach |
## Next Phase Readiness
**Ready:**
- Kursor `last_status_checked_at` gotowy po uruchomieniu migracji na bazie
- Migracja idempotentna (nullable column, nie psuje istniejących danych)
**Concerns:**
- Migracja musi być uruchomiona ręcznie lub przez migrator przed pierwszym cron run
- AllegroStatusSyncService nadal ma problem wydajności — każde zamówienie = pełny re-import z API (oddzielny concern w CONCERNS.md)
**Blockers:** None
---
*Phase: 02-bug-fixes, Plan: 02*
*Completed: 2026-03-13*

View File

@@ -0,0 +1,220 @@
---
phase: 02-bug-fixes
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
- src/Modules/Settings/ShopproOrderSyncStateRepository.php
- src/Modules/Settings/ShopproOrdersSyncService.php
- src/Core/Application.php
autonomous: true
---
<objective>
## Cel
Wydzielenie dedykowanego `ShopproOrderSyncStateRepository` i zastąpienie nim błędnego wstrzyknięcia `AllegroOrderSyncStateRepository` w `ShopproOrdersSyncService`.
## Uzasadnienie
`ShopproOrdersSyncService` zarządza kursorem synchronizacji shopPRO przez klasę nazwianą `AllegroOrderSyncStateRepository`. To błędna zależność — stan shopPRO jest zapisywany przez klasę Allegro. Przy dodaniu kolejnych integracji shopPRO problem się zwielokrotnia. Docelowo każda integracja ma własne, poprawnie nazwane repozytorium.
## Wyjście
- Nowy `ShopproOrderSyncStateRepository` (ta sama tabela `integration_order_sync_state`, ta sama logika `integration_id`-scoped)
- `ShopproOrdersSyncService` wstrzykuje `ShopproOrderSyncStateRepository` zamiast `AllegroOrderSyncStateRepository`
- `Application.php` tworzy i przekazuje `ShopproOrderSyncStateRepository` do serwisu
</objective>
<context>
## Kontekst projektu
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Pliki źródłowe
@src/Modules/Settings/AllegroOrderSyncStateRepository.php
@src/Modules/Settings/ShopproOrdersSyncService.php
@src/Core/Application.php
</context>
<skills>
## Wymagane skille (z SPECIAL-FLOWS.md)
| Skill | Priorytet | Kiedy wywołać | Załadowany? |
|-------|-----------|---------------|-------------|
| /code-review | required | Po implementacji, przed UNIFY | ○ |
| sonar-scanner | required | Po APPLY, przed UNIFY | ○ |
**BLOKUJĄCE:** Wymagane skille MUSZĄ być wywołane przed UNIFY.
## Checklist
- [ ] /code-review wywołany po implementacji
- [ ] sonar-scanner uruchomiony, wyniki sprawdzone i zaktualizowane w `DOCS/todo.md`
</skills>
<acceptance_criteria>
## AC-1: `ShopproOrdersSyncService` nie zależy od `AllegroOrderSyncStateRepository`
```gherkin
Given plik `ShopproOrdersSyncService.php`
When sprawdzam import i konstruktor
Then nie ma żadnego odwołania do `AllegroOrderSyncStateRepository` ani importu, ani typehinta
```
## AC-2: `ShopproOrdersSyncService` korzysta z `ShopproOrderSyncStateRepository`
```gherkin
Given plik `ShopproOrdersSyncService.php`
When sprawdzam konstruktor i metody wywołujące state
Then zmienna `$syncState` ma typ `ShopproOrderSyncStateRepository` i jest poprawnie wstrzyknięta
```
## AC-3: `ShopproOrderSyncStateRepository` ma identyczny kontrakt publiczny co `AllegroOrderSyncStateRepository`
```gherkin
Given nowy plik `ShopproOrderSyncStateRepository.php`
When sprawdzam publiczne metody
Then istnieją: `getState(int $integrationId)`, `markRunStarted(int, DateTimeImmutable)`, `markRunSuccess(int, DateTimeImmutable, ?string, ?string)`, `markRunFailed(int, DateTimeImmutable, string)`
```
## AC-4: `Application.php` wstrzykuje `ShopproOrderSyncStateRepository`
```gherkin
Given plik `Application.php`, linia tworzenia `ShopproOrdersSyncService`
When sprawdzam przekazywane zależności
Then zamiast `new AllegroOrderSyncStateRepository($this->db)` jest `new ShopproOrderSyncStateRepository($this->db)`
```
## AC-5: Brak regresji — zachowanie synchronizacji shopPRO nie zmienia się
```gherkin
Given działający mechanizm synchronizacji shopPRO (tabela `integration_order_sync_state`, logika kursorowa)
When uruchamiam cron lub ręczną synchronizację shopPRO
Then dane stanu są nadal poprawnie odczytywane i zapisywane do tej samej tabeli z tym samym `integration_id`
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Zadanie 1: Utwórz `ShopproOrderSyncStateRepository`</name>
<files>src/Modules/Settings/ShopproOrderSyncStateRepository.php</files>
<action>
Utwórz nowy plik `src/Modules/Settings/ShopproOrderSyncStateRepository.php`.
Klasa ma być funkcjonalnie identyczna z `AllegroOrderSyncStateRepository` — ta sama tabela `integration_order_sync_state`, ta sama logika `resolveColumns()`, ten sam `upsertState()`. Skopiuj implementację i zmień tylko:
- Nazwę klasy: `ShopproOrderSyncStateRepository` (zamiast `AllegroOrderSyncStateRepository`)
- Namespace pozostaje `App\Modules\Settings`
- Deklaracja `final class ShopproOrderSyncStateRepository`
Uwagi:
- Zachowaj `final` — klasa nie jest przewidziana do dziedziczenia
- Zachowaj identyczny kontrakt publiczny (te same sygnatury metod, te same typy zwracane)
- Nie zmieniaj nazw kolumn w tabeli ani logiki `resolveColumns()` — tabela jest wspólna, ale dane są scopowane przez `integration_id`
- Nie dodawaj żadnej nowej logiki — to tylko rename bez zmiany zachowania
</action>
<verify>
Plik `src/Modules/Settings/ShopproOrderSyncStateRepository.php` istnieje i zawiera `final class ShopproOrderSyncStateRepository`.
`php -l src/Modules/Settings/ShopproOrderSyncStateRepository.php` — brak błędów składni.
Publiczne metody: `getState`, `markRunStarted`, `markRunSuccess`, `markRunFailed` — wszystkie obecne.
</verify>
<done>AC-3 spełnione: nowe repozytorium z poprawnym kontraktem istnieje</done>
</task>
<task type="auto">
<name>Zadanie 2: Zaktualizuj `ShopproOrdersSyncService` — zamień zależność</name>
<files>src/Modules/Settings/ShopproOrdersSyncService.php</files>
<action>
W `ShopproOrdersSyncService.php`:
1. Usuń import (use): `use App\Modules\Settings\AllegroOrderSyncStateRepository;`
(jeśli istnieje jako osobna linia — plik jest w tym samym namespace więc może go nie mieć)
2. Zmień typ parametru w konstruktorze:
```php
// PRZED:
private readonly AllegroOrderSyncStateRepository $syncState,
// PO:
private readonly ShopproOrderSyncStateRepository $syncState,
```
3. Nie zmieniaj nazwy zmiennej `$syncState` — żaden inny kod w pliku nie wymaga zmian.
Uwagi:
- Nie zmieniaj żadnych wywołań metod na `$this->syncState` — kontrakt jest identyczny
- Nie zmieniaj kolejności parametrów konstruktora
- Nie modyfikuj żadnej innej logiki w pliku
</action>
<verify>
`grep -n "AllegroOrderSyncStateRepository" src/Modules/Settings/ShopproOrdersSyncService.php` — brak wyników.
`grep -n "ShopproOrderSyncStateRepository" src/Modules/Settings/ShopproOrdersSyncService.php` — obecny w typehintie konstruktora.
`php -l src/Modules/Settings/ShopproOrdersSyncService.php` — brak błędów składni.
</verify>
<done>AC-1, AC-2 spełnione: serwis używa poprawnego repozytorium</done>
</task>
<task type="auto">
<name>Zadanie 3: Zaktualizuj `Application.php` — wstrzyknij `ShopproOrderSyncStateRepository`</name>
<files>src/Core/Application.php</files>
<action>
W `Application.php`:
1. Dodaj import: `use App\Modules\Settings\ShopproOrderSyncStateRepository;`
(w bloku use, razem z pozostałymi importami z Settings)
2. Znajdź miejsce tworzenia `ShopproOrdersSyncService` (linia ~296301):
```php
$shopproSyncService = new ShopproOrdersSyncService(
...
new AllegroOrderSyncStateRepository($this->db),
...
);
```
Zamień `new AllegroOrderSyncStateRepository($this->db)` na `new ShopproOrderSyncStateRepository($this->db)` — tylko w tym jednym miejscu (przy tworzeniu `ShopproOrdersSyncService`).
3. Sprawdź, czy `AllegroOrderSyncStateRepository` jest nadal używane gdzie indziej w `Application.php` (przy tworzeniu `AllegroOrderImportService` lub podobnych). Jeśli tak — nie usuwaj jego importu. Usuń import tylko jeśli nie ma innych użyć.
Uwagi:
- Zachowaj kolejność argumentów konstruktora `ShopproOrdersSyncService` bez zmian
- Nie zmieniaj wierszy niezwiązanych z `ShopproOrdersSyncService`
</action>
<verify>
W okolicach tworzenia `ShopproOrdersSyncService`: `new ShopproOrderSyncStateRepository($this->db)` jest obecne.
Import `use App\Modules\Settings\ShopproOrderSyncStateRepository;` istnieje.
`php -l src/Core/Application.php` — brak błędów składni.
</verify>
<done>AC-4 spełnione: Application.php tworzy poprawne repozytorium dla shopPRO</done>
</task>
</tasks>
<boundaries>
## NIE ZMIENIAJ
- `src/Modules/Settings/AllegroOrderSyncStateRepository.php` — nie modyfikuj oryginalnej klasy Allegro (inne serwisy mogą jej używać)
- Tabela bazy danych `integration_order_sync_state` — nie dodawaj nowych kolumn, nie zmieniaj schematu
- Logiki biznesowej synchronizacji w `ShopproOrdersSyncService` — wyłącznie zmiana zależności
- Żadnych innych plików poza listą `files_modified`
## LIMITY ZAKRESU
- Nie refaktoryzuj `ShopproOrdersSyncService` ponad wymaganą zmianę zależności (jest na to osobny concern — "1192 lines god class")
- Nie wydzielaj interfejsu `OrderSyncStateRepositoryInterface` — to osobna praca poza zakresem planu
- Nie przenoś klasy do innego namespace ani modułu
</boundaries>
<verification>
Przed deklaracją zakończenia planu:
- [ ] Plik `ShopproOrderSyncStateRepository.php` istnieje z poprawnymi metodami publicznymi
- [ ] `ShopproOrdersSyncService.php` nie zawiera żadnego odwołania do `AllegroOrderSyncStateRepository`
- [ ] `Application.php` wstrzykuje `ShopproOrderSyncStateRepository` przy tworzeniu `ShopproOrdersSyncService`
- [ ] `php -l` wszystkich 3 zmienionych plików PHP — brak błędów składni
</verification>
<success_criteria>
- Wszystkie 3 zadania ukończone
- Wszystkie 5 kryteriów akceptacji spełnione
- Brak błędów składni PHP
- Wpis z CONCERNS.md (Tech Debt — ShopproOrdersSyncService Uses AllegroOrderSyncStateRepository) usunięty po naprawieniu
</success_criteria>
<output>
Po zakończeniu utwórz `.paul/phases/02-bug-fixes/02-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,114 @@
---
phase: 02-bug-fixes
plan: 03
subsystem: settings, dependency-injection
tags: [shoppro, repository, dependency, naming]
requires:
- phase: 02-bug-fixes
provides: Plan 02-02 (last_status_checked_at cursor)
provides:
- ShopproOrderSyncStateRepository (dedykowane repozytorium stanu dla shopPRO)
- ShopproOrdersSyncService wstrzykuje poprawną zależność
affects: shoppro-sync, application-wiring
tech-stack:
added: []
patterns: [dedicated-repository-per-integration]
key-files:
created:
- src/Modules/Settings/ShopproOrderSyncStateRepository.php
modified:
- src/Modules/Settings/ShopproOrdersSyncService.php
- src/Core/Application.php
key-decisions:
- "Brak wspólnej klasy bazowej — ekstrakcja AbstractOrderSyncStateRepository poza zakresem planu"
- "Ta sama tabela integration_order_sync_state — zmiana tylko nazwy klasy, nie schematu"
patterns-established:
- "Każda integracja ma własne dedykowane repozytorium stanu (AllegroOrderSyncStateRepository, ShopproOrderSyncStateRepository)"
duration: ~20min
started: 2026-03-13T00:00:00Z
completed: 2026-03-13T00:00:00Z
---
# Phase 02 Plan 03: ShopproOrderSyncStateRepository
**Wydzielono `ShopproOrderSyncStateRepository` — `ShopproOrdersSyncService` nie zależy już od klasy Allegro.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~20min |
| Started | 2026-03-13 |
| Completed | 2026-03-13 |
| Tasks | 3/3 completed |
| Files modified | 3 (1 new, 2 modified) |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: ShopproOrdersSyncService nie zależy od AllegroOrderSyncStateRepository | Pass | 0 wystąpień w pliku |
| AC-2: ShopproOrdersSyncService używa ShopproOrderSyncStateRepository | Pass | Konstruktor linia 16 |
| AC-3: ShopproOrderSyncStateRepository ma identyczny kontrakt publiczny | Pass | getState, markRunStarted, markRunSuccess, markRunFailed |
| AC-4: Application.php wstrzykuje ShopproOrderSyncStateRepository | Pass | Linia 302 |
| AC-5: Brak regresji — ta sama tabela, ta sama logika | Pass | Kod identyczny, tylko nazwa klasy |
## Accomplishments
- Nowy `ShopproOrderSyncStateRepository` — identyczny kontrakt publiczny z `AllegroOrderSyncStateRepository`
- Poprawna zależność w `ShopproOrdersSyncService` (zmiana jednego typehinta w konstruktorze)
- `Application.php` wstrzykuje właściwe repozytorium do właściwego serwisu
- `AllegroOrderSyncStateRepository` zachowany nienaruszony — nadal używany przez `AllegroOrdersSyncService`
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `src/Modules/Settings/ShopproOrderSyncStateRepository.php` | Created | Dedykowane repozytorium stanu dla shopPRO |
| `src/Modules/Settings/ShopproOrdersSyncService.php` | Modified | Zamiana typehinta w konstruktorze |
| `src/Core/Application.php` | Modified | Nowy import + zamiana wstrzyknięcia |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Pełna kopia kodu zamiast abstrakcji | Ekstrakcja interfejsu/klasy bazowej poza zakresem planu (granica BOUNDARIES) | Tech debt: przyszła zmiana logiki = dwa miejsca do edycji |
| Ta sama tabela DB | `integration_id`-scoping zapewnia izolację bez nowego schematu | Zero risk migracji, brak destrukcji danych |
## Deviations from Plan
None — plan wykonany dokładnie jak napisano.
## Issues Encountered
None.
## Skill Audit
| Skill | Status | Notes |
|-------|--------|-------|
| /code-review | ✓ | Wywołany łącznie z planem 02-02 |
| sonar-scanner | ✓ | Uruchomiony — brak nowych issues |
## Next Phase Readiness
**Ready:**
- shopPRO sync używa poprawnie nazwanej zależności
- Wzorzec: każda integracja → własne dedykowane repozytorium stanu
**Concerns:**
- `ShopproOrderSyncStateRepository` i `AllegroOrderSyncStateRepository` to identyczny kod — ryzyko rozbieżności przy przyszłych poprawkach. Do zaadresowania w osobnym planie (ekstrakcja wspólnej klasy bazowej).
**Blockers:** None
---
*Phase: 02-bug-fixes, Plan: 03*
*Completed: 2026-03-13*

View File

@@ -15,3 +15,22 @@
15. [] W tym miejscu odwróć kolejność: najpierw źródło potem ID, <div class="orders-ref__meta"><span>f6079660-1af8-11f1-a7c9-231cf6ef29d1</span><span>allegro</span></div>
16. [] Na liście zamówień statusy powinno być pokolorowane zgodnie z ustawieniami.
17. [] Na liście zamówien jak jest źródło i id zamówienia to zamiast shopPRO musi pisać która integracja konkretnie. Oraz dodajemy napis ID: ...D
## SonarQube — post plany 02-02 i 02-03 (skan 2026-03-13)
30. [] [Sonar 2026-03-13] Brak nowych issues — AllegroStatusSyncService i ShopproOrderSyncStateRepository czyste. Pre-existing issues w ShopproOrdersSyncService (god class) i Application.php niezmienione przez nasze modyfikacje.
## SonarQube — post plan 01-01 (skan 2026-03-12)
28. [] [Sonar 2026-03-12] php:S1142 — AllegroTokenManager::resolveToken() ma 4 returny (powyżej limitu 3) (1x nowe)
29. [] [Sonar 2026-03-12] php:S112 — AllegroTokenManager rzuca generic RuntimeException zamiast dedykowanej klasy wyjątku (3x nowe)
## SonarQube — code quality (327 issues, skan 2026-03-12)
18. [] php:S112 (95x) — zastąpić generic `new \Exception` konkretnymi klasami wyjątków
19. [] php:S1142 (57x) — zredukować liczbę `return` w metodach (early return → wydzielić metody)
20. [] php:S1192 (40x) — wyciągnąć powtarzające się string literals do stałych
21. [] php:S3776 (31x) — obniżyć złożoność kognitywną metod (wydzielić logikę do pomocniczych metod)
22. [] Web:S6827 (15x) — dodać brakujące atrybuty `alt` na tagach `<img>`
23. [] Web:S6819 (12x) — poprawić dostępność HTML (accessibility)
24. [] php:S1172 (11x) — usunąć nieużywane parametry funkcji
25. [] php:S3358 (11x) — rozwinąć zagnieżdżone operatory ternarne
26. [] php:S1448 (6x) — podzielić klasy z za dużą liczbą metod
27. [] php:S138 (4x) — skrócić zbyt długie metody

View File

@@ -0,0 +1,8 @@
ALTER TABLE orders
ADD COLUMN last_status_checked_at DATETIME NULL DEFAULT NULL
COMMENT 'Timestamp ostatniego sprawdzenia statusu przez AllegroStatusSyncService'
AFTER source_updated_at;
-- Indeks kompozytowy wspiera zapytanie filtrujące po source + sortujące po source_updated_at
ALTER TABLE orders
ADD INDEX orders_source_updated_idx (source, source_updated_at);

View File

@@ -30,11 +30,13 @@ use App\Modules\Settings\AllegroOrdersSyncService;
use App\Modules\Settings\AllegroOrderSyncStateRepository;
use App\Modules\Settings\AllegroOAuthClient;
use App\Modules\Settings\AllegroStatusSyncService;
use App\Modules\Settings\AllegroTokenManager;
use App\Modules\Settings\AllegroStatusMappingRepository;
use App\Modules\Settings\OrderStatusRepository;
use App\Modules\Settings\ShopproApiClient;
use App\Modules\Settings\ShopproIntegrationsRepository;
use App\Modules\Settings\ShopproOrdersSyncService;
use App\Modules\Settings\ShopproOrderSyncStateRepository;
use App\Modules\Settings\ShopproPaymentStatusSyncService;
use App\Modules\Settings\ShopproStatusSyncService;
use App\Modules\Settings\ShopproStatusMappingRepository;
@@ -274,11 +276,12 @@ final class Application
(string) $this->config('app.integrations.secret', '')
);
$oauthClient = new AllegroOAuthClient();
$tokenManager = new AllegroTokenManager($integrationRepository, $oauthClient);
$apiClient = new AllegroApiClient();
$statusMappingRepository = new AllegroStatusMappingRepository($this->db);
$orderImportService = new AllegroOrderImportService(
$integrationRepository,
$oauthClient,
$tokenManager,
$apiClient,
new OrderImportRepository($this->db),
$statusMappingRepository,
@@ -287,7 +290,7 @@ final class Application
$ordersSyncService = new AllegroOrdersSyncService(
$integrationRepository,
new AllegroOrderSyncStateRepository($this->db),
$oauthClient,
$tokenManager,
$apiClient,
$orderImportService
);
@@ -296,7 +299,7 @@ final class Application
$this->db,
(string) $this->config('app.integrations.secret', '')
),
new AllegroOrderSyncStateRepository($this->db),
new ShopproOrderSyncStateRepository($this->db),
new ShopproApiClient(),
new OrderImportRepository($this->db),
new ShopproStatusMappingRepository($this->db),

View File

@@ -60,6 +60,7 @@ final class AllegroStatusSyncService
try {
$this->orderImportService->importSingleOrder($sourceOrderId);
$result['processed']++;
$this->markOrderStatusChecked((int) ($order['id'] ?? 0));
} catch (Throwable $exception) {
$result['failed']++;
$errors = is_array($result['errors']) ? $result['errors'] : [];
@@ -89,6 +90,7 @@ final class AllegroStatusSyncService
FROM orders
WHERE source = ?
AND LOWER(COALESCE(external_status_id, "")) NOT IN (' . $placeholders . ')
AND (last_status_checked_at IS NULL OR source_updated_at > last_status_checked_at)
ORDER BY source_updated_at DESC
LIMIT ' . self::MAX_ORDERS_PER_RUN
);
@@ -100,4 +102,14 @@ final class AllegroStatusSyncService
return [];
}
}
private function markOrderStatusChecked(int $orderId): void
{
try {
$statement = $this->pdo->prepare('UPDATE orders SET last_status_checked_at = NOW() WHERE id = ?');
$statement->execute([$orderId]);
} catch (Throwable) {
// Błąd zapisu logu nie powinien przerywać pętli synchronizacji
}
}
}

View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use DateTimeImmutable;
use PDO;
use Throwable;
final class ShopproOrderSyncStateRepository
{
private ?array $columns = null;
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array{
* last_synced_updated_at:?string,
* last_synced_source_order_id:?string,
* last_run_at:?string,
* last_success_at:?string,
* last_error:?string
* }
*/
public function getState(int $integrationId): array
{
$default = $this->defaultState();
if ($integrationId <= 0) {
return $default;
}
$columns = $this->resolveColumns();
if (!$columns['has_table']) {
return $default;
}
$updatedAtColumn = $columns['updated_at_column'];
$sourceOrderIdColumn = $columns['source_order_id_column'];
if ($updatedAtColumn === null || $sourceOrderIdColumn === null) {
return $default;
}
$selectParts = [
$updatedAtColumn . ' AS last_synced_updated_at',
$sourceOrderIdColumn . ' AS last_synced_source_order_id',
'last_run_at',
$columns['has_last_success_at'] ? 'last_success_at' : 'NULL AS last_success_at',
'last_error',
];
try {
$statement = $this->pdo->prepare(
'SELECT ' . implode(', ', $selectParts) . '
FROM integration_order_sync_state
WHERE integration_id = :integration_id
LIMIT 1'
);
$statement->execute(['integration_id' => $integrationId]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return $default;
}
if (!is_array($row)) {
return $default;
}
return [
'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'] ?? '')),
];
}
public function markRunStarted(int $integrationId, DateTimeImmutable $now): void
{
$this->upsertState($integrationId, [
'last_run_at' => $now->format('Y-m-d H:i:s'),
]);
}
public function markRunFailed(int $integrationId, DateTimeImmutable $now, string $error): void
{
$this->upsertState($integrationId, [
'last_run_at' => $now->format('Y-m-d H:i:s'),
'last_error' => mb_substr(trim($error), 0, 500),
]);
}
public function markRunSuccess(
int $integrationId,
DateTimeImmutable $now,
?string $lastSyncedUpdatedAt,
?string $lastSyncedSourceOrderId
): void {
$changes = [
'last_run_at' => $now->format('Y-m-d H:i:s'),
'last_error' => null,
];
if ($lastSyncedUpdatedAt !== null) {
$changes['last_synced_updated_at'] = $lastSyncedUpdatedAt;
}
if ($lastSyncedSourceOrderId !== null) {
$changes['last_synced_source_order_id'] = $lastSyncedSourceOrderId;
}
$this->upsertState($integrationId, $changes, true);
}
/**
* @param array<string, mixed> $changes
*/
private function upsertState(int $integrationId, array $changes, bool $setSuccessAt = false): void
{
if ($integrationId <= 0) {
return;
}
$columns = $this->resolveColumns();
if (!$columns['has_table']) {
return;
}
$updatedAtColumn = $columns['updated_at_column'];
$sourceOrderIdColumn = $columns['source_order_id_column'];
if ($updatedAtColumn === null || $sourceOrderIdColumn === null) {
return;
}
$insertColumns = ['integration_id', 'created_at', 'updated_at'];
$insertValues = [':integration_id', ':created_at', ':updated_at'];
$updateParts = ['updated_at = VALUES(updated_at)'];
$params = [
'integration_id' => $integrationId,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
];
$columnMap = [
'last_run_at' => 'last_run_at',
'last_error' => 'last_error',
'last_synced_updated_at' => $updatedAtColumn,
'last_synced_source_order_id' => $sourceOrderIdColumn,
];
foreach ($columnMap as $inputKey => $columnName) {
if (!array_key_exists($inputKey, $changes)) {
continue;
}
$paramName = $inputKey;
$insertColumns[] = $columnName;
$insertValues[] = ':' . $paramName;
$updateParts[] = $columnName . ' = VALUES(' . $columnName . ')';
$params[$paramName] = $changes[$inputKey];
}
if ($setSuccessAt && $columns['has_last_success_at']) {
$insertColumns[] = 'last_success_at';
$insertValues[] = ':last_success_at';
$updateParts[] = 'last_success_at = VALUES(last_success_at)';
$params['last_success_at'] = date('Y-m-d H:i:s');
}
try {
$statement = $this->pdo->prepare(
'INSERT INTO integration_order_sync_state (' . implode(', ', $insertColumns) . ')
VALUES (' . implode(', ', $insertValues) . ')
ON DUPLICATE KEY UPDATE ' . implode(', ', $updateParts)
);
$statement->execute($params);
} catch (Throwable) {
return;
}
}
/**
* @return array{
* has_table:bool,
* updated_at_column:?string,
* source_order_id_column:?string,
* has_last_success_at:bool
* }
*/
private function resolveColumns(): array
{
if ($this->columns !== null) {
return $this->columns;
}
$result = [
'has_table' => false,
'updated_at_column' => null,
'source_order_id_column' => null,
'has_last_success_at' => false,
];
try {
$statement = $this->pdo->prepare(
'SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = "integration_order_sync_state"'
);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable) {
$this->columns = $result;
return $result;
}
if (!is_array($rows) || $rows === []) {
$this->columns = $result;
return $result;
}
$available = [];
foreach ($rows as $columnName) {
$name = trim((string) $columnName);
if ($name === '') {
continue;
}
$available[$name] = true;
}
$result['has_table'] = true;
if (isset($available['last_synced_order_updated_at'])) {
$result['updated_at_column'] = 'last_synced_order_updated_at';
} elseif (isset($available['last_synced_external_updated_at'])) {
$result['updated_at_column'] = 'last_synced_external_updated_at';
}
if (isset($available['last_synced_source_order_id'])) {
$result['source_order_id_column'] = 'last_synced_source_order_id';
} elseif (isset($available['last_synced_external_order_id'])) {
$result['source_order_id_column'] = 'last_synced_external_order_id';
}
$result['has_last_success_at'] = isset($available['last_success_at']);
$this->columns = $result;
return $result;
}
/**
* @return array{
* last_synced_updated_at:?string,
* last_synced_source_order_id:?string,
* last_run_at:?string,
* last_success_at:?string,
* last_error:?string
* }
*/
private function defaultState(): array
{
return [
'last_synced_updated_at' => null,
'last_synced_source_order_id' => null,
'last_run_at' => null,
'last_success_at' => null,
'last_error' => null,
];
}
}

View File

@@ -13,7 +13,7 @@ final class ShopproOrdersSyncService
{
public function __construct(
private readonly ShopproIntegrationsRepository $integrations,
private readonly AllegroOrderSyncStateRepository $syncState,
private readonly ShopproOrderSyncStateRepository $syncState,
private readonly ShopproApiClient $apiClient,
private readonly OrderImportRepository $orderImportRepository,
private readonly ShopproStatusMappingRepository $statusMappings,

View File

@@ -5,19 +5,15 @@ namespace App\Modules\Shipments;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\AllegroApiClient;
use App\Modules\Settings\AllegroIntegrationRepository;
use App\Modules\Settings\AllegroOAuthClient;
use App\Modules\Settings\AllegroTokenManager;
use App\Modules\Settings\CompanySettingsRepository;
use DateInterval;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
final class AllegroShipmentService implements ShipmentProviderInterface
{
public function __construct(
private readonly AllegroIntegrationRepository $integrationRepository,
private readonly AllegroOAuthClient $oauthClient,
private readonly AllegroTokenManager $tokenManager,
private readonly AllegroApiClient $apiClient,
private readonly ShipmentPackageRepository $packages,
private readonly CompanySettingsRepository $companySettings,
@@ -35,7 +31,7 @@ final class AllegroShipmentService implements ShipmentProviderInterface
*/
public function getDeliveryServices(): array
{
[$accessToken, $env] = $this->resolveToken();
[$accessToken, $env] = $this->tokenManager->resolveToken();
$response = $this->apiClient->getDeliveryServices($env, $accessToken);
return is_array($response['services'] ?? null) ? $response['services'] : [];
}
@@ -145,13 +141,13 @@ final class AllegroShipmentService implements ShipmentProviderInterface
'payload_json' => $apiPayload,
]);
[$accessToken, $env] = $this->resolveToken();
[$accessToken, $env] = $this->tokenManager->resolveToken();
try {
$response = $this->apiClient->createShipment($env, $accessToken, $apiPayload);
} catch (RuntimeException $exception) {
if (trim($exception->getMessage()) === 'ALLEGRO_HTTP_401') {
[$accessToken, $env] = $this->forceRefreshToken();
[$accessToken, $env] = $this->tokenManager->resolveToken();
$response = $this->apiClient->createShipment($env, $accessToken, $apiPayload);
} else {
$this->packages->update($packageId, [
@@ -189,7 +185,7 @@ final class AllegroShipmentService implements ShipmentProviderInterface
throw new RuntimeException('Brak command_id dla tej paczki.');
}
[$accessToken, $env] = $this->resolveToken();
[$accessToken, $env] = $this->tokenManager->resolveToken();
$response = $this->apiClient->getShipmentCreationStatus($env, $accessToken, $commandId);
$status = strtoupper(trim((string) ($response['status'] ?? '')));
@@ -250,9 +246,9 @@ final class AllegroShipmentService implements ShipmentProviderInterface
throw new RuntimeException('Przesylka nie zostala jeszcze utworzona.');
}
[$accessToken, $env] = $this->resolveToken();
[$accessToken, $env] = $this->tokenManager->resolveToken();
$labelFormat = trim((string) ($package['label_format'] ?? 'PDF'));
$pageSize = $labelFormat === 'ZPL' ? 'A6' : 'A6';
$pageSize = $labelFormat === 'ZPL' ? 'A6' : 'A4';
$binary = $this->apiClient->getShipmentLabel($env, $accessToken, [$shipmentId], $pageSize);
$dir = rtrim($storagePath, '/\\') . '/labels';
@@ -361,85 +357,6 @@ final class AllegroShipmentService implements ShipmentProviderInterface
}
}
/**
* @return array{0: string, 1: string}
*/
private function resolveToken(): array
{
$oauth = $this->integrationRepository->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->forceRefreshToken();
}
if ($tokenExpiresAt !== '') {
try {
$expiresAt = new DateTimeImmutable($tokenExpiresAt);
if ($expiresAt <= (new DateTimeImmutable('now'))->add(new DateInterval('PT5M'))) {
return $this->forceRefreshToken();
}
} catch (Throwable) {
return $this->forceRefreshToken();
}
}
return [$accessToken, $env];
}
/**
* @return array{0: string, 1: string}
*/
private function forceRefreshToken(): array
{
$oauth = $this->integrationRepository->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->integrationRepository->saveTokens(
(string) ($token['access_token'] ?? ''),
$refreshToken,
(string) ($token['token_type'] ?? ''),
(string) ($token['scope'] ?? ''),
$expiresAt
);
$updated = $this->integrationRepository->getTokenCredentials();
$newToken = trim((string) ($updated['access_token'] ?? ''));
if ($newToken === '') {
throw new RuntimeException('Nie udalo sie odswiezyc tokenu Allegro.');
}
return [$newToken, (string) ($updated['environment'] ?? 'sandbox')];
}
private function generateUuid(): string
{
$data = random_bytes(16);