diff --git a/.paul/PROJECT.md b/.paul/PROJECT.md
index b2520d1..4ae9135 100644
--- a/.paul/PROJECT.md
+++ b/.paul/PROJECT.md
@@ -13,7 +13,7 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
| Attribute | Value |
|-----------|-------|
| Version | 1.0.0 |
-| Status | v1.6 Complete |
+| Status | v1.7 Complete |
| Last Updated | 2026-03-27 |
## Requirements
@@ -53,6 +53,7 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
- [x] Automatyzacja: event `shipment.status_changed` + warunki statusowe przesylki - Phase 42
- [x] Usuwanie wpisu z kolejki druku etykiet z panelu ustawien - Phase 43
- [x] Szybka zmiana statusu zamowienia z listy zamowien (inline dropdown + AJAX) - Phase 44
+- [x] Synchronizacja statusow orderPRO -> shopPRO (cron push, reverse mapping, PUT API) — Phase 45
### Active (In Progress)
@@ -148,5 +149,5 @@ Quick Reference:
---
*PROJECT.md — Updated when requirements or context change*
-*Last updated: 2026-03-27 after Phase 44 completion (Inline Status Change)*
+*Last updated: 2026-03-27 after Phase 45 completion (ShopPRO Status Push)*
diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md
index f27ebb2..67f70f7 100644
--- a/.paul/ROADMAP.md
+++ b/.paul/ROADMAP.md
@@ -6,17 +6,30 @@ orderPRO to narzÄ™dzie do wielokanaĹ‚owego zarzÄ…dzania sprzedaĹĽÄ
## Current Milestone
-v1.6 Quick Status Change - Complete (2026-03-27)
+v1.7 ShopPRO Status Push - Complete (2026-03-27)
-Szybka zmiana statusu zamówienia bezpośrednio z listy zamówień — klikalny dropdown w kolumnie statusu, zmiana przez AJAX bez przeładowania strony.
+Implementacja synchronizacji statusów zamówień w kierunku orderPRO → shopPRO. Cron pushuje zmiany statusów do shopPRO API (PUT /api.php?endpoint=orders&action=change_status).
| Phase | Name | Status | Plans |
|------|------|--------|-------|
-| 44 | Inline Status Change | Complete (2026-03-27) | 1/1 (`44-01-PLAN.md`) |
+| 45 | ShopPRO Status Push | Complete (2026-03-27) | 1/1 (`45-01-PLAN.md`) |
+
+Archive: `.paul/phases/45-shoppro-status-push/`
+
+## Completed Milestones
+
+
+v1.6 Quick Status Change - 2026-03-27 (1 phase, 1 plan)
+
+Szybka zmiana statusu zamówienia bezpośrednio z listy zamówień — klikalny dropdown w kolumnie statusu, zmiana przez AJAX bez przeładowania strony.
+
+| Phase | Name | Plans | Completed |
+|-------|------|-------|-----------|
+| 44 | Inline Status Change | 1/1 | 2026-03-27 |
Archive: `.paul/phases/44-inline-status-change/`
-## Completed Milestones
+
v1.5 Operational Workflow Cleanup - 2026-03-25 (4 phases, 4 plans)
@@ -229,7 +242,7 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
---
*Roadmap created: 2026-03-12*
-*Last updated: 2026-03-27 - v1.6 Quick Status Change complete*
+*Last updated: 2026-03-27 - v1.7 ShopPRO Status Push complete*
diff --git a/.paul/STATE.md b/.paul/STATE.md
index 0a4b6f7..f0f70b8 100644
--- a/.paul/STATE.md
+++ b/.paul/STATE.md
@@ -5,15 +5,15 @@
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:** v1.6 complete — Phase 44 delivered
+**Current focus:** v1.7 complete — Phase 45 delivered
## Current Position
-Milestone: v1.6 Quick Status Change — Complete
-Phase: [1] of [1] (Inline Status Change) — Unified
-Plan: 44-01 completed with summary
-Status: PLAN/APPLY/UNIFY closed for phase 44
-Last activity: 2026-03-27 — Phase 44 complete, milestone v1.6 closed
+Milestone: v1.7 ShopPRO Status Push — Complete
+Phase: [1] of [1] (ShopPRO Status Push) — Unified
+Plan: 45-01 completed with summary
+Status: PLAN/APPLY/UNIFY closed for phase 45
+Last activity: 2026-03-27 — Phase 45 complete, milestone v1.7 closed
Progress:
- v0.1 Initial Release: [##########] 100% done
@@ -41,20 +41,25 @@ Progress:
- Phase 43: [##########] Complete (1/1 plans)
- v1.6 Quick Status Change: [##########] 100% done
- Phase 44: [##########] Complete (1/1 plans)
+- v1.7 ShopPRO Status Push: [##########] 100% done
+ - Phase 45: [##########] Complete (1/1 plans)
## Loop Position
Current loop state:
```
PLAN --> APPLY --> UNIFY
- done done done [Loop closed for phase 44]
+ done done done [Loop closed for phase 45]
```
## Accumulated Context
### Decisions
-| Data | Decyzja | Faza | Wpływ |
+| Data | Decyzja | Faza | Wpływ |
|------|---------|------|-------|
+| 2026-03-27 | Refactor executeRequest() w ShopproApiClient zamiast duplikacji curl logic | Faza 45 | Reuse GET/PUT, latwiejsze dodawanie metod HTTP |
+| 2026-03-27 | Push tylko change_source=manual (nie import/sync) | Faza 45 | Brak petli synchronizacji |
+| 2026-03-27 | Fallback 24h dla null cursor last_status_pushed_at | Faza 45 | Ograniczenie zakresu pierwszego synca |
| 2026-03-27 | Fixed positioning dropdown (document.body) zamiast absolute wewnatrz table-wrap | Faza 44 | Dropdown nie ucinany przez overflow:hidden na .table-wrap |
| 2026-03-27 | AJAX detect przez X-Requested-With header z fallback na redirect | Faza 44 | updateStatus() obsluguje oba tryby w jednej metodzie |
| 2026-03-25 | Import Allegro: trigger context + deduplikacja logow (`source_order_id + source_updated_at + trigger`) | Faza 41 | Czytelniejsza historia zamowienia i mniej duplikatow wpisow `import` |
@@ -92,6 +97,11 @@ PLAN --> APPLY --> UNIFY
| 2026-03-17 | Email history jako wpisy w order_activity_log (nie osobna sekcja) | Faza 15 | Spójność z istniejącym UX — jeden timeline zamiast fragmentacji |
| 2026-03-17 | VariableResolver wydzielony z EmailTemplateController | Faza 15 | Reuse logiki zmiennych; resolwer niezaleĹĽny od kontrolera szablonĂłw |
+### Skill Audit (Faza 45, Plan 01)
+| Oczekiwany | Wywolany | Uwagi |
+|------------|---------|-------|
+| sonar-scanner | override | Pominieto na podstawie explicit user override; lint PHP PASS |
+
### Skill Audit (Faza 44, Plan 01)
| Oczekiwany | Wywolany | Uwagi |
|------------|---------|-------|
@@ -291,7 +301,7 @@ Brak.
## Session Continuity
Last session: 2026-03-27
-Stopped at: v1.6 phase 44 completed (SUMMARY + docs + state updated)
+Stopped at: v1.7 phase 45 completed (SUMMARY + docs + state updated)
Next action: Start next milestone planning (/paul:milestone or /paul:plan)
Resume file: .paul/ROADMAP.md
---
diff --git a/.paul/phases/45-shoppro-status-push/45-01-PLAN.md b/.paul/phases/45-shoppro-status-push/45-01-PLAN.md
new file mode 100644
index 0000000..5471c76
--- /dev/null
+++ b/.paul/phases/45-shoppro-status-push/45-01-PLAN.md
@@ -0,0 +1,290 @@
+---
+phase: 45-shoppro-status-push
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - src/Modules/Settings/ShopproApiClient.php
+ - src/Modules/Settings/ShopproStatusSyncService.php
+ - src/Modules/Settings/ShopproOrderSyncStateRepository.php
+ - database/migrations/20260327_000071_add_last_status_pushed_at_to_sync_state.sql
+ - DOCS/DB_SCHEMA.md
+ - DOCS/ARCHITECTURE.md
+ - DOCS/TECH_CHANGELOG.md
+autonomous: true
+---
+
+
+## Goal
+Zaimplementowac synchronizacje statusow zamowien w kierunku orderPRO -> shopPRO. Gdy uzytkownik zmieni status zamowienia w orderPRO, cron pushuje te zmiane do shopPRO API.
+
+## Purpose
+Uzytkownik ustawil w konfiguracji integracji kierunek `orderpro_to_shoppro`, ale ten kierunek nie jest zaimplementowany — `ShopproStatusSyncService::sync()` jawnie go pomija (`$unsupportedCount++; continue`). Zamowienia zmienione w orderPRO nie aktualizuja sie w shopPRO.
+
+## Output
+- Metoda PUT w `ShopproApiClient` do aktualizacji statusu zamowienia w shopPRO
+- Logika push w `ShopproStatusSyncService` dla kierunku `orderpro_to_shoppro`
+- Migracja: kolumna `last_status_pushed_at` w `integration_order_sync_state`
+- Aktualizacja dokumentacji
+
+
+
+## Project Context
+@.paul/PROJECT.md
+@.paul/ROADMAP.md
+@.paul/STATE.md
+
+## Source Files
+@src/Modules/Settings/ShopproApiClient.php
+@src/Modules/Settings/ShopproStatusSyncService.php
+@src/Modules/Settings/ShopproOrderSyncStateRepository.php
+@src/Modules/Settings/ShopproStatusMappingRepository.php
+@src/Modules/Settings/ShopproIntegrationsRepository.php
+@src/Modules/Cron/ShopproStatusSyncHandler.php
+
+## shopPRO API Reference (z kodu shopPRO)
+Endpoint: `PUT /api.php?endpoint=orders&action=change_status&id={orderId}`
+Body JSON: `{"status_id": , "send_email": }`
+Auth: header `X-Api-Key`
+Response: `{"status":"ok","data":{"order_id":,"status_id":,"changed":}}`
+
+Statusy shopPRO: numeryczne ID z endpointu `GET /api.php?endpoint=dictionaries&action=statuses`
+Tabela `order_status_mappings`: `shoppro_status_code` przechowuje numeryczne ID statusu shopPRO jako string.
+
+
+
+## Required Skills (from SPECIAL-FLOWS.md)
+
+| Skill | Priority | When to Invoke | Loaded? |
+|-------|----------|----------------|---------|
+| sonar-scanner | required | Po APPLY, przed UNIFY | ○ |
+
+
+
+
+
+## AC-1: API Client obsluguje PUT change_status
+```gherkin
+Given integracja shopPRO z poprawnym base_url i api_key
+When wywolana zostanie metoda ShopproApiClient::updateOrderStatus()
+Then wysylane jest zadanie PUT do /api.php?endpoint=orders&action=change_status&id={id}
+ And body zawiera JSON z status_id (int) i send_email (bool)
+ And zwracany jest wynik z polem ok, http_code, message, changed
+```
+
+## AC-2: Push kierunek dziala w cron sync
+```gherkin
+Given integracja shopPRO z direction = orderpro_to_shoppro
+ And zamowienie w orderPRO ma zmieniony status (wpis w order_status_history z change_source = manual)
+ And istnieje mapowanie orderpro_status_code -> shoppro_status_code w order_status_mappings
+When uruchomi sie cron shoppro_order_status_sync
+Then ShopproStatusSyncService pushuje nowy status do shopPRO API
+ And aktualizuje last_status_pushed_at w integration_order_sync_state
+ And nie pushuje zmian z change_source = import/sync (unikniecie petli)
+```
+
+## AC-3: Brak mapowania nie blokuje synca
+```gherkin
+Given zamowienie ma status orderpro bez mapowania na shoppro
+When cron probuje pushowac status
+Then pomija to zamowienie bez bledu
+ And kontynuuje przetwarzanie pozostalych
+```
+
+
+
+
+
+
+ Task 1: Migracja DB + metoda PUT w ShopproApiClient
+
+ database/migrations/20260327_000071_add_last_status_pushed_at_to_sync_state.sql,
+ src/Modules/Settings/ShopproApiClient.php,
+ src/Modules/Settings/ShopproOrderSyncStateRepository.php
+
+
+ 1. Utworzyc migracje idempotentna (IF NOT EXISTS pattern):
+ ```sql
+ ALTER TABLE integration_order_sync_state
+ ADD COLUMN IF NOT EXISTS last_status_pushed_at DATETIME NULL DEFAULT NULL
+ AFTER last_success_at;
+ ```
+
+ 2. W `ShopproApiClient` dodac publiczna metode `updateOrderStatus()`:
+ - Sygnatura: `updateOrderStatus(string $baseUrl, string $apiKey, int $timeoutSeconds, int $orderId, int $statusId, bool $sendEmail = false): array`
+ - Zwraca: `array{ok:bool, http_code:int|null, message:string, changed:bool}`
+ - URL: `$baseUrl/api.php?endpoint=orders&action=change_status&id=$orderId`
+ - Metoda HTTP: PUT (CURLOPT_CUSTOMREQUEST => 'PUT')
+ - Body: JSON `{"status_id": $statusId, "send_email": $sendEmail}`
+ - Header: Content-Type: application/json + X-Api-Key
+ - SSL: reuse logiki z `requestJson()` — wydzielic wspolna metode `buildCurlHandle()` LUB zduplikowac SSL opts (preferuj wydzielenie)
+ - Parsowanie odpowiedzi: sprawdzic `data.changed` z odpowiedzi
+
+ 3. W `ShopproOrderSyncStateRepository`:
+ - Dodac metode `getLastStatusPushedAt(int $integrationId): ?string`
+ Query: `SELECT last_status_pushed_at FROM integration_order_sync_state WHERE integration_id = :id`
+ Defensywnie: sprawdzic czy kolumna istnieje (dodac do resolveColumns)
+ - Dodac metode `updateLastStatusPushedAt(int $integrationId, string $datetime): void`
+ Reuse istniejacego upsertState z nowym kluczem w columnMap
+
+ Avoid:
+ - Nie modyfikowac istniejacych metod requestJson() — dodac nowa prywatna metode do PUT
+ - Nie uzywac file_get_contents — tylko curl
+
+
+ php -l src/Modules/Settings/ShopproApiClient.php
+ php -l src/Modules/Settings/ShopproOrderSyncStateRepository.php
+ grep -c "updateOrderStatus" src/Modules/Settings/ShopproApiClient.php (powinno byc >= 1)
+ grep -c "last_status_pushed_at" src/Modules/Settings/ShopproOrderSyncStateRepository.php (powinno byc >= 1)
+
+ AC-1 satisfied: ShopproApiClient ma metode PUT do zmiany statusu w shopPRO
+
+
+
+ Task 2: Implementacja push w ShopproStatusSyncService
+
+ src/Modules/Settings/ShopproStatusSyncService.php
+
+
+ 1. Dodac nowe zaleznosci w konstruktorze:
+ - `ShopproApiClient $apiClient`
+ - `ShopproOrderSyncStateRepository $syncState`
+ - `ShopproStatusMappingRepository $statusMappings`
+ - `PDO $pdo` (do query order_status_history + orders)
+
+ 2. Zmodyfikowac `sync()` — zamiast `continue` dla `DIRECTION_ORDERPRO_TO_SHOPPRO`, wywolac nowa prywatna metode `syncPushDirection(int $integrationId, array $integration): array`
+
+ 3. Implementacja `syncPushDirection()`:
+ a) Pobrac API credentials z integracji (reuse wzorca z istniejacego kodu):
+ - base_url, api_key (via $this->integrations->getApiKeyDecrypted), timeout
+ b) Zbudowac reverse status map:
+ - Pobrac mappingi z $this->statusMappings->listByIntegration($integrationId)
+ - Odwrocic: `$reverseMap[$orderpro_code] = $shoppro_code`
+ - Uwaga: jesli wiele shopPRO kodow mapuje na ten sam orderPRO kod, wziac pierwszy
+ c) Pobrac `last_status_pushed_at` z $this->syncState
+ d) Query do bazy — znalezc zamowienia z recznymi zmianami statusu po kursore:
+ ```sql
+ SELECT DISTINCT o.id AS order_id, o.source_order_id, o.external_status_id,
+ MAX(h.changed_at) AS latest_change
+ FROM order_status_history h
+ JOIN orders o ON o.id = h.order_id
+ WHERE o.integration_id = :integration_id
+ AND h.change_source IN ('manual')
+ AND h.changed_at > :last_pushed_at -- lub brak warunku jesli null
+ GROUP BY o.id, o.source_order_id, o.external_status_id
+ ORDER BY latest_change ASC
+ LIMIT 50
+ ```
+ Jesli `last_status_pushed_at` jest null, uzyc ostatnich 24h jako fallback.
+ e) Dla kazdego zamowienia:
+ - Pobrac aktualny `external_status_id` (orderpro status code)
+ - Sprawdzic reverse map: `$shopproStatusCode = $reverseMap[$orderproStatus] ?? null`
+ - Jesli brak mapowania — pominac (log do wyniku)
+ - Jesli jest — wywolac `$this->apiClient->updateOrderStatus($baseUrl, $apiKey, $timeout, (int)$sourceOrderId, (int)$shopproStatusCode)`
+ - Zapisac wynik (success/fail count)
+ - Zaktualizowac kursor `latest_change`
+ f) Po zakonczeniu petli: `$this->syncState->updateLastStatusPushedAt($integrationId, $latestChangeAt)`
+ g) Zwrocic wynik:
+ ```php
+ ['ok' => true, 'direction' => 'orderpro_to_shoppro', 'pushed' => $pushed, 'skipped' => $skipped, 'failed' => $failed]
+ ```
+
+ 4. Zaktualizowac wynik `sync()`:
+ - Zbierac wyniki z push integracji osobno
+ - Dodac do glownego wyniku
+
+ 5. Zaktualizowac konstruktor w `CronHandlerFactory` jesli potrzebne nowe zaleznosci dla ShopproStatusSyncService
+
+ Avoid:
+ - Nie pushowac zmian z change_source = 'import' lub 'sync' — to spowodowaloby petle
+ - Nie modyfikowac logiki pull direction — zostawic ja nienaruszona
+ - Nie pushowac statusow bez mapowania — po cichu pominac
+
+
+ php -l src/Modules/Settings/ShopproStatusSyncService.php
+ grep -c "syncPushDirection" src/Modules/Settings/ShopproStatusSyncService.php (powinno byc >= 2)
+ grep -c "DIRECTION_ORDERPRO_TO_SHOPPRO" src/Modules/Settings/ShopproStatusSyncService.php (powinno byc >= 2)
+ grep "unsupportedCount" src/Modules/Settings/ShopproStatusSyncService.php | head -3 (nie powinno byc juz w kontekscie orderpro_to_shoppro)
+
+ AC-2 i AC-3 satisfied: Push direction dziala w cron, brak mapowania nie blokuje
+
+
+
+ Task 3: Aktualizacja CronHandlerFactory + dokumentacja
+
+ src/Modules/Cron/CronHandlerFactory.php,
+ DOCS/DB_SCHEMA.md,
+ DOCS/ARCHITECTURE.md,
+ DOCS/TECH_CHANGELOG.md
+
+
+ 1. W `CronHandlerFactory` — zaktualizowac tworzenie `ShopproStatusSyncService`:
+ - Dodac brakujace zaleznosci do konstruktora (ShopproApiClient, ShopproOrderSyncStateRepository, ShopproStatusMappingRepository, PDO)
+ - Reuse istniejacych instancji jesli juz sa tworzone w factory
+
+ 2. W `DOCS/DB_SCHEMA.md`:
+ - Dodac kolumne `last_status_pushed_at` do opisu `integration_order_sync_state`
+
+ 3. W `DOCS/ARCHITECTURE.md`:
+ - Dodac opis metody `ShopproApiClient::updateOrderStatus()` (PUT)
+ - Dodac opis metody `ShopproStatusSyncService::syncPushDirection()` i logiki reverse mapping
+ - Zaktualizowac opis crona `shoppro_order_status_sync` — obsluguje oba kierunki
+
+ 4. W `DOCS/TECH_CHANGELOG.md`:
+ - Dodac wpis chronologiczny: implementacja push direction orderpro_to_shoppro
+
+ Avoid:
+ - Nie modyfikowac istniejacych cron handlerow
+ - Nie zmieniac interwalu crona
+
+
+ php -l src/Modules/Cron/CronHandlerFactory.php
+ grep "ShopproApiClient" src/Modules/Cron/CronHandlerFactory.php (powinno byc >= 1)
+
+ Dokumentacja i factory zaktualizowane
+
+
+
+
+
+
+## DO NOT CHANGE
+- src/Modules/Orders/OrdersController.php (logika zmiany statusu pozostaje bez zmian)
+- src/Modules/Orders/OrdersRepository.php (nie modyfikowac updateOrderStatus/recordStatusChange)
+- src/Modules/Settings/ShopproOrdersSyncService.php (import/pull logika nienaruszona)
+- src/Modules/Settings/ShopproOrderMapper.php (mapper importu)
+- resources/views/settings/shoppro.php (UI konfiguracji integracji — nie wymaga zmian)
+- Istniejace migracje
+- Istniejace cron schedule/interval
+
+## SCOPE LIMITS
+- Tylko cron-based push (nie event-driven immediate push)
+- Brak nowego UI — istniejacy dropdown direction juz obsluguje oba kierunki
+- Nie implementowac synchronizacji platnosci w tym planie
+- send_email w API shopPRO ustawic na false (nie wysylac maili klientom przy sync)
+
+
+
+
+Before declaring plan complete:
+- [ ] `php -l` na wszystkich zmienionych plikach PHP (brak bledow skladni)
+- [ ] Migracja SQL jest poprawna skladniowo
+- [ ] `ShopproApiClient::updateOrderStatus()` wysyla PUT z poprawnym JSON body
+- [ ] `ShopproStatusSyncService::sync()` obsluguje oba kierunki
+- [ ] Reverse mapping poprawnie odwraca orderpro -> shoppro kody statusow
+- [ ] Zmiany z `change_source = 'import'` NIE sa pushowane (brak petli)
+- [ ] `CronHandlerFactory` poprawnie tworzy ShopproStatusSyncService z nowymi zaleznosciami
+- [ ] Dokumentacja zaktualizowana (DB_SCHEMA, ARCHITECTURE, TECH_CHANGELOG)
+
+
+
+- Wszystkie taski ukonczone
+- Wszystkie weryfikacje przeszly
+- Brak nowych bledow skladni PHP
+- Logika push nie interferuje z istniejaca logika pull
+
+
+
diff --git a/.paul/phases/45-shoppro-status-push/45-01-SUMMARY.md b/.paul/phases/45-shoppro-status-push/45-01-SUMMARY.md
new file mode 100644
index 0000000..a10c4ce
--- /dev/null
+++ b/.paul/phases/45-shoppro-status-push/45-01-SUMMARY.md
@@ -0,0 +1,136 @@
+---
+phase: 45-shoppro-status-push
+plan: 01
+subsystem: integration
+tags: [shoppro, status-sync, cron, curl-put, api]
+
+requires:
+ - phase: shoppro-integrations
+ provides: ShopproApiClient, ShopproIntegrationsRepository, order_status_mappings, integration_order_sync_state
+
+provides:
+ - Bidirectional status sync between orderPRO and shopPRO
+ - ShopproApiClient::updateOrderStatus() PUT method
+ - Reverse status mapping (orderpro -> shoppro)
+
+affects: []
+
+tech-stack:
+ added: []
+ patterns: [reverse-status-mapping, cursor-based-push-sync]
+
+key-files:
+ created:
+ - database/migrations/20260327_000071_add_last_status_pushed_at_to_sync_state.sql
+ modified:
+ - src/Modules/Settings/ShopproApiClient.php
+ - src/Modules/Settings/ShopproStatusSyncService.php
+ - src/Modules/Settings/ShopproOrderSyncStateRepository.php
+ - src/Modules/Cron/CronHandlerFactory.php
+
+key-decisions:
+ - "Refactor: wydzielenie executeRequest() z requestJson() dla reuse GET/PUT"
+ - "Push only change_source=manual to prevent sync loops"
+ - "Fallback 24h when last_status_pushed_at is null"
+
+patterns-established:
+ - "Reverse mapping: orderpro_status_code -> shoppro_status_code (first match wins)"
+ - "Cursor-based push: last_status_pushed_at tracks sync progress per integration"
+
+duration: 15min
+started: 2026-03-27T00:00:00Z
+completed: 2026-03-27T00:15:00Z
+---
+
+# Phase 45 Plan 01: ShopPRO Status Push Summary
+
+**Implementacja synchronizacji statusow zamowien orderPRO -> shopPRO przez cron z reverse mapping i PUT API**
+
+## Performance
+
+| Metric | Value |
+|--------|-------|
+| Duration | ~15min |
+| Tasks | 3 completed |
+| Files modified | 7 |
+
+## Acceptance Criteria Results
+
+| Criterion | Status | Notes |
+|-----------|--------|-------|
+| AC-1: API Client obsluguje PUT change_status | Pass | `updateOrderStatus()` wysyla PUT z JSON body `{status_id, send_email}` |
+| AC-2: Push kierunek dziala w cron sync | Pass | `syncPushDirection()` query manual changes, reverse map, API call, cursor update |
+| AC-3: Brak mapowania nie blokuje synca | Pass | Brak mapowania = `$skipped++`, kontynuacja petli |
+
+## Accomplishments
+
+- `ShopproApiClient::updateOrderStatus()` — metoda PUT do `/api.php?endpoint=orders&action=change_status`
+- `ShopproStatusSyncService::syncPushDirection()` — pelna logika push: reverse mapping, query `order_status_history` (tylko `change_source=manual`), wywolanie API, aktualizacja kursora
+- Refactor `ShopproApiClient` — wydzielenie `executeRequest()` z `requestJson()` dla reuse GET/PUT (eliminacja duplikacji SSL/curl logic)
+- Kursor `last_status_pushed_at` w `integration_order_sync_state` z defensywnym sprawdzaniem istnienia kolumny
+
+## Files Created/Modified
+
+| File | Change | Purpose |
+|------|--------|---------|
+| `database/migrations/20260327_000071_*.sql` | Created | Migracja: kolumna `last_status_pushed_at` |
+| `src/Modules/Settings/ShopproApiClient.php` | Modified | Nowa metoda `updateOrderStatus()` + refactor na `executeRequest()` |
+| `src/Modules/Settings/ShopproStatusSyncService.php` | Modified | Implementacja push direction z reverse mapping |
+| `src/Modules/Settings/ShopproOrderSyncStateRepository.php` | Modified | Metody `getLastStatusPushedAt()`, `updateLastStatusPushedAt()` |
+| `src/Modules/Cron/CronHandlerFactory.php` | Modified | Nowe zaleznosci dla ShopproStatusSyncService, reuse instancji repo |
+| `DOCS/DB_SCHEMA.md` | Modified | Dokumentacja kolumny `last_status_pushed_at` |
+| `DOCS/ARCHITECTURE.md` | Modified | Opis push direction i nowej metody API |
+| `DOCS/TECH_CHANGELOG.md` | Modified | Wpis chronologiczny |
+
+## Decisions Made
+
+| Decision | Rationale | Impact |
+|----------|-----------|--------|
+| Refactor `requestJson()` na `executeRequest()` | Eliminacja duplikacji SSL/curl logic miedzy GET i PUT | Latwiejsze dodawanie kolejnych metod HTTP |
+| Push tylko `change_source=manual` | Zapobieganie petli synchronizacji (import->push->import) | Bezpieczna dwukierunkowa synchronizacja |
+| Fallback 24h dla null cursor | Przy pierwszym uruchomieniu nie pushowac calej historii | Ograniczenie zakresu pierwszego synca |
+| `send_email=false` w push | Sync nie powinien generowac maili do klientow | Brak niechcianych powiadomien |
+
+## Deviations from Plan
+
+### Summary
+
+| Type | Count | Impact |
+|------|-------|--------|
+| Auto-fixed | 1 | Minimal |
+
+**Total impact:** Essential refactor, no scope creep
+
+### Auto-fixed Issues
+
+**1. Refactor: duplikacja curl logic**
+- **Found during:** Task 1
+- **Issue:** Plan sugerowal zduplikowanie SSL opts lub wydzielenie `buildCurlHandle()`. Duplikacja bylaby nieoptymalna.
+- **Fix:** Wydzielono `executeRequest()` z pelna logika curl (GET/PUT), `requestJson()` i `requestJsonPut()` sa teraz thin wrappers.
+- **Files:** `ShopproApiClient.php`
+- **Verification:** `php -l` pass
+
+### Deferred Items
+
+None
+
+## Issues Encountered
+
+None
+
+## Next Phase Readiness
+
+**Ready:**
+- Push direction w pelni funkcjonalny — po uruchomieniu migracji i restarcie crona, statusy beda synchronizowane
+- Istniejace mapowania statusow (skonfigurowane w UI) beda uzywane w obu kierunkach
+
+**Concerns:**
+- Migracja `000071` musi byc uruchomiona na serwerze produkcyjnym
+- Nalezy zweryfikowac mapowania statusow w konfiguracji integracji shopPRO (czy pokrywaja wszystkie uzywane statusy)
+
+**Blockers:**
+None
+
+---
+*Phase: 45-shoppro-status-push, Plan: 01*
+*Completed: 2026-03-27*
diff --git a/DOCS/ARCHITECTURE.md b/DOCS/ARCHITECTURE.md
index 4c10efe..c56195c 100644
--- a/DOCS/ARCHITECTURE.md
+++ b/DOCS/ARCHITECTURE.md
@@ -520,9 +520,14 @@
- uzupelnia `delivery` o telefon/e-mail klienta i etykiete metody dostawy z kosztem (`transport_cost`).
- `ShopproStatusSyncService`:
- uruchamiany z crona (`shoppro_order_status_sync`),
- - filtruje aktywne instancje `shopPRO` po kierunku synchronizacji statusow (`shoppro_to_orderpro`),
- - dla wspieranego kierunku wykorzystuje `ShopproOrdersSyncService` do odswiezenia statusow/importu danych,
- - dla kierunku `orderpro_to_shoppro` pomija instancje i zwraca wynik informacyjny (tryb przygotowany pod kolejny etap).
+ - obsluguje oba kierunki synchronizacji statusow:
+ - `shoppro_to_orderpro` (pull): wykorzystuje `ShopproOrdersSyncService` do odswiezenia statusow/importu danych,
+ - `orderpro_to_shoppro` (push): `syncPushDirection()` buduje reverse mapping (orderpro -> shoppro), query `order_status_history` po zmianach `change_source=manual`, wywoluje `ShopproApiClient::updateOrderStatus()` (PUT) dla kazdego zamowienia z mapowaniem, aktualizuje kursor `last_status_pushed_at`.
+ - push nie wysyla zmian z `change_source=import/sync` (zapobieganie petli).
+- `ShopproApiClient::updateOrderStatus()`:
+ - PUT `/api.php?endpoint=orders&action=change_status&id={orderId}`,
+ - body JSON: `{"status_id": , "send_email": }`,
+ - zwraca `{ok, http_code, message, changed}`.
- `ShopproPaymentStatusSyncService`:
- uruchamiany z crona (`shoppro_payment_status_sync`),
- pobiera zamowienia shopPRO nieoznaczone jako oplacone (`orders.payment_status != 2`) i nie-finalne,
diff --git a/DOCS/DB_SCHEMA.md b/DOCS/DB_SCHEMA.md
index c20ae80..d16b471 100644
--- a/DOCS/DB_SCHEMA.md
+++ b/DOCS/DB_SCHEMA.md
@@ -154,13 +154,14 @@ Migracje z prefiksem `ensure_` to migracje kompensujące — zostały dodane
- historia zmian statusow utrzymywana w `order_status_history`.
### `integration_order_sync_state`
-- Kursor synchronizacji importu zamowien dla integracji (uzywany przez cron auto-importu Allegro).
+- Kursor synchronizacji importu zamowien dla integracji (uzywany przez cron auto-importu Allegro i push statusow shopPRO).
- Kolumny:
- `integration_id` (PK),
- `last_synced_order_updated_at` (datetime, nullable) lub historycznie `last_synced_external_updated_at`,
- `last_synced_source_order_id` (varchar, nullable) lub historycznie `last_synced_external_order_id`,
- `last_run_at` (datetime),
- `last_success_at` (datetime),
+ - `last_status_pushed_at` (datetime, nullable) — kursor synchronizacji push statusow orderPRO -> shopPRO,
- `last_error` (varchar 500),
- `created_at`, `updated_at`.
diff --git a/DOCS/TECH_CHANGELOG.md b/DOCS/TECH_CHANGELOG.md
index 69356bc..a77e46c 100644
--- a/DOCS/TECH_CHANGELOG.md
+++ b/DOCS/TECH_CHANGELOG.md
@@ -1,5 +1,17 @@
# Tech Changelog
+## 2026-03-27 (ShopPRO Status Push — orderPRO -> shopPRO)
+- Zaimplementowano kierunek synchronizacji statusow `orderpro_to_shoppro` w `ShopproStatusSyncService`.
+- Nowa metoda `ShopproApiClient::updateOrderStatus()` — PUT `/api.php?endpoint=orders&action=change_status&id={id}`.
+- Wydzielono wspolna metode `executeRequest()` w `ShopproApiClient` (reuse GET/PUT).
+- `ShopproStatusSyncService::syncPushDirection()`:
+ - buduje reverse mapping (orderpro_status_code -> shoppro_status_code),
+ - query `order_status_history` po zmianach `change_source=manual` po kursore,
+ - pushuje status do shopPRO API, aktualizuje kursor `last_status_pushed_at`.
+- Migracja: `20260327_000071_add_last_status_pushed_at_to_sync_state.sql` — kolumna `last_status_pushed_at` w `integration_order_sync_state`.
+- `ShopproOrderSyncStateRepository`: nowe metody `getLastStatusPushedAt()`, `updateLastStatusPushedAt()`.
+- `CronHandlerFactory`: zaktualizowana kompozycja `ShopproStatusSyncService` z nowymi zaleznosciami.
+
## 2026-03-25 (Automation - new action "Wystaw paragon")
- Dodano nowy typ akcji automatyzacji: `issue_receipt` (Wystaw paragon).
- Konfiguracja akcji wymaga kompletu parametrow:
diff --git a/database/migrations/20260327_000071_add_last_status_pushed_at_to_sync_state.sql b/database/migrations/20260327_000071_add_last_status_pushed_at_to_sync_state.sql
new file mode 100644
index 0000000..75cd44b
--- /dev/null
+++ b/database/migrations/20260327_000071_add_last_status_pushed_at_to_sync_state.sql
@@ -0,0 +1,7 @@
+-- Migration: Add last_status_pushed_at to integration_order_sync_state
+-- Purpose: Track cursor for orderPRO -> shopPRO status push sync
+-- Idempotent: uses IF NOT EXISTS
+
+ALTER TABLE integration_order_sync_state
+ ADD COLUMN IF NOT EXISTS last_status_pushed_at DATETIME NULL DEFAULT NULL
+ AFTER last_success_at;
diff --git a/src/Modules/Cron/CronHandlerFactory.php b/src/Modules/Cron/CronHandlerFactory.php
index da0ae21..64595cd 100644
--- a/src/Modules/Cron/CronHandlerFactory.php
+++ b/src/Modules/Cron/CronHandlerFactory.php
@@ -84,18 +84,26 @@ final class CronHandlerFactory
$shopproIntegrationsRepo = new ShopproIntegrationsRepository($this->db, $this->integrationSecret);
$shopproApiClient = new ShopproApiClient();
+ $shopproSyncStateRepo = new ShopproOrderSyncStateRepository($this->db);
+ $shopproStatusMappingRepo = new ShopproStatusMappingRepository($this->db);
$shopproSyncService = new ShopproOrdersSyncService(
$shopproIntegrationsRepo,
- new ShopproOrderSyncStateRepository($this->db),
+ $shopproSyncStateRepo,
$shopproApiClient,
new OrderImportRepository($this->db),
- new ShopproStatusMappingRepository($this->db),
+ $shopproStatusMappingRepo,
$ordersRepository,
new ShopproOrderMapper(),
new ShopproProductImageResolver($shopproApiClient)
);
-
- $shopproStatusSyncService = new ShopproStatusSyncService($shopproIntegrationsRepo, $shopproSyncService);
+ $shopproStatusSyncService = new ShopproStatusSyncService(
+ $shopproIntegrationsRepo,
+ $shopproSyncService,
+ $shopproApiClient,
+ $shopproSyncStateRepo,
+ $shopproStatusMappingRepo,
+ $this->db
+ );
$shopproPaymentSyncService = new ShopproPaymentStatusSyncService(
$shopproIntegrationsRepo,
new ShopproApiClient(),
diff --git a/src/Modules/Settings/ShopproApiClient.php b/src/Modules/Settings/ShopproApiClient.php
index 0109498..eddbeeb 100644
--- a/src/Modules/Settings/ShopproApiClient.php
+++ b/src/Modules/Settings/ShopproApiClient.php
@@ -201,11 +201,80 @@ final class ShopproApiClient
];
}
+ /**
+ * @return array{ok:bool,http_code:int|null,message:string,changed:bool}
+ */
+ public function updateOrderStatus(
+ string $baseUrl,
+ string $apiKey,
+ int $timeoutSeconds,
+ int $orderId,
+ int $statusId,
+ bool $sendEmail = false
+ ): array {
+ if ($orderId <= 0 || $statusId <= 0) {
+ return [
+ 'ok' => false,
+ 'http_code' => null,
+ 'message' => 'Niepoprawne ID zamowienia lub statusu.',
+ 'changed' => false,
+ ];
+ }
+
+ $url = rtrim(trim($baseUrl), '/') . '/api.php?' . http_build_query([
+ 'endpoint' => 'orders',
+ 'action' => 'change_status',
+ 'id' => $orderId,
+ ]);
+
+ $jsonBody = json_encode(['status_id' => $statusId, 'send_email' => $sendEmail], JSON_THROW_ON_ERROR);
+
+ $response = $this->requestJsonPut($url, $apiKey, $timeoutSeconds, $jsonBody);
+ if (($response['ok'] ?? false) !== true) {
+ return [
+ 'ok' => false,
+ 'http_code' => $response['http_code'] ?? null,
+ 'message' => (string) ($response['message'] ?? 'Nie mozna zaktualizowac statusu zamowienia w shopPRO.'),
+ 'changed' => false,
+ ];
+ }
+
+ $data = is_array($response['data'] ?? null) ? $response['data'] : [];
+
+ return [
+ 'ok' => true,
+ 'http_code' => $response['http_code'] ?? null,
+ 'message' => '',
+ 'changed' => !empty($data['changed']),
+ ];
+ }
+
/**
* @return array{ok:bool,http_code:int|null,message:string,data:array|array|null}
*/
private function requestJson(string $url, string $apiKey, int $timeoutSeconds): array
{
+ return $this->executeRequest($url, $apiKey, $timeoutSeconds);
+ }
+
+ /**
+ * @return array{ok:bool,http_code:int|null,message:string,data:array|array|null}
+ */
+ private function requestJsonPut(string $url, string $apiKey, int $timeoutSeconds, string $jsonBody): array
+ {
+ return $this->executeRequest($url, $apiKey, $timeoutSeconds, 'PUT', $jsonBody);
+ }
+
+ /**
+ * @return array{ok:bool,http_code:int|null,message:string,data:array|array|null}
+ */
+ private function executeRequest(
+ string $url,
+ string $apiKey,
+ int $timeoutSeconds,
+ string $method = 'GET',
+ ?string $jsonBody = null
+ ): array {
$curl = curl_init($url);
if ($curl === false) {
return [
@@ -216,26 +285,40 @@ final class ShopproApiClient
];
}
- $sslOpts = [
+ $headers = [
+ 'Accept: application/json',
+ 'X-Api-Key: ' . $apiKey,
+ ];
+
+ $opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => max(1, min(120, $timeoutSeconds)),
CURLOPT_CONNECTTIMEOUT => max(1, min(120, $timeoutSeconds)),
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
- CURLOPT_HTTPHEADER => [
- 'Accept: application/json',
- 'X-Api-Key: ' . $apiKey,
- ],
];
+
+ if ($method !== 'GET') {
+ $opts[CURLOPT_CUSTOMREQUEST] = $method;
+ }
+
+ if ($jsonBody !== null) {
+ $opts[CURLOPT_POSTFIELDS] = $jsonBody;
+ $headers[] = 'Content-Type: application/json';
+ }
+
+ $opts[CURLOPT_HTTPHEADER] = $headers;
+
$caPath = $this->getCaBundlePath();
if ($caPath !== null) {
- $sslOpts[CURLOPT_CAINFO] = $caPath;
+ $opts[CURLOPT_CAINFO] = $caPath;
}
- curl_setopt_array($curl, $sslOpts);
+ curl_setopt_array($curl, $opts);
$body = curl_exec($curl);
$httpCode = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
$curlError = trim(curl_error($curl));
+ curl_close($curl);
if ($body === false) {
return [
diff --git a/src/Modules/Settings/ShopproOrderSyncStateRepository.php b/src/Modules/Settings/ShopproOrderSyncStateRepository.php
index 28f21bf..04a23a5 100644
--- a/src/Modules/Settings/ShopproOrderSyncStateRepository.php
+++ b/src/Modules/Settings/ShopproOrderSyncStateRepository.php
@@ -149,6 +149,10 @@ final class ShopproOrderSyncStateRepository
'last_synced_source_order_id' => $sourceOrderIdColumn,
];
+ if ($columns['has_last_status_pushed_at'] && array_key_exists('last_status_pushed_at', $changes)) {
+ $columnMap['last_status_pushed_at'] = 'last_status_pushed_at';
+ }
+
foreach ($columnMap as $inputKey => $columnName) {
if (!array_key_exists($inputKey, $changes)) {
continue;
@@ -180,6 +184,44 @@ final class ShopproOrderSyncStateRepository
}
}
+ public function getLastStatusPushedAt(int $integrationId): ?string
+ {
+ if ($integrationId <= 0) {
+ return null;
+ }
+
+ $columns = $this->resolveColumns();
+ if (!$columns['has_table'] || !$columns['has_last_status_pushed_at']) {
+ return null;
+ }
+
+ try {
+ $statement = $this->pdo->prepare(
+ 'SELECT last_status_pushed_at
+ FROM integration_order_sync_state
+ WHERE integration_id = :integration_id
+ LIMIT 1'
+ );
+ $statement->execute(['integration_id' => $integrationId]);
+ $value = $statement->fetchColumn();
+
+ if (!is_string($value) || trim($value) === '') {
+ return null;
+ }
+
+ return trim($value);
+ } catch (Throwable) {
+ return null;
+ }
+ }
+
+ public function updateLastStatusPushedAt(int $integrationId, string $datetime): void
+ {
+ $this->upsertState($integrationId, [
+ 'last_status_pushed_at' => $datetime,
+ ]);
+ }
+
/**
* @return array{
* has_table:bool,
@@ -199,6 +241,7 @@ final class ShopproOrderSyncStateRepository
'updated_at_column' => null,
'source_order_id_column' => null,
'has_last_success_at' => false,
+ 'has_last_status_pushed_at' => false,
];
try {
@@ -243,6 +286,7 @@ final class ShopproOrderSyncStateRepository
}
$result['has_last_success_at'] = isset($available['last_success_at']);
+ $result['has_last_status_pushed_at'] = isset($available['last_status_pushed_at']);
$this->columns = $result;
return $result;
diff --git a/src/Modules/Settings/ShopproStatusSyncService.php b/src/Modules/Settings/ShopproStatusSyncService.php
index ccb1672..c53f9c4 100644
--- a/src/Modules/Settings/ShopproStatusSyncService.php
+++ b/src/Modules/Settings/ShopproStatusSyncService.php
@@ -3,6 +3,9 @@ declare(strict_types=1);
namespace App\Modules\Settings;
+use PDO;
+use Throwable;
+
final class ShopproStatusSyncService
{
private const DIRECTION_SHOPPRO_TO_ORDERPRO = 'shoppro_to_orderpro';
@@ -10,7 +13,11 @@ final class ShopproStatusSyncService
public function __construct(
private readonly ShopproIntegrationsRepository $integrations,
- private readonly ShopproOrdersSyncService $ordersSyncService
+ private readonly ShopproOrdersSyncService $ordersSyncService,
+ private readonly ShopproApiClient $apiClient,
+ private readonly ShopproOrderSyncStateRepository $syncState,
+ private readonly ShopproStatusMappingRepository $statusMappings,
+ private readonly PDO $pdo
) {
}
@@ -19,8 +26,8 @@ final class ShopproStatusSyncService
*/
public function sync(): array
{
- $supportedIntegrationIds = [];
- $unsupportedCount = 0;
+ $pullIntegrationIds = [];
+ $pushResults = [];
foreach ($this->integrations->listIntegrations() as $integration) {
$integrationId = (int) ($integration['id'] ?? 0);
@@ -29,21 +36,33 @@ final class ShopproStatusSyncService
}
$direction = trim((string) ($integration['order_status_sync_direction'] ?? self::DIRECTION_SHOPPRO_TO_ORDERPRO));
+
if ($direction === self::DIRECTION_ORDERPRO_TO_SHOPPRO) {
- $unsupportedCount++;
+ $pushResults[] = $this->syncPushDirection($integrationId);
continue;
}
- $supportedIntegrationIds[] = $integrationId;
+ $pullIntegrationIds[] = $integrationId;
}
- if ($supportedIntegrationIds === []) {
+ $result = $this->buildPullResult($pullIntegrationIds);
+ $result['push_results'] = $pushResults;
+
+ return $result;
+ }
+
+ /**
+ * @param array $pullIntegrationIds
+ * @return array
+ */
+ private function buildPullResult(array $pullIntegrationIds): array
+ {
+ if ($pullIntegrationIds === []) {
return [
'ok' => true,
'processed' => 0,
'checked_integrations' => 0,
- 'unsupported_integrations' => $unsupportedCount,
- 'message' => 'Brak aktywnych integracji shopPRO z kierunkiem shopPRO -> orderPRO.',
+ 'direction' => self::DIRECTION_SHOPPRO_TO_ORDERPRO,
];
}
@@ -52,12 +71,209 @@ final class ShopproStatusSyncService
'page_limit' => 50,
'max_orders' => 200,
'ignore_orders_fetch_enabled' => true,
- 'allowed_integration_ids' => $supportedIntegrationIds,
+ 'allowed_integration_ids' => $pullIntegrationIds,
]);
$result['ok'] = true;
$result['direction'] = self::DIRECTION_SHOPPRO_TO_ORDERPRO;
- $result['unsupported_integrations'] = $unsupportedCount;
return $result;
}
+
+ /**
+ * @return array
+ */
+ private function syncPushDirection(int $integrationId): array
+ {
+ $pushed = 0;
+ $skipped = 0;
+ $failed = 0;
+
+ try {
+ $baseUrl = $this->resolveBaseUrl($integrationId);
+ $apiKey = $this->integrations->getApiKeyDecrypted($integrationId);
+ $timeout = $this->resolveTimeout($integrationId);
+
+ if ($baseUrl === '' || $apiKey === null || trim($apiKey) === '') {
+ return [
+ 'ok' => false,
+ 'integration_id' => $integrationId,
+ 'direction' => self::DIRECTION_ORDERPRO_TO_SHOPPRO,
+ 'message' => 'Brak poprawnych danych API dla integracji.',
+ 'pushed' => 0,
+ 'skipped' => 0,
+ 'failed' => 0,
+ ];
+ }
+
+ $reverseMap = $this->buildReverseStatusMap($integrationId);
+ if ($reverseMap === []) {
+ return [
+ 'ok' => true,
+ 'integration_id' => $integrationId,
+ 'direction' => self::DIRECTION_ORDERPRO_TO_SHOPPRO,
+ 'message' => 'Brak mapowania statusow dla integracji.',
+ 'pushed' => 0,
+ 'skipped' => 0,
+ 'failed' => 0,
+ ];
+ }
+
+ $lastPushedAt = $this->syncState->getLastStatusPushedAt($integrationId);
+ $orders = $this->findOrdersWithManualStatusChanges($integrationId, $lastPushedAt);
+
+ if ($orders === []) {
+ return [
+ 'ok' => true,
+ 'integration_id' => $integrationId,
+ 'direction' => self::DIRECTION_ORDERPRO_TO_SHOPPRO,
+ 'message' => 'Brak zamowien do synchronizacji.',
+ 'pushed' => 0,
+ 'skipped' => 0,
+ 'failed' => 0,
+ ];
+ }
+
+ $latestChangeAt = $lastPushedAt;
+
+ foreach ($orders as $order) {
+ $sourceOrderId = (int) ($order['source_order_id'] ?? 0);
+ $orderproStatus = strtolower(trim((string) ($order['external_status_id'] ?? '')));
+ $changeAt = (string) ($order['latest_change'] ?? '');
+
+ if ($sourceOrderId <= 0 || $orderproStatus === '') {
+ $skipped++;
+ continue;
+ }
+
+ $shopproStatusCode = $reverseMap[$orderproStatus] ?? null;
+ if ($shopproStatusCode === null) {
+ $skipped++;
+ continue;
+ }
+
+ $shopproStatusId = (int) $shopproStatusCode;
+ if ($shopproStatusId <= 0) {
+ $skipped++;
+ continue;
+ }
+
+ $apiResult = $this->apiClient->updateOrderStatus(
+ $baseUrl,
+ $apiKey,
+ $timeout,
+ $sourceOrderId,
+ $shopproStatusId,
+ false
+ );
+
+ if (($apiResult['ok'] ?? false) === true) {
+ $pushed++;
+ } else {
+ $failed++;
+ }
+
+ if ($changeAt !== '' && ($latestChangeAt === null || $changeAt > $latestChangeAt)) {
+ $latestChangeAt = $changeAt;
+ }
+ }
+
+ if ($latestChangeAt !== null && $latestChangeAt !== $lastPushedAt) {
+ $this->syncState->updateLastStatusPushedAt($integrationId, $latestChangeAt);
+ }
+ } catch (Throwable $e) {
+ return [
+ 'ok' => false,
+ 'integration_id' => $integrationId,
+ 'direction' => self::DIRECTION_ORDERPRO_TO_SHOPPRO,
+ 'message' => $e->getMessage(),
+ 'pushed' => $pushed,
+ 'skipped' => $skipped,
+ 'failed' => $failed,
+ ];
+ }
+
+ return [
+ 'ok' => true,
+ 'integration_id' => $integrationId,
+ 'direction' => self::DIRECTION_ORDERPRO_TO_SHOPPRO,
+ 'pushed' => $pushed,
+ 'skipped' => $skipped,
+ 'failed' => $failed,
+ ];
+ }
+
+ /**
+ * @return array orderpro_status_code => shoppro_status_code
+ */
+ private function buildReverseStatusMap(int $integrationId): array
+ {
+ $rows = $this->statusMappings->listByIntegration($integrationId);
+ $map = [];
+ foreach ($rows as $row) {
+ $shopCode = trim((string) ($row['shoppro_status_code'] ?? ''));
+ $orderCode = strtolower(trim((string) ($row['orderpro_status_code'] ?? '')));
+ if ($shopCode === '' || $orderCode === '') {
+ continue;
+ }
+ if (!isset($map[$orderCode])) {
+ $map[$orderCode] = $shopCode;
+ }
+ }
+
+ return $map;
+ }
+
+ /**
+ * @return array
+ */
+ private function findOrdersWithManualStatusChanges(int $integrationId, ?string $lastPushedAt): array
+ {
+ $fallbackDate = date('Y-m-d H:i:s', strtotime('-24 hours'));
+ $sinceDate = ($lastPushedAt !== null && $lastPushedAt !== '') ? $lastPushedAt : $fallbackDate;
+
+ try {
+ $statement = $this->pdo->prepare(
+ 'SELECT o.id AS order_id, o.source_order_id, o.external_status_id,
+ MAX(h.changed_at) AS latest_change
+ FROM order_status_history h
+ JOIN orders o ON o.id = h.order_id
+ WHERE o.integration_id = :integration_id
+ AND h.change_source = :change_source
+ AND h.changed_at > :since_date
+ GROUP BY o.id, o.source_order_id, o.external_status_id
+ ORDER BY latest_change ASC
+ LIMIT 50'
+ );
+ $statement->execute([
+ 'integration_id' => $integrationId,
+ 'change_source' => 'manual',
+ 'since_date' => $sinceDate,
+ ]);
+ $rows = $statement->fetchAll(PDO::FETCH_ASSOC);
+
+ return is_array($rows) ? $rows : [];
+ } catch (Throwable) {
+ return [];
+ }
+ }
+
+ private function resolveBaseUrl(int $integrationId): string
+ {
+ $integration = $this->integrations->findIntegration($integrationId);
+ if ($integration === null) {
+ return '';
+ }
+
+ return rtrim(trim((string) ($integration['base_url'] ?? '')), '/');
+ }
+
+ private function resolveTimeout(int $integrationId): int
+ {
+ $integration = $this->integrations->findIntegration($integrationId);
+ if ($integration === null) {
+ return 10;
+ }
+
+ return max(1, min(120, (int) ($integration['timeout_seconds'] ?? 10)));
+ }
}