From 7ac4293df4a07694c306ccca0036143c283f708b Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Wed, 4 Mar 2026 23:21:35 +0100 Subject: [PATCH] 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. --- .vscode/ftp-kr.sync.cache.json | 4 +- AGENTS.md | 15 + DOCS/ARCHITECTURE.md | 121 +++ DOCS/DB_SCHEMA.md | 56 ++ DOCS/TECH_CHANGELOG.md | 113 +++ DOCS/todo.md | 8 + bin/cron.php | 73 +- ...ate_allegro_integration_settings_table.sql | 30 + ...024_add_allegro_token_refresh_schedule.sql | 74 ++ ...te_allegro_order_status_mappings_table.sql | 10 + ...6_make_allegro_status_mapping_nullable.sql | 2 + ...027_add_allegro_orders_import_schedule.sql | 39 + ...00028_add_allegro_status_sync_schedule.sql | 29 + public/assets/css/app.css | 2 +- resources/lang/pl.php | 136 +++ resources/scss/app.scss | 13 + resources/views/layouts/app.php | 6 + resources/views/settings/allegro.php | 309 +++++++ resources/views/settings/cron.php | 147 ++++ routes/web.php | 61 ++ src/Core/Application.php | 97 ++- .../Cron/AllegroOrdersImportHandler.php | 26 + src/Modules/Cron/AllegroStatusSyncHandler.php | 22 + .../Cron/AllegroTokenRefreshHandler.php | 64 ++ src/Modules/Cron/CronRepository.php | 448 ++++++++++ src/Modules/Cron/CronRunner.php | 99 +++ src/Modules/Orders/OrderImportRepository.php | 421 +++++++++ src/Modules/Orders/OrdersController.php | 44 +- src/Modules/Orders/OrdersRepository.php | 154 +++- src/Modules/Settings/AllegroApiClient.php | 108 +++ .../Settings/AllegroIntegrationController.php | 703 +++++++++++++++ .../Settings/AllegroIntegrationRepository.php | 320 +++++++ src/Modules/Settings/AllegroOAuthClient.php | 177 ++++ .../Settings/AllegroOrderImportService.php | 801 ++++++++++++++++++ .../AllegroOrderSyncStateRepository.php | 275 ++++++ .../Settings/AllegroOrdersSyncService.php | 333 ++++++++ .../AllegroStatusDiscoveryService.php | 180 ++++ .../AllegroStatusMappingRepository.php | 131 +++ .../Settings/AllegroStatusSyncService.php | 53 ++ .../Settings/CronSettingsController.php | 85 ++ 40 files changed, 5758 insertions(+), 31 deletions(-) create mode 100644 DOCS/todo.md create mode 100644 database/migrations/20260304_000023_create_allegro_integration_settings_table.sql create mode 100644 database/migrations/20260304_000024_add_allegro_token_refresh_schedule.sql create mode 100644 database/migrations/20260304_000025_create_allegro_order_status_mappings_table.sql create mode 100644 database/migrations/20260304_000026_make_allegro_status_mapping_nullable.sql create mode 100644 database/migrations/20260304_000027_add_allegro_orders_import_schedule.sql create mode 100644 database/migrations/20260304_000028_add_allegro_status_sync_schedule.sql create mode 100644 resources/views/settings/allegro.php create mode 100644 resources/views/settings/cron.php create mode 100644 src/Modules/Cron/AllegroOrdersImportHandler.php create mode 100644 src/Modules/Cron/AllegroStatusSyncHandler.php create mode 100644 src/Modules/Cron/AllegroTokenRefreshHandler.php create mode 100644 src/Modules/Cron/CronRepository.php create mode 100644 src/Modules/Cron/CronRunner.php create mode 100644 src/Modules/Orders/OrderImportRepository.php create mode 100644 src/Modules/Settings/AllegroApiClient.php create mode 100644 src/Modules/Settings/AllegroIntegrationController.php create mode 100644 src/Modules/Settings/AllegroIntegrationRepository.php create mode 100644 src/Modules/Settings/AllegroOAuthClient.php create mode 100644 src/Modules/Settings/AllegroOrderImportService.php create mode 100644 src/Modules/Settings/AllegroOrderSyncStateRepository.php create mode 100644 src/Modules/Settings/AllegroOrdersSyncService.php create mode 100644 src/Modules/Settings/AllegroStatusDiscoveryService.php create mode 100644 src/Modules/Settings/AllegroStatusMappingRepository.php create mode 100644 src/Modules/Settings/AllegroStatusSyncService.php create mode 100644 src/Modules/Settings/CronSettingsController.php diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json index 9ac54f1..bdcffa9 100644 --- a/.vscode/ftp-kr.sync.cache.json +++ b/.vscode/ftp-kr.sync.cache.json @@ -3,8 +3,8 @@ "public_html": { "AGENTS.md": { "type": "-", - "size": 2207, - "lmtime": 1772497458624, + "size": 2593, + "lmtime": 1772522685966, "modified": false }, "ARCHITECTURE.md": { diff --git a/AGENTS.md b/AGENTS.md index aef8058..45c4cdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 30–50 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 2–3 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`. diff --git a/DOCS/ARCHITECTURE.md b/DOCS/ARCHITECTURE.md index bb3cc60..6c789b3 100644 --- a/DOCS/ARCHITECTURE.md +++ b/DOCS/ARCHITECTURE.md @@ -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`: diff --git a/DOCS/DB_SCHEMA.md b/DOCS/DB_SCHEMA.md index 4d3aa11..d2ce5d5 100644 --- a/DOCS/DB_SCHEMA.md +++ b/DOCS/DB_SCHEMA.md @@ -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, diff --git a/DOCS/TECH_CHANGELOG.md b/DOCS/TECH_CHANGELOG.md index 1299d53..21afaab 100644 --- a/DOCS/TECH_CHANGELOG.md +++ b/DOCS/TECH_CHANGELOG.md @@ -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), diff --git a/DOCS/todo.md b/DOCS/todo.md new file mode 100644 index 0000000..b793b19 --- /dev/null +++ b/DOCS/todo.md @@ -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. \ No newline at end of file diff --git a/bin/cron.php b/bin/cron.php index da486b3..1d22779 100644 --- a/bin/cron.php +++ b/bin/cron.php @@ -1,5 +1,74 @@ 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); diff --git a/database/migrations/20260304_000023_create_allegro_integration_settings_table.sql b/database/migrations/20260304_000023_create_allegro_integration_settings_table.sql new file mode 100644 index 0000000..4646b56 --- /dev/null +++ b/database/migrations/20260304_000023_create_allegro_integration_settings_table.sql @@ -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); diff --git a/database/migrations/20260304_000024_add_allegro_token_refresh_schedule.sql b/database/migrations/20260304_000024_add_allegro_token_refresh_schedule.sql new file mode 100644 index 0000000..5b8bf85 --- /dev/null +++ b/database/migrations/20260304_000024_add_allegro_token_refresh_schedule.sql @@ -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); diff --git a/database/migrations/20260304_000025_create_allegro_order_status_mappings_table.sql b/database/migrations/20260304_000025_create_allegro_order_status_mappings_table.sql new file mode 100644 index 0000000..c824b31 --- /dev/null +++ b/database/migrations/20260304_000025_create_allegro_order_status_mappings_table.sql @@ -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; diff --git a/database/migrations/20260304_000026_make_allegro_status_mapping_nullable.sql b/database/migrations/20260304_000026_make_allegro_status_mapping_nullable.sql new file mode 100644 index 0000000..6ecb941 --- /dev/null +++ b/database/migrations/20260304_000026_make_allegro_status_mapping_nullable.sql @@ -0,0 +1,2 @@ +ALTER TABLE allegro_order_status_mappings + MODIFY COLUMN orderpro_status_code VARCHAR(64) NULL; diff --git a/database/migrations/20260304_000027_add_allegro_orders_import_schedule.sql b/database/migrations/20260304_000027_add_allegro_orders_import_schedule.sql new file mode 100644 index 0000000..75f2cc3 --- /dev/null +++ b/database/migrations/20260304_000027_add_allegro_orders_import_schedule.sql @@ -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); diff --git a/database/migrations/20260304_000028_add_allegro_status_sync_schedule.sql b/database/migrations/20260304_000028_add_allegro_status_sync_schedule.sql new file mode 100644 index 0000000..e9995e5 --- /dev/null +++ b/database/migrations/20260304_000028_add_allegro_status_sync_schedule.sql @@ -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); diff --git a/public/assets/css/app.css b/public/assets/css/app.css index c50a238..8a6efb8 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -1 +1 @@ -:root{--c-primary: #6690f4;--c-primary-dark: #3164db;--c-bg: #f4f6f9;--c-surface: #ffffff;--c-text: #4e5e6a;--c-text-strong: #2d3748;--c-muted: #718096;--c-border: #e2e8f0;--c-danger: #cc0000;--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06)}.btn{display:inline-flex;align-items:center;justify-content:center;min-height:34px;padding:6px 12px;border:1px solid rgba(0,0,0,0);border-radius:8px;font:inherit;font-weight:600;text-decoration:none;cursor:pointer;transition:background-color .2s ease,border-color .2s ease,color .2s ease,transform .1s ease}.btn--primary{color:#fff;background:var(--c-primary)}.btn--primary:hover{background:var(--c-primary-dark)}.btn--secondary{color:var(--c-text-strong);border-color:var(--c-border);background:var(--c-surface)}.btn--secondary:hover{border-color:#cbd5e0;background:#f8fafc}.btn--danger{color:#fff;border-color:#b91c1c;background:#dc2626}.btn--danger:hover{border-color:#991b1b;background:#b91c1c}.btn--block{width:100%}.btn:active{transform:translateY(1px)}.btn:focus-visible{outline:none;box-shadow:var(--focus-ring);border-color:var(--c-primary)}.form-control{width:100%;min-height:34px;border:1px solid var(--c-border);border-radius:8px;padding:5px 10px;font:inherit;color:var(--c-text-strong);background:#fff;transition:border-color .2s ease,box-shadow .2s ease}.form-control:focus{outline:none;border-color:var(--c-primary);box-shadow:var(--focus-ring)}.alert{padding:12px 14px;border-radius:8px;border:1px solid rgba(0,0,0,0);font-size:13px;min-height:44px}.alert--danger{border-color:#fed7d7;background:#fff5f5;color:var(--c-danger)}.alert--success{border-color:#b7ebcf;background:#f0fff6;color:#0f6b39}.alert--warning{border-color:#f7dd8b;background:#fff8e8;color:#815500}.form-field{display:grid;gap:5px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.table-wrap{width:100%;overflow-x:auto}.table{width:100%;border-collapse:collapse;background:var(--c-surface)}.table th,.table td{padding:10px 12px;border-bottom:1px solid var(--c-border);text-align:left}.table th{color:var(--c-text-strong);font-weight:700;background:#f8fafc}.table--details th{white-space:nowrap}.pagination{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.pagination__item{display:inline-flex;align-items:center;justify-content:center;min-width:36px;height:36px;padding:0 10px;border-radius:8px;border:1px solid var(--c-border);color:var(--c-text-strong);background:var(--c-surface);text-decoration:none;font-weight:600}.pagination__item:hover{border-color:#cbd5e0;background:#f8fafc}.pagination__item.is-active{border-color:var(--c-primary);color:var(--c-primary);background:#edf2ff}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;font-size:13px;color:var(--c-text);background:var(--c-bg)}a{color:var(--c-primary)}.app-shell{min-height:100vh;display:grid;grid-template-columns:260px 1fr}.sidebar{border-right:1px solid var(--c-border);border-right-color:#243041;background:#111a28;padding:18px 14px}.sidebar__brand{margin:4px 10px 16px;color:#e9f0ff;font-size:24px;font-weight:300;letter-spacing:-0.02em}.sidebar__brand strong{font-weight:700}.sidebar__nav{display:grid;gap:6px}.sidebar__link{border-radius:8px;padding:10px 12px;text-decoration:none;color:#cbd5e1;font-weight:600}.sidebar__link:hover{color:#f8fafc;background:#1b2a3f}.sidebar__link.is-active{color:#fff;background:#2e4f93}.sidebar__group{display:grid;gap:6px}.sidebar__group-toggle{list-style:none;border-radius:8px;padding:10px 12px;color:#cbd5e1;font-weight:600;cursor:pointer}.sidebar__group-toggle::-webkit-details-marker{display:none}.sidebar__group:hover .sidebar__group-toggle{color:#f8fafc;background:#1b2a3f}.sidebar__group.is-active .sidebar__group-toggle{color:#fff;background:#2e4f93}.sidebar__group-links{display:grid;gap:4px;padding-left:8px}.sidebar__sublink{border-radius:8px;padding:8px 10px;text-decoration:none;color:#cbd5e1;font-size:13px;font-weight:500}.sidebar__sublink:hover{color:#f8fafc;background:#1b2a3f}.sidebar__sublink.is-active{color:#fff;background:#2e4f93}.app-main{min-width:0}.topbar{height:50px;border-bottom:1px solid var(--c-border);background:var(--c-surface);display:flex;align-items:center;justify-content:space-between;padding:0 20px;position:sticky;top:0;z-index:100}.brand{font-size:22px;font-weight:300;letter-spacing:-0.02em;color:var(--c-text-strong)}.brand strong{font-weight:700}.container{max-width:none;width:calc(100% - 20px);margin:12px 10px;padding:0 4px 14px}.card{background:var(--c-surface);border-radius:10px;box-shadow:var(--shadow-card);padding:14px}.card h1{margin:0 0 10px;color:var(--c-text-strong);font-size:24px;font-weight:700}.muted{color:var(--c-muted)}.accent{color:var(--c-primary);font-weight:600}.users-form{display:grid;gap:14px;max-width:460px}.section-title{margin:0;color:var(--c-text-strong);font-size:18px;font-weight:700}.mt-12{margin-top:8px}.mt-16{margin-top:12px}.settings-grid{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:12px}.settings-nav{display:flex;gap:8px;flex-wrap:wrap}.settings-nav__link{text-decoration:none;border:1px solid var(--c-border);border-radius:8px;padding:8px 12px;color:var(--c-text-strong);font-weight:600}.settings-nav__link:hover{background:#f8fafc}.settings-nav__link.is-active{border-color:var(--c-primary);color:var(--c-primary);background:#edf2ff}.settings-stat{border:1px solid var(--c-border);border-radius:8px;padding:12px;background:#f8fafc}.settings-stat__label{display:block;color:var(--c-muted);font-size:12px;margin-bottom:4px}.settings-stat__value{color:var(--c-text-strong);font-size:20px}.settings-logs{margin:0;padding:12px;border-radius:8px;border:1px solid var(--c-border);background:#0b1220;color:#d1d5db;font-size:12px;line-height:1.5;overflow:auto}.page-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.filters-grid{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:12px}.filters-actions{display:flex;align-items:end;gap:8px}.product-form .form-control{width:100%}.form-grid{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:12px}.form-actions{display:flex;gap:8px;flex-wrap:wrap}.statuses-form{display:grid;gap:8px;grid-template-columns:repeat(2, minmax(0, 1fr))}.statuses-color-input{min-height:32px;padding:2px}.statuses-hint{grid-column:1/-1;margin:0}.statuses-group-block{border:1px solid var(--c-border);border-radius:10px;padding:8px;background:#fbfdff}.statuses-group-block__head{display:flex;align-items:center;justify-content:space-between;gap:6px;flex-wrap:wrap}.statuses-group-block__title{margin:0;display:inline-flex;align-items:center;gap:6px;color:var(--c-text-strong);font-size:14px}.statuses-color-dot{width:12px;height:12px;border-radius:999px;border:1px solid rgba(15,23,42,.15)}.statuses-dnd-list{margin:6px 0 0;padding:0;list-style:none;display:grid;gap:6px}.statuses-dnd-item{display:grid;grid-template-columns:24px 1fr;gap:6px;border:1px solid #dce4f0;border-radius:8px;background:#fff;padding:6px}.statuses-dnd-item__content{display:flex;align-items:center;gap:6px;min-width:0}.statuses-dnd-item.is-dragging{opacity:.6}.statuses-dnd-item__drag{display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;border-radius:6px;color:#64748b;cursor:grab;user-select:none;font-weight:700;font-size:12px}.statuses-dnd-item__drag:active{cursor:grabbing}.statuses-inline-form{display:grid;gap:6px}.statuses-inline-form--row{grid-template-columns:minmax(180px, 1.4fr) minmax(150px, 1fr) auto auto auto;align-items:center;flex:1 1 auto;min-width:0}.statuses-inline-form--row-group{grid-template-columns:minmax(180px, 1.5fr) 56px auto auto auto;align-items:center;flex:1 1 auto;min-width:0}.statuses-inline-form--row .form-control,.statuses-inline-form--row-group .form-control{min-height:30px;padding:4px 8px}.statuses-inline-form--row .btn,.statuses-inline-form--row-group .btn,.statuses-inline-delete .btn{min-height:30px;padding:4px 10px;font-size:12px}.statuses-inline-check{margin-top:0;white-space:nowrap;font-size:12px}.statuses-inline-delete{margin:0;flex:0 0 auto}.statuses-code-label{font-size:12px;color:var(--c-muted)}.statuses-code-readonly{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;font-size:12px}.statuses-code-readonly code{background:#eef2f7;border-radius:6px;padding:1px 6px;color:#1f2937;font-size:12px}.field-inline{display:flex;align-items:center;gap:8px;margin-top:2px}.modal-backdrop{position:fixed;inset:0;background:rgba(15,23,42,.5);display:flex;align-items:center;justify-content:center;padding:16px;z-index:200}.modal-backdrop[hidden]{display:none}.modal{width:min(560px,100%);background:#fff;border-radius:10px;box-shadow:0 20px 40px rgba(15,23,42,.35);overflow:hidden}.modal__header{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:16px 18px;border-bottom:1px solid var(--c-border)}.modal__header h3{margin:0;font-size:18px;color:var(--c-text-strong)}.modal__body{padding:16px 18px 18px}.status-pill{display:inline-flex;align-items:center;justify-content:center;border:1px solid #fed7d7;background:#fff5f5;color:#9b2c2c;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600}.status-pill.is-active{border-color:#b7ebcf;background:#f0fff6;color:#0f6b39}.table-list{display:grid;gap:14px}.table-list__header{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}.table-list__left{display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap}.table-list-header-actions{display:inline-flex;align-items:center;gap:10px;flex-wrap:wrap}.js-filter-toggle-btn.is-active{border-color:#cbd5e0;background:#edf2ff;color:var(--c-primary-dark)}.table-filter-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;font-size:11px;font-weight:700;color:#fff;background:var(--c-primary);border-radius:999px}.table-filters-wrapper{display:none}.table-filters-wrapper.is-open{display:block}.table-list-filters{display:grid;gap:12px;grid-template-columns:repeat(auto-fit, minmax(170px, 1fr))}.table-col-toggle-wrapper{position:relative}.table-col-toggle-dropdown{display:none;position:absolute;right:0;top:calc(100% + 6px);z-index:30;width:260px;max-height:360px;overflow:auto;border:1px solid var(--c-border);border-radius:10px;background:#fff;box-shadow:0 10px 25px rgba(15,23,42,.12)}.table-col-toggle-dropdown.is-open{display:block}.table-col-toggle-header{padding:10px 12px;border-bottom:1px solid var(--c-border);font-size:12px;font-weight:700;color:var(--c-muted)}.table-col-toggle-item{display:flex;align-items:center;gap:10px;padding:8px 12px;font-size:13px;color:var(--c-text-strong)}.table-col-toggle-item:hover{background:#f8fafc}.table-col-toggle-footer{border-top:1px solid var(--c-border);padding:8px 12px}.table-col-hidden{display:none}.table-col-switch{position:relative;display:inline-block;width:34px;min-width:34px;height:18px}.table-col-switch input{opacity:0;width:0;height:0;position:absolute}.table-col-switch-slider{position:absolute;top:0;left:0;right:0;bottom:0;background:#cbd5e1;border-radius:999px;transition:background-color .2s ease}.table-col-switch-slider::before{content:"";position:absolute;height:14px;width:14px;left:2px;bottom:2px;background:#fff;border-radius:50%;transition:transform .2s ease}.table-col-switch input:checked+.table-col-switch-slider{background:#16a34a}.table-col-switch input:checked+.table-col-switch-slider::before{transform:translateX(16px)}.table-sort-link{display:inline-flex;align-items:center;gap:6px;color:var(--c-text-strong);text-decoration:none}.table-sort-link:hover{color:var(--c-primary-dark)}.table-sort-icon.is-muted{color:#a0aec0}.table-list__footer{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.table-list-per-page-form{display:inline-flex;align-items:center;gap:8px}.table-list-per-page-form .form-control{min-width:90px}.table-select-col{width:44px;text-align:center}.table-select-toggle{display:inline-flex;align-items:center;justify-content:center}.table-select-toggle input[type=checkbox]{width:16px;height:16px}.orders-page .orders-head{background:linear-gradient(120deg, #f8fbff 0%, #eef5ff 100%);border:1px solid #dbe7fb}.orders-page .table-list{border:1px solid #dde5f2;border-radius:12px;box-shadow:0 6px 16px rgba(20,44,86,.08)}.orders-page .table-list__header{padding:10px 6px 2px}.orders-page .table-list-filters{padding:6px 6px 2px;border-top:1px solid #ebf0f7;border-bottom:1px solid #ebf0f7;background:#f9fbff}.orders-page .table-wrap{border-radius:10px;overflow:hidden;border:1px solid #e7edf6}.orders-page .table thead th{background:#f3f7fd;color:#30435f;font-size:12px;text-transform:uppercase;letter-spacing:.03em}.orders-page .table tbody td{vertical-align:middle;padding-top:10px;padding-bottom:10px;border-bottom-color:#edf2f8}.orders-page .table tbody tr:hover td{background:#f9fcff}.orders-list-page{padding:10px;margin-bottom:10px}.orders-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}.orders-stats{display:inline-grid;grid-template-columns:repeat(3, minmax(86px, auto));gap:8px}.orders-stat{border:1px solid #d8e2f0;background:#f8fbff;border-radius:8px;padding:6px 8px;line-height:1.15}.orders-stat__label{display:block;color:#5f6f83;font-size:11px;margin-bottom:2px}.orders-stat__value{color:#12233a;font-size:16px;font-weight:700}.orders-ref{display:grid;gap:2px;min-width:170px}.orders-ref__main{font-weight:700;color:#0f1f35;font-size:14px}.orders-ref__meta{display:inline-flex;flex-wrap:wrap;gap:4px 10px;color:#64748b;font-size:12px}.orders-buyer{display:grid;gap:2px}.orders-buyer__name{color:#0f172a;font-weight:600;font-size:14px}.orders-buyer__meta{display:inline-flex;flex-wrap:wrap;gap:4px 10px;color:#64748b;font-size:12px}.orders-status-wrap{display:inline-flex;align-items:center;gap:5px;flex-wrap:wrap}.order-tag{display:inline-flex;align-items:center;justify-content:center;border:1px solid #d8e1ef;background:#f8fafc;color:#334155;border-radius:999px;padding:2px 8px;font-size:12px;font-weight:700;line-height:1.1}.order-tag.is-info{border-color:#bfdbfe;background:#eff6ff;color:#1d4ed8}.order-tag.is-success{border-color:#bbf7d0;background:#f0fdf4;color:#166534}.order-tag.is-danger{border-color:#fecaca;background:#fef2f2;color:#b91c1c}.order-tag.is-warn{border-color:#fde68a;background:#fffbeb;color:#92400e}.orders-mini{font-size:14px;color:#223247;line-height:1.25}.orders-products{display:grid;gap:4px;min-width:240px}.orders-products__meta,.orders-products__more{font-size:12px;color:#64748b}.orders-product{display:grid;grid-template-columns:48px 1fr;gap:6px;align-items:center}.orders-product__thumb{width:48px;height:48px;border-radius:4px;border:1px solid #dbe3ef;object-fit:cover;background:#fff}.orders-product__thumb--empty{display:inline-block;background:#eef2f7;border-style:dashed}.orders-product__txt{min-width:0;display:grid;gap:1px}.orders-product__name{font-size:14px;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.orders-product__qty{font-size:12px;color:#64748b}.orders-image-trigger{border:0;padding:0;margin:0;background:rgba(0,0,0,0);cursor:zoom-in;display:inline-flex;align-items:center;justify-content:center}.orders-money{display:grid;gap:2px}.orders-money__main{color:#0f172a;font-weight:700;font-size:14px}.orders-money__meta{color:#64748b;font-size:12px}.table-list[data-table-list-id=orders]{gap:8px}.table-list[data-table-list-id=orders] .table-list__header{padding:2px 0 0}.table-list[data-table-list-id=orders] .table-list-filters{gap:8px;grid-template-columns:repeat(auto-fit, minmax(150px, 1fr))}.table-list[data-table-list-id=orders] .table th,.table-list[data-table-list-id=orders] .table td{padding:6px 8px}.table-list[data-table-list-id=orders] .table thead th{font-size:12px;text-transform:uppercase;letter-spacing:.02em;white-space:nowrap}.table-list[data-table-list-id=orders] .table tbody td{vertical-align:top;font-size:14px;line-height:1.25}.order-show-layout{display:grid;grid-template-columns:220px minmax(0, 1fr);gap:12px;align-items:start}.order-statuses-side{position:sticky;top:60px;padding:10px}.order-statuses-side__title{font-size:13px;font-weight:700;color:#0f172a;margin-bottom:8px}.order-status-group{margin-bottom:10px}.order-status-group__name{font-size:12px;color:#475569;font-weight:700;margin-bottom:5px}.order-status-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 6px;border-radius:6px;color:#334155;font-size:12px;text-decoration:none}.order-status-row__count{min-width:24px;text-align:center;border-radius:999px;background:var(--status-color, #64748b);padding:1px 6px;font-weight:700;font-size:11px;color:#fff}.order-status-row:hover{background:#f1f5f9}.order-status-row.is-active{background:rgba(15,23,42,.06);color:#0f172a;font-weight:700}.order-show-main{min-width:0}.order-details-actions{display:inline-flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}.order-details-page{padding:12px}.order-details-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}.order-back-link{color:#475569;text-decoration:none;font-weight:600}.order-back-link:hover{color:#1d4ed8}.order-details-sub{display:inline-flex;gap:10px;flex-wrap:wrap;color:#64748b;font-size:12px}.order-details-pill{border-radius:999px;padding:5px 10px;background:#eef6ff;border:1px solid #cfe2ff;color:#1d4ed8;font-size:12px;font-weight:700}.order-details-tabs{display:flex;gap:6px;flex-wrap:wrap}.order-details-tab{border:1px solid #d6deea;border-radius:8px;padding:5px 10px;color:#475569;font-size:12px;background:#f8fafc;cursor:pointer}.order-details-tab.is-active{border-color:#bfdbfe;color:#1d4ed8;background:#eff6ff;font-weight:700}.order-item-cell{display:grid;grid-template-columns:44px 1fr;gap:8px;align-items:center;min-width:260px}.order-item-thumb{width:44px;height:44px;border-radius:6px;border:1px solid #dbe3ef;object-fit:cover}.order-item-thumb--empty{display:inline-block;background:#eef2f7;border-style:dashed}.order-item-name{font-weight:600;color:#0f172a}.order-grid-2{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:12px}.order-grid-3{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:12px}.order-kv{margin:0;display:grid;grid-template-columns:150px 1fr;gap:6px 10px;font-size:12px}.order-kv dt{color:#64748b}.order-kv dd{margin:0;color:#0f172a;font-weight:600}.order-address{display:grid;gap:3px;font-size:12px;color:#0f172a}.order-events{display:grid;gap:8px}.order-event{border:1px solid #e2e8f0;border-radius:8px;padding:8px;background:#fbfdff}.order-event__head{color:#64748b;font-size:11px}.order-event__body{margin-top:4px;color:#0f172a;font-size:12px}.order-tab-panel{display:none}.order-tab-panel.is-active{display:block}.order-empty-placeholder{border:1px dashed #cbd5e1;border-radius:8px;min-height:180px;background:#f8fafc}.order-status-badge{display:inline-flex;align-items:center;justify-content:center;padding:4px 10px;border-radius:999px;font-size:12px;font-weight:700;border:1px solid #cbd5e1;color:#334155;background:#f8fafc}.order-status-badge.is-info{border-color:#bfdbfe;background:#eff6ff;color:#1d4ed8}.order-status-badge.is-success{border-color:#bbf7d0;background:#f0fdf4;color:#166534}.order-status-badge.is-danger{border-color:#fecaca;background:#fef2f2;color:#b91c1c}.order-status-badge.is-warn{border-color:#fde68a;background:#fffbeb;color:#92400e}.order-status-badge.is-empty{color:#94a3b8}.order-buyer{display:grid;gap:2px}.order-buyer__name{color:#0f172a;font-weight:600}.order-buyer__email{color:#64748b;font-size:12px}.table-inline-action{display:inline-block;margin-right:6px}.product-name-cell{display:inline-flex;align-items:center;gap:10px}.product-name-thumb{width:60px;height:60px;border-radius:6px;object-fit:cover;border:1px solid var(--c-border);background:#f8fafc}.product-name-thumb--empty{display:inline-block;width:60px;height:60px;border-radius:6px;border:1px dashed #cbd5e0;background:#f8fafc}.product-name-thumb-btn{border:0;padding:0;background:rgba(0,0,0,0);cursor:pointer;display:inline-flex;align-items:center;justify-content:center}.product-name-thumb-btn:focus-visible{outline:none;box-shadow:var(--focus-ring);border-radius:8px}.modal--image-preview{width:min(760px,100%)}.product-image-preview__img{display:block;width:100%;max-height:70vh;object-fit:contain;border-radius:8px;background:#f8fafc}.product-images-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));gap:12px}.product-image-card{border:1px solid #dfe3ea;border-radius:10px;padding:10px;background:#fff}.product-image-card__thumb-wrap{position:relative;border-radius:8px;overflow:hidden;background:#f2f5f8}.product-image-card__thumb{width:100%;height:160px;object-fit:cover;display:block}.product-image-card__thumb.is-empty{height:160px;display:grid;place-items:center;color:#6b7785;font-size:12px}.product-image-card__badge{display:none;position:absolute;top:8px;left:8px;background:#1f7a43;color:#fff;padding:3px 8px;border-radius:999px;font-size:11px}.product-image-card.is-main .product-image-card__badge{display:inline-block}.product-image-card__meta{margin-top:8px;font-size:11px;line-height:1.25;color:#5f6b79;overflow-wrap:anywhere}.product-image-card__actions{margin-top:10px;display:grid;grid-template-columns:1fr;gap:8px}.product-image-card__actions .btn{min-height:34px;font-size:12px;line-height:1.2;padding:6px 10px}.product-links-search-form{display:grid;gap:12px;grid-template-columns:minmax(220px, 320px) minmax(220px, 1fr) auto;align-items:end}.product-links-head{display:grid;gap:8px;grid-template-columns:repeat(3, minmax(0, 1fr))}.product-tabs-nav{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.product-links-inline-form{display:grid;gap:8px;grid-template-columns:minmax(140px, 1fr) minmax(140px, 1fr) auto;align-items:center}.product-links-actions-row{display:flex;align-items:center;gap:8px;flex-wrap:nowrap}.product-links-actions-row .product-links-relink-form{flex:1 1 auto}.product-links-unlink-form{margin:0;flex:0 0 auto}.product-link-status-cell{display:inline-flex;align-items:center;gap:6px}.product-link-alert-indicator{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:999px;border:1px solid #f59e0b;background:#fffbeb;color:#b45309;font-size:12px;font-weight:700;cursor:help}.product-link-events-list{margin:0;padding:0;list-style:none;display:grid;gap:4px}.product-link-events-list li{display:grid;gap:2px}.product-link-events-type{font-weight:600;color:var(--c-text-strong)}.product-link-events-date{color:var(--c-muted);font-size:12px}.product-show-images-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));gap:12px}.product-show-image-card{border:1px solid var(--c-border);border-radius:10px;background:#fff;padding:10px;overflow:hidden}.product-show-image-card__meta{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;min-width:0}.product-show-image-path{font-size:12px;min-width:0;overflow:hidden}.product-show-image-path summary{cursor:pointer;color:var(--c-muted, #888);list-style:none;user-select:none;white-space:nowrap}.product-show-image-path summary::-webkit-details-marker{display:none}.product-show-image-path summary::after{content:" ▾"}.product-show-image-path[open] summary::after{content:" ▴"}.product-show-image-path__url{margin-top:4px;word-break:break-all;overflow-wrap:break-word;font-size:11px}.product-show-image{width:100%;max-height:260px;object-fit:cover;border-radius:8px;border:1px solid #d9e0ea}.content-tabs-card{margin-top:0}.content-tabs-nav{display:flex;gap:4px;border-bottom:2px solid var(--c-border);margin-bottom:16px;flex-wrap:wrap}.content-tab-btn{padding:8px 16px;border:none;background:none;cursor:pointer;font-size:14px;font-weight:500;color:var(--c-text-muted, #6b7280);border-bottom:2px solid rgba(0,0,0,0);margin-bottom:-2px;border-radius:4px 4px 0 0;transition:color .15s,border-color .15s}.content-tab-btn:hover{color:var(--c-text-strong, #111827)}.content-tab-btn.is-active{color:var(--c-primary, #2563eb);border-bottom-color:var(--c-primary, #2563eb)}.content-tab-panel{display:none}.content-tab-panel.is-active{display:block}@media(max-width: 768px){.app-shell{grid-template-columns:1fr}.sidebar{border-right:0;border-bottom:1px solid #243041;padding:14px}.sidebar__brand{margin:0 0 10px;font-size:22px}.sidebar__nav{display:flex;gap:8px;overflow-x:auto}.sidebar__link{white-space:nowrap}.topbar{padding:0 14px}.container{margin-top:16px;width:calc(100% - 16px);margin-left:8px;margin-right:8px;padding:0 3px 12px}.settings-grid{grid-template-columns:1fr}.page-head{flex-direction:column;align-items:flex-start}.orders-stats{grid-template-columns:1fr;width:100%}.order-show-layout{grid-template-columns:1fr}.order-statuses-side{position:static;top:auto}.order-details-actions{justify-content:flex-start}.order-grid-2,.order-grid-3{grid-template-columns:1fr}.order-kv{grid-template-columns:1fr;gap:2px}.filters-grid,.form-grid,.statuses-form,.statuses-inline-form,.table-list-filters,.product-links-search-form,.product-links-inline-form{grid-template-columns:1fr}.statuses-dnd-item__content{display:block}.statuses-inline-delete{margin-top:6px}.filters-actions{align-items:center}.table-list__header,.table-list__footer{align-items:flex-start}.product-links-head{grid-template-columns:1fr}.card{padding:12px}.modal--image-preview{width:min(92vw,100%)}} +:root{--c-primary: #6690f4;--c-primary-dark: #3164db;--c-bg: #f4f6f9;--c-surface: #ffffff;--c-text: #4e5e6a;--c-text-strong: #2d3748;--c-muted: #718096;--c-border: #e2e8f0;--c-danger: #cc0000;--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06)}.btn{display:inline-flex;align-items:center;justify-content:center;min-height:34px;padding:6px 12px;border:1px solid rgba(0,0,0,0);border-radius:8px;font:inherit;font-weight:600;text-decoration:none;cursor:pointer;transition:background-color .2s ease,border-color .2s ease,color .2s ease,transform .1s ease}.btn--primary{color:#fff;background:var(--c-primary)}.btn--primary:hover{background:var(--c-primary-dark)}.btn--secondary{color:var(--c-text-strong);border-color:var(--c-border);background:var(--c-surface)}.btn--secondary:hover{border-color:#cbd5e0;background:#f8fafc}.btn--danger{color:#fff;border-color:#b91c1c;background:#dc2626}.btn--danger:hover{border-color:#991b1b;background:#b91c1c}.btn--block{width:100%}.btn:active{transform:translateY(1px)}.btn:focus-visible{outline:none;box-shadow:var(--focus-ring);border-color:var(--c-primary)}.form-control{width:100%;min-height:34px;border:1px solid var(--c-border);border-radius:8px;padding:5px 10px;font:inherit;color:var(--c-text-strong);background:#fff;transition:border-color .2s ease,box-shadow .2s ease}.form-control:focus{outline:none;border-color:var(--c-primary);box-shadow:var(--focus-ring)}.alert{padding:12px 14px;border-radius:8px;border:1px solid rgba(0,0,0,0);font-size:13px;min-height:44px}.alert--danger{border-color:#fed7d7;background:#fff5f5;color:var(--c-danger)}.alert--success{border-color:#b7ebcf;background:#f0fff6;color:#0f6b39}.alert--warning{border-color:#f7dd8b;background:#fff8e8;color:#815500}.form-field{display:grid;gap:5px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.table-wrap{width:100%;overflow-x:auto}.table{width:100%;border-collapse:collapse;background:var(--c-surface)}.table th,.table td{padding:10px 12px;border-bottom:1px solid var(--c-border);text-align:left}.table th{color:var(--c-text-strong);font-weight:700;background:#f8fafc}.table--details th{white-space:nowrap}.pagination{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.pagination__item{display:inline-flex;align-items:center;justify-content:center;min-width:36px;height:36px;padding:0 10px;border-radius:8px;border:1px solid var(--c-border);color:var(--c-text-strong);background:var(--c-surface);text-decoration:none;font-weight:600}.pagination__item:hover{border-color:#cbd5e0;background:#f8fafc}.pagination__item.is-active{border-color:var(--c-primary);color:var(--c-primary);background:#edf2ff}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;font-size:13px;color:var(--c-text);background:var(--c-bg)}a{color:var(--c-primary)}.app-shell{min-height:100vh;display:grid;grid-template-columns:260px 1fr}.sidebar{border-right:1px solid var(--c-border);border-right-color:#243041;background:#111a28;padding:18px 14px}.sidebar__brand{margin:4px 10px 16px;color:#e9f0ff;font-size:24px;font-weight:300;letter-spacing:-0.02em}.sidebar__brand strong{font-weight:700}.sidebar__nav{display:grid;gap:6px}.sidebar__link{border-radius:8px;padding:10px 12px;text-decoration:none;color:#cbd5e1;font-weight:600}.sidebar__link:hover{color:#f8fafc;background:#1b2a3f}.sidebar__link.is-active{color:#fff;background:#2e4f93}.sidebar__group{display:grid;gap:6px}.sidebar__group-toggle{list-style:none;border-radius:8px;padding:10px 12px;color:#cbd5e1;font-weight:600;cursor:pointer}.sidebar__group-toggle::-webkit-details-marker{display:none}.sidebar__group:hover .sidebar__group-toggle{color:#f8fafc;background:#1b2a3f}.sidebar__group.is-active .sidebar__group-toggle{color:#fff;background:#2e4f93}.sidebar__group-links{display:grid;gap:4px;padding-left:8px}.sidebar__sublink{border-radius:8px;padding:8px 10px;text-decoration:none;color:#cbd5e1;font-size:13px;font-weight:500}.sidebar__sublink:hover{color:#f8fafc;background:#1b2a3f}.sidebar__sublink.is-active{color:#fff;background:#2e4f93}.app-main{min-width:0}.topbar{height:50px;border-bottom:1px solid var(--c-border);background:var(--c-surface);display:flex;align-items:center;justify-content:space-between;padding:0 20px;position:sticky;top:0;z-index:100}.brand{font-size:22px;font-weight:300;letter-spacing:-0.02em;color:var(--c-text-strong)}.brand strong{font-weight:700}.container{max-width:none;width:calc(100% - 20px);margin:12px 10px;padding:0 4px 14px}.card{background:var(--c-surface);border-radius:10px;box-shadow:var(--shadow-card);padding:14px}.card h1{margin:0 0 10px;color:var(--c-text-strong);font-size:24px;font-weight:700}.muted{color:var(--c-muted)}.accent{color:var(--c-primary);font-weight:600}.users-form{display:grid;gap:14px;max-width:460px}.section-title{margin:0;color:var(--c-text-strong);font-size:18px;font-weight:700}.mt-12{margin-top:8px}.mt-16{margin-top:12px}.settings-grid{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:12px}.settings-nav{display:flex;gap:8px;flex-wrap:wrap}.settings-nav__link{text-decoration:none;border:1px solid var(--c-border);border-radius:8px;padding:8px 12px;color:var(--c-text-strong);font-weight:600}.settings-nav__link:hover{background:#f8fafc}.settings-nav__link.is-active{border-color:var(--c-primary);color:var(--c-primary);background:#edf2ff}.settings-stat{border:1px solid var(--c-border);border-radius:8px;padding:12px;background:#f8fafc}.settings-stat__label{display:block;color:var(--c-muted);font-size:12px;margin-bottom:4px}.settings-stat__value{color:var(--c-text-strong);font-size:20px}.settings-logs{margin:0;padding:12px;border-radius:8px;border:1px solid var(--c-border);background:#0b1220;color:#d1d5db;font-size:12px;line-height:1.5;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;justify-content:space-between;gap:12px}.filters-grid{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:12px}.filters-actions{display:flex;align-items:end;gap:8px}.product-form .form-control{width:100%}.form-grid{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:12px}.form-actions{display:flex;gap:8px;flex-wrap:wrap}.statuses-form{display:grid;gap:8px;grid-template-columns:repeat(2, minmax(0, 1fr))}.statuses-color-input{min-height:32px;padding:2px}.statuses-hint{grid-column:1/-1;margin:0}.statuses-group-block{border:1px solid var(--c-border);border-radius:10px;padding:8px;background:#fbfdff}.statuses-group-block__head{display:flex;align-items:center;justify-content:space-between;gap:6px;flex-wrap:wrap}.statuses-group-block__title{margin:0;display:inline-flex;align-items:center;gap:6px;color:var(--c-text-strong);font-size:14px}.statuses-color-dot{width:12px;height:12px;border-radius:999px;border:1px solid rgba(15,23,42,.15)}.statuses-dnd-list{margin:6px 0 0;padding:0;list-style:none;display:grid;gap:6px}.statuses-dnd-item{display:grid;grid-template-columns:24px 1fr;gap:6px;border:1px solid #dce4f0;border-radius:8px;background:#fff;padding:6px}.statuses-dnd-item__content{display:flex;align-items:center;gap:6px;min-width:0}.statuses-dnd-item.is-dragging{opacity:.6}.statuses-dnd-item__drag{display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;border-radius:6px;color:#64748b;cursor:grab;user-select:none;font-weight:700;font-size:12px}.statuses-dnd-item__drag:active{cursor:grabbing}.statuses-inline-form{display:grid;gap:6px}.statuses-inline-form--row{grid-template-columns:minmax(180px, 1.4fr) minmax(150px, 1fr) auto auto auto;align-items:center;flex:1 1 auto;min-width:0}.statuses-inline-form--row-group{grid-template-columns:minmax(180px, 1.5fr) 56px auto auto auto;align-items:center;flex:1 1 auto;min-width:0}.statuses-inline-form--row .form-control,.statuses-inline-form--row-group .form-control{min-height:30px;padding:4px 8px}.statuses-inline-form--row .btn,.statuses-inline-form--row-group .btn,.statuses-inline-delete .btn{min-height:30px;padding:4px 10px;font-size:12px}.statuses-inline-check{margin-top:0;white-space:nowrap;font-size:12px}.statuses-inline-delete{margin:0;flex:0 0 auto}.statuses-code-label{font-size:12px;color:var(--c-muted)}.statuses-code-readonly{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;font-size:12px}.statuses-code-readonly code{background:#eef2f7;border-radius:6px;padding:1px 6px;color:#1f2937;font-size:12px}.field-inline{display:flex;align-items:center;gap:8px;margin-top:2px}.modal-backdrop{position:fixed;inset:0;background:rgba(15,23,42,.5);display:flex;align-items:center;justify-content:center;padding:16px;z-index:200}.modal-backdrop[hidden]{display:none}.modal{width:min(560px,100%);background:#fff;border-radius:10px;box-shadow:0 20px 40px rgba(15,23,42,.35);overflow:hidden}.modal__header{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:16px 18px;border-bottom:1px solid var(--c-border)}.modal__header h3{margin:0;font-size:18px;color:var(--c-text-strong)}.modal__body{padding:16px 18px 18px}.status-pill{display:inline-flex;align-items:center;justify-content:center;border:1px solid #fed7d7;background:#fff5f5;color:#9b2c2c;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600}.status-pill.is-active{border-color:#b7ebcf;background:#f0fff6;color:#0f6b39}.table-list{display:grid;gap:14px}.table-list__header{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}.table-list__left{display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap}.table-list-header-actions{display:inline-flex;align-items:center;gap:10px;flex-wrap:wrap}.js-filter-toggle-btn.is-active{border-color:#cbd5e0;background:#edf2ff;color:var(--c-primary-dark)}.table-filter-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;font-size:11px;font-weight:700;color:#fff;background:var(--c-primary);border-radius:999px}.table-filters-wrapper{display:none}.table-filters-wrapper.is-open{display:block}.table-list-filters{display:grid;gap:12px;grid-template-columns:repeat(auto-fit, minmax(170px, 1fr))}.table-col-toggle-wrapper{position:relative}.table-col-toggle-dropdown{display:none;position:absolute;right:0;top:calc(100% + 6px);z-index:30;width:260px;max-height:360px;overflow:auto;border:1px solid var(--c-border);border-radius:10px;background:#fff;box-shadow:0 10px 25px rgba(15,23,42,.12)}.table-col-toggle-dropdown.is-open{display:block}.table-col-toggle-header{padding:10px 12px;border-bottom:1px solid var(--c-border);font-size:12px;font-weight:700;color:var(--c-muted)}.table-col-toggle-item{display:flex;align-items:center;gap:10px;padding:8px 12px;font-size:13px;color:var(--c-text-strong)}.table-col-toggle-item:hover{background:#f8fafc}.table-col-toggle-footer{border-top:1px solid var(--c-border);padding:8px 12px}.table-col-hidden{display:none}.table-col-switch{position:relative;display:inline-block;width:34px;min-width:34px;height:18px}.table-col-switch input{opacity:0;width:0;height:0;position:absolute}.table-col-switch-slider{position:absolute;top:0;left:0;right:0;bottom:0;background:#cbd5e1;border-radius:999px;transition:background-color .2s ease}.table-col-switch-slider::before{content:"";position:absolute;height:14px;width:14px;left:2px;bottom:2px;background:#fff;border-radius:50%;transition:transform .2s ease}.table-col-switch input:checked+.table-col-switch-slider{background:#16a34a}.table-col-switch input:checked+.table-col-switch-slider::before{transform:translateX(16px)}.table-sort-link{display:inline-flex;align-items:center;gap:6px;color:var(--c-text-strong);text-decoration:none}.table-sort-link:hover{color:var(--c-primary-dark)}.table-sort-icon.is-muted{color:#a0aec0}.table-list__footer{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.table-list-per-page-form{display:inline-flex;align-items:center;gap:8px}.table-list-per-page-form .form-control{min-width:90px}.table-select-col{width:44px;text-align:center}.table-select-toggle{display:inline-flex;align-items:center;justify-content:center}.table-select-toggle input[type=checkbox]{width:16px;height:16px}.orders-page .orders-head{background:linear-gradient(120deg, #f8fbff 0%, #eef5ff 100%);border:1px solid #dbe7fb}.orders-page .table-list{border:1px solid #dde5f2;border-radius:12px;box-shadow:0 6px 16px rgba(20,44,86,.08)}.orders-page .table-list__header{padding:10px 6px 2px}.orders-page .table-list-filters{padding:6px 6px 2px;border-top:1px solid #ebf0f7;border-bottom:1px solid #ebf0f7;background:#f9fbff}.orders-page .table-wrap{border-radius:10px;overflow:hidden;border:1px solid #e7edf6}.orders-page .table thead th{background:#f3f7fd;color:#30435f;font-size:12px;text-transform:uppercase;letter-spacing:.03em}.orders-page .table tbody td{vertical-align:middle;padding-top:10px;padding-bottom:10px;border-bottom-color:#edf2f8}.orders-page .table tbody tr:hover td{background:#f9fcff}.orders-list-page{padding:10px;margin-bottom:10px}.orders-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}.orders-stats{display:inline-grid;grid-template-columns:repeat(3, minmax(86px, auto));gap:8px}.orders-stat{border:1px solid #d8e2f0;background:#f8fbff;border-radius:8px;padding:6px 8px;line-height:1.15}.orders-stat__label{display:block;color:#5f6f83;font-size:11px;margin-bottom:2px}.orders-stat__value{color:#12233a;font-size:16px;font-weight:700}.orders-ref{display:grid;gap:2px;min-width:170px}.orders-ref__main{font-weight:700;color:#0f1f35;font-size:14px}.orders-ref__meta{display:inline-flex;flex-wrap:wrap;gap:4px 10px;color:#64748b;font-size:12px}.orders-buyer{display:grid;gap:2px}.orders-buyer__name{color:#0f172a;font-weight:600;font-size:14px}.orders-buyer__meta{display:inline-flex;flex-wrap:wrap;gap:4px 10px;color:#64748b;font-size:12px}.orders-status-wrap{display:inline-flex;align-items:center;gap:5px;flex-wrap:wrap}.order-tag{display:inline-flex;align-items:center;justify-content:center;border:1px solid #d8e1ef;background:#f8fafc;color:#334155;border-radius:999px;padding:2px 8px;font-size:12px;font-weight:700;line-height:1.1}.order-tag.is-info{border-color:#bfdbfe;background:#eff6ff;color:#1d4ed8}.order-tag.is-success{border-color:#bbf7d0;background:#f0fdf4;color:#166534}.order-tag.is-danger{border-color:#fecaca;background:#fef2f2;color:#b91c1c}.order-tag.is-warn{border-color:#fde68a;background:#fffbeb;color:#92400e}.orders-mini{font-size:14px;color:#223247;line-height:1.25}.orders-products{display:grid;gap:4px;min-width:240px}.orders-products__meta,.orders-products__more{font-size:12px;color:#64748b}.orders-product{display:grid;grid-template-columns:48px 1fr;gap:6px;align-items:center}.orders-product__thumb{width:48px;height:48px;border-radius:4px;border:1px solid #dbe3ef;object-fit:cover;background:#fff}.orders-product__thumb--empty{display:inline-block;background:#eef2f7;border-style:dashed}.orders-product__txt{min-width:0;display:grid;gap:1px}.orders-product__name{font-size:14px;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.orders-product__qty{font-size:12px;color:#64748b}.orders-image-trigger{border:0;padding:0;margin:0;background:rgba(0,0,0,0);cursor:zoom-in;display:inline-flex;align-items:center;justify-content:center}.orders-money{display:grid;gap:2px}.orders-money__main{color:#0f172a;font-weight:700;font-size:14px}.orders-money__meta{color:#64748b;font-size:12px}.table-list[data-table-list-id=orders]{gap:8px}.table-list[data-table-list-id=orders] .table-list__header{padding:2px 0 0}.table-list[data-table-list-id=orders] .table-list-filters{gap:8px;grid-template-columns:repeat(auto-fit, minmax(150px, 1fr))}.table-list[data-table-list-id=orders] .table th,.table-list[data-table-list-id=orders] .table td{padding:6px 8px}.table-list[data-table-list-id=orders] .table thead th{font-size:12px;text-transform:uppercase;letter-spacing:.02em;white-space:nowrap}.table-list[data-table-list-id=orders] .table tbody td{vertical-align:top;font-size:14px;line-height:1.25}.order-show-layout{display:grid;grid-template-columns:220px minmax(0, 1fr);gap:12px;align-items:start}.order-statuses-side{position:sticky;top:60px;padding:10px}.order-statuses-side__title{font-size:13px;font-weight:700;color:#0f172a;margin-bottom:8px}.order-status-group{margin-bottom:10px}.order-status-group__name{font-size:12px;color:#475569;font-weight:700;margin-bottom:5px}.order-status-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 6px;border-radius:6px;color:#334155;font-size:12px;text-decoration:none}.order-status-row__count{min-width:24px;text-align:center;border-radius:999px;background:var(--status-color, #64748b);padding:1px 6px;font-weight:700;font-size:11px;color:#fff}.order-status-row:hover{background:#f1f5f9}.order-status-row.is-active{background:rgba(15,23,42,.06);color:#0f172a;font-weight:700}.order-show-main{min-width:0}.order-details-actions{display:inline-flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}.order-details-page{padding:12px}.order-details-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}.order-back-link{color:#475569;text-decoration:none;font-weight:600}.order-back-link:hover{color:#1d4ed8}.order-details-sub{display:inline-flex;gap:10px;flex-wrap:wrap;color:#64748b;font-size:12px}.order-details-pill{border-radius:999px;padding:5px 10px;background:#eef6ff;border:1px solid #cfe2ff;color:#1d4ed8;font-size:12px;font-weight:700}.order-details-tabs{display:flex;gap:6px;flex-wrap:wrap}.order-details-tab{border:1px solid #d6deea;border-radius:8px;padding:5px 10px;color:#475569;font-size:12px;background:#f8fafc;cursor:pointer}.order-details-tab.is-active{border-color:#bfdbfe;color:#1d4ed8;background:#eff6ff;font-weight:700}.order-item-cell{display:grid;grid-template-columns:44px 1fr;gap:8px;align-items:center;min-width:260px}.order-item-thumb{width:44px;height:44px;border-radius:6px;border:1px solid #dbe3ef;object-fit:cover}.order-item-thumb--empty{display:inline-block;background:#eef2f7;border-style:dashed}.order-item-name{font-weight:600;color:#0f172a}.order-grid-2{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:12px}.order-grid-3{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:12px}.order-kv{margin:0;display:grid;grid-template-columns:150px 1fr;gap:6px 10px;font-size:12px}.order-kv dt{color:#64748b}.order-kv dd{margin:0;color:#0f172a;font-weight:600}.order-address{display:grid;gap:3px;font-size:12px;color:#0f172a}.order-events{display:grid;gap:8px}.order-event{border:1px solid #e2e8f0;border-radius:8px;padding:8px;background:#fbfdff}.order-event__head{color:#64748b;font-size:11px}.order-event__body{margin-top:4px;color:#0f172a;font-size:12px}.order-tab-panel{display:none}.order-tab-panel.is-active{display:block}.order-empty-placeholder{border:1px dashed #cbd5e1;border-radius:8px;min-height:180px;background:#f8fafc}.order-status-badge{display:inline-flex;align-items:center;justify-content:center;padding:4px 10px;border-radius:999px;font-size:12px;font-weight:700;border:1px solid #cbd5e1;color:#334155;background:#f8fafc}.order-status-badge.is-info{border-color:#bfdbfe;background:#eff6ff;color:#1d4ed8}.order-status-badge.is-success{border-color:#bbf7d0;background:#f0fdf4;color:#166534}.order-status-badge.is-danger{border-color:#fecaca;background:#fef2f2;color:#b91c1c}.order-status-badge.is-warn{border-color:#fde68a;background:#fffbeb;color:#92400e}.order-status-badge.is-empty{color:#94a3b8}.order-buyer{display:grid;gap:2px}.order-buyer__name{color:#0f172a;font-weight:600}.order-buyer__email{color:#64748b;font-size:12px}.table-inline-action{display:inline-block;margin-right:6px}.product-name-cell{display:inline-flex;align-items:center;gap:10px}.product-name-thumb{width:60px;height:60px;border-radius:6px;object-fit:cover;border:1px solid var(--c-border);background:#f8fafc}.product-name-thumb--empty{display:inline-block;width:60px;height:60px;border-radius:6px;border:1px dashed #cbd5e0;background:#f8fafc}.product-name-thumb-btn{border:0;padding:0;background:rgba(0,0,0,0);cursor:pointer;display:inline-flex;align-items:center;justify-content:center}.product-name-thumb-btn:focus-visible{outline:none;box-shadow:var(--focus-ring);border-radius:8px}.modal--image-preview{width:min(760px,100%)}.product-image-preview__img{display:block;width:100%;max-height:70vh;object-fit:contain;border-radius:8px;background:#f8fafc}.product-images-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));gap:12px}.product-image-card{border:1px solid #dfe3ea;border-radius:10px;padding:10px;background:#fff}.product-image-card__thumb-wrap{position:relative;border-radius:8px;overflow:hidden;background:#f2f5f8}.product-image-card__thumb{width:100%;height:160px;object-fit:cover;display:block}.product-image-card__thumb.is-empty{height:160px;display:grid;place-items:center;color:#6b7785;font-size:12px}.product-image-card__badge{display:none;position:absolute;top:8px;left:8px;background:#1f7a43;color:#fff;padding:3px 8px;border-radius:999px;font-size:11px}.product-image-card.is-main .product-image-card__badge{display:inline-block}.product-image-card__meta{margin-top:8px;font-size:11px;line-height:1.25;color:#5f6b79;overflow-wrap:anywhere}.product-image-card__actions{margin-top:10px;display:grid;grid-template-columns:1fr;gap:8px}.product-image-card__actions .btn{min-height:34px;font-size:12px;line-height:1.2;padding:6px 10px}.product-links-search-form{display:grid;gap:12px;grid-template-columns:minmax(220px, 320px) minmax(220px, 1fr) auto;align-items:end}.product-links-head{display:grid;gap:8px;grid-template-columns:repeat(3, minmax(0, 1fr))}.product-tabs-nav{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.product-links-inline-form{display:grid;gap:8px;grid-template-columns:minmax(140px, 1fr) minmax(140px, 1fr) auto;align-items:center}.product-links-actions-row{display:flex;align-items:center;gap:8px;flex-wrap:nowrap}.product-links-actions-row .product-links-relink-form{flex:1 1 auto}.product-links-unlink-form{margin:0;flex:0 0 auto}.product-link-status-cell{display:inline-flex;align-items:center;gap:6px}.product-link-alert-indicator{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:999px;border:1px solid #f59e0b;background:#fffbeb;color:#b45309;font-size:12px;font-weight:700;cursor:help}.product-link-events-list{margin:0;padding:0;list-style:none;display:grid;gap:4px}.product-link-events-list li{display:grid;gap:2px}.product-link-events-type{font-weight:600;color:var(--c-text-strong)}.product-link-events-date{color:var(--c-muted);font-size:12px}.product-show-images-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));gap:12px}.product-show-image-card{border:1px solid var(--c-border);border-radius:10px;background:#fff;padding:10px;overflow:hidden}.product-show-image-card__meta{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;min-width:0}.product-show-image-path{font-size:12px;min-width:0;overflow:hidden}.product-show-image-path summary{cursor:pointer;color:var(--c-muted, #888);list-style:none;user-select:none;white-space:nowrap}.product-show-image-path summary::-webkit-details-marker{display:none}.product-show-image-path summary::after{content:" ▾"}.product-show-image-path[open] summary::after{content:" ▴"}.product-show-image-path__url{margin-top:4px;word-break:break-all;overflow-wrap:break-word;font-size:11px}.product-show-image{width:100%;max-height:260px;object-fit:cover;border-radius:8px;border:1px solid #d9e0ea}.content-tabs-card{margin-top:0}.content-tabs-nav{display:flex;gap:4px;border-bottom:2px solid var(--c-border);margin-bottom:16px;flex-wrap:wrap}.content-tab-btn{padding:8px 16px;border:none;background:none;cursor:pointer;font-size:14px;font-weight:500;color:var(--c-text-muted, #6b7280);border-bottom:2px solid rgba(0,0,0,0);margin-bottom:-2px;border-radius:4px 4px 0 0;transition:color .15s,border-color .15s}.content-tab-btn:hover{color:var(--c-text-strong, #111827)}.content-tab-btn.is-active{color:var(--c-primary, #2563eb);border-bottom-color:var(--c-primary, #2563eb)}.content-tab-panel{display:none}.content-tab-panel.is-active{display:block}@media(max-width: 768px){.app-shell{grid-template-columns:1fr}.sidebar{border-right:0;border-bottom:1px solid #243041;padding:14px}.sidebar__brand{margin:0 0 10px;font-size:22px}.sidebar__nav{display:flex;gap:8px;overflow-x:auto}.sidebar__link{white-space:nowrap}.topbar{padding:0 14px}.container{margin-top:16px;width:calc(100% - 16px);margin-left:8px;margin-right:8px;padding:0 3px 12px}.settings-grid{grid-template-columns:1fr}.page-head{flex-direction:column;align-items:flex-start}.orders-stats{grid-template-columns:1fr;width:100%}.order-show-layout{grid-template-columns:1fr}.order-statuses-side{position:static;top:auto}.order-details-actions{justify-content:flex-start}.order-grid-2,.order-grid-3{grid-template-columns:1fr}.order-kv{grid-template-columns:1fr;gap:2px}.filters-grid,.form-grid,.statuses-form,.statuses-inline-form,.table-list-filters,.product-links-search-form,.product-links-inline-form{grid-template-columns:1fr}.statuses-dnd-item__content{display:block}.statuses-inline-delete{margin-top:6px}.filters-actions{align-items:center}.table-list__header,.table-list__footer{align-items:flex-start}.product-links-head{grid-template-columns:1fr}.card{padding:12px}.modal--image-preview{width:min(92vw,100%)}} diff --git a/resources/lang/pl.php b/resources/lang/pl.php index 74e0bca..005ec58 100644 --- a/resources/lang/pl.php +++ b/resources/lang/pl.php @@ -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', diff --git a/resources/scss/app.scss b/resources/scss/app.scss index 45a82c3..3ea3fa7 100644 --- a/resources/scss/app.scss +++ b/resources/scss/app.scss @@ -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; diff --git a/resources/views/layouts/app.php b/resources/views/layouts/app.php index 8b0177f..df4d8a9 100644 --- a/resources/views/layouts/app.php +++ b/resources/views/layouts/app.php @@ -40,6 +40,12 @@ + + + + + + diff --git a/resources/views/settings/allegro.php b/resources/views/settings/allegro.php new file mode 100644 index 0000000..46f4714 --- /dev/null +++ b/resources/views/settings/allegro.php @@ -0,0 +1,309 @@ + + +
+

+

+ + + + + + +
+ + + + + +
+ +
+ + +
+
+

+

+ +
+ +
+

+
+ + + + + + + + + + + + + + +
+ +
+
+
+ +
+

+

+ +

+ +

$connectedAt])) ?>

+ + +

$tokenExpiresAt])) ?>

+ +
+ + +
+
+ +
+

+

+
+ + +
+ +
+
+
+
+ +
+
+

+

+ +
+ + +
+ +
+ + + + +
+ +
+
+
+ +
+

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+
+ +
+ +
+ +
+
+
+ +
+
+

+

+
+ + + + + + +
+ +
+
+
+
+
+ + diff --git a/resources/views/settings/cron.php b/resources/views/settings/cron.php new file mode 100644 index 0000000..5498a2c --- /dev/null +++ b/resources/views/settings/cron.php @@ -0,0 +1,147 @@ + + +
+

+ + + + + + +
+ + +

+

+ +
+ + + + + + +
+ +
+
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID
+
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID
+
+
diff --git a/routes/web.php b/routes/web.php index e78d381..050cb3c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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']); }; diff --git a/src/Core/Application.php b/src/Core/Application.php index 07f052e..3d10057 100644 --- a/src/Core/Application.php +++ b/src/Core/Application.php @@ -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 diff --git a/src/Modules/Cron/AllegroOrdersImportHandler.php b/src/Modules/Cron/AllegroOrdersImportHandler.php new file mode 100644 index 0000000..64e2921 --- /dev/null +++ b/src/Modules/Cron/AllegroOrdersImportHandler.php @@ -0,0 +1,26 @@ + $payload + * @return array + */ + 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), + ]); + } +} diff --git a/src/Modules/Cron/AllegroStatusSyncHandler.php b/src/Modules/Cron/AllegroStatusSyncHandler.php new file mode 100644 index 0000000..37f6268 --- /dev/null +++ b/src/Modules/Cron/AllegroStatusSyncHandler.php @@ -0,0 +1,22 @@ + $payload + * @return array + */ + public function handle(array $payload): array + { + return $this->syncService->sync(); + } +} diff --git a/src/Modules/Cron/AllegroTokenRefreshHandler.php b/src/Modules/Cron/AllegroTokenRefreshHandler.php new file mode 100644 index 0000000..b9fea3a --- /dev/null +++ b/src/Modules/Cron/AllegroTokenRefreshHandler.php @@ -0,0 +1,64 @@ + $payload + * @return array + */ + 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, + ]; + } +} diff --git a/src/Modules/Cron/CronRepository.php b/src/Modules/Cron/CronRepository.php new file mode 100644 index 0000000..7f9725c --- /dev/null +++ b/src/Modules/Cron/CronRepository.php @@ -0,0 +1,448 @@ +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> + */ + 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> + */ + 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> + */ + 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> + */ + 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 $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|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|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|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 $row + * @return array + */ + 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 $row + * @return array + */ + 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; + } +} diff --git a/src/Modules/Cron/CronRunner.php b/src/Modules/Cron/CronRunner.php new file mode 100644 index 0000000..546471e --- /dev/null +++ b/src/Modules/Cron/CronRunner.php @@ -0,0 +1,99 @@ + $handlers + */ + public function __construct( + private readonly CronRepository $repository, + private readonly Logger $logger, + private readonly array $handlers + ) { + } + + /** + * @return array + */ + 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 $payload + * @return array + */ + 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; + } +} diff --git a/src/Modules/Orders/OrderImportRepository.php b/src/Modules/Orders/OrderImportRepository.php new file mode 100644 index 0000000..e9519d8 --- /dev/null +++ b/src/Modules/Orders/OrderImportRepository.php @@ -0,0 +1,421 @@ + $orderData + * @param array> $addresses + * @param array> $items + * @param array> $payments + * @param array> $shipments + * @param array> $notes + * @param array> $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 $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 $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 $orderData + * @return array + */ + 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> $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> $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> $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> $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> $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> $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; + } +} diff --git a/src/Modules/Orders/OrdersController.php b/src/Modules/Orders/OrdersController.php index f1604b3..c65b5c2 100644 --- a/src/Modules/Orders/OrdersController.php +++ b/src/Modules/Orders/OrdersController.php @@ -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 . '' . '', 'status_badges' => '
' - . $this->statusBadge($this->statusLabel($status, $statusLabelMap)) + . $this->statusBadge($status, $this->statusLabel($status, $statusLabelMap)) . '
', 'products' => $this->productsHtml($itemsPreview, $itemsCount, $itemsQty), 'totals' => '
' @@ -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 $statusCodes + * @param array $statusLabelMap + * @return array + */ + 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> $itemsPreview */ diff --git a/src/Modules/Orders/OrdersRepository.php b/src/Modules/Orders/OrdersRepository.php index 5d2209d..14dc0b5 100644 --- a/src/Modules/Orders/OrdersRepository.php +++ b/src/Modules/Orders/OrdersRepository.php @@ -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); diff --git a/src/Modules/Settings/AllegroApiClient.php b/src/Modules/Settings/AllegroApiClient.php new file mode 100644 index 0000000..27b3d82 --- /dev/null +++ b/src/Modules/Settings/AllegroApiClient.php @@ -0,0 +1,108 @@ + + */ + 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 + */ + 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 + */ + 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 + */ + 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; + } +} diff --git a/src/Modules/Settings/AllegroIntegrationController.php b/src/Modules/Settings/AllegroIntegrationController.php new file mode 100644 index 0000000..1ea1224 --- /dev/null +++ b/src/Modules/Settings/AllegroIntegrationController.php @@ -0,0 +1,703 @@ + 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 + */ + 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 $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 + */ + 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 + */ + 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 + */ + private function allowedStatusSyncDirections(): array + { + return [ + self::STATUS_SYNC_DIRECTION_ALLEGRO_TO_ORDERPRO, + self::STATUS_SYNC_DIRECTION_ORDERPRO_TO_ALLEGRO, + ]; + } +} diff --git a/src/Modules/Settings/AllegroIntegrationRepository.php b/src/Modules/Settings/AllegroIntegrationRepository.php new file mode 100644 index 0000000..3358a58 --- /dev/null +++ b/src/Modules/Settings/AllegroIntegrationRepository.php @@ -0,0 +1,320 @@ + + */ + 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 $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|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|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|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|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 + */ + 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 : ''; + } +} diff --git a/src/Modules/Settings/AllegroOAuthClient.php b/src/Modules/Settings/AllegroOAuthClient.php new file mode 100644 index 0000000..da7e102 --- /dev/null +++ b/src/Modules/Settings/AllegroOAuthClient.php @@ -0,0 +1,177 @@ + $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 $formData + * @return array + */ + 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; + } +} diff --git a/src/Modules/Settings/AllegroOrderImportService.php b/src/Modules/Settings/AllegroOrderImportService.php new file mode 100644 index 0000000..16582db --- /dev/null +++ b/src/Modules/Settings/AllegroOrderImportService.php @@ -0,0 +1,801 @@ + + */ + 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 + */ + 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 $oauth + * @return array{0:string, 1:array} + */ + 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 $oauth + * @return array{0:string, 1:array} + */ + 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 $payload + * @return array{ + * order:array, + * addresses:array>, + * items:array>, + * image_diagnostics:array, + * payments:array>, + * shipments:array>, + * notes:array>, + * status_history:array> + * } + */ + 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 $buyer + * @param array $delivery + * @param array $invoice + * @return array> + */ + 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 $lineItems + * @return array{ + * items:array>, + * image_diagnostics:array + * } + */ + 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 $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 $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 $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 $payment + * @return array> + */ + 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 $payload + * @param array $delivery + * @return array> + */ + 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 $payload + * @return array> + */ + 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 $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; + } +} diff --git a/src/Modules/Settings/AllegroOrderSyncStateRepository.php b/src/Modules/Settings/AllegroOrderSyncStateRepository.php new file mode 100644 index 0000000..13b1329 --- /dev/null +++ b/src/Modules/Settings/AllegroOrderSyncStateRepository.php @@ -0,0 +1,275 @@ +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 $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; + } +} diff --git a/src/Modules/Settings/AllegroOrdersSyncService.php b/src/Modules/Settings/AllegroOrdersSyncService.php new file mode 100644 index 0000000..22808a4 --- /dev/null +++ b/src/Modules/Settings/AllegroOrdersSyncService.php @@ -0,0 +1,333 @@ + $options + * @return array + */ + 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 + */ + 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 $oauth + * @return array{0:string, 1:array} + */ + 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 $oauth + * @return array{0:string, 1:array} + */ + 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; + } +} diff --git a/src/Modules/Settings/AllegroStatusDiscoveryService.php b/src/Modules/Settings/AllegroStatusDiscoveryService.php new file mode 100644 index 0000000..762b6e9 --- /dev/null +++ b/src/Modules/Settings/AllegroStatusDiscoveryService.php @@ -0,0 +1,180 @@ +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 + */ + 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 $oauth + * @return array{0:string, 1:array} + */ + 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 $oauth + * @return array{0:string, 1:array} + */ + 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); + } +} diff --git a/src/Modules/Settings/AllegroStatusMappingRepository.php b/src/Modules/Settings/AllegroStatusMappingRepository.php new file mode 100644 index 0000000..fcac410 --- /dev/null +++ b/src/Modules/Settings/AllegroStatusMappingRepository.php @@ -0,0 +1,131 @@ +> + */ + 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; + } +} diff --git a/src/Modules/Settings/AllegroStatusSyncService.php b/src/Modules/Settings/AllegroStatusSyncService.php new file mode 100644 index 0000000..5189d47 --- /dev/null +++ b/src/Modules/Settings/AllegroStatusSyncService.php @@ -0,0 +1,53 @@ + + */ + 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, + ]; + } +} diff --git a/src/Modules/Settings/CronSettingsController.php b/src/Modules/Settings/CronSettingsController.php new file mode 100644 index 0000000..4411a1a --- /dev/null +++ b/src/Modules/Settings/CronSettingsController.php @@ -0,0 +1,85 @@ +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'); + } +}