From 957fddaf846aaf7b9d89d252621fa63fb6642031 Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Fri, 27 Mar 2026 12:54:57 +0100 Subject: [PATCH] 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) --- .paul/PROJECT.md | 5 +- .paul/ROADMAP.md | 23 +- .paul/STATE.md | 28 +- .../45-shoppro-status-push/45-01-PLAN.md | 290 ++++++++++++++++++ .../45-shoppro-status-push/45-01-SUMMARY.md | 136 ++++++++ DOCS/ARCHITECTURE.md | 11 +- DOCS/DB_SCHEMA.md | 3 +- DOCS/TECH_CHANGELOG.md | 12 + ...dd_last_status_pushed_at_to_sync_state.sql | 7 + src/Modules/Cron/CronHandlerFactory.php | 16 +- src/Modules/Settings/ShopproApiClient.php | 97 +++++- .../ShopproOrderSyncStateRepository.php | 44 +++ .../Settings/ShopproStatusSyncService.php | 236 +++++++++++++- 13 files changed, 867 insertions(+), 41 deletions(-) create mode 100644 .paul/phases/45-shoppro-status-push/45-01-PLAN.md create mode 100644 .paul/phases/45-shoppro-status-push/45-01-SUMMARY.md create mode 100644 database/migrations/20260327_000071_add_last_status_pushed_at_to_sync_state.sql 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 + + + +After completion, create `.paul/phases/45-shoppro-status-push/45-01-SUMMARY.md` + 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))); + } }