feat: Implement Allegro Order Sync and Status Management

- Added AllegroOrderSyncStateRepository for managing sync state with Allegro orders.
- Introduced AllegroOrdersSyncService to handle the synchronization of orders from Allegro.
- Created AllegroStatusDiscoveryService to discover and store order statuses from Allegro.
- Developed AllegroStatusMappingRepository for managing status mappings between Allegro and OrderPro.
- Implemented AllegroStatusSyncService to facilitate status synchronization.
- Added CronSettingsController for managing cron job settings related to Allegro integration.
This commit is contained in:
2026-03-04 23:21:35 +01:00
parent 9ca79ca8d8
commit 7ac4293df4
40 changed files with 5758 additions and 31 deletions

View File

@@ -3,8 +3,8 @@
"public_html": {
"AGENTS.md": {
"type": "-",
"size": 2207,
"lmtime": 1772497458624,
"size": 2593,
"lmtime": 1772522685966,
"modified": false
},
"ARCHITECTURE.md": {

View File

@@ -5,6 +5,21 @@
- Nie podpinaj `DB_HOST_REMOTE` do runtime aplikacji.
- Runtime aplikacji ma korzystac standardowo z `DB_HOST`.
## Zasady pisania kodu
- Kod ma być czytelny „dla obcego”: jasne nazwy, mało magii
- Brak „skrótów na szybko” typu logika w widokach, copy-paste, losowe helpery bez spójności
- Każda funkcja/klasa ma mieć jedną odpowiedzialność, zwykle do 3050 linii (jeśli dłuższe dzielić)
- max 3 poziomy zagnieżdżeń (if/foreach), reszta do osobnych metod
- Nazewnictwo:
- klasy: PascalCase
- metody/zmienne: camelCase
- stałe: UPPER_SNAKE_CASE
- Zero „skrótologii” w nazwach (np. $d, $tmp, $x1) poza pętlami 23 linijki
- medoo + prepared statements bez wyjątków (żadnego sklejania SQL stringiem)
- XSS: escape w widokach (np. helper e())
- CSRF dla formularzy, sensowna obsługa sesji
- Kod ma mieć komentarze tylko tam, gdzie wyjaśniają „dlaczego”, nie „co”
## Utrwalanie stalych wymagan
- Trwale wymagania techniczne zapisuj w tym pliku (`AGENTS.md`) w root projektu.
- Dla zmiennych srodowiskowych utrzymuj tez wpisy w `.env.example`.

View File

@@ -29,6 +29,17 @@
- `POST /settings/statuses/update`
- `POST /settings/statuses/delete`
- `POST /settings/statuses/reorder`
- `GET /settings/cron`
- `POST /settings/cron`
- `GET /settings/integrations/allegro`
- `POST /settings/integrations/allegro/save`
- `POST /settings/integrations/allegro/oauth/start`
- `POST /settings/integrations/allegro/import-single`
- `POST /settings/integrations/allegro/statuses/save`
- `POST /settings/integrations/allegro/statuses/save-bulk`
- `POST /settings/integrations/allegro/statuses/delete`
- `POST /settings/integrations/allegro/statuses/sync`
- `GET /settings/integrations/allegro/oauth/callback`
- `GET /health`, `GET /` (redirect)
## Korekta logowania
@@ -45,8 +56,25 @@
- `App\Modules\Orders\OrdersRepository`
- `App\Modules\Settings\SettingsController`
- `App\Modules\Settings\OrderStatusRepository`
- `App\Modules\Settings\AllegroIntegrationController`
- `App\Modules\Settings\AllegroIntegrationRepository`
- `App\Modules\Settings\AllegroOAuthClient`
- `App\Modules\Settings\AllegroApiClient`
- `App\Modules\Settings\AllegroOrderImportService`
- `App\Modules\Settings\AllegroStatusMappingRepository`
- `App\Modules\Settings\AllegroStatusDiscoveryService`
- `App\Modules\Orders\OrderImportRepository`
- `App\Modules\Settings\CronSettingsController`
- `App\Modules\Cron\CronRepository`
- `App\Modules\Cron\CronRunner`
- `App\Modules\Cron\AllegroTokenRefreshHandler`
- `App\Modules\Cron\AllegroOrdersImportHandler`
- `App\Modules\Cron\AllegroStatusSyncHandler`
- `App\Modules\Users\UsersController`
- `App\Modules\Users\UserRepository`
- `App\Modules\Settings\AllegroOrdersSyncService`
- `App\Modules\Settings\AllegroOrderSyncStateRepository`
- `App\Modules\Settings\AllegroStatusSyncService`
## Przeplyw Zamowienia > Lista zamowien
- `GET /orders/list`:
@@ -56,12 +84,14 @@
- buduje panel statusow z grupami i licznikami (`buildStatusPanel(...)`) z linkami filtrujacymi po statusie,
- panel statusow i etykiety statusow sa zgodne z konfiguracja z `Ustawienia > Statusy` (z fallbackiem `Pozostale`),
- renderuje podglad pozycji zamowienia (nazwa, miniatura, ilosc) na bazie `order_items`,
- miniatura pozycji jest rozwiazywana priorytetowo: `order_items.media_url` -> glowny obraz powiazanego produktu orderPRO (`product_channel_map` + `sales_channels[allegro]` + `product_images`),
- obsluguje modal podgladu zdjecia pozycji po kliknieciu miniatury,
- normalizuje status techniczny na etykiete biznesowa (bez kodu statusu),
- renderuje widok `resources/views/orders/list.php` i komponent tabeli `resources/views/components/table-list.php`.
- `GET /orders/{id}`:
- `OrdersController::show(Request): Response`
- pobiera szczegoly przez `OrdersRepository::findDetails(int $orderId)`, statystyke statusow przez `statusCounts()` oraz konfiguracje przez `statusPanelConfig()`,
- pozycje zamowienia maja ten sam mechanizm rozwiazywania miniatur co lista (`media_url` z zamowienia lub obraz z mapowania produktu),
- buduje panel statusow z grupami i licznikami (`buildStatusPanel(...)`),
- renderuje klikalne taby sekcji i przelaczanie paneli po stronie klienta (JS w `orders/show.php`),
- renderuje widok `resources/views/orders/show.php` z sekcjami:
@@ -124,6 +154,97 @@
## Nawigacja ustawien
- Sidebar (`resources/views/layouts/app.php`) ma nowy podlink:
- `Statusy` (`/settings/statuses`).
- `Cron` (`/settings/cron`).
- `Integracje Allegro` (`/settings/integrations/allegro`).
## Przeplyw Ustawienia > Cron
- `GET /settings/cron`:
- `CronSettingsController::index(Request): Response`
- pobiera ustawienia `cron_run_on_web`, `cron_web_limit`,
- renderuje harmonogramy (`cron_schedules`) oraz kolejke/historie (`cron_jobs`).
- `POST /settings/cron`:
- `CronSettingsController::save(Request): Response`
- waliduje CSRF,
- zapisuje `cron_run_on_web` i `cron_web_limit` do `app_settings`.
## Przeplyw wykonania crona
- `bin/cron.php`:
- laduje aplikacje i uruchamia `CronRunner::run($limit)`.
- `App\Core\Application::maybeRunCronOnWeb(Request): void`:
- przy wlaczonej opcji `cron_run_on_web=1` uruchamia `CronRunner` podczas requestu HTTP,
- stosuje throttling sesyjny i lock DB (`GET_LOCK`) zeby uniknac wielu rownoleglych workerow.
- `CronRunner`:
- dispatchuje due schedule z `cron_schedules` do `cron_jobs`,
- pobiera pending joby wg priorytetu i czasu,
- wykonuje handler po `job_type`.
- Pierwszy aktywny handler:
- `allegro_token_refresh` -> `AllegroTokenRefreshHandler::handle(...)` (odswiezenie tokenu OAuth Allegro).
- Dodatkowy handler:
- `allegro_orders_import` -> `AllegroOrdersImportHandler::handle(...)` (automatyczny import zamowien Allegro).
- `allegro_status_sync` -> `AllegroStatusSyncHandler::handle(...)` (synchronizacja statusow wg kierunku z ustawien integracji Allegro).
## Przeplyw Ustawienia > Integracje > Allegro
- `GET /settings/integrations/allegro`:
- `AllegroIntegrationController::index(Request): Response`
- odczytuje konfiguracje przez `AllegroIntegrationRepository::getSettings()`,
- renderuje `resources/views/settings/allegro.php` z domyslnym callback URL.
- `POST /settings/integrations/allegro/save`:
- `AllegroIntegrationController::save(Request): Response`
- waliduje CSRF, srodowisko, `redirect_uri` i date startu,
- zapisuje ustawienia przez `AllegroIntegrationRepository::saveSettings(...)`.
- `POST /settings/integrations/allegro/settings/save`:
- `AllegroIntegrationController::saveImportSettings(Request): Response`
- zapisuje interwal harmonogramu `allegro_orders_import` (w minutach) do `cron_schedules.interval_seconds`,
- zapisuje kierunek synchronizacji statusow i interwal synchronizacji statusow do `app_settings`,
- zapisuje interwal joba `allegro_status_sync` do `cron_schedules.interval_seconds`.
- `POST /settings/integrations/allegro/oauth/start`:
- `AllegroIntegrationController::startOAuth(Request): Response`
- waliduje CSRF i komplet danych OAuth (`client_id`, `client_secret`, `redirect_uri`),
- buduje URL autoryzacji przez `AllegroOAuthClient::buildAuthorizeUrl(...)` (scope: `orders:read` + `sale:offers:read`),
- zapisuje `state` w sesji i przekierowuje do Allegro.
- `POST /settings/integrations/allegro/import-single`:
- `AllegroIntegrationController::importSingleOrder(Request): Response`
- waliduje CSRF i `checkout_form_id`,
- uruchamia `AllegroOrderImportService::importSingleOrder(...)`,
- po imporcie pokazuje diagnostyke miniatur pozycji (ile pozycji ma obrazek i przyczyny brakow).
- `POST /settings/integrations/allegro/statuses/save`:
- `AllegroIntegrationController::saveStatusMapping(Request): Response`
- zapisuje mapowanie `allegro_status_code -> orderpro_status_code`.
- `POST /settings/integrations/allegro/statuses/save-bulk`:
- `AllegroIntegrationController::saveStatusMappingsBulk(Request): Response`
- zapisuje mapowania zbiorczo dla wszystkich wierszy tabeli mapowan.
- `POST /settings/integrations/allegro/statuses/delete`:
- `AllegroIntegrationController::deleteStatusMapping(Request): Response`
- usuwa mapowanie po `mapping_id`.
- `POST /settings/integrations/allegro/statuses/sync`:
- `AllegroIntegrationController::syncStatusesFromAllegro(Request): Response`
- pobiera statusy z API Allegro (`checkout-forms`) i dopisuje je do tabeli mapowan.
- `GET /settings/integrations/allegro/oauth/callback`:
- `AllegroIntegrationController::oauthCallback(Request): Response`
- waliduje `state` i `code`,
- wymienia `code` na tokeny przez `AllegroOAuthClient::exchangeAuthorizationCode(...)`,
- zapisuje tokeny przez `AllegroIntegrationRepository::saveTokens(...)`.
- `AllegroOrderImportService`:
- pilnuje waznosci tokenu (refresh przed requestem lub retry po `401`),
- pobiera zamowienie `GET /order/checkout-forms/{id}` przez `AllegroApiClient`,
- dla pozycji bez obrazka w checkout-form pobiera szczegoly oferty `GET /sale/product-offers/{offerId}` i uzupelnia `order_items.media_url`,
- mapuje forme wysylki Allegro (`delivery.method.name`/`delivery.method.id`) do pol zamowienia (`external_carrier_id`, `external_carrier_account_id`),
- dla dostawy do punktu odbioru mapuje adres `delivery.pickupPoint.address` i nazwe punktu do adresu typu `delivery`,
- mapuje terminy wysylki z `delivery.time.dispatch` do `send_date_min` / `send_date_max`,
- buduje diagnostyke importu miniatur (statystyki + przyczyny brakow),
- mapuje status Allegro na status orderPRO na podstawie `allegro_order_status_mappings`,
- mapuje payload Allegro na neutralny model tabel zamowien,
- zapisuje aggregate przez `OrderImportRepository::upsertOrderAggregate(...)`.
- `AllegroOrdersSyncService`:
- uruchamiany z crona (`allegro_orders_import`),
- respektuje ustawienia integracji (`orders_fetch_enabled`, `orders_fetch_start_date`),
- pobiera listy checkout forms (`GET /order/checkout-forms?sort=-updatedAt`) i importuje nowe/zmienione zamowienia,
- utrzymuje kursor sync i status ostatniego wykonania w `integration_order_sync_state`.
- `AllegroStatusSyncService`:
- uruchamiany z crona (`allegro_status_sync`),
- respektuje ustawienie kierunku `allegro_status_sync_direction`,
- dla kierunku `allegro_to_orderpro` wykorzystuje mechanizm importu zamowien do aktualizacji statusow,
- dla kierunku `orderpro_to_allegro` zwraca wynik informacyjny (tryb przygotowany pod kolejny etap).
## Przeplyw Ustawienia > Baza danych
- `GET /settings/database`:

View File

@@ -9,6 +9,18 @@
- 2026-03-03: Wdrozono generyczne tabele zamowien na bazie docelowej skryptem `bin/deploy_and_seed_orders.php` (bez migratora SQL).
- 2026-03-03: Dodano UI `Zamowienia > Lista zamowien` - bez zmian schematu (wykorzystuje istniejace tabele domeny zamowien).
- 2026-03-03: Dodano UI `Zamowienia > Szczegoly zamowienia` (`GET /orders/{id}`) - bez zmian schematu.
- 2026-03-04: Dodano tabele `allegro_integration_settings` pod konfiguracje OAuth2 Allegro i tokeny dostepowe.
- 2026-03-04: Dodano harmonogram crona `allegro_token_refresh` (co 3600s, priorytet 10, max 3 proby).
- 2026-03-04: Dodano reczny import pojedynczego zamowienia Allegro - bez zmian schematu (wykorzystuje istniejace tabele domeny zamowien).
- 2026-03-04: Dodano tabele `allegro_order_status_mappings` do mapowania statusow Allegro na statusy orderPRO.
- 2026-03-04: Zmieniono `allegro_order_status_mappings.orderpro_status_code` na nullable, aby zapisac statusy pobrane z Allegro przed przypisaniem mapowania.
- 2026-03-04: Dodano rozwiazywanie miniatur pozycji zamowien z mapowania produktu orderPRO (`product_channel_map` + `product_images`) - bez zmian schematu.
- 2026-03-04: Dodano diagnostyke importu miniatur Allegro (alerty przyczyn brakow) - bez zmian schematu.
- 2026-03-04: Dodano harmonogram `allegro_orders_import` (auto-import zamowien Allegro) oraz rozszerzono `integration_order_sync_state` o kolumny kursora sync (`last_synced_order_updated_at`, `last_synced_source_order_id`, `last_success_at`) - migracja `20260304_000027_add_allegro_orders_import_schedule.sql`.
- 2026-03-04: Dodano zakladke `Ustawienia` w integracji Allegro z konfiguracja interwalu importu zamowien; zapis aktualizuje istniejacy rekord `cron_schedules` (`job_type=allegro_orders_import`) - bez zmian schematu.
- 2026-03-04: Rozszerzono zakladke `Ustawienia` integracji Allegro o kierunek synchronizacji statusow i interwal synchronizacji statusow; zapis do `app_settings` (`allegro_status_sync_direction`, `allegro_status_sync_interval_minutes`) - bez zmian schematu.
- 2026-03-04: Dodano harmonogram `allegro_status_sync` (cron synchronizacji statusow) + defaulty `app_settings` dla kierunku i interwalu status sync - migracja `20260304_000028_add_allegro_status_sync_schedule.sql`.
- 2026-03-04: Import Allegro mapuje forme wysylki do `orders.external_carrier_id` i `orders.external_carrier_account_id` - bez zmian schematu.
## Tabele
@@ -65,6 +77,50 @@
- `payload_json` dostepne dla diagnostyki/replay,
- historia zmian statusow utrzymywana w `order_status_history`.
### `integration_order_sync_state`
- Kursor synchronizacji importu zamowien dla integracji (uzywany przez cron auto-importu Allegro).
- 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_error` (varchar 500),
- `created_at`, `updated_at`.
### `allegro_integration_settings`
- Konfiguracja pojedynczej integracji Allegro (`id = 1`) zarzadzanej z `Ustawienia > Integracje > Allegro`.
- Kolumny:
- `id` (PK, tinyint unsigned),
- `environment` (varchar 16, `sandbox|production`),
- `client_id` (varchar 128),
- `client_secret_encrypted` (text),
- `redirect_uri` (varchar 255),
- `orders_fetch_enabled` (tinyint(1), domyslnie `0`),
- `orders_fetch_start_date` (date),
- `access_token_encrypted` (mediumtext),
- `refresh_token_encrypted` (mediumtext),
- `token_type` (varchar 32),
- `token_scope` (varchar 255),
- `token_expires_at` (datetime),
- `connected_at` (datetime),
- `created_at`, `updated_at`.
- Indeksy:
- `allegro_integration_settings_environment_idx` (`environment`),
- `allegro_integration_settings_token_expires_at_idx` (`token_expires_at`).
### `allegro_order_status_mappings`
- Mapowanie kodow statusow Allegro na kody statusow orderPRO.
- Kolumny:
- `id` (PK, int unsigned, AI),
- `allegro_status_code` (varchar 64, UNIQUE),
- `allegro_status_name` (varchar 120),
- `orderpro_status_code` (varchar 64),
- `created_at`, `updated_at`.
- Indeksy:
- `allegro_order_status_mappings_code_unique` (UNIQUE: `allegro_status_code`),
- `allegro_order_status_mappings_orderpro_code_idx` (`orderpro_status_code`).
## Zasady aktualizacji
- Po kazdej migracji dopisz:
- nowe/zmienione tabele i kolumny,

View File

@@ -1,5 +1,118 @@
# Tech Changelog
## 2026-03-04
- Import zamowienia Allegro zapisuje teraz forme wysylki:
- `delivery.method.name` (fallback `delivery.method.id`) trafia do `orders.external_carrier_id`,
- techniczne `delivery.method.id` trafia do `orders.external_carrier_account_id`.
- Dopracowano mapowanie dostawy dla zamowien Allegro:
- dla dostawy do punktu odbioru adres `Dane wysylki` jest budowany z `delivery.pickupPoint` (nazwa punktu + adres punktu),
- terminy z `delivery.time.dispatch` sa zapisywane do `orders.send_date_min` / `orders.send_date_max`.
- Rozszerzono UI `Ustawienia > Integracje > Allegro` o zakladke `Ustawienia`:
- dodano opcje konfiguracji interwalu pobierania zamowien (minuty),
- nowy endpoint zapisu `POST /settings/integrations/allegro/settings/save`,
- zapis aktualizuje harmonogram joba `allegro_orders_import` w `cron_schedules`.
- Rozszerzono zakladke `Ustawienia` integracji Allegro:
- dodano `kierunek synchronizacji statusow` (`Allegro -> orderPRO`, `orderPRO -> Allegro`),
- dodano `interwal synchronizacji statusow` (minuty),
- zapis do `app_settings` (`allegro_status_sync_direction`, `allegro_status_sync_interval_minutes`) pod przyszly cron synchronizacji statusow.
- Dodano cron synchronizacji statusow Allegro:
- nowy job type `allegro_status_sync` z harmonogramem domyslnym co 900s,
- nowy handler `App\Modules\Cron\AllegroStatusSyncHandler`,
- nowy serwis `App\Modules\Settings\AllegroStatusSyncService` (obsluga kierunku sync z ustawien integracji).
- Dodano migracje `20260304_000028_add_allegro_status_sync_schedule.sql`:
- seed `cron_schedules` dla `allegro_status_sync`,
- seed domyslnych ustawien `app_settings` dla kierunku i interwalu synchronizacji statusow.
- Dodano automatyczny import zamowien Allegro przez cron:
- nowy job type `allegro_orders_import` z harmonogramem co 300s (priorytet 20, max_attempts 3),
- nowy handler `App\Modules\Cron\AllegroOrdersImportHandler`,
- nowy serwis `App\Modules\Settings\AllegroOrdersSyncService` (stronicowanie checkout forms, deduplikacja/idempotentny import, limity batcha),
- nowy repo `App\Modules\Settings\AllegroOrderSyncStateRepository` utrzymujacy kursor i status wykonania w `integration_order_sync_state`.
- Podlaczono handler `allegro_orders_import` do runnera crona:
- `bin/cron.php` (CLI worker),
- `App\Core\Application::maybeRunCronOnWeb` (tryb cron-on-web).
- Dodano migracje `20260304_000027_add_allegro_orders_import_schedule.sql`:
- seed harmonogramu `allegro_orders_import`,
- uzupelnienie kompatybilnosci tabeli `integration_order_sync_state` o kolumny kursora sync.
- Dodano obsluge miniatur produktow dla importu Allegro i widokow zamowien:
- import pojedynczego zamowienia Allegro zapisuje URL obrazka pozycji (`lineItems.offer.image*`) do `order_items.media_url`,
- lista i szczegoly zamowienia rozwiazuja miniature priorytetowo: `order_items.media_url` -> glowny obraz powiazanego produktu orderPRO (`product_channel_map` + `sales_channels=allegro` + `product_images`).
- dodano bezpieczny fallback zgodnosci: jesli wymagane tabele/kolumny mapowania produktu nie istnieja w aktualnym schemacie DB, UI korzysta tylko z `order_items.media_url` (bez bledu 404 na szczegolach zamowienia).
- Rozszerzono import pojedynczego zamowienia Allegro o fallback pobrania obrazka po `offerId`:
- gdy `checkout-form lineItem` nie zawiera obrazka, importer pobiera dane oferty przez `GET /sale/product-offers/{offerId}` i zapisuje URL miniatury do `order_items.media_url`,
- obslugiwane sa rowniez URL w formacie `//...` (normalizacja do `https://...`).
- Dodano diagnostyke importu obrazkow Allegro:
- po imporcie pojedynczego zamowienia UI pokazuje podsumowanie `obrazki: X/Y` i ostrzezenie z przyczynami brakow,
- diagnostyka rozroznia m.in. brak obrazka w checkout-form, brak obrazka w API oferty, brak `offerId` oraz bledy HTTP API ofert (np. `403`).
- Rozszerzono zakres scope zadany w OAuth Allegro:
- autoryzacja prosi teraz o `allegro:api:orders:read` oraz `allegro:api:sale:offers:read`,
- po zmianie scope wymagane jest ponowne polaczenie OAuth (`Polacz ponownie`), aby nowe tokeny mialy dostep do API ofert.
- Poprawiono odczyt statusow zamowien w UI listy/szczegolow:
- status efektywny jest liczony dynamicznie z mapowaniem `allegro_order_status_mappings` (nie tylko przy imporcie),
- panel statusow, filtr statusu i etykieta statusu w tabeli korzystaja z tego samego statusu po mapowaniu.
- UI mapowania statusow Allegro zmieniono na zapis zbiorczy:
- jeden przycisk `Zapisz mapowania` dla wszystkich selectow w tabeli,
- nowy endpoint `POST /settings/integrations/allegro/statuses/save-bulk`.
- Dodano automatyczne pobieranie statusow Allegro do mapowania:
- endpoint `POST /settings/integrations/allegro/statuses/sync`,
- przycisk `Pobierz statusy z Allegro` w zakladce `Ustawienia > Integracje > Allegro > Statusy`,
- nowa klasa `App\Modules\Settings\AllegroStatusDiscoveryService` (statusy z API `checkout-forms`).
- Dodano migracje `20260304_000026_make_allegro_status_mapping_nullable.sql`:
- `allegro_order_status_mappings.orderpro_status_code` jest teraz nullable (statusy moga byc najpierw odkryte, potem mapowane).
- Zmieniono podejscie do statusow Allegro:
- usunieto fallbackowe tlumaczenia statusow z kodu listy zamowien,
- dodano zakladke `Statusy` w `Ustawienia > Integracje > Allegro` z recznym mapowaniem `status Allegro -> status orderPRO`.
- Dodano migracje `20260304_000025_create_allegro_order_status_mappings_table.sql`.
- Dodano `App\Modules\Settings\AllegroStatusMappingRepository`.
- Import pojedynczego zamowienia Allegro mapuje teraz status przez `allegro_order_status_mappings` (jesli istnieje wpis), zamiast fallbackowej translacji.
- Poprawiono prezentacje statusow na liscie zamowien:
- filtr statusu pokazuje etykiety biznesowe zamiast surowych kodow (`external_status_id`),
- kody bez mapowania sa tylko formatowane technicznie do czytelnej postaci (`do_odbioru` -> `Do odbioru`).
- Dodano reczny import pojedynczego zamowienia Allegro z poziomu `Ustawienia > Integracje > Allegro`:
- endpoint `POST /settings/integrations/allegro/import-single`,
- formularz z polem `checkout_form_id` w widoku integracji Allegro.
- Dodano klasy importu Allegro:
- `App\Modules\Settings\AllegroApiClient` (request `GET /order/checkout-forms/{id}`),
- `App\Modules\Settings\AllegroOrderImportService` (refresh tokenu + mapowanie payloadu),
- `App\Modules\Orders\OrderImportRepository` (upsert aggregate do tabel zamowien).
- Import pojedynczego zamowienia dziala idempotentnie po kluczu biznesowym (`source=allegro`, `source_order_id`) i nadpisuje kolekcje 1:N (adresy/pozycje/platnosci/wysylki/notatki/historia) aktualnym snapshotem z API.
- Dodano nowy modul crona oparty o tabele `cron_schedules` + `cron_jobs`:
- `App\Modules\Cron\CronRepository`,
- `App\Modules\Cron\CronRunner`.
- Dodano pierwszy handler crona:
- `App\Modules\Cron\AllegroTokenRefreshHandler` dla joba `allegro_token_refresh`.
- Dodano odswiezanie tokenu OAuth w `App\Modules\Settings\AllegroOAuthClient::refreshAccessToken(...)`.
- Rozszerzono `App\Modules\Settings\AllegroIntegrationRepository` o odczyt danych refresh tokenu.
- Odtworzono CLI worker `bin/cron.php` (uruchomienie jobow wg harmonogramu i priorytetu).
- Dodano migracje `20260304_000024_add_allegro_token_refresh_schedule.sql`:
- harmonogram `allegro_token_refresh` (`interval_seconds=3600`, `priority=10`, `max_attempts=3`, `enabled=1`).
- Dodano zakladke `Ustawienia > Cron`:
- `GET /settings/cron`,
- `POST /settings/cron`,
- kontrola opcji `cron_run_on_web` i `cron_web_limit`,
- podglad harmonogramow i kolejki/historii jobow.
- Podlaczono wykonanie crona podczas requestow HTTP:
- `App\Core\Application::maybeRunCronOnWeb(Request)` jest uruchamiane w `Application::run()`,
- aktywowane ustawieniem `cron_run_on_web` i ograniczane lockiem DB + throttlingiem sesyjnym.
- Dodano nowa zakladke `Ustawienia > Integracje > Allegro`:
- route i widok konfiguracji,
- sekcja z gotowym `redirect_uri` do rejestracji aplikacji Allegro.
- Dodano endpointy Allegro OAuth:
- `GET /settings/integrations/allegro`,
- `POST /settings/integrations/allegro/save`,
- `POST /settings/integrations/allegro/oauth/start`,
- `GET /settings/integrations/allegro/oauth/callback`.
- Dodano klasy:
- `App\Modules\Settings\AllegroIntegrationController`,
- `App\Modules\Settings\AllegroIntegrationRepository`,
- `App\Modules\Settings\AllegroOAuthClient`.
- Dodano migracje `20260304_000023_create_allegro_integration_settings_table.sql`:
- tabela `allegro_integration_settings` na konfiguracje OAuth2 i tokeny (`client_secret`, `access_token`, `refresh_token` trzymane jako zaszyfrowane).
- Dodano walidacje i obsluge flow Authorization Code:
- generowanie `state` i walidacja callbacku,
- wymiana `code` na tokeny przez endpoint tokenowy Allegro (sandbox/production).
- Rozszerzono nawigacje `Ustawienia` o link `Integracje Allegro`.
- Dodano style SCSS dla bloku prezentacji callback URL i przebudowano asset CSS (`public/assets/css/app.css`).
## 2026-03-02
- Dodano zakladke `Ustawienia > Statusy` do zarzadzania:
- grupami statusow (z kolorem na poziomie grupy),

8
DOCS/todo.md Normal file
View File

@@ -0,0 +1,8 @@
1. [] Na liĹcie zamĂłwieĹ„ powiÄ™kszenie zdjÄ™cia produktu na hover nie na onclick, wtedy to nie moĹĽe być modal zamykany X
2. [] Zmiana statusu rejestrowana w Historii zmian zamĂłwienia
3. [] Pobranie zamĂłwienia rejestrowane w histori zmian zamĂłwienia
4. [x] Przy imporcie zamĂłwieĹ„ musi być pobierania forma wysyĹki.
5. [] W szczególach zamówienia dorobić opcję zmiany statusu.
6. [] W szczególach zamówienia 2 razy wyświetla się ID zamówienai z allegro, np: 008d3d60-1743-11f1-b15c-fdb4f87ccfc6
7. [] Przy imporcie z allegro liczba przesyłek jest 0.
8. [] Kolumna LP w szczególach zamówienia jest zbyt szeroka.

View File

@@ -1,5 +1,74 @@
<?php
declare(strict_types=1);
fwrite(STDERR, "Cron module has been archived in users-only reset.\n");
exit(1);
use App\Core\Application;
use App\Modules\Cron\AllegroOrdersImportHandler;
use App\Modules\Cron\AllegroStatusSyncHandler;
use App\Modules\Cron\AllegroTokenRefreshHandler;
use App\Modules\Cron\CronRepository;
use App\Modules\Cron\CronRunner;
use App\Modules\Orders\OrderImportRepository;
use App\Modules\Settings\AllegroApiClient;
use App\Modules\Settings\AllegroIntegrationRepository;
use App\Modules\Settings\AllegroOrderImportService;
use App\Modules\Settings\AllegroOrdersSyncService;
use App\Modules\Settings\AllegroOrderSyncStateRepository;
use App\Modules\Settings\AllegroOAuthClient;
use App\Modules\Settings\AllegroStatusSyncService;
use App\Modules\Settings\AllegroStatusMappingRepository;
/** @var Application $app */
$app = require dirname(__DIR__) . '/bootstrap/app.php';
$limit = 20;
foreach (array_slice($argv, 1) as $arg) {
if (preg_match('/^--limit=(\d+)$/', (string) $arg, $matches) === 1) {
$limit = max(1, min(100, (int) ($matches[1] ?? 20)));
}
}
$cronRepository = new CronRepository($app->db());
$integrationRepository = new AllegroIntegrationRepository(
$app->db(),
(string) $app->config('app.integrations.secret', '')
);
$oauthClient = new AllegroOAuthClient();
$apiClient = new AllegroApiClient();
$statusMappingRepository = new AllegroStatusMappingRepository($app->db());
$orderImportService = new AllegroOrderImportService(
$integrationRepository,
$oauthClient,
$apiClient,
new OrderImportRepository($app->db()),
$statusMappingRepository
);
$ordersSyncService = new AllegroOrdersSyncService(
$integrationRepository,
new AllegroOrderSyncStateRepository($app->db()),
$oauthClient,
$apiClient,
$orderImportService
);
$runner = new CronRunner(
$cronRepository,
$app->logger(),
[
'allegro_token_refresh' => new AllegroTokenRefreshHandler(
$integrationRepository,
$oauthClient
),
'allegro_orders_import' => new AllegroOrdersImportHandler(
$ordersSyncService
),
'allegro_status_sync' => new AllegroStatusSyncHandler(
new AllegroStatusSyncService(
$cronRepository,
$ordersSyncService
)
),
]
);
$result = $runner->run($limit);
fwrite(STDOUT, json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);

View File

@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS allegro_integration_settings (
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
environment VARCHAR(16) NOT NULL DEFAULT 'sandbox',
client_id VARCHAR(128) NULL,
client_secret_encrypted TEXT NULL,
redirect_uri VARCHAR(255) NULL,
orders_fetch_enabled TINYINT(1) NOT NULL DEFAULT 0,
orders_fetch_start_date DATE NULL,
access_token_encrypted MEDIUMTEXT NULL,
refresh_token_encrypted MEDIUMTEXT NULL,
token_type VARCHAR(32) NULL,
token_scope VARCHAR(255) NULL,
token_expires_at DATETIME NULL,
connected_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY allegro_integration_settings_environment_idx (environment),
KEY allegro_integration_settings_token_expires_at_idx (token_expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO allegro_integration_settings (
id, environment, client_id, client_secret_encrypted, redirect_uri, orders_fetch_enabled,
orders_fetch_start_date, access_token_encrypted, refresh_token_encrypted, token_type, token_scope,
token_expires_at, connected_at, created_at, updated_at
)
VALUES (
1, 'sandbox', NULL, NULL, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NOW(), NOW()
)
ON DUPLICATE KEY UPDATE
updated_at = VALUES(updated_at);

View File

@@ -0,0 +1,74 @@
CREATE TABLE IF NOT EXISTS cron_jobs (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
job_type VARCHAR(80) NOT NULL,
status ENUM('pending', 'processing', 'completed', 'failed', 'cancelled') NOT NULL DEFAULT 'pending',
priority TINYINT UNSIGNED NOT NULL DEFAULT 100,
payload JSON NULL,
result JSON NULL,
attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,
max_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 3,
last_error VARCHAR(500) NULL,
scheduled_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME NULL,
completed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY cron_jobs_status_priority_scheduled_idx (status, priority, scheduled_at),
KEY cron_jobs_job_type_idx (job_type),
KEY cron_jobs_scheduled_at_idx (scheduled_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS cron_schedules (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
job_type VARCHAR(80) NOT NULL,
interval_seconds INT UNSIGNED NOT NULL,
priority TINYINT UNSIGNED NOT NULL DEFAULT 100,
max_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 3,
payload JSON NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
last_run_at DATETIME NULL,
next_run_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY cron_schedules_job_type_unique (job_type),
KEY cron_schedules_enabled_next_run_idx (enabled, next_run_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS app_settings (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(120) NOT NULL,
setting_value TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY app_settings_setting_key_unique (setting_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO app_settings (setting_key, setting_value, created_at, updated_at)
VALUES
('cron_run_on_web', '0', NOW(), NOW()),
('cron_web_limit', '5', NOW(), NOW())
ON DUPLICATE KEY UPDATE
setting_value = VALUES(setting_value),
updated_at = VALUES(updated_at);
INSERT INTO cron_schedules (
job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at, created_at, updated_at
) VALUES (
'allegro_token_refresh',
3600,
10,
3,
NULL,
1,
NULL,
NOW(),
NOW(),
NOW()
)
ON DUPLICATE KEY UPDATE
interval_seconds = VALUES(interval_seconds),
priority = VALUES(priority),
max_attempts = VALUES(max_attempts),
payload = VALUES(payload),
enabled = VALUES(enabled),
updated_at = VALUES(updated_at);

View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS allegro_order_status_mappings (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
allegro_status_code VARCHAR(64) NOT NULL,
allegro_status_name VARCHAR(120) NULL,
orderpro_status_code VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY allegro_order_status_mappings_code_unique (allegro_status_code),
KEY allegro_order_status_mappings_orderpro_code_idx (orderpro_status_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,2 @@
ALTER TABLE allegro_order_status_mappings
MODIFY COLUMN orderpro_status_code VARCHAR(64) NULL;

View File

@@ -0,0 +1,39 @@
CREATE TABLE IF NOT EXISTS integration_order_sync_state (
integration_id INT UNSIGNED NOT NULL PRIMARY KEY,
last_synced_order_updated_at DATETIME NULL,
last_synced_source_order_id VARCHAR(64) NULL,
last_synced_external_order_id VARCHAR(128) NULL,
last_run_at DATETIME NULL,
last_success_at DATETIME NULL,
last_error VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE integration_order_sync_state
ADD COLUMN IF NOT EXISTS last_synced_order_updated_at DATETIME NULL AFTER integration_id,
ADD COLUMN IF NOT EXISTS last_synced_source_order_id VARCHAR(64) NULL AFTER last_synced_order_updated_at,
ADD COLUMN IF NOT EXISTS last_success_at DATETIME NULL AFTER last_run_at,
ADD COLUMN IF NOT EXISTS created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER last_error;
INSERT INTO cron_schedules (
job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at, created_at, updated_at
) VALUES (
'allegro_orders_import',
300,
20,
3,
JSON_OBJECT('max_pages', 5, 'page_limit', 50, 'max_orders', 200),
1,
NULL,
NOW(),
NOW(),
NOW()
)
ON DUPLICATE KEY UPDATE
interval_seconds = VALUES(interval_seconds),
priority = VALUES(priority),
max_attempts = VALUES(max_attempts),
payload = VALUES(payload),
enabled = VALUES(enabled),
updated_at = VALUES(updated_at);

View File

@@ -0,0 +1,29 @@
INSERT INTO app_settings (setting_key, setting_value, created_at, updated_at)
VALUES
('allegro_status_sync_direction', 'allegro_to_orderpro', NOW(), NOW()),
('allegro_status_sync_interval_minutes', '15', NOW(), NOW())
ON DUPLICATE KEY UPDATE
setting_value = VALUES(setting_value),
updated_at = VALUES(updated_at);
INSERT INTO cron_schedules (
job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at, created_at, updated_at
) VALUES (
'allegro_status_sync',
900,
25,
3,
NULL,
1,
NULL,
NOW(),
NOW(),
NOW()
)
ON DUPLICATE KEY UPDATE
interval_seconds = VALUES(interval_seconds),
priority = VALUES(priority),
max_attempts = VALUES(max_attempts),
payload = VALUES(payload),
enabled = VALUES(enabled),
updated_at = VALUES(updated_at);

File diff suppressed because one or more lines are too long

View File

@@ -28,6 +28,7 @@ return [
'dashboard' => 'Dashboard',
'settings' => 'Ustawienia',
'statuses' => 'Statusy',
'allegro' => 'Integracje Allegro',
],
'marketplace' => [
'title' => 'Marketplace',
@@ -532,6 +533,141 @@ return [
'status_reorder_failed' => 'Nie udalo sie zapisac kolejnosci statusow.',
],
],
'allegro' => [
'title' => 'Integracja Allegro',
'description' => 'Konfiguracja OAuth2 i pobierania zamowien z Allegro.',
'tabs' => [
'label' => 'Zakladki integracji Allegro',
'integration' => 'Integracja',
'statuses' => 'Statusy',
'settings' => 'Ustawienia',
],
'callback' => [
'title' => 'Redirect URI do Allegro',
'hint' => 'Ten adres wpisz w aplikacji Allegro Developer jako redirect URI.',
],
'config' => [
'title' => 'Konfiguracja API',
],
'fields' => [
'environment' => 'Srodowisko',
'client_id' => 'Client ID',
'client_secret' => 'Client Secret',
'redirect_uri' => 'Redirect URI',
'redirect_uri_hint' => 'Musi byc identyczne jak w panelu aplikacji Allegro.',
'orders_fetch_enabled' => 'Wlacz pobieranie zamowien',
'orders_fetch_start_date' => 'Data startu pobierania',
],
'environment' => [
'sandbox' => 'Sandbox',
'production' => 'Produkcja',
],
'client_secret' => [
'saved' => 'Client Secret jest zapisany. Pozostaw pole puste, aby nie zmieniac.',
'missing' => 'Brak zapisanego Client Secret.',
],
'oauth' => [
'title' => 'Polaczenie OAuth',
'connected' => 'Konto Allegro jest polaczone.',
'not_connected' => 'Brak aktywnego polaczenia OAuth z Allegro.',
'connected_at' => 'Data polaczenia: :date',
'token_expires_at' => 'Waznosc access tokenu do: :date',
],
'import_single' => [
'title' => 'Import pojedynczego zamowienia',
'description' => 'Podaj ID checkout form Allegro, aby recznie pobrac i zapisac jedno zamowienie do orderPRO.',
'checkout_form_id' => 'ID zamowienia Allegro (checkout form)',
'checkout_form_id_placeholder' => 'np. 87ca8f8e-1b4f-11ef-b0f9-0242ac120002',
],
'import_action' => [
'created' => 'utworzono',
'updated' => 'zaktualizowano',
],
'settings' => [
'title' => 'Ustawienia synchronizacji',
'description' => 'Parametry automatycznego pobierania zamowien Allegro przez cron.',
'orders_import_interval_minutes' => 'Interwal pobierania zamowien (minuty)',
'orders_import_interval_hint' => 'Zakres: 1-1440 minut. Dotyczy harmonogramu joba allegro_orders_import.',
'status_sync_direction' => 'Kierunek synchronizacji statusow',
'status_sync_direction_allegro_to_orderpro' => 'Allegro -> orderPRO',
'status_sync_direction_orderpro_to_allegro' => 'orderPRO -> Allegro',
'status_sync_direction_hint' => 'Aktualnie aktywny jest kierunek Allegro -> orderPRO. Ustawienie orderPRO -> Allegro jest przygotowane pod kolejny etap.',
'status_sync_interval_minutes' => 'Interwal synchronizacji statusow (minuty)',
'status_sync_interval_hint' => 'Zakres: 1-1440 minut. Ustawienie zostanie uzyte przez zadanie synchronizacji statusow.',
'save' => 'Zapisz ustawienia',
],
'statuses' => [
'title' => 'Mapowanie statusow Allegro',
'description' => 'Mapowanie kodow statusow Allegro na statusy orderPRO. Import zamowien zapisuje status orderPRO na podstawie tego mapowania.',
'list_title' => 'Aktualne mapowania',
'empty' => 'Brak zapisanych mapowan statusow Allegro.',
'fields' => [
'allegro_status_code' => 'Kod statusu Allegro',
'allegro_status_code_placeholder' => 'np. sent',
'allegro_status_name' => 'Nazwa statusu Allegro',
'allegro_status_name_placeholder' => 'np. Wyslane',
'orderpro_status_code' => 'Status orderPRO',
'orderpro_status_placeholder' => '-- wybierz status orderPRO --',
'actions' => 'Akcje',
],
'actions' => [
'save' => 'Zapisz mapowanie',
'sync' => 'Pobierz statusy z Allegro',
'save_bulk' => 'Zapisz mapowania',
'delete' => 'Usun',
],
'confirm' => [
'title' => 'Potwierdzenie',
'confirm' => 'Usun',
'cancel' => 'Anuluj',
'delete' => 'Czy na pewno usunac mapowanie statusu Allegro?',
],
'flash' => [
'allegro_status_required' => 'Podaj kod statusu Allegro.',
'orderpro_status_required' => 'Wybierz status orderPRO.',
'orderpro_status_not_found' => 'Wybrany status orderPRO nie istnieje.',
'mapping_not_found' => 'Nie znaleziono wskazanego mapowania statusu.',
'saved' => 'Mapowanie statusu Allegro zostalo zapisane.',
'saved_bulk' => 'Mapowania statusow Allegro zostaly zapisane.',
'save_failed' => 'Nie udalo sie zapisac mapowania statusu Allegro.',
'deleted' => 'Mapowanie statusu Allegro zostalo usuniete.',
'delete_failed' => 'Nie udalo sie usunac mapowania statusu Allegro.',
'sync_ok' => 'Pobrano statusy z Allegro. Rozpoznane statusy: :discovered, sprawdzone zamowienia: :samples.',
'sync_failed' => 'Nie udalo sie pobrac statusow z Allegro.',
],
],
'actions' => [
'save' => 'Zapisz ustawienia Allegro',
'connect' => 'Polacz konto Allegro',
'import_single' => 'Importuj zamowienie',
],
'validation' => [
'environment_invalid' => 'Wybierz poprawne srodowisko Allegro.',
'client_id_too_long' => 'Client ID jest za dlugie (max 128 znakow).',
'redirect_uri_invalid' => 'Podaj poprawny redirect URI (http lub https).',
'orders_fetch_start_date_invalid' => 'Podaj poprawna date startu pobierania (RRRR-MM-DD).',
'orders_import_interval_invalid' => 'Podaj poprawny interwal pobierania zamowien (1-1440 minut).',
'status_sync_direction_invalid' => 'Wybierz poprawny kierunek synchronizacji statusow.',
'status_sync_interval_invalid' => 'Podaj poprawny interwal synchronizacji statusow (1-1440 minut).',
],
'flash' => [
'saved' => 'Ustawienia Allegro zostaly zapisane.',
'save_failed' => 'Nie udalo sie zapisac ustawien Allegro.',
'import_settings_saved' => 'Ustawienia harmonogramu importu Allegro zostaly zapisane.',
'import_settings_save_failed' => 'Nie udalo sie zapisac ustawien harmonogramu importu Allegro.',
'credentials_missing' => 'Uzupelnij Client ID, Client Secret i Redirect URI, a potem zapisz ustawienia.',
'oauth_connected' => 'Autoryzacja Allegro zakonczona powodzeniem.',
'oauth_failed' => 'Nie udalo sie zakonczyc autoryzacji Allegro.',
'oauth_state_invalid' => 'Nieprawidlowy stan autoryzacji (state). Sprobuj polaczyc konto ponownie.',
'oauth_code_missing' => 'Brak kodu autoryzacyjnego z Allegro.',
'checkout_form_id_required' => 'Podaj ID zamowienia Allegro do importu.',
'import_single_ok' => 'Import zamowienia zakonczony. Allegro #:source_order_id -> lokalne #:local_id (:action).',
'import_single_media_summary' => 'Obrazki pozycji: :with_image/:total_items, bez obrazka: :without_image.',
'import_single_media_warning' => 'Nie udalo sie pobrac obrazka dla :without_image pozycji. Przyczyny: :reasons.',
'import_single_media_warning_generic' => 'Nie udalo sie pobrac obrazka dla :without_image pozycji.',
'import_single_failed' => 'Import zamowienia zakonczyl sie bledem.',
],
],
'integrations' => [
'title' => 'Integracje shopPRO',
'list_title' => 'Integracje shopPRO',

View File

@@ -263,6 +263,19 @@ a {
overflow: auto;
}
.settings-allegro-callback {
display: block;
width: 100%;
padding: 8px 10px;
border: 1px solid var(--c-border);
border-radius: 8px;
background: #f8fafc;
color: var(--c-text-strong);
font-size: 12px;
line-height: 1.45;
word-break: break-all;
}
.page-head {
display: flex;
align-items: center;

View File

@@ -40,6 +40,12 @@
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'statuses' ? ' is-active' : '' ?>" href="/settings/statuses">
<?= $e($t('navigation.statuses')) ?>
</a>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'cron' ? ' is-active' : '' ?>" href="/settings/cron">
<?= $e($t('navigation.cron')) ?>
</a>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'allegro' ? ' is-active' : '' ?>" href="/settings/integrations/allegro">
<?= $e($t('navigation.allegro')) ?>
</a>
</div>
</details>
</nav>

View File

@@ -0,0 +1,309 @@
<?php
$integration = is_array($settings ?? null) ? $settings : [];
$activeTab = (string) ($activeTab ?? 'integration');
$environment = (string) ($integration['environment'] ?? 'sandbox');
$clientId = (string) ($integration['client_id'] ?? '');
$hasClientSecret = (bool) ($integration['has_client_secret'] ?? false);
$redirectUri = (string) ($integration['redirect_uri'] ?? '');
$ordersFetchEnabled = (bool) ($integration['orders_fetch_enabled'] ?? false);
$ordersFetchStartDate = (string) ($integration['orders_fetch_start_date'] ?? '');
$isConnected = (bool) ($integration['is_connected'] ?? false);
$tokenExpiresAt = (string) ($integration['token_expires_at'] ?? '');
$connectedAt = (string) ($integration['connected_at'] ?? '');
$defaultCallback = (string) ($defaultRedirectUri ?? '');
$importIntervalSeconds = max(60, (int) ($importIntervalSeconds ?? 300));
$importIntervalMinutes = max(1, (int) floor($importIntervalSeconds / 60));
$statusSyncDirection = (string) ($statusSyncDirection ?? 'allegro_to_orderpro');
$statusSyncIntervalMinutes = max(1, (int) ($statusSyncIntervalMinutes ?? 15));
$statusMappings = is_array($statusMappings ?? null) ? $statusMappings : [];
$orderproStatuses = is_array($orderproStatuses ?? null) ? $orderproStatuses : [];
?>
<section class="card">
<h2 class="section-title"><?= $e($t('settings.allegro.title')) ?></h2>
<p class="muted mt-12"><?= $e($t('settings.allegro.description')) ?></p>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
<?php endif; ?>
<?php if (!empty($warningMessage)): ?>
<div class="alert alert--warning mt-12" role="alert"><?= $e((string) $warningMessage) ?></div>
<?php endif; ?>
</section>
<section class="card mt-16">
<nav class="content-tabs-nav" aria-label="<?= $e($t('settings.allegro.tabs.label')) ?>">
<button type="button" class="content-tab-btn<?= $activeTab === 'integration' ? ' is-active' : '' ?>" data-tab-target="allegro-tab-integration">
<?= $e($t('settings.allegro.tabs.integration')) ?>
</button>
<button type="button" class="content-tab-btn<?= $activeTab === 'statuses' ? ' is-active' : '' ?>" data-tab-target="allegro-tab-statuses">
<?= $e($t('settings.allegro.tabs.statuses')) ?>
</button>
<button type="button" class="content-tab-btn<?= $activeTab === 'settings' ? ' is-active' : '' ?>" data-tab-target="allegro-tab-settings">
<?= $e($t('settings.allegro.tabs.settings')) ?>
</button>
</nav>
<div class="content-tab-panel<?= $activeTab === 'integration' ? ' is-active' : '' ?>" data-tab-panel="allegro-tab-integration">
<section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.allegro.callback.title')) ?></h3>
<p class="muted mt-12"><?= $e($t('settings.allegro.callback.hint')) ?></p>
<code class="settings-allegro-callback mt-12"><?= $e($defaultCallback) ?></code>
</section>
<section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.allegro.config.title')) ?></h3>
<form class="statuses-form mt-16" action="/settings/integrations/allegro/save" method="post" novalidate>
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.allegro.fields.environment')) ?></span>
<select class="form-control" name="environment">
<option value="sandbox"<?= $environment === 'sandbox' ? ' selected' : '' ?>><?= $e($t('settings.allegro.environment.sandbox')) ?></option>
<option value="production"<?= $environment === 'production' ? ' selected' : '' ?>><?= $e($t('settings.allegro.environment.production')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.allegro.fields.client_id')) ?></span>
<input class="form-control" type="text" name="client_id" maxlength="128" value="<?= $e($clientId) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.allegro.fields.client_secret')) ?></span>
<input class="form-control" type="password" name="client_secret" autocomplete="new-password">
<span class="muted"><?= $e($hasClientSecret ? $t('settings.allegro.client_secret.saved') : $t('settings.allegro.client_secret.missing')) ?></span>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.allegro.fields.redirect_uri')) ?></span>
<input class="form-control" type="url" name="redirect_uri" maxlength="255" value="<?= $e($redirectUri) ?>">
<span class="muted"><?= $e($t('settings.allegro.fields.redirect_uri_hint')) ?></span>
</label>
<label class="field-inline">
<input type="hidden" name="orders_fetch_enabled" value="0">
<input type="checkbox" name="orders_fetch_enabled" value="1"<?= $ordersFetchEnabled ? ' checked' : '' ?>>
<span><?= $e($t('settings.allegro.fields.orders_fetch_enabled')) ?></span>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.allegro.fields.orders_fetch_start_date')) ?></span>
<input class="form-control" type="date" name="orders_fetch_start_date" value="<?= $e($ordersFetchStartDate) ?>">
</label>
<div class="form-actions">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.allegro.actions.save')) ?></button>
</div>
</form>
</section>
<section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.allegro.oauth.title')) ?></h3>
<p class="muted mt-12">
<?= $e($isConnected ? $t('settings.allegro.oauth.connected') : $t('settings.allegro.oauth.not_connected')) ?>
</p>
<?php if ($connectedAt !== ''): ?>
<p class="muted mt-12"><?= $e($t('settings.allegro.oauth.connected_at', ['date' => $connectedAt])) ?></p>
<?php endif; ?>
<?php if ($tokenExpiresAt !== ''): ?>
<p class="muted mt-12"><?= $e($t('settings.allegro.oauth.token_expires_at', ['date' => $tokenExpiresAt])) ?></p>
<?php endif; ?>
<form class="mt-16" action="/settings/integrations/allegro/oauth/start" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<button type="submit" class="btn btn--secondary"><?= $e($t('settings.allegro.actions.connect')) ?></button>
</form>
</section>
<section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.allegro.import_single.title')) ?></h3>
<p class="muted mt-12"><?= $e($t('settings.allegro.import_single.description')) ?></p>
<form class="statuses-form mt-12" action="/settings/integrations/allegro/import-single" method="post" novalidate>
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.allegro.import_single.checkout_form_id')) ?></span>
<input class="form-control" type="text" name="checkout_form_id" maxlength="128" required placeholder="<?= $e($t('settings.allegro.import_single.checkout_form_id_placeholder')) ?>">
</label>
<div class="form-actions">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.allegro.actions.import_single')) ?></button>
</div>
</form>
</section>
</div>
<div class="content-tab-panel<?= $activeTab === 'statuses' ? ' is-active' : '' ?>" data-tab-panel="allegro-tab-statuses">
<section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.allegro.statuses.title')) ?></h3>
<p class="muted mt-12"><?= $e($t('settings.allegro.statuses.description')) ?></p>
<form class="mt-12" action="/settings/integrations/allegro/statuses/sync" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<button type="submit" class="btn btn--secondary"><?= $e($t('settings.allegro.statuses.actions.sync')) ?></button>
</form>
<form class="statuses-form mt-12" action="/settings/integrations/allegro/statuses/save" method="post" novalidate>
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.allegro.statuses.fields.allegro_status_code')) ?></span>
<input class="form-control" type="text" name="allegro_status_code" maxlength="64" required placeholder="<?= $e($t('settings.allegro.statuses.fields.allegro_status_code_placeholder')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.allegro.statuses.fields.allegro_status_name')) ?></span>
<input class="form-control" type="text" name="allegro_status_name" maxlength="120" placeholder="<?= $e($t('settings.allegro.statuses.fields.allegro_status_name_placeholder')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.allegro.statuses.fields.orderpro_status_code')) ?></span>
<select class="form-control" name="orderpro_status_code" required>
<option value=""><?= $e($t('settings.allegro.statuses.fields.orderpro_status_placeholder')) ?></option>
<?php foreach ($orderproStatuses as $status): ?>
<?php $statusCode = strtolower(trim((string) ($status['code'] ?? ''))); ?>
<?php if ($statusCode === '') continue; ?>
<option value="<?= $e($statusCode) ?>">
<?= $e((string) ($status['name'] ?? $statusCode)) ?> (<?= $e($statusCode) ?>)
</option>
<?php endforeach; ?>
</select>
</label>
<div class="form-actions">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.allegro.statuses.actions.save')) ?></button>
</div>
</form>
</section>
<section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.allegro.statuses.list_title')) ?></h3>
<form action="/settings/integrations/allegro/statuses/save-bulk" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.allegro.statuses.fields.allegro_status_code')) ?></th>
<th><?= $e($t('settings.allegro.statuses.fields.allegro_status_name')) ?></th>
<th><?= $e($t('settings.allegro.statuses.fields.orderpro_status_code')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($statusMappings === []): ?>
<tr>
<td colspan="3" class="muted"><?= $e($t('settings.allegro.statuses.empty')) ?></td>
</tr>
<?php else: ?>
<?php foreach ($statusMappings as $mapping): ?>
<tr>
<td>
<code><?= $e((string) ($mapping['allegro_status_code'] ?? '')) ?></code>
<input type="hidden" name="allegro_status_code[]" value="<?= $e((string) ($mapping['allegro_status_code'] ?? '')) ?>">
</td>
<td>
<?= $e((string) ($mapping['allegro_status_name'] ?? '')) ?>
<input type="hidden" name="allegro_status_name[]" value="<?= $e((string) ($mapping['allegro_status_name'] ?? '')) ?>">
</td>
<td>
<select class="form-control" name="orderpro_status_code[]">
<option value=""><?= $e($t('settings.allegro.statuses.fields.orderpro_status_placeholder')) ?></option>
<?php foreach ($orderproStatuses as $status): ?>
<?php $statusCode = strtolower(trim((string) ($status['code'] ?? ''))); ?>
<?php if ($statusCode === '') continue; ?>
<option value="<?= $e($statusCode) ?>"<?= $statusCode === strtolower(trim((string) ($mapping['orderpro_status_code'] ?? ''))) ? ' selected' : '' ?>>
<?= $e((string) ($status['name'] ?? $statusCode)) ?> (<?= $e($statusCode) ?>)
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($statusMappings !== []): ?>
<div class="form-actions mt-12">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.allegro.statuses.actions.save_bulk')) ?></button>
</div>
<?php endif; ?>
</form>
</section>
</div>
<div class="content-tab-panel<?= $activeTab === 'settings' ? ' is-active' : '' ?>" data-tab-panel="allegro-tab-settings">
<section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.allegro.settings.title')) ?></h3>
<p class="muted mt-12"><?= $e($t('settings.allegro.settings.description')) ?></p>
<form class="statuses-form mt-12" action="/settings/integrations/allegro/settings/save" method="post" novalidate>
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.allegro.settings.orders_import_interval_minutes')) ?></span>
<input
class="form-control"
type="number"
name="orders_import_interval_minutes"
min="1"
max="1440"
step="1"
value="<?= $e((string) $importIntervalMinutes) ?>"
>
<span class="muted"><?= $e($t('settings.allegro.settings.orders_import_interval_hint')) ?></span>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.allegro.settings.status_sync_direction')) ?></span>
<select class="form-control" name="status_sync_direction">
<option value="allegro_to_orderpro"<?= $statusSyncDirection === 'allegro_to_orderpro' ? ' selected' : '' ?>>
<?= $e($t('settings.allegro.settings.status_sync_direction_allegro_to_orderpro')) ?>
</option>
<option value="orderpro_to_allegro"<?= $statusSyncDirection === 'orderpro_to_allegro' ? ' selected' : '' ?>>
<?= $e($t('settings.allegro.settings.status_sync_direction_orderpro_to_allegro')) ?>
</option>
</select>
<span class="muted"><?= $e($t('settings.allegro.settings.status_sync_direction_hint')) ?></span>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.allegro.settings.status_sync_interval_minutes')) ?></span>
<input
class="form-control"
type="number"
name="status_sync_interval_minutes"
min="1"
max="1440"
step="1"
value="<?= $e((string) $statusSyncIntervalMinutes) ?>"
>
<span class="muted"><?= $e($t('settings.allegro.settings.status_sync_interval_hint')) ?></span>
</label>
<div class="form-actions">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.allegro.settings.save')) ?></button>
</div>
</form>
</section>
</div>
</section>
<script>
(function () {
var tabs = document.querySelectorAll('[data-tab-target]');
var panels = document.querySelectorAll('[data-tab-panel]');
if (tabs.length === 0 || panels.length === 0) {
return;
}
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
var target = tab.getAttribute('data-tab-target');
tabs.forEach(function (node) { node.classList.remove('is-active'); });
panels.forEach(function (panel) { panel.classList.remove('is-active'); });
tab.classList.add('is-active');
var panel = document.querySelector('[data-tab-panel=\"' + target + '\"]');
if (panel) {
panel.classList.add('is-active');
}
});
});
})();
</script>

View File

@@ -0,0 +1,147 @@
<?php
$schedulesList = is_array($schedules ?? null) ? $schedules : [];
$futureJobsList = is_array($futureJobs ?? null) ? $futureJobs : [];
$pastJobsList = is_array($pastJobs ?? null) ? $pastJobs : [];
?>
<section class="card">
<h2 class="section-title"><?= $e($t('settings.cron.title')) ?></h2>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
<?php endif; ?>
<h3 class="section-title mt-16"><?= $e($t('settings.cron.run_on_web_title')) ?></h3>
<p class="muted mt-12"><?= $e($t('settings.cron.run_on_web_description')) ?></p>
<form class="statuses-form mt-12" action="/settings/cron" method="post" novalidate>
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="field-inline">
<input type="hidden" name="cron_run_on_web" value="0">
<input type="checkbox" name="cron_run_on_web" value="1"<?= !empty($runOnWeb) ? ' checked' : '' ?>>
<span><?= $e($t('settings.cron.run_on_web_label')) ?></span>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.cron.web_limit')) ?></span>
<input class="form-control" type="number" min="1" max="100" step="1" name="cron_web_limit" value="<?= $e((string) ($webLimit ?? 5)) ?>">
</label>
<div class="form-actions">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.cron.actions.save')) ?></button>
</div>
</form>
</section>
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('settings.cron.schedules_title')) ?></h3>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.cron.fields.job_type')) ?></th>
<th><?= $e($t('settings.cron.fields.enabled')) ?></th>
<th><?= $e($t('settings.cron.fields.interval')) ?></th>
<th><?= $e($t('settings.cron.fields.priority')) ?></th>
<th><?= $e($t('settings.cron.fields.last_run_at')) ?></th>
<th><?= $e($t('settings.cron.fields.next_run_at')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($schedulesList === []): ?>
<tr><td class="muted" colspan="6"><?= $e($t('settings.cron.empty_schedules')) ?></td></tr>
<?php else: ?>
<?php foreach ($schedulesList as $item): ?>
<tr>
<td><?= $e((string) ($item['job_type'] ?? '')) ?></td>
<td><?= $e(!empty($item['enabled']) ? $t('settings.cron.enabled.yes') : $t('settings.cron.enabled.no')) ?></td>
<td><?= $e((string) ($item['interval_seconds'] ?? '')) ?></td>
<td><?= $e((string) ($item['priority'] ?? '')) ?></td>
<td><?= $e((string) ($item['last_run_at'] ?? '')) ?></td>
<td><?= $e((string) ($item['next_run_at'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('settings.cron.future_jobs_title')) ?></h3>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th><?= $e($t('settings.cron.fields.job_type')) ?></th>
<th><?= $e($t('settings.cron.fields.status')) ?></th>
<th><?= $e($t('settings.cron.fields.priority')) ?></th>
<th><?= $e($t('settings.cron.fields.scheduled_at')) ?></th>
<th><?= $e($t('settings.cron.fields.attempts')) ?></th>
<th><?= $e($t('settings.cron.fields.last_error')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($futureJobsList === []): ?>
<tr><td class="muted" colspan="7"><?= $e($t('settings.cron.empty_future_jobs')) ?></td></tr>
<?php else: ?>
<?php foreach ($futureJobsList as $item): ?>
<tr>
<td><?= $e((string) ($item['id'] ?? 0)) ?></td>
<td><?= $e((string) ($item['job_type'] ?? '')) ?></td>
<td><?= $e((string) ($item['status'] ?? '')) ?></td>
<td><?= $e((string) ($item['priority'] ?? '')) ?></td>
<td><?= $e((string) ($item['scheduled_at'] ?? '')) ?></td>
<td><?= $e((string) ($item['attempts'] ?? 0) . '/' . (string) ($item['max_attempts'] ?? 0)) ?></td>
<td><?= $e((string) ($item['last_error'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="card mt-16">
<h3 class="section-title"><?= $e($t('settings.cron.past_jobs_title')) ?></h3>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th><?= $e($t('settings.cron.fields.job_type')) ?></th>
<th><?= $e($t('settings.cron.fields.status')) ?></th>
<th><?= $e($t('settings.cron.fields.priority')) ?></th>
<th><?= $e($t('settings.cron.fields.scheduled_at')) ?></th>
<th><?= $e($t('settings.cron.fields.attempts')) ?></th>
<th><?= $e($t('settings.cron.fields.completed_at')) ?></th>
<th><?= $e($t('settings.cron.fields.last_error')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($pastJobsList === []): ?>
<tr><td class="muted" colspan="8"><?= $e($t('settings.cron.empty_past_jobs')) ?></td></tr>
<?php else: ?>
<?php foreach ($pastJobsList as $item): ?>
<tr>
<td><?= $e((string) ($item['id'] ?? 0)) ?></td>
<td><?= $e((string) ($item['job_type'] ?? '')) ?></td>
<td><?= $e((string) ($item['status'] ?? '')) ?></td>
<td><?= $e((string) ($item['priority'] ?? '')) ?></td>
<td><?= $e((string) ($item['scheduled_at'] ?? '')) ?></td>
<td><?= $e((string) ($item['attempts'] ?? 0) . '/' . (string) ($item['max_attempts'] ?? 0)) ?></td>
<td><?= $e((string) ($item['completed_at'] ?? '')) ?></td>
<td><?= $e((string) ($item['last_error'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>

View File

@@ -6,7 +6,17 @@ use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Modules\Auth\AuthController;
use App\Modules\Auth\AuthMiddleware;
use App\Modules\Cron\CronRepository;
use App\Modules\Orders\OrdersController;
use App\Modules\Orders\OrderImportRepository;
use App\Modules\Settings\AllegroApiClient;
use App\Modules\Settings\AllegroIntegrationController;
use App\Modules\Settings\AllegroIntegrationRepository;
use App\Modules\Settings\AllegroOAuthClient;
use App\Modules\Settings\AllegroOrderImportService;
use App\Modules\Settings\AllegroStatusDiscoveryService;
use App\Modules\Settings\AllegroStatusMappingRepository;
use App\Modules\Settings\CronSettingsController;
use App\Modules\Settings\SettingsController;
use App\Modules\Users\UsersController;
@@ -20,6 +30,45 @@ return static function (Application $app): void {
$usersController = new UsersController($template, $translator, $auth, $app->users());
$ordersController = new OrdersController($template, $translator, $auth, $app->orders());
$settingsController = new SettingsController($template, $translator, $auth, $app->migrator(), $app->orderStatuses());
$allegroIntegrationRepository = new AllegroIntegrationRepository(
$app->db(),
(string) $app->config('app.integrations.secret', '')
);
$allegroStatusMappingRepository = new AllegroStatusMappingRepository($app->db());
$allegroOAuthClient = new AllegroOAuthClient();
$cronRepository = new CronRepository($app->db());
$allegroIntegrationController = new AllegroIntegrationController(
$template,
$translator,
$auth,
$allegroIntegrationRepository,
$allegroStatusMappingRepository,
$app->orderStatuses(),
$cronRepository,
$allegroOAuthClient,
new AllegroOrderImportService(
$allegroIntegrationRepository,
$allegroOAuthClient,
new AllegroApiClient(),
new OrderImportRepository($app->db()),
$allegroStatusMappingRepository
),
new AllegroStatusDiscoveryService(
$allegroIntegrationRepository,
$allegroOAuthClient,
new AllegroApiClient(),
$allegroStatusMappingRepository
),
(string) $app->config('app.url', '')
);
$cronSettingsController = new CronSettingsController(
$template,
$translator,
$auth,
$cronRepository,
(bool) $app->config('app.cron.run_on_web_default', false),
(int) $app->config('app.cron.web_limit_default', 5)
);
$authMiddleware = new AuthMiddleware($auth);
$router->get('/health', static fn (Request $request): Response => Response::json([
@@ -57,4 +106,16 @@ return static function (Application $app): void {
$router->post('/settings/statuses/update', [$settingsController, 'updateStatus'], [$authMiddleware]);
$router->post('/settings/statuses/delete', [$settingsController, 'deleteStatus'], [$authMiddleware]);
$router->post('/settings/statuses/reorder', [$settingsController, 'reorderStatuses'], [$authMiddleware]);
$router->get('/settings/cron', [$cronSettingsController, 'index'], [$authMiddleware]);
$router->post('/settings/cron', [$cronSettingsController, 'save'], [$authMiddleware]);
$router->get('/settings/integrations/allegro', [$allegroIntegrationController, 'index'], [$authMiddleware]);
$router->post('/settings/integrations/allegro/save', [$allegroIntegrationController, 'save'], [$authMiddleware]);
$router->post('/settings/integrations/allegro/settings/save', [$allegroIntegrationController, 'saveImportSettings'], [$authMiddleware]);
$router->post('/settings/integrations/allegro/oauth/start', [$allegroIntegrationController, 'startOAuth'], [$authMiddleware]);
$router->post('/settings/integrations/allegro/import-single', [$allegroIntegrationController, 'importSingleOrder'], [$authMiddleware]);
$router->post('/settings/integrations/allegro/statuses/save', [$allegroIntegrationController, 'saveStatusMapping'], [$authMiddleware]);
$router->post('/settings/integrations/allegro/statuses/save-bulk', [$allegroIntegrationController, 'saveStatusMappingsBulk'], [$authMiddleware]);
$router->post('/settings/integrations/allegro/statuses/delete', [$allegroIntegrationController, 'deleteStatusMapping'], [$authMiddleware]);
$router->post('/settings/integrations/allegro/statuses/sync', [$allegroIntegrationController, 'syncStatusesFromAllegro'], [$authMiddleware]);
$router->get('/settings/integrations/allegro/oauth/callback', [$allegroIntegrationController, 'oauthCallback']);
};

View File

@@ -13,7 +13,21 @@ use App\Core\Support\Logger;
use App\Core\Support\Session;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Cron\AllegroOrdersImportHandler;
use App\Modules\Cron\AllegroStatusSyncHandler;
use App\Modules\Cron\AllegroTokenRefreshHandler;
use App\Modules\Cron\CronRepository;
use App\Modules\Cron\CronRunner;
use App\Modules\Orders\OrderImportRepository;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\AllegroApiClient;
use App\Modules\Settings\AllegroIntegrationRepository;
use App\Modules\Settings\AllegroOrderImportService;
use App\Modules\Settings\AllegroOrdersSyncService;
use App\Modules\Settings\AllegroOrderSyncStateRepository;
use App\Modules\Settings\AllegroOAuthClient;
use App\Modules\Settings\AllegroStatusSyncService;
use App\Modules\Settings\AllegroStatusMappingRepository;
use App\Modules\Settings\OrderStatusRepository;
use App\Modules\Users\UserRepository;
use Throwable;
@@ -70,6 +84,7 @@ final class Application
public function run(): void
{
$request = Request::capture();
$this->maybeRunCronOnWeb($request);
$response = $this->router->dispatch($request);
$response->send();
}
@@ -215,7 +230,87 @@ final class Application
private function maybeRunCronOnWeb(Request $request): void
{
return;
$path = $request->path();
if ($path === '/health' || str_starts_with($path, '/assets/')) {
return;
}
try {
$repository = new CronRepository($this->db);
$runOnWeb = $repository->getBoolSetting(
'cron_run_on_web',
(bool) $this->config('app.cron.run_on_web_default', false)
);
if (!$runOnWeb) {
return;
}
$webLimit = $repository->getIntSetting(
'cron_web_limit',
(int) $this->config('app.cron.web_limit_default', 5),
1,
100
);
if ($this->isWebCronThrottled(10)) {
return;
}
if (!$this->acquireWebCronLock()) {
return;
}
try {
$integrationRepository = new AllegroIntegrationRepository(
$this->db,
(string) $this->config('app.integrations.secret', '')
);
$oauthClient = new AllegroOAuthClient();
$apiClient = new AllegroApiClient();
$statusMappingRepository = new AllegroStatusMappingRepository($this->db);
$orderImportService = new AllegroOrderImportService(
$integrationRepository,
$oauthClient,
$apiClient,
new OrderImportRepository($this->db),
$statusMappingRepository
);
$ordersSyncService = new AllegroOrdersSyncService(
$integrationRepository,
new AllegroOrderSyncStateRepository($this->db),
$oauthClient,
$apiClient,
$orderImportService
);
$runner = new CronRunner(
$repository,
$this->logger,
[
'allegro_token_refresh' => new AllegroTokenRefreshHandler(
$integrationRepository,
$oauthClient
),
'allegro_orders_import' => new AllegroOrdersImportHandler(
$ordersSyncService
),
'allegro_status_sync' => new AllegroStatusSyncHandler(
new AllegroStatusSyncService(
$repository,
$ordersSyncService
)
),
]
);
$runner->run($webLimit);
} finally {
$this->releaseWebCronLock();
}
} catch (Throwable $exception) {
$this->logger->error('Web cron run failed', [
'message' => $exception->getMessage(),
'path' => $path,
]);
}
}
private function isWebCronThrottled(int $minIntervalSeconds): bool

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\Settings\AllegroOrdersSyncService;
final class AllegroOrdersImportHandler
{
public function __construct(private readonly AllegroOrdersSyncService $syncService)
{
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function handle(array $payload): array
{
return $this->syncService->sync([
'max_pages' => (int) ($payload['max_pages'] ?? 5),
'page_limit' => (int) ($payload['page_limit'] ?? 50),
'max_orders' => (int) ($payload['max_orders'] ?? 200),
]);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\Settings\AllegroStatusSyncService;
final class AllegroStatusSyncHandler
{
public function __construct(private readonly AllegroStatusSyncService $syncService)
{
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function handle(array $payload): array
{
return $this->syncService->sync();
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\Settings\AllegroIntegrationRepository;
use App\Modules\Settings\AllegroOAuthClient;
use DateInterval;
use DateTimeImmutable;
use RuntimeException;
final class AllegroTokenRefreshHandler
{
public function __construct(
private readonly AllegroIntegrationRepository $repository,
private readonly AllegroOAuthClient $oauthClient
) {
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function handle(array $payload): array
{
$credentials = $this->repository->getRefreshTokenCredentials();
if ($credentials === null) {
throw new RuntimeException('Brak kompletnych danych Allegro OAuth do odswiezenia tokenu.');
}
$token = $this->oauthClient->refreshAccessToken(
(string) ($credentials['environment'] ?? 'sandbox'),
(string) ($credentials['client_id'] ?? ''),
(string) ($credentials['client_secret'] ?? ''),
(string) ($credentials['refresh_token'] ?? '')
);
$expiresAt = null;
$expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
if ($expiresIn > 0) {
$expiresAt = (new DateTimeImmutable('now'))
->add(new DateInterval('PT' . $expiresIn . 'S'))
->format('Y-m-d H:i:s');
}
$refreshToken = trim((string) ($token['refresh_token'] ?? ''));
if ($refreshToken === '') {
$refreshToken = (string) ($credentials['refresh_token'] ?? '');
}
$this->repository->saveTokens(
(string) ($token['access_token'] ?? ''),
$refreshToken,
(string) ($token['token_type'] ?? ''),
(string) ($token['scope'] ?? ''),
$expiresAt
);
return [
'ok' => true,
'expires_at' => $expiresAt,
];
}
}

View File

@@ -0,0 +1,448 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use DateTimeImmutable;
use PDO;
use Throwable;
final class CronRepository
{
public function __construct(private readonly PDO $pdo)
{
}
public function getBoolSetting(string $key, bool $default): bool
{
$value = $this->getSettingValue($key);
if ($value === null) {
return $default;
}
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
public function getIntSetting(string $key, int $default, int $min, int $max): int
{
$value = $this->getSettingValue($key);
if ($value === null || !is_numeric($value)) {
return max($min, min($max, $default));
}
return max($min, min($max, (int) $value));
}
public function getStringSetting(string $key, string $default): string
{
$value = $this->getSettingValue($key);
if ($value === null) {
return $default;
}
return $value;
}
public function upsertSetting(string $key, string $value): void
{
$statement = $this->pdo->prepare(
'INSERT INTO app_settings (setting_key, setting_value, created_at, updated_at)
VALUES (:setting_key, :setting_value, NOW(), NOW())
ON DUPLICATE KEY UPDATE
setting_value = VALUES(setting_value),
updated_at = VALUES(updated_at)'
);
$statement->execute([
'setting_key' => $key,
'setting_value' => $value,
]);
}
/**
* @return array<int, array<string, mixed>>
*/
public function listSchedules(): array
{
$statement = $this->pdo->query(
'SELECT id, job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at
FROM cron_schedules
ORDER BY priority ASC, job_type ASC'
);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
return array_map(fn (array $row): array => $this->normalizeScheduleRow($row), $rows);
}
/**
* @return array<int, array<string, mixed>>
*/
public function listFutureJobs(int $limit = 50): array
{
$safeLimit = max(1, min(200, $limit));
$statement = $this->pdo->prepare(
'SELECT id, job_type, status, priority, attempts, max_attempts, scheduled_at, last_error, created_at
FROM cron_jobs
WHERE status IN ("pending", "processing")
ORDER BY scheduled_at ASC, priority ASC, id ASC
LIMIT :limit'
);
$statement->bindValue(':limit', $safeLimit, PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
return array_map(fn (array $row): array => $this->normalizeJobRow($row), $rows);
}
/**
* @return array<int, array<string, mixed>>
*/
public function listPastJobs(int $limit = 50): array
{
$safeLimit = max(1, min(200, $limit));
$statement = $this->pdo->prepare(
'SELECT id, job_type, status, priority, attempts, max_attempts, scheduled_at, started_at, completed_at, last_error, created_at
FROM cron_jobs
WHERE status IN ("completed", "failed", "cancelled")
ORDER BY completed_at DESC, id DESC
LIMIT :limit'
);
$statement->bindValue(':limit', $safeLimit, PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
return array_map(fn (array $row): array => $this->normalizeJobRow($row), $rows);
}
/**
* @return array<int, array<string, mixed>>
*/
public function findDueSchedules(DateTimeImmutable $now): array
{
$statement = $this->pdo->prepare(
'SELECT id, job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at
FROM cron_schedules
WHERE enabled = 1
AND (next_run_at IS NULL OR next_run_at <= :now)
ORDER BY priority ASC, next_run_at ASC, id ASC'
);
$statement->execute([
'now' => $now->format('Y-m-d H:i:s'),
]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
return array_map(fn (array $row): array => $this->normalizeScheduleRow($row), $rows);
}
/**
* @param array<string, mixed> $schedule
*/
public function enqueueJobFromSchedule(array $schedule, DateTimeImmutable $now): void
{
$payloadJson = $this->encodeJson($schedule['payload'] ?? null);
$jobStatement = $this->pdo->prepare(
'INSERT INTO cron_jobs (
job_type, status, priority, payload, attempts, max_attempts,
scheduled_at, created_at, updated_at
) VALUES (
:job_type, "pending", :priority, :payload, 0, :max_attempts,
:scheduled_at, NOW(), NOW()
)'
);
$jobStatement->execute([
'job_type' => (string) ($schedule['job_type'] ?? ''),
'priority' => (int) ($schedule['priority'] ?? 100),
'payload' => $payloadJson,
'max_attempts' => max(1, (int) ($schedule['max_attempts'] ?? 3)),
'scheduled_at' => $now->format('Y-m-d H:i:s'),
]);
$intervalSeconds = max(1, (int) ($schedule['interval_seconds'] ?? 60));
$nextRunAt = $now->modify('+' . $intervalSeconds . ' seconds');
$scheduleStatement = $this->pdo->prepare(
'UPDATE cron_schedules
SET last_run_at = :last_run_at,
next_run_at = :next_run_at,
updated_at = NOW()
WHERE id = :id'
);
$scheduleStatement->execute([
'last_run_at' => $now->format('Y-m-d H:i:s'),
'next_run_at' => $nextRunAt->format('Y-m-d H:i:s'),
'id' => (int) ($schedule['id'] ?? 0),
]);
}
/**
* @return array<string, mixed>|null
*/
public function claimNextPendingJob(DateTimeImmutable $now): ?array
{
$selectStatement = $this->pdo->prepare(
'SELECT id, job_type, status, priority, payload, attempts, max_attempts, scheduled_at
FROM cron_jobs
WHERE status = "pending"
AND scheduled_at <= :now
ORDER BY priority ASC, scheduled_at ASC, id ASC
LIMIT 1'
);
$selectStatement->execute([
'now' => $now->format('Y-m-d H:i:s'),
]);
$row = $selectStatement->fetch(PDO::FETCH_ASSOC);
if (!is_array($row)) {
return null;
}
$jobId = (int) ($row['id'] ?? 0);
if ($jobId <= 0) {
return null;
}
$updateStatement = $this->pdo->prepare(
'UPDATE cron_jobs
SET status = "processing",
started_at = :started_at,
attempts = attempts + 1,
updated_at = NOW()
WHERE id = :id
AND status = "pending"'
);
$updateStatement->execute([
'started_at' => $now->format('Y-m-d H:i:s'),
'id' => $jobId,
]);
if ($updateStatement->rowCount() !== 1) {
return null;
}
$refreshStatement = $this->pdo->prepare(
'SELECT id, job_type, status, priority, payload, attempts, max_attempts, scheduled_at, started_at
FROM cron_jobs
WHERE id = :id
LIMIT 1'
);
$refreshStatement->execute(['id' => $jobId]);
$claimed = $refreshStatement->fetch(PDO::FETCH_ASSOC);
if (!is_array($claimed)) {
return null;
}
return $this->normalizeJobRow($claimed);
}
/**
* @param array<string, mixed>|null $result
*/
public function markJobCompleted(int $jobId, ?array $result = null): void
{
$statement = $this->pdo->prepare(
'UPDATE cron_jobs
SET status = "completed",
result = :result,
completed_at = NOW(),
last_error = NULL,
updated_at = NOW()
WHERE id = :id'
);
$statement->execute([
'result' => $this->encodeJson($result),
'id' => $jobId,
]);
}
/**
* @param array<string, mixed>|null $result
*/
public function markJobFailed(int $jobId, string $error, DateTimeImmutable $now, int $retryDelaySeconds = 60, ?array $result = null): void
{
$statement = $this->pdo->prepare(
'SELECT attempts, max_attempts
FROM cron_jobs
WHERE id = :id
LIMIT 1'
);
$statement->execute(['id' => $jobId]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
if (!is_array($row)) {
return;
}
$attempts = (int) ($row['attempts'] ?? 0);
$maxAttempts = max(1, (int) ($row['max_attempts'] ?? 1));
$errorMessage = mb_substr(trim($error), 0, 500);
if ($attempts < $maxAttempts) {
$scheduledAt = $now->modify('+' . max(10, $retryDelaySeconds) . ' seconds');
$retryStatement = $this->pdo->prepare(
'UPDATE cron_jobs
SET status = "pending",
result = :result,
scheduled_at = :scheduled_at,
started_at = NULL,
completed_at = NULL,
last_error = :last_error,
updated_at = NOW()
WHERE id = :id'
);
$retryStatement->execute([
'result' => $this->encodeJson($result),
'scheduled_at' => $scheduledAt->format('Y-m-d H:i:s'),
'last_error' => $errorMessage,
'id' => $jobId,
]);
return;
}
$failStatement = $this->pdo->prepare(
'UPDATE cron_jobs
SET status = "failed",
result = :result,
completed_at = NOW(),
last_error = :last_error,
updated_at = NOW()
WHERE id = :id'
);
$failStatement->execute([
'result' => $this->encodeJson($result),
'last_error' => $errorMessage,
'id' => $jobId,
]);
}
public function upsertSchedule(
string $jobType,
int $intervalSeconds,
int $priority,
int $maxAttempts,
?array $payload,
bool $enabled
): void {
$statement = $this->pdo->prepare(
'INSERT INTO cron_schedules (
job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at, created_at, updated_at
) VALUES (
:job_type, :interval_seconds, :priority, :max_attempts, :payload, :enabled, NULL, NOW(), NOW(), NOW()
)
ON DUPLICATE KEY UPDATE
interval_seconds = VALUES(interval_seconds),
priority = VALUES(priority),
max_attempts = VALUES(max_attempts),
payload = VALUES(payload),
enabled = VALUES(enabled),
updated_at = VALUES(updated_at)'
);
$statement->execute([
'job_type' => trim($jobType),
'interval_seconds' => max(1, $intervalSeconds),
'priority' => max(1, min(255, $priority)),
'max_attempts' => max(1, min(20, $maxAttempts)),
'payload' => $this->encodeJson($payload),
'enabled' => $enabled ? 1 : 0,
]);
}
private function getSettingValue(string $key): ?string
{
try {
$statement = $this->pdo->prepare(
'SELECT setting_value
FROM app_settings
WHERE setting_key = :setting_key
LIMIT 1'
);
$statement->execute(['setting_key' => $key]);
$value = $statement->fetchColumn();
} catch (Throwable) {
return null;
}
if (!is_string($value)) {
return null;
}
return trim($value);
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function normalizeScheduleRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'job_type' => (string) ($row['job_type'] ?? ''),
'interval_seconds' => (int) ($row['interval_seconds'] ?? 0),
'priority' => (int) ($row['priority'] ?? 100),
'max_attempts' => (int) ($row['max_attempts'] ?? 3),
'payload' => $this->decodeJson((string) ($row['payload'] ?? '')),
'enabled' => (int) ($row['enabled'] ?? 0) === 1,
'last_run_at' => (string) ($row['last_run_at'] ?? ''),
'next_run_at' => (string) ($row['next_run_at'] ?? ''),
];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function normalizeJobRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'job_type' => (string) ($row['job_type'] ?? ''),
'status' => (string) ($row['status'] ?? ''),
'priority' => (int) ($row['priority'] ?? 100),
'payload' => $this->decodeJson((string) ($row['payload'] ?? '')),
'result' => $this->decodeJson((string) ($row['result'] ?? '')),
'attempts' => (int) ($row['attempts'] ?? 0),
'max_attempts' => (int) ($row['max_attempts'] ?? 3),
'scheduled_at' => (string) ($row['scheduled_at'] ?? ''),
'started_at' => (string) ($row['started_at'] ?? ''),
'completed_at' => (string) ($row['completed_at'] ?? ''),
'last_error' => (string) ($row['last_error'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
];
}
private function encodeJson(mixed $value): ?string
{
if ($value === null) {
return null;
}
if (!is_array($value)) {
return null;
}
if ($value === []) {
return null;
}
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: null;
}
private function decodeJson(string $value): ?array
{
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
$decoded = json_decode($trimmed, true);
return is_array($decoded) ? $decoded : null;
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use App\Core\Support\Logger;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
final class CronRunner
{
/**
* @param array<string, object> $handlers
*/
public function __construct(
private readonly CronRepository $repository,
private readonly Logger $logger,
private readonly array $handlers
) {
}
/**
* @return array<string, int>
*/
public function run(int $limit): array
{
$safeLimit = max(1, min(100, $limit));
$now = new DateTimeImmutable('now');
$dispatched = $this->dispatchDueSchedules($now);
$processed = 0;
$completed = 0;
$failed = 0;
while ($processed < $safeLimit) {
$job = $this->repository->claimNextPendingJob(new DateTimeImmutable('now'));
if ($job === null) {
break;
}
$processed++;
$jobId = (int) ($job['id'] ?? 0);
$jobType = (string) ($job['job_type'] ?? '');
try {
$result = $this->handleJob($jobType, is_array($job['payload'] ?? null) ? $job['payload'] : []);
$this->repository->markJobCompleted($jobId, $result);
$completed++;
} catch (Throwable $exception) {
$this->repository->markJobFailed($jobId, $exception->getMessage(), new DateTimeImmutable('now'), 60);
$this->logger->error('Cron job failed', [
'job_id' => $jobId,
'job_type' => $jobType,
'error' => $exception->getMessage(),
]);
$failed++;
}
}
return [
'dispatched' => $dispatched,
'processed' => $processed,
'completed' => $completed,
'failed' => $failed,
];
}
private function dispatchDueSchedules(DateTimeImmutable $now): int
{
$schedules = $this->repository->findDueSchedules($now);
$count = 0;
foreach ($schedules as $schedule) {
$this->repository->enqueueJobFromSchedule($schedule, $now);
$count++;
}
return $count;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function handleJob(string $jobType, array $payload): array
{
$handler = $this->handlers[$jobType] ?? null;
if ($handler === null || !method_exists($handler, 'handle')) {
throw new RuntimeException('Brak handlera dla typu joba: ' . $jobType);
}
$result = $handler->handle($payload);
if (!is_array($result)) {
return ['ok' => true];
}
return $result;
}
}

View File

@@ -0,0 +1,421 @@
<?php
declare(strict_types=1);
namespace App\Modules\Orders;
use PDO;
use Throwable;
final class OrderImportRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @param array<string, mixed> $orderData
* @param array<int, array<string, mixed>> $addresses
* @param array<int, array<string, mixed>> $items
* @param array<int, array<string, mixed>> $payments
* @param array<int, array<string, mixed>> $shipments
* @param array<int, array<string, mixed>> $notes
* @param array<int, array<string, mixed>> $statusHistory
* @return array{order_id:int, created:bool}
*/
public function upsertOrderAggregate(
array $orderData,
array $addresses,
array $items,
array $payments,
array $shipments,
array $notes,
array $statusHistory
): array {
$this->pdo->beginTransaction();
try {
$source = trim((string) ($orderData['source'] ?? 'allegro'));
$sourceOrderId = trim((string) ($orderData['source_order_id'] ?? ''));
$existingOrderId = $this->findOrderIdBySource($source, $sourceOrderId);
$created = $existingOrderId === null;
$orderId = $created
? $this->insertOrder($orderData)
: $this->updateOrder($existingOrderId, $orderData);
$this->replaceAddresses($orderId, $addresses);
$this->replaceItems($orderId, $items);
$this->replacePayments($orderId, $payments);
$this->replaceShipments($orderId, $shipments);
$this->replaceNotes($orderId, $notes);
$this->replaceStatusHistory($orderId, $statusHistory);
$this->pdo->commit();
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $exception;
}
return [
'order_id' => $orderId,
'created' => $created,
];
}
private function findOrderIdBySource(string $source, string $sourceOrderId): ?int
{
$statement = $this->pdo->prepare(
'SELECT id
FROM orders
WHERE source = :source AND source_order_id = :source_order_id
LIMIT 1'
);
$statement->execute([
'source' => $source,
'source_order_id' => $sourceOrderId,
]);
$value = $statement->fetchColumn();
if ($value === false) {
return null;
}
$id = (int) $value;
return $id > 0 ? $id : null;
}
/**
* @param array<string, mixed> $orderData
*/
private function insertOrder(array $orderData): int
{
$statement = $this->pdo->prepare(
'INSERT INTO orders (
integration_id, source, source_order_id, external_order_id, external_platform_id, external_platform_account_id,
external_status_id, external_payment_type_id, payment_status, external_carrier_id, external_carrier_account_id,
customer_login, is_invoice, is_encrypted, is_canceled_by_buyer, currency,
total_without_tax, total_with_tax, total_paid, send_date_min, send_date_max, ordered_at,
source_created_at, source_updated_at, preferences_json, payload_json, fetched_at
) VALUES (
:integration_id, :source, :source_order_id, :external_order_id, :external_platform_id, :external_platform_account_id,
:external_status_id, :external_payment_type_id, :payment_status, :external_carrier_id, :external_carrier_account_id,
:customer_login, :is_invoice, :is_encrypted, :is_canceled_by_buyer, :currency,
:total_without_tax, :total_with_tax, :total_paid, :send_date_min, :send_date_max, :ordered_at,
:source_created_at, :source_updated_at, :preferences_json, :payload_json, :fetched_at
)'
);
$statement->execute($this->orderParams($orderData));
return (int) $this->pdo->lastInsertId();
}
/**
* @param array<string, mixed> $orderData
*/
private function updateOrder(int $orderId, array $orderData): int
{
$statement = $this->pdo->prepare(
'UPDATE orders
SET integration_id = :integration_id,
source = :source,
source_order_id = :source_order_id,
external_order_id = :external_order_id,
external_platform_id = :external_platform_id,
external_platform_account_id = :external_platform_account_id,
external_status_id = :external_status_id,
external_payment_type_id = :external_payment_type_id,
payment_status = :payment_status,
external_carrier_id = :external_carrier_id,
external_carrier_account_id = :external_carrier_account_id,
customer_login = :customer_login,
is_invoice = :is_invoice,
is_encrypted = :is_encrypted,
is_canceled_by_buyer = :is_canceled_by_buyer,
currency = :currency,
total_without_tax = :total_without_tax,
total_with_tax = :total_with_tax,
total_paid = :total_paid,
send_date_min = :send_date_min,
send_date_max = :send_date_max,
ordered_at = :ordered_at,
source_created_at = :source_created_at,
source_updated_at = :source_updated_at,
preferences_json = :preferences_json,
payload_json = :payload_json,
fetched_at = :fetched_at,
updated_at = NOW()
WHERE id = :id'
);
$params = $this->orderParams($orderData);
$params['id'] = $orderId;
$statement->execute($params);
return $orderId;
}
/**
* @param array<string, mixed> $orderData
* @return array<string, mixed>
*/
private function orderParams(array $orderData): array
{
return [
'integration_id' => $orderData['integration_id'] ?? null,
'source' => (string) ($orderData['source'] ?? 'allegro'),
'source_order_id' => (string) ($orderData['source_order_id'] ?? ''),
'external_order_id' => $orderData['external_order_id'] ?? null,
'external_platform_id' => $orderData['external_platform_id'] ?? null,
'external_platform_account_id' => $orderData['external_platform_account_id'] ?? null,
'external_status_id' => $orderData['external_status_id'] ?? null,
'external_payment_type_id' => $orderData['external_payment_type_id'] ?? null,
'payment_status' => $orderData['payment_status'] ?? null,
'external_carrier_id' => $orderData['external_carrier_id'] ?? null,
'external_carrier_account_id' => $orderData['external_carrier_account_id'] ?? null,
'customer_login' => $orderData['customer_login'] ?? null,
'is_invoice' => !empty($orderData['is_invoice']) ? 1 : 0,
'is_encrypted' => !empty($orderData['is_encrypted']) ? 1 : 0,
'is_canceled_by_buyer' => !empty($orderData['is_canceled_by_buyer']) ? 1 : 0,
'currency' => (string) ($orderData['currency'] ?? 'PLN'),
'total_without_tax' => $orderData['total_without_tax'] ?? null,
'total_with_tax' => $orderData['total_with_tax'] ?? null,
'total_paid' => $orderData['total_paid'] ?? null,
'send_date_min' => $orderData['send_date_min'] ?? null,
'send_date_max' => $orderData['send_date_max'] ?? null,
'ordered_at' => $orderData['ordered_at'] ?? null,
'source_created_at' => $orderData['source_created_at'] ?? null,
'source_updated_at' => $orderData['source_updated_at'] ?? null,
'preferences_json' => $this->encodeJson($orderData['preferences_json'] ?? null),
'payload_json' => $this->encodeJson($orderData['payload_json'] ?? null),
'fetched_at' => $orderData['fetched_at'] ?? date('Y-m-d H:i:s'),
];
}
/**
* @param array<int, array<string, mixed>> $rows
*/
private function replaceAddresses(int $orderId, array $rows): void
{
$this->pdo->prepare('DELETE FROM order_addresses WHERE order_id = :order_id')->execute(['order_id' => $orderId]);
if ($rows === []) {
return;
}
$statement = $this->pdo->prepare(
'INSERT INTO order_addresses (
order_id, address_type, name, phone, email, street_name, street_number, city, zip_code, country,
department, parcel_external_id, parcel_name, address_class, company_tax_number, company_name, payload_json
) VALUES (
:order_id, :address_type, :name, :phone, :email, :street_name, :street_number, :city, :zip_code, :country,
:department, :parcel_external_id, :parcel_name, :address_class, :company_tax_number, :company_name, :payload_json
)'
);
foreach ($rows as $row) {
$statement->execute([
'order_id' => $orderId,
'address_type' => (string) ($row['address_type'] ?? 'customer'),
'name' => (string) ($row['name'] ?? ''),
'phone' => $row['phone'] ?? null,
'email' => $row['email'] ?? null,
'street_name' => $row['street_name'] ?? null,
'street_number' => $row['street_number'] ?? null,
'city' => $row['city'] ?? null,
'zip_code' => $row['zip_code'] ?? null,
'country' => $row['country'] ?? null,
'department' => $row['department'] ?? null,
'parcel_external_id' => $row['parcel_external_id'] ?? null,
'parcel_name' => $row['parcel_name'] ?? null,
'address_class' => $row['address_class'] ?? null,
'company_tax_number' => $row['company_tax_number'] ?? null,
'company_name' => $row['company_name'] ?? null,
'payload_json' => $this->encodeJson($row['payload_json'] ?? null),
]);
}
}
/**
* @param array<int, array<string, mixed>> $rows
*/
private function replaceItems(int $orderId, array $rows): void
{
$this->pdo->prepare('DELETE FROM order_items WHERE order_id = :order_id')->execute(['order_id' => $orderId]);
if ($rows === []) {
return;
}
$statement = $this->pdo->prepare(
'INSERT INTO order_items (
order_id, source_item_id, external_item_id, ean, sku, original_name, original_code,
original_price_with_tax, original_price_without_tax, media_url, quantity, tax_rate, item_status,
unit, item_type, source_product_id, source_product_set_id, sort_order, payload_json
) VALUES (
:order_id, :source_item_id, :external_item_id, :ean, :sku, :original_name, :original_code,
:original_price_with_tax, :original_price_without_tax, :media_url, :quantity, :tax_rate, :item_status,
:unit, :item_type, :source_product_id, :source_product_set_id, :sort_order, :payload_json
)'
);
foreach ($rows as $row) {
$statement->execute([
'order_id' => $orderId,
'source_item_id' => $row['source_item_id'] ?? null,
'external_item_id' => $row['external_item_id'] ?? null,
'ean' => $row['ean'] ?? null,
'sku' => $row['sku'] ?? null,
'original_name' => (string) ($row['original_name'] ?? ''),
'original_code' => $row['original_code'] ?? null,
'original_price_with_tax' => $row['original_price_with_tax'] ?? null,
'original_price_without_tax' => $row['original_price_without_tax'] ?? null,
'media_url' => $row['media_url'] ?? null,
'quantity' => $row['quantity'] ?? 1,
'tax_rate' => $row['tax_rate'] ?? null,
'item_status' => $row['item_status'] ?? null,
'unit' => $row['unit'] ?? null,
'item_type' => (string) ($row['item_type'] ?? 'product'),
'source_product_id' => $row['source_product_id'] ?? null,
'source_product_set_id' => $row['source_product_set_id'] ?? null,
'sort_order' => (int) ($row['sort_order'] ?? 0),
'payload_json' => $this->encodeJson($row['payload_json'] ?? null),
]);
}
}
/**
* @param array<int, array<string, mixed>> $rows
*/
private function replacePayments(int $orderId, array $rows): void
{
$this->pdo->prepare('DELETE FROM order_payments WHERE order_id = :order_id')->execute(['order_id' => $orderId]);
if ($rows === []) {
return;
}
$statement = $this->pdo->prepare(
'INSERT INTO order_payments (
order_id, source_payment_id, external_payment_id, payment_type_id, payment_date, amount, currency, comment, payload_json
) VALUES (
:order_id, :source_payment_id, :external_payment_id, :payment_type_id, :payment_date, :amount, :currency, :comment, :payload_json
)'
);
foreach ($rows as $row) {
$statement->execute([
'order_id' => $orderId,
'source_payment_id' => $row['source_payment_id'] ?? null,
'external_payment_id' => $row['external_payment_id'] ?? null,
'payment_type_id' => (string) ($row['payment_type_id'] ?? 'unknown'),
'payment_date' => $row['payment_date'] ?? null,
'amount' => $row['amount'] ?? null,
'currency' => $row['currency'] ?? null,
'comment' => $row['comment'] ?? null,
'payload_json' => $this->encodeJson($row['payload_json'] ?? null),
]);
}
}
/**
* @param array<int, array<string, mixed>> $rows
*/
private function replaceShipments(int $orderId, array $rows): void
{
$this->pdo->prepare('DELETE FROM order_shipments WHERE order_id = :order_id')->execute(['order_id' => $orderId]);
if ($rows === []) {
return;
}
$statement = $this->pdo->prepare(
'INSERT INTO order_shipments (
order_id, source_shipment_id, external_shipment_id, tracking_number, carrier_provider_id, posted_at, media_uuid, payload_json
) VALUES (
:order_id, :source_shipment_id, :external_shipment_id, :tracking_number, :carrier_provider_id, :posted_at, :media_uuid, :payload_json
)'
);
foreach ($rows as $row) {
$statement->execute([
'order_id' => $orderId,
'source_shipment_id' => $row['source_shipment_id'] ?? null,
'external_shipment_id' => $row['external_shipment_id'] ?? null,
'tracking_number' => (string) ($row['tracking_number'] ?? ''),
'carrier_provider_id' => (string) ($row['carrier_provider_id'] ?? 'unknown'),
'posted_at' => $row['posted_at'] ?? null,
'media_uuid' => $row['media_uuid'] ?? null,
'payload_json' => $this->encodeJson($row['payload_json'] ?? null),
]);
}
}
/**
* @param array<int, array<string, mixed>> $rows
*/
private function replaceNotes(int $orderId, array $rows): void
{
$this->pdo->prepare('DELETE FROM order_notes WHERE order_id = :order_id')->execute(['order_id' => $orderId]);
if ($rows === []) {
return;
}
$statement = $this->pdo->prepare(
'INSERT INTO order_notes (
order_id, source_note_id, note_type, created_at_external, comment, payload_json
) VALUES (
:order_id, :source_note_id, :note_type, :created_at_external, :comment, :payload_json
)'
);
foreach ($rows as $row) {
$statement->execute([
'order_id' => $orderId,
'source_note_id' => $row['source_note_id'] ?? null,
'note_type' => (string) ($row['note_type'] ?? 'message'),
'created_at_external' => $row['created_at_external'] ?? null,
'comment' => (string) ($row['comment'] ?? ''),
'payload_json' => $this->encodeJson($row['payload_json'] ?? null),
]);
}
}
/**
* @param array<int, array<string, mixed>> $rows
*/
private function replaceStatusHistory(int $orderId, array $rows): void
{
$this->pdo->prepare('DELETE FROM order_status_history WHERE order_id = :order_id')->execute(['order_id' => $orderId]);
if ($rows === []) {
return;
}
$statement = $this->pdo->prepare(
'INSERT INTO order_status_history (
order_id, from_status_id, to_status_id, changed_at, change_source, comment, payload_json
) VALUES (
:order_id, :from_status_id, :to_status_id, :changed_at, :change_source, :comment, :payload_json
)'
);
foreach ($rows as $row) {
$statement->execute([
'order_id' => $orderId,
'from_status_id' => $row['from_status_id'] ?? null,
'to_status_id' => (string) ($row['to_status_id'] ?? ''),
'changed_at' => $row['changed_at'] ?? date('Y-m-d H:i:s'),
'change_source' => (string) ($row['change_source'] ?? 'import'),
'comment' => $row['comment'] ?? null,
'payload_json' => $this->encodeJson($row['payload_json'] ?? null),
]);
}
}
private function encodeJson(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
if (!is_array($value)) {
return null;
}
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: null;
}
}

View File

@@ -39,11 +39,11 @@ final class OrdersController
$result = $this->orders->paginate($filters);
$totalPages = max(1, (int) ceil(((int) $result['total']) / max(1, (int) $result['per_page'])));
$sourceOptions = $this->orders->sourceOptions();
$statusOptions = $this->orders->statusOptions();
$stats = $this->orders->quickStats();
$statusCounts = $this->orders->statusCounts();
$statusConfig = $this->orders->statusPanelConfig();
$statusLabelMap = $this->statusLabelMap($statusConfig);
$statusOptions = $this->buildStatusFilterOptions($this->orders->statusOptions(), $statusLabelMap);
$statusPanel = $this->buildStatusPanel($statusConfig, $statusCounts, $filters['status'], $filters);
$tableRows = array_map(fn (array $row): array => $this->toTableRow($row, $statusLabelMap), (array) ($result['items'] ?? []));
@@ -144,7 +144,7 @@ final class OrdersController
$documents = is_array($details['documents'] ?? null) ? $details['documents'] : [];
$notes = is_array($details['notes'] ?? null) ? $details['notes'] : [];
$history = is_array($details['status_history'] ?? null) ? $details['status_history'] : [];
$statusCode = (string) ($order['external_status_id'] ?? '');
$statusCode = (string) (($order['effective_status_id'] ?? '') !== '' ? $order['effective_status_id'] : ($order['external_status_id'] ?? ''));
$statusCounts = $this->orders->statusCounts();
$statusConfig = $this->orders->statusPanelConfig();
$statusLabelMap = $this->statusLabelMap($statusConfig);
@@ -183,7 +183,7 @@ final class OrdersController
$buyerName = trim((string) ($row['buyer_name'] ?? ''));
$buyerEmail = trim((string) ($row['buyer_email'] ?? ''));
$buyerCity = trim((string) ($row['buyer_city'] ?? ''));
$status = trim((string) ($row['external_status_id'] ?? ''));
$status = trim((string) (($row['effective_status_id'] ?? '') !== '' ? $row['effective_status_id'] : ($row['external_status_id'] ?? '')));
$currency = trim((string) ($row['currency'] ?? ''));
$totalWithTax = $row['total_with_tax'] !== null ? number_format((float) $row['total_with_tax'], 2, '.', ' ') : '-';
$totalPaid = $row['total_paid'] !== null ? number_format((float) $row['total_paid'], 2, '.', ' ') : '-';
@@ -211,7 +211,7 @@ final class OrdersController
. '</div>'
. '</div>',
'status_badges' => '<div class="orders-status-wrap">'
. $this->statusBadge($this->statusLabel($status, $statusLabelMap))
. $this->statusBadge($status, $this->statusLabel($status, $statusLabelMap))
. '</div>',
'products' => $this->productsHtml($itemsPreview, $itemsCount, $itemsQty),
'totals' => '<div class="orders-money">'
@@ -227,17 +227,18 @@ final class OrdersController
];
}
private function statusBadge(string $status): string
private function statusBadge(string $statusCode, string $statusLabel): string
{
$label = $status !== '' ? $status : '-';
$label = $statusLabel !== '' ? $statusLabel : '-';
$code = strtolower(trim($statusCode));
$class = 'is-neutral';
if (in_array($status, ['shipped', 'delivered'], true)) {
if (in_array($code, ['shipped', 'delivered'], true)) {
$class = 'is-success';
} elseif (in_array($status, ['cancelled', 'returned'], true)) {
} elseif (in_array($code, ['cancelled', 'returned'], true)) {
$class = 'is-danger';
} elseif (in_array($status, ['new', 'confirmed'], true)) {
} elseif (in_array($code, ['new', 'confirmed'], true)) {
$class = 'is-info';
} elseif (in_array($status, ['processing', 'packed', 'paid'], true)) {
} elseif (in_array($code, ['processing', 'packed', 'paid'], true)) {
$class = 'is-warn';
}
@@ -255,7 +256,8 @@ final class OrdersController
return (string) $statusLabelMap[$key];
}
return ucfirst($statusCode);
$normalized = str_replace(['_', '-'], ' ', $key);
return ucfirst($normalized);
}
/**
@@ -415,6 +417,26 @@ final class OrdersController
return $map;
}
/**
* @param array<string, string> $statusCodes
* @param array<string, string> $statusLabelMap
* @return array<string, string>
*/
private function buildStatusFilterOptions(array $statusCodes, array $statusLabelMap): array
{
$options = [];
foreach ($statusCodes as $code => $value) {
$rawCode = trim((string) ($code !== '' ? $code : $value));
if ($rawCode === '') {
continue;
}
$normalizedCode = strtolower($rawCode);
$options[$normalizedCode] = $this->statusLabel($normalizedCode, $statusLabelMap);
}
return $options;
}
/**
* @param array<int, array<string, mixed>> $itemsPreview
*/

View File

@@ -8,6 +8,8 @@ use Throwable;
final class OrdersRepository
{
private ?bool $supportsMappedMedia = null;
public function __construct(private readonly PDO $pdo)
{
}
@@ -24,6 +26,7 @@ final class OrdersRepository
$where = [];
$params = [];
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$search = trim((string) ($filters['search'] ?? ''));
if ($search !== '') {
@@ -45,7 +48,7 @@ final class OrdersRepository
$status = trim((string) ($filters['status'] ?? ''));
if ($status !== '') {
$where[] = 'o.external_status_id = :status';
$where[] = $effectiveStatusSql . ' = :status';
$params['status'] = $status;
}
@@ -86,7 +89,8 @@ final class OrdersRepository
try {
$countSql = 'SELECT COUNT(*) FROM orders o '
. 'LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"'
. 'LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer" '
. 'LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code'
. $whereSql;
$countStmt = $this->pdo->prepare($countSql);
$countStmt->execute($params);
@@ -98,6 +102,7 @@ final class OrdersRepository
o.source_order_id,
o.external_order_id,
o.external_status_id,
' . $effectiveStatusSql . ' AS effective_status_id,
o.payment_status,
o.currency,
o.total_with_tax,
@@ -115,7 +120,8 @@ final class OrdersRepository
(SELECT COUNT(*) FROM order_shipments sh WHERE sh.order_id = o.id) AS shipments_count,
(SELECT COUNT(*) FROM order_documents od WHERE od.order_id = o.id) AS documents_count
FROM orders o
LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"'
LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code'
. $whereSql
. ' ORDER BY ' . $sortColumn . ' ' . $sortDir
. ' LIMIT :limit OFFSET :offset';
@@ -150,6 +156,7 @@ final class OrdersRepository
'source_order_id' => (string) ($row['source_order_id'] ?? ''),
'external_order_id' => (string) ($row['external_order_id'] ?? ''),
'external_status_id' => (string) ($row['external_status_id'] ?? ''),
'effective_status_id' => (string) ($row['effective_status_id'] ?? ''),
'payment_status' => isset($row['payment_status']) ? (int) $row['payment_status'] : null,
'currency' => (string) ($row['currency'] ?? ''),
'total_with_tax' => $row['total_with_tax'] !== null ? (float) $row['total_with_tax'] : null,
@@ -191,7 +198,15 @@ final class OrdersRepository
public function statusOptions(): array
{
try {
$rows = $this->pdo->query('SELECT DISTINCT external_status_id FROM orders WHERE external_status_id IS NOT NULL AND external_status_id <> "" ORDER BY external_status_id ASC')->fetchAll(PDO::FETCH_COLUMN);
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$rows = $this->pdo->query(
'SELECT DISTINCT ' . $effectiveStatusSql . ' AS effective_status_id
FROM orders o
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code
WHERE ' . $effectiveStatusSql . ' IS NOT NULL
AND ' . $effectiveStatusSql . ' <> ""
ORDER BY effective_status_id ASC'
)->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable) {
return [];
}
@@ -245,11 +260,13 @@ final class OrdersRepository
public function quickStats(): array
{
try {
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$row = $this->pdo->query('SELECT
COUNT(*) AS all_count,
SUM(CASE WHEN payment_status = 2 THEN 1 ELSE 0 END) AS paid_count,
SUM(CASE WHEN external_status_id IN ("shipped", "delivered", "returned") THEN 1 ELSE 0 END) AS shipped_count
FROM orders')->fetch(PDO::FETCH_ASSOC);
SUM(CASE WHEN ' . $effectiveStatusSql . ' IN ("shipped", "delivered", "returned") THEN 1 ELSE 0 END) AS shipped_count
FROM orders o
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code')->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [
'all' => 0,
@@ -279,7 +296,13 @@ final class OrdersRepository
public function statusCounts(): array
{
try {
$rows = $this->pdo->query('SELECT external_status_id, COUNT(*) AS cnt FROM orders GROUP BY external_status_id')->fetchAll(PDO::FETCH_ASSOC);
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$rows = $this->pdo->query(
'SELECT ' . $effectiveStatusSql . ' AS effective_status_id, COUNT(*) AS cnt
FROM orders o
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code
GROUP BY effective_status_id'
)->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable) {
return [];
}
@@ -290,7 +313,7 @@ final class OrdersRepository
$result = [];
foreach ($rows as $row) {
$key = trim((string) ($row['external_status_id'] ?? ''));
$key = trim((string) ($row['effective_status_id'] ?? ''));
if ($key === '') {
$key = '_empty';
}
@@ -366,7 +389,14 @@ final class OrdersRepository
}
try {
$orderStmt = $this->pdo->prepare('SELECT * FROM orders WHERE id = :id LIMIT 1');
$effectiveStatusSql = $this->effectiveStatusSql('o', 'asm');
$orderStmt = $this->pdo->prepare(
'SELECT o.*, ' . $effectiveStatusSql . ' AS effective_status_id
FROM orders o
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.external_status_id) = asm.allegro_status_code
WHERE o.id = :id
LIMIT 1'
);
$orderStmt->execute(['id' => $orderId]);
$order = $orderStmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($order)) {
@@ -380,12 +410,25 @@ final class OrdersRepository
$addresses = [];
}
$itemsStmt = $this->pdo->prepare('SELECT * FROM order_items WHERE order_id = :order_id ORDER BY sort_order ASC, id ASC');
$itemsMediaSql = $this->resolvedMediaUrlSql('oi');
$itemsStmt = $this->pdo->prepare('SELECT oi.*, ' . $itemsMediaSql . ' AS resolved_media_url
FROM order_items oi
WHERE oi.order_id = :order_id
ORDER BY oi.sort_order ASC, oi.id ASC');
$itemsStmt->execute(['order_id' => $orderId]);
$items = $itemsStmt->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($items)) {
$items = [];
}
$items = array_map(static function (array $row): array {
$resolvedMediaUrl = trim((string) ($row['resolved_media_url'] ?? ''));
if ($resolvedMediaUrl !== '') {
$row['media_url'] = $resolvedMediaUrl;
}
unset($row['resolved_media_url']);
return $row;
}, $items);
$paymentsStmt = $this->pdo->prepare('SELECT * FROM order_payments WHERE order_id = :order_id ORDER BY payment_date ASC, id ASC');
$paymentsStmt->execute(['order_id' => $orderId]);
@@ -457,10 +500,11 @@ final class OrdersRepository
$placeholders = implode(',', array_fill(0, count($cleanIds), '?'));
try {
$sql = 'SELECT order_id, original_name, quantity, COALESCE(media_url, "") AS media_url, sort_order, id
FROM order_items
WHERE order_id IN (' . $placeholders . ')
ORDER BY order_id ASC, sort_order ASC, id ASC';
$resolvedMediaSql = $this->resolvedMediaUrlSql('oi');
$sql = 'SELECT oi.order_id, oi.original_name, oi.quantity, ' . $resolvedMediaSql . ' AS media_url, oi.sort_order, oi.id
FROM order_items oi
WHERE oi.order_id IN (' . $placeholders . ')
ORDER BY oi.order_id ASC, oi.sort_order ASC, oi.id ASC';
$stmt = $this->pdo->prepare($sql);
foreach ($cleanIds as $index => $orderId) {
$stmt->bindValue($index + 1, $orderId, PDO::PARAM_INT);
@@ -496,6 +540,88 @@ final class OrdersRepository
return $result;
}
private function effectiveStatusSql(string $orderAlias, string $mappingAlias): string
{
return 'CASE
WHEN ' . $orderAlias . '.source = "allegro"
AND ' . $mappingAlias . '.orderpro_status_code IS NOT NULL
AND ' . $mappingAlias . '.orderpro_status_code <> ""
THEN ' . $mappingAlias . '.orderpro_status_code
ELSE ' . $orderAlias . '.external_status_id
END';
}
private function resolvedMediaUrlSql(string $itemAlias): string
{
if (!$this->canResolveMappedMedia()) {
return 'COALESCE(NULLIF(TRIM(' . $itemAlias . '.media_url), ""), "")';
}
return 'COALESCE(
NULLIF(TRIM(' . $itemAlias . '.media_url), ""),
(
SELECT NULLIF(TRIM(pi.storage_path), "")
FROM product_channel_map pcm
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
INNER JOIN product_images pi ON pi.product_id = pcm.product_id
WHERE LOWER(sc.code) = "allegro"
AND (
pcm.external_product_id = ' . $itemAlias . '.external_item_id
OR pcm.external_product_id = ' . $itemAlias . '.source_product_id
)
ORDER BY pi.is_main DESC, pi.sort_order ASC, pi.id ASC
LIMIT 1
),
""
)';
}
private function canResolveMappedMedia(): bool
{
if ($this->supportsMappedMedia !== null) {
return $this->supportsMappedMedia;
}
try {
$requiredColumns = [
['table' => 'product_channel_map', 'column' => 'product_id'],
['table' => 'product_channel_map', 'column' => 'channel_id'],
['table' => 'product_channel_map', 'column' => 'external_product_id'],
['table' => 'sales_channels', 'column' => 'id'],
['table' => 'sales_channels', 'column' => 'code'],
['table' => 'product_images', 'column' => 'id'],
['table' => 'product_images', 'column' => 'product_id'],
['table' => 'product_images', 'column' => 'storage_path'],
['table' => 'product_images', 'column' => 'sort_order'],
['table' => 'product_images', 'column' => 'is_main'],
];
$pairsSql = [];
$params = [];
foreach ($requiredColumns as $index => $required) {
$tableParam = ':table_' . $index;
$columnParam = ':column_' . $index;
$pairsSql[] = '(TABLE_NAME = ' . $tableParam . ' AND COLUMN_NAME = ' . $columnParam . ')';
$params['table_' . $index] = $required['table'];
$params['column_' . $index] = $required['column'];
}
$sql = 'SELECT COUNT(*) AS cnt
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND (' . implode(' OR ', $pairsSql) . ')';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$count = (int) $stmt->fetchColumn();
$this->supportsMappedMedia = ($count === count($requiredColumns));
} catch (Throwable) {
$this->supportsMappedMedia = false;
}
return $this->supportsMappedMedia;
}
private function normalizeColorHex(string $value): string
{
$trimmed = trim($value);

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use RuntimeException;
final class AllegroApiClient
{
/**
* @return array<string, mixed>
*/
public function getCheckoutForm(string $environment, string $accessToken, string $checkoutFormId): array
{
$safeId = rawurlencode(trim($checkoutFormId));
if ($safeId === '') {
throw new RuntimeException('Brak ID zamowienia Allegro do pobrania.');
}
$url = rtrim($this->apiBaseUrl($environment), '/') . '/order/checkout-forms/' . $safeId;
return $this->requestJson($url, $accessToken);
}
/**
* @return array<string, mixed>
*/
public function listCheckoutForms(string $environment, string $accessToken, int $limit, int $offset): array
{
$safeLimit = max(1, min(100, $limit));
$safeOffset = max(0, $offset);
$query = http_build_query([
'limit' => $safeLimit,
'offset' => $safeOffset,
'sort' => '-updatedAt',
]);
$url = rtrim($this->apiBaseUrl($environment), '/') . '/order/checkout-forms?' . $query;
return $this->requestJson($url, $accessToken);
}
/**
* @return array<string, mixed>
*/
public function getProductOffer(string $environment, string $accessToken, string $offerId): array
{
$safeId = rawurlencode(trim($offerId));
if ($safeId === '') {
throw new RuntimeException('Brak ID oferty Allegro do pobrania.');
}
$url = rtrim($this->apiBaseUrl($environment), '/') . '/sale/product-offers/' . $safeId;
return $this->requestJson($url, $accessToken);
}
private function apiBaseUrl(string $environment): string
{
return trim(strtolower($environment)) === 'production'
? 'https://api.allegro.pl'
: 'https://api.allegro.pl.allegrosandbox.pl';
}
/**
* @return array<string, mixed>
*/
private function requestJson(string $url, string $accessToken): array
{
$ch = curl_init($url);
if ($ch === false) {
throw new RuntimeException('Nie udalo sie zainicjowac polaczenia z API Allegro.');
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPGET => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Accept: application/vnd.allegro.public.v1+json',
'Authorization: Bearer ' . $accessToken,
],
]);
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
$ch = null;
if ($responseBody === false) {
throw new RuntimeException('Blad polaczenia z API Allegro: ' . $curlError);
}
$json = json_decode((string) $responseBody, true);
if (!is_array($json)) {
throw new RuntimeException('Nieprawidlowy JSON odpowiedzi API Allegro.');
}
if ($httpCode === 401) {
throw new RuntimeException('ALLEGRO_HTTP_401');
}
if ($httpCode < 200 || $httpCode >= 300) {
$message = trim((string) ($json['message'] ?? 'Blad API Allegro.'));
throw new RuntimeException('API Allegro HTTP ' . $httpCode . ': ' . $message);
}
return $json;
}
}

View File

@@ -0,0 +1,703 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Cron\CronRepository;
use DateInterval;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
final class AllegroIntegrationController
{
private const OAUTH_STATE_SESSION_KEY = 'allegro_oauth_state';
private const ORDERS_IMPORT_JOB_TYPE = 'allegro_orders_import';
private const STATUS_SYNC_JOB_TYPE = 'allegro_status_sync';
private const ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS = 300;
private const ORDERS_IMPORT_DEFAULT_PRIORITY = 20;
private const ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS = 3;
private const STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO = 'allegro_to_orderpro';
private const STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO = 'orderpro_to_allegro';
private const STATUS_SYNC_DEFAULT_INTERVAL_MINUTES = 15;
private const ORDERS_IMPORT_DEFAULT_PAYLOAD = [
'max_pages' => 5,
'page_limit' => 50,
'max_orders' => 200,
];
private const OAUTH_SCOPES = [
AllegroOAuthClient::ORDERS_READ_SCOPE,
AllegroOAuthClient::SALE_OFFERS_READ_SCOPE,
];
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly AllegroIntegrationRepository $repository,
private readonly AllegroStatusMappingRepository $statusMappings,
private readonly OrderStatusRepository $orderStatuses,
private readonly CronRepository $cronRepository,
private readonly AllegroOAuthClient $oauthClient,
private readonly AllegroOrderImportService $orderImportService,
private readonly AllegroStatusDiscoveryService $statusDiscoveryService,
private readonly string $appUrl
) {
}
public function index(Request $request): Response
{
$settings = $this->repository->getSettings();
$tab = trim((string) $request->input('tab', 'integration'));
if (!in_array($tab, ['integration', 'statuses', 'settings'], true)) {
$tab = 'integration';
}
$defaultRedirectUri = $this->defaultRedirectUri();
if (trim((string) ($settings['redirect_uri'] ?? '')) === '') {
$settings['redirect_uri'] = $defaultRedirectUri;
}
$importIntervalSeconds = $this->currentImportIntervalSeconds();
$statusSyncDirection = $this->currentStatusSyncDirection();
$statusSyncIntervalMinutes = $this->currentStatusSyncIntervalMinutes();
$html = $this->template->render('settings/allegro', [
'title' => $this->translator->get('settings.allegro.title'),
'activeMenu' => 'settings',
'activeSettings' => 'allegro',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'settings' => $settings,
'activeTab' => $tab,
'importIntervalSeconds' => $importIntervalSeconds,
'statusSyncDirection' => $statusSyncDirection,
'statusSyncIntervalMinutes' => $statusSyncIntervalMinutes,
'statusMappings' => $this->statusMappings->listMappings(),
'orderproStatuses' => $this->orderStatuses->listStatuses(),
'defaultRedirectUri' => $defaultRedirectUri,
'errorMessage' => (string) Flash::get('settings_error', ''),
'successMessage' => (string) Flash::get('settings_success', ''),
'warningMessage' => (string) Flash::get('settings_warning', ''),
], 'layouts/app');
return Response::html($html);
}
public function save(Request $request): Response
{
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
if ($csrfError !== null) {
return $csrfError;
}
$environment = trim((string) $request->input('environment', 'sandbox'));
if (!in_array($environment, ['sandbox', 'production'], true)) {
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.environment_invalid'));
return Response::redirect('/settings/integrations/allegro');
}
$clientId = trim((string) $request->input('client_id', ''));
if ($clientId !== '' && mb_strlen($clientId) > 128) {
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.client_id_too_long'));
return Response::redirect('/settings/integrations/allegro');
}
$redirectUriInput = trim((string) $request->input('redirect_uri', ''));
$redirectUri = $redirectUriInput !== '' ? $redirectUriInput : $this->defaultRedirectUri();
if (!$this->isValidHttpUrl($redirectUri)) {
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.redirect_uri_invalid'));
return Response::redirect('/settings/integrations/allegro');
}
$ordersFetchStartDate = trim((string) $request->input('orders_fetch_start_date', ''));
if ($ordersFetchStartDate !== '' && !$this->isValidDate($ordersFetchStartDate)) {
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.orders_fetch_start_date_invalid'));
return Response::redirect('/settings/integrations/allegro');
}
try {
$this->repository->saveSettings([
'environment' => $environment,
'client_id' => $clientId,
'client_secret' => trim((string) $request->input('client_secret', '')),
'redirect_uri' => $redirectUri,
'orders_fetch_enabled' => (string) $request->input('orders_fetch_enabled', '0') === '1',
'orders_fetch_start_date' => $ordersFetchStartDate,
]);
Flash::set('settings_success', $this->translator->get('settings.allegro.flash.saved'));
} catch (Throwable $exception) {
Flash::set(
'settings_error',
$this->translator->get('settings.allegro.flash.save_failed') . ' ' . $exception->getMessage()
);
}
return Response::redirect('/settings/integrations/allegro');
}
public function saveImportSettings(Request $request): Response
{
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
if ($csrfError !== null) {
return $csrfError;
}
$intervalMinutesRaw = (int) $request->input('orders_import_interval_minutes', 5);
$intervalMinutes = max(1, min(1440, $intervalMinutesRaw));
if ($intervalMinutesRaw !== $intervalMinutes) {
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.orders_import_interval_invalid'));
return Response::redirect('/settings/integrations/allegro?tab=settings');
}
$statusSyncDirection = trim((string) $request->input(
'status_sync_direction',
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO
));
if (!in_array($statusSyncDirection, $this->allowedStatusSyncDirections(), true)) {
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.status_sync_direction_invalid'));
return Response::redirect('/settings/integrations/allegro?tab=settings');
}
$statusSyncIntervalRaw = (int) $request->input(
'status_sync_interval_minutes',
self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES
);
$statusSyncInterval = max(1, min(1440, $statusSyncIntervalRaw));
if ($statusSyncIntervalRaw !== $statusSyncInterval) {
Flash::set('settings_error', $this->translator->get('settings.allegro.validation.status_sync_interval_invalid'));
return Response::redirect('/settings/integrations/allegro?tab=settings');
}
$existing = $this->findImportSchedule();
$priority = (int) ($existing['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY);
$maxAttempts = (int) ($existing['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS);
$payload = is_array($existing['payload'] ?? null)
? (array) $existing['payload']
: self::ORDERS_IMPORT_DEFAULT_PAYLOAD;
$enabled = array_key_exists('enabled', $existing)
? (bool) $existing['enabled']
: true;
$statusSchedule = $this->findStatusSyncSchedule();
$statusPriority = (int) ($statusSchedule['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY);
$statusMaxAttempts = (int) ($statusSchedule['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS);
$statusEnabled = array_key_exists('enabled', $statusSchedule)
? (bool) $statusSchedule['enabled']
: true;
try {
$this->cronRepository->upsertSchedule(
self::ORDERS_IMPORT_JOB_TYPE,
$intervalMinutes * 60,
$priority,
$maxAttempts,
$payload,
$enabled
);
$this->cronRepository->upsertSchedule(
self::STATUS_SYNC_JOB_TYPE,
$statusSyncInterval * 60,
$statusPriority,
$statusMaxAttempts,
null,
$statusEnabled
);
$this->cronRepository->upsertSetting('allegro_status_sync_direction', $statusSyncDirection);
$this->cronRepository->upsertSetting('allegro_status_sync_interval_minutes', (string) $statusSyncInterval);
Flash::set('settings_success', $this->translator->get('settings.allegro.flash.import_settings_saved'));
} catch (Throwable $exception) {
Flash::set(
'settings_error',
$this->translator->get('settings.allegro.flash.import_settings_save_failed') . ' ' . $exception->getMessage()
);
}
return Response::redirect('/settings/integrations/allegro?tab=settings');
}
public function saveStatusMapping(Request $request): Response
{
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
if ($csrfError !== null) {
return $csrfError;
}
$allegroStatusCode = strtolower(trim((string) $request->input('allegro_status_code', '')));
$orderproStatusCode = strtolower(trim((string) $request->input('orderpro_status_code', '')));
$allegroStatusName = trim((string) $request->input('allegro_status_name', ''));
if ($allegroStatusCode === '') {
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.allegro_status_required'));
return Response::redirect('/settings/integrations/allegro?tab=statuses');
}
if ($orderproStatusCode === '') {
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.orderpro_status_required'));
return Response::redirect('/settings/integrations/allegro?tab=statuses');
}
if (!$this->orderStatusCodeExists($orderproStatusCode)) {
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.orderpro_status_not_found'));
return Response::redirect('/settings/integrations/allegro?tab=statuses');
}
try {
$this->statusMappings->upsertMapping($allegroStatusCode, $allegroStatusName !== '' ? $allegroStatusName : null, $orderproStatusCode);
Flash::set('settings_success', $this->translator->get('settings.allegro.statuses.flash.saved'));
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.save_failed') . ' ' . $exception->getMessage());
}
return Response::redirect('/settings/integrations/allegro?tab=statuses');
}
public function saveStatusMappingsBulk(Request $request): Response
{
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
if ($csrfError !== null) {
return $csrfError;
}
$codes = $request->input('allegro_status_code', []);
$names = $request->input('allegro_status_name', []);
$selectedOrderproCodes = $request->input('orderpro_status_code', []);
if (!is_array($codes) || !is_array($names) || !is_array($selectedOrderproCodes)) {
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.save_failed'));
return Response::redirect('/settings/integrations/allegro?tab=statuses');
}
try {
foreach ($codes as $index => $rawCode) {
$allegroStatusCode = strtolower(trim((string) $rawCode));
if ($allegroStatusCode === '') {
continue;
}
$allegroStatusName = trim((string) ($names[$index] ?? ''));
$orderproStatusCodeRaw = strtolower(trim((string) ($selectedOrderproCodes[$index] ?? '')));
$orderproStatusCode = $orderproStatusCodeRaw !== '' ? $orderproStatusCodeRaw : null;
if ($orderproStatusCode !== null && !$this->orderStatusCodeExists($orderproStatusCode)) {
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.orderpro_status_not_found'));
return Response::redirect('/settings/integrations/allegro?tab=statuses');
}
$this->statusMappings->upsertMapping(
$allegroStatusCode,
$allegroStatusName !== '' ? $allegroStatusName : null,
$orderproStatusCode
);
}
Flash::set('settings_success', $this->translator->get('settings.allegro.statuses.flash.saved_bulk'));
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.save_failed') . ' ' . $exception->getMessage());
}
return Response::redirect('/settings/integrations/allegro?tab=statuses');
}
public function deleteStatusMapping(Request $request): Response
{
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
if ($csrfError !== null) {
return $csrfError;
}
$mappingId = max(0, (int) $request->input('mapping_id', 0));
if ($mappingId <= 0) {
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.mapping_not_found'));
return Response::redirect('/settings/integrations/allegro?tab=statuses');
}
try {
$this->statusMappings->deleteMappingById($mappingId);
Flash::set('settings_success', $this->translator->get('settings.allegro.statuses.flash.deleted'));
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.delete_failed') . ' ' . $exception->getMessage());
}
return Response::redirect('/settings/integrations/allegro?tab=statuses');
}
public function syncStatusesFromAllegro(Request $request): Response
{
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
if ($csrfError !== null) {
return $csrfError;
}
try {
$result = $this->statusDiscoveryService->discoverAndStoreStatuses(5, 100);
Flash::set(
'settings_success',
$this->translator->get('settings.allegro.statuses.flash.sync_ok', [
'discovered' => (string) ((int) ($result['discovered'] ?? 0)),
'samples' => (string) ((int) ($result['samples'] ?? 0)),
])
);
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.allegro.statuses.flash.sync_failed') . ' ' . $exception->getMessage());
}
return Response::redirect('/settings/integrations/allegro?tab=statuses');
}
public function startOAuth(Request $request): Response
{
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
if ($csrfError !== null) {
return $csrfError;
}
try {
$credentials = $this->requireOAuthCredentials();
$state = bin2hex(random_bytes(24));
$_SESSION[self::OAUTH_STATE_SESSION_KEY] = $state;
$url = $this->oauthClient->buildAuthorizeUrl(
(string) $credentials['environment'],
(string) $credentials['client_id'],
(string) $credentials['redirect_uri'],
$state,
self::OAUTH_SCOPES
);
return Response::redirect($url);
} catch (Throwable $exception) {
Flash::set('settings_error', $exception->getMessage());
return Response::redirect('/settings/integrations/allegro');
}
}
public function oauthCallback(Request $request): Response
{
$error = trim((string) $request->input('error', ''));
if ($error !== '') {
$description = trim((string) $request->input('error_description', ''));
$message = $this->translator->get('settings.allegro.flash.oauth_failed');
if ($description !== '') {
$message .= ' ' . $description;
}
Flash::set('settings_error', $message);
return Response::redirect('/settings/integrations/allegro');
}
$state = trim((string) $request->input('state', ''));
$expectedState = trim((string) ($_SESSION[self::OAUTH_STATE_SESSION_KEY] ?? ''));
unset($_SESSION[self::OAUTH_STATE_SESSION_KEY]);
if ($state === '' || $expectedState === '' || !hash_equals($expectedState, $state)) {
Flash::set('settings_error', $this->translator->get('settings.allegro.flash.oauth_state_invalid'));
return Response::redirect('/settings/integrations/allegro');
}
$authorizationCode = trim((string) $request->input('code', ''));
if ($authorizationCode === '') {
Flash::set('settings_error', $this->translator->get('settings.allegro.flash.oauth_code_missing'));
return Response::redirect('/settings/integrations/allegro');
}
try {
$credentials = $this->requireOAuthCredentials();
$token = $this->oauthClient->exchangeAuthorizationCode(
(string) $credentials['environment'],
(string) $credentials['client_id'],
(string) $credentials['client_secret'],
(string) $credentials['redirect_uri'],
$authorizationCode
);
$expiresAt = null;
if ((int) ($token['expires_in'] ?? 0) > 0) {
$expiresAt = (new DateTimeImmutable('now'))
->add(new DateInterval('PT' . (int) $token['expires_in'] . 'S'))
->format('Y-m-d H:i:s');
}
$this->repository->saveTokens(
(string) ($token['access_token'] ?? ''),
(string) ($token['refresh_token'] ?? ''),
(string) ($token['token_type'] ?? ''),
(string) ($token['scope'] ?? ''),
$expiresAt
);
Flash::set('settings_success', $this->translator->get('settings.allegro.flash.oauth_connected'));
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.allegro.flash.oauth_failed') . ' ' . $exception->getMessage());
}
return Response::redirect('/settings/integrations/allegro');
}
public function importSingleOrder(Request $request): Response
{
$csrfError = $this->validateCsrf((string) $request->input('_token', ''));
if ($csrfError !== null) {
return $csrfError;
}
$checkoutFormId = trim((string) $request->input('checkout_form_id', ''));
if ($checkoutFormId === '') {
Flash::set('settings_error', $this->translator->get('settings.allegro.flash.checkout_form_id_required'));
return Response::redirect('/settings/integrations/allegro');
}
try {
$result = $this->orderImportService->importSingleOrder($checkoutFormId);
$imageDiagnostics = is_array($result['image_diagnostics'] ?? null) ? $result['image_diagnostics'] : [];
Flash::set(
'settings_success',
$this->translator->get('settings.allegro.flash.import_single_ok', [
'source_order_id' => (string) ($result['source_order_id'] ?? $checkoutFormId),
'local_id' => (string) ((int) ($result['order_id'] ?? 0)),
'action' => !empty($result['created'])
? $this->translator->get('settings.allegro.import_action.created')
: $this->translator->get('settings.allegro.import_action.updated'),
]) . ' '
. $this->translator->get('settings.allegro.flash.import_single_media_summary', [
'with_image' => (string) ((int) ($imageDiagnostics['with_image'] ?? 0)),
'total_items' => (string) ((int) ($imageDiagnostics['total_items'] ?? 0)),
'without_image' => (string) ((int) ($imageDiagnostics['without_image'] ?? 0)),
])
);
$warningDetails = $this->buildImportImageWarningMessage($imageDiagnostics);
if ($warningDetails !== '') {
Flash::set('settings_warning', $warningDetails);
}
} catch (Throwable $exception) {
Flash::set(
'settings_error',
$this->translator->get('settings.allegro.flash.import_single_failed') . ' ' . $exception->getMessage()
);
}
return Response::redirect('/settings/integrations/allegro');
}
private function defaultRedirectUri(): string
{
$base = trim($this->appUrl);
if ($base === '') {
$base = 'http://localhost:8000';
}
return rtrim($base, '/') . '/settings/integrations/allegro/oauth/callback';
}
private function validateCsrf(string $token): ?Response
{
if (Csrf::validate($token)) {
return null;
}
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/settings/integrations/allegro');
}
/**
* @return array<string, string>
*/
private function requireOAuthCredentials(): array
{
$credentials = $this->repository->getOAuthCredentials();
if ($credentials === null) {
throw new RuntimeException($this->translator->get('settings.allegro.flash.credentials_missing'));
}
return $credentials;
}
private function isValidHttpUrl(string $url): bool
{
$trimmed = trim($url);
if ($trimmed === '') {
return false;
}
if (filter_var($trimmed, FILTER_VALIDATE_URL) === false) {
return false;
}
$scheme = strtolower((string) parse_url($trimmed, PHP_URL_SCHEME));
return $scheme === 'http' || $scheme === 'https';
}
private function isValidDate(string $value): bool
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
return false;
}
$date = DateTimeImmutable::createFromFormat('Y-m-d', $value);
return $date instanceof DateTimeImmutable && $date->format('Y-m-d') === $value;
}
private function orderStatusCodeExists(string $code): bool
{
$needle = strtolower(trim($code));
if ($needle === '') {
return false;
}
foreach ($this->orderStatuses->listStatuses() as $row) {
$statusCode = strtolower(trim((string) ($row['code'] ?? '')));
if ($statusCode === $needle) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $imageDiagnostics
*/
private function buildImportImageWarningMessage(array $imageDiagnostics): string
{
$withoutImage = (int) ($imageDiagnostics['without_image'] ?? 0);
if ($withoutImage <= 0) {
return '';
}
$reasonCountsRaw = $imageDiagnostics['reason_counts'] ?? [];
if (!is_array($reasonCountsRaw) || $reasonCountsRaw === []) {
return $this->translator->get('settings.allegro.flash.import_single_media_warning_generic', [
'without_image' => (string) $withoutImage,
]);
}
$parts = [];
foreach ($reasonCountsRaw as $reason => $countRaw) {
$count = (int) $countRaw;
if ($count <= 0) {
continue;
}
$parts[] = $this->reasonLabel((string) $reason) . ': ' . $count;
}
if ($parts === []) {
return $this->translator->get('settings.allegro.flash.import_single_media_warning_generic', [
'without_image' => (string) $withoutImage,
]);
}
return $this->translator->get('settings.allegro.flash.import_single_media_warning', [
'without_image' => (string) $withoutImage,
'reasons' => implode(', ', $parts),
]);
}
private function reasonLabel(string $reasonCode): string
{
return match ($reasonCode) {
'missing_offer_id' => 'brak ID oferty',
'missing_in_checkout_form' => 'brak obrazka w checkout form',
'missing_in_offer_api' => 'brak obrazka w API oferty',
'offer_api_access_denied_403' => 'brak uprawnien API ofert (403)',
'offer_api_unauthorized_401' => 'token nieautoryzowany dla API ofert (401)',
'offer_api_not_found_404' => 'oferta nie znaleziona (404)',
'offer_api_request_failed' => 'blad zapytania do API oferty',
default => str_starts_with($reasonCode, 'offer_api_http_')
? 'blad API oferty (' . str_replace('offer_api_http_', '', $reasonCode) . ')'
: $reasonCode,
};
}
private function currentImportIntervalSeconds(): int
{
$schedule = $this->findImportSchedule();
$value = (int) ($schedule['interval_seconds'] ?? self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS);
return max(60, min(86400, $value));
}
/**
* @return array<string, mixed>
*/
private function findImportSchedule(): array
{
try {
$schedules = $this->cronRepository->listSchedules();
} catch (Throwable) {
return [];
}
foreach ($schedules as $schedule) {
if (!is_array($schedule)) {
continue;
}
if ((string) ($schedule['job_type'] ?? '') !== self::ORDERS_IMPORT_JOB_TYPE) {
continue;
}
return $schedule;
}
return [];
}
/**
* @return array<string, mixed>
*/
private function findStatusSyncSchedule(): array
{
try {
$schedules = $this->cronRepository->listSchedules();
} catch (Throwable) {
return [];
}
foreach ($schedules as $schedule) {
if (!is_array($schedule)) {
continue;
}
if ((string) ($schedule['job_type'] ?? '') !== self::STATUS_SYNC_JOB_TYPE) {
continue;
}
return $schedule;
}
return [];
}
private function currentStatusSyncDirection(): string
{
$value = trim($this->cronRepository->getStringSetting(
'allegro_status_sync_direction',
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO
));
if (!in_array($value, $this->allowedStatusSyncDirections(), true)) {
return self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO;
}
return $value;
}
private function currentStatusSyncIntervalMinutes(): int
{
return $this->cronRepository->getIntSetting(
'allegro_status_sync_interval_minutes',
self::STATUS_SYNC_DEFAULT_INTERVAL_MINUTES,
1,
1440
);
}
/**
* @return array<int, string>
*/
private function allowedStatusSyncDirections(): array
{
return [
self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO,
self::STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO,
];
}
}

View File

@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
use RuntimeException;
use Throwable;
final class AllegroIntegrationRepository
{
public function __construct(
private readonly PDO $pdo,
private readonly string $secret
) {
}
/**
* @return array<string, mixed>
*/
public function getSettings(): array
{
$row = $this->fetchRow();
if ($row === null) {
return $this->defaultSettings();
}
return [
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
'client_id' => trim((string) ($row['client_id'] ?? '')),
'has_client_secret' => trim((string) ($row['client_secret_encrypted'] ?? '')) !== '',
'redirect_uri' => trim((string) ($row['redirect_uri'] ?? '')),
'orders_fetch_enabled' => (int) ($row['orders_fetch_enabled'] ?? 0) === 1,
'orders_fetch_start_date' => $this->normalizeDateOrNull((string) ($row['orders_fetch_start_date'] ?? '')),
'is_connected' => trim((string) ($row['refresh_token_encrypted'] ?? '')) !== '',
'token_expires_at' => trim((string) ($row['token_expires_at'] ?? '')),
'connected_at' => trim((string) ($row['connected_at'] ?? '')),
];
}
/**
* @param array<string, mixed> $payload
*/
public function saveSettings(array $payload): void
{
$this->ensureRow();
$current = $this->fetchRow();
if ($current === null) {
throw new RuntimeException('Brak rekordu konfiguracji Allegro.');
}
$clientSecret = trim((string) ($payload['client_secret'] ?? ''));
$clientSecretEncrypted = trim((string) ($current['client_secret_encrypted'] ?? ''));
if ($clientSecret !== '') {
$clientSecretEncrypted = (string) $this->encrypt($clientSecret);
}
$statement = $this->pdo->prepare(
'UPDATE allegro_integration_settings
SET environment = :environment,
client_id = :client_id,
client_secret_encrypted = :client_secret_encrypted,
redirect_uri = :redirect_uri,
orders_fetch_enabled = :orders_fetch_enabled,
orders_fetch_start_date = :orders_fetch_start_date,
updated_at = NOW()
WHERE id = 1'
);
$statement->execute([
'environment' => $this->normalizeEnvironment((string) ($payload['environment'] ?? 'sandbox')),
'client_id' => $this->nullableString((string) ($payload['client_id'] ?? '')),
'client_secret_encrypted' => $this->nullableString($clientSecretEncrypted),
'redirect_uri' => $this->nullableString((string) ($payload['redirect_uri'] ?? '')),
'orders_fetch_enabled' => ((bool) ($payload['orders_fetch_enabled'] ?? false)) ? 1 : 0,
'orders_fetch_start_date' => $this->nullableString((string) ($payload['orders_fetch_start_date'] ?? '')),
]);
}
/**
* @return array<string, string>|null
*/
public function getOAuthCredentials(): ?array
{
$row = $this->fetchRow();
if ($row === null) {
return null;
}
$clientId = trim((string) ($row['client_id'] ?? ''));
$clientSecret = $this->decrypt((string) ($row['client_secret_encrypted'] ?? ''));
$redirectUri = trim((string) ($row['redirect_uri'] ?? ''));
if ($clientId === '' || $clientSecret === '' || $redirectUri === '') {
return null;
}
return [
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
'client_id' => $clientId,
'client_secret' => $clientSecret,
'redirect_uri' => $redirectUri,
];
}
public function saveTokens(
string $accessToken,
string $refreshToken,
string $tokenType,
string $scope,
?string $tokenExpiresAt
): void {
$this->ensureRow();
$statement = $this->pdo->prepare(
'UPDATE allegro_integration_settings
SET access_token_encrypted = :access_token_encrypted,
refresh_token_encrypted = :refresh_token_encrypted,
token_type = :token_type,
token_scope = :token_scope,
token_expires_at = :token_expires_at,
connected_at = NOW(),
updated_at = NOW()
WHERE id = 1'
);
$statement->execute([
'access_token_encrypted' => $this->encrypt($accessToken),
'refresh_token_encrypted' => $this->encrypt($refreshToken),
'token_type' => $this->nullableString($tokenType),
'token_scope' => $this->nullableString($scope),
'token_expires_at' => $this->nullableString((string) $tokenExpiresAt),
]);
}
/**
* @return array<string, string>|null
*/
public function getRefreshTokenCredentials(): ?array
{
$row = $this->fetchRow();
if ($row === null) {
return null;
}
$clientId = trim((string) ($row['client_id'] ?? ''));
$clientSecret = $this->decrypt((string) ($row['client_secret_encrypted'] ?? ''));
$refreshToken = $this->decrypt((string) ($row['refresh_token_encrypted'] ?? ''));
if ($clientId === '' || $clientSecret === '' || $refreshToken === '') {
return null;
}
return [
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
'client_id' => $clientId,
'client_secret' => $clientSecret,
'refresh_token' => $refreshToken,
];
}
/**
* @return array<string, string>|null
*/
public function getTokenCredentials(): ?array
{
$row = $this->fetchRow();
if ($row === null) {
return null;
}
$clientId = trim((string) ($row['client_id'] ?? ''));
$clientSecret = $this->decrypt((string) ($row['client_secret_encrypted'] ?? ''));
$refreshToken = $this->decrypt((string) ($row['refresh_token_encrypted'] ?? ''));
$accessToken = $this->decrypt((string) ($row['access_token_encrypted'] ?? ''));
if ($clientId === '' || $clientSecret === '' || $refreshToken === '') {
return null;
}
return [
'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? 'sandbox')),
'client_id' => $clientId,
'client_secret' => $clientSecret,
'refresh_token' => $refreshToken,
'access_token' => $accessToken,
'token_expires_at' => trim((string) ($row['token_expires_at'] ?? '')),
];
}
private function ensureRow(): void
{
$statement = $this->pdo->prepare(
'INSERT INTO allegro_integration_settings (
id, environment, orders_fetch_enabled, created_at, updated_at
) VALUES (
1, :environment, 0, NOW(), NOW()
)
ON DUPLICATE KEY UPDATE
updated_at = VALUES(updated_at)'
);
$statement->execute([
'environment' => 'sandbox',
]);
}
/**
* @return array<string, mixed>|null
*/
private function fetchRow(): ?array
{
try {
$statement = $this->pdo->prepare('SELECT * FROM allegro_integration_settings WHERE id = 1 LIMIT 1');
$statement->execute();
$row = $statement->fetch(PDO::FETCH_ASSOC);
} catch (Throwable) {
return null;
}
return is_array($row) ? $row : null;
}
/**
* @return array<string, mixed>
*/
private function defaultSettings(): array
{
return [
'environment' => 'sandbox',
'client_id' => '',
'has_client_secret' => false,
'redirect_uri' => '',
'orders_fetch_enabled' => false,
'orders_fetch_start_date' => null,
'is_connected' => false,
'token_expires_at' => '',
'connected_at' => '',
];
}
private function normalizeEnvironment(string $environment): string
{
$value = trim(strtolower($environment));
if ($value === 'production') {
return 'production';
}
return 'sandbox';
}
private function normalizeDateOrNull(string $value): ?string
{
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $trimmed) !== 1) {
return null;
}
return $trimmed;
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function encrypt(string $plainText): ?string
{
$value = trim($plainText);
if ($value === '') {
return null;
}
if ($this->secret === '') {
throw new RuntimeException('Brak INTEGRATIONS_SECRET do szyfrowania danych integracji.');
}
$encryptionKey = hash('sha256', 'enc|' . $this->secret, true);
$hmacKey = hash('sha256', 'auth|' . $this->secret, true);
$iv = random_bytes(16);
$cipherRaw = openssl_encrypt($value, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv);
if ($cipherRaw === false) {
throw new RuntimeException('Nie udalo sie zaszyfrowac danych integracji.');
}
$mac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true);
return 'v1:' . base64_encode($iv . $mac . $cipherRaw);
}
private function decrypt(string $encryptedValue): string
{
$payload = trim($encryptedValue);
if ($payload === '') {
return '';
}
if ($this->secret === '') {
throw new RuntimeException('Brak INTEGRATIONS_SECRET do odszyfrowania danych integracji.');
}
if (!str_starts_with($payload, 'v1:')) {
return '';
}
$raw = base64_decode(substr($payload, 3), true);
if ($raw === false || strlen($raw) <= 48) {
return '';
}
$iv = substr($raw, 0, 16);
$mac = substr($raw, 16, 32);
$cipherRaw = substr($raw, 48);
$hmacKey = hash('sha256', 'auth|' . $this->secret, true);
$expectedMac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true);
if (!hash_equals($expectedMac, $mac)) {
return '';
}
$encryptionKey = hash('sha256', 'enc|' . $this->secret, true);
$plain = openssl_decrypt($cipherRaw, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv);
return is_string($plain) ? $plain : '';
}
}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use RuntimeException;
final class AllegroOAuthClient
{
public const ORDERS_READ_SCOPE = 'allegro:api:orders:read';
public const SALE_OFFERS_READ_SCOPE = 'allegro:api:sale:offers:read';
/**
* @param array<int, string> $scopes
*/
public function buildAuthorizeUrl(
string $environment,
string $clientId,
string $redirectUri,
string $state,
array $scopes
): string {
$scopeValue = trim(implode(' ', array_values(array_filter(
$scopes,
static fn (mixed $scope): bool => is_string($scope) && trim($scope) !== ''
))));
$query = http_build_query([
'response_type' => 'code',
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'scope' => $scopeValue,
'state' => $state,
]);
return $this->authorizeBaseUrl($environment) . '?' . $query;
}
/**
* @return array{access_token:string, refresh_token:string, token_type:string, scope:string, expires_in:int}
*/
public function exchangeAuthorizationCode(
string $environment,
string $clientId,
string $clientSecret,
string $redirectUri,
string $authorizationCode
): array {
$payload = $this->requestToken(
$this->tokenUrl($environment),
$clientId,
$clientSecret,
[
'grant_type' => 'authorization_code',
'code' => $authorizationCode,
'redirect_uri' => $redirectUri,
]
);
$accessToken = trim((string) ($payload['access_token'] ?? ''));
$refreshToken = trim((string) ($payload['refresh_token'] ?? ''));
if ($accessToken === '' || $refreshToken === '') {
throw new RuntimeException('Allegro nie zwrocilo kompletu tokenow OAuth.');
}
return [
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'token_type' => trim((string) ($payload['token_type'] ?? 'Bearer')),
'scope' => trim((string) ($payload['scope'] ?? '')),
'expires_in' => max(0, (int) ($payload['expires_in'] ?? 0)),
];
}
/**
* @return array{access_token:string, refresh_token:string, token_type:string, scope:string, expires_in:int}
*/
public function refreshAccessToken(
string $environment,
string $clientId,
string $clientSecret,
string $refreshToken
): array {
$payload = $this->requestToken(
$this->tokenUrl($environment),
$clientId,
$clientSecret,
[
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
]
);
$accessToken = trim((string) ($payload['access_token'] ?? ''));
if ($accessToken === '') {
throw new RuntimeException('Allegro nie zwrocilo access_token po odswiezeniu.');
}
return [
'access_token' => $accessToken,
'refresh_token' => trim((string) ($payload['refresh_token'] ?? '')),
'token_type' => trim((string) ($payload['token_type'] ?? 'Bearer')),
'scope' => trim((string) ($payload['scope'] ?? '')),
'expires_in' => max(0, (int) ($payload['expires_in'] ?? 0)),
];
}
private function authorizeBaseUrl(string $environment): string
{
return $this->normalizeEnvironment($environment) === 'production'
? 'https://allegro.pl/auth/oauth/authorize'
: 'https://allegro.pl.allegrosandbox.pl/auth/oauth/authorize';
}
private function tokenUrl(string $environment): string
{
return $this->normalizeEnvironment($environment) === 'production'
? 'https://allegro.pl/auth/oauth/token'
: 'https://allegro.pl.allegrosandbox.pl/auth/oauth/token';
}
private function normalizeEnvironment(string $environment): string
{
return trim(strtolower($environment)) === 'production' ? 'production' : 'sandbox';
}
/**
* @param array<string, string> $formData
* @return array<string, mixed>
*/
private function requestToken(
string $url,
string $clientId,
string $clientSecret,
array $formData
): array {
$ch = curl_init($url);
if ($ch === false) {
throw new RuntimeException('Nie udalo sie zainicjowac polaczenia OAuth z Allegro.');
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Content-Type: application/x-www-form-urlencoded',
'Authorization: Basic ' . base64_encode($clientId . ':' . $clientSecret),
],
CURLOPT_POSTFIELDS => http_build_query($formData),
]);
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
$ch = null;
if ($responseBody === false) {
throw new RuntimeException('Blad polaczenia OAuth z Allegro: ' . $curlError);
}
$json = json_decode((string) $responseBody, true);
if (!is_array($json)) {
throw new RuntimeException('Nieprawidlowy JSON odpowiedzi OAuth Allegro.');
}
if ($httpCode < 200 || $httpCode >= 300) {
$error = trim((string) ($json['error'] ?? 'oauth_error'));
$description = trim((string) ($json['error_description'] ?? 'Brak szczegolow bledu OAuth.'));
throw new RuntimeException('OAuth Allegro [' . $error . ']: ' . $description);
}
return $json;
}
}

View File

@@ -0,0 +1,801 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Modules\Orders\OrderImportRepository;
use DateInterval;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
final class AllegroOrderImportService
{
public function __construct(
private readonly AllegroIntegrationRepository $integrationRepository,
private readonly AllegroOAuthClient $oauthClient,
private readonly AllegroApiClient $apiClient,
private readonly OrderImportRepository $orders,
private readonly AllegroStatusMappingRepository $statusMappings
) {
}
/**
* @return array<string, mixed>
*/
public function importSingleOrder(string $checkoutFormId): array
{
$orderId = trim($checkoutFormId);
if ($orderId === '') {
throw new RuntimeException('Podaj ID zamowienia Allegro.');
}
$oauth = $this->requireOAuthData();
[$accessToken, $oauth] = $this->resolveAccessToken($oauth);
try {
$payload = $this->apiClient->getCheckoutForm(
(string) ($oauth['environment'] ?? 'sandbox'),
$accessToken,
$orderId
);
} catch (RuntimeException $exception) {
if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
throw $exception;
}
[$accessToken, $oauth] = $this->forceRefreshToken($oauth);
$payload = $this->apiClient->getCheckoutForm(
(string) ($oauth['environment'] ?? 'sandbox'),
$accessToken,
$orderId
);
}
$mapped = $this->mapCheckoutFormPayload(
$payload,
(string) ($oauth['environment'] ?? 'sandbox'),
$accessToken
);
$saveResult = $this->orders->upsertOrderAggregate(
$mapped['order'],
$mapped['addresses'],
$mapped['items'],
$mapped['payments'],
$mapped['shipments'],
$mapped['notes'],
$mapped['status_history']
);
return [
'order_id' => (int) ($saveResult['order_id'] ?? 0),
'created' => !empty($saveResult['created']),
'source_order_id' => (string) ($mapped['order']['source_order_id'] ?? ''),
'image_diagnostics' => (array) ($mapped['image_diagnostics'] ?? []),
];
}
/**
* @return array<string, string>
*/
private function requireOAuthData(): array
{
$oauth = $this->integrationRepository->getTokenCredentials();
if ($oauth === null) {
throw new RuntimeException('Brak kompletnych danych OAuth Allegro. Polacz konto ponownie.');
}
return $oauth;
}
/**
* @param array<string, string> $oauth
* @return array{0:string, 1:array<string, string>}
*/
private function resolveAccessToken(array $oauth): array
{
$tokenExpiresAt = trim((string) ($oauth['token_expires_at'] ?? ''));
$accessToken = trim((string) ($oauth['access_token'] ?? ''));
if ($accessToken === '') {
return $this->forceRefreshToken($oauth);
}
if ($tokenExpiresAt === '') {
return [$accessToken, $oauth];
}
try {
$expiresAt = new DateTimeImmutable($tokenExpiresAt);
} catch (Throwable) {
return $this->forceRefreshToken($oauth);
}
if ($expiresAt <= (new DateTimeImmutable('now'))->add(new DateInterval('PT5M'))) {
return $this->forceRefreshToken($oauth);
}
return [$accessToken, $oauth];
}
/**
* @param array<string, string> $oauth
* @return array{0:string, 1:array<string, string>}
*/
private function forceRefreshToken(array $oauth): array
{
$token = $this->oauthClient->refreshAccessToken(
(string) ($oauth['environment'] ?? 'sandbox'),
(string) ($oauth['client_id'] ?? ''),
(string) ($oauth['client_secret'] ?? ''),
(string) ($oauth['refresh_token'] ?? '')
);
$expiresAt = null;
$expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
if ($expiresIn > 0) {
$expiresAt = (new DateTimeImmutable('now'))
->add(new DateInterval('PT' . $expiresIn . 'S'))
->format('Y-m-d H:i:s');
}
$refreshToken = trim((string) ($token['refresh_token'] ?? ''));
if ($refreshToken === '') {
$refreshToken = (string) ($oauth['refresh_token'] ?? '');
}
$this->integrationRepository->saveTokens(
(string) ($token['access_token'] ?? ''),
$refreshToken,
(string) ($token['token_type'] ?? ''),
(string) ($token['scope'] ?? ''),
$expiresAt
);
$updatedOauth = $this->requireOAuthData();
$newAccessToken = trim((string) ($updatedOauth['access_token'] ?? ''));
if ($newAccessToken === '') {
throw new RuntimeException('Nie udalo sie zapisac odswiezonego tokenu Allegro.');
}
return [$newAccessToken, $updatedOauth];
}
/**
* @param array<string, mixed> $payload
* @return array{
* order:array<string, mixed>,
* addresses:array<int, array<string, mixed>>,
* items:array<int, array<string, mixed>>,
* image_diagnostics:array<string, mixed>,
* payments:array<int, array<string, mixed>>,
* shipments:array<int, array<string, mixed>>,
* notes:array<int, array<string, mixed>>,
* status_history:array<int, array<string, mixed>>
* }
*/
private function mapCheckoutFormPayload(array $payload, string $environment, string $accessToken): array
{
$checkoutFormId = trim((string) ($payload['id'] ?? ''));
if ($checkoutFormId === '') {
throw new RuntimeException('Odpowiedz Allegro nie zawiera ID zamowienia.');
}
$status = trim((string) ($payload['status'] ?? ''));
$fulfillmentStatus = trim((string) ($payload['fulfillment']['status'] ?? ''));
$rawAllegroStatus = strtolower($fulfillmentStatus !== '' ? $fulfillmentStatus : $status);
$mappedOrderproStatus = $this->statusMappings->findMappedOrderproStatusCode($rawAllegroStatus);
$externalStatus = $mappedOrderproStatus !== null ? $mappedOrderproStatus : $rawAllegroStatus;
$paymentStatusRaw = strtolower(trim((string) ($payload['payment']['status'] ?? '')));
$totalWithTax = $this->amountToFloat($payload['summary']['totalToPay'] ?? null);
$totalPaid = $this->amountToFloat($payload['summary']['paidAmount'] ?? null);
if ($totalPaid === null) {
$totalPaid = $this->amountToFloat($payload['payment']['paidAmount'] ?? null);
}
if ($totalPaid === null) {
$totalPaid = $this->amountToFloat($payload['payment']['amount'] ?? null);
}
$currency = trim((string) ($payload['summary']['totalToPay']['currency'] ?? ''));
if ($currency === '') {
$currency = trim((string) ($payload['payment']['amount']['currency'] ?? 'PLN'));
}
if ($currency === '') {
$currency = 'PLN';
}
$buyer = is_array($payload['buyer'] ?? null) ? $payload['buyer'] : [];
$delivery = is_array($payload['delivery'] ?? null) ? $payload['delivery'] : [];
$invoice = is_array($payload['invoice'] ?? null) ? $payload['invoice'] : [];
$payment = is_array($payload['payment'] ?? null) ? $payload['payment'] : [];
$lineItems = is_array($payload['lineItems'] ?? null) ? $payload['lineItems'] : [];
$deliveryMethod = is_array($delivery['method'] ?? null) ? $delivery['method'] : [];
$deliveryMethodId = trim((string) ($deliveryMethod['id'] ?? ''));
$deliveryMethodName = trim((string) ($deliveryMethod['name'] ?? ''));
$deliveryForm = $deliveryMethodName !== '' ? $deliveryMethodName : $deliveryMethodId;
$deliveryTime = is_array($delivery['time'] ?? null) ? $delivery['time'] : [];
$dispatchTime = is_array($deliveryTime['dispatch'] ?? null) ? $deliveryTime['dispatch'] : [];
$sendDateMin = $this->normalizeDateTime((string) ($dispatchTime['from'] ?? ''));
$sendDateMax = $this->normalizeDateTime((string) ($dispatchTime['to'] ?? ''));
if ($sendDateMin === null) {
$sendDateMin = $this->normalizeDateTime((string) ($deliveryTime['from'] ?? ''));
}
if ($sendDateMax === null) {
$sendDateMax = $this->normalizeDateTime((string) ($deliveryTime['to'] ?? ''));
}
$boughtAt = $this->normalizeDateTime((string) ($payload['boughtAt'] ?? ''));
$updatedAt = $this->normalizeDateTime((string) ($payload['updatedAt'] ?? ''));
$fetchedAt = date('Y-m-d H:i:s');
$order = [
'integration_id' => null,
'source' => 'allegro',
'source_order_id' => $checkoutFormId,
'external_order_id' => $checkoutFormId,
'external_platform_id' => trim((string) ($payload['marketplace']['id'] ?? 'allegro-pl')),
'external_platform_account_id' => null,
'external_status_id' => $externalStatus,
'external_payment_type_id' => trim((string) ($payment['type'] ?? '')),
'payment_status' => $this->mapPaymentStatus($paymentStatusRaw),
'external_carrier_id' => $deliveryForm !== '' ? $deliveryForm : null,
'external_carrier_account_id' => $deliveryMethodId !== '' ? $deliveryMethodId : null,
'customer_login' => trim((string) ($buyer['login'] ?? '')),
'is_invoice' => !empty($invoice['required']),
'is_encrypted' => false,
'is_canceled_by_buyer' => in_array($externalStatus, ['cancelled', 'canceled'], true),
'currency' => strtoupper($currency),
'total_without_tax' => null,
'total_with_tax' => $totalWithTax,
'total_paid' => $totalPaid,
'send_date_min' => $sendDateMin,
'send_date_max' => $sendDateMax,
'ordered_at' => $boughtAt,
'source_created_at' => $boughtAt,
'source_updated_at' => $updatedAt,
'preferences_json' => [
'status' => $status,
'fulfillment_status' => $fulfillmentStatus,
'allegro_status_raw' => $rawAllegroStatus,
'payment_status' => $paymentStatusRaw,
'delivery_method_name' => $deliveryMethodName,
'delivery_method_id' => $deliveryMethodId,
'delivery_cost' => $delivery['cost'] ?? null,
'delivery_time' => $deliveryTime,
],
'payload_json' => $payload,
'fetched_at' => $fetchedAt,
];
$addresses = $this->buildAddresses($buyer, $delivery, $invoice);
$itemsResult = $this->buildItems($lineItems, $environment, $accessToken);
$items = (array) ($itemsResult['items'] ?? []);
$payments = $this->buildPayments($payment, $currency);
$shipments = $this->buildShipments($payload, $delivery);
$notes = $this->buildNotes($payload);
$statusHistory = [[
'from_status_id' => null,
'to_status_id' => $externalStatus !== '' ? $externalStatus : 'unknown',
'changed_at' => $updatedAt !== null ? $updatedAt : $fetchedAt,
'change_source' => 'import',
'comment' => 'Import z Allegro checkout form',
'payload_json' => [
'status' => $status,
'fulfillment_status' => $fulfillmentStatus,
'allegro_status_raw' => $rawAllegroStatus,
],
]];
return [
'order' => $order,
'addresses' => $addresses,
'items' => $items,
'image_diagnostics' => (array) ($itemsResult['image_diagnostics'] ?? []),
'payments' => $payments,
'shipments' => $shipments,
'notes' => $notes,
'status_history' => $statusHistory,
];
}
/**
* @param array<string, mixed> $buyer
* @param array<string, mixed> $delivery
* @param array<string, mixed> $invoice
* @return array<int, array<string, mixed>>
*/
private function buildAddresses(array $buyer, array $delivery, array $invoice): array
{
$result = [];
$customerName = trim((string) (($buyer['firstName'] ?? '') . ' ' . ($buyer['lastName'] ?? '')));
if ($customerName === '') {
$customerName = trim((string) ($buyer['login'] ?? ''));
}
if ($customerName === '') {
$customerName = 'Kupujacy Allegro';
}
$result[] = [
'address_type' => 'customer',
'name' => $customerName,
'phone' => $this->nullableString((string) ($buyer['phoneNumber'] ?? '')),
'email' => $this->nullableString((string) ($buyer['email'] ?? '')),
'street_name' => null,
'street_number' => null,
'city' => null,
'zip_code' => null,
'country' => null,
'department' => null,
'parcel_external_id' => null,
'parcel_name' => null,
'address_class' => null,
'company_tax_number' => null,
'company_name' => null,
'payload_json' => $buyer,
];
$deliveryAddress = is_array($delivery['address'] ?? null) ? $delivery['address'] : [];
$pickupPoint = is_array($delivery['pickupPoint'] ?? null) ? $delivery['pickupPoint'] : [];
$pickupAddress = is_array($pickupPoint['address'] ?? null) ? $pickupPoint['address'] : [];
if ($deliveryAddress !== [] || $pickupAddress !== []) {
$isPickupPointDelivery = $pickupAddress !== [];
$name = $isPickupPointDelivery
? $this->nullableString((string) ($pickupPoint['name'] ?? ''))
: $this->fallbackName($deliveryAddress, 'Dostawa');
if ($name === null) {
$name = 'Dostawa';
}
$street = $isPickupPointDelivery
? $this->nullableString((string) ($pickupAddress['street'] ?? ''))
: $this->nullableString((string) ($deliveryAddress['street'] ?? ''));
$city = $isPickupPointDelivery
? $this->nullableString((string) ($pickupAddress['city'] ?? ''))
: $this->nullableString((string) ($deliveryAddress['city'] ?? ''));
$zipCode = $isPickupPointDelivery
? $this->nullableString((string) ($pickupAddress['zipCode'] ?? ''))
: $this->nullableString((string) ($deliveryAddress['zipCode'] ?? ''));
$country = $isPickupPointDelivery
? $this->nullableString((string) ($pickupAddress['countryCode'] ?? ''))
: $this->nullableString((string) ($deliveryAddress['countryCode'] ?? ''));
$result[] = [
'address_type' => 'delivery',
'name' => $name,
'phone' => $this->nullableString((string) ($deliveryAddress['phoneNumber'] ?? '')),
'email' => $this->nullableString((string) ($deliveryAddress['email'] ?? $buyer['email'] ?? '')),
'street_name' => $street,
'street_number' => null,
'city' => $city,
'zip_code' => $zipCode,
'country' => $country,
'department' => null,
'parcel_external_id' => $this->nullableString((string) ($pickupPoint['id'] ?? '')),
'parcel_name' => $this->nullableString((string) ($pickupPoint['name'] ?? '')),
'address_class' => null,
'company_tax_number' => null,
'company_name' => $this->nullableString((string) ($deliveryAddress['companyName'] ?? '')),
'payload_json' => [
'address' => $deliveryAddress,
'pickup_point' => $pickupPoint,
],
];
}
$invoiceAddress = is_array($invoice['address'] ?? null) ? $invoice['address'] : [];
if ($invoiceAddress !== []) {
$result[] = [
'address_type' => 'invoice',
'name' => $this->fallbackName($invoiceAddress, 'Faktura'),
'phone' => $this->nullableString((string) ($invoiceAddress['phoneNumber'] ?? '')),
'email' => $this->nullableString((string) ($invoiceAddress['email'] ?? '')),
'street_name' => $this->nullableString((string) ($invoiceAddress['street'] ?? '')),
'street_number' => null,
'city' => $this->nullableString((string) ($invoiceAddress['city'] ?? '')),
'zip_code' => $this->nullableString((string) ($invoiceAddress['zipCode'] ?? '')),
'country' => $this->nullableString((string) ($invoiceAddress['countryCode'] ?? '')),
'department' => null,
'parcel_external_id' => null,
'parcel_name' => null,
'address_class' => null,
'company_tax_number' => $this->nullableString((string) ($invoiceAddress['taxId'] ?? '')),
'company_name' => $this->nullableString((string) ($invoiceAddress['companyName'] ?? '')),
'payload_json' => $invoiceAddress,
];
}
return $result;
}
/**
* @param array<int, mixed> $lineItems
* @return array{
* items:array<int, array<string, mixed>>,
* image_diagnostics:array<string, mixed>
* }
*/
private function buildItems(array $lineItems, string $environment, string $accessToken): array
{
$result = [];
$offerImageCache = [];
$diagnostics = [
'total_items' => 0,
'with_image' => 0,
'without_image' => 0,
'source_counts' => [
'checkout_form' => 0,
'offer_api' => 0,
],
'reason_counts' => [],
'sample_issues' => [],
];
$sortOrder = 0;
foreach ($lineItems as $itemRaw) {
if (!is_array($itemRaw)) {
continue;
}
$diagnostics['total_items'] = (int) $diagnostics['total_items'] + 1;
$offer = is_array($itemRaw['offer'] ?? null) ? $itemRaw['offer'] : [];
$name = trim((string) ($offer['name'] ?? ''));
if ($name === '') {
$name = 'Pozycja Allegro';
}
$offerId = trim((string) ($offer['id'] ?? ''));
$mediaUrl = $this->extractLineItemImageUrl($itemRaw);
$imageSource = 'none';
$missingReason = null;
if ($mediaUrl === null && $offerId !== '') {
$offerImageResult = $this->resolveOfferImageUrlFromApi($offerId, $environment, $accessToken, $offerImageCache);
$mediaUrl = $offerImageResult['url'];
if ($mediaUrl !== null) {
$imageSource = 'offer_api';
} else {
$missingReason = $offerImageResult['reason'];
}
} elseif ($mediaUrl === null) {
$missingReason = 'missing_offer_id';
} else {
$imageSource = 'checkout_form';
}
if ($mediaUrl !== null) {
$diagnostics['with_image'] = (int) $diagnostics['with_image'] + 1;
if ($imageSource === 'offer_api') {
$diagnostics['source_counts']['offer_api'] = (int) ($diagnostics['source_counts']['offer_api'] ?? 0) + 1;
} else {
$diagnostics['source_counts']['checkout_form'] = (int) ($diagnostics['source_counts']['checkout_form'] ?? 0) + 1;
}
} else {
$diagnostics['without_image'] = (int) $diagnostics['without_image'] + 1;
$reasonCode = $missingReason ?? 'missing_in_checkout_form';
$reasonCounts = is_array($diagnostics['reason_counts']) ? $diagnostics['reason_counts'] : [];
$reasonCounts[$reasonCode] = (int) ($reasonCounts[$reasonCode] ?? 0) + 1;
$diagnostics['reason_counts'] = $reasonCounts;
$sampleIssues = is_array($diagnostics['sample_issues']) ? $diagnostics['sample_issues'] : [];
if (count($sampleIssues) < 5) {
$sampleIssues[] = [
'offer_id' => $offerId,
'name' => $name,
'reason' => $reasonCode,
];
}
$diagnostics['sample_issues'] = $sampleIssues;
}
$result[] = [
'source_item_id' => $this->nullableString((string) ($itemRaw['id'] ?? '')),
'external_item_id' => $this->nullableString((string) ($offer['id'] ?? '')),
'ean' => null,
'sku' => null,
'original_name' => $name,
'original_code' => $this->nullableString((string) ($offer['id'] ?? '')),
'original_price_with_tax' => $this->amountToFloat($itemRaw['originalPrice'] ?? null),
'original_price_without_tax' => null,
'media_url' => $mediaUrl,
'quantity' => (float) ($itemRaw['quantity'] ?? 1),
'tax_rate' => null,
'item_status' => null,
'unit' => 'pcs',
'item_type' => 'product',
'source_product_id' => $this->nullableString((string) ($offer['id'] ?? '')),
'source_product_set_id' => null,
'sort_order' => $sortOrder++,
'payload_json' => $itemRaw,
];
}
return [
'items' => $result,
'image_diagnostics' => $diagnostics,
];
}
/**
* @param array<string, array{url:?string, reason:?string}> $offerImageCache
* @return array{url:?string, reason:?string}
*/
private function resolveOfferImageUrlFromApi(
string $offerId,
string $environment,
string $accessToken,
array &$offerImageCache
): array {
if (array_key_exists($offerId, $offerImageCache)) {
return $offerImageCache[$offerId];
}
try {
$offerPayload = $this->apiClient->getProductOffer($environment, $accessToken, $offerId);
$url = $this->extractOfferImageUrl($offerPayload);
if ($url !== null) {
$offerImageCache[$offerId] = ['url' => $url, 'reason' => null];
return $offerImageCache[$offerId];
}
$offerImageCache[$offerId] = ['url' => null, 'reason' => 'missing_in_offer_api'];
} catch (Throwable $exception) {
$reason = $this->mapOfferApiErrorToReason($exception->getMessage());
$offerImageCache[$offerId] = ['url' => null, 'reason' => $reason];
}
return $offerImageCache[$offerId];
}
private function mapOfferApiErrorToReason(string $message): string
{
$normalized = strtoupper(trim($message));
if (str_contains($normalized, 'HTTP 403')) {
return 'offer_api_access_denied_403';
}
if (str_contains($normalized, 'HTTP 401')) {
return 'offer_api_unauthorized_401';
}
if (str_contains($normalized, 'HTTP 404')) {
return 'offer_api_not_found_404';
}
if (preg_match('/HTTP\s+(\d{3})/', $normalized, $matches) === 1) {
return 'offer_api_http_' . $matches[1];
}
return 'offer_api_request_failed';
}
/**
* @param array<string, mixed> $offerPayload
*/
private function extractOfferImageUrl(array $offerPayload): ?string
{
$candidates = [
(string) ($offerPayload['imageUrl'] ?? ''),
(string) ($offerPayload['image']['url'] ?? ''),
];
$images = $offerPayload['images'] ?? null;
if (is_array($images)) {
$firstImage = $images[0] ?? null;
if (is_array($firstImage)) {
$candidates[] = (string) ($firstImage['url'] ?? '');
} elseif (is_string($firstImage)) {
$candidates[] = $firstImage;
}
}
$productSet = $offerPayload['productSet'] ?? null;
if (is_array($productSet)) {
$firstSet = $productSet[0] ?? null;
if (is_array($firstSet)) {
$product = is_array($firstSet['product'] ?? null) ? $firstSet['product'] : [];
$productImage = is_array($product['images'] ?? null) ? ($product['images'][0] ?? null) : null;
if (is_array($productImage)) {
$candidates[] = (string) ($productImage['url'] ?? '');
} elseif (is_string($productImage)) {
$candidates[] = $productImage;
}
}
}
foreach ($candidates as $candidate) {
$url = trim($candidate);
if ($url === '') {
continue;
}
if (str_starts_with($url, '//')) {
return 'https:' . $url;
}
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
continue;
}
return $url;
}
return null;
}
/**
* @param array<string, mixed> $itemRaw
*/
private function extractLineItemImageUrl(array $itemRaw): ?string
{
$offer = is_array($itemRaw['offer'] ?? null) ? $itemRaw['offer'] : [];
$candidates = [
(string) ($itemRaw['imageUrl'] ?? ''),
(string) ($offer['imageUrl'] ?? ''),
(string) ($offer['image']['url'] ?? ''),
];
$images = $offer['images'] ?? null;
if (is_array($images)) {
$firstImage = $images[0] ?? null;
if (is_array($firstImage)) {
$candidates[] = (string) ($firstImage['url'] ?? '');
} elseif (is_string($firstImage)) {
$candidates[] = $firstImage;
}
}
foreach ($candidates as $candidate) {
$url = trim($candidate);
if ($url === '') {
continue;
}
if (str_starts_with($url, '//')) {
return 'https:' . $url;
}
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
continue;
}
return $url;
}
return null;
}
/**
* @param array<string, mixed> $payment
* @return array<int, array<string, mixed>>
*/
private function buildPayments(array $payment, string $fallbackCurrency): array
{
$paymentId = trim((string) ($payment['id'] ?? ''));
if ($paymentId === '') {
return [];
}
$amount = $this->amountToFloat($payment['paidAmount'] ?? null);
if ($amount === null) {
$amount = $this->amountToFloat($payment['amount'] ?? null);
}
return [[
'source_payment_id' => $paymentId,
'external_payment_id' => $paymentId,
'payment_type_id' => trim((string) ($payment['type'] ?? 'allegro')),
'payment_date' => $this->normalizeDateTime((string) ($payment['finishedAt'] ?? '')),
'amount' => $amount,
'currency' => $this->nullableString((string) ($payment['amount']['currency'] ?? $fallbackCurrency)),
'comment' => $this->nullableString((string) ($payment['provider'] ?? '')),
'payload_json' => $payment,
]];
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $delivery
* @return array<int, array<string, mixed>>
*/
private function buildShipments(array $payload, array $delivery): array
{
$shipments = is_array($payload['fulfillment']['shipments'] ?? null)
? $payload['fulfillment']['shipments']
: [];
$result = [];
foreach ($shipments as $shipmentRaw) {
if (!is_array($shipmentRaw)) {
continue;
}
$trackingNumber = trim((string) ($shipmentRaw['waybill'] ?? $shipmentRaw['trackingNumber'] ?? ''));
if ($trackingNumber === '') {
continue;
}
$carrierId = trim((string) ($shipmentRaw['carrierId'] ?? $delivery['method']['id'] ?? 'allegro'));
$result[] = [
'source_shipment_id' => $this->nullableString((string) ($shipmentRaw['id'] ?? '')),
'external_shipment_id' => $this->nullableString((string) ($shipmentRaw['id'] ?? '')),
'tracking_number' => $trackingNumber,
'carrier_provider_id' => $carrierId !== '' ? $carrierId : 'allegro',
'posted_at' => $this->normalizeDateTime((string) ($shipmentRaw['createdAt'] ?? $payload['updatedAt'] ?? '')),
'media_uuid' => null,
'payload_json' => $shipmentRaw,
];
}
return $result;
}
/**
* @param array<string, mixed> $payload
* @return array<int, array<string, mixed>>
*/
private function buildNotes(array $payload): array
{
$message = trim((string) ($payload['messageToSeller'] ?? ''));
if ($message === '') {
return [];
}
return [[
'source_note_id' => null,
'note_type' => 'buyer_message',
'created_at_external' => $this->normalizeDateTime((string) ($payload['updatedAt'] ?? '')),
'comment' => $message,
'payload_json' => ['messageToSeller' => $message],
]];
}
private function mapPaymentStatus(string $status): ?int
{
return match ($status) {
'paid', 'finished', 'completed' => 2,
'partially_paid', 'in_progress' => 1,
'cancelled', 'canceled', 'failed', 'unpaid' => 0,
default => null,
};
}
private function amountToFloat(mixed $amountNode): ?float
{
if (!is_array($amountNode)) {
return null;
}
$value = trim((string) ($amountNode['amount'] ?? ''));
if ($value === '' || !is_numeric($value)) {
return null;
}
return (float) $value;
}
private function normalizeDateTime(string $value): ?string
{
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
try {
return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s');
} catch (Throwable) {
return null;
}
}
/**
* @param array<string, mixed> $address
*/
private function fallbackName(array $address, string $fallback): string
{
$name = trim((string) (($address['firstName'] ?? '') . ' ' . ($address['lastName'] ?? '')));
if ($name !== '') {
return $name;
}
$company = trim((string) ($address['companyName'] ?? ''));
if ($company !== '') {
return $company;
}
return $fallback;
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}

View File

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

View File

@@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use DateInterval;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
final class AllegroOrdersSyncService
{
private const ALLEGRO_INTEGRATION_ID = 1;
public function __construct(
private readonly AllegroIntegrationRepository $integrationRepository,
private readonly AllegroOrderSyncStateRepository $syncStateRepository,
private readonly AllegroOAuthClient $oauthClient,
private readonly AllegroApiClient $apiClient,
private readonly AllegroOrderImportService $orderImportService
) {
}
/**
* @param array<string, mixed> $options
* @return array<string, mixed>
*/
public function sync(array $options = []): array
{
$settings = $this->integrationRepository->getSettings();
if (empty($settings['orders_fetch_enabled'])) {
return [
'enabled' => false,
'processed' => 0,
'imported_created' => 0,
'imported_updated' => 0,
'failed' => 0,
'skipped' => 0,
'cursor_before' => null,
'cursor_after' => null,
'errors' => [],
];
}
$now = new DateTimeImmutable('now');
$state = $this->syncStateRepository->getState(self::ALLEGRO_INTEGRATION_ID);
$this->syncStateRepository->markRunStarted(self::ALLEGRO_INTEGRATION_ID, $now);
$maxPages = max(1, min(20, (int) ($options['max_pages'] ?? 5)));
$pageLimit = max(1, min(100, (int) ($options['page_limit'] ?? 50)));
$maxOrders = max(1, min(1000, (int) ($options['max_orders'] ?? 200)));
$startDateRaw = trim((string) ($settings['orders_fetch_start_date'] ?? ''));
$startDate = $this->normalizeStartDate($startDateRaw);
$cursorUpdatedAt = $this->nullableString((string) ($state['last_synced_updated_at'] ?? ''));
$cursorSourceOrderId = $this->nullableString((string) ($state['last_synced_source_order_id'] ?? ''));
$result = [
'enabled' => true,
'processed' => 0,
'imported_created' => 0,
'imported_updated' => 0,
'failed' => 0,
'skipped' => 0,
'cursor_before' => $cursorUpdatedAt,
'cursor_after' => $cursorUpdatedAt,
'errors' => [],
];
$latestProcessedUpdatedAt = $cursorUpdatedAt;
$latestProcessedSourceOrderId = $cursorSourceOrderId;
try {
$oauth = $this->requireOAuthData();
[$accessToken, $oauth] = $this->resolveAccessToken($oauth);
$offset = 0;
$shouldStop = false;
for ($page = 0; $page < $maxPages; $page++) {
try {
$response = $this->apiClient->listCheckoutForms(
(string) ($oauth['environment'] ?? 'sandbox'),
$accessToken,
$pageLimit,
$offset
);
} catch (RuntimeException $exception) {
if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
throw $exception;
}
[$accessToken, $oauth] = $this->forceRefreshToken($oauth);
$response = $this->apiClient->listCheckoutForms(
(string) ($oauth['environment'] ?? 'sandbox'),
$accessToken,
$pageLimit,
$offset
);
}
$forms = is_array($response['checkoutForms'] ?? null) ? $response['checkoutForms'] : [];
if ($forms === []) {
break;
}
foreach ($forms as $form) {
if (!is_array($form)) {
continue;
}
$sourceOrderId = trim((string) ($form['id'] ?? ''));
$sourceUpdatedAt = $this->normalizeDateTime((string) ($form['updatedAt'] ?? $form['boughtAt'] ?? ''));
if ($sourceOrderId === '' || $sourceUpdatedAt === null) {
$result['skipped'] = (int) $result['skipped'] + 1;
continue;
}
if ($startDate !== null && $sourceUpdatedAt < $startDate) {
$shouldStop = true;
break;
}
if (!$this->isAfterCursor($sourceUpdatedAt, $sourceOrderId, $cursorUpdatedAt, $cursorSourceOrderId)) {
$shouldStop = true;
break;
}
if (((int) $result['processed']) >= $maxOrders) {
$shouldStop = true;
break;
}
$result['processed'] = (int) $result['processed'] + 1;
try {
$importResult = $this->orderImportService->importSingleOrder($sourceOrderId);
if (!empty($importResult['created'])) {
$result['imported_created'] = (int) $result['imported_created'] + 1;
} else {
$result['imported_updated'] = (int) $result['imported_updated'] + 1;
}
} catch (Throwable $exception) {
$result['failed'] = (int) $result['failed'] + 1;
$errors = is_array($result['errors']) ? $result['errors'] : [];
if (count($errors) < 20) {
$errors[] = [
'source_order_id' => $sourceOrderId,
'error' => $exception->getMessage(),
];
}
$result['errors'] = $errors;
}
if ($this->isAfterCursor(
$sourceUpdatedAt,
$sourceOrderId,
$latestProcessedUpdatedAt,
$latestProcessedSourceOrderId
)) {
$latestProcessedUpdatedAt = $sourceUpdatedAt;
$latestProcessedSourceOrderId = $sourceOrderId;
}
}
if ($shouldStop || count($forms) < $pageLimit) {
break;
}
$offset += $pageLimit;
}
$this->syncStateRepository->markRunSuccess(
self::ALLEGRO_INTEGRATION_ID,
new DateTimeImmutable('now'),
$latestProcessedUpdatedAt,
$latestProcessedSourceOrderId
);
$result['cursor_after'] = $latestProcessedUpdatedAt;
return $result;
} catch (Throwable $exception) {
$this->syncStateRepository->markRunFailed(
self::ALLEGRO_INTEGRATION_ID,
new DateTimeImmutable('now'),
$exception->getMessage()
);
throw $exception;
}
}
/**
* @return array<string, string>
*/
private function requireOAuthData(): array
{
$oauth = $this->integrationRepository->getTokenCredentials();
if ($oauth === null) {
throw new RuntimeException('Brak kompletnych danych OAuth Allegro. Polacz konto ponownie.');
}
return $oauth;
}
/**
* @param array<string, string> $oauth
* @return array{0:string, 1:array<string, string>}
*/
private function resolveAccessToken(array $oauth): array
{
$tokenExpiresAt = trim((string) ($oauth['token_expires_at'] ?? ''));
$accessToken = trim((string) ($oauth['access_token'] ?? ''));
if ($accessToken === '') {
return $this->forceRefreshToken($oauth);
}
if ($tokenExpiresAt === '') {
return [$accessToken, $oauth];
}
try {
$expiresAt = new DateTimeImmutable($tokenExpiresAt);
} catch (Throwable) {
return $this->forceRefreshToken($oauth);
}
if ($expiresAt <= (new DateTimeImmutable('now'))->add(new DateInterval('PT5M'))) {
return $this->forceRefreshToken($oauth);
}
return [$accessToken, $oauth];
}
/**
* @param array<string, string> $oauth
* @return array{0:string, 1:array<string, string>}
*/
private function forceRefreshToken(array $oauth): array
{
$token = $this->oauthClient->refreshAccessToken(
(string) ($oauth['environment'] ?? 'sandbox'),
(string) ($oauth['client_id'] ?? ''),
(string) ($oauth['client_secret'] ?? ''),
(string) ($oauth['refresh_token'] ?? '')
);
$expiresAt = null;
$expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
if ($expiresIn > 0) {
$expiresAt = (new DateTimeImmutable('now'))
->add(new DateInterval('PT' . $expiresIn . 'S'))
->format('Y-m-d H:i:s');
}
$refreshToken = trim((string) ($token['refresh_token'] ?? ''));
if ($refreshToken === '') {
$refreshToken = (string) ($oauth['refresh_token'] ?? '');
}
$this->integrationRepository->saveTokens(
(string) ($token['access_token'] ?? ''),
$refreshToken,
(string) ($token['token_type'] ?? ''),
(string) ($token['scope'] ?? ''),
$expiresAt
);
$updatedOauth = $this->requireOAuthData();
$newAccessToken = trim((string) ($updatedOauth['access_token'] ?? ''));
if ($newAccessToken === '') {
throw new RuntimeException('Nie udalo sie zapisac odswiezonego tokenu Allegro.');
}
return [$newAccessToken, $updatedOauth];
}
private function normalizeDateTime(string $value): ?string
{
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
try {
return (new DateTimeImmutable($trimmed))->format('Y-m-d H:i:s');
} catch (Throwable) {
return null;
}
}
private function normalizeStartDate(string $value): ?string
{
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $trimmed) !== 1) {
return null;
}
return $trimmed . ' 00:00:00';
}
private function isAfterCursor(
string $sourceUpdatedAt,
string $sourceOrderId,
?string $cursorUpdatedAt,
?string $cursorSourceOrderId
): bool {
if ($cursorUpdatedAt === null || $cursorUpdatedAt === '') {
return true;
}
if ($sourceUpdatedAt > $cursorUpdatedAt) {
return true;
}
if ($sourceUpdatedAt < $cursorUpdatedAt) {
return false;
}
if ($cursorSourceOrderId === null || $cursorSourceOrderId === '') {
return true;
}
return strcmp($sourceOrderId, $cursorSourceOrderId) > 0;
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use DateInterval;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
final class AllegroStatusDiscoveryService
{
public function __construct(
private readonly AllegroIntegrationRepository $integrationRepository,
private readonly AllegroOAuthClient $oauthClient,
private readonly AllegroApiClient $apiClient,
private readonly AllegroStatusMappingRepository $statusMappings
) {
}
/**
* @return array{discovered:int, samples:int}
*/
public function discoverAndStoreStatuses(int $maxPages = 3, int $pageLimit = 100): array
{
$oauth = $this->requireOAuthData();
[$accessToken, $oauth] = $this->resolveAccessToken($oauth);
$unique = [];
$safePages = max(1, min(10, $maxPages));
$safeLimit = max(1, min(100, $pageLimit));
$offset = 0;
$samples = 0;
for ($page = 0; $page < $safePages; $page++) {
try {
$response = $this->apiClient->listCheckoutForms(
(string) ($oauth['environment'] ?? 'sandbox'),
$accessToken,
$safeLimit,
$offset
);
} catch (RuntimeException $exception) {
if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') {
throw $exception;
}
[$accessToken, $oauth] = $this->forceRefreshToken($oauth);
$response = $this->apiClient->listCheckoutForms(
(string) ($oauth['environment'] ?? 'sandbox'),
$accessToken,
$safeLimit,
$offset
);
}
$forms = is_array($response['checkoutForms'] ?? null) ? $response['checkoutForms'] : [];
if ($forms === []) {
break;
}
foreach ($forms as $form) {
if (!is_array($form)) {
continue;
}
$rawStatus = strtolower(trim((string) ($form['fulfillment']['status'] ?? $form['status'] ?? '')));
if ($rawStatus === '') {
continue;
}
$samples++;
$unique[$rawStatus] = $this->prettifyStatusName($rawStatus);
}
if (count($forms) < $safeLimit) {
break;
}
$offset += $safeLimit;
}
foreach ($unique as $code => $name) {
$this->statusMappings->upsertDiscoveredStatus((string) $code, (string) $name);
}
return [
'discovered' => count($unique),
'samples' => $samples,
];
}
/**
* @return array<string, string>
*/
private function requireOAuthData(): array
{
$oauth = $this->integrationRepository->getTokenCredentials();
if ($oauth === null) {
throw new RuntimeException('Brak kompletnych danych OAuth Allegro. Polacz konto ponownie.');
}
return $oauth;
}
/**
* @param array<string, string> $oauth
* @return array{0:string, 1:array<string, string>}
*/
private function resolveAccessToken(array $oauth): array
{
$tokenExpiresAt = trim((string) ($oauth['token_expires_at'] ?? ''));
$accessToken = trim((string) ($oauth['access_token'] ?? ''));
if ($accessToken === '') {
return $this->forceRefreshToken($oauth);
}
if ($tokenExpiresAt === '') {
return [$accessToken, $oauth];
}
try {
$expiresAt = new DateTimeImmutable($tokenExpiresAt);
} catch (Throwable) {
return $this->forceRefreshToken($oauth);
}
if ($expiresAt <= (new DateTimeImmutable('now'))->add(new DateInterval('PT5M'))) {
return $this->forceRefreshToken($oauth);
}
return [$accessToken, $oauth];
}
/**
* @param array<string, string> $oauth
* @return array{0:string, 1:array<string, string>}
*/
private function forceRefreshToken(array $oauth): array
{
$token = $this->oauthClient->refreshAccessToken(
(string) ($oauth['environment'] ?? 'sandbox'),
(string) ($oauth['client_id'] ?? ''),
(string) ($oauth['client_secret'] ?? ''),
(string) ($oauth['refresh_token'] ?? '')
);
$expiresAt = null;
$expiresIn = max(0, (int) ($token['expires_in'] ?? 0));
if ($expiresIn > 0) {
$expiresAt = (new DateTimeImmutable('now'))
->add(new DateInterval('PT' . $expiresIn . 'S'))
->format('Y-m-d H:i:s');
}
$refreshToken = trim((string) ($token['refresh_token'] ?? ''));
if ($refreshToken === '') {
$refreshToken = (string) ($oauth['refresh_token'] ?? '');
}
$this->integrationRepository->saveTokens(
(string) ($token['access_token'] ?? ''),
$refreshToken,
(string) ($token['token_type'] ?? ''),
(string) ($token['scope'] ?? ''),
$expiresAt
);
$updatedOauth = $this->requireOAuthData();
$newAccessToken = trim((string) ($updatedOauth['access_token'] ?? ''));
if ($newAccessToken === '') {
throw new RuntimeException('Nie udalo sie zapisac odswiezonego tokenu Allegro.');
}
return [$newAccessToken, $updatedOauth];
}
private function prettifyStatusName(string $statusCode): string
{
$normalized = str_replace(['_', '-'], ' ', strtolower(trim($statusCode)));
return ucfirst($normalized);
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
final class AllegroStatusMappingRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array<string, mixed>>
*/
public function listMappings(): array
{
$statement = $this->pdo->query(
'SELECT id, allegro_status_code, allegro_status_name, orderpro_status_code, created_at, updated_at
FROM allegro_order_status_mappings
ORDER BY allegro_status_code ASC'
);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return [];
}
return array_map(static function (array $row): array {
return [
'id' => (int) ($row['id'] ?? 0),
'allegro_status_code' => strtolower(trim((string) ($row['allegro_status_code'] ?? ''))),
'allegro_status_name' => trim((string) ($row['allegro_status_name'] ?? '')),
'orderpro_status_code' => strtolower(trim((string) ($row['orderpro_status_code'] ?? ''))),
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}, $rows);
}
public function upsertMapping(string $allegroStatusCode, ?string $allegroStatusName, ?string $orderproStatusCode): void
{
$code = strtolower(trim($allegroStatusCode));
$orderproCode = $orderproStatusCode !== null ? strtolower(trim($orderproStatusCode)) : null;
if ($code === '') {
return;
}
$statement = $this->pdo->prepare(
'INSERT INTO allegro_order_status_mappings (
allegro_status_code, allegro_status_name, orderpro_status_code, created_at, updated_at
) VALUES (
:allegro_status_code, :allegro_status_name, :orderpro_status_code, NOW(), NOW()
)
ON DUPLICATE KEY UPDATE
allegro_status_name = VALUES(allegro_status_name),
orderpro_status_code = VALUES(orderpro_status_code),
updated_at = VALUES(updated_at)'
);
$statement->execute([
'allegro_status_code' => $code,
'allegro_status_name' => $this->nullableString((string) $allegroStatusName),
'orderpro_status_code' => $orderproCode !== null && $orderproCode !== '' ? $orderproCode : null,
]);
}
public function upsertDiscoveredStatus(string $allegroStatusCode, ?string $allegroStatusName): void
{
$code = strtolower(trim($allegroStatusCode));
if ($code === '') {
return;
}
$statement = $this->pdo->prepare(
'INSERT INTO allegro_order_status_mappings (
allegro_status_code, allegro_status_name, orderpro_status_code, created_at, updated_at
) VALUES (
:allegro_status_code, :allegro_status_name, NULL, NOW(), NOW()
)
ON DUPLICATE KEY UPDATE
allegro_status_name = CASE
WHEN VALUES(allegro_status_name) IS NULL OR VALUES(allegro_status_name) = "" THEN allegro_status_name
ELSE VALUES(allegro_status_name)
END,
updated_at = VALUES(updated_at)'
);
$statement->execute([
'allegro_status_code' => $code,
'allegro_status_name' => $this->nullableString((string) $allegroStatusName),
]);
}
public function deleteMappingById(int $id): void
{
if ($id <= 0) {
return;
}
$statement = $this->pdo->prepare('DELETE FROM allegro_order_status_mappings WHERE id = :id');
$statement->execute(['id' => $id]);
}
public function findMappedOrderproStatusCode(string $allegroStatusCode): ?string
{
$code = strtolower(trim($allegroStatusCode));
if ($code === '') {
return null;
}
$statement = $this->pdo->prepare(
'SELECT orderpro_status_code
FROM allegro_order_status_mappings
WHERE allegro_status_code = :allegro_status_code
LIMIT 1'
);
$statement->execute(['allegro_status_code' => $code]);
$value = $statement->fetchColumn();
if (!is_string($value)) {
return null;
}
$mapped = strtolower(trim($value));
return $mapped !== '' ? $mapped : null;
}
private function nullableString(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Modules\Cron\CronRepository;
final class AllegroStatusSyncService
{
private const DIRECTION_ALLEGRO_TO_ORDERPRO = 'allegro_to_orderpro';
private const DIRECTION_ORDERPRO_TO_ALLEGRO = 'orderpro_to_allegro';
public function __construct(
private readonly CronRepository $cronRepository,
private readonly AllegroOrdersSyncService $ordersSyncService
) {
}
/**
* @return array<string, mixed>
*/
public function sync(): array
{
$direction = trim($this->cronRepository->getStringSetting(
'allegro_status_sync_direction',
self::DIRECTION_ALLEGRO_TO_ORDERPRO
));
if (!in_array($direction, [self::DIRECTION_ALLEGRO_TO_ORDERPRO, self::DIRECTION_ORDERPRO_TO_ALLEGRO], true)) {
$direction = self::DIRECTION_ALLEGRO_TO_ORDERPRO;
}
if ($direction === self::DIRECTION_ORDERPRO_TO_ALLEGRO) {
return [
'ok' => true,
'direction' => $direction,
'processed' => 0,
'message' => 'Kierunek orderPRO -> Allegro nie jest jeszcze wdrozony.',
];
}
$ordersResult = $this->ordersSyncService->sync([
'max_pages' => 3,
'page_limit' => 50,
'max_orders' => 100,
]);
return [
'ok' => true,
'direction' => $direction,
'orders_sync' => $ordersResult,
];
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Cron\CronRepository;
use Throwable;
final class CronSettingsController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly CronRepository $cronRepository,
private readonly bool $runOnWebDefault,
private readonly int $webLimitDefault
) {
}
public function index(Request $request): Response
{
try {
$runOnWeb = $this->cronRepository->getBoolSetting('cron_run_on_web', $this->runOnWebDefault);
$webLimit = $this->cronRepository->getIntSetting('cron_web_limit', $this->webLimitDefault, 1, 100);
$schedules = $this->cronRepository->listSchedules();
$futureJobs = $this->cronRepository->listFutureJobs(60);
$pastJobs = $this->cronRepository->listPastJobs(60);
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.cron.flash.load_failed') . ' ' . $exception->getMessage());
$runOnWeb = $this->runOnWebDefault;
$webLimit = $this->webLimitDefault;
$schedules = [];
$futureJobs = [];
$pastJobs = [];
}
$html = $this->template->render('settings/cron', [
'title' => $this->translator->get('settings.cron.title'),
'activeMenu' => 'settings',
'activeSettings' => 'cron',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'runOnWeb' => $runOnWeb,
'webLimit' => $webLimit,
'schedules' => $schedules,
'futureJobs' => $futureJobs,
'pastJobs' => $pastJobs,
'errorMessage' => (string) Flash::get('settings_error', ''),
'successMessage' => (string) Flash::get('settings_success', ''),
], 'layouts/app');
return Response::html($html);
}
public function save(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/settings/cron');
}
$runOnWeb = (string) $request->input('cron_run_on_web', '0') === '1';
$webLimitRaw = (int) $request->input('cron_web_limit', $this->webLimitDefault);
$webLimit = max(1, min(100, $webLimitRaw));
try {
$this->cronRepository->upsertSetting('cron_run_on_web', $runOnWeb ? '1' : '0');
$this->cronRepository->upsertSetting('cron_web_limit', (string) $webLimit);
Flash::set('settings_success', $this->translator->get('settings.cron.flash.saved'));
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.cron.flash.save_failed') . ' ' . $exception->getMessage());
}
return Response::redirect('/settings/cron');
}
}