feat(v1.7): orderPRO -> shopPRO status push sync

Implement bidirectional status sync for shopPRO integrations.
When direction is set to orderpro_to_shoppro, cron pushes manual
status changes to shopPRO via PUT API with reverse status mapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 12:54:57 +01:00
parent 054816b0ba
commit 957fddaf84
13 changed files with 867 additions and 41 deletions

View File

@@ -13,7 +13,7 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
| Attribute | Value | | Attribute | Value |
|-----------|-------| |-----------|-------|
| Version | 1.0.0 | | Version | 1.0.0 |
| Status | v1.6 Complete | | Status | v1.7 Complete |
| Last Updated | 2026-03-27 | | Last Updated | 2026-03-27 |
## Requirements ## 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] Automatyzacja: event `shipment.status_changed` + warunki statusowe przesylki - Phase 42
- [x] Usuwanie wpisu z kolejki druku etykiet z panelu ustawien - Phase 43 - [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] 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) ### Active (In Progress)
@@ -148,5 +149,5 @@ Quick Reference:
--- ---
*PROJECT.md — Updated when requirements or context change* *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)*

View File

@@ -6,17 +6,30 @@ orderPRO to narzÄ™dzie do wielokanaĹowego zarzÄ…dzania sprzedaĹĽÄ
## Current Milestone ## 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 | | 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
<details>
<summary>v1.6 Quick Status Change - 2026-03-27 (1 phase, 1 plan)</summary>
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/` Archive: `.paul/phases/44-inline-status-change/`
## Completed Milestones </details>
<details> <details>
<summary>v1.5 Operational Workflow Cleanup - 2026-03-25 (4 phases, 4 plans)</summary> <summary>v1.5 Operational Workflow Cleanup - 2026-03-25 (4 phases, 4 plans)</summary>
@@ -229,7 +242,7 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
--- ---
*Roadmap created: 2026-03-12* *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*

View File

@@ -5,15 +5,15 @@
See: .paul/PROJECT.md (updated 2026-03-12) 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. **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 ## Current Position
Milestone: v1.6 Quick Status Change — Complete Milestone: v1.7 ShopPRO Status Push — Complete
Phase: [1] of [1] (Inline Status Change) — Unified Phase: [1] of [1] (ShopPRO Status Push) — Unified
Plan: 44-01 completed with summary Plan: 45-01 completed with summary
Status: PLAN/APPLY/UNIFY closed for phase 44 Status: PLAN/APPLY/UNIFY closed for phase 45
Last activity: 2026-03-27 — Phase 44 complete, milestone v1.6 closed Last activity: 2026-03-27 — Phase 45 complete, milestone v1.7 closed
Progress: Progress:
- v0.1 Initial Release: [##########] 100% done - v0.1 Initial Release: [##########] 100% done
@@ -41,20 +41,25 @@ Progress:
- Phase 43: [##########] Complete (1/1 plans) - Phase 43: [##########] Complete (1/1 plans)
- v1.6 Quick Status Change: [##########] 100% done - v1.6 Quick Status Change: [##########] 100% done
- Phase 44: [##########] Complete (1/1 plans) - Phase 44: [##########] Complete (1/1 plans)
- v1.7 ShopPRO Status Push: [##########] 100% done
- Phase 45: [##########] Complete (1/1 plans)
## Loop Position ## Loop Position
Current loop state: Current loop state:
``` ```
PLAN --> APPLY --> UNIFY PLAN --> APPLY --> UNIFY
done done done [Loop closed for phase 44] done done done [Loop closed for phase 45]
``` ```
## Accumulated Context ## Accumulated Context
### Decisions ### 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 | 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-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` | | 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 | 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 | | 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) ### Skill Audit (Faza 44, Plan 01)
| Oczekiwany | Wywolany | Uwagi | | Oczekiwany | Wywolany | Uwagi |
|------------|---------|-------| |------------|---------|-------|
@@ -291,7 +301,7 @@ Brak.
## Session Continuity ## Session Continuity
Last session: 2026-03-27 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) Next action: Start next milestone planning (/paul:milestone or /paul:plan)
Resume file: .paul/ROADMAP.md Resume file: .paul/ROADMAP.md
--- ---

View File

@@ -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
---
<objective>
## 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
</objective>
<context>
## 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": <int>, "send_email": <bool>}`
Auth: header `X-Api-Key`
Response: `{"status":"ok","data":{"order_id":<int>,"status_id":<int>,"changed":<bool>}}`
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.
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| sonar-scanner | required | Po APPLY, przed UNIFY | ○ |
</skills>
<acceptance_criteria>
## 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
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Migracja DB + metoda PUT w ShopproApiClient</name>
<files>
database/migrations/20260327_000071_add_last_status_pushed_at_to_sync_state.sql,
src/Modules/Settings/ShopproApiClient.php,
src/Modules/Settings/ShopproOrderSyncStateRepository.php
</files>
<action>
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
</action>
<verify>
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)
</verify>
<done>AC-1 satisfied: ShopproApiClient ma metode PUT do zmiany statusu w shopPRO</done>
</task>
<task type="auto">
<name>Task 2: Implementacja push w ShopproStatusSyncService</name>
<files>
src/Modules/Settings/ShopproStatusSyncService.php
</files>
<action>
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
</action>
<verify>
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)
</verify>
<done>AC-2 i AC-3 satisfied: Push direction dziala w cron, brak mapowania nie blokuje</done>
</task>
<task type="auto">
<name>Task 3: Aktualizacja CronHandlerFactory + dokumentacja</name>
<files>
src/Modules/Cron/CronHandlerFactory.php,
DOCS/DB_SCHEMA.md,
DOCS/ARCHITECTURE.md,
DOCS/TECH_CHANGELOG.md
</files>
<action>
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
</action>
<verify>
php -l src/Modules/Cron/CronHandlerFactory.php
grep "ShopproApiClient" src/Modules/Cron/CronHandlerFactory.php (powinno byc >= 1)
</verify>
<done>Dokumentacja i factory zaktualizowane</done>
</task>
</tasks>
<boundaries>
## 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)
</boundaries>
<verification>
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)
</verification>
<success_criteria>
- Wszystkie taski ukonczone
- Wszystkie weryfikacje przeszly
- Brak nowych bledow skladni PHP
- Logika push nie interferuje z istniejaca logika pull
</success_criteria>
<output>
After completion, create `.paul/phases/45-shoppro-status-push/45-01-SUMMARY.md`
</output>

View File

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

View File

@@ -520,9 +520,14 @@
- uzupelnia `delivery` o telefon/e-mail klienta i etykiete metody dostawy z kosztem (`transport_cost`). - uzupelnia `delivery` o telefon/e-mail klienta i etykiete metody dostawy z kosztem (`transport_cost`).
- `ShopproStatusSyncService`: - `ShopproStatusSyncService`:
- uruchamiany z crona (`shoppro_order_status_sync`), - uruchamiany z crona (`shoppro_order_status_sync`),
- filtruje aktywne instancje `shopPRO` po kierunku synchronizacji statusow (`shoppro_to_orderpro`), - obsluguje oba kierunki synchronizacji statusow:
- dla wspieranego kierunku wykorzystuje `ShopproOrdersSyncService` do odswiezenia statusow/importu danych, - `shoppro_to_orderpro` (pull): wykorzystuje `ShopproOrdersSyncService` do odswiezenia statusow/importu danych,
- dla kierunku `orderpro_to_shoppro` pomija instancje i zwraca wynik informacyjny (tryb przygotowany pod kolejny etap). - `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": <int>, "send_email": <bool>}`,
- zwraca `{ok, http_code, message, changed}`.
- `ShopproPaymentStatusSyncService`: - `ShopproPaymentStatusSyncService`:
- uruchamiany z crona (`shoppro_payment_status_sync`), - uruchamiany z crona (`shoppro_payment_status_sync`),
- pobiera zamowienia shopPRO nieoznaczone jako oplacone (`orders.payment_status != 2`) i nie-finalne, - pobiera zamowienia shopPRO nieoznaczone jako oplacone (`orders.payment_status != 2`) i nie-finalne,

View File

@@ -154,13 +154,14 @@ Migracje z prefiksem `ensure_` to migracje kompensujące — zostały dodane
- historia zmian statusow utrzymywana w `order_status_history`. - historia zmian statusow utrzymywana w `order_status_history`.
### `integration_order_sync_state` ### `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: - Kolumny:
- `integration_id` (PK), - `integration_id` (PK),
- `last_synced_order_updated_at` (datetime, nullable) lub historycznie `last_synced_external_updated_at`, - `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_synced_source_order_id` (varchar, nullable) lub historycznie `last_synced_external_order_id`,
- `last_run_at` (datetime), - `last_run_at` (datetime),
- `last_success_at` (datetime), - `last_success_at` (datetime),
- `last_status_pushed_at` (datetime, nullable) — kursor synchronizacji push statusow orderPRO -> shopPRO,
- `last_error` (varchar 500), - `last_error` (varchar 500),
- `created_at`, `updated_at`. - `created_at`, `updated_at`.

View File

@@ -1,5 +1,17 @@
# Tech Changelog # 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") ## 2026-03-25 (Automation - new action "Wystaw paragon")
- Dodano nowy typ akcji automatyzacji: `issue_receipt` (Wystaw paragon). - Dodano nowy typ akcji automatyzacji: `issue_receipt` (Wystaw paragon).
- Konfiguracja akcji wymaga kompletu parametrow: - Konfiguracja akcji wymaga kompletu parametrow:

View File

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

View File

@@ -84,18 +84,26 @@ final class CronHandlerFactory
$shopproIntegrationsRepo = new ShopproIntegrationsRepository($this->db, $this->integrationSecret); $shopproIntegrationsRepo = new ShopproIntegrationsRepository($this->db, $this->integrationSecret);
$shopproApiClient = new ShopproApiClient(); $shopproApiClient = new ShopproApiClient();
$shopproSyncStateRepo = new ShopproOrderSyncStateRepository($this->db);
$shopproStatusMappingRepo = new ShopproStatusMappingRepository($this->db);
$shopproSyncService = new ShopproOrdersSyncService( $shopproSyncService = new ShopproOrdersSyncService(
$shopproIntegrationsRepo, $shopproIntegrationsRepo,
new ShopproOrderSyncStateRepository($this->db), $shopproSyncStateRepo,
$shopproApiClient, $shopproApiClient,
new OrderImportRepository($this->db), new OrderImportRepository($this->db),
new ShopproStatusMappingRepository($this->db), $shopproStatusMappingRepo,
$ordersRepository, $ordersRepository,
new ShopproOrderMapper(), new ShopproOrderMapper(),
new ShopproProductImageResolver($shopproApiClient) new ShopproProductImageResolver($shopproApiClient)
); );
$shopproStatusSyncService = new ShopproStatusSyncService(
$shopproStatusSyncService = new ShopproStatusSyncService($shopproIntegrationsRepo, $shopproSyncService); $shopproIntegrationsRepo,
$shopproSyncService,
$shopproApiClient,
$shopproSyncStateRepo,
$shopproStatusMappingRepo,
$this->db
);
$shopproPaymentSyncService = new ShopproPaymentStatusSyncService( $shopproPaymentSyncService = new ShopproPaymentStatusSyncService(
$shopproIntegrationsRepo, $shopproIntegrationsRepo,
new ShopproApiClient(), new ShopproApiClient(),

View File

@@ -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<string,mixed>|array<int,mixed>|null} * @return array{ok:bool,http_code:int|null,message:string,data:array<string,mixed>|array<int,mixed>|null}
*/ */
private function requestJson(string $url, string $apiKey, int $timeoutSeconds): array 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<string,mixed>|array<int,mixed>|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<string,mixed>|array<int,mixed>|null}
*/
private function executeRequest(
string $url,
string $apiKey,
int $timeoutSeconds,
string $method = 'GET',
?string $jsonBody = null
): array {
$curl = curl_init($url); $curl = curl_init($url);
if ($curl === false) { if ($curl === false) {
return [ return [
@@ -216,26 +285,40 @@ final class ShopproApiClient
]; ];
} }
$sslOpts = [ $headers = [
'Accept: application/json',
'X-Api-Key: ' . $apiKey,
];
$opts = [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => max(1, min(120, $timeoutSeconds)), CURLOPT_TIMEOUT => max(1, min(120, $timeoutSeconds)),
CURLOPT_CONNECTTIMEOUT => max(1, min(120, $timeoutSeconds)), CURLOPT_CONNECTTIMEOUT => max(1, min(120, $timeoutSeconds)),
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2, 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(); $caPath = $this->getCaBundlePath();
if ($caPath !== null) { 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); $body = curl_exec($curl);
$httpCode = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE); $httpCode = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
$curlError = trim(curl_error($curl)); $curlError = trim(curl_error($curl));
curl_close($curl);
if ($body === false) { if ($body === false) {
return [ return [

View File

@@ -149,6 +149,10 @@ final class ShopproOrderSyncStateRepository
'last_synced_source_order_id' => $sourceOrderIdColumn, '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) { foreach ($columnMap as $inputKey => $columnName) {
if (!array_key_exists($inputKey, $changes)) { if (!array_key_exists($inputKey, $changes)) {
continue; 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{ * @return array{
* has_table:bool, * has_table:bool,
@@ -199,6 +241,7 @@ final class ShopproOrderSyncStateRepository
'updated_at_column' => null, 'updated_at_column' => null,
'source_order_id_column' => null, 'source_order_id_column' => null,
'has_last_success_at' => false, 'has_last_success_at' => false,
'has_last_status_pushed_at' => false,
]; ];
try { try {
@@ -243,6 +286,7 @@ final class ShopproOrderSyncStateRepository
} }
$result['has_last_success_at'] = isset($available['last_success_at']); $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; $this->columns = $result;
return $result; return $result;

View File

@@ -3,6 +3,9 @@ declare(strict_types=1);
namespace App\Modules\Settings; namespace App\Modules\Settings;
use PDO;
use Throwable;
final class ShopproStatusSyncService final class ShopproStatusSyncService
{ {
private const DIRECTION_SHOPPRO_TO_ORDERPRO = 'shoppro_to_orderpro'; private const DIRECTION_SHOPPRO_TO_ORDERPRO = 'shoppro_to_orderpro';
@@ -10,7 +13,11 @@ final class ShopproStatusSyncService
public function __construct( public function __construct(
private readonly ShopproIntegrationsRepository $integrations, 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 public function sync(): array
{ {
$supportedIntegrationIds = []; $pullIntegrationIds = [];
$unsupportedCount = 0; $pushResults = [];
foreach ($this->integrations->listIntegrations() as $integration) { foreach ($this->integrations->listIntegrations() as $integration) {
$integrationId = (int) ($integration['id'] ?? 0); $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)); $direction = trim((string) ($integration['order_status_sync_direction'] ?? self::DIRECTION_SHOPPRO_TO_ORDERPRO));
if ($direction === self::DIRECTION_ORDERPRO_TO_SHOPPRO) { if ($direction === self::DIRECTION_ORDERPRO_TO_SHOPPRO) {
$unsupportedCount++; $pushResults[] = $this->syncPushDirection($integrationId);
continue; continue;
} }
$supportedIntegrationIds[] = $integrationId; $pullIntegrationIds[] = $integrationId;
} }
if ($supportedIntegrationIds === []) { $result = $this->buildPullResult($pullIntegrationIds);
$result['push_results'] = $pushResults;
return $result;
}
/**
* @param array<int, int> $pullIntegrationIds
* @return array<string, mixed>
*/
private function buildPullResult(array $pullIntegrationIds): array
{
if ($pullIntegrationIds === []) {
return [ return [
'ok' => true, 'ok' => true,
'processed' => 0, 'processed' => 0,
'checked_integrations' => 0, 'checked_integrations' => 0,
'unsupported_integrations' => $unsupportedCount, 'direction' => self::DIRECTION_SHOPPRO_TO_ORDERPRO,
'message' => 'Brak aktywnych integracji shopPRO z kierunkiem shopPRO -> orderPRO.',
]; ];
} }
@@ -52,12 +71,209 @@ final class ShopproStatusSyncService
'page_limit' => 50, 'page_limit' => 50,
'max_orders' => 200, 'max_orders' => 200,
'ignore_orders_fetch_enabled' => true, 'ignore_orders_fetch_enabled' => true,
'allowed_integration_ids' => $supportedIntegrationIds, 'allowed_integration_ids' => $pullIntegrationIds,
]); ]);
$result['ok'] = true; $result['ok'] = true;
$result['direction'] = self::DIRECTION_SHOPPRO_TO_ORDERPRO; $result['direction'] = self::DIRECTION_SHOPPRO_TO_ORDERPRO;
$result['unsupported_integrations'] = $unsupportedCount;
return $result; return $result;
} }
/**
* @return array<string, mixed>
*/
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<string, string> 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<int, array{order_id:int,source_order_id:string,external_status_id:string,latest_change:string}>
*/
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)));
}
} }