diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json index 60ad5bb..f6c3117 100644 --- a/.vscode/ftp-kr.sync.cache.json +++ b/.vscode/ftp-kr.sync.cache.json @@ -7,6 +7,12 @@ "lmtime": 1772652932723, "modified": false }, + "_allegro_check.php": { + "type": "-", + "size": 1954, + "lmtime": 1772803697369, + "modified": false + }, "ARCHITECTURE.md": { "type": "-", "size": 659, @@ -733,6 +739,24 @@ } } }, + "_db_check2.php": { + "type": "-", + "size": 1656, + "lmtime": 1772803550728, + "modified": false + }, + "_db_check3.php": { + "type": "-", + "size": 1919, + "lmtime": 1772803572007, + "modified": false + }, + "_db_check.php": { + "type": "-", + "size": 2025, + "lmtime": 1772803459353, + "modified": false + }, "DB_SCHEMA.md": { "type": "-", "size": 363, @@ -748,9 +772,9 @@ }, "DB_SCHEMA.md": { "type": "-", - "size": 9585, + "size": 9908, "lmtime": 1772751841787, - "modified": false + "modified": true }, "ORDERS_SCHEMA_APILO_DRAFT.md": { "type": "-", @@ -766,8 +790,8 @@ }, "TECH_CHANGELOG.md": { "type": "-", - "size": 21813, - "lmtime": 1772751847189, + "size": 22477, + "lmtime": 1772803892625, "modified": false }, "todo.md": { @@ -2057,8 +2081,8 @@ "lang": { "pl.php": { "type": "-", - "size": 49807, - "lmtime": 1772751834546, + "size": 49998, + "lmtime": 1772791865236, "modified": false } }, @@ -2168,14 +2192,14 @@ }, "list.php": { "type": "-", - "size": 2344, + "size": 1552, "lmtime": 1772497374098, "modified": true }, "show.php": { "type": "-", - "size": 17387, - "lmtime": 1772747158454, + "size": 26016, + "lmtime": 1772791685988, "modified": false } }, @@ -2214,8 +2238,8 @@ "settings": { "allegro.php": { "type": "-", - "size": 31284, - "lmtime": 1772754778803, + "size": 32395, + "lmtime": 1772791281085, "modified": false }, "apaczka.php": { @@ -2290,8 +2314,8 @@ "shipments": { "prepare.php": { "type": "-", - "size": 26553, - "lmtime": 1772754107648, + "size": 28300, + "lmtime": 1772792497957, "modified": false } } @@ -2300,8 +2324,8 @@ "routes": { "web.php": { "type": "-", - "size": 10727, - "lmtime": 1772750372002, + "size": 10849, + "lmtime": 1772791657587, "modified": false } }, @@ -2349,8 +2373,8 @@ "Core": { "Application.php": { "type": "-", - "size": 10729, - "lmtime": 1772661737740, + "size": 11003, + "lmtime": 1772803779141, "modified": false }, "Database": { @@ -2566,9 +2590,9 @@ "Orders": { "OrderImportRepository.php": { "type": "-", - "size": 18405, + "size": 18666, "lmtime": 1772655751334, - "modified": false + "modified": true }, "OrderImportService.php": { "type": "-", @@ -2578,15 +2602,15 @@ }, "OrdersController.php": { "type": "-", - "size": 26496, - "lmtime": 1772752037320, + "size": 27379, + "lmtime": 1772791653816, "modified": false }, "OrdersRepository.php": { "type": "-", - "size": 30133, + "size": 30430, "lmtime": 1772752022929, - "modified": false + "modified": true }, "OrderStatusSyncService.php": { "type": "-", @@ -2686,8 +2710,8 @@ }, "AllegroIntegrationController.php": { "type": "-", - "size": 36725, - "lmtime": 1772751733083, + "size": 36988, + "lmtime": 1772794656848, "modified": false }, "AllegroIntegrationRepository.php": { @@ -2704,14 +2728,14 @@ }, "AllegroOrderImportService.php": { "type": "-", - "size": 32629, - "lmtime": 1772743953928, + "size": 32797, + "lmtime": 1772792227813, "modified": false }, "AllegroOrdersSyncService.php": { "type": "-", "size": 11544, - "lmtime": 1772660728121, + "lmtime": 1772803813533, "modified": false }, "AllegroOrderSyncStateRepository.php": { @@ -2734,8 +2758,8 @@ }, "AllegroStatusSyncService.php": { "type": "-", - "size": 1537, - "lmtime": 1772661706942, + "size": 3266, + "lmtime": 1772803803020, "modified": false }, "ApaczkaIntegrationController.php": { @@ -2834,14 +2858,14 @@ "Shipments": { "AllegroShipmentService.php": { "type": "-", - "size": 17565, - "lmtime": 1772753969008, + "size": 18000, + "lmtime": 1772792396143, "modified": false }, "ShipmentController.php": { "type": "-", - "size": 10999, - "lmtime": 1772753068348, + "size": 12247, + "lmtime": 1772792492765, "modified": false }, "ShipmentPackageRepository.php": { @@ -4735,6 +4759,12 @@ } } }, + "_test_status_sync.php": { + "type": "-", + "size": 2434, + "lmtime": 1772803861129, + "modified": false + }, "tmp_gs1_test.php": { "type": "-", "size": 3392, diff --git a/DOCS/ARCHITECTURE.md b/DOCS/ARCHITECTURE.md index 008ef55..971d7df 100644 --- a/DOCS/ARCHITECTURE.md +++ b/DOCS/ARCHITECTURE.md @@ -32,6 +32,7 @@ - `POST /settings/statuses/reorder` - `GET /settings/cron` - `POST /settings/cron` +- `GET /settings/integrations` - `GET /settings/integrations/allegro` - `POST /settings/integrations/allegro/save` - `POST /settings/integrations/allegro/oauth/start` @@ -45,6 +46,12 @@ - `POST /settings/integrations/apaczka/save` - `GET /settings/integrations/inpost` - `POST /settings/integrations/inpost/save` +- `GET /settings/integrations/shoppro` +- `POST /settings/integrations/shoppro/save` +- `POST /settings/integrations/shoppro/test` +- `POST /settings/integrations/shoppro/statuses/save` +- `POST /settings/integrations/shoppro/statuses/sync` +- `POST /settings/integrations/shoppro/delivery/save` - `GET /health` - `GET /` (redirect) @@ -69,6 +76,8 @@ - `App\Modules\Settings\AllegroOrderImportService` - `App\Modules\Settings\AllegroStatusMappingRepository` - `App\Modules\Settings\AllegroStatusDiscoveryService` +- `App\Modules\Settings\IntegrationsRepository` +- `App\Modules\Settings\IntegrationSecretCipher` - `App\Modules\Orders\OrderImportRepository` - `App\Modules\Settings\CronSettingsController` - `App\Modules\Cron\CronRepository` @@ -76,12 +85,24 @@ - `App\Modules\Cron\AllegroTokenRefreshHandler` - `App\Modules\Cron\AllegroOrdersImportHandler` - `App\Modules\Cron\AllegroStatusSyncHandler` +- `App\Modules\Cron\ShopproOrdersImportHandler` +- `App\Modules\Cron\ShopproStatusSyncHandler` +- `App\Modules\Cron\ShopproPaymentStatusSyncHandler` - `App\Modules\Users\UsersController` - `App\Modules\Users\UserRepository` - `App\Modules\Settings\ApaczkaIntegrationController` - `App\Modules\Settings\ApaczkaIntegrationRepository` - `App\Modules\Settings\InpostIntegrationController` - `App\Modules\Settings\InpostIntegrationRepository` +- `App\Modules\Settings\IntegrationsHubController` +- `App\Modules\Settings\ShopproIntegrationsController` +- `App\Modules\Settings\ShopproIntegrationsRepository` +- `App\Modules\Settings\ShopproStatusMappingRepository` +- `App\Modules\Settings\ShopproDeliveryMethodMappingRepository` +- `App\Modules\Settings\ShopproApiClient` +- `App\Modules\Settings\ShopproOrdersSyncService` +- `App\Modules\Settings\ShopproStatusSyncService` +- `App\Modules\Settings\ShopproPaymentStatusSyncService` - `App\Modules\Settings\AllegroOrdersSyncService` - `App\Modules\Settings\AllegroOrderSyncStateRepository` - `App\Modules\Settings\AllegroStatusSyncService` @@ -167,15 +188,14 @@ - Sidebar (`resources/views/layouts/app.php`) ma nowy podlink: - `Statusy` (`/settings/statuses`). - `Cron` (`/settings/cron`). - - `Integracje Allegro` (`/settings/integrations/allegro`). - - `Integracja Apaczka` (`/settings/integrations/apaczka`). - - `Integracja InPost` (`/settings/integrations/inpost`). + - `Integracje` (`/settings/integrations`) - wspolny hub konfiguracji providerow. ## 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`). + - renderuje harmonogramy (`cron_schedules`) oraz kolejke/historie (`cron_jobs`), + - historia (`past_jobs`) ma stronicowanie po parametrze query `past_page` (25 rekordow na strone). - `POST /settings/cron`: - `CronSettingsController::save(Request): Response` - waliduje CSRF, @@ -196,6 +216,9 @@ - Dodatkowy handler: - `allegro_orders_import` -> `AllegroOrdersImportHandler::handle(...)` (automatyczny import zamowien Allegro). - `allegro_status_sync` -> `AllegroStatusSyncHandler::handle(...)` (synchronizacja statusow wg kierunku z ustawien integracji Allegro). + - `shoppro_orders_import` -> `ShopproOrdersImportHandler::handle(...)` (automatyczny import zamowien z aktywnych integracji `shopPRO` z wlaczonym pobieraniem). + - `shoppro_order_status_sync` -> `ShopproStatusSyncHandler::handle(...)` (synchronizacja statusow shopPRO wg kierunku ustawionego per instancja). + - `shoppro_payment_status_sync` -> `ShopproPaymentStatusSyncHandler::handle(...)` (odswiezanie statusu platnosci zamowien shopPRO na podstawie flagi `paid`). ## Przeplyw Ustawienia > Integracje > Allegro - `GET /settings/integrations/allegro`: @@ -248,10 +271,11 @@ - 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, + - mapuje payload Allegro na neutralny model tabel zamowien (z `integration_id` aktywnej instancji Allegro), - zapisuje aggregate przez `OrderImportRepository::upsertOrderAggregate(...)`. - `AllegroOrdersSyncService`: - uruchamiany z crona (`allegro_orders_import`), + - korzysta z dynamicznego `integration_id` aktywnego srodowiska Allegro (zamiast stalej), - 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`. @@ -286,7 +310,7 @@ - `POST /settings/integrations/apaczka/save`: - `ApaczkaIntegrationController::save(Request): Response` - waliduje CSRF i klucz API, - - zapisuje zaszyfrowany klucz API przez `ApaczkaIntegrationRepository::saveSettings(...)`. + - zapisuje zaszyfrowany klucz API przez `ApaczkaIntegrationRepository::saveSettings(...)` do tabeli bazowej `integrations` (`type=apaczka`). ## Przeplyw Ustawienia > Integracja InPost - `GET /settings/integrations/inpost`: @@ -296,7 +320,9 @@ - `POST /settings/integrations/inpost/save`: - `InpostIntegrationController::save(Request): Response` - waliduje CSRF, - - zapisuje ustawienia (token API szyfrowany AES-256-CBC, parametry domyslne przesylek) przez `InpostIntegrationRepository::saveSettings(...)`. + - zapisuje ustawienia przez `InpostIntegrationRepository::saveSettings(...)`: + - token API w `integrations.api_key_encrypted` (`type=inpost`), + - parametry specyficzne przewoznika w `inpost_integration_settings`. ## Przeplyw Ustawienia > Baza danych - `GET /settings/database`: @@ -323,3 +349,66 @@ - nowe klasy i metody (sygnatury + odpowiedzialnosc), - zmiany przeplywu request -> controller -> repository, - kontrakty wejscia/wyjscia istotnych metod. +## Przeplyw Ustawienia > Integracje (hub) +- `GET /settings/integrations`: + - `IntegrationsHubController::index(Request): Response` + - buduje liste instancji providerow (Allegro sandbox/production, Apaczka, InPost, shopPRO), + - pokazuje tabele podsumowania i przycisk `Ustawienia` w kazdym wierszu, + - przycisk `Ustawienia` prowadzi do dedykowanego ekranu providera (`/settings/integrations/allegro|apaczka|inpost|shoppro`), + - renderuje `resources/views/settings/integrations.php`. + +## Przeplyw Ustawienia > Integracje > shopPRO +- `GET /settings/integrations/shoppro`: + - `ShopproIntegrationsController::index(Request): Response` + - pobiera liste instancji przez `ShopproIntegrationsRepository::listIntegrations()`, + - opcjonalnie laduje wskazana instancje (`?id=`) przez `findIntegration(...)`, + - renderuje `resources/views/settings/shoppro.php` z zakladkami: `Integracja`, `Statusy`, `Ustawienia`, `Formy dostawy`. +- `POST /settings/integrations/shoppro/save`: + - `ShopproIntegrationsController::save(Request): Response` + - waliduje CSRF, nazwe, URL (`http|https`), klucz API (wymagany przy nowej konfiguracji) oraz format daty `orders_fetch_start_date` (`Y-m-d`), + - zapisuje konfiguracje przez `ShopproIntegrationsRepository::saveIntegration(...)` do tabeli bazowej `integrations` (`type=shoppro`), + - zapisuje interwal joba `shoppro_orders_import` (minuty) do `cron_schedules.interval_seconds`, + - zapisuje kierunek synchronizacji statusow per instancja (`integrations.order_status_sync_direction`), + - zapisuje interwal joba `shoppro_order_status_sync` (minuty) do `cron_schedules.interval_seconds`, + - zapisuje interwal joba `shoppro_payment_status_sync` (minuty) do `cron_schedules.interval_seconds`, + - zapisuje liste statusow orderPRO (per instancja) dla kontroli platnosci (`integrations.payment_sync_status_codes_json`). +- `POST /settings/integrations/shoppro/test`: + - `ShopproIntegrationsController::test(Request): Response` + - waliduje CSRF i `integration_id`, + - wykonuje test API przez `ShopproIntegrationsRepository::testConnection(...)`, + - zapisuje wynik testu w `integrations.last_test_*` i `integration_test_logs`. +- `POST /settings/integrations/shoppro/statuses/sync`: + - `ShopproIntegrationsController::syncStatuses(Request): Response` + - pobiera slownik statusow z API (`dictionaries/statuses`) przez `ShopproIntegrationsRepository::fetchOrderStatuses(...)`, + - przekazuje odkryte statusy do widoku zakladki `Statusy` (flash/sesja). +- `POST /settings/integrations/shoppro/statuses/save`: + - `ShopproIntegrationsController::saveStatusMappings(Request): Response` + - waliduje CSRF, `integration_id` i kody statusow orderPRO, + - zapisuje mapowania per instancja shopPRO przez `ShopproStatusMappingRepository::replaceForIntegration(...)` do `order_status_mappings`. +- `POST /settings/integrations/shoppro/delivery/save`: + - `ShopproIntegrationsController::saveDeliveryMappings(Request): Response` + - waliduje CSRF i `integration_id`, + - zapisuje mapowania form dostawy przez `ShopproDeliveryMethodMappingRepository::saveMappings(...)` (per instancja). +- `ShopproOrdersSyncService`: + - uruchamiany z crona (`shoppro_orders_import`), + - pobiera liste zamowien i (opcjonalnie) szczegoly zamowienia z API shopPRO, + - mapuje kwoty z fallbackami (`summary`, `paid`, `transport_cost`) oraz ceny pozycji (`price_brutto`), + - uzupelnia `order_items.media_url` przez pobranie `products/get` po `product_id`, gdy zamowienie nie zawiera obrazu. + - mapuje punkty odbioru (`inpost_paczkomat` / `orlen_point`) do adresu `delivery` (`parcel_external_id`, `parcel_name`, ulica/kod/miasto), + - uzupelnia `delivery` o telefon/e-mail klienta i etykiete metody dostawy z kosztem (`transport_cost`). +- `ShopproStatusSyncService`: + - uruchamiany z crona (`shoppro_order_status_sync`), + - filtruje aktywne instancje `shopPRO` po kierunku synchronizacji statusow (`shoppro_to_orderpro`), + - dla wspieranego kierunku wykorzystuje `ShopproOrdersSyncService` do odswiezenia statusow/importu danych, + - dla kierunku `orderpro_to_shoppro` pomija instancje i zwraca wynik informacyjny (tryb przygotowany pod kolejny etap). +- `ShopproPaymentStatusSyncService`: + - uruchamiany z crona (`shoppro_payment_status_sync`), + - pobiera zamowienia shopPRO nieoznaczone jako oplacone (`orders.payment_status != 2`) i nie-finalne, + - dla kazdego zamowienia odpytuje API `orders/get|details` i odczytuje flage `paid`, + - aktualizuje `orders.payment_status`, `orders.total_paid` i `order_payments`, + - zapisuje log `payment` do `order_activity_log`, + - respektuje liste statusow z `integrations.payment_sync_status_codes_json` (gdy pusta: fallback na pomijanie statusow finalnych). +- Zakladka `Formy dostawy` (shopPRO): + - laduje formy dostawy wykryte w zamowieniach danej instancji (`orders.source=shoppro` + `orders.integration_id`), + - laduje uslugi dostawy z Allegro API (`delivery-services`) z fallbackiem na odswiezenie tokenu OAuth, + - zapisuje mapowanie: forma dostawy shopPRO -> usluga Allegro/InPost WZA. diff --git a/DOCS/DB_SCHEMA.md b/DOCS/DB_SCHEMA.md index 28071b5..4b29894 100644 --- a/DOCS/DB_SCHEMA.md +++ b/DOCS/DB_SCHEMA.md @@ -27,6 +27,24 @@ - 2026-03-06: Dodano kolumne `carrier` do tabeli `allegro_delivery_method_mappings` (default 'allegro') - umozliwia mapowanie na roznych przewoznikow (Allegro, InPost). - 2026-03-06: Wdrozono migracje `20260302_000019_add_internal_order_number_to_orders.sql` - kolumna `internal_order_number` VARCHAR(11) UNIQUE w tabeli `orders`, format `OPXXXXXXXXX` (np. `OP000000001`); backfill istniejacych rekordow; UI: lista i szczegoly zamowien wyswietlaja numer wewnetrzny jako glowny identyfikator. - 2026-03-04: Poprawiono prezentacje daty zamowienia na liscie (`fallback ordered_at -> source_created_at -> source_updated_at -> fetched_at`) - bez zmian schematu. +- 2026-03-08: Rozpoczeto ujednolicanie integracji - migracja `20260308_000037_unify_integrations_base_links.sql`: + - dodano `integration_id` do tabel `allegro_integration_settings`, `apaczka_integration_settings`, `inpost_integration_settings`, + - dodano FK/UNIQUE 1:1 z tabela `integrations`, + - dodano/uzupelniono rekordy bazowe providerow (`allegro`, `apaczka`, `inpost`) w `integrations`. +- 2026-03-08: Dodano UI i endpointy konfiguracji `shopPRO` (wieloinstancyjnie) w oparciu o istniejaca tabele `integrations` (`type=shoppro`) - bez zmian schematu. +- 2026-03-08: Dodano mapowanie statusow dla `shopPRO` (zakladka `Statusy`) z zapisem do istniejacej tabeli `order_status_mappings` per `integration_id` - bez zmian schematu. +- 2026-03-08: Dodano migracje naprawcza `20260308_000038_ensure_order_status_mappings_table.sql` (uzupelnia brakujaca tabele `order_status_mappings` w srodowiskach z niepelna historia migracji). +- 2026-03-08: Dodano migracje naprawcza `20260308_000039_ensure_integrations_fetch_columns.sql` (uzupelnia brakujace kolumny `orders_fetch_enabled` i `orders_fetch_start_date` w `integrations`). +- 2026-03-08: Dodano migracje `20260308_000040_ensure_shoppro_orders_import_schedule.sql` (seed/naprawa harmonogramu `cron_schedules` dla joba `shoppro_orders_import`) - bez zmian schematu tabel. +- 2026-03-08: Dodano migracje `20260308_000041_ensure_shoppro_status_sync_schedule_and_direction.sql` (seed/naprawa harmonogramu `cron_schedules` dla joba `shoppro_order_status_sync` oraz uzupelnienie kolumny `integrations.order_status_sync_direction` w srodowiskach niezgodnych). +- 2026-03-08: Dodano migracje `20260308_000042_ensure_shoppro_payment_sync_schedule_and_columns.sql`: + - uzupelnienie kolumny `integrations.payment_sync_status_codes_json` (JSON) dla konfiguracji statusow objetych kontrola platnosci, + - seed/naprawa harmonogramu `cron_schedules` dla joba `shoppro_payment_status_sync` (domyslnie 600s, priorytet 105). +- 2026-03-08: Dodano migracje `20260308_000043_create_shoppro_delivery_method_mappings_table.sql`: + - nowa tabela `shoppro_delivery_method_mappings` (mapowanie form dostawy per `integration_id`), + - tabela przechowuje mapowanie formy shopPRO na usluge Allegro WZA/InPost (`allegro_delivery_method_id`, `allegro_credentials_id`, `allegro_carrier_id`, `allegro_service_name`, `carrier`). +- 2026-03-08: Poprawiono mapowanie importu zamowien shopPRO (kwoty i miniatury pozycji) - bez zmian schematu bazy. +- 2026-03-08: Poprawiono mapowanie danych wysylki shopPRO (paczkomat/punkt odbioru + kontakt klienta + koszt transportu) - bez zmian schematu bazy. ## Tabele @@ -94,10 +112,27 @@ - `last_error` (varchar 500), - `created_at`, `updated_at`. +### `order_status_mappings` +- Mapowanie statusow zamowien shopPRO na statusy orderPRO per instancja integracji. +- Kolumny: + - `id` (PK, int unsigned, AI), + - `integration_id` (int unsigned, FK -> `integrations.id`), + - `shoppro_status_code` (varchar 64), + - `shoppro_status_name` (varchar 128, nullable), + - `orderpro_status_code` (varchar 64), + - `created_at`, `updated_at`. +- Indeksy: + - `order_status_mappings_integration_shoppro_unique` (UNIQUE: `integration_id`, `shoppro_status_code`), + - `order_status_mappings_integration_idx` (`integration_id`), + - `order_status_mappings_orderpro_idx` (`orderpro_status_code`). +- Klucze obce: + - `order_status_mappings_integration_fk`: `integration_id` -> `integrations.id` (`ON DELETE CASCADE`, `ON UPDATE CASCADE`). + ### `allegro_integration_settings` -- Konfiguracja pojedynczej integracji Allegro (`id = 1`) zarzadzanej z `Ustawienia > Integracje > Allegro`. +- Konfiguracja OAuth i sync dla integracji Allegro per srodowisko (`sandbox|production`) zarzadzana z `Ustawienia > Integracje > Allegro`. - Kolumny: - `id` (PK, tinyint unsigned), + - `integration_id` (int unsigned, UNIQUE, FK -> `integrations.id`), - `environment` (varchar 16, `sandbox|production`), - `client_id` (varchar 128), - `client_secret_encrypted` (text), @@ -113,7 +148,8 @@ - `created_at`, `updated_at`. - Indeksy: - `allegro_integration_settings_environment_idx` (`environment`), - - `allegro_integration_settings_token_expires_at_idx` (`token_expires_at`). + - `allegro_integration_settings_token_expires_at_idx` (`token_expires_at`), + - `allegro_integration_settings_integration_unique` (`integration_id`, UNIQUE). ### `allegro_order_status_mappings` - Mapowanie kodow statusow Allegro na kody statusow orderPRO. @@ -142,17 +178,54 @@ - `order_activity_log_order_created_idx` (`order_id`, `created_at`), - `order_activity_log_event_type_idx` (`event_type`). +### `integrations` +- Bazowa tabela wszystkich instancji integracji (model docelowy pod wielu providerow i wiele kont per provider). +- Kolumny: + - `id` (PK, int unsigned, AI), + - `type` (varchar 32, np. `allegro`, `apaczka`, `inpost`, `shoppro`), + - `name` (varchar 128, unikalne w obrebie `type`), + - `base_url` (varchar 255), + - `api_key_encrypted` (text, nullable), + - `timeout_seconds` (smallint unsigned), + - `is_active` (tinyint(1)), + - `orders_fetch_enabled` (tinyint(1)), + - `orders_fetch_start_date` (date, nullable), + - `order_status_sync_direction` (varchar 32), + - `payment_sync_status_codes_json` (json, nullable; lista kodow statusow orderPRO, dla ktorych cron ma sprawdzac oplacenie zamowien), + - pola diagnostyki testu (`last_test_status`, `last_test_http_code`, `last_test_message`, `last_test_at`), + - `created_at`, `updated_at`. + +### `shoppro_delivery_method_mappings` +- Mapowanie form dostawy shopPRO na uslugi dostawy Allegro WZA/InPost per instancja integracji. +- Kolumny: + - `id` (PK, int unsigned, AI), + - `integration_id` (int unsigned, FK -> `integrations.id`), + - `order_delivery_method` (varchar 200), + - `carrier` (varchar 50; np. `allegro`, `inpost`), + - `allegro_delivery_method_id` (varchar 128), + - `allegro_credentials_id` (varchar 128, nullable), + - `allegro_carrier_id` (varchar 128, nullable), + - `allegro_service_name` (varchar 255, nullable), + - `created_at`, `updated_at`. +- Indeksy: + - `shoppro_dm_mapping_unique` (UNIQUE: `integration_id`, `order_delivery_method`), + - `shoppro_dm_mapping_integration_idx` (`integration_id`). +- Klucze obce: + - `shoppro_dm_mapping_integration_fk`: `integration_id` -> `integrations.id` (`ON DELETE CASCADE`, `ON UPDATE CASCADE`). + ### `apaczka_integration_settings` -- Konfiguracja pojedynczej integracji Apaczka (`id = 1`) zarzadzanej z `Ustawienia > Integracja Apaczka`. +- Tabela kompatybilnosci dla integracji Apaczka (`id = 1`); sekret API jest utrzymywany bazowo w `integrations.api_key_encrypted`. - Kolumny: - `id` (PK, tinyint unsigned), + - `integration_id` (int unsigned, UNIQUE, FK -> `integrations.id`), - `api_key_encrypted` (text, nullable), - `created_at`, `updated_at`. ### `inpost_integration_settings` -- Konfiguracja pojedynczej integracji InPost ShipX (`id = 1`) zarzadzanej z `Ustawienia > Integracja InPost`. +- Tabela ustawien specyficznych InPost ShipX (`id = 1`); token API utrzymywany bazowo w `integrations.api_key_encrypted`. - Kolumny: - `id` (PK, tinyint unsigned), + - `integration_id` (int unsigned, UNIQUE, FK -> `integrations.id`), - `api_token_encrypted` (text, nullable), - `organization_id` (varchar 50, nullable), - `environment` (enum: sandbox, production), diff --git a/DOCS/TECH_CHANGELOG.md b/DOCS/TECH_CHANGELOG.md index 2b9e54b..0ec5092 100644 --- a/DOCS/TECH_CHANGELOG.md +++ b/DOCS/TECH_CHANGELOG.md @@ -1,5 +1,148 @@ # Tech Changelog +## 2026-03-08 +- Dodano zakladke `Formy dostawy` dla integracji `shopPRO` (analogicznie do Allegro): + - nowy endpoint `POST /settings/integrations/shoppro/delivery/save`, + - mapowanie per instancja: forma dostawy shopPRO -> usluga dostawy Allegro/InPost WZA, + - UI z wyborem przewoznika (`allegro`/`inpost`) i wyszukiwaniem uslug Allegro. +- Dodano `ShopproDeliveryMethodMappingRepository` i tabele mapowan per `integration_id`. +- Dodano migracje `20260308_000043_create_shoppro_delivery_method_mappings_table.sql`. +- Dodano synchronizacje platnosci shopPRO oparta o flage `paid`: + - nowy job cron `shoppro_payment_status_sync`, + - nowy handler `App\Modules\Cron\ShopproPaymentStatusSyncHandler`, + - nowy serwis `App\Modules\Settings\ShopproPaymentStatusSyncService`, + - runner podlaczony w `App\Core\Application::maybeRunCronOnWeb(...)` oraz `bin/cron.php`. +- Rozszerzono `Ustawienia > Integracje > shopPRO > Ustawienia`: + - dodano interwal sprawdzania platnosci (minuty) dla joba `shoppro_payment_status_sync`, + - dodano wybor statusow orderPRO, dla ktorych cron ma sprawdzac oplacenie zamowien, + - zapis konfiguracji listy statusow per instancja trafia do `integrations.payment_sync_status_codes_json`. +- Import zamowien shopPRO zapisuje status platnosci numerycznie (`orders.payment_status`) na podstawie flagi `paid`, co unifikuje filtry/statystyki platnosci z Allegro. +- Dodano migracje `20260308_000042_ensure_shoppro_payment_sync_schedule_and_columns.sql`: + - uzupelnienie kolumny `integrations.payment_sync_status_codes_json`, + - seed/naprawa harmonogramu `shoppro_payment_status_sync` (domyslnie 600s, priorytet 105). +- Fix danych wysylki dla zamowien shopPRO (np. `OP000000016`): + - `ShopproOrdersSyncService` mapuje `inpost_paczkomat`/`orlen_point` do adresu `delivery` (punkt, ulica, kod, miasto), + - zapisuje `parcel_external_id` i `parcel_name` dla punktu odbioru, + - `delivery` dziedziczy telefon i e-mail klienta, gdy API nie zwraca osobnych danych odbiorcy, + - etykieta metody dostawy (`external_carrier_id`) zawiera koszt transportu (`transport_cost`), np. `Paczkomaty InPost - przedpłata: 13.5 zł`. +- Fix importu shopPRO dla listy zamowien (`Kwoty` + miniatury): + - `ShopproOrdersSyncService` mapuje kwoty zamowienia z `summary` i `paid` (fallback), ceny pozycji z `price_brutto`, + - poprawiono laczenie payloadow `orders/list` i `orders/get|details` (zachowanie kluczowych pol z listy), + - dodano fallback miniatur pozycji przez API `products/get` po `product_id`, + - dodano dodatkowy fallback miniatur po `parent_product_id` (warianty), gdy obraz nie istnieje na produkcie potomnym. +- `OrdersRepository`: + - resolver miniatur pozycji uwzglednia kod kanalu zgodny ze zrodlem zamowienia (`o.source`) zamiast stalego `allegro`. +- Korekta layoutu sekcji `Ustawienia` dla integracji `shopPRO`: + - wyrownano pola w siatce (`integration-settings-group__grid`) przez `align-items: start`, + - wymuszono spojna wysokosc kontrolek (`.form-control`), w tym pola `date`, + - przebudowano CSS (`public/assets/css/app.css`) dla rownego przebiegu linii i pol w obu kolumnach. +- Fix UX przycisku `Nowa integracja` w `Ustawienia > Integracje > shopPRO`: + - przycisk otwiera teraz tryb wymuszonego tworzenia (`?new=1`), + - `ShopproIntegrationsController::index(...)` nie auto-wybiera wtedy pierwszej istniejacej integracji, + - formularz tworzenia otwiera sie zawsze jako pusty. +- Poprawiono prezentacje dostawy na szczegolach zamowienia (`orders/show`): + - `Platnosc i wysylka` sanitizuje nazwe przewoznika (usuwa tagi HTML typu `...`), + - `Dane wysylki` pokazuja `parcel_name` i `parcel_external_id` (np. punkt/paczkomat Allegro), + - gdy brak adresu `delivery`, sekcja `Dane wysylki` pokazuje fallback z metody dostawy (`external_carrier_id`). +- Poprawiono import shopPRO dla formy dostawy: + - `ShopproOrdersSyncService` sanitizuje `external_carrier_id` i `order_shipments.carrier_provider_id` (usuwanie HTML + dekodowanie encji), + - rozszerzono fallbacki mapowania przewoznika (`transport`, `transport_description`, `transport_id`). +- Fix mapowania formy dostawy z shopPRO: + - `ShopproOrdersSyncService` mapuje teraz `orders.external_carrier_id` z fallbackiem na pola `transport` i `transport_description`, + - `orders.external_carrier_account_id` mapowane z `transport_id`, + - `order_shipments.carrier_provider_id` rozszerzone o fallback `transport`/`transport_description`. +- Fix importu adresow shopPRO na realnym payloadzie zamowien (`client_name`, `client_surname`, `client_email`, `client_phone`): + - `ShopproOrdersSyncService::mapAddresses(...)` mapuje teraz pola klienta w formacie flat (bez zagniezdzenia), + - usunieto przypadek tworzenia pustego adresu `delivery` tylko na bazie fallbacku e-mail, + - po wymuszonym re-sync zamowienia #13 dane zamawiajacego zapisaly sie jako `Jacek Pyziak`, `pyziak84@gmail.com`, `530755774`. +- Poprawiono mapowanie danych adresowych w imporcie zamowien `shopPRO`: + - `ShopproOrdersSyncService::mapAddresses(...)` obsluguje rozszerzony zestaw aliasow pol klienta i dostawy (`buyer/customer/client`, `billing_address`, `shipping_address`, `delivery_address`, `receiver`, warianty `first_name/last_name`, `postcode` itd.), + - adres dostawy jest zapisywany takze wtedy, gdy brak pelnej nazwy, ale istnieja inne dane adresowe, + - rozszerzono mapowanie `orders.customer_login` i `orders.external_carrier_id` o dodatkowe fallbacki z payloadu shopPRO. +- Poprawiono UX zakladki `Ustawienia` integracji `shopPRO`: + - ustawienia sa pogrupowane w sekcje `Pobieranie zamowien` oraz `Synchronizacja statusow`, + - dodano naglowki sekcji i opisy kontekstu, aby pola nie zlewaly sie wizualnie, + - dodano dedykowane style SCSS (`integration-settings-group*`) i przebudowano `public/assets/css/app.css`. +- Dodano cron synchronizacji statusow `shopPRO`: + - nowy handler `App\Modules\Cron\ShopproStatusSyncHandler`, + - nowy serwis `App\Modules\Settings\ShopproStatusSyncService`, + - nowy job `shoppro_order_status_sync` podlaczony do runnera w `App\Core\Application::maybeRunCronOnWeb(...)` i `bin/cron.php`. +- Rozszerzono `Ustawienia > Integracje > shopPRO > Ustawienia`: + - dodano wybor kierunku synchronizacji statusow (`shopPRO -> orderPRO`, `orderPRO -> shopPRO`), + - dodano pole interwalu synchronizacji statusow (minuty), + - zapis aktualizuje `integrations.order_status_sync_direction` i harmonogram `cron_schedules` dla `shoppro_order_status_sync`. +- Dodano migracje `20260308_000041_ensure_shoppro_status_sync_schedule_and_direction.sql`: + - seed/naprawa harmonogramu `shoppro_order_status_sync` (domyslnie 900s, priorytet 100), + - uzupelnienie kolumny `integrations.order_status_sync_direction` jesli brak. +- Rozszerzono `ShopproOrdersSyncService` o opcje uruchomienia filtrowanego po `integration_id` i z pominięciem flagi `orders_fetch_enabled` (wykorzystane przez cron synchronizacji statusow). +- Dodano cron importu zamowien z `shopPRO`: + - nowy handler `App\Modules\Cron\ShopproOrdersImportHandler`, + - nowy serwis `App\Modules\Settings\ShopproOrdersSyncService`, + - nowy klient API `App\Modules\Settings\ShopproApiClient`, + - job `shoppro_orders_import` jest podlaczony do wykonania zarowno w `App\Core\Application::maybeRunCronOnWeb(...)`, jak i w `bin/cron.php`. +- Rozszerzono `Ustawienia > Integracje > shopPRO > Ustawienia`: + - dodano pole interwalu pobierania zamowien (minuty), + - zapis aktualizuje harmonogram `cron_schedules` dla joba `shoppro_orders_import`. +- Dodano migracje `20260308_000040_ensure_shoppro_orders_import_schedule.sql`: + - seed/naprawa harmonogramu `shoppro_orders_import` (domyslnie 300s, priorytet 90). +- Zadanie #13 z `DOCS/todo.md`: dodano stronicowanie historii w `Ustawienia > Cron`: + - `CronSettingsController` pobiera `past_page` z query i przekazuje metadane paginacji do widoku, + - `CronRepository` rozszerzono o `countPastJobs()` oraz `listPastJobs(limit, offset)`, + - widok `settings/cron.php` renderuje kontrolki paginacji dla sekcji `Historia jobow (przeszle)`. +- Rozpoczeto ujednolicanie modelu integracji na baze `integrations`: + - dodano klasy wspolne `App\Modules\Settings\IntegrationsRepository` oraz `App\Modules\Settings\IntegrationSecretCipher`, + - ograniczono duplikacje szyfrowania sekretow integracji (wspolny cipher dla repozytoriow integracji). +- Migracja `20260308_000037_unify_integrations_base_links.sql`: + - dodaje `integration_id` do `allegro_integration_settings`, `apaczka_integration_settings`, `inpost_integration_settings`, + - podpina relacje 1:1 FK do `integrations`, + - seeduje bazowe rekordy providerow (`allegro`, `apaczka`, `inpost`) i backfilluje powiazania. +- `ApaczkaIntegrationRepository`: + - klucz API zapisuje/odczytuje z `integrations.api_key_encrypted` (`type=apaczka`). +- `InpostIntegrationRepository`: + - token API zapisuje/odczytuje z `integrations.api_key_encrypted` (`type=inpost`), + - ustawienia specyficzne przewoznika pozostaja w `inpost_integration_settings`. +- `AllegroIntegrationRepository`: + - zapewnia powiazanie aktywnego srodowiska OAuth z rekordem bazowym `integrations`, + - dodana metoda `getActiveIntegrationId()` pod spojnosc domeny zamowien/sync. +- `AllegroOrderImportService` i `AllegroOrdersSyncService`: + - przestaly uzywac stalej/`null` dla `integration_id`, + - korzystaja z aktywnego `integration_id` Allegro, co eliminuje sztywne zalozenie `integration_id=1`. +- Dodano wspolny ekran `Ustawienia > Integracje`: + - nowa route `GET /settings/integrations`, + - nowa klasa `App\Modules\Settings\IntegrationsHubController`, + - nawigacja boczna prowadzi do jednego huba integracji. +- Hub integracji zawiera tabele podsumowania oraz akcje per instancja: + - przycisk `Ustawienia` w kazdym wierszu prowadzi do dedykowanego ekranu zaawansowanego providera. +- Fix UI: rozciaganie przyciskow w formularzach (`.form-actions`) przy ukladzie grid: + - ustawiono `align-items: flex-start` oraz `align-self: flex-start` dla `.form-actions .btn`, + - eliminuje pionowe rozciaganie przyciskow do wysokosci sasiednich pol formularza. +- Dodano wieloinstancyjna konfiguracje integracji `shopPRO`: + - nowe endpointy: `GET /settings/integrations/shoppro`, `POST /settings/integrations/shoppro/save`, `POST /settings/integrations/shoppro/test`, + - nowa klasa `App\Modules\Settings\ShopproIntegrationsController`, + - nowy widok `resources/views/settings/shoppro.php` (lista instancji, formularz dodawania/edycji, test polaczenia), + - hub integracji (`/settings/integrations`) zawiera wiersz `shopPRO` z przejsciem do ekranu ustawien, + - dodano pomocnicze style `.table-row-actions` dla kompaktowych akcji w tabelach. +- Ekran `shopPRO` rozbudowano o zakladki analogiczne do Allegro: + - `Integracja`, `Statusy`, `Ustawienia`, `Formy dostawy`, + - `Ustawienia` zawiera pola `Pobieraj zamowienia` i `Data startu pobierania`, + - zapis/odczyt tych pol jest realizowany przez tabele bazowa `integrations` (`orders_fetch_enabled`, `orders_fetch_start_date`). +- Wdrozone mapowanie statusow shopPRO (zakladka `Statusy`): + - nowe endpointy: `POST /settings/integrations/shoppro/statuses/sync` oraz `POST /settings/integrations/shoppro/statuses/save`, + - dodana klasa `App\Modules\Settings\ShopproStatusMappingRepository`, + - synchronizacja statusow pobiera slownik `dictionaries/statuses` z API shopPRO, + - zapis mapowan trafia do `order_status_mappings` per `integration_id` (wieloinstancyjnie). +- Poprawiono parser statusow shopPRO: + - obsluguje odpowiedzi zagniezdzone w `data`, + - obsluguje rowniez format mapy `kod => nazwa` oraz dodatkowe aliasy pol (`status_code`, `status_name`, `symbol`, `slug`). +- Dodano migracje naprawcza `20260308_000038_ensure_order_status_mappings_table.sql`: + - tworzy `order_status_mappings` jesli tabela nie istnieje (scenariusz niepelnej historii migracji na srodowisku). +- Dodano migracje naprawcza `20260308_000039_ensure_integrations_fetch_columns.sql`: + - uzupelnia w `integrations` brakujace kolumny `orders_fetch_enabled` i `orders_fetch_start_date` + dla srodowisk, gdzie tabela `integrations` zostala odtworzona pozniej niz pierwotne migracje shopPRO. +- Poprawiono UX ekranu `shopPRO`: + - przy istniejacych instancjach automatycznie wybierana jest pierwsza integracja (bez koniecznosci wracania do zakladki `Integracja`), + - dodano przełącznik instancji nad zakladkami (`Wybrana integracja`) dostepny globalnie dla `Statusy/Ustawienia/Formy dostawy`. + ## 2026-03-06 - Fix: synchronizacja statusow Allegro nie aktualizowala zamowien. - Przyczyna: Allegro API nie zmienia `updatedAt` przy zmianie `fulfillment.status`. diff --git a/DOCS/todo.md b/DOCS/todo.md index d15a36f..e73e47b 100644 --- a/DOCS/todo.md +++ b/DOCS/todo.md @@ -1,15 +1,17 @@ -1. [x] 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. [x] Dodać rejestrację historii zamówień, i zmiana statusu rejestrowana w Historii zmian zamĂłwienia -3. [x] Pobranie zamĂłwienia rejestrowane w histori zmian zamĂłwienia -4. [x] Przy imporcie zamĂłwieĹ„ musi być pobierania forma wysyĹ‚ki. -5. [x] W szczegĂłlach zamĂłwienia dorobić opcjÄ™ zmiany statusu. -6. [x] W szczególach zamówienia 2 razy wyświetla się ID zamówienai z allegro, np: 008d3d60-1743-11f1-b15c-fdb4f87ccfc6 -7. [x] Przy imporcie z allegro liczba przesyłek jest 0. -8. [x] Kolumna LP w szczególach zamówienia jest zbyt szeroka. -9. [x] Na lisćie zamówień pole po którym jest domyślnie sortowana czyli data zamówienia jest puste. -10. [x] Na liście zamówień ukryć kolumnę ostatnia zmiana. -11. [x] W ustawieniach dodać zakładkę Integracja Apaczka. Dodać tam pierwsze ustawienie, czyli klucz API. -12. [] synchronizować ręczną zmianę statusu z allegro -13. [] W ustawieniach cron https://orderpro.projectpro.pl/settings/cron historia powinna mieć stronicowanie -14. [] border inputów, select, textarea, itd zrób troszkę ciemniejszy - +1. [x] 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. [x] Doda� rejestracj� historii zam�wie�, i zmiana statusu rejestrowana w Historii zmian zamówienia +3. [x] Pobranie zamówienia rejestrowane w histori zmian zamówienia +4. [x] Przy imporcie zamówień musi być pobierania forma wysyłki. +5. [x] W szczególach zamówienia dorobić opcję zmiany statusu. +6. [x] W szczeg�lach zam�wienia 2 razy wy�wietla si� ID zam�wienai z allegro, np: 008d3d60-1743-11f1-b15c-fdb4f87ccfc6 +7. [x] Przy imporcie z allegro liczba przesy�ek jest 0. +8. [x] Kolumna LP w szczeg�lach zam�wienia jest zbyt szeroka. +9. [x] Na lis�ie zam�wie� pole po kt�rym jest domy�lnie sortowana czyli data zam�wienia jest puste. +10. [x] Na li�cie zam�wie� ukry� kolumn� ostatnia zmiana. +11. [x] W ustawieniach doda� zak�adk� Integracja Apaczka. Doda� tam pierwsze ustawienie, czyli klucz API. +12. [] synchronizowa� r�czn� zmian� statusu z allegro +13. [x] W ustawieniach cron https://orderpro.projectpro.pl/settings/cron historia powinna mie� stronicowanie +14. [] border input�w, select, textarea, itd zr�b troszk� ciemniejszy +15. [] W tym miejscu odwróć kolejność: najpierw źródło potem ID,
f6079660-1af8-11f1-a7c9-231cf6ef29d1allegro
+16. [] Na liście zamówień statusy powinno być pokolorowane zgodnie z ustawieniami. +17. [] Na liście zamówien jak jest źródło i id zamówienia to zamiast shopPRO musi pisać która integracja konkretnie. Oraz dodajemy napis ID: ...D diff --git a/bin/cron.php b/bin/cron.php index 22f5fd4..6397d74 100644 --- a/bin/cron.php +++ b/bin/cron.php @@ -7,6 +7,9 @@ use App\Modules\Cron\AllegroStatusSyncHandler; use App\Modules\Cron\AllegroTokenRefreshHandler; use App\Modules\Cron\CronRepository; use App\Modules\Cron\CronRunner; +use App\Modules\Cron\ShopproOrdersImportHandler; +use App\Modules\Cron\ShopproPaymentStatusSyncHandler; +use App\Modules\Cron\ShopproStatusSyncHandler; use App\Modules\Orders\OrderImportRepository; use App\Modules\Orders\OrdersRepository; use App\Modules\Settings\AllegroApiClient; @@ -17,6 +20,12 @@ use App\Modules\Settings\AllegroOrderSyncStateRepository; use App\Modules\Settings\AllegroOAuthClient; use App\Modules\Settings\AllegroStatusSyncService; use App\Modules\Settings\AllegroStatusMappingRepository; +use App\Modules\Settings\ShopproApiClient; +use App\Modules\Settings\ShopproIntegrationsRepository; +use App\Modules\Settings\ShopproOrdersSyncService; +use App\Modules\Settings\ShopproPaymentStatusSyncService; +use App\Modules\Settings\ShopproStatusSyncService; +use App\Modules\Settings\ShopproStatusMappingRepository; /** @var Application $app */ $app = require dirname(__DIR__) . '/bootstrap/app.php'; @@ -51,6 +60,33 @@ $ordersSyncService = new AllegroOrdersSyncService( $apiClient, $orderImportService ); +$shopproSyncService = new ShopproOrdersSyncService( + new ShopproIntegrationsRepository( + $app->db(), + (string) $app->config('app.integrations.secret', '') + ), + new AllegroOrderSyncStateRepository($app->db()), + new ShopproApiClient(), + new OrderImportRepository($app->db()), + new ShopproStatusMappingRepository($app->db()), + new OrdersRepository($app->db()) +); +$shopproStatusSyncService = new ShopproStatusSyncService( + new ShopproIntegrationsRepository( + $app->db(), + (string) $app->config('app.integrations.secret', '') + ), + $shopproSyncService +); +$shopproPaymentSyncService = new ShopproPaymentStatusSyncService( + new ShopproIntegrationsRepository( + $app->db(), + (string) $app->config('app.integrations.secret', '') + ), + new ShopproApiClient(), + new OrdersRepository($app->db()), + $app->db() +); $runner = new CronRunner( $cronRepository, @@ -66,9 +102,19 @@ $runner = new CronRunner( 'allegro_status_sync' => new AllegroStatusSyncHandler( new AllegroStatusSyncService( $cronRepository, - $ordersSyncService + $orderImportService, + $app->db() ) ), + 'shoppro_orders_import' => new ShopproOrdersImportHandler( + $shopproSyncService + ), + 'shoppro_order_status_sync' => new ShopproStatusSyncHandler( + $shopproStatusSyncService + ), + 'shoppro_payment_status_sync' => new ShopproPaymentStatusSyncHandler( + $shopproPaymentSyncService + ), ] ); diff --git a/database/migrations/20260308_000037_unify_integrations_base_links.sql b/database/migrations/20260308_000037_unify_integrations_base_links.sql new file mode 100644 index 0000000..004a134 --- /dev/null +++ b/database/migrations/20260308_000037_unify_integrations_base_links.sql @@ -0,0 +1,175 @@ +CREATE TABLE IF NOT EXISTS integrations ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + type VARCHAR(32) NOT NULL, + name VARCHAR(128) NOT NULL, + base_url VARCHAR(255) NOT NULL, + api_key_encrypted TEXT NULL, + timeout_seconds SMALLINT UNSIGNED NOT NULL DEFAULT 10, + is_active TINYINT(1) NOT NULL DEFAULT 1, + last_test_status VARCHAR(16) NULL, + last_test_http_code SMALLINT UNSIGNED NULL, + last_test_message VARCHAR(255) NULL, + last_test_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 integrations_type_name_unique (type, name), + KEY integrations_type_active_idx (type, is_active), + KEY integrations_last_test_at_idx (last_test_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS integration_test_logs ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + integration_id INT UNSIGNED NOT NULL, + status VARCHAR(16) NOT NULL, + http_code SMALLINT UNSIGNED NULL, + message VARCHAR(255) NOT NULL, + endpoint_url VARCHAR(255) NULL, + tested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY integration_test_logs_integration_idx (integration_id), + KEY integration_test_logs_tested_at_idx (tested_at), + CONSTRAINT integration_test_logs_integration_fk + FOREIGN KEY (integration_id) REFERENCES integrations(id) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE `allegro_integration_settings` + ADD COLUMN IF NOT EXISTS `integration_id` INT UNSIGNED NULL AFTER `id`; + +ALTER TABLE `apaczka_integration_settings` + ADD COLUMN IF NOT EXISTS `integration_id` INT UNSIGNED NULL AFTER `id`; + +ALTER TABLE `inpost_integration_settings` + ADD COLUMN IF NOT EXISTS `integration_id` INT UNSIGNED NULL AFTER `id`; + +INSERT INTO `integrations` (`type`, `name`, `base_url`, `timeout_seconds`, `is_active`, `created_at`, `updated_at`) +VALUES + ('allegro', 'Allegro Sandbox', 'https://api.allegro.pl.allegrosandbox.pl', 20, 1, NOW(), NOW()), + ('allegro', 'Allegro Production', 'https://api.allegro.pl', 20, 1, NOW(), NOW()), + ('apaczka', 'Apaczka', 'https://www.apaczka.pl', 20, 1, NOW(), NOW()), + ('inpost', 'InPost ShipX', 'https://api-shipx-pl.easypack24.net', 20, 1, NOW(), NOW()) +ON DUPLICATE KEY UPDATE + `updated_at` = VALUES(`updated_at`); + +UPDATE `allegro_integration_settings` `s` +INNER JOIN `integrations` `i` + ON `i`.`type` = 'allegro' + AND `i`.`name` = CASE + WHEN `s`.`environment` = 'production' THEN 'Allegro Production' + ELSE 'Allegro Sandbox' + END +SET `s`.`integration_id` = `i`.`id` +WHERE `s`.`integration_id` IS NULL OR `s`.`integration_id` = 0; + +UPDATE `apaczka_integration_settings` `s` +INNER JOIN `integrations` `i` + ON `i`.`type` = 'apaczka' + AND `i`.`name` = 'Apaczka' +SET `s`.`integration_id` = `i`.`id` +WHERE `s`.`id` = 1 + AND (`s`.`integration_id` IS NULL OR `s`.`integration_id` = 0); + +UPDATE `inpost_integration_settings` `s` +INNER JOIN `integrations` `i` + ON `i`.`type` = 'inpost' + AND `i`.`name` = 'InPost ShipX' +SET `s`.`integration_id` = `i`.`id` +WHERE `s`.`id` = 1 + AND (`s`.`integration_id` IS NULL OR `s`.`integration_id` = 0); + +UPDATE `integrations` `i` +INNER JOIN `apaczka_integration_settings` `s` + ON `s`.`integration_id` = `i`.`id` +SET `i`.`api_key_encrypted` = `s`.`api_key_encrypted`, + `i`.`updated_at` = NOW() +WHERE `i`.`type` = 'apaczka' + AND (`i`.`api_key_encrypted` IS NULL OR `i`.`api_key_encrypted` = '') + AND `s`.`api_key_encrypted` IS NOT NULL + AND `s`.`api_key_encrypted` <> ''; + +UPDATE `integrations` `i` +INNER JOIN `inpost_integration_settings` `s` + ON `s`.`integration_id` = `i`.`id` +SET `i`.`api_key_encrypted` = `s`.`api_token_encrypted`, + `i`.`updated_at` = NOW() +WHERE `i`.`type` = 'inpost' + AND (`i`.`api_key_encrypted` IS NULL OR `i`.`api_key_encrypted` = '') + AND `s`.`api_token_encrypted` IS NOT NULL + AND `s`.`api_token_encrypted` <> ''; + +SET @sql := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `allegro_integration_settings` ADD UNIQUE KEY `allegro_integration_settings_integration_unique` (`integration_id`)', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'allegro_integration_settings' + AND index_name = 'allegro_integration_settings_integration_unique' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `apaczka_integration_settings` ADD UNIQUE KEY `apaczka_integration_settings_integration_unique` (`integration_id`)', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'apaczka_integration_settings' + AND index_name = 'apaczka_integration_settings_integration_unique' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `inpost_integration_settings` ADD UNIQUE KEY `inpost_integration_settings_integration_unique` (`integration_id`)', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'inpost_integration_settings' + AND index_name = 'inpost_integration_settings_integration_unique' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `allegro_integration_settings` ADD CONSTRAINT `allegro_integration_settings_integration_fk` FOREIGN KEY (`integration_id`) REFERENCES `integrations`(`id`) ON DELETE CASCADE ON UPDATE CASCADE', + 'SELECT 1') + FROM information_schema.table_constraints + WHERE constraint_schema = DATABASE() + AND table_name = 'allegro_integration_settings' + AND constraint_name = 'allegro_integration_settings_integration_fk' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `apaczka_integration_settings` ADD CONSTRAINT `apaczka_integration_settings_integration_fk` FOREIGN KEY (`integration_id`) REFERENCES `integrations`(`id`) ON DELETE CASCADE ON UPDATE CASCADE', + 'SELECT 1') + FROM information_schema.table_constraints + WHERE constraint_schema = DATABASE() + AND table_name = 'apaczka_integration_settings' + AND constraint_name = 'apaczka_integration_settings_integration_fk' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `inpost_integration_settings` ADD CONSTRAINT `inpost_integration_settings_integration_fk` FOREIGN KEY (`integration_id`) REFERENCES `integrations`(`id`) ON DELETE CASCADE ON UPDATE CASCADE', + 'SELECT 1') + FROM information_schema.table_constraints + WHERE constraint_schema = DATABASE() + AND table_name = 'inpost_integration_settings' + AND constraint_name = 'inpost_integration_settings_integration_fk' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/database/migrations/20260308_000038_ensure_order_status_mappings_table.sql b/database/migrations/20260308_000038_ensure_order_status_mappings_table.sql new file mode 100644 index 0000000..cef3299 --- /dev/null +++ b/database/migrations/20260308_000038_ensure_order_status_mappings_table.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS order_status_mappings ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + integration_id INT UNSIGNED NOT NULL, + shoppro_status_code VARCHAR(64) NOT NULL, + shoppro_status_name VARCHAR(128) 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 order_status_mappings_integration_shoppro_unique (integration_id, shoppro_status_code), + KEY order_status_mappings_integration_idx (integration_id), + KEY order_status_mappings_orderpro_idx (orderpro_status_code), + CONSTRAINT order_status_mappings_integration_fk + FOREIGN KEY (integration_id) REFERENCES integrations(id) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/migrations/20260308_000039_ensure_integrations_fetch_columns.sql b/database/migrations/20260308_000039_ensure_integrations_fetch_columns.sql new file mode 100644 index 0000000..c6ad2e1 --- /dev/null +++ b/database/migrations/20260308_000039_ensure_integrations_fetch_columns.sql @@ -0,0 +1,25 @@ +SET @sql := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `integrations` ADD COLUMN `orders_fetch_enabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `is_active`', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'integrations' + AND column_name = 'orders_fetch_enabled' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `integrations` ADD COLUMN `orders_fetch_start_date` DATE NULL AFTER `orders_fetch_enabled`', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'integrations' + AND column_name = 'orders_fetch_start_date' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/database/migrations/20260308_000040_ensure_shoppro_orders_import_schedule.sql b/database/migrations/20260308_000040_ensure_shoppro_orders_import_schedule.sql new file mode 100644 index 0000000..65e9559 --- /dev/null +++ b/database/migrations/20260308_000040_ensure_shoppro_orders_import_schedule.sql @@ -0,0 +1,19 @@ +INSERT INTO cron_schedules ( + job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at, created_at, updated_at +) VALUES ( + 'shoppro_orders_import', + 300, + 90, + 3, + NULL, + 1, + NULL, + NOW(), + NOW(), + NOW() +) +ON DUPLICATE KEY UPDATE + interval_seconds = IFNULL(interval_seconds, VALUES(interval_seconds)), + priority = IFNULL(priority, VALUES(priority)), + max_attempts = IFNULL(max_attempts, VALUES(max_attempts)), + updated_at = VALUES(updated_at); diff --git a/database/migrations/20260308_000041_ensure_shoppro_status_sync_schedule_and_direction.sql b/database/migrations/20260308_000041_ensure_shoppro_status_sync_schedule_and_direction.sql new file mode 100644 index 0000000..9c63116 --- /dev/null +++ b/database/migrations/20260308_000041_ensure_shoppro_status_sync_schedule_and_direction.sql @@ -0,0 +1,32 @@ +SET @sql := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `integrations` ADD COLUMN `order_status_sync_direction` VARCHAR(32) NOT NULL DEFAULT ''shoppro_to_orderpro'' AFTER `orders_fetch_start_date`', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'integrations' + AND column_name = 'order_status_sync_direction' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +INSERT INTO cron_schedules ( + job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at, created_at, updated_at +) VALUES ( + 'shoppro_order_status_sync', + 900, + 100, + 3, + NULL, + 1, + NULL, + NOW(), + NOW(), + NOW() +) +ON DUPLICATE KEY UPDATE + interval_seconds = IFNULL(interval_seconds, VALUES(interval_seconds)), + priority = IFNULL(priority, VALUES(priority)), + max_attempts = IFNULL(max_attempts, VALUES(max_attempts)), + updated_at = VALUES(updated_at); diff --git a/database/migrations/20260308_000042_ensure_shoppro_payment_sync_schedule_and_columns.sql b/database/migrations/20260308_000042_ensure_shoppro_payment_sync_schedule_and_columns.sql new file mode 100644 index 0000000..07768f0 --- /dev/null +++ b/database/migrations/20260308_000042_ensure_shoppro_payment_sync_schedule_and_columns.sql @@ -0,0 +1,32 @@ +SET @sql := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `integrations` ADD COLUMN `payment_sync_status_codes_json` JSON NULL AFTER `order_status_sync_direction`', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'integrations' + AND column_name = 'payment_sync_status_codes_json' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +INSERT INTO cron_schedules ( + job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at, created_at, updated_at +) VALUES ( + 'shoppro_payment_status_sync', + 600, + 105, + 3, + NULL, + 1, + NULL, + NOW(), + NOW(), + NOW() +) +ON DUPLICATE KEY UPDATE + interval_seconds = IFNULL(interval_seconds, VALUES(interval_seconds)), + priority = IFNULL(priority, VALUES(priority)), + max_attempts = IFNULL(max_attempts, VALUES(max_attempts)), + updated_at = VALUES(updated_at); diff --git a/database/migrations/20260308_000043_create_shoppro_delivery_method_mappings_table.sql b/database/migrations/20260308_000043_create_shoppro_delivery_method_mappings_table.sql new file mode 100644 index 0000000..ba89a2c --- /dev/null +++ b/database/migrations/20260308_000043_create_shoppro_delivery_method_mappings_table.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS shoppro_delivery_method_mappings ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + integration_id INT UNSIGNED NOT NULL, + order_delivery_method VARCHAR(200) NOT NULL, + carrier VARCHAR(50) NOT NULL DEFAULT 'allegro', + allegro_delivery_method_id VARCHAR(128) NOT NULL, + allegro_credentials_id VARCHAR(128) NULL, + allegro_carrier_id VARCHAR(128) NULL, + allegro_service_name VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY shoppro_dm_mapping_unique (integration_id, order_delivery_method), + KEY shoppro_dm_mapping_integration_idx (integration_id), + CONSTRAINT shoppro_dm_mapping_integration_fk + FOREIGN KEY (integration_id) REFERENCES integrations(id) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/public/assets/css/app.css b/public/assets/css/app.css index b65572b..c3f67d5 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -1,2291 +1 @@ -@charset "UTF-8"; -: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 transparent; - border-radius: 8px; - font: inherit; - font-weight: 600; - text-decoration: none; - cursor: pointer; - transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.1s ease; -} - -.btn--primary { - color: #ffffff; - 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: #ffffff; - border-color: #b91c1c; - background: #dc2626; -} - -.btn--danger:hover { - border-color: #991b1b; - background: #b91c1c; -} - -.btn--sm { - min-height: 28px; - padding: 3px 10px; - font-size: 12px; -} - -.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: #ffffff; - transition: border-color 0.2s ease, box-shadow 0.2s ease; -} - -.form-control:focus { - outline: none; - border-color: var(--c-primary); - box-shadow: var(--focus-ring); -} - -.input { - min-height: 34px; - border: 1px solid var(--c-border); - border-radius: 8px; - padding: 5px 10px; - font: inherit; - color: var(--c-text-strong); - background: #ffffff; -} - -.input--sm { - min-height: 28px; - padding: 3px 8px; - font-size: 12px; -} - -.flash { - padding: 8px 12px; - border-radius: 6px; - font-size: 13px; -} - -.flash--success { - border: 1px solid #b7ebcf; - background: #f0fff6; - color: #0f6b39; -} - -.flash--error { - border: 1px solid #fed7d7; - background: #fff5f5; - color: var(--c-danger); -} - -.alert { - padding: 12px 14px; - border-radius: 8px; - border: 1px solid transparent; - 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-wrap--visible { - overflow: visible !important; - overflow-x: visible !important; -} - -.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; -} - -.table--details th:first-child, -.table--details td:first-child { - width: 36px; - text-align: center; -} - -.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: flex; -} - -.sidebar { - width: 260px; - min-width: 260px; - flex-shrink: 0; - overflow: hidden; - transition: width 0.22s ease, min-width 0.22s ease; - border-right: 1px solid #243041; - background: #111a28; - padding: 18px 10px; - display: flex; - flex-direction: column; -} - -.sidebar.is-collapsed { - width: 52px; - min-width: 52px; -} - -.sidebar__brand { - display: flex; - align-items: center; - justify-content: space-between; - margin: 4px 4px 16px; - gap: 6px; - min-width: 0; -} - -.sidebar__brand-text { - color: #e9f0ff; - font-size: 24px; - font-weight: 300; - letter-spacing: -0.02em; - white-space: nowrap; - overflow: hidden; - flex: 1; - min-width: 0; -} -.sidebar__brand-text strong { - font-weight: 700; -} - -.sidebar__collapse-btn { - flex-shrink: 0; - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - background: transparent; - border: 1px solid #2a3a54; - border-radius: 6px; - color: #64748b; - cursor: pointer; - padding: 0; - transition: background 0.15s, color 0.15s; -} -.sidebar__collapse-btn:hover { - background: #1b2a3f; - color: #cbd5e1; -} - -.sidebar__collapse-icon { - display: block; - transition: transform 0.22s ease; - flex-shrink: 0; -} - -.sidebar.is-collapsed .sidebar__collapse-icon { - transform: rotate(180deg); -} - -.sidebar__nav { - display: grid; - gap: 4px; -} - -.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: #ffffff; - background: #2e4f93; -} - -.sidebar__group { - display: grid; - gap: 2px; -} - -.sidebar__group-toggle { - list-style: none; - border-radius: 8px; - padding: 9px 10px; - color: #cbd5e1; - font-weight: 600; - cursor: pointer; - display: flex; - align-items: center; - gap: 9px; - white-space: nowrap; - user-select: none; -} - -.sidebar__group-toggle::-webkit-details-marker { - display: none; -} - -.sidebar__group:hover .sidebar__group-toggle, -.sidebar__group-toggle:hover { - color: #f8fafc; - background: #1b2a3f; -} - -.sidebar__group.is-active .sidebar__group-toggle { - color: #ffffff; - background: #2e4f93; -} - -.sidebar__icon { - flex-shrink: 0; - width: 18px; - height: 18px; - display: flex; - align-items: center; - justify-content: center; - opacity: 0.85; -} - -.sidebar__label { - flex: 1; - min-width: 0; - overflow: hidden; -} - -.sidebar__toggle-arrow { - flex-shrink: 0; - margin-left: auto; - opacity: 0.5; - transition: transform 0.18s ease; -} - -details[open] > .sidebar__group-toggle .sidebar__toggle-arrow { - transform: rotate(180deg); -} - -.sidebar__group-links { - display: grid; - gap: 2px; - padding-left: 12px; - overflow: hidden; -} - -.sidebar__sublink { - border-radius: 6px; - padding: 7px 10px 7px 8px; - text-decoration: none; - color: #94a3b8; - font-size: 12.5px; - font-weight: 500; - display: flex; - align-items: center; - gap: 8px; - white-space: nowrap; -} -.sidebar__sublink::before { - content: ""; - flex-shrink: 0; - width: 5px; - height: 5px; - border-radius: 50%; - background: rgba(148, 163, 184, 0.3); - transition: background 0.15s; -} -.sidebar__sublink:hover { - color: #e2e8f0; - background: #1b2a3f; -} -.sidebar__sublink:hover::before { - background: rgba(148, 163, 184, 0.65); -} -.sidebar__sublink.is-active { - color: #ffffff; - background: rgba(46, 79, 147, 0.55); -} -.sidebar__sublink.is-active::before { - background: #93c5fd; -} - -.app-main { - flex: 1; - 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-0 { - margin-top: 0; -} - -.mt-4 { - margin-top: 4px; -} - -.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-grid-2 { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} - -.form-grid-3 { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; -} - -.form-grid-4 { - display: grid; - grid-template-columns: repeat(4, 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, 0.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: 0.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, 0.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, 0.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, 0.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 0.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 0.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, 0.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: 0.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; -} -.order-tag.is-cod { - border-color: #f9a8d4; - background: #fdf2f8; - color: #9d174d; -} -.order-tag.is-unpaid { - border-color: #fca5a5; - background: #fef2f2; - color: #b91c1c; -} - -.orders-mini { - font-size: 14px; - color: #223247; - line-height: 1.25; -} -.orders-mini__delivery { - font-size: 12px; - color: #64748b; - margin-bottom: 2px; - max-width: 160px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.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-hover-wrap { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: zoom-in; -} - -.orders-image-hover-popup { - display: none; - position: fixed; - left: auto; - top: auto; - width: 350px; - max-height: 350px; - object-fit: contain; - border-radius: 8px; - background: #fff; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); - border: 1px solid #dfe3ea; - z-index: 100; - pointer-events: none; -} - -.orders-image-hover-wrap:hover .orders-image-hover-popup { - display: block; -} - -.activity-type-badge { - display: inline-block; - padding: 2px 8px; - border-radius: 4px; - font-size: 12px; - font-weight: 500; - white-space: nowrap; - background: #e2e8f0; - color: #334155; -} -.activity-type-badge--status_change { - background: #dbeafe; - color: #1e40af; -} -.activity-type-badge--payment { - background: #dcfce7; - color: #166534; -} -.activity-type-badge--invoice { - background: #fef3c7; - color: #92400e; -} -.activity-type-badge--shipment { - background: #e0e7ff; - color: #3730a3; -} -.activity-type-badge--message { - background: #f3e8ff; - color: #6b21a8; -} -.activity-type-badge--document { - background: #fce7f3; - color: #9d174d; -} -.activity-type-badge--import { - background: #f1f5f9; - color: #475569; -} -.activity-type-badge--note { - background: #ecfdf5; - color: #065f46; -} - -.text-nowrap { - white-space: nowrap; -} - -.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: 0.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: #ffffff; -} - -.order-status-row:hover { - background: #f1f5f9; -} - -.order-status-row.is-active { - background: rgba(15, 23, 42, 0.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-status-change { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.order-status-change__form { - display: flex; - align-items: center; - gap: 6px; -} - -.order-status-change__select { - min-width: 180px; -} - -.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; -} - -.payment-summary { - display: grid; - gap: 6px; - max-width: 420px; -} - -.payment-summary__row { - display: flex; - align-items: center; - gap: 10px; - font-size: 12px; -} - -.payment-summary__label { - width: 150px; - flex-shrink: 0; - color: #64748b; -} - -.payment-summary__value { - font-weight: 600; - color: #0f172a; -} - -.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: transparent; - 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; -} - -.shipment-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} - -.searchable-select { - position: relative; -} -.searchable-select__trigger { - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - user-select: none; - min-height: 34px; -} -.searchable-select__trigger::after { - content: ""; - width: 0; - height: 0; - border-left: 4px solid transparent; - border-right: 4px solid transparent; - border-top: 5px solid var(--c-text-muted, #6b7280); - margin-left: 8px; - flex-shrink: 0; -} -.searchable-select__trigger--placeholder { - color: var(--c-text-muted, #6b7280); -} -.searchable-select__dropdown { - display: none; - position: absolute; - left: 0; - right: 0; - top: 100%; - z-index: 50; - max-height: 280px; - overflow: auto; - background: #fff; - border: 1px solid var(--c-border); - border-top: 0; - border-radius: 0 0 8px 8px; - box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12); -} -.searchable-select__dropdown.is-open { - display: block; -} -.searchable-select__search { - position: sticky; - top: 0; - border: none !important; - border-bottom: 1px solid var(--c-border) !important; - border-radius: 0 !important; - box-shadow: none !important; - font-size: 13px; - background: #fff; - z-index: 1; -} -.searchable-select__option { - padding: 7px 10px; - font-size: 13px; - cursor: pointer; - color: var(--c-text-strong); -} -.searchable-select__option:hover { - background: #f1f5f9; -} -.searchable-select__option.is-selected { - background: #edf2ff; - font-weight: 600; -} - -.flash { - padding: 10px 14px; - border-radius: 8px; - font-size: 13px; - font-weight: 500; -} -.flash--success { - background: #f0fdf4; - border: 1px solid #bbf7d0; - color: #166534; -} -.flash--error { - background: #fef2f2; - border: 1px solid #fecaca; - color: #b91c1c; -} - -.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 transparent; - margin-bottom: -2px; - border-radius: 4px 4px 0 0; - transition: color 0.15s, border-color 0.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 { - flex-direction: column; - } - .sidebar { - width: 100% !important; - min-width: 0 !important; - border-right: 0; - border-bottom: 1px solid #243041; - padding: 14px; - overflow-x: auto; - } - .sidebar__brand { - margin: 0 0 10px; - font-size: 22px; - } - .sidebar__collapse-btn { - display: none; - } - .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, - .form-grid-2, - .form-grid-3, - .form-grid-4, - .shipment-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--sm{min-height:28px;padding:3px 10px;font-size:12px}.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)}.input{min-height:34px;border:1px solid var(--c-border);border-radius:8px;padding:5px 10px;font:inherit;color:var(--c-text-strong);background:#fff}.input--sm{min-height:28px;padding:3px 8px;font-size:12px}.flash{padding:8px 12px;border-radius:6px;font-size:13px}.flash--success{border:1px solid #b7ebcf;background:#f0fff6;color:#0f6b39}.flash--error{border:1px solid #fed7d7;background:#fff5f5;color:var(--c-danger)}.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-wrap--visible{overflow:visible !important;overflow-x:visible !important}.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}.table--details th:first-child,.table--details td:first-child{width:36px;text-align:center}.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:flex}.sidebar{width:260px;min-width:260px;flex-shrink:0;overflow:hidden;transition:width .22s ease,min-width .22s ease;border-right:1px solid #243041;background:#111a28;padding:18px 10px;display:flex;flex-direction:column}.sidebar.is-collapsed{width:52px;min-width:52px}.sidebar__brand{display:flex;align-items:center;justify-content:space-between;margin:4px 4px 16px;gap:6px;min-width:0}.sidebar__brand-text{color:#e9f0ff;font-size:24px;font-weight:300;letter-spacing:-0.02em;white-space:nowrap;overflow:hidden;flex:1;min-width:0}.sidebar__brand-text strong{font-weight:700}.sidebar__collapse-btn{flex-shrink:0;width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0);border:1px solid #2a3a54;border-radius:6px;color:#64748b;cursor:pointer;padding:0;transition:background .15s,color .15s}.sidebar__collapse-btn:hover{background:#1b2a3f;color:#cbd5e1}.sidebar__collapse-icon{display:block;transition:transform .22s ease;flex-shrink:0}.sidebar.is-collapsed .sidebar__collapse-icon{transform:rotate(180deg)}.sidebar__nav{display:grid;gap:4px}.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:2px}.sidebar__group-toggle{list-style:none;border-radius:8px;padding:9px 10px;color:#cbd5e1;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:9px;white-space:nowrap;user-select:none}.sidebar__group-toggle::-webkit-details-marker{display:none}.sidebar__group:hover .sidebar__group-toggle,.sidebar__group-toggle:hover{color:#f8fafc;background:#1b2a3f}.sidebar__group.is-active .sidebar__group-toggle{color:#fff;background:#2e4f93}.sidebar__icon{flex-shrink:0;width:18px;height:18px;display:flex;align-items:center;justify-content:center;opacity:.85}.sidebar__label{flex:1;min-width:0;overflow:hidden}.sidebar__toggle-arrow{flex-shrink:0;margin-left:auto;opacity:.5;transition:transform .18s ease}details[open]>.sidebar__group-toggle .sidebar__toggle-arrow{transform:rotate(180deg)}.sidebar__group-links{display:grid;gap:2px;padding-left:12px;overflow:hidden}.sidebar__sublink{border-radius:6px;padding:7px 10px 7px 8px;text-decoration:none;color:#94a3b8;font-size:12.5px;font-weight:500;display:flex;align-items:center;gap:8px;white-space:nowrap}.sidebar__sublink::before{content:"";flex-shrink:0;width:5px;height:5px;border-radius:50%;background:rgba(148,163,184,.3);transition:background .15s}.sidebar__sublink:hover{color:#e2e8f0;background:#1b2a3f}.sidebar__sublink:hover::before{background:rgba(148,163,184,.65)}.sidebar__sublink.is-active{color:#fff;background:rgba(46,79,147,.55)}.sidebar__sublink.is-active::before{background:#93c5fd}.app-main{flex:1;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-0{margin-top:0}.mt-4{margin-top:4px}.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-grid-2{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:12px}.form-grid-3{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:12px}.form-grid-4{display:grid;grid-template-columns:repeat(4, minmax(0, 1fr));gap:12px}.form-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-start}.form-actions .btn{align-self:flex-start}.statuses-form{display:grid;gap:8px;grid-template-columns:repeat(2, minmax(0, 1fr))}.statuses-form .form-actions{grid-column:1/-1}.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-row-actions{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap}.table-row-actions form{margin:0}.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}.order-tag.is-cod{border-color:#f9a8d4;background:#fdf2f8;color:#9d174d}.order-tag.is-unpaid{border-color:#fca5a5;background:#fef2f2;color:#b91c1c}.orders-mini{font-size:14px;color:#223247;line-height:1.25}.orders-mini__delivery{font-size:12px;color:#64748b;margin-bottom:2px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.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-hover-wrap{position:relative;display:inline-flex;align-items:center;justify-content:center;cursor:zoom-in}.orders-image-hover-popup{display:none;position:fixed;left:auto;top:auto;width:350px;max-height:350px;object-fit:contain;border-radius:8px;background:#fff;box-shadow:0 8px 24px rgba(0,0,0,.18);border:1px solid #dfe3ea;z-index:100;pointer-events:none}.orders-image-hover-wrap:hover .orders-image-hover-popup{display:block}.activity-type-badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:500;white-space:nowrap;background:#e2e8f0;color:#334155}.activity-type-badge--status_change{background:#dbeafe;color:#1e40af}.activity-type-badge--payment{background:#dcfce7;color:#166534}.activity-type-badge--invoice{background:#fef3c7;color:#92400e}.activity-type-badge--shipment{background:#e0e7ff;color:#3730a3}.activity-type-badge--message{background:#f3e8ff;color:#6b21a8}.activity-type-badge--document{background:#fce7f3;color:#9d174d}.activity-type-badge--import{background:#f1f5f9;color:#475569}.activity-type-badge--note{background:#ecfdf5;color:#065f46}.text-nowrap{white-space:nowrap}.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-status-change{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.order-status-change__form{display:flex;align-items:center;gap:6px}.order-status-change__select{min-width:180px}.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}.payment-summary{display:grid;gap:6px;max-width:420px}.payment-summary__row{display:flex;align-items:center;gap:10px;font-size:12px}.payment-summary__label{width:150px;flex-shrink:0;color:#64748b}.payment-summary__value{font-weight:600;color:#0f172a}.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}.shipment-grid{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:12px}.searchable-select{position:relative}.searchable-select__trigger{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;min-height:34px}.searchable-select__trigger::after{content:"";width:0;height:0;border-left:4px solid rgba(0,0,0,0);border-right:4px solid rgba(0,0,0,0);border-top:5px solid var(--c-text-muted, #6b7280);margin-left:8px;flex-shrink:0}.searchable-select__trigger--placeholder{color:var(--c-text-muted, #6b7280)}.searchable-select__dropdown{display:none;position:absolute;left:0;right:0;top:100%;z-index:50;max-height:280px;overflow:auto;background:#fff;border:1px solid var(--c-border);border-top:0;border-radius:0 0 8px 8px;box-shadow:0 8px 20px rgba(15,23,42,.12)}.searchable-select__dropdown.is-open{display:block}.searchable-select__search{position:sticky;top:0;border:none !important;border-bottom:1px solid var(--c-border) !important;border-radius:0 !important;box-shadow:none !important;font-size:13px;background:#fff;z-index:1}.searchable-select__option{padding:7px 10px;font-size:13px;cursor:pointer;color:var(--c-text-strong)}.searchable-select__option:hover{background:#f1f5f9}.searchable-select__option.is-selected{background:#edf2ff;font-weight:600}.flash{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.flash--success{background:#f0fdf4;border:1px solid #bbf7d0;color:#166534}.flash--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.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}.shoppro-tabs-toolbar{display:flex;align-items:flex-end;justify-content:space-between;gap:10px;margin-bottom:10px;flex-wrap:wrap}.shoppro-tabs-toolbar__field{margin:0;min-width:260px;max-width:420px;flex:1 1 320px}.shoppro-tabs-toolbar__field .form-control{width:100%}.shoppro-tabs-toolbar__actions{display:inline-flex;align-items:center;gap:8px}.integration-settings-group{grid-column:1/-1;border:1px solid var(--c-border);border-radius:10px;background:#f8fbff;padding:10px}.integration-settings-group__head{margin-bottom:8px;padding:4px 8px;border-left:3px solid var(--c-primary, #2563eb);background:#eef4ff;border-radius:6px}.integration-settings-group__title{margin:0;font-size:14px;font-weight:700;letter-spacing:.01em;color:#1e3a8a}.integration-settings-group__desc{margin:4px 0 0;color:#4b5563}.integration-settings-group__grid{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:10px 12px;align-items:start}.integration-settings-group__full{grid-column:1/-1}.integration-settings-group__grid .form-field{margin:0;align-self:start}.integration-settings-group__grid .form-control{min-height:34px;height:34px}.integration-settings-group__grid input[type=date].form-control{line-height:1.2}.integration-settings-checkboxes{border:0;padding:0;margin:0}.integration-settings-checkboxes .field-label{display:block;margin-bottom:2px}.integration-settings-checkboxes__list{display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:6px 12px}.integration-settings-checkboxes__item{display:inline-flex;align-items:center;gap:6px;font-size:13px;color:#334155}@media(max-width: 768px){.app-shell{flex-direction:column}.sidebar{width:100% !important;min-width:0 !important;border-right:0;border-bottom:1px solid #243041;padding:14px;overflow-x:auto}.sidebar__brand{margin:0 0 10px;font-size:22px}.sidebar__collapse-btn{display:none}.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,.form-grid-2,.form-grid-3,.form-grid-4,.shipment-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}.integration-settings-group__grid{grid-template-columns:1fr}.integration-settings-checkboxes__list{grid-template-columns:1fr}.card{padding:12px}.modal--image-preview{width:min(92vw,100%)}} diff --git a/public/assets/css/login.css b/public/assets/css/login.css index 243bf94..fb27907 100644 --- a/public/assets/css/login.css +++ b/public/assets/css/login.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}:root{--shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14)}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;color:var(--c-text);background:var(--c-bg);overflow-x:hidden}.bg-orb{position:fixed;width:460px;height:460px;border-radius:999px;filter:blur(28px);z-index:0;opacity:.45;pointer-events:none}.bg-orb-left{top:-200px;left:-180px;background:radial-gradient(circle, rgba(102, 144, 244, 0.48) 0%, rgba(102, 144, 244, 0) 70%)}.bg-orb-right{right:-200px;bottom:-220px;background:radial-gradient(circle, rgba(30, 42, 58, 0.36) 0%, rgba(30, 42, 58, 0) 70%)}.login-page{min-height:100vh;display:grid;place-items:center;padding:32px 20px;position:relative;z-index:1}.login-card{width:100%;max-width:430px;background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;box-shadow:var(--shadow-card);padding:34px 30px 28px;animation:card-enter 420ms ease-out}.login-header{margin-bottom:24px}.login-badge{display:inline-block;margin:0 0 14px;padding:5px 12px;border-radius:999px;border:1px solid #d9e2ff;background:#eef2ff;color:#3f5faf;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}h1{margin:0;color:var(--c-text-strong);font-size:clamp(1.6rem,2.5vw,1.9rem);line-height:1.15;font-weight:700}.login-subtitle{margin:10px 0 0;font-size:15px;line-height:1.55;color:var(--c-muted)}.login-alert{margin-bottom:18px}.login-alert-placeholder{opacity:.56}.login-form{display:grid;gap:16px}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.login-form .form-control{min-height:46px;padding:0 14px;border-width:2px}.login-form .form-control::placeholder{color:#cbd5e0}.login-submit{margin-top:2px;font-size:15px;min-height:48px}@keyframes card-enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@media(max-width: 640px){.login-page{padding:18px 14px}.login-card{padding:24px 20px 20px}h1{font-size:1.55rem}} +: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--sm{min-height:28px;padding:3px 10px;font-size:12px}.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)}.input{min-height:34px;border:1px solid var(--c-border);border-radius:8px;padding:5px 10px;font:inherit;color:var(--c-text-strong);background:#fff}.input--sm{min-height:28px;padding:3px 8px;font-size:12px}.flash{padding:8px 12px;border-radius:6px;font-size:13px}.flash--success{border:1px solid #b7ebcf;background:#f0fff6;color:#0f6b39}.flash--error{border:1px solid #fed7d7;background:#fff5f5;color:var(--c-danger)}.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-wrap--visible{overflow:visible !important;overflow-x:visible !important}.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}.table--details th:first-child,.table--details td:first-child{width:36px;text-align:center}.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}:root{--shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14)}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;color:var(--c-text);background:var(--c-bg);overflow-x:hidden}.bg-orb{position:fixed;width:460px;height:460px;border-radius:999px;filter:blur(28px);z-index:0;opacity:.45;pointer-events:none}.bg-orb-left{top:-200px;left:-180px;background:radial-gradient(circle, rgba(102, 144, 244, 0.48) 0%, rgba(102, 144, 244, 0) 70%)}.bg-orb-right{right:-200px;bottom:-220px;background:radial-gradient(circle, rgba(30, 42, 58, 0.36) 0%, rgba(30, 42, 58, 0) 70%)}.login-page{min-height:100vh;display:grid;place-items:center;padding:32px 20px;position:relative;z-index:1}.login-card{width:100%;max-width:430px;background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;box-shadow:var(--shadow-card);padding:34px 30px 28px;animation:card-enter 420ms ease-out}.login-header{margin-bottom:24px}.login-badge{display:inline-block;margin:0 0 14px;padding:5px 12px;border-radius:999px;border:1px solid #d9e2ff;background:#eef2ff;color:#3f5faf;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}h1{margin:0;color:var(--c-text-strong);font-size:clamp(1.6rem,2.5vw,1.9rem);line-height:1.15;font-weight:700}.login-subtitle{margin:10px 0 0;font-size:15px;line-height:1.55;color:var(--c-muted)}.login-alert{margin-bottom:18px}.login-alert-placeholder{opacity:.56}.login-form{display:grid;gap:16px}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.login-form .form-control{min-height:46px;padding:0 14px;border-width:2px}.login-form .form-control::placeholder{color:#cbd5e0}.login-submit{margin-top:2px;font-size:15px;min-height:48px}@keyframes card-enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@media(max-width: 640px){.login-page{padding:18px 14px}.login-card{padding:24px 20px 20px}h1{font-size:1.55rem}} diff --git a/resources/lang/pl.php b/resources/lang/pl.php index c1b31de..4ede57c 100644 --- a/resources/lang/pl.php +++ b/resources/lang/pl.php @@ -28,6 +28,7 @@ return [ 'dashboard' => 'Dashboard', 'settings' => 'Ustawienia', 'statuses' => 'Statusy', + 'integrations' => 'Integracje', 'allegro' => 'Integracje Allegro', 'apaczka' => 'Integracja Apaczka', 'inpost' => 'Integracja InPost', @@ -469,6 +470,46 @@ return [ 'title' => 'Ustawienia', 'description' => 'Konfiguracja i narzedzia administracyjne systemu.', 'submenu_label' => 'Sekcje ustawien', + 'integrations_hub' => [ + 'title' => 'Integracje', + 'description' => 'Wspolny panel konfiguracji wszystkich providerow.', + 'list_title' => 'Skonfigurowane integracje', + 'empty' => 'Brak dostepnych integracji.', + 'fields' => [ + 'provider' => 'Provider', + 'instance' => 'Instancja', + 'authorization' => 'Status polaczenia', + 'secret' => 'Sekret API', + 'active' => 'Aktywna', + 'last_test' => 'Ostatni test', + 'actions' => 'Akcje', + ], + 'providers' => [ + 'allegro' => 'Allegro', + 'allegro_sandbox' => 'Allegro Sandbox', + 'allegro_production' => 'Allegro Production', + 'apaczka' => 'Apaczka', + 'inpost' => 'InPost', + 'shoppro' => 'shopPRO', + 'shoppro_instances' => ':count instancji', + ], + 'status' => [ + 'connected' => 'Polaczono', + 'not_connected' => 'Brak polaczenia', + 'configured' => 'Skonfigurowana', + 'not_configured' => 'Brak konfiguracji', + 'saved' => 'Zapisany', + 'missing' => 'Brak', + ], + 'active' => [ + 'yes' => 'Tak', + 'no' => 'Nie', + ], + 'actions' => [ + 'configure' => 'Konfiguruj', + 'settings' => 'Ustawienia', + ], + ], 'database' => [ 'title' => 'Baza danych', 'state' => [ @@ -800,10 +841,80 @@ return [ ], 'integrations' => [ 'title' => 'Integracje shopPRO', + 'description' => 'W tym miejscu konfigurujesz wiele niezaleznych instancji shopPRO.', 'list_title' => 'Integracje shopPRO', 'create_title' => 'Dodaj integracje', 'edit_title' => 'Edytuj integracje', 'empty' => 'Brak skonfigurowanych integracji.', + 'tabs' => [ + 'label' => 'Zakladki integracji shopPRO', + 'integration' => 'Integracja', + 'statuses' => 'Statusy', + 'settings' => 'Ustawienia', + 'delivery' => 'Formy dostawy', + ], + 'selector' => [ + 'integration' => 'Wybrana integracja', + ], + 'statuses' => [ + 'title' => 'Statusy', + 'description' => 'Mapowanie statusow zamowien pomiedzy shopPRO i orderPRO.', + 'empty' => 'Brak statusow do mapowania. Uzyj przycisku pobrania statusow.', + 'select_integration_first' => 'Najpierw wybierz lub zapisz integracje w zakladce Integracja.', + 'actions' => [ + 'sync' => 'Pobierz statusy z shopPRO', + ], + 'flash' => [ + 'sync_ok' => 'Pobrano statusy shopPRO. Rozpoznane statusy: :count.', + 'sync_failed' => 'Nie udalo sie pobrac statusow shopPRO.', + 'saved' => 'Mapowanie statusow zostalo zapisane.', + 'save_failed' => 'Nie udalo sie zapisac mapowania statusow.', + 'invalid_payload' => 'Niepoprawne dane mapowania statusow.', + ], + ], + 'settings' => [ + 'title' => 'Ustawienia synchronizacji', + 'description' => 'Parametry automatycznego pobierania zamowien shopPRO.', + 'select_integration_first' => 'Najpierw wybierz lub zapisz integracje w zakladce Integracja.', + 'orders_group_title' => 'Pobieranie zamowien', + 'orders_group_description' => 'Ustawienia automatycznego importu zamowien z shopPRO.', + 'orders_import_interval_minutes' => 'Interwal pobierania zamowien (minuty)', + 'orders_import_interval_hint' => 'Zakres: 1-1440 minut. Dotyczy harmonogramu joba shoppro_orders_import.', + 'statuses_group_title' => 'Synchronizacja statusow', + 'statuses_group_description' => 'Ustawienia harmonogramu i kierunku synchronizacji statusow.', + 'status_sync_direction_hint' => 'Aktualnie aktywny jest kierunek shopPRO -> orderPRO. Ustawienie orderPRO -> shopPRO jest przygotowane pod kolejny etap.', + 'status_sync_interval_minutes' => 'Interwal synchronizacji statusow (minuty)', + 'status_sync_interval_hint' => 'Zakres: 1-1440 minut. Dotyczy harmonogramu joba shoppro_order_status_sync.', + 'payment_group_title' => 'Synchronizacja platnosci', + 'payment_group_description' => 'Sprawdza czy zamowienia shopPRO zostaly oplacone i aktualizuje status platnosci w orderPRO.', + 'payment_sync_interval_minutes' => 'Interwal sprawdzania platnosci (minuty)', + 'payment_sync_interval_hint' => 'Zakres: 1-1440 minut. Dotyczy harmonogramu joba shoppro_payment_status_sync.', + 'payment_sync_status_codes' => 'Statusy do sprawdzania platnosci', + 'payment_sync_status_codes_hint' => 'Jesli nic nie zaznaczysz, system pominie tylko statusy koncowe (np. wyslane/anulowane).', + ], + 'delivery' => [ + 'title' => 'Formy dostawy', + 'description' => 'Mapowanie form dostawy shopPRO do uslug nadawczych Allegro WZA/InPost.', + 'select_integration_first' => 'Najpierw wybierz lub zapisz integracje w zakladce Integracja.', + 'empty_orders' => 'Brak form dostawy shopPRO wykrytych w zamowieniach tej integracji.', + 'not_connected' => 'Brak aktywnego polaczenia Allegro. Podlacz konto Allegro, aby pobrac liste uslug dostawy.', + 'no_inpost_services' => 'Brak uslug InPost (sprawdz polaczenie z Allegro).', + 'fields' => [ + 'order_method' => 'Forma dostawy shopPRO', + 'carrier' => 'Przewoznik', + 'allegro_service' => 'Usluga dostawy Allegro', + 'no_mapping' => 'brak mapowania', + 'search_placeholder' => 'Szukaj uslugi Allegro...', + 'select_carrier_first' => 'Najpierw wybierz przewoznika.', + ], + 'actions' => [ + 'save' => 'Zapisz mapowania dostawy', + ], + 'flash' => [ + 'saved' => 'Mapowania form dostawy zostaly zapisane.', + 'save_failed' => 'Nie udalo sie zapisac mapowan form dostawy.', + ], + ], 'fields' => [ 'name' => 'Nazwa', 'base_url' => 'Base URL', @@ -947,6 +1058,9 @@ return [ 'save_failed' => 'Nie udalo sie zapisac ustawien crona.', 'load_failed' => 'Nie udalo sie pobrac danych crona.', ], + 'pagination' => [ + 'summary' => 'Strona :page/:total_pages, rekordy: :total', + ], ], 'gs1' => [ 'title' => 'GS1 / EAN', diff --git a/resources/scss/app.scss b/resources/scss/app.scss index 394e8e8..0001b7f 100644 --- a/resources/scss/app.scss +++ b/resources/scss/app.scss @@ -445,6 +445,11 @@ details[open] > .sidebar__group-toggle .sidebar__toggle-arrow { display: flex; gap: 8px; flex-wrap: wrap; + align-items: flex-start; +} + +.form-actions .btn { + align-self: flex-start; } .statuses-form { @@ -453,6 +458,10 @@ details[open] > .sidebar__group-toggle .sidebar__toggle-arrow { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.statuses-form .form-actions { + grid-column: 1 / -1; +} + .statuses-color-input { min-height: 32px; padding: 2px; @@ -673,6 +682,17 @@ details[open] > .sidebar__group-toggle .sidebar__toggle-arrow { color: #0f6b39; } +.table-row-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.table-row-actions form { + margin: 0; +} + .table-list { display: grid; gap: 14px; @@ -1997,6 +2017,111 @@ details[open] > .sidebar__group-toggle .sidebar__toggle-arrow { } } +.shoppro-tabs-toolbar { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.shoppro-tabs-toolbar__field { + margin: 0; + min-width: 260px; + max-width: 420px; + flex: 1 1 320px; +} + +.shoppro-tabs-toolbar__field .form-control { + width: 100%; +} + +.shoppro-tabs-toolbar__actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.integration-settings-group { + grid-column: 1 / -1; + border: 1px solid var(--c-border); + border-radius: 10px; + background: #f8fbff; + padding: 10px; +} + +.integration-settings-group__head { + margin-bottom: 8px; + padding: 4px 8px; + border-left: 3px solid var(--c-primary, #2563eb); + background: #eef4ff; + border-radius: 6px; +} + +.integration-settings-group__title { + margin: 0; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.01em; + color: #1e3a8a; +} + +.integration-settings-group__desc { + margin: 4px 0 0; + color: #4b5563; +} + +.integration-settings-group__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; + align-items: start; +} + +.integration-settings-group__full { + grid-column: 1 / -1; +} + +.integration-settings-group__grid .form-field { + margin: 0; + align-self: start; +} + +.integration-settings-group__grid .form-control { + min-height: 34px; + height: 34px; +} + +.integration-settings-group__grid input[type='date'].form-control { + line-height: 1.2; +} + +.integration-settings-checkboxes { + border: 0; + padding: 0; + margin: 0; +} + +.integration-settings-checkboxes .field-label { + display: block; + margin-bottom: 2px; +} + +.integration-settings-checkboxes__list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 12px; +} + +.integration-settings-checkboxes__item { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #334155; +} + @media (max-width: 768px) { .app-shell { flex-direction: column; @@ -2114,6 +2239,14 @@ details[open] > .sidebar__group-toggle .sidebar__toggle-arrow { grid-template-columns: 1fr; } + .integration-settings-group__grid { + grid-template-columns: 1fr; + } + + .integration-settings-checkboxes__list { + grid-template-columns: 1fr; + } + .card { padding: 12px; } diff --git a/resources/views/layouts/app.php b/resources/views/layouts/app.php index 0b5c4e9..24d0ec2 100644 --- a/resources/views/layouts/app.php +++ b/resources/views/layouts/app.php @@ -73,14 +73,8 @@ - - - - - - - - + + diff --git a/resources/views/orders/show.php b/resources/views/orders/show.php index b4420ac..bd4e72a 100644 --- a/resources/views/orders/show.php +++ b/resources/views/orders/show.php @@ -15,6 +15,8 @@ $allStatusesList = is_array($allStatuses ?? null) ? $allStatuses : []; $currentStatusCodeValue = (string) ($currentStatusCode ?? ''); $flashSuccessMsg = (string) ($flashSuccess ?? ''); $flashErrorMsg = (string) ($flashError ?? ''); +$carrierRaw = (string) ($orderRow['external_carrier_id'] ?? ''); +$carrierDisplay = trim(html_entity_decode(strip_tags($carrierRaw), ENT_QUOTES | ENT_HTML5, 'UTF-8')); $addressByType = [ 'customer' => null, @@ -193,7 +195,7 @@ foreach ($addressesList as $address) {
-
+
@@ -207,12 +209,22 @@ foreach ($addressesList as $address) {

-
-
+ +
+ +
-
+
+ +
+ + +
+
diff --git a/resources/views/settings/cron.php b/resources/views/settings/cron.php index 5498a2c..b6a5289 100644 --- a/resources/views/settings/cron.php +++ b/resources/views/settings/cron.php @@ -2,6 +2,10 @@ $schedulesList = is_array($schedules ?? null) ? $schedules : []; $futureJobsList = is_array($futureJobs ?? null) ? $futureJobs : []; $pastJobsList = is_array($pastJobs ?? null) ? $pastJobs : []; +$pastPagination = is_array($pastJobsPagination ?? null) ? $pastJobsPagination : []; +$pastPage = max(1, (int) ($pastPagination['page'] ?? 1)); +$pastTotalPages = max(1, (int) ($pastPagination['total_pages'] ?? 1)); +$pastTotal = max(0, (int) ($pastPagination['total'] ?? 0)); ?>
@@ -144,4 +148,26 @@ $pastJobsList = is_array($pastJobs ?? null) ? $pastJobs : [];
+ 1): ?> +
+ diff --git a/resources/views/settings/integrations.php b/resources/views/settings/integrations.php new file mode 100644 index 0000000..9ccf5c8 --- /dev/null +++ b/resources/views/settings/integrations.php @@ -0,0 +1,58 @@ + + +
+

+

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

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+
diff --git a/resources/views/settings/shoppro.php b/resources/views/settings/shoppro.php new file mode 100644 index 0000000..106aea7 --- /dev/null +++ b/resources/views/settings/shoppro.php @@ -0,0 +1,677 @@ + 0; +$selectedIntegrationId = (int) ($formValues['integration_id'] ?? 0); +$selectedPaymentSyncCodes = is_array($formValues['payment_sync_status_codes'] ?? null) ? $formValues['payment_sync_status_codes'] : []; +$dmMappings = is_array($deliveryMappings ?? null) ? $deliveryMappings : []; +$dmOrderMethods = is_array($orderDeliveryMethods ?? null) ? $orderDeliveryMethods : []; +$dmAllegroServices = is_array($allegroDeliveryServices ?? null) ? $allegroDeliveryServices : []; +$dmInpostServices = is_array($inpostDeliveryServices ?? null) ? $inpostDeliveryServices : []; +$dmServicesError = (string) ($allegroDeliveryServicesError ?? ''); +$dmMappingsByMethod = []; +foreach ($dmMappings as $dm) { + if (!is_array($dm)) { + continue; + } + $dmMappingsByMethod[trim((string) ($dm['order_delivery_method'] ?? ''))] = $dm; +} +?> + +
+

+

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

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID
+ + + + + + +
+ + + + | HTTP + + +
+
+ + + +
+ + + + +
+
+
+
+
+ +
+

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

+

+ + +

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

+

+ + +

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

+

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

+

+
+
+ + + +
+
+ +
+
+

+

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

+

+ + +

+ + +
+ + + +

+ +
+ + +
+ + + + + + + + + + $orderMethod): ?> + + + + + + + + +
+ + + + + +
+ + + + + +
+ +
+
+ -- -- +
+ + +
+ () +
+ +
+
+ +
+ +
+ + + +
+ +
+ +
+
+
+
+
+ +
+
+ + +
+
+
+ + diff --git a/routes/web.php b/routes/web.php index 53d91bf..d12baf4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -21,6 +21,12 @@ use App\Modules\Settings\ApaczkaIntegrationController; use App\Modules\Settings\ApaczkaIntegrationRepository; use App\Modules\Settings\InpostIntegrationController; use App\Modules\Settings\InpostIntegrationRepository; +use App\Modules\Settings\IntegrationsHubController; +use App\Modules\Settings\IntegrationsRepository; +use App\Modules\Settings\ShopproIntegrationsController; +use App\Modules\Settings\ShopproDeliveryMethodMappingRepository; +use App\Modules\Settings\ShopproIntegrationsRepository; +use App\Modules\Settings\ShopproStatusMappingRepository; use App\Modules\Settings\AllegroDeliveryMethodMappingRepository; use App\Modules\Settings\CompanySettingsController; use App\Modules\Settings\CompanySettingsRepository; @@ -97,6 +103,33 @@ return static function (Application $app): void { $auth, $inpostIntegrationRepository ); + $shopproIntegrationsRepository = new ShopproIntegrationsRepository( + $app->db(), + (string) $app->config('app.integrations.secret', '') + ); + $shopproIntegrationsController = new ShopproIntegrationsController( + $template, + $translator, + $auth, + $shopproIntegrationsRepository, + new ShopproStatusMappingRepository($app->db()), + $app->orderStatuses(), + $cronRepository, + new ShopproDeliveryMethodMappingRepository($app->db()), + $allegroIntegrationRepository, + $allegroOAuthClient, + new AllegroApiClient() + ); + $integrationsHubController = new IntegrationsHubController( + $template, + $translator, + $auth, + new IntegrationsRepository($app->db()), + $allegroIntegrationRepository, + $apaczkaIntegrationRepository, + $inpostIntegrationRepository, + $shopproIntegrationsRepository + ); $cronSettingsController = new CronSettingsController( $template, $translator, @@ -173,6 +206,7 @@ return static function (Application $app): void { $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', [$integrationsHubController, 'index'], [$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]); @@ -188,6 +222,12 @@ return static function (Application $app): void { $router->post('/settings/integrations/apaczka/save', [$apaczkaIntegrationController, 'save'], [$authMiddleware]); $router->get('/settings/integrations/inpost', [$inpostIntegrationController, 'index'], [$authMiddleware]); $router->post('/settings/integrations/inpost/save', [$inpostIntegrationController, 'save'], [$authMiddleware]); + $router->get('/settings/integrations/shoppro', [$shopproIntegrationsController, 'index'], [$authMiddleware]); + $router->post('/settings/integrations/shoppro/save', [$shopproIntegrationsController, 'save'], [$authMiddleware]); + $router->post('/settings/integrations/shoppro/test', [$shopproIntegrationsController, 'test'], [$authMiddleware]); + $router->post('/settings/integrations/shoppro/statuses/save', [$shopproIntegrationsController, 'saveStatusMappings'], [$authMiddleware]); + $router->post('/settings/integrations/shoppro/statuses/sync', [$shopproIntegrationsController, 'syncStatuses'], [$authMiddleware]); + $router->post('/settings/integrations/shoppro/delivery/save', [$shopproIntegrationsController, 'saveDeliveryMappings'], [$authMiddleware]); $router->get('/settings/company', [$companySettingsController, 'index'], [$authMiddleware]); $router->post('/settings/company/save', [$companySettingsController, 'save'], [$authMiddleware]); $router->get('/orders/{id}/shipment/prepare', [$shipmentController, 'prepare'], [$authMiddleware]); diff --git a/src/Core/Application.php b/src/Core/Application.php index 72d15d1..f120f90 100644 --- a/src/Core/Application.php +++ b/src/Core/Application.php @@ -18,6 +18,9 @@ use App\Modules\Cron\AllegroStatusSyncHandler; use App\Modules\Cron\AllegroTokenRefreshHandler; use App\Modules\Cron\CronRepository; use App\Modules\Cron\CronRunner; +use App\Modules\Cron\ShopproOrdersImportHandler; +use App\Modules\Cron\ShopproPaymentStatusSyncHandler; +use App\Modules\Cron\ShopproStatusSyncHandler; use App\Modules\Orders\OrderImportRepository; use App\Modules\Orders\OrdersRepository; use App\Modules\Settings\AllegroApiClient; @@ -29,6 +32,12 @@ use App\Modules\Settings\AllegroOAuthClient; use App\Modules\Settings\AllegroStatusSyncService; use App\Modules\Settings\AllegroStatusMappingRepository; use App\Modules\Settings\OrderStatusRepository; +use App\Modules\Settings\ShopproApiClient; +use App\Modules\Settings\ShopproIntegrationsRepository; +use App\Modules\Settings\ShopproOrdersSyncService; +use App\Modules\Settings\ShopproPaymentStatusSyncService; +use App\Modules\Settings\ShopproStatusSyncService; +use App\Modules\Settings\ShopproStatusMappingRepository; use App\Modules\Users\UserRepository; use Throwable; use PDO; @@ -282,6 +291,33 @@ final class Application $apiClient, $orderImportService ); + $shopproSyncService = new ShopproOrdersSyncService( + new ShopproIntegrationsRepository( + $this->db, + (string) $this->config('app.integrations.secret', '') + ), + new AllegroOrderSyncStateRepository($this->db), + new ShopproApiClient(), + new OrderImportRepository($this->db), + new ShopproStatusMappingRepository($this->db), + new OrdersRepository($this->db) + ); + $shopproStatusSyncService = new ShopproStatusSyncService( + new ShopproIntegrationsRepository( + $this->db, + (string) $this->config('app.integrations.secret', '') + ), + $shopproSyncService + ); + $shopproPaymentSyncService = new ShopproPaymentStatusSyncService( + new ShopproIntegrationsRepository( + $this->db, + (string) $this->config('app.integrations.secret', '') + ), + new ShopproApiClient(), + new OrdersRepository($this->db), + $this->db + ); $runner = new CronRunner( $repository, @@ -301,6 +337,15 @@ final class Application $this->db ) ), + 'shoppro_orders_import' => new ShopproOrdersImportHandler( + $shopproSyncService + ), + 'shoppro_order_status_sync' => new ShopproStatusSyncHandler( + $shopproStatusSyncService + ), + 'shoppro_payment_status_sync' => new ShopproPaymentStatusSyncHandler( + $shopproPaymentSyncService + ), ] ); $runner->run($webLimit); diff --git a/src/Modules/Cron/CronRepository.php b/src/Modules/Cron/CronRepository.php index 7f9725c..92fdad0 100644 --- a/src/Modules/Cron/CronRepository.php +++ b/src/Modules/Cron/CronRepository.php @@ -102,17 +102,19 @@ final class CronRepository /** * @return array> */ - public function listPastJobs(int $limit = 50): array + public function listPastJobs(int $limit = 50, int $offset = 0): array { $safeLimit = max(1, min(200, $limit)); + $safeOffset = max(0, $offset); $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' + LIMIT :limit OFFSET :offset' ); $statement->bindValue(':limit', $safeLimit, PDO::PARAM_INT); + $statement->bindValue(':offset', $safeOffset, PDO::PARAM_INT); $statement->execute(); $rows = $statement->fetchAll(PDO::FETCH_ASSOC); if (!is_array($rows)) { @@ -122,6 +124,18 @@ final class CronRepository return array_map(fn (array $row): array => $this->normalizeJobRow($row), $rows); } + public function countPastJobs(): int + { + $statement = $this->pdo->query( + 'SELECT COUNT(*) + FROM cron_jobs + WHERE status IN ("completed", "failed", "cancelled")' + ); + $value = $statement !== false ? $statement->fetchColumn() : 0; + + return max(0, (int) $value); + } + /** * @return array> */ diff --git a/src/Modules/Cron/ShopproOrdersImportHandler.php b/src/Modules/Cron/ShopproOrdersImportHandler.php new file mode 100644 index 0000000..e400116 --- /dev/null +++ b/src/Modules/Cron/ShopproOrdersImportHandler.php @@ -0,0 +1,26 @@ + $payload + * @return array + */ + public function handle(array $payload): array + { + return $this->syncService->sync([ + 'max_pages' => (int) ($payload['max_pages'] ?? 3), + 'page_limit' => (int) ($payload['page_limit'] ?? 50), + 'max_orders' => (int) ($payload['max_orders'] ?? 200), + ]); + } +} diff --git a/src/Modules/Cron/ShopproPaymentStatusSyncHandler.php b/src/Modules/Cron/ShopproPaymentStatusSyncHandler.php new file mode 100644 index 0000000..7deadad --- /dev/null +++ b/src/Modules/Cron/ShopproPaymentStatusSyncHandler.php @@ -0,0 +1,24 @@ + $payload + * @return array + */ + public function handle(array $payload): array + { + return $this->syncService->sync([ + 'per_integration_limit' => (int) ($payload['per_integration_limit'] ?? 100), + ]); + } +} diff --git a/src/Modules/Cron/ShopproStatusSyncHandler.php b/src/Modules/Cron/ShopproStatusSyncHandler.php new file mode 100644 index 0000000..df7b714 --- /dev/null +++ b/src/Modules/Cron/ShopproStatusSyncHandler.php @@ -0,0 +1,22 @@ + $payload + * @return array + */ + public function handle(array $payload): array + { + return $this->syncService->sync(); + } +} diff --git a/src/Modules/Orders/OrdersController.php b/src/Modules/Orders/OrdersController.php index 10d9ed5..1bbb7e7 100644 --- a/src/Modules/Orders/OrdersController.php +++ b/src/Modules/Orders/OrdersController.php @@ -538,6 +538,7 @@ final class OrdersController private function shippingHtml(string $deliveryMethod, int $shipments, int $documents): string { + $deliveryMethod = trim(html_entity_decode(strip_tags($deliveryMethod), ENT_QUOTES | ENT_HTML5, 'UTF-8')); $html = '
'; if ($deliveryMethod !== '' && !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $deliveryMethod)) { $html .= '
' . htmlspecialchars($deliveryMethod, ENT_QUOTES, 'UTF-8') . '
'; diff --git a/src/Modules/Orders/OrdersRepository.php b/src/Modules/Orders/OrdersRepository.php index 5726e7a..13eec77 100644 --- a/src/Modules/Orders/OrdersRepository.php +++ b/src/Modules/Orders/OrdersRepository.php @@ -420,9 +420,10 @@ final class OrdersRepository $addresses = []; } - $itemsMediaSql = $this->resolvedMediaUrlSql('oi'); + $itemsMediaSql = $this->resolvedMediaUrlSql('oi', 'o.source'); $itemsStmt = $this->pdo->prepare('SELECT oi.*, ' . $itemsMediaSql . ' AS resolved_media_url FROM order_items oi + INNER JOIN orders o ON o.id = oi.order_id WHERE oi.order_id = :order_id ORDER BY oi.sort_order ASC, oi.id ASC'); $itemsStmt->execute(['order_id' => $orderId]); @@ -513,9 +514,10 @@ final class OrdersRepository $placeholders = implode(',', array_fill(0, count($cleanIds), '?')); try { - $resolvedMediaSql = $this->resolvedMediaUrlSql('oi'); + $resolvedMediaSql = $this->resolvedMediaUrlSql('oi', 'o.source'); $sql = 'SELECT oi.order_id, oi.original_name, oi.quantity, ' . $resolvedMediaSql . ' AS media_url, oi.sort_order, oi.id FROM order_items oi + INNER JOIN orders o ON o.id = oi.order_id WHERE oi.order_id IN (' . $placeholders . ') ORDER BY oi.order_id ASC, oi.sort_order ASC, oi.id ASC'; $stmt = $this->pdo->prepare($sql); @@ -574,7 +576,7 @@ final class OrdersRepository . ')'; } - private function resolvedMediaUrlSql(string $itemAlias): string + private function resolvedMediaUrlSql(string $itemAlias, string $sourceAlias = '"allegro"'): string { if (!$this->canResolveMappedMedia()) { return 'COALESCE(NULLIF(TRIM(' . $itemAlias . '.media_url), ""), "")'; @@ -587,7 +589,7 @@ final class OrdersRepository 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" + WHERE LOWER(sc.code) = LOWER(' . $sourceAlias . ') AND ( pcm.external_product_id = ' . $itemAlias . '.external_item_id OR pcm.external_product_id = ' . $itemAlias . '.source_product_id diff --git a/src/Modules/Settings/AllegroIntegrationController.php b/src/Modules/Settings/AllegroIntegrationController.php index 63246d6..33df2cc 100644 --- a/src/Modules/Settings/AllegroIntegrationController.php +++ b/src/Modules/Settings/AllegroIntegrationController.php @@ -111,34 +111,35 @@ final class AllegroIntegrationController public function save(Request $request): Response { - $csrfError = $this->validateCsrf((string) $request->input('_token', '')); - if ($csrfError !== null) { - return $csrfError; + $redirectTo = $this->resolveRedirectPath((string) $request->input('return_to', '/settings/integrations/allegro')); + if (!Csrf::validate((string) $request->input('_token', ''))) { + Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired')); + return Response::redirect($redirectTo); } $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'); + return Response::redirect($redirectTo); } $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'); + return Response::redirect($redirectTo); } $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'); + return Response::redirect($redirectTo); } $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'); + return Response::redirect($redirectTo); } try { @@ -159,7 +160,7 @@ final class AllegroIntegrationController ); } - return Response::redirect('/settings/integrations/allegro'); + return Response::redirect($redirectTo); } public function saveImportSettings(Request $request): Response @@ -649,6 +650,19 @@ final class AllegroIntegrationController return Response::redirect('/settings/integrations/allegro'); } + private function resolveRedirectPath(string $candidate): string + { + $value = trim($candidate); + if ($value === '') { + return '/settings/integrations/allegro'; + } + if (!str_starts_with($value, '/settings/integrations')) { + return '/settings/integrations/allegro'; + } + + return $value; + } + /** * @return array */ diff --git a/src/Modules/Settings/AllegroIntegrationRepository.php b/src/Modules/Settings/AllegroIntegrationRepository.php index 72b0398..e6775e9 100644 --- a/src/Modules/Settings/AllegroIntegrationRepository.php +++ b/src/Modules/Settings/AllegroIntegrationRepository.php @@ -10,11 +10,18 @@ use Throwable; final class AllegroIntegrationRepository { private const DEFAULT_ENVIRONMENT = 'sandbox'; + private const INTEGRATION_TYPE = 'allegro'; + + private readonly IntegrationsRepository $integrations; + private readonly IntegrationSecretCipher $cipher; + private ?bool $hasIntegrationIdColumn = null; public function __construct( private readonly PDO $pdo, private readonly string $secret ) { + $this->integrations = new IntegrationsRepository($this->pdo); + $this->cipher = new IntegrationSecretCipher($this->secret); } /** @@ -30,6 +37,7 @@ final class AllegroIntegrationRepository return [ 'environment' => $this->normalizeEnvironment((string) ($row['environment'] ?? self::DEFAULT_ENVIRONMENT)), + 'integration_id' => (int) ($row['integration_id'] ?? 0), 'client_id' => trim((string) ($row['client_id'] ?? '')), 'has_client_secret' => trim((string) ($row['client_secret_encrypted'] ?? '')) !== '', 'redirect_uri' => trim((string) ($row['redirect_uri'] ?? '')), @@ -60,6 +68,21 @@ final class AllegroIntegrationRepository return $this->normalizeEnvironment(trim((string) ($row['setting_value'] ?? self::DEFAULT_ENVIRONMENT))); } + public function getActiveIntegrationId(): int + { + $environment = $this->getActiveEnvironment(); + $row = $this->fetchRowByEnv($environment); + $rowIntegrationId = (int) ($row['integration_id'] ?? 0); + if ($rowIntegrationId > 0) { + return $rowIntegrationId; + } + + $integrationId = $this->ensureBaseIntegration($environment); + $this->assignIntegrationIdIfPossible($environment, $integrationId); + + return $integrationId; + } + public function setActiveEnvironment(string $environment): void { $env = $this->normalizeEnvironment($environment); @@ -86,7 +109,7 @@ final class AllegroIntegrationRepository $clientSecret = trim((string) ($payload['client_secret'] ?? '')); $clientSecretEncrypted = trim((string) ($current['client_secret_encrypted'] ?? '')); if ($clientSecret !== '') { - $clientSecretEncrypted = (string) $this->encrypt($clientSecret); + $clientSecretEncrypted = (string) $this->cipher->encrypt($clientSecret); } $statement = $this->pdo->prepare( @@ -123,7 +146,7 @@ final class AllegroIntegrationRepository } $clientId = trim((string) ($row['client_id'] ?? '')); - $clientSecret = $this->decrypt((string) ($row['client_secret_encrypted'] ?? '')); + $clientSecret = (string) $this->cipher->decrypt((string) ($row['client_secret_encrypted'] ?? '')); $redirectUri = trim((string) ($row['redirect_uri'] ?? '')); if ($clientId === '' || $clientSecret === '' || $redirectUri === '') { return null; @@ -160,8 +183,8 @@ final class AllegroIntegrationRepository ); $statement->execute([ 'environment' => $env, - 'access_token_encrypted' => $this->encrypt($accessToken), - 'refresh_token_encrypted' => $this->encrypt($refreshToken), + 'access_token_encrypted' => $this->cipher->encrypt($accessToken), + 'refresh_token_encrypted' => $this->cipher->encrypt($refreshToken), 'token_type' => $this->nullableString($tokenType), 'token_scope' => $this->nullableString($scope), 'token_expires_at' => $this->nullableString((string) $tokenExpiresAt), @@ -180,8 +203,8 @@ final class AllegroIntegrationRepository } $clientId = trim((string) ($row['client_id'] ?? '')); - $clientSecret = $this->decrypt((string) ($row['client_secret_encrypted'] ?? '')); - $refreshToken = $this->decrypt((string) ($row['refresh_token_encrypted'] ?? '')); + $clientSecret = (string) $this->cipher->decrypt((string) ($row['client_secret_encrypted'] ?? '')); + $refreshToken = (string) $this->cipher->decrypt((string) ($row['refresh_token_encrypted'] ?? '')); if ($clientId === '' || $clientSecret === '' || $refreshToken === '') { return null; } @@ -206,9 +229,9 @@ final class AllegroIntegrationRepository } $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'] ?? '')); + $clientSecret = (string) $this->cipher->decrypt((string) ($row['client_secret_encrypted'] ?? '')); + $refreshToken = (string) $this->cipher->decrypt((string) ($row['refresh_token_encrypted'] ?? '')); + $accessToken = (string) $this->cipher->decrypt((string) ($row['access_token_encrypted'] ?? '')); if ($clientId === '' || $clientSecret === '' || $refreshToken === '') { return null; } @@ -226,6 +249,26 @@ final class AllegroIntegrationRepository private function ensureRow(string $environment): void { $env = $this->normalizeEnvironment($environment); + $integrationId = $this->ensureBaseIntegration($env); + + if ($this->hasIntegrationIdColumn()) { + $statement = $this->pdo->prepare( + 'INSERT INTO allegro_integration_settings ( + integration_id, environment, orders_fetch_enabled, created_at, updated_at + ) VALUES ( + :integration_id, :environment, 0, NOW(), NOW() + ) + ON DUPLICATE KEY UPDATE + updated_at = updated_at' + ); + $statement->execute([ + 'integration_id' => $integrationId, + 'environment' => $env, + ]); + $this->assignIntegrationIdIfPossible($env, $integrationId); + return; + } + $statement = $this->pdo->prepare( 'INSERT INTO allegro_integration_settings ( environment, orders_fetch_enabled, created_at, updated_at @@ -240,6 +283,79 @@ final class AllegroIntegrationRepository ]); } + private function ensureBaseIntegration(string $environment): int + { + $env = $this->normalizeEnvironment($environment); + return $this->integrations->ensureIntegration( + self::INTEGRATION_TYPE, + $this->integrationNameForEnvironment($env), + $this->integrationBaseUrlForEnvironment($env), + 20, + true + ); + } + + private function assignIntegrationIdIfPossible(string $environment, int $integrationId): void + { + if ($integrationId <= 0 || !$this->hasIntegrationIdColumn()) { + return; + } + + try { + $statement = $this->pdo->prepare( + 'UPDATE allegro_integration_settings + SET integration_id = :integration_id, + updated_at = NOW() + WHERE environment = :environment + AND (integration_id IS NULL OR integration_id = 0)' + ); + $statement->execute([ + 'integration_id' => $integrationId, + 'environment' => $this->normalizeEnvironment($environment), + ]); + } catch (Throwable) { + return; + } + } + + private function hasIntegrationIdColumn(): bool + { + if ($this->hasIntegrationIdColumn !== null) { + return $this->hasIntegrationIdColumn; + } + + try { + $statement = $this->pdo->prepare( + "SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'allegro_integration_settings' + AND COLUMN_NAME = 'integration_id' + LIMIT 1" + ); + $statement->execute(); + $value = $statement->fetchColumn(); + } catch (Throwable) { + $this->hasIntegrationIdColumn = false; + return false; + } + + $this->hasIntegrationIdColumn = $value !== false; + return $this->hasIntegrationIdColumn; + } + + private function integrationNameForEnvironment(string $environment): string + { + return $environment === 'production' ? 'Allegro Production' : 'Allegro Sandbox'; + } + + private function integrationBaseUrlForEnvironment(string $environment): string + { + return $environment === 'production' + ? 'https://api.allegro.pl' + : 'https://api.allegro.pl.allegrosandbox.pl'; + } + /** * @return array|null */ @@ -274,6 +390,7 @@ final class AllegroIntegrationRepository { return [ 'environment' => $this->normalizeEnvironment($environment), + 'integration_id' => 0, 'client_id' => '', 'has_client_secret' => false, 'redirect_uri' => '', @@ -313,60 +430,4 @@ final class AllegroIntegrationRepository $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/AllegroOrderImportService.php b/src/Modules/Settings/AllegroOrderImportService.php index 530d2ed..4e808ef 100644 --- a/src/Modules/Settings/AllegroOrderImportService.php +++ b/src/Modules/Settings/AllegroOrderImportService.php @@ -252,7 +252,7 @@ final class AllegroOrderImportService $fetchedAt = date('Y-m-d H:i:s'); $order = [ - 'integration_id' => null, + 'integration_id' => $this->integrationRepository->getActiveIntegrationId(), 'source' => 'allegro', 'source_order_id' => $checkoutFormId, 'external_order_id' => $checkoutFormId, diff --git a/src/Modules/Settings/AllegroOrdersSyncService.php b/src/Modules/Settings/AllegroOrdersSyncService.php index 22808a4..384c44f 100644 --- a/src/Modules/Settings/AllegroOrdersSyncService.php +++ b/src/Modules/Settings/AllegroOrdersSyncService.php @@ -10,8 +10,6 @@ use Throwable; final class AllegroOrdersSyncService { - private const ALLEGRO_INTEGRATION_ID = 1; - public function __construct( private readonly AllegroIntegrationRepository $integrationRepository, private readonly AllegroOrderSyncStateRepository $syncStateRepository, @@ -42,9 +40,14 @@ final class AllegroOrdersSyncService ]; } + $integrationId = $this->integrationRepository->getActiveIntegrationId(); + if ($integrationId <= 0) { + throw new RuntimeException('Brak aktywnej integracji bazowej Allegro.'); + } + $now = new DateTimeImmutable('now'); - $state = $this->syncStateRepository->getState(self::ALLEGRO_INTEGRATION_ID); - $this->syncStateRepository->markRunStarted(self::ALLEGRO_INTEGRATION_ID, $now); + $state = $this->syncStateRepository->getState($integrationId); + $this->syncStateRepository->markRunStarted($integrationId, $now); $maxPages = max(1, min(20, (int) ($options['max_pages'] ?? 5))); $pageLimit = max(1, min(100, (int) ($options['page_limit'] ?? 50))); @@ -171,7 +174,7 @@ final class AllegroOrdersSyncService } $this->syncStateRepository->markRunSuccess( - self::ALLEGRO_INTEGRATION_ID, + $integrationId, new DateTimeImmutable('now'), $latestProcessedUpdatedAt, $latestProcessedSourceOrderId @@ -181,7 +184,7 @@ final class AllegroOrdersSyncService return $result; } catch (Throwable $exception) { $this->syncStateRepository->markRunFailed( - self::ALLEGRO_INTEGRATION_ID, + $integrationId, new DateTimeImmutable('now'), $exception->getMessage() ); diff --git a/src/Modules/Settings/ApaczkaIntegrationController.php b/src/Modules/Settings/ApaczkaIntegrationController.php index 3357485..0d186a7 100644 --- a/src/Modules/Settings/ApaczkaIntegrationController.php +++ b/src/Modules/Settings/ApaczkaIntegrationController.php @@ -42,15 +42,17 @@ final class ApaczkaIntegrationController public function save(Request $request): Response { + $redirectTo = $this->resolveRedirectPath((string) $request->input('return_to', '/settings/integrations/apaczka')); + if (!Csrf::validate((string) $request->input('_token', ''))) { Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired')); - return Response::redirect('/settings/integrations/apaczka'); + return Response::redirect($redirectTo); } $apiKey = trim((string) $request->input('api_key', '')); if ($apiKey === '') { Flash::set('settings_error', $this->translator->get('settings.apaczka.validation.api_key_required')); - return Response::redirect('/settings/integrations/apaczka'); + return Response::redirect($redirectTo); } try { @@ -65,6 +67,19 @@ final class ApaczkaIntegrationController ); } - return Response::redirect('/settings/integrations/apaczka'); + return Response::redirect($redirectTo); + } + + private function resolveRedirectPath(string $candidate): string + { + $value = trim($candidate); + if ($value === '') { + return '/settings/integrations/apaczka'; + } + if (!str_starts_with($value, '/settings/integrations')) { + return '/settings/integrations/apaczka'; + } + + return $value; } } diff --git a/src/Modules/Settings/ApaczkaIntegrationRepository.php b/src/Modules/Settings/ApaczkaIntegrationRepository.php index ff21799..5229af4 100644 --- a/src/Modules/Settings/ApaczkaIntegrationRepository.php +++ b/src/Modules/Settings/ApaczkaIntegrationRepository.php @@ -4,15 +4,22 @@ declare(strict_types=1); namespace App\Modules\Settings; use PDO; -use RuntimeException; -use Throwable; final class ApaczkaIntegrationRepository { + private const INTEGRATION_TYPE = 'apaczka'; + private const INTEGRATION_NAME = 'Apaczka'; + private const INTEGRATION_BASE_URL = 'https://www.apaczka.pl'; + + private readonly IntegrationsRepository $integrations; + private readonly IntegrationSecretCipher $cipher; + public function __construct( private readonly PDO $pdo, private readonly string $secret ) { + $this->integrations = new IntegrationsRepository($this->pdo); + $this->cipher = new IntegrationSecretCipher($this->secret); } /** @@ -20,13 +27,11 @@ final class ApaczkaIntegrationRepository */ public function getSettings(): array { - $row = $this->fetchRow(); - if ($row === null) { - return $this->defaultSettings(); - } + $integrationId = $this->ensureBaseIntegration(); + $integration = $this->integrations->findById($integrationId); return [ - 'has_api_key' => trim((string) ($row['api_key_encrypted'] ?? '')) !== '', + 'has_api_key' => trim((string) ($integration['api_key_encrypted'] ?? '')) !== '', ]; } @@ -35,90 +40,25 @@ final class ApaczkaIntegrationRepository */ public function saveSettings(array $payload): void { - $this->ensureRow(); - $current = $this->fetchRow(); - if ($current === null) { - throw new RuntimeException('Brak rekordu konfiguracji Apaczka.'); - } + $integrationId = $this->ensureBaseIntegration(); $apiKey = trim((string) ($payload['api_key'] ?? '')); - $apiKeyEncrypted = trim((string) ($current['api_key_encrypted'] ?? '')); - if ($apiKey !== '') { - $apiKeyEncrypted = (string) $this->encrypt($apiKey); + if ($apiKey === '') { + return; } - $statement = $this->pdo->prepare( - 'UPDATE apaczka_integration_settings - SET api_key_encrypted = :api_key_encrypted, - updated_at = NOW() - WHERE id = 1' + $encrypted = $this->cipher->encrypt($apiKey); + $this->integrations->updateApiKeyEncrypted($integrationId, $encrypted); + } + + private function ensureBaseIntegration(): int + { + return $this->integrations->ensureIntegration( + self::INTEGRATION_TYPE, + self::INTEGRATION_NAME, + self::INTEGRATION_BASE_URL, + 20, + true ); - $statement->execute([ - 'api_key_encrypted' => $this->nullableString($apiKeyEncrypted), - ]); - } - - private function ensureRow(): void - { - $statement = $this->pdo->prepare( - 'INSERT INTO apaczka_integration_settings (id, created_at, updated_at) - VALUES (1, NOW(), NOW()) - ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at)' - ); - $statement->execute(); - } - - /** - * @return array|null - */ - private function fetchRow(): ?array - { - try { - $statement = $this->pdo->prepare('SELECT * FROM apaczka_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 [ - 'has_api_key' => false, - ]; - } - - 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); } } diff --git a/src/Modules/Settings/CronSettingsController.php b/src/Modules/Settings/CronSettingsController.php index 4411a1a..fae4dcc 100644 --- a/src/Modules/Settings/CronSettingsController.php +++ b/src/Modules/Settings/CronSettingsController.php @@ -15,6 +15,8 @@ use Throwable; final class CronSettingsController { + private const PAST_JOBS_PER_PAGE = 25; + public function __construct( private readonly Template $template, private readonly Translator $translator, @@ -27,12 +29,20 @@ final class CronSettingsController public function index(Request $request): Response { + $pastPage = max(1, (int) $request->input('past_page', 1)); + try { $runOnWeb = $this->cronRepository->getBoolSetting('cron_run_on_web', $this->runOnWebDefault); $webLimit = $this->cronRepository->getIntSetting('cron_web_limit', $this->webLimitDefault, 1, 100); $schedules = $this->cronRepository->listSchedules(); $futureJobs = $this->cronRepository->listFutureJobs(60); - $pastJobs = $this->cronRepository->listPastJobs(60); + $pastJobsTotal = $this->cronRepository->countPastJobs(); + $pastTotalPages = max(1, (int) ceil($pastJobsTotal / self::PAST_JOBS_PER_PAGE)); + if ($pastPage > $pastTotalPages) { + $pastPage = $pastTotalPages; + } + $pastOffset = ($pastPage - 1) * self::PAST_JOBS_PER_PAGE; + $pastJobs = $this->cronRepository->listPastJobs(self::PAST_JOBS_PER_PAGE, $pastOffset); } catch (Throwable $exception) { Flash::set('settings_error', $this->translator->get('settings.cron.flash.load_failed') . ' ' . $exception->getMessage()); $runOnWeb = $this->runOnWebDefault; @@ -40,6 +50,9 @@ final class CronSettingsController $schedules = []; $futureJobs = []; $pastJobs = []; + $pastJobsTotal = 0; + $pastTotalPages = 1; + $pastPage = 1; } $html = $this->template->render('settings/cron', [ @@ -53,6 +66,12 @@ final class CronSettingsController 'schedules' => $schedules, 'futureJobs' => $futureJobs, 'pastJobs' => $pastJobs, + 'pastJobsPagination' => [ + 'page' => $pastPage, + 'per_page' => self::PAST_JOBS_PER_PAGE, + 'total' => $pastJobsTotal, + 'total_pages' => $pastTotalPages, + ], 'errorMessage' => (string) Flash::get('settings_error', ''), 'successMessage' => (string) Flash::get('settings_success', ''), ], 'layouts/app'); diff --git a/src/Modules/Settings/InpostIntegrationController.php b/src/Modules/Settings/InpostIntegrationController.php index 6730e45..235808d 100644 --- a/src/Modules/Settings/InpostIntegrationController.php +++ b/src/Modules/Settings/InpostIntegrationController.php @@ -42,9 +42,11 @@ final class InpostIntegrationController public function save(Request $request): Response { + $redirectTo = $this->resolveRedirectPath((string) $request->input('return_to', '/settings/integrations/inpost')); + if (!Csrf::validate((string) $request->input('_token', ''))) { Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired')); - return Response::redirect('/settings/integrations/inpost'); + return Response::redirect($redirectTo); } try { @@ -72,6 +74,19 @@ final class InpostIntegrationController ); } - return Response::redirect('/settings/integrations/inpost'); + return Response::redirect($redirectTo); + } + + private function resolveRedirectPath(string $candidate): string + { + $value = trim($candidate); + if ($value === '') { + return '/settings/integrations/inpost'; + } + if (!str_starts_with($value, '/settings/integrations')) { + return '/settings/integrations/inpost'; + } + + return $value; } } diff --git a/src/Modules/Settings/InpostIntegrationRepository.php b/src/Modules/Settings/InpostIntegrationRepository.php index e33baac..3471b5d 100644 --- a/src/Modules/Settings/InpostIntegrationRepository.php +++ b/src/Modules/Settings/InpostIntegrationRepository.php @@ -9,10 +9,19 @@ use Throwable; final class InpostIntegrationRepository { + private const INTEGRATION_TYPE = 'inpost'; + private const INTEGRATION_NAME = 'InPost ShipX'; + private const INTEGRATION_BASE_URL = 'https://api-shipx-pl.easypack24.net'; + + private readonly IntegrationsRepository $integrations; + private readonly IntegrationSecretCipher $cipher; + public function __construct( private readonly PDO $pdo, private readonly string $secret ) { + $this->integrations = new IntegrationsRepository($this->pdo); + $this->cipher = new IntegrationSecretCipher($this->secret); } /** @@ -22,16 +31,20 @@ final class InpostIntegrationRepository { $row = $this->fetchRow(); if ($row === null) { - return $this->defaultSettings(); + $row = $this->defaultSettings(); } + $encryptedToken = $this->resolveEncryptedToken($row); + return [ - 'has_api_token' => trim((string) ($row['api_token_encrypted'] ?? '')) !== '', + 'has_api_token' => $encryptedToken !== null && $encryptedToken !== '', 'organization_id' => (string) ($row['organization_id'] ?? ''), 'environment' => (string) ($row['environment'] ?? 'sandbox'), 'default_dispatch_method' => (string) ($row['default_dispatch_method'] ?? 'pop'), 'default_dispatch_point' => (string) ($row['default_dispatch_point'] ?? ''), - 'default_insurance' => $row['default_insurance'] !== null ? (float) $row['default_insurance'] : null, + 'default_insurance' => isset($row['default_insurance']) && $row['default_insurance'] !== null + ? (float) $row['default_insurance'] + : null, 'default_locker_size' => (string) ($row['default_locker_size'] ?? 'small'), 'default_courier_length' => (int) ($row['default_courier_length'] ?? 20), 'default_courier_width' => (int) ($row['default_courier_width'] ?? 15), @@ -54,12 +67,17 @@ final class InpostIntegrationRepository throw new RuntimeException('Brak rekordu konfiguracji InPost.'); } + $integrationId = $this->ensureBaseIntegration(); + $currentEncrypted = $this->resolveEncryptedToken($current); + $apiToken = trim((string) ($payload['api_token'] ?? '')); - $apiTokenEncrypted = trim((string) ($current['api_token_encrypted'] ?? '')); + $nextEncrypted = $currentEncrypted; if ($apiToken !== '') { - $apiTokenEncrypted = (string) $this->encrypt($apiToken); + $nextEncrypted = $this->cipher->encrypt($apiToken); } + $this->integrations->updateApiKeyEncrypted($integrationId, $nextEncrypted); + $statement = $this->pdo->prepare( 'UPDATE inpost_integration_settings SET api_token_encrypted = :api_token_encrypted, @@ -80,7 +98,7 @@ final class InpostIntegrationRepository WHERE id = 1' ); $statement->execute([ - 'api_token_encrypted' => $this->nullableString($apiTokenEncrypted), + 'api_token_encrypted' => $this->nullableString((string) $nextEncrypted), 'organization_id' => $this->nullableString(trim((string) ($payload['organization_id'] ?? ''))), 'environment' => in_array($payload['environment'] ?? '', ['sandbox', 'production'], true) ? $payload['environment'] @@ -117,12 +135,12 @@ final class InpostIntegrationRepository return null; } - $encrypted = trim((string) ($row['api_token_encrypted'] ?? '')); - if ($encrypted === '') { + $encrypted = $this->resolveEncryptedToken($row); + if ($encrypted === null || $encrypted === '') { return null; } - return $this->decrypt($encrypted); + return $this->cipher->decrypt($encrypted); } private function ensureRow(): void @@ -174,60 +192,32 @@ final class InpostIntegrationRepository ]; } + private function resolveEncryptedToken(array $row): ?string + { + $integrationId = $this->ensureBaseIntegration(); + $fromBase = $this->integrations->getApiKeyEncrypted($integrationId); + if ($fromBase !== null && $fromBase !== '') { + return $fromBase; + } + + $legacy = trim((string) ($row['api_token_encrypted'] ?? '')); + return $legacy === '' ? null : $legacy; + } + + private function ensureBaseIntegration(): int + { + return $this->integrations->ensureIntegration( + self::INTEGRATION_TYPE, + self::INTEGRATION_NAME, + self::INTEGRATION_BASE_URL, + 20, + true + ); + } + 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 $encrypted): ?string - { - if ($this->secret === '') { - throw new RuntimeException('Brak INTEGRATIONS_SECRET do odszyfrowania danych integracji.'); - } - if (!str_starts_with($encrypted, 'v1:')) { - return null; - } - - $raw = base64_decode(substr($encrypted, 3), true); - if ($raw === false || strlen($raw) < 48) { - return null; - } - - $encryptionKey = hash('sha256', 'enc|' . $this->secret, true); - $hmacKey = hash('sha256', 'auth|' . $this->secret, true); - $iv = substr($raw, 0, 16); - $mac = substr($raw, 16, 32); - $cipherRaw = substr($raw, 48); - - $expectedMac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true); - if (!hash_equals($expectedMac, $mac)) { - return null; - } - - $decrypted = openssl_decrypt($cipherRaw, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv); - return $decrypted !== false ? $decrypted : null; - } } diff --git a/src/Modules/Settings/IntegrationSecretCipher.php b/src/Modules/Settings/IntegrationSecretCipher.php new file mode 100644 index 0000000..27e7e94 --- /dev/null +++ b/src/Modules/Settings/IntegrationSecretCipher.php @@ -0,0 +1,65 @@ +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); + } + + public function decrypt(string $encrypted): ?string + { + if ($this->secret === '') { + throw new RuntimeException('Brak INTEGRATIONS_SECRET do odszyfrowania danych integracji.'); + } + + if (!str_starts_with($encrypted, 'v1:')) { + return null; + } + + $raw = base64_decode(substr($encrypted, 3), true); + if ($raw === false || strlen($raw) < 48) { + return null; + } + + $encryptionKey = hash('sha256', 'enc|' . $this->secret, true); + $hmacKey = hash('sha256', 'auth|' . $this->secret, true); + $iv = substr($raw, 0, 16); + $mac = substr($raw, 16, 32); + $cipherRaw = substr($raw, 48); + + $expectedMac = hash_hmac('sha256', $iv . $cipherRaw, $hmacKey, true); + if (!hash_equals($expectedMac, $mac)) { + return null; + } + + $decrypted = openssl_decrypt($cipherRaw, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv); + return $decrypted !== false ? $decrypted : null; + } +} diff --git a/src/Modules/Settings/IntegrationsHubController.php b/src/Modules/Settings/IntegrationsHubController.php new file mode 100644 index 0000000..dca3888 --- /dev/null +++ b/src/Modules/Settings/IntegrationsHubController.php @@ -0,0 +1,169 @@ +buildAllegroRow('sandbox'), + $this->buildAllegroRow('production'), + $this->buildApaczkaRow(), + $this->buildInpostRow(), + $this->buildShopproRow(), + ]; + + $html = $this->template->render('settings/integrations', [ + 'title' => $this->translator->get('settings.integrations_hub.title'), + 'activeMenu' => 'settings', + 'activeSettings' => 'integrations', + 'user' => $this->auth->user(), + 'csrfToken' => Csrf::token(), + 'rows' => $rows, + 'errorMessage' => (string) Flash::get('settings_error', ''), + 'successMessage' => (string) Flash::get('settings_success', ''), + ], 'layouts/app'); + + return Response::html($html); + } + + /** + * @return array + */ + private function buildAllegroRow(string $environment): array + { + $env = $environment === 'production' ? 'production' : 'sandbox'; + $settings = $this->allegro->getSettings($env); + $integrationName = $env === 'production' ? 'Allegro Production' : 'Allegro Sandbox'; + $meta = $this->integrations->findByTypeAndName('allegro', $integrationName) ?? []; + + return [ + 'provider' => $this->translator->get('settings.integrations_hub.providers.allegro'), + 'instance' => $this->translator->get('settings.integrations_hub.providers.' . ($env === 'production' ? 'allegro_production' : 'allegro_sandbox')), + 'authorization_status' => !empty($settings['is_connected']) + ? $this->translator->get('settings.integrations_hub.status.connected') + : $this->translator->get('settings.integrations_hub.status.not_connected'), + 'secret_status' => !empty($settings['has_client_secret']) + ? $this->translator->get('settings.integrations_hub.status.saved') + : $this->translator->get('settings.integrations_hub.status.missing'), + 'is_active' => (int) ($meta['is_active'] ?? 0) === 1, + 'last_test_at' => trim((string) ($meta['last_test_at'] ?? '')), + 'configure_url' => '/settings/integrations/allegro?env=' . rawurlencode($env), + 'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'), + ]; + } + + /** + * @return array + */ + private function buildApaczkaRow(): array + { + $settings = $this->apaczka->getSettings(); + $meta = $this->integrations->findByTypeAndName('apaczka', 'Apaczka') ?? []; + + return [ + 'provider' => $this->translator->get('settings.integrations_hub.providers.apaczka'), + 'instance' => 'Apaczka', + 'authorization_status' => !empty($settings['has_api_key']) + ? $this->translator->get('settings.integrations_hub.status.configured') + : $this->translator->get('settings.integrations_hub.status.not_configured'), + 'secret_status' => !empty($settings['has_api_key']) + ? $this->translator->get('settings.integrations_hub.status.saved') + : $this->translator->get('settings.integrations_hub.status.missing'), + 'is_active' => (int) ($meta['is_active'] ?? 0) === 1, + 'last_test_at' => trim((string) ($meta['last_test_at'] ?? '')), + 'configure_url' => '/settings/integrations/apaczka', + 'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'), + ]; + } + + /** + * @return array + */ + private function buildInpostRow(): array + { + $settings = $this->inpost->getSettings(); + $meta = $this->integrations->findByTypeAndName('inpost', 'InPost ShipX') ?? []; + + return [ + 'provider' => $this->translator->get('settings.integrations_hub.providers.inpost'), + 'instance' => 'InPost ShipX', + 'authorization_status' => !empty($settings['has_api_token']) + ? $this->translator->get('settings.integrations_hub.status.configured') + : $this->translator->get('settings.integrations_hub.status.not_configured'), + 'secret_status' => !empty($settings['has_api_token']) + ? $this->translator->get('settings.integrations_hub.status.saved') + : $this->translator->get('settings.integrations_hub.status.missing'), + 'is_active' => (int) ($meta['is_active'] ?? 0) === 1, + 'last_test_at' => trim((string) ($meta['last_test_at'] ?? '')), + 'configure_url' => '/settings/integrations/inpost', + 'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'), + ]; + } + + /** + * @return array + */ + private function buildShopproRow(): array + { + $rows = $this->shoppro->listIntegrations(); + $instancesCount = count($rows); + $activeCount = 0; + $configuredCount = 0; + $lastTestAt = ''; + + foreach ($rows as $row) { + if (!empty($row['is_active'])) { + $activeCount++; + } + if (!empty($row['has_api_key'])) { + $configuredCount++; + } + + $testedAt = trim((string) ($row['last_test_at'] ?? '')); + if ($testedAt !== '' && ($lastTestAt === '' || strcmp($testedAt, $lastTestAt) > 0)) { + $lastTestAt = $testedAt; + } + } + + return [ + 'provider' => $this->translator->get('settings.integrations_hub.providers.shoppro'), + 'instance' => $this->translator->get('settings.integrations_hub.providers.shoppro_instances', [ + 'count' => $instancesCount, + ]), + 'authorization_status' => $configuredCount > 0 + ? $this->translator->get('settings.integrations_hub.status.configured') + : $this->translator->get('settings.integrations_hub.status.not_configured'), + 'secret_status' => $configuredCount > 0 + ? $this->translator->get('settings.integrations_hub.status.saved') + : $this->translator->get('settings.integrations_hub.status.missing'), + 'is_active' => $activeCount > 0, + 'last_test_at' => $lastTestAt, + 'configure_url' => '/settings/integrations/shoppro', + 'configure_label' => $this->translator->get('settings.integrations_hub.actions.configure'), + ]; + } + +} diff --git a/src/Modules/Settings/IntegrationsRepository.php b/src/Modules/Settings/IntegrationsRepository.php new file mode 100644 index 0000000..3060a8d --- /dev/null +++ b/src/Modules/Settings/IntegrationsRepository.php @@ -0,0 +1,155 @@ +|null + */ + public function findByTypeAndName(string $type, string $name): ?array + { + try { + $statement = $this->pdo->prepare( + 'SELECT * FROM integrations WHERE type = :type AND name = :name LIMIT 1' + ); + $statement->execute([ + 'type' => trim($type), + 'name' => trim($name), + ]); + $row = $statement->fetch(PDO::FETCH_ASSOC); + } catch (Throwable) { + return null; + } + + return is_array($row) ? $row : null; + } + + /** + * @return array|null + */ + public function findFirstByType(string $type): ?array + { + try { + $statement = $this->pdo->prepare( + 'SELECT * FROM integrations WHERE type = :type ORDER BY id ASC LIMIT 1' + ); + $statement->execute(['type' => trim($type)]); + $row = $statement->fetch(PDO::FETCH_ASSOC); + } catch (Throwable) { + return null; + } + + return is_array($row) ? $row : null; + } + + /** + * @return array|null + */ + public function findById(int $id): ?array + { + if ($id <= 0) { + return null; + } + + try { + $statement = $this->pdo->prepare('SELECT * FROM integrations WHERE id = :id LIMIT 1'); + $statement->execute(['id' => $id]); + $row = $statement->fetch(PDO::FETCH_ASSOC); + } catch (Throwable) { + return null; + } + + return is_array($row) ? $row : null; + } + + public function ensureIntegration( + string $type, + string $name, + string $baseUrl, + int $timeoutSeconds = 10, + bool $isActive = true + ): int { + $typeValue = trim($type); + $nameValue = trim($name); + $baseUrlValue = trim($baseUrl); + + $existing = $this->findByTypeAndName($typeValue, $nameValue); + if ($existing !== null) { + return (int) ($existing['id'] ?? 0); + } + + $statement = $this->pdo->prepare( + 'INSERT INTO integrations ( + type, name, base_url, timeout_seconds, is_active, created_at, updated_at + ) VALUES ( + :type, :name, :base_url, :timeout_seconds, :is_active, NOW(), NOW() + )' + ); + $statement->execute([ + 'type' => $typeValue, + 'name' => $nameValue, + 'base_url' => $baseUrlValue, + 'timeout_seconds' => max(1, $timeoutSeconds), + 'is_active' => $isActive ? 1 : 0, + ]); + + return (int) $this->pdo->lastInsertId(); + } + + public function updateApiKeyEncrypted(int $integrationId, ?string $encrypted): void + { + if ($integrationId <= 0) { + return; + } + + $statement = $this->pdo->prepare( + 'UPDATE integrations + SET api_key_encrypted = :api_key_encrypted, + updated_at = NOW() + WHERE id = :id' + ); + $statement->execute([ + 'id' => $integrationId, + 'api_key_encrypted' => $this->nullableString((string) $encrypted), + ]); + } + + public function getApiKeyEncrypted(int $integrationId): ?string + { + if ($integrationId <= 0) { + return null; + } + + try { + $statement = $this->pdo->prepare( + 'SELECT api_key_encrypted FROM integrations WHERE id = :id LIMIT 1' + ); + $statement->execute(['id' => $integrationId]); + $value = $statement->fetchColumn(); + } catch (Throwable) { + return null; + } + + if (!is_string($value)) { + return null; + } + + $trimmed = trim($value); + return $trimmed === '' ? null : $trimmed; + } + + private function nullableString(string $value): ?string + { + $trimmed = trim($value); + return $trimmed === '' ? null : $trimmed; + } +} diff --git a/src/Modules/Settings/ShopproApiClient.php b/src/Modules/Settings/ShopproApiClient.php new file mode 100644 index 0000000..59bda76 --- /dev/null +++ b/src/Modules/Settings/ShopproApiClient.php @@ -0,0 +1,263 @@ +>,total:int,page:int,per_page:int} + */ + public function fetchOrders( + string $baseUrl, + string $apiKey, + int $timeoutSeconds, + int $page = 1, + int $perPage = 100, + ?string $fromDate = null + ): array { + $query = [ + 'endpoint' => 'orders', + 'action' => 'list', + 'page' => max(1, $page), + 'per_page' => max(1, min(100, $perPage)), + 'sort' => 'updated_at', + 'sort_dir' => 'ASC', + ]; + + $dateFrom = trim((string) $fromDate); + if ($dateFrom !== '') { + $query['date_from'] = $dateFrom; + $query['updated_from'] = $dateFrom; + } + + $url = rtrim(trim($baseUrl), '/') . '/api.php?' . http_build_query($query); + $response = $this->requestJson($url, $apiKey, $timeoutSeconds); + if (($response['ok'] ?? false) !== true) { + return [ + 'ok' => false, + 'http_code' => $response['http_code'] ?? null, + 'message' => (string) ($response['message'] ?? 'Nie mozna pobrac listy zamowien z shopPRO.'), + 'items' => [], + 'total' => 0, + 'page' => max(1, $page), + 'per_page' => max(1, min(100, $perPage)), + ]; + } + + $data = is_array($response['data'] ?? null) ? $response['data'] : []; + $items = []; + if (isset($data['items']) && is_array($data['items'])) { + $items = $data['items']; + } elseif (isset($data['orders']) && is_array($data['orders'])) { + $items = $data['orders']; + } elseif ($data !== [] && array_keys($data) === range(0, count($data) - 1)) { + $items = $data; + } + + return [ + 'ok' => true, + 'http_code' => $response['http_code'] ?? null, + 'message' => '', + 'items' => array_values(array_filter($items, static fn (mixed $row): bool => is_array($row))), + 'total' => (int) ($data['total'] ?? count($items)), + 'page' => (int) ($data['page'] ?? max(1, $page)), + 'per_page' => (int) ($data['per_page'] ?? max(1, min(100, $perPage))), + ]; + } + + /** + * @return array{ok:bool,http_code:int|null,message:string,order:array|null} + */ + public function fetchOrderById(string $baseUrl, string $apiKey, int $timeoutSeconds, string $orderId): array + { + $id = trim($orderId); + if ($id === '') { + return [ + 'ok' => false, + 'http_code' => null, + 'message' => 'Niepoprawne ID zamowienia.', + 'order' => null, + ]; + } + + $base = rtrim(trim($baseUrl), '/'); + $attemptUrls = [ + $base . '/api.php?' . http_build_query(['endpoint' => 'orders', 'action' => 'get', 'id' => $id]), + $base . '/api.php?' . http_build_query(['endpoint' => 'orders', 'action' => 'details', 'id' => $id]), + ]; + + $lastMessage = 'Nie mozna pobrac szczegolow zamowienia z shopPRO.'; + $lastCode = null; + + foreach ($attemptUrls as $url) { + $response = $this->requestJson($url, $apiKey, $timeoutSeconds); + if (($response['ok'] ?? false) !== true) { + $lastMessage = trim((string) ($response['message'] ?? $lastMessage)); + $lastCode = $response['http_code'] ?? null; + continue; + } + + $data = $response['data'] ?? null; + if (!is_array($data)) { + continue; + } + + if (isset($data['order']) && is_array($data['order'])) { + return [ + 'ok' => true, + 'http_code' => $response['http_code'] ?? null, + 'message' => '', + 'order' => $data['order'], + ]; + } + + return [ + 'ok' => true, + 'http_code' => $response['http_code'] ?? null, + 'message' => '', + 'order' => $data, + ]; + } + + return [ + 'ok' => false, + 'http_code' => $lastCode, + 'message' => $lastMessage, + 'order' => null, + ]; + } + + /** + * @return array{ok:bool,http_code:int|null,message:string,product:array|null} + */ + public function fetchProductById(string $baseUrl, string $apiKey, int $timeoutSeconds, int $productId): array + { + if ($productId <= 0) { + return [ + 'ok' => false, + 'http_code' => null, + 'message' => 'Niepoprawne ID produktu.', + 'product' => null, + ]; + } + + $normalizedBaseUrl = rtrim(trim($baseUrl), '/'); + $query = http_build_query([ + 'endpoint' => 'products', + 'action' => 'get', + 'id' => $productId, + ]); + $endpointUrl = $normalizedBaseUrl . '/api.php?' . $query; + + $response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds); + if (($response['ok'] ?? false) !== true) { + return [ + 'ok' => false, + 'http_code' => $response['http_code'] ?? null, + 'message' => (string) ($response['message'] ?? 'Nie mozna pobrac produktu z shopPRO.'), + 'product' => null, + ]; + } + + $data = is_array($response['data'] ?? null) ? $response['data'] : null; + if ($data === null) { + return [ + 'ok' => false, + 'http_code' => $response['http_code'] ?? null, + 'message' => 'shopPRO zwrocil pusty payload produktu.', + 'product' => null, + ]; + } + + return [ + 'ok' => true, + 'http_code' => $response['http_code'] ?? null, + 'message' => '', + 'product' => $data, + ]; + } + + /** + * @return array{ok:bool,http_code:int|null,message:string,data:array|array|null} + */ + private function requestJson(string $url, string $apiKey, int $timeoutSeconds): array + { + $curl = curl_init($url); + if ($curl === false) { + return [ + 'ok' => false, + 'http_code' => null, + 'message' => 'Nie udalo sie zainicjalizowac polaczenia HTTP.', + 'data' => null, + ]; + } + + curl_setopt_array($curl, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => max(1, min(120, $timeoutSeconds)), + CURLOPT_CONNECTTIMEOUT => max(1, min(120, $timeoutSeconds)), + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + 'X-Api-Key: ' . $apiKey, + ], + ]); + + $body = curl_exec($curl); + $httpCode = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE); + $curlError = trim(curl_error($curl)); + + if ($body === false) { + return [ + 'ok' => false, + 'http_code' => $httpCode > 0 ? $httpCode : null, + 'message' => $curlError !== '' ? $curlError : 'Nieznany blad transportu HTTP.', + 'data' => null, + ]; + } + + $decoded = json_decode((string) $body, true); + if (!is_array($decoded)) { + return [ + 'ok' => false, + 'http_code' => $httpCode > 0 ? $httpCode : null, + 'message' => 'Odpowiedz API nie jest poprawnym JSON.', + 'data' => null, + ]; + } + + $apiStatus = trim((string) ($decoded['status'] ?? '')); + $apiCode = trim((string) ($decoded['code'] ?? '')); + $apiMessage = trim((string) ($decoded['message'] ?? '')); + if ($apiStatus !== '' && mb_strtolower($apiStatus) !== 'ok') { + $message = trim('shopPRO zwrocil blad. ' . $apiCode . ' ' . $apiMessage); + return [ + 'ok' => false, + 'http_code' => $httpCode > 0 ? $httpCode : null, + 'message' => $message !== '' ? $message : 'Nieznany blad API shopPRO.', + 'data' => null, + ]; + } + + if ($httpCode >= 400) { + return [ + 'ok' => false, + 'http_code' => $httpCode, + 'message' => $apiMessage !== '' ? $apiMessage : 'Blad HTTP podczas komunikacji z shopPRO.', + 'data' => null, + ]; + } + + $data = $decoded['data'] ?? $decoded; + if (!is_array($data)) { + $data = []; + } + + return [ + 'ok' => true, + 'http_code' => $httpCode > 0 ? $httpCode : null, + 'message' => '', + 'data' => $data, + ]; + } +} diff --git a/src/Modules/Settings/ShopproDeliveryMethodMappingRepository.php b/src/Modules/Settings/ShopproDeliveryMethodMappingRepository.php new file mode 100644 index 0000000..61e3c15 --- /dev/null +++ b/src/Modules/Settings/ShopproDeliveryMethodMappingRepository.php @@ -0,0 +1,104 @@ +> + */ + public function listMappings(int $integrationId): array + { + if ($integrationId <= 0) { + return []; + } + + $stmt = $this->pdo->prepare( + 'SELECT * + FROM shoppro_delivery_method_mappings + WHERE integration_id = :integration_id + ORDER BY order_delivery_method ASC' + ); + $stmt->execute(['integration_id' => $integrationId]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + return is_array($rows) ? $rows : []; + } + + /** + * @param array> $mappings + */ + public function saveMappings(int $integrationId, array $mappings): void + { + if ($integrationId <= 0) { + return; + } + + $deleteStmt = $this->pdo->prepare( + 'DELETE FROM shoppro_delivery_method_mappings WHERE integration_id = :integration_id' + ); + $deleteStmt->execute(['integration_id' => $integrationId]); + + if ($mappings === []) { + return; + } + + $insertStmt = $this->pdo->prepare( + 'INSERT INTO shoppro_delivery_method_mappings ( + integration_id, order_delivery_method, carrier, allegro_delivery_method_id, + allegro_credentials_id, allegro_carrier_id, allegro_service_name + ) VALUES ( + :integration_id, :order_delivery_method, :carrier, :allegro_delivery_method_id, + :allegro_credentials_id, :allegro_carrier_id, :allegro_service_name + )' + ); + + foreach ($mappings as $mapping) { + $orderMethod = trim((string) ($mapping['order_delivery_method'] ?? '')); + $allegroMethodId = trim((string) ($mapping['allegro_delivery_method_id'] ?? '')); + if ($orderMethod === '' || $allegroMethodId === '') { + continue; + } + + $carrier = trim((string) ($mapping['carrier'] ?? 'allegro')); + $insertStmt->execute([ + 'integration_id' => $integrationId, + 'order_delivery_method' => $orderMethod, + 'carrier' => $carrier !== '' ? $carrier : 'allegro', + 'allegro_delivery_method_id' => $allegroMethodId, + 'allegro_credentials_id' => trim((string) ($mapping['allegro_credentials_id'] ?? '')), + 'allegro_carrier_id' => trim((string) ($mapping['allegro_carrier_id'] ?? '')), + 'allegro_service_name' => trim((string) ($mapping['allegro_service_name'] ?? '')), + ]); + } + } + + /** + * @return array + */ + public function getDistinctOrderDeliveryMethods(int $integrationId): array + { + if ($integrationId <= 0) { + return []; + } + + $stmt = $this->pdo->prepare( + "SELECT DISTINCT external_carrier_id + FROM orders + WHERE external_carrier_id IS NOT NULL + AND external_carrier_id <> '' + AND source = 'shoppro' + AND integration_id = :integration_id + ORDER BY external_carrier_id ASC" + ); + $stmt->execute(['integration_id' => $integrationId]); + $rows = $stmt->fetchAll(PDO::FETCH_COLUMN); + return is_array($rows) ? $rows : []; + } +} diff --git a/src/Modules/Settings/ShopproIntegrationsController.php b/src/Modules/Settings/ShopproIntegrationsController.php new file mode 100644 index 0000000..0fff23e --- /dev/null +++ b/src/Modules/Settings/ShopproIntegrationsController.php @@ -0,0 +1,868 @@ +repository->listIntegrations(); + $forceNewMode = trim((string) $request->input('new', '')) === '1'; + $selectedId = max(0, (int) $request->input('id', 0)); + $selectedIntegration = $selectedId > 0 ? $this->repository->findIntegration($selectedId) : null; + if (!$forceNewMode && $selectedIntegration === null && $integrations !== []) { + $firstId = (int) ($integrations[0]['id'] ?? 0); + if ($firstId > 0) { + $selectedIntegration = $this->repository->findIntegration($firstId); + } + } + $this->ensureImportScheduleExists(); + $this->ensureStatusSyncScheduleExists(); + $this->ensurePaymentSyncScheduleExists(); + $activeTab = $this->resolveTab((string) $request->input('tab', 'integration')); + $discoveredStatuses = $this->readDiscoveredStatuses(); + $statusRows = $selectedIntegration !== null + ? $this->buildStatusRows((int) ($selectedIntegration['id'] ?? 0), $discoveredStatuses) + : []; + $deliveryServicesData = $activeTab === 'delivery' + ? $this->loadDeliveryServices() + : [[], '']; + $deliveryMappings = $selectedIntegration !== null + ? $this->deliveryMappings->listMappings((int) ($selectedIntegration['id'] ?? 0)) + : []; + $orderDeliveryMethods = $selectedIntegration !== null + ? $this->deliveryMappings->getDistinctOrderDeliveryMethods((int) ($selectedIntegration['id'] ?? 0)) + : []; + + $html = $this->template->render('settings/shoppro', [ + 'title' => $this->translator->get('settings.integrations.title'), + 'activeMenu' => 'settings', + 'activeSettings' => 'shoppro', + 'user' => $this->auth->user(), + 'csrfToken' => Csrf::token(), + 'activeTab' => $activeTab, + 'rows' => $integrations, + 'selectedIntegration' => $selectedIntegration, + 'form' => $this->buildFormValues($selectedIntegration), + 'ordersImportIntervalMinutes' => $this->currentImportIntervalMinutes(), + 'statusSyncIntervalMinutes' => $this->currentStatusSyncIntervalMinutes(), + 'paymentSyncIntervalMinutes' => $this->currentPaymentSyncIntervalMinutes(), + 'statusRows' => $statusRows, + 'orderproStatuses' => $this->orderStatuses->listStatuses(), + 'deliveryMappings' => $deliveryMappings, + 'orderDeliveryMethods' => $orderDeliveryMethods, + 'allegroDeliveryServices' => $deliveryServicesData[0], + 'allegroDeliveryServicesError' => $deliveryServicesData[1], + 'inpostDeliveryServices' => array_values(array_filter( + $deliveryServicesData[0], + static fn (array $svc): bool => stripos((string) ($svc['carrierId'] ?? ''), 'inpost') !== false + )), + 'errorMessage' => (string) Flash::get('settings_error', ''), + 'successMessage' => (string) Flash::get('settings_success', ''), + ], 'layouts/app'); + + return Response::html($html); + } + + public function save(Request $request): Response + { + $integrationId = max(0, (int) $request->input('integration_id', 0)); + $tab = $this->resolveTab((string) $request->input('tab', 'integration')); + $redirectBase = '/settings/integrations/shoppro'; + $redirectTo = $this->buildRedirectUrl($integrationId, $tab); + + if (!Csrf::validate((string) $request->input('_token', ''))) { + Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired')); + return Response::redirect($redirectTo); + } + + $existing = $integrationId > 0 ? $this->repository->findIntegration($integrationId) : null; + if ($integrationId > 0 && $existing === null) { + Flash::set('settings_error', $this->translator->get('settings.integrations.flash.not_found')); + return Response::redirect($this->buildRedirectUrl(0, $tab)); + } + + $name = trim((string) $request->input('name', '')); + if (mb_strlen($name) < 2) { + Flash::set('settings_error', $this->translator->get('settings.integrations.validation.name_min')); + return Response::redirect($redirectTo); + } + + $baseUrl = rtrim(trim((string) $request->input('base_url', '')), '/'); + if (!$this->isValidHttpUrl($baseUrl)) { + Flash::set('settings_error', $this->translator->get('settings.integrations.validation.base_url_invalid')); + return Response::redirect($redirectTo); + } + + $apiKey = trim((string) $request->input('api_key', '')); + $hasExistingApiKey = (bool) ($existing['has_api_key'] ?? false); + if ($tab === 'integration' && $apiKey === '' && !$hasExistingApiKey) { + Flash::set('settings_error', $this->translator->get('settings.integrations.validation.api_key_required')); + return Response::redirect($redirectTo); + } + + $ordersFetchStartDate = trim((string) $request->input('orders_fetch_start_date', '')); + if ($ordersFetchStartDate !== '' && !$this->isValidYmdDate($ordersFetchStartDate)) { + Flash::set('settings_error', $this->translator->get('settings.integrations.validation.orders_fetch_start_date_invalid')); + return Response::redirect($redirectTo); + } + + if ($this->isDuplicateName($integrationId, $name)) { + Flash::set('settings_error', $this->translator->get('settings.integrations.validation.name_taken')); + return Response::redirect($redirectTo); + } + + try { + $statusSyncDirectionInput = $request->input('order_status_sync_direction', null); + $statusSyncDirection = $statusSyncDirectionInput !== null + ? trim((string) $statusSyncDirectionInput) + : (string) ($existing['order_status_sync_direction'] ?? 'shoppro_to_orderpro'); + $paymentSyncStatusCodesInput = $request->input('payment_sync_status_codes', null); + if (is_array($paymentSyncStatusCodesInput)) { + $paymentSyncStatusCodes = $paymentSyncStatusCodesInput; + } elseif ($tab === 'settings') { + $paymentSyncStatusCodes = []; + } else { + $paymentSyncStatusCodes = $existing['payment_sync_status_codes'] ?? []; + } + + $savedId = $this->repository->saveIntegration([ + 'integration_id' => $integrationId, + 'name' => $name, + 'base_url' => $baseUrl, + 'api_key' => $apiKey, + 'timeout_seconds' => max(1, min(120, (int) $request->input('timeout_seconds', 10))), + 'is_active' => $request->input('is_active', ''), + 'orders_fetch_enabled' => $request->input('orders_fetch_enabled', ''), + 'orders_fetch_start_date' => $ordersFetchStartDate, + 'order_status_sync_direction' => $statusSyncDirection, + 'payment_sync_status_codes' => $paymentSyncStatusCodes, + ]); + $this->saveImportIntervalIfRequested($request); + $this->saveStatusSyncIntervalIfRequested($request); + $this->savePaymentSyncIntervalIfRequested($request); + + $flashKey = $integrationId > 0 + ? 'settings.integrations.flash.updated' + : 'settings.integrations.flash.created'; + Flash::set('settings_success', $this->translator->get($flashKey)); + + return Response::redirect($this->buildRedirectUrl($savedId, $tab)); + } catch (Throwable) { + Flash::set('settings_error', $this->translator->get('settings.integrations.flash.failed')); + return Response::redirect($redirectTo); + } + } + + public function test(Request $request): Response + { + $integrationId = max(0, (int) $request->input('integration_id', 0)); + $tab = $this->resolveTab((string) $request->input('tab', 'integration')); + $redirectBase = '/settings/integrations/shoppro'; + $redirectTo = $this->buildRedirectUrl($integrationId, $tab); + + if (!Csrf::validate((string) $request->input('_token', ''))) { + Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired')); + return Response::redirect($redirectTo); + } + + if ($integrationId <= 0) { + Flash::set('settings_error', $this->translator->get('settings.integrations.flash.not_found')); + return Response::redirect($this->buildRedirectUrl(0, $tab)); + } + + $result = $this->repository->testConnection($integrationId); + $isOk = (string) ($result['status'] ?? 'error') === 'ok'; + $message = trim((string) ($result['message'] ?? '')); + $httpCode = $result['http_code'] ?? null; + + if ($isOk) { + Flash::set('settings_success', $this->translator->get('settings.integrations.flash.test_ok')); + } else { + $suffix = $message !== '' ? ' ' . $message : ''; + $httpPart = $httpCode !== null ? ' (HTTP ' . (string) $httpCode . ')' : ''; + Flash::set( + 'settings_error', + $this->translator->get('settings.integrations.flash.test_failed') . $httpPart . $suffix + ); + } + + return Response::redirect($redirectTo); + } + + public function saveStatusMappings(Request $request): Response + { + $integrationId = max(0, (int) $request->input('integration_id', 0)); + $redirectTo = $this->buildRedirectUrl($integrationId, 'statuses'); + + if (!Csrf::validate((string) $request->input('_token', ''))) { + Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired')); + return Response::redirect($redirectTo); + } + + if ($integrationId <= 0 || $this->repository->findIntegration($integrationId) === null) { + Flash::set('settings_error', $this->translator->get('settings.integrations.flash.not_found')); + return Response::redirect($this->buildRedirectUrl(0, 'statuses')); + } + + $shopCodes = $request->input('shoppro_status_code', []); + $shopNames = $request->input('shoppro_status_name', []); + $orderCodes = $request->input('orderpro_status_code', []); + if (!is_array($shopCodes) || !is_array($shopNames) || !is_array($orderCodes)) { + Flash::set('settings_error', $this->translator->get('settings.integrations.statuses.flash.invalid_payload')); + return Response::redirect($redirectTo); + } + + $allowedOrderpro = $this->resolveAllowedOrderproStatusCodes(); + $rowsCount = min(count($shopCodes), count($shopNames), count($orderCodes)); + $mappings = []; + for ($index = 0; $index < $rowsCount; $index++) { + $shopCode = trim((string) ($shopCodes[$index] ?? '')); + $shopName = trim((string) ($shopNames[$index] ?? '')); + $orderCode = strtolower(trim((string) ($orderCodes[$index] ?? ''))); + + if ($shopCode === '') { + continue; + } + + if ($orderCode === '') { + continue; + } + + if (!isset($allowedOrderpro[$orderCode])) { + continue; + } + + $mappings[] = [ + 'shoppro_status_code' => $shopCode, + 'shoppro_status_name' => $shopName, + 'orderpro_status_code' => $orderCode, + ]; + } + + try { + $this->statusMappings->replaceForIntegration($integrationId, $mappings); + Flash::set('settings_success', $this->translator->get('settings.integrations.statuses.flash.saved')); + } catch (Throwable $exception) { + Flash::set( + 'settings_error', + $this->translator->get('settings.integrations.statuses.flash.save_failed') . ' ' . $exception->getMessage() + ); + } + + return Response::redirect($redirectTo); + } + + public function syncStatuses(Request $request): Response + { + $integrationId = max(0, (int) $request->input('integration_id', 0)); + $redirectTo = $this->buildRedirectUrl($integrationId, 'statuses'); + + if (!Csrf::validate((string) $request->input('_token', ''))) { + Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired')); + return Response::redirect($redirectTo); + } + + if ($integrationId <= 0 || $this->repository->findIntegration($integrationId) === null) { + Flash::set('settings_error', $this->translator->get('settings.integrations.flash.not_found')); + return Response::redirect($this->buildRedirectUrl(0, 'statuses')); + } + + $result = $this->repository->fetchOrderStatuses($integrationId); + if (($result['ok'] ?? false) !== true) { + $message = trim((string) ($result['message'] ?? '')); + Flash::set( + 'settings_error', + $this->translator->get('settings.integrations.statuses.flash.sync_failed') . ($message !== '' ? ' ' . $message : '') + ); + return Response::redirect($redirectTo); + } + + $statuses = $result['statuses'] ?? []; + Flash::set('shoppro_discovered_statuses', is_array($statuses) ? $statuses : []); + Flash::set( + 'settings_success', + $this->translator->get('settings.integrations.statuses.flash.sync_ok', [ + 'count' => (string) (is_array($statuses) ? count($statuses) : 0), + ]) + ); + + return Response::redirect($redirectTo); + } + + public function saveDeliveryMappings(Request $request): Response + { + $integrationId = max(0, (int) $request->input('integration_id', 0)); + $redirectTo = $this->buildRedirectUrl($integrationId, 'delivery'); + + if (!Csrf::validate((string) $request->input('_token', ''))) { + Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired')); + return Response::redirect($redirectTo); + } + + if ($integrationId <= 0 || $this->repository->findIntegration($integrationId) === null) { + Flash::set('settings_error', $this->translator->get('settings.integrations.flash.not_found')); + return Response::redirect($this->buildRedirectUrl(0, 'delivery')); + } + + $orderMethods = (array) $request->input('order_delivery_method', []); + $carriers = (array) $request->input('carrier', []); + $allegroMethodIds = (array) $request->input('allegro_delivery_method_id', []); + $credentialsIds = (array) $request->input('allegro_credentials_id', []); + $carrierIds = (array) $request->input('allegro_carrier_id', []); + $serviceNames = (array) $request->input('allegro_service_name', []); + + $mappings = []; + foreach ($orderMethods as $index => $orderMethod) { + $orderMethodValue = trim((string) $orderMethod); + $carrier = trim((string) ($carriers[$index] ?? 'allegro')); + $allegroMethodId = trim((string) ($allegroMethodIds[$index] ?? '')); + if ($orderMethodValue === '' || $allegroMethodId === '') { + continue; + } + + $mappings[] = [ + 'order_delivery_method' => $orderMethodValue, + 'carrier' => $carrier, + 'allegro_delivery_method_id' => $allegroMethodId, + 'allegro_credentials_id' => trim((string) ($credentialsIds[$index] ?? '')), + 'allegro_carrier_id' => trim((string) ($carrierIds[$index] ?? '')), + 'allegro_service_name' => trim((string) ($serviceNames[$index] ?? '')), + ]; + } + + try { + $this->deliveryMappings->saveMappings($integrationId, $mappings); + Flash::set('settings_success', $this->translator->get('settings.integrations.delivery.flash.saved')); + } catch (Throwable $exception) { + Flash::set( + 'settings_error', + $this->translator->get('settings.integrations.delivery.flash.save_failed') . ' ' . $exception->getMessage() + ); + } + + return Response::redirect($redirectTo); + } + + /** + * @param array|null $integration + * @return array + */ + private function buildFormValues(?array $integration): array + { + if ($integration === null) { + return [ + 'integration_id' => 0, + 'name' => '', + 'base_url' => '', + 'timeout_seconds' => 10, + 'is_active' => 1, + 'orders_fetch_enabled' => 0, + 'orders_fetch_start_date' => '', + 'order_status_sync_direction' => 'shoppro_to_orderpro', + 'payment_sync_status_codes' => [], + ]; + } + + return [ + 'integration_id' => (int) ($integration['id'] ?? 0), + 'name' => (string) ($integration['name'] ?? ''), + 'base_url' => (string) ($integration['base_url'] ?? ''), + 'timeout_seconds' => (int) ($integration['timeout_seconds'] ?? 10), + 'is_active' => !empty($integration['is_active']) ? 1 : 0, + 'orders_fetch_enabled' => !empty($integration['orders_fetch_enabled']) ? 1 : 0, + 'orders_fetch_start_date' => (string) ($integration['orders_fetch_start_date'] ?? ''), + 'order_status_sync_direction' => (string) ($integration['order_status_sync_direction'] ?? 'shoppro_to_orderpro'), + 'payment_sync_status_codes' => is_array($integration['payment_sync_status_codes'] ?? null) + ? $integration['payment_sync_status_codes'] + : [], + ]; + } + + private function isDuplicateName(int $currentId, string $name): bool + { + $needle = mb_strtolower(trim($name)); + if ($needle === '') { + return false; + } + + $rows = $this->repository->listIntegrations(); + foreach ($rows as $row) { + $rowId = (int) ($row['id'] ?? 0); + if ($rowId === $currentId) { + continue; + } + + $rowName = mb_strtolower(trim((string) ($row['name'] ?? ''))); + if ($rowName === $needle) { + return true; + } + } + + return false; + } + + private function isValidHttpUrl(string $value): bool + { + if (filter_var($value, FILTER_VALIDATE_URL) === false) { + return false; + } + + $scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME)); + return in_array($scheme, ['http', 'https'], true); + } + + private function isValidYmdDate(string $value): bool + { + $date = DateTimeImmutable::createFromFormat('Y-m-d', $value); + return $date !== false && $date->format('Y-m-d') === $value; + } + + /** + * @return array + */ + private function buildStatusRows(int $integrationId, array $discoveredStatuses): array + { + $mappedRows = $this->statusMappings->listByIntegration($integrationId); + $result = []; + + foreach ($mappedRows as $row) { + $code = trim((string) ($row['shoppro_status_code'] ?? '')); + if ($code === '') { + continue; + } + + $key = mb_strtolower($code); + $result[$key] = [ + 'shoppro_status_code' => $code, + 'shoppro_status_name' => trim((string) ($row['shoppro_status_name'] ?? '')), + 'orderpro_status_code' => strtolower(trim((string) ($row['orderpro_status_code'] ?? ''))), + ]; + } + + foreach ($discoveredStatuses as $status) { + if (!is_array($status)) { + continue; + } + + $code = trim((string) ($status['code'] ?? '')); + if ($code === '') { + continue; + } + + $key = mb_strtolower($code); + if (!isset($result[$key])) { + $result[$key] = [ + 'shoppro_status_code' => $code, + 'shoppro_status_name' => trim((string) ($status['name'] ?? '')), + 'orderpro_status_code' => '', + ]; + } + } + + uasort($result, static function (array $left, array $right): int { + return strcmp( + strtolower((string) ($left['shoppro_status_code'] ?? '')), + strtolower((string) ($right['shoppro_status_code'] ?? '')) + ); + }); + + return array_values($result); + } + + /** + * @return array + */ + private function resolveAllowedOrderproStatusCodes(): array + { + $allowed = []; + foreach ($this->orderStatuses->listStatuses() as $status) { + if (!is_array($status)) { + continue; + } + + $code = strtolower(trim((string) ($status['code'] ?? ''))); + if ($code === '') { + continue; + } + + $allowed[$code] = true; + } + + return $allowed; + } + + /** + * @return array + */ + private function readDiscoveredStatuses(): array + { + $raw = Flash::get('shoppro_discovered_statuses', []); + if (!is_array($raw)) { + return []; + } + + $result = []; + foreach ($raw as $item) { + if (!is_array($item)) { + continue; + } + + $code = trim((string) ($item['code'] ?? '')); + if ($code === '') { + continue; + } + + $result[] = [ + 'code' => $code, + 'name' => trim((string) ($item['name'] ?? $code)), + ]; + } + + return $result; + } + + private function resolveTab(string $candidate): string + { + $value = trim($candidate); + $allowed = ['integration', 'statuses', 'settings', 'delivery']; + if (!in_array($value, $allowed, true)) { + return 'integration'; + } + + return $value; + } + + private function buildRedirectUrl(int $integrationId, string $tab): string + { + $url = '/settings/integrations/shoppro'; + $query = []; + if ($integrationId > 0) { + $query['id'] = (string) $integrationId; + } + if ($tab !== 'integration') { + $query['tab'] = $tab; + } + + if ($query === []) { + return $url; + } + + return $url . '?' . http_build_query($query); + } + + private function currentImportIntervalMinutes(): int + { + $schedule = $this->findImportSchedule(); + $seconds = (int) ($schedule['interval_seconds'] ?? self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS); + return max(1, min(1440, (int) floor(max(60, $seconds) / 60))); + } + + /** + * @return array + */ + private function findImportSchedule(): array + { + foreach ($this->cronRepository->listSchedules() as $schedule) { + if ((string) ($schedule['job_type'] ?? '') !== self::ORDERS_IMPORT_JOB_TYPE) { + continue; + } + + return $schedule; + } + + return []; + } + + /** + * @return array + */ + private function findStatusSyncSchedule(): array + { + foreach ($this->cronRepository->listSchedules() as $schedule) { + if ((string) ($schedule['job_type'] ?? '') !== self::ORDER_STATUS_SYNC_JOB_TYPE) { + continue; + } + + return $schedule; + } + + return []; + } + + /** + * @return array + */ + private function findPaymentSyncSchedule(): array + { + foreach ($this->cronRepository->listSchedules() as $schedule) { + if ((string) ($schedule['job_type'] ?? '') !== self::PAYMENT_SYNC_JOB_TYPE) { + continue; + } + + return $schedule; + } + + return []; + } + + private function ensureImportScheduleExists(): void + { + try { + if ($this->findImportSchedule() !== []) { + return; + } + $this->cronRepository->upsertSchedule( + self::ORDERS_IMPORT_JOB_TYPE, + self::ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS, + self::ORDERS_IMPORT_DEFAULT_PRIORITY, + self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS, + null, + true + ); + } catch (Throwable) { + return; + } + } + + private function ensureStatusSyncScheduleExists(): void + { + try { + if ($this->findStatusSyncSchedule() !== []) { + return; + } + $this->cronRepository->upsertSchedule( + self::ORDER_STATUS_SYNC_JOB_TYPE, + self::ORDER_STATUS_SYNC_DEFAULT_INTERVAL_SECONDS, + self::ORDER_STATUS_SYNC_DEFAULT_PRIORITY, + self::ORDER_STATUS_SYNC_DEFAULT_MAX_ATTEMPTS, + null, + true + ); + } catch (Throwable) { + return; + } + } + + private function ensurePaymentSyncScheduleExists(): void + { + try { + if ($this->findPaymentSyncSchedule() !== []) { + return; + } + $this->cronRepository->upsertSchedule( + self::PAYMENT_SYNC_JOB_TYPE, + self::PAYMENT_SYNC_DEFAULT_INTERVAL_SECONDS, + self::PAYMENT_SYNC_DEFAULT_PRIORITY, + self::PAYMENT_SYNC_DEFAULT_MAX_ATTEMPTS, + null, + true + ); + } catch (Throwable) { + return; + } + } + + private function saveImportIntervalIfRequested(Request $request): void + { + if ($request->input('orders_import_interval_minutes', null) === null) { + return; + } + + $this->ensureImportScheduleExists(); + $minutes = max(1, min(1440, (int) $request->input('orders_import_interval_minutes', 5))); + $schedule = $this->findImportSchedule(); + $priority = max(1, min(255, (int) ($schedule['priority'] ?? self::ORDERS_IMPORT_DEFAULT_PRIORITY))); + $maxAttempts = max(1, min(20, (int) ($schedule['max_attempts'] ?? self::ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS))); + $payload = is_array($schedule['payload'] ?? null) ? $schedule['payload'] : null; + $enabled = array_key_exists('enabled', $schedule) ? !empty($schedule['enabled']) : true; + + $this->cronRepository->upsertSchedule( + self::ORDERS_IMPORT_JOB_TYPE, + $minutes * 60, + $priority, + $maxAttempts, + $payload, + $enabled + ); + } + + private function currentStatusSyncIntervalMinutes(): int + { + $schedule = $this->findStatusSyncSchedule(); + $seconds = (int) ($schedule['interval_seconds'] ?? self::ORDER_STATUS_SYNC_DEFAULT_INTERVAL_SECONDS); + return max(1, min(1440, (int) floor(max(60, $seconds) / 60))); + } + + private function saveStatusSyncIntervalIfRequested(Request $request): void + { + if ($request->input('status_sync_interval_minutes', null) === null) { + return; + } + + $this->ensureStatusSyncScheduleExists(); + $minutes = max(1, min(1440, (int) $request->input('status_sync_interval_minutes', 15))); + $schedule = $this->findStatusSyncSchedule(); + $priority = max(1, min(255, (int) ($schedule['priority'] ?? self::ORDER_STATUS_SYNC_DEFAULT_PRIORITY))); + $maxAttempts = max(1, min(20, (int) ($schedule['max_attempts'] ?? self::ORDER_STATUS_SYNC_DEFAULT_MAX_ATTEMPTS))); + $payload = is_array($schedule['payload'] ?? null) ? $schedule['payload'] : null; + $enabled = array_key_exists('enabled', $schedule) ? !empty($schedule['enabled']) : true; + + $this->cronRepository->upsertSchedule( + self::ORDER_STATUS_SYNC_JOB_TYPE, + $minutes * 60, + $priority, + $maxAttempts, + $payload, + $enabled + ); + } + + private function currentPaymentSyncIntervalMinutes(): int + { + $schedule = $this->findPaymentSyncSchedule(); + $seconds = (int) ($schedule['interval_seconds'] ?? self::PAYMENT_SYNC_DEFAULT_INTERVAL_SECONDS); + return max(1, min(1440, (int) floor(max(60, $seconds) / 60))); + } + + private function savePaymentSyncIntervalIfRequested(Request $request): void + { + if ($request->input('payment_sync_interval_minutes', null) === null) { + return; + } + + $this->ensurePaymentSyncScheduleExists(); + $minutes = max(1, min(1440, (int) $request->input('payment_sync_interval_minutes', 10))); + $schedule = $this->findPaymentSyncSchedule(); + $priority = max(1, min(255, (int) ($schedule['priority'] ?? self::PAYMENT_SYNC_DEFAULT_PRIORITY))); + $maxAttempts = max(1, min(20, (int) ($schedule['max_attempts'] ?? self::PAYMENT_SYNC_DEFAULT_MAX_ATTEMPTS))); + $payload = is_array($schedule['payload'] ?? null) ? $schedule['payload'] : null; + $enabled = array_key_exists('enabled', $schedule) ? !empty($schedule['enabled']) : true; + + $this->cronRepository->upsertSchedule( + self::PAYMENT_SYNC_JOB_TYPE, + $minutes * 60, + $priority, + $maxAttempts, + $payload, + $enabled + ); + } + + /** + * @return array{0: array>, 1: string} + */ + private function loadDeliveryServices(): array + { + try { + $oauth = $this->allegroIntegrationRepository->getTokenCredentials(); + if (!is_array($oauth)) { + return [[], $this->translator->get('settings.integrations.delivery.not_connected')]; + } + + $env = (string) ($oauth['environment'] ?? 'sandbox'); + $accessToken = trim((string) ($oauth['access_token'] ?? '')); + if ($accessToken === '') { + return [[], $this->translator->get('settings.integrations.delivery.not_connected')]; + } + + try { + $response = $this->allegroApiClient->getDeliveryServices($env, $accessToken); + } catch (RuntimeException $exception) { + if (trim($exception->getMessage()) !== 'ALLEGRO_HTTP_401') { + throw $exception; + } + + $refreshedToken = $this->refreshAllegroAccessToken($oauth); + if ($refreshedToken === null) { + return [[], $this->translator->get('settings.integrations.delivery.not_connected')]; + } + $response = $this->allegroApiClient->getDeliveryServices($env, $refreshedToken); + } + + $services = is_array($response['services'] ?? null) ? $response['services'] : []; + return [$services, '']; + } catch (Throwable $exception) { + return [[], $exception->getMessage()]; + } + } + + /** + * @param array $oauth + */ + private function refreshAllegroAccessToken(array $oauth): ?string + { + try { + $token = $this->allegroOAuthClient->refreshAccessToken( + (string) ($oauth['environment'] ?? 'sandbox'), + (string) ($oauth['client_id'] ?? ''), + (string) ($oauth['client_secret'] ?? ''), + (string) ($oauth['refresh_token'] ?? '') + ); + + $expiresIn = max(0, (int) ($token['expires_in'] ?? 0)); + $expiresAt = $expiresIn > 0 + ? (new DateTimeImmutable('now'))->add(new DateInterval('PT' . $expiresIn . 'S'))->format('Y-m-d H:i:s') + : null; + $refreshToken = trim((string) ($token['refresh_token'] ?? '')); + if ($refreshToken === '') { + $refreshToken = (string) ($oauth['refresh_token'] ?? ''); + } + + $this->allegroIntegrationRepository->saveTokens( + (string) ($token['access_token'] ?? ''), + $refreshToken, + (string) ($token['token_type'] ?? ''), + (string) ($token['scope'] ?? ''), + $expiresAt + ); + + $accessToken = trim((string) ($token['access_token'] ?? '')); + return $accessToken !== '' ? $accessToken : null; + } catch (Throwable) { + return null; + } + } +} diff --git a/src/Modules/Settings/ShopproIntegrationsRepository.php b/src/Modules/Settings/ShopproIntegrationsRepository.php new file mode 100644 index 0000000..5a54e51 --- /dev/null +++ b/src/Modules/Settings/ShopproIntegrationsRepository.php @@ -0,0 +1,591 @@ +cipher = new IntegrationSecretCipher($this->secret); + } + + /** + * @return array> + */ + public function listIntegrations(): array + { + try { + $statement = $this->pdo->prepare( + 'SELECT * + FROM integrations + WHERE type = :type + ORDER BY is_active DESC, name ASC, id ASC' + ); + $statement->execute(['type' => self::TYPE]); + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + } catch (Throwable) { + return []; + } + + if (!is_array($rows)) { + return []; + } + + $result = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $encrypted = trim((string) ($row['api_key_encrypted'] ?? '')); + $result[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'name' => trim((string) ($row['name'] ?? '')), + 'base_url' => trim((string) ($row['base_url'] ?? '')), + 'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10), + 'is_active' => (int) ($row['is_active'] ?? 0) === 1, + 'orders_fetch_enabled' => (int) ($row['orders_fetch_enabled'] ?? 0) === 1, + 'orders_fetch_start_date' => trim((string) ($row['orders_fetch_start_date'] ?? '')), + 'order_status_sync_direction' => $this->normalizeStatusSyncDirection((string) ($row['order_status_sync_direction'] ?? '')), + 'payment_sync_status_codes' => $this->decodeStatusCodesJson($row['payment_sync_status_codes_json'] ?? null), + 'has_api_key' => $encrypted !== '', + 'last_test_status' => trim((string) ($row['last_test_status'] ?? '')), + 'last_test_http_code' => $row['last_test_http_code'] !== null ? (int) $row['last_test_http_code'] : null, + 'last_test_message' => trim((string) ($row['last_test_message'] ?? '')), + 'last_test_at' => trim((string) ($row['last_test_at'] ?? '')), + ]; + } + + return $result; + } + + /** + * @return array|null + */ + public function findIntegration(int $integrationId): ?array + { + if ($integrationId <= 0) { + return null; + } + + try { + $statement = $this->pdo->prepare( + 'SELECT * + FROM integrations + WHERE id = :id AND type = :type + LIMIT 1' + ); + $statement->execute([ + 'id' => $integrationId, + 'type' => self::TYPE, + ]); + $row = $statement->fetch(PDO::FETCH_ASSOC); + } catch (Throwable) { + return null; + } + + if (!is_array($row)) { + return null; + } + + return [ + 'id' => (int) ($row['id'] ?? 0), + 'name' => trim((string) ($row['name'] ?? '')), + 'base_url' => trim((string) ($row['base_url'] ?? '')), + 'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10), + 'is_active' => (int) ($row['is_active'] ?? 0) === 1, + 'orders_fetch_enabled' => (int) ($row['orders_fetch_enabled'] ?? 0) === 1, + 'orders_fetch_start_date' => trim((string) ($row['orders_fetch_start_date'] ?? '')), + 'order_status_sync_direction' => $this->normalizeStatusSyncDirection((string) ($row['order_status_sync_direction'] ?? '')), + 'payment_sync_status_codes' => $this->decodeStatusCodesJson($row['payment_sync_status_codes_json'] ?? null), + 'payment_sync_status_codes_json' => $row['payment_sync_status_codes_json'] ?? null, + 'api_key_encrypted' => trim((string) ($row['api_key_encrypted'] ?? '')), + 'has_api_key' => trim((string) ($row['api_key_encrypted'] ?? '')) !== '', + ]; + } + + public function getApiKeyDecrypted(int $integrationId): ?string + { + $integration = $this->findIntegration($integrationId); + if ($integration === null) { + return null; + } + + $encrypted = trim((string) ($integration['api_key_encrypted'] ?? '')); + if ($encrypted === '') { + return null; + } + + $decrypted = (string) $this->cipher->decrypt($encrypted); + $trimmed = trim($decrypted); + return $trimmed === '' ? null : $trimmed; + } + + /** + * @param array $payload + */ + public function saveIntegration(array $payload): int + { + $integrationId = (int) ($payload['integration_id'] ?? 0); + $name = trim((string) ($payload['name'] ?? '')); + $baseUrl = rtrim(trim((string) ($payload['base_url'] ?? '')), '/'); + $timeoutSeconds = max(1, (int) ($payload['timeout_seconds'] ?? 10)); + $isActive = !empty($payload['is_active']) ? 1 : 0; + $ordersFetchEnabled = !empty($payload['orders_fetch_enabled']) ? 1 : 0; + $ordersFetchStartDate = $this->normalizeDate((string) ($payload['orders_fetch_start_date'] ?? '')); + $statusSyncDirection = $this->normalizeStatusSyncDirection((string) ($payload['order_status_sync_direction'] ?? '')); + $paymentSyncStatusCodesJson = $this->encodeStatusCodesJson($payload['payment_sync_status_codes'] ?? []); + $apiKey = trim((string) ($payload['api_key'] ?? '')); + + if ($integrationId > 0) { + $existing = $this->findIntegration($integrationId); + if ($existing === null) { + throw new RuntimeException('INTEGRATION_NOT_FOUND'); + } + + $encryptedApiKey = trim((string) ($existing['api_key_encrypted'] ?? '')); + if ($apiKey !== '') { + $encryptedApiKey = (string) $this->cipher->encrypt($apiKey); + } + + $statement = $this->pdo->prepare( + 'UPDATE integrations + SET name = :name, + base_url = :base_url, + api_key_encrypted = :api_key_encrypted, + timeout_seconds = :timeout_seconds, + is_active = :is_active, + orders_fetch_enabled = :orders_fetch_enabled, + orders_fetch_start_date = :orders_fetch_start_date, + order_status_sync_direction = :order_status_sync_direction, + payment_sync_status_codes_json = :payment_sync_status_codes_json, + updated_at = NOW() + WHERE id = :id AND type = :type' + ); + $statement->execute([ + 'id' => $integrationId, + 'type' => self::TYPE, + 'name' => $name, + 'base_url' => $baseUrl, + 'api_key_encrypted' => $this->nullableString($encryptedApiKey), + 'timeout_seconds' => $timeoutSeconds, + 'is_active' => $isActive, + 'orders_fetch_enabled' => $ordersFetchEnabled, + 'orders_fetch_start_date' => $ordersFetchStartDate, + 'order_status_sync_direction' => $statusSyncDirection, + 'payment_sync_status_codes_json' => $paymentSyncStatusCodesJson, + ]); + + return $integrationId; + } + + $encryptedApiKey = $this->cipher->encrypt($apiKey); + $statement = $this->pdo->prepare( + 'INSERT INTO integrations ( + type, name, base_url, api_key_encrypted, timeout_seconds, is_active, orders_fetch_enabled, orders_fetch_start_date, order_status_sync_direction, payment_sync_status_codes_json, created_at, updated_at + ) VALUES ( + :type, :name, :base_url, :api_key_encrypted, :timeout_seconds, :is_active, :orders_fetch_enabled, :orders_fetch_start_date, :order_status_sync_direction, :payment_sync_status_codes_json, NOW(), NOW() + )' + ); + $statement->execute([ + 'type' => self::TYPE, + 'name' => $name, + 'base_url' => $baseUrl, + 'api_key_encrypted' => $this->nullableString((string) $encryptedApiKey), + 'timeout_seconds' => $timeoutSeconds, + 'is_active' => $isActive, + 'orders_fetch_enabled' => $ordersFetchEnabled, + 'orders_fetch_start_date' => $ordersFetchStartDate, + 'order_status_sync_direction' => $statusSyncDirection, + 'payment_sync_status_codes_json' => $paymentSyncStatusCodesJson, + ]); + + return (int) $this->pdo->lastInsertId(); + } + + /** + * @return array{status:string,http_code:int|null,message:string} + */ + public function testConnection(int $integrationId): array + { + $integration = $this->findIntegration($integrationId); + if ($integration === null) { + return [ + 'status' => 'error', + 'http_code' => null, + 'message' => 'Nie znaleziono integracji shopPRO.', + ]; + } + + $apiKeyEncrypted = trim((string) ($integration['api_key_encrypted'] ?? '')); + $apiKey = $apiKeyEncrypted !== '' ? (string) $this->cipher->decrypt($apiKeyEncrypted) : ''; + if ($apiKey === '') { + return [ + 'status' => 'error', + 'http_code' => null, + 'message' => 'Brak zapisanego klucza API.', + ]; + } + + $baseUrl = rtrim((string) ($integration['base_url'] ?? ''), '/'); + $timeout = max(1, min(120, (int) ($integration['timeout_seconds'] ?? 10))); + $url = $baseUrl . '/api.php?endpoint=dictionaries&action=statuses'; + + $curl = curl_init($url); + if ($curl === false) { + return [ + 'status' => 'error', + 'http_code' => null, + 'message' => 'Nie udalo sie zainicjalizowac polaczenia HTTP.', + ]; + } + + curl_setopt_array($curl, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => $timeout, + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + 'X-Api-Key: ' . $apiKey, + ], + ]); + + $body = curl_exec($curl); + $httpCode = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE); + $curlError = curl_error($curl); + curl_close($curl); + + if ($body === false) { + $message = trim($curlError) !== '' ? trim($curlError) : 'Nieznany blad transportu HTTP.'; + $result = [ + 'status' => 'error', + 'http_code' => $httpCode > 0 ? $httpCode : null, + 'message' => $message, + ]; + $this->storeTestResult($integrationId, $url, $result); + return $result; + } + + $decoded = json_decode((string) $body, true); + if (!is_array($decoded)) { + $result = [ + 'status' => 'error', + 'http_code' => $httpCode > 0 ? $httpCode : null, + 'message' => 'Odpowiedz nie jest poprawnym JSON.', + ]; + $this->storeTestResult($integrationId, $url, $result); + return $result; + } + + $apiStatus = trim((string) ($decoded['status'] ?? '')); + $isOk = $httpCode >= 200 && $httpCode < 300 && $apiStatus === 'ok'; + $message = $isOk + ? 'Polaczenie z shopPRO dziala poprawnie.' + : trim((string) ($decoded['message'] ?? 'Blad odpowiedzi API shopPRO.')); + + $result = [ + 'status' => $isOk ? 'ok' : 'error', + 'http_code' => $httpCode > 0 ? $httpCode : null, + 'message' => $message, + ]; + $this->storeTestResult($integrationId, $url, $result); + + return $result; + } + + /** + * @return array{ok:bool,http_code:int|null,message:string,statuses:array} + */ + public function fetchOrderStatuses(int $integrationId): array + { + $integration = $this->findIntegration($integrationId); + if ($integration === null) { + return [ + 'ok' => false, + 'http_code' => null, + 'message' => 'Nie znaleziono integracji shopPRO.', + 'statuses' => [], + ]; + } + + $apiKeyEncrypted = trim((string) ($integration['api_key_encrypted'] ?? '')); + $apiKey = $apiKeyEncrypted !== '' ? (string) $this->cipher->decrypt($apiKeyEncrypted) : ''; + if ($apiKey === '') { + return [ + 'ok' => false, + 'http_code' => null, + 'message' => 'Brak zapisanego klucza API.', + 'statuses' => [], + ]; + } + + $baseUrl = rtrim((string) ($integration['base_url'] ?? ''), '/'); + $timeout = max(1, min(120, (int) ($integration['timeout_seconds'] ?? 10))); + $url = $baseUrl . '/api.php?endpoint=dictionaries&action=statuses'; + + $curl = curl_init($url); + if ($curl === false) { + return [ + 'ok' => false, + 'http_code' => null, + 'message' => 'Nie udalo sie zainicjalizowac polaczenia HTTP.', + 'statuses' => [], + ]; + } + + curl_setopt_array($curl, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => $timeout, + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + 'X-Api-Key: ' . $apiKey, + ], + ]); + + $body = curl_exec($curl); + $httpCode = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE); + $curlError = curl_error($curl); + curl_close($curl); + + if ($body === false) { + return [ + 'ok' => false, + 'http_code' => $httpCode > 0 ? $httpCode : null, + 'message' => trim($curlError) !== '' ? trim($curlError) : 'Nieznany blad transportu HTTP.', + 'statuses' => [], + ]; + } + + $decoded = json_decode((string) $body, true); + if (!is_array($decoded)) { + return [ + 'ok' => false, + 'http_code' => $httpCode > 0 ? $httpCode : null, + 'message' => 'Odpowiedz nie jest poprawnym JSON.', + 'statuses' => [], + ]; + } + + $payload = isset($decoded['data']) && is_array($decoded['data']) + ? $decoded['data'] + : $decoded; + + $rawStatuses = []; + if (isset($payload['statuses']) && is_array($payload['statuses'])) { + $rawStatuses = $payload['statuses']; + } elseif (isset($payload['order_statuses']) && is_array($payload['order_statuses'])) { + $rawStatuses = $payload['order_statuses']; + } elseif ($payload !== [] && array_keys($payload) === range(0, count($payload) - 1)) { + $rawStatuses = $payload; + } + + $statuses = $this->normalizeStatusesPayload($rawStatuses); + return [ + 'ok' => true, + 'http_code' => $httpCode > 0 ? $httpCode : null, + 'message' => '', + 'statuses' => $statuses, + ]; + } + + /** + * @param array{status:string,http_code:int|null,message:string} $result + */ + private function storeTestResult(int $integrationId, string $endpointUrl, array $result): void + { + $status = trim((string) ($result['status'] ?? 'error')); + $httpCode = $result['http_code'] ?? null; + $message = mb_substr(trim((string) ($result['message'] ?? '')), 0, 255); + + try { + $statement = $this->pdo->prepare( + 'UPDATE integrations + SET last_test_status = :last_test_status, + last_test_http_code = :last_test_http_code, + last_test_message = :last_test_message, + last_test_at = NOW(), + updated_at = NOW() + WHERE id = :id AND type = :type' + ); + $statement->execute([ + 'id' => $integrationId, + 'type' => self::TYPE, + 'last_test_status' => $this->nullableString($status), + 'last_test_http_code' => $httpCode, + 'last_test_message' => $this->nullableString($message), + ]); + + $log = $this->pdo->prepare( + 'INSERT INTO integration_test_logs ( + integration_id, status, http_code, message, endpoint_url, tested_at + ) VALUES ( + :integration_id, :status, :http_code, :message, :endpoint_url, NOW() + )' + ); + $log->execute([ + 'integration_id' => $integrationId, + 'status' => $status, + 'http_code' => $httpCode, + 'message' => $message, + 'endpoint_url' => $endpointUrl, + ]); + } catch (Throwable) { + return; + } + } + + private function nullableString(string $value): ?string + { + $trimmed = trim($value); + return $trimmed === '' ? null : $trimmed; + } + + private function normalizeDate(string $value): ?string + { + $trimmed = trim($value); + return $trimmed === '' ? null : $trimmed; + } + + private function normalizeStatusSyncDirection(string $value): string + { + $normalized = trim(mb_strtolower($value)); + if ($normalized === self::DIRECTION_ORDERPRO_TO_SHOPPRO) { + return self::DIRECTION_ORDERPRO_TO_SHOPPRO; + } + + return self::DIRECTION_SHOPPRO_TO_ORDERPRO; + } + + /** + * @param mixed $value + * @return array + */ + private function decodeStatusCodesJson(mixed $value): array + { + if (!is_string($value) || trim($value) === '') { + return []; + } + + $decoded = json_decode($value, true); + if (!is_array($decoded)) { + return []; + } + + $result = []; + $seen = []; + foreach ($decoded as $rawCode) { + $code = strtolower(trim((string) $rawCode)); + if ($code === '' || isset($seen[$code])) { + continue; + } + $seen[$code] = true; + $result[] = $code; + } + + return $result; + } + + private function encodeStatusCodesJson(mixed $rawCodes): ?string + { + if (!is_array($rawCodes)) { + return null; + } + + $codes = []; + $seen = []; + foreach ($rawCodes as $rawCode) { + $code = strtolower(trim((string) $rawCode)); + if ($code === '' || isset($seen[$code])) { + continue; + } + $seen[$code] = true; + $codes[] = $code; + } + + if ($codes === []) { + return null; + } + + return json_encode($codes, JSON_UNESCAPED_UNICODE); + } + + /** + * @param array $rawStatuses + * @return array + */ + private function normalizeStatusesPayload(array $rawStatuses): array + { + $result = []; + $seen = []; + + foreach ($rawStatuses as $key => $item) { + if (!is_array($item)) { + $codeFromKey = trim((string) $key); + $nameFromValue = trim((string) $item); + if ($codeFromKey !== '') { + $normalizedCode = mb_strtolower($codeFromKey); + if (!isset($seen[$normalizedCode])) { + $seen[$normalizedCode] = true; + $result[] = [ + 'code' => $codeFromKey, + 'name' => $nameFromValue !== '' ? $nameFromValue : $codeFromKey, + ]; + } + } + continue; + } + + if (!is_array($item)) { + continue; + } + + $code = trim((string) ( + $item['code'] + ?? $item['status_code'] + ?? $item['status'] + ?? $item['symbol'] + ?? $item['slug'] + ?? $item['id'] + ?? $key + )); + $name = trim((string) ( + $item['name'] + ?? $item['status_name'] + ?? $item['label'] + ?? $item['title'] + ?? $item['value'] + ?? $code + )); + if ($code === '') { + continue; + } + + $normalizedCode = mb_strtolower($code); + if (isset($seen[$normalizedCode])) { + continue; + } + + $seen[$normalizedCode] = true; + $result[] = [ + 'code' => $code, + 'name' => $name !== '' ? $name : $code, + ]; + } + + return $result; + } +} diff --git a/src/Modules/Settings/ShopproOrdersSyncService.php b/src/Modules/Settings/ShopproOrdersSyncService.php new file mode 100644 index 0000000..66c428f --- /dev/null +++ b/src/Modules/Settings/ShopproOrdersSyncService.php @@ -0,0 +1,1074 @@ + $options + * @return array + */ + public function sync(array $options = []): array + { + $maxPages = max(1, min(20, (int) ($options['max_pages'] ?? 3))); + $pageLimit = max(1, min(100, (int) ($options['page_limit'] ?? 50))); + $maxOrders = max(1, min(1000, (int) ($options['max_orders'] ?? 200))); + $ignoreOrdersFetchEnabled = !empty($options['ignore_orders_fetch_enabled']); + $allowedIntegrationIds = $this->normalizeIntegrationIds($options['allowed_integration_ids'] ?? []); + + $result = [ + 'processed' => 0, + 'imported_created' => 0, + 'imported_updated' => 0, + 'failed' => 0, + 'skipped' => 0, + 'checked_integrations' => 0, + 'errors' => [], + ]; + + foreach ($this->integrations->listIntegrations() as $integration) { + $integrationId = (int) ($integration['id'] ?? 0); + if ($integrationId <= 0) { + continue; + } + if ($allowedIntegrationIds !== [] && !isset($allowedIntegrationIds[$integrationId])) { + continue; + } + if (empty($integration['is_active']) || empty($integration['has_api_key'])) { + continue; + } + if (!$ignoreOrdersFetchEnabled && empty($integration['orders_fetch_enabled'])) { + continue; + } + + $result['checked_integrations'] = (int) $result['checked_integrations'] + 1; + $state = $this->syncState->getState($integrationId); + $this->syncState->markRunStarted($integrationId, new DateTimeImmutable('now')); + + try { + $statusMap = $this->buildStatusMap($integrationId); + $cursorUpdatedAt = $this->nullableString((string) ($state['last_synced_updated_at'] ?? '')); + $cursorOrderId = $this->nullableString((string) ($state['last_synced_source_order_id'] ?? '')); + $startDate = $this->resolveStartDate( + (string) ($integration['orders_fetch_start_date'] ?? ''), + $cursorUpdatedAt + ); + $baseUrl = trim((string) ($integration['base_url'] ?? '')); + $apiKey = $this->integrations->getApiKeyDecrypted($integrationId); + $timeout = max(1, min(120, (int) ($integration['timeout_seconds'] ?? 10))); + $productImageCache = []; + + if ($baseUrl === '' || $apiKey === null || trim($apiKey) === '') { + throw new \RuntimeException('Brak poprawnych danych API dla integracji.'); + } + + $latestUpdatedAt = $cursorUpdatedAt; + $latestOrderId = $cursorOrderId; + $shouldStop = false; + + for ($page = 1; $page <= $maxPages; $page++) { + $orders = $this->apiClient->fetchOrders($baseUrl, $apiKey, $timeout, $page, $pageLimit, $startDate); + if (($orders['ok'] ?? false) !== true) { + throw new \RuntimeException((string) ($orders['message'] ?? 'Blad pobierania listy zamowien.')); + } + + $items = is_array($orders['items'] ?? null) ? $orders['items'] : []; + if ($items === []) { + break; + } + + $candidates = $this->buildCandidates($items, $cursorUpdatedAt, $cursorOrderId); + foreach ($candidates as $candidate) { + if ((int) $result['processed'] >= $maxOrders) { + $shouldStop = true; + break; + } + + $sourceOrderId = (string) ($candidate['source_order_id'] ?? ''); + $sourceUpdatedAt = (string) ($candidate['source_updated_at'] ?? ''); + $rawOrder = is_array($candidate['payload'] ?? null) ? $candidate['payload'] : []; + + $details = $this->apiClient->fetchOrderById($baseUrl, $apiKey, $timeout, $sourceOrderId); + if (($details['ok'] ?? false) === true && is_array($details['order'] ?? null)) { + $detailsOrder = (array) $details['order']; + foreach ([ + 'products', + 'summary', + 'paid', + 'transport_cost', + 'transport', + 'transport_description', + 'client_name', + 'client_surname', + 'client_email', + 'client_phone', + 'client_city', + 'client_street', + 'client_postal_code', + ] as $protectedKey) { + if (array_key_exists($protectedKey, $rawOrder)) { + unset($detailsOrder[$protectedKey]); + } + } + $rawOrder = array_replace($rawOrder, $detailsOrder); + } + + try { + $productImages = $this->resolveProductImagesForOrder( + $baseUrl, + (string) $apiKey, + $timeout, + $rawOrder, + $productImageCache + ); + $aggregate = $this->mapOrderAggregate( + $integrationId, + $rawOrder, + $statusMap, + $sourceOrderId, + $sourceUpdatedAt, + $productImages + ); + $save = $this->orderImportRepository->upsertOrderAggregate( + $aggregate['order'], + $aggregate['addresses'], + $aggregate['items'], + $aggregate['payments'], + $aggregate['shipments'], + $aggregate['notes'], + $aggregate['status_history'] + ); + $result['processed'] = (int) $result['processed'] + 1; + if (!empty($save['created'])) { + $result['imported_created'] = (int) $result['imported_created'] + 1; + } else { + $result['imported_updated'] = (int) $result['imported_updated'] + 1; + } + + $this->orders->recordActivity( + (int) ($save['order_id'] ?? 0), + 'import', + 'Import zamowienia z shopPRO', + [ + 'integration_id' => $integrationId, + 'source_order_id' => $sourceOrderId, + ], + 'import', + 'shopPRO' + ); + } catch (Throwable $exception) { + $result['failed'] = (int) $result['failed'] + 1; + $errors = is_array($result['errors']) ? $result['errors'] : []; + if (count($errors) < 20) { + $errors[] = [ + 'integration_id' => $integrationId, + 'source_order_id' => $sourceOrderId, + 'error' => $exception->getMessage(), + ]; + } + $result['errors'] = $errors; + } + + if ($latestUpdatedAt === null || $sourceUpdatedAt > $latestUpdatedAt) { + $latestUpdatedAt = $sourceUpdatedAt; + $latestOrderId = $sourceOrderId; + } elseif ($latestUpdatedAt === $sourceUpdatedAt && ($latestOrderId === null || strcmp($sourceOrderId, $latestOrderId) > 0)) { + $latestOrderId = $sourceOrderId; + } + } + + if ($shouldStop || count($items) < $pageLimit) { + break; + } + } + + $this->syncState->markRunSuccess( + $integrationId, + new DateTimeImmutable('now'), + $latestUpdatedAt, + $latestOrderId + ); + } catch (Throwable $exception) { + $this->syncState->markRunFailed($integrationId, new DateTimeImmutable('now'), $exception->getMessage()); + $result['failed'] = (int) $result['failed'] + 1; + $errors = is_array($result['errors']) ? $result['errors'] : []; + if (count($errors) < 20) { + $errors[] = [ + 'integration_id' => $integrationId, + 'error' => $exception->getMessage(), + ]; + } + $result['errors'] = $errors; + } + } + + return $result; + } + + /** + * @param mixed $rawIds + * @return array + */ + private function normalizeIntegrationIds(mixed $rawIds): array + { + if (!is_array($rawIds)) { + return []; + } + + $result = []; + foreach ($rawIds as $rawId) { + $id = (int) $rawId; + if ($id <= 0) { + continue; + } + $result[$id] = true; + } + + return $result; + } + + /** + * @return array + */ + private function buildStatusMap(int $integrationId): array + { + $rows = $this->statusMappings->listByIntegration($integrationId); + $map = []; + foreach ($rows as $row) { + $shopCode = strtolower(trim((string) ($row['shoppro_status_code'] ?? ''))); + $orderCode = strtolower(trim((string) ($row['orderpro_status_code'] ?? ''))); + if ($shopCode === '' || $orderCode === '') { + continue; + } + $map[$shopCode] = $orderCode; + } + + return $map; + } + + private function resolveStartDate(string $settingsDate, ?string $cursorUpdatedAt): ?string + { + $settings = trim($settingsDate); + $cursor = $this->nullableString((string) $cursorUpdatedAt); + if ($settings === '' && $cursor === null) { + return null; + } + if ($settings === '') { + return $cursor; + } + if ($cursor === null) { + return $settings; + } + + return $cursor > $settings ? $cursor : $settings; + } + + /** + * @param array> $items + * @return array}> + */ + private function buildCandidates(array $items, ?string $cursorUpdatedAt, ?string $cursorOrderId): array + { + $result = []; + foreach ($items as $row) { + $sourceOrderId = $this->normalizeOrderId($this->readPath($row, ['id', 'order_id', 'external_order_id'])); + $sourceUpdatedAt = $this->normalizeDateTime((string) $this->readPath($row, ['updated_at', 'date_updated', 'modified_at', 'date_modified', 'created_at', 'date_created'])); + if ($sourceOrderId === '' || $sourceUpdatedAt === null) { + continue; + } + if (!$this->isAfterCursor($sourceUpdatedAt, $sourceOrderId, $cursorUpdatedAt, $cursorOrderId)) { + continue; + } + + $result[] = [ + 'source_order_id' => $sourceOrderId, + 'source_updated_at' => $sourceUpdatedAt, + 'payload' => $row, + ]; + } + + usort($result, static function (array $a, array $b): int { + $cmp = strcmp((string) ($a['source_updated_at'] ?? ''), (string) ($b['source_updated_at'] ?? '')); + if ($cmp !== 0) { + return $cmp; + } + + return strcmp((string) ($a['source_order_id'] ?? ''), (string) ($b['source_order_id'] ?? '')); + }); + + return $result; + } + + private function isAfterCursor(string $sourceUpdatedAt, string $sourceOrderId, ?string $cursorUpdatedAt, ?string $cursorOrderId): bool + { + if ($cursorUpdatedAt === null || $cursorUpdatedAt === '') { + return true; + } + if ($sourceUpdatedAt > $cursorUpdatedAt) { + return true; + } + if ($sourceUpdatedAt < $cursorUpdatedAt) { + return false; + } + if ($cursorOrderId === null || $cursorOrderId === '') { + return true; + } + + return strcmp($sourceOrderId, $cursorOrderId) > 0; + } + + /** + * @param array $payload + * @param array $statusMap + * @param array $productImagesById + * @return array{ + * order:array, + * addresses:array>, + * items:array>, + * payments:array>, + * shipments:array>, + * notes:array>, + * status_history:array> + * } + */ + private function mapOrderAggregate( + int $integrationId, + array $payload, + array $statusMap, + string $fallbackOrderId, + string $fallbackUpdatedAt, + array $productImagesById = [] + ): array { + $sourceOrderId = $this->normalizeOrderId($this->readPath($payload, ['id', 'order_id', 'external_order_id'])); + if ($sourceOrderId === '') { + $sourceOrderId = $fallbackOrderId; + } + + $sourceCreatedAt = $this->normalizeDateTime((string) $this->readPath($payload, ['created_at', 'date_created', 'date_add'])); + $sourceUpdatedAt = $this->normalizeDateTime((string) $this->readPath($payload, ['updated_at', 'date_updated', 'modified_at', 'date_modified', 'created_at'])); + if ($sourceUpdatedAt === null) { + $sourceUpdatedAt = $fallbackUpdatedAt !== '' ? $fallbackUpdatedAt : date('Y-m-d H:i:s'); + } + + $originalStatus = strtolower(trim((string) $this->readPath($payload, ['status', 'status_code', 'order_status']))); + $effectiveStatus = $statusMap[$originalStatus] ?? $originalStatus; + if ($effectiveStatus === '') { + $effectiveStatus = 'new'; + } + + $currency = trim((string) $this->readPath($payload, ['currency', 'totals.currency'])); + if ($currency === '') { + $currency = 'PLN'; + } + + $totalGross = $this->toFloatOrNull($this->readPath($payload, [ + 'total_gross', 'total_with_tax', 'summary.total', 'totals.gross', 'summary', 'amount', + ])); + $transportCost = $this->toFloatOrNull($this->readPath($payload, ['transport_cost', 'delivery_cost', 'shipping.cost'])); + if ($totalGross === null && $transportCost !== null) { + $productsSum = 0.0; + $hasProducts = false; + $rawItemsForSummary = $this->readPath($payload, ['products', 'items', 'order_items']); + if (is_array($rawItemsForSummary)) { + foreach ($rawItemsForSummary as $rawItem) { + if (!is_array($rawItem)) { + continue; + } + $itemPrice = $this->toFloatOrNull($this->readPath($rawItem, [ + 'price_brutto', 'price_gross', 'gross_price', 'price', + ])); + $itemQty = $this->toFloatOrDefault($this->readPath($rawItem, ['quantity', 'qty']), 1.0); + if ($itemPrice === null) { + continue; + } + $hasProducts = true; + $productsSum += ($itemPrice * $itemQty); + } + } + if ($hasProducts) { + $totalGross = $productsSum + $transportCost; + } + } + $totalNet = $this->toFloatOrNull($this->readPath($payload, ['total_net', 'total_without_tax', 'totals.net'])); + $totalPaid = $this->toFloatOrNull($this->readPath($payload, ['total_paid', 'payments.total_paid', 'payment.total', 'paid_amount'])); + $paidFlag = $this->readPath($payload, ['paid', 'is_paid']); + $isPaid = $this->normalizePaidFlag($paidFlag); + if ($totalPaid === null) { + if ($isPaid && $totalGross !== null) { + $totalPaid = $totalGross; + } + } + + $deliveryLabel = $this->buildDeliveryMethodLabel($payload); + + $order = [ + 'integration_id' => $integrationId, + 'source' => 'shoppro', + 'source_order_id' => $sourceOrderId, + 'external_order_id' => $sourceOrderId, + 'external_platform_id' => 'shoppro', + 'external_platform_account_id' => null, + 'external_status_id' => $effectiveStatus, + 'external_payment_type_id' => $this->nullableString((string) $this->readPath($payload, ['payment_method', 'payment.method', 'payments.method'])), + 'payment_status' => $this->mapPaymentStatus($payload, $isPaid), + 'external_carrier_id' => $this->nullableString($deliveryLabel), + 'external_carrier_account_id' => $this->nullableString((string) $this->readPath($payload, [ + 'transport_id', 'shipping.method_id', 'delivery.method_id', + ])), + 'customer_login' => $this->nullableString((string) $this->readPath($payload, [ + 'buyer_email', 'customer.email', 'buyer.email', 'client.email', 'email', 'customer.login', 'buyer.login', + ])), + 'is_invoice' => !empty($this->readPath($payload, ['is_invoice', 'invoice.required'])), + 'is_encrypted' => false, + 'is_canceled_by_buyer' => false, + 'currency' => $currency, + 'total_without_tax' => $totalNet, + 'total_with_tax' => $totalGross, + 'total_paid' => $totalPaid, + 'send_date_min' => null, + 'send_date_max' => null, + 'ordered_at' => $sourceCreatedAt, + 'source_created_at' => $sourceCreatedAt, + 'source_updated_at' => $sourceUpdatedAt, + 'preferences_json' => null, + 'payload_json' => $payload, + 'fetched_at' => date('Y-m-d H:i:s'), + ]; + + $addresses = $this->mapAddresses($payload); + $items = $this->mapItems($payload, $productImagesById); + $payments = $this->mapPayments($payload, $currency, $totalPaid); + $shipments = $this->mapShipments($payload); + $notes = $this->mapNotes($payload); + $statusHistory = [[ + 'from_status_id' => null, + 'to_status_id' => $effectiveStatus, + 'changed_at' => $sourceUpdatedAt, + 'change_source' => 'import', + 'comment' => $originalStatus !== '' ? 'shopPRO status: ' . $originalStatus : null, + 'payload_json' => null, + ]]; + + return [ + 'order' => $order, + 'addresses' => $addresses, + 'items' => $items, + 'payments' => $payments, + 'shipments' => $shipments, + 'notes' => $notes, + 'status_history' => $statusHistory, + ]; + } + + /** + * @param array $payload + * @return array> + */ + private function mapAddresses(array $payload): array + { + $result = []; + + $customerFirstName = $this->nullableString((string) $this->readPath($payload, [ + 'buyer.first_name', 'buyer.firstname', 'customer.first_name', 'customer.firstname', + 'client.first_name', 'client.firstname', 'billing_address.first_name', 'billing_address.firstname', + 'first_name', 'firstname', 'client_name', 'imie', + ])); + $customerLastName = $this->nullableString((string) $this->readPath($payload, [ + 'buyer.last_name', 'buyer.lastname', 'customer.last_name', 'customer.lastname', + 'client.last_name', 'client.lastname', 'billing_address.last_name', 'billing_address.lastname', + 'last_name', 'lastname', 'client_surname', 'nazwisko', + ])); + $customerName = $this->nullableString((string) $this->readPath($payload, [ + 'buyer_name', 'buyer.name', 'customer.name', 'client.name', 'billing_address.name', + 'receiver.name', 'client', 'customer_full_name', 'client_full_name', + ])); + if ($customerName === null) { + $customerName = $this->composeName($customerFirstName, $customerLastName, 'Klient'); + } + + $customerEmail = $this->nullableString((string) $this->readPath($payload, [ + 'buyer_email', 'buyer.email', 'customer.email', 'client.email', 'billing_address.email', + 'shipping_address.email', 'delivery_address.email', 'email', 'client_email', 'mail', + ])); + $customerPhone = $this->nullableString((string) $this->readPath($payload, [ + 'buyer_phone', 'buyer.phone', 'customer.phone', 'client.phone', 'billing_address.phone', + 'shipping_address.phone', 'delivery_address.phone', 'phone', 'telephone', 'client_phone', + 'phone_number', 'client_phone_number', + ])); + + $result[] = [ + 'address_type' => 'customer', + 'name' => $customerName ?? 'Klient', + 'phone' => $customerPhone, + 'email' => $customerEmail, + 'street_name' => $this->nullableString((string) $this->readPath($payload, [ + 'buyer_address.street', 'customer.address.street', 'billing_address.street', 'client.address.street', + 'address.street', 'street', 'client_street', 'ulica', + ])), + 'street_number' => $this->nullableString((string) $this->readPath($payload, [ + 'buyer_address.street_number', 'customer.address.street_number', 'billing_address.street_number', + 'billing_address.house_number', 'client.address.street_number', 'address.street_number', + 'house_number', 'street_no', 'street_number', 'nr_domu', + ])), + 'city' => $this->nullableString((string) $this->readPath($payload, [ + 'buyer_address.city', 'customer.address.city', 'billing_address.city', 'client.address.city', + 'address.city', 'city', 'client_city', 'miejscowosc', + ])), + 'zip_code' => $this->nullableString((string) $this->readPath($payload, [ + 'buyer_address.zip', 'buyer_address.postcode', 'customer.address.zip', 'customer.address.postcode', + 'billing_address.zip', 'billing_address.postcode', 'client.address.zip', 'address.zip', + 'address.postcode', 'zip', 'postcode', 'client_postal_code', 'kod_pocztowy', + ])), + 'country' => $this->nullableString((string) $this->readPath($payload, [ + 'buyer_address.country', 'customer.address.country', 'billing_address.country', 'client.address.country', + 'address.country', 'country', 'kraj', + ])), + 'payload_json' => [ + 'buyer' => $this->readPath($payload, ['buyer']), + 'customer' => $this->readPath($payload, ['customer']), + 'billing_address' => $this->readPath($payload, ['billing_address']), + 'buyer_address' => $this->readPath($payload, ['buyer_address']), + 'address' => $this->readPath($payload, ['address']), + ], + ]; + + $deliveryFirstName = $this->nullableString((string) $this->readPath($payload, [ + 'delivery.address.first_name', 'delivery.address.firstname', 'shipping.address.first_name', 'shipping.address.firstname', + 'delivery_address.first_name', 'delivery_address.firstname', 'shipping_address.first_name', 'shipping_address.firstname', + 'receiver.first_name', 'receiver.firstname', 'delivery_first_name', 'shipping_first_name', + ])); + $deliveryLastName = $this->nullableString((string) $this->readPath($payload, [ + 'delivery.address.last_name', 'delivery.address.lastname', 'shipping.address.last_name', 'shipping.address.lastname', + 'delivery_address.last_name', 'delivery_address.lastname', 'shipping_address.last_name', 'shipping_address.lastname', + 'receiver.last_name', 'receiver.lastname', 'delivery_last_name', 'shipping_last_name', + ])); + $deliveryName = $this->nullableString((string) $this->readPath($payload, [ + 'delivery.address.name', 'shipping.address.name', 'delivery_address.name', 'shipping_address.name', + 'receiver.name', 'delivery_name', 'shipping_name', + ])); + if ($deliveryName === null) { + $deliveryName = $this->composeName($deliveryFirstName, $deliveryLastName, null); + } + + $pickupData = $this->parsePickupPoint((string) $this->readPath($payload, ['inpost_paczkomat', 'orlen_point', 'pickup_point'])); + $deliveryAddress = [ + 'name' => $deliveryName, + 'phone' => $this->nullableString((string) $this->readPath($payload, [ + 'delivery.address.phone', 'shipping.address.phone', 'delivery_address.phone', 'shipping_address.phone', + 'receiver.phone', 'delivery_phone', 'shipping_phone', + ])) ?? $customerPhone, + 'email' => $this->nullableString((string) $this->readPath($payload, [ + 'delivery.address.email', 'shipping.address.email', 'delivery_address.email', 'shipping_address.email', + 'receiver.email', 'delivery_email', 'shipping_email', + ])) ?? $customerEmail, + 'street_name' => $this->nullableString((string) $this->readPath($payload, [ + 'delivery.address.street', 'shipping.address.street', 'delivery_address.street', 'shipping_address.street', + 'receiver.address.street', 'delivery_street', 'shipping_street', + ])) ?? $this->nullableString($pickupData['street'] ?? ''), + 'street_number' => $this->nullableString((string) $this->readPath($payload, [ + 'delivery.address.street_number', 'shipping.address.street_number', 'delivery_address.street_number', 'shipping_address.street_number', + 'delivery.address.house_number', 'shipping.address.house_number', 'receiver.address.street_number', + 'receiver.address.house_number', 'delivery_street_number', 'shipping_street_number', + ])), + 'city' => $this->nullableString((string) $this->readPath($payload, [ + 'delivery.address.city', 'shipping.address.city', 'delivery_address.city', 'shipping_address.city', + 'receiver.address.city', 'delivery_city', 'shipping_city', + ])) ?? $this->nullableString($pickupData['city'] ?? ''), + 'zip_code' => $this->nullableString((string) $this->readPath($payload, [ + 'delivery.address.zip', 'delivery.address.postcode', 'shipping.address.zip', 'shipping.address.postcode', + 'delivery_address.zip', 'delivery_address.postcode', 'shipping_address.zip', 'shipping_address.postcode', + 'receiver.address.zip', 'receiver.address.postcode', 'delivery_zip', 'delivery_postcode', + 'shipping_zip', 'shipping_postcode', + ])) ?? $this->nullableString($pickupData['zip_code'] ?? ''), + 'country' => $this->nullableString((string) $this->readPath($payload, [ + 'delivery.address.country', 'shipping.address.country', 'delivery_address.country', 'shipping_address.country', + 'receiver.address.country', 'delivery_country', 'shipping_country', + ])), + 'parcel_external_id' => $this->nullableString($pickupData['code'] ?? ''), + 'parcel_name' => $this->nullableString($pickupData['label'] ?? ''), + 'payload_json' => [ + 'delivery' => $this->readPath($payload, ['delivery']), + 'shipping' => $this->readPath($payload, ['shipping']), + 'delivery_address' => $this->readPath($payload, ['delivery_address']), + 'shipping_address' => $this->readPath($payload, ['shipping_address']), + 'receiver' => $this->readPath($payload, ['receiver']), + 'inpost_paczkomat' => $this->readPath($payload, ['inpost_paczkomat']), + 'orlen_point' => $this->readPath($payload, ['orlen_point']), + ], + ]; + + if (($deliveryAddress['name'] ?? null) === null) { + $deliveryAddress['name'] = $this->nullableString($this->buildDeliveryMethodLabel($payload)); + } + + $hasDeliveryData = $this->hasAddressData($deliveryAddress); + if ($hasDeliveryData) { + $result[] = [ + 'address_type' => 'delivery', + 'name' => $deliveryAddress['name'] ?? $customerName ?? 'Dostawa', + 'phone' => $deliveryAddress['phone'] ?? null, + 'email' => $deliveryAddress['email'] ?? $customerEmail, + 'street_name' => $deliveryAddress['street_name'] ?? null, + 'street_number' => $deliveryAddress['street_number'] ?? null, + 'city' => $deliveryAddress['city'] ?? null, + 'zip_code' => $deliveryAddress['zip_code'] ?? null, + 'country' => $deliveryAddress['country'] ?? null, + 'parcel_external_id' => $deliveryAddress['parcel_external_id'] ?? null, + 'parcel_name' => $deliveryAddress['parcel_name'] ?? null, + 'payload_json' => is_array($deliveryAddress['payload_json'] ?? null) ? $deliveryAddress['payload_json'] : null, + ]; + } + + return $result; + } + + private function composeName(?string $firstName, ?string $lastName, ?string $fallback): ?string + { + $name = trim(trim((string) $firstName) . ' ' . trim((string) $lastName)); + if ($name !== '') { + return $name; + } + + $fallbackValue = trim((string) $fallback); + return $fallbackValue !== '' ? $fallbackValue : null; + } + + /** + * @param array $address + */ + private function hasAddressData(array $address): bool + { + $fields = ['name', 'phone', 'email', 'street_name', 'street_number', 'city', 'zip_code', 'country']; + foreach ($fields as $field) { + $value = trim((string) ($address[$field] ?? '')); + if ($value !== '') { + return true; + } + } + + return false; + } + + /** + * @param array $payload + * @param array $productImagesById + * @return array> + */ + private function mapItems(array $payload, array $productImagesById = []): array + { + $rawItems = $this->readPath($payload, ['items']); + if (!is_array($rawItems)) { + $rawItems = $this->readPath($payload, ['order_items']); + } + if (!is_array($rawItems)) { + $rawItems = $this->readPath($payload, ['products']); + } + if (!is_array($rawItems)) { + return []; + } + + $result = []; + $sort = 0; + foreach ($rawItems as $row) { + if (!is_array($row)) { + continue; + } + $name = trim((string) $this->readPath($row, ['name', 'title'])); + if ($name === '') { + continue; + } + + $productId = (int) $this->readPath($row, ['product_id']); + $parentProductId = (int) $this->readPath($row, ['parent_product_id']); + $mediaUrl = $this->nullableString((string) $this->readPath($row, ['image', 'image_url', 'img_url', 'img', 'photo', 'photo_url'])); + if ($mediaUrl === null && $productId > 0 && isset($productImagesById[$productId])) { + $mediaUrl = $this->nullableString((string) $productImagesById[$productId]); + } + if ($mediaUrl === null && $parentProductId > 0 && isset($productImagesById[$parentProductId])) { + $mediaUrl = $this->nullableString((string) $productImagesById[$parentProductId]); + } + + $result[] = [ + 'source_item_id' => $this->nullableString((string) $this->readPath($row, ['id', 'item_id'])), + 'external_item_id' => $this->nullableString((string) $this->readPath($row, ['id', 'item_id'])), + 'ean' => $this->nullableString((string) $this->readPath($row, ['ean'])), + 'sku' => $this->nullableString((string) $this->readPath($row, ['sku', 'symbol', 'code'])), + 'original_name' => $name, + 'original_code' => $this->nullableString((string) $this->readPath($row, ['code', 'symbol'])), + 'original_price_with_tax' => $this->toFloatOrNull($this->readPath($row, ['price_gross', 'gross_price', 'price', 'price_brutto'])), + 'original_price_without_tax' => $this->toFloatOrNull($this->readPath($row, ['price_net', 'net_price', 'price_netto'])), + 'media_url' => $mediaUrl, + 'quantity' => $this->toFloatOrDefault($this->readPath($row, ['quantity', 'qty']), 1.0), + 'tax_rate' => $this->toFloatOrNull($this->readPath($row, ['vat', 'tax_rate'])), + 'item_status' => null, + 'unit' => $this->nullableString((string) $this->readPath($row, ['unit'])), + 'item_type' => 'product', + 'source_product_id' => $this->nullableString((string) ($productId > 0 ? $productId : $parentProductId)), + 'source_product_set_id' => $this->nullableString((string) ($parentProductId > 0 ? $parentProductId : '')), + 'sort_order' => $sort++, + 'payload_json' => $row, + ]; + } + + return $result; + } + + /** + * @param array $payload + * @return array> + */ + private function mapPayments(array $payload, string $currency, ?float $totalPaid): array + { + $paymentMethod = $this->nullableString((string) $this->readPath($payload, ['payment_method', 'payment.method'])); + if ($paymentMethod === null && $totalPaid === null) { + return []; + } + + return [[ + 'source_payment_id' => null, + 'external_payment_id' => null, + 'payment_type_id' => $paymentMethod ?? 'unknown', + 'payment_date' => $this->nullableString((string) $this->readPath($payload, ['payment_date', 'payment.date'])), + 'amount' => $totalPaid, + 'currency' => $currency, + 'comment' => $this->nullableString((string) $this->readPath($payload, ['payment_status', 'payment.status'])), + 'payload_json' => null, + ]]; + } + + /** + * @param array $payload + * @return array> + */ + private function mapShipments(array $payload): array + { + $tracking = $this->nullableString((string) $this->readPath($payload, ['delivery_tracking_number', 'delivery.tracking_number', 'shipping.tracking_number'])); + if ($tracking === null) { + return []; + } + + return [[ + 'source_shipment_id' => null, + 'external_shipment_id' => null, + 'tracking_number' => $tracking, + 'carrier_provider_id' => $this->sanitizePlainText((string) ($this->readPath($payload, [ + 'delivery_method', 'shipping.method', 'transport', 'transport_description', + ]) ?? 'unknown')), + 'posted_at' => $this->nullableString((string) $this->readPath($payload, ['delivery.posted_at', 'shipping.posted_at'])), + 'media_uuid' => null, + 'payload_json' => null, + ]]; + } + + /** + * @param array $payload + * @return array> + */ + private function mapNotes(array $payload): array + { + $comment = $this->nullableString((string) $this->readPath($payload, ['notes', 'comment', 'customer_comment'])); + if ($comment === null) { + return []; + } + + return [[ + 'source_note_id' => null, + 'note_type' => 'message', + 'created_at_external' => null, + 'comment' => $comment, + 'payload_json' => null, + ]]; + } + + private function normalizeOrderId(mixed $value): string + { + return trim((string) $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; + } + } + + private function nullableString(string $value): ?string + { + $trimmed = trim($value); + return $trimmed === '' ? null : $trimmed; + } + + private function normalizePaidFlag(mixed $value): bool + { + if ($value === true) { + return true; + } + if ($value === false || $value === null) { + return false; + } + + $normalized = strtolower(trim((string) $value)); + return in_array($normalized, ['1', 'true', 'yes', 'paid'], true); + } + + private function mapPaymentStatus(array $payload, bool $isPaid): ?int + { + if ($isPaid) { + return 2; + } + + $raw = strtolower(trim((string) $this->readPath($payload, ['payment_status', 'payment.status']))); + if ($raw === '') { + return 0; + } + + return match ($raw) { + 'paid', 'finished', 'completed' => 2, + 'partially_paid', 'in_progress' => 1, + 'cancelled', 'canceled', 'failed', 'unpaid', 'not_paid' => 0, + default => 0, + }; + } + + private function sanitizePlainText(string $value): string + { + $withoutTags = strip_tags($value); + $decoded = html_entity_decode($withoutTags, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + return trim(preg_replace('/\s+/', ' ', $decoded) ?? ''); + } + + private function buildDeliveryMethodLabel(array $payload): string + { + $label = $this->sanitizePlainText((string) $this->readPath($payload, [ + 'delivery_method', 'shipping.method', 'delivery.method', 'shipping_method', 'delivery_name', 'shipping_name', + 'transport', 'transport_description', + ])); + $cost = $this->toFloatOrNull($this->readPath($payload, ['transport_cost', 'delivery_cost', 'shipping.cost'])); + if ($label !== '' && $cost !== null) { + $label .= ': ' . $this->formatMoneyCompact($cost) . ' zł'; + } + + return $label; + } + + private function formatMoneyCompact(float $amount): string + { + $formatted = number_format($amount, 2, '.', ''); + return rtrim(rtrim($formatted, '0'), '.'); + } + + /** + * @return array{code:string,label:string,street:string,zip_code:string,city:string} + */ + private function parsePickupPoint(string $raw): array + { + $value = trim($this->sanitizePlainText($raw)); + if ($value === '') { + return ['code' => '', 'label' => '', 'street' => '', 'zip_code' => '', 'city' => '']; + } + + $code = ''; + $address = $value; + $parts = preg_split('/\s*\|\s*/', $value); + if (is_array($parts) && count($parts) >= 2) { + $code = trim((string) ($parts[0] ?? '')); + $address = trim((string) ($parts[1] ?? '')); + } + + $street = ''; + $zip = ''; + $city = ''; + if (preg_match('/^(.*?),\s*(\d{2}-\d{3})\s+(.+)$/u', $address, $matches) === 1) { + $street = trim((string) ($matches[1] ?? '')); + $zip = trim((string) ($matches[2] ?? '')); + $city = trim((string) ($matches[3] ?? '')); + } elseif (preg_match('/(\d{2}-\d{3})\s+(.+)$/u', $address, $matches) === 1) { + $zip = trim((string) ($matches[1] ?? '')); + $city = trim((string) ($matches[2] ?? '')); + } + + return [ + 'code' => $code, + 'label' => $value, + 'street' => $street, + 'zip_code' => $zip, + 'city' => $city, + ]; + } + + /** + * @param array $orderPayload + * @param array $cache + * @return array + */ + private function resolveProductImagesForOrder( + string $baseUrl, + string $apiKey, + int $timeout, + array $orderPayload, + array &$cache + ): array { + $result = []; + $rawItems = $this->readPath($orderPayload, ['products', 'items', 'order_items']); + if (!is_array($rawItems)) { + return []; + } + + foreach ($rawItems as $item) { + if (!is_array($item)) { + continue; + } + $productId = (int) $this->readPath($item, ['product_id']); + $parentProductId = (int) $this->readPath($item, ['parent_product_id']); + if ($productId <= 0 && $parentProductId <= 0) { + continue; + } + + foreach ([$productId, $parentProductId] as $candidateId) { + if ($candidateId <= 0) { + continue; + } + if (!isset($cache[$candidateId])) { + $cache[$candidateId] = $this->fetchPrimaryProductImageUrl($baseUrl, $apiKey, $timeout, $candidateId) ?? ''; + } + } + + if ($productId > 0) { + $url = trim((string) ($cache[$productId] ?? '')); + if ($url !== '') { + $result[$productId] = $url; + } + } + if ($parentProductId > 0) { + $url = trim((string) ($cache[$parentProductId] ?? '')); + if ($url !== '') { + $result[$parentProductId] = $url; + } + } + } + + return $result; + } + + private function fetchPrimaryProductImageUrl(string $baseUrl, string $apiKey, int $timeout, int $productId): ?string + { + $response = $this->apiClient->fetchProductById($baseUrl, $apiKey, $timeout, $productId); + if (($response['ok'] ?? false) !== true || !is_array($response['product'] ?? null)) { + return null; + } + + $product = (array) $response['product']; + $images = $this->readPath($product, ['images', 'photos', 'gallery']); + if (is_array($images)) { + foreach ($images as $image) { + if (is_array($image)) { + $src = trim((string) ($image['src'] ?? $image['url'] ?? $image['image'] ?? '')); + if ($src !== '') { + return $this->normalizeMediaUrl($baseUrl, $src); + } + } elseif (is_string($image)) { + $src = trim($image); + if ($src !== '') { + return $this->normalizeMediaUrl($baseUrl, $src); + } + } + } + } + + $flat = trim((string) $this->readPath($product, ['image', 'image_url', 'photo', 'photo_url', 'img', 'img_url'])); + if ($flat !== '') { + return $this->normalizeMediaUrl($baseUrl, $flat); + } + + return null; + } + + private function normalizeMediaUrl(string $baseUrl, string $value): string + { + $trimmed = trim($value); + if ($trimmed === '') { + return ''; + } + if (str_starts_with($trimmed, '//')) { + return 'https:' . $trimmed; + } + if (preg_match('#^https?://#i', $trimmed) === 1) { + return $trimmed; + } + + return rtrim($baseUrl, '/') . '/' . ltrim($trimmed, '/'); + } + + private function toFloatOrNull(mixed $value): ?float + { + if ($value === null || $value === '') { + return null; + } + if (is_string($value)) { + $value = str_replace(',', '.', trim($value)); + } + if (!is_numeric($value)) { + return null; + } + + return (float) $value; + } + + private function toFloatOrDefault(mixed $value, float $default): float + { + $result = $this->toFloatOrNull($value); + return $result ?? $default; + } + + private function readPath(mixed $payload, array $paths): mixed + { + foreach ($paths as $path) { + $value = $this->readSinglePath($payload, (string) $path); + if ($value !== null && $value !== '') { + return $value; + } + } + + return null; + } + + private function readSinglePath(mixed $payload, string $path): mixed + { + if ($path === '') { + return null; + } + + $segments = explode('.', $path); + $current = $payload; + foreach ($segments as $segment) { + if (!is_array($current) || !array_key_exists($segment, $current)) { + return null; + } + $current = $current[$segment]; + } + + return $current; + } +} diff --git a/src/Modules/Settings/ShopproPaymentStatusSyncService.php b/src/Modules/Settings/ShopproPaymentStatusSyncService.php new file mode 100644 index 0000000..b74ca9a --- /dev/null +++ b/src/Modules/Settings/ShopproPaymentStatusSyncService.php @@ -0,0 +1,404 @@ + + */ + private const DEFAULT_FINAL_STATUS_CODES = [ + 'wyslane', + 'zrealizowane', + 'anulowane', + 'cancelled', + 'canceled', + 'delivered', + 'returned', + 'shipped', + ]; + + public function __construct( + private readonly ShopproIntegrationsRepository $integrations, + private readonly ShopproApiClient $apiClient, + private readonly OrdersRepository $orders, + private readonly PDO $pdo + ) { + } + + /** + * @return array + */ + public function sync(array $options = []): array + { + $perIntegrationLimit = max(1, min(500, (int) ($options['per_integration_limit'] ?? 100))); + $result = [ + 'ok' => true, + 'checked_integrations' => 0, + 'processed_orders' => 0, + 'updated_orders' => 0, + 'skipped_orders' => 0, + 'failed_orders' => 0, + 'errors' => [], + ]; + + foreach ($this->integrations->listIntegrations() as $integration) { + $integrationId = (int) ($integration['id'] ?? 0); + if ($integrationId <= 0 || empty($integration['is_active']) || empty($integration['has_api_key'])) { + continue; + } + + $baseUrl = trim((string) ($integration['base_url'] ?? '')); + $apiKey = $this->integrations->getApiKeyDecrypted($integrationId); + $timeout = max(1, min(120, (int) ($integration['timeout_seconds'] ?? 10))); + if ($baseUrl === '' || $apiKey === null || trim($apiKey) === '') { + continue; + } + + $result['checked_integrations'] = (int) $result['checked_integrations'] + 1; + $watchedStatuses = $this->resolveWatchedStatusCodes($integration); + $orders = $this->findCandidateOrders($integrationId, $watchedStatuses, $perIntegrationLimit); + + foreach ($orders as $order) { + $result['processed_orders'] = (int) $result['processed_orders'] + 1; + $sourceOrderId = trim((string) ($order['source_order_id'] ?? '')); + if ($sourceOrderId === '') { + $result['skipped_orders'] = (int) $result['skipped_orders'] + 1; + continue; + } + + try { + $updated = $this->syncSingleOrderPayment( + $integrationId, + $baseUrl, + $apiKey, + $timeout, + $order + ); + if ($updated) { + $result['updated_orders'] = (int) $result['updated_orders'] + 1; + } else { + $result['skipped_orders'] = (int) $result['skipped_orders'] + 1; + } + } catch (Throwable $exception) { + $result['failed_orders'] = (int) $result['failed_orders'] + 1; + $errors = is_array($result['errors']) ? $result['errors'] : []; + if (count($errors) < 20) { + $errors[] = [ + 'integration_id' => $integrationId, + 'order_id' => (int) ($order['id'] ?? 0), + 'source_order_id' => $sourceOrderId, + 'error' => $exception->getMessage(), + ]; + } + $result['errors'] = $errors; + } + } + } + + return $result; + } + + /** + * @param array $integration + * @return array + */ + private function resolveWatchedStatusCodes(array $integration): array + { + $rawCodes = $integration['payment_sync_status_codes'] ?? []; + if (!is_array($rawCodes)) { + return []; + } + + $result = []; + $seen = []; + foreach ($rawCodes as $rawCode) { + $code = strtolower(trim((string) $rawCode)); + if ($code === '' || isset($seen[$code])) { + continue; + } + $seen[$code] = true; + $result[] = $code; + } + + return $result; + } + + /** + * @param array $watchedStatuses + * @return array> + */ + private function findCandidateOrders(int $integrationId, array $watchedStatuses, int $limit): array + { + $where = [ + 'source = :source', + 'integration_id = :integration_id', + 'source_order_id IS NOT NULL', + 'source_order_id <> ""', + '(payment_status IS NULL OR payment_status <> :paid_status)', + ]; + $params = [ + 'source' => 'shoppro', + 'integration_id' => $integrationId, + 'paid_status' => self::PAID_STATUS, + ]; + + $statusPlaceholders = []; + $statusCodes = $watchedStatuses !== [] ? $watchedStatuses : self::DEFAULT_FINAL_STATUS_CODES; + foreach ($statusCodes as $index => $statusCode) { + $placeholder = ':status_' . $index; + $statusPlaceholders[] = $placeholder; + $params['status_' . $index] = strtolower($statusCode); + } + + if ($watchedStatuses !== []) { + $where[] = 'LOWER(COALESCE(external_status_id, "")) IN (' . implode(', ', $statusPlaceholders) . ')'; + } else { + $where[] = 'LOWER(COALESCE(external_status_id, "")) NOT IN (' . implode(', ', $statusPlaceholders) . ')'; + } + + $sql = 'SELECT id, source_order_id, payment_status, total_paid, total_with_tax, currency, external_payment_type_id + FROM orders + WHERE ' . implode(' AND ', $where) . ' + ORDER BY source_updated_at DESC, id DESC + LIMIT :limit'; + $stmt = $this->pdo->prepare($sql); + foreach ($params as $key => $value) { + if (is_int($value)) { + $stmt->bindValue(':' . $key, $value, PDO::PARAM_INT); + continue; + } + $stmt->bindValue(':' . $key, $value); + } + $stmt->bindValue(':limit', max(1, min(1000, $limit)), PDO::PARAM_INT); + $stmt->execute(); + + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + return is_array($rows) ? $rows : []; + } + + /** + * @param array $order + */ + private function syncSingleOrderPayment( + int $integrationId, + string $baseUrl, + string $apiKey, + int $timeout, + array $order + ): bool { + $sourceOrderId = trim((string) ($order['source_order_id'] ?? '')); + if ($sourceOrderId === '') { + return false; + } + + $details = $this->apiClient->fetchOrderById($baseUrl, $apiKey, $timeout, $sourceOrderId); + if (($details['ok'] ?? false) !== true || !is_array($details['order'] ?? null)) { + throw new \RuntimeException((string) ($details['message'] ?? 'Blad pobierania szczegolow zamowienia.')); + } + + $payload = (array) $details['order']; + $isPaid = $this->resolvePaidFlag($payload); + if ($isPaid === null) { + return false; + } + + $newPaymentStatus = $isPaid ? self::PAID_STATUS : self::UNPAID_STATUS; + $existingTotalWithTax = $order['total_with_tax'] !== null ? (float) $order['total_with_tax'] : null; + $newTotalPaid = $isPaid + ? $this->resolvePaidAmount($payload, $existingTotalWithTax) + : 0.0; + $existingPaymentStatus = isset($order['payment_status']) ? (int) $order['payment_status'] : null; + $existingTotalPaid = $order['total_paid'] !== null ? (float) $order['total_paid'] : null; + $paymentMethod = $this->nullableString((string) ($payload['payment_method'] ?? $order['external_payment_type_id'] ?? '')); + $paymentDate = $this->normalizeDateTime((string) ($payload['payment_date'] ?? '')); + $sourceUpdatedAt = $this->normalizeDateTime((string) ($payload['updated_at'] ?? $payload['date_updated'] ?? '')); + + if ( + $existingPaymentStatus === $newPaymentStatus + && $this->floatsEqual($existingTotalPaid, $newTotalPaid) + && $paymentMethod === $this->nullableString((string) ($order['external_payment_type_id'] ?? '')) + ) { + return false; + } + + $orderId = (int) ($order['id'] ?? 0); + if ($orderId <= 0) { + return false; + } + + $this->pdo->beginTransaction(); + try { + $this->updateOrderPaymentColumns($orderId, $newPaymentStatus, $newTotalPaid, $paymentMethod, $sourceUpdatedAt); + $this->replaceOrderPaymentRow($orderId, $paymentMethod, $paymentDate, $newTotalPaid, (string) ($order['currency'] ?? 'PLN'), $isPaid); + $this->pdo->commit(); + } catch (Throwable $exception) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $exception; + } + + $summary = $isPaid + ? 'shopPRO: zamowienie oznaczone jako oplacone' + : 'shopPRO: zamowienie oznaczone jako nieoplacone'; + $this->orders->recordActivity( + $orderId, + 'payment', + $summary, + [ + 'integration_id' => $integrationId, + 'source_order_id' => $sourceOrderId, + 'old_payment_status' => $existingPaymentStatus, + 'new_payment_status' => $newPaymentStatus, + 'old_total_paid' => $existingTotalPaid, + 'new_total_paid' => $newTotalPaid, + ], + 'sync', + 'shopPRO' + ); + + return true; + } + + private function updateOrderPaymentColumns( + int $orderId, + int $paymentStatus, + ?float $totalPaid, + ?string $paymentMethod, + ?string $sourceUpdatedAt + ): void { + $sql = 'UPDATE orders + SET payment_status = :payment_status, + total_paid = :total_paid, + external_payment_type_id = :external_payment_type_id, + fetched_at = NOW(), + updated_at = NOW()'; + $params = [ + 'id' => $orderId, + 'payment_status' => $paymentStatus, + 'total_paid' => $totalPaid, + 'external_payment_type_id' => $paymentMethod, + ]; + if ($sourceUpdatedAt !== null) { + $sql .= ', source_updated_at = :source_updated_at'; + $params['source_updated_at'] = $sourceUpdatedAt; + } + $sql .= ' WHERE id = :id'; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + } + + private function replaceOrderPaymentRow( + int $orderId, + ?string $paymentMethod, + ?string $paymentDate, + ?float $amount, + string $currency, + bool $isPaid + ): void { + $deleteStmt = $this->pdo->prepare('DELETE FROM order_payments WHERE order_id = :order_id'); + $deleteStmt->execute(['order_id' => $orderId]); + + $insertStmt = $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, NULL, NULL, :payment_type_id, :payment_date, :amount, :currency, :comment, NULL + )' + ); + $insertStmt->execute([ + 'order_id' => $orderId, + 'payment_type_id' => $paymentMethod ?? 'unknown', + 'payment_date' => $paymentDate, + 'amount' => $amount, + 'currency' => trim($currency) !== '' ? strtoupper($currency) : 'PLN', + 'comment' => $isPaid ? 'paid' : 'unpaid', + ]); + } + + private function resolvePaidFlag(array $payload): ?bool + { + $raw = $payload['paid'] ?? $payload['is_paid'] ?? null; + if ($raw === null) { + return null; + } + if (is_bool($raw)) { + return $raw; + } + + $value = strtolower(trim((string) $raw)); + if (in_array($value, ['1', 'true', 'yes', 'paid'], true)) { + return true; + } + if (in_array($value, ['0', 'false', 'no', 'unpaid'], true)) { + return false; + } + + return null; + } + + private function resolvePaidAmount(array $payload, ?float $fallbackGross): ?float + { + $value = $payload['total_paid'] ?? null; + if ($value !== null && is_numeric((string) $value)) { + return (float) $value; + } + + $grossCandidates = [ + $payload['total_gross'] ?? null, + $payload['total_with_tax'] ?? null, + $payload['summary']['total'] ?? null, + $payload['summary'] ?? null, + ]; + foreach ($grossCandidates as $candidate) { + if ($candidate !== null && is_numeric((string) $candidate)) { + return (float) $candidate; + } + } + + return $fallbackGross; + } + + 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 nullableString(string $value): ?string + { + $trimmed = trim($value); + return $trimmed === '' ? null : $trimmed; + } + + private function floatsEqual(?float $left, ?float $right): bool + { + if ($left === null && $right === null) { + return true; + } + if ($left === null || $right === null) { + return false; + } + + return abs($left - $right) < 0.00001; + } +} diff --git a/src/Modules/Settings/ShopproStatusMappingRepository.php b/src/Modules/Settings/ShopproStatusMappingRepository.php new file mode 100644 index 0000000..a371008 --- /dev/null +++ b/src/Modules/Settings/ShopproStatusMappingRepository.php @@ -0,0 +1,99 @@ + + */ + public function listByIntegration(int $integrationId): array + { + if ($integrationId <= 0) { + return []; + } + + $statement = $this->pdo->prepare( + 'SELECT shoppro_status_code, shoppro_status_name, orderpro_status_code + FROM order_status_mappings + WHERE integration_id = :integration_id + ORDER BY shoppro_status_code ASC' + ); + $statement->execute(['integration_id' => $integrationId]); + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + + if (!is_array($rows)) { + return []; + } + + $result = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $shopproCode = trim((string) ($row['shoppro_status_code'] ?? '')); + if ($shopproCode === '') { + continue; + } + + $result[] = [ + 'shoppro_status_code' => $shopproCode, + 'shoppro_status_name' => trim((string) ($row['shoppro_status_name'] ?? '')), + 'orderpro_status_code' => trim((string) ($row['orderpro_status_code'] ?? '')), + ]; + } + + return $result; + } + + /** + * @param array $mappings + */ + public function replaceForIntegration(int $integrationId, array $mappings): void + { + if ($integrationId <= 0) { + return; + } + + $deleteStatement = $this->pdo->prepare( + 'DELETE FROM order_status_mappings WHERE integration_id = :integration_id' + ); + $deleteStatement->execute(['integration_id' => $integrationId]); + + if ($mappings === []) { + return; + } + + $insertStatement = $this->pdo->prepare( + 'INSERT INTO order_status_mappings ( + integration_id, shoppro_status_code, shoppro_status_name, orderpro_status_code, created_at, updated_at + ) VALUES ( + :integration_id, :shoppro_status_code, :shoppro_status_name, :orderpro_status_code, NOW(), NOW() + )' + ); + + foreach ($mappings as $mapping) { + $shopproCode = trim((string) ($mapping['shoppro_status_code'] ?? '')); + $orderproCode = trim((string) ($mapping['orderpro_status_code'] ?? '')); + if ($shopproCode === '' || $orderproCode === '') { + continue; + } + + $shopproName = trim((string) ($mapping['shoppro_status_name'] ?? '')); + $insertStatement->execute([ + 'integration_id' => $integrationId, + 'shoppro_status_code' => $shopproCode, + 'shoppro_status_name' => $shopproName !== '' ? $shopproName : null, + 'orderpro_status_code' => $orderproCode, + ]); + } + } +} diff --git a/src/Modules/Settings/ShopproStatusSyncService.php b/src/Modules/Settings/ShopproStatusSyncService.php new file mode 100644 index 0000000..ccb1672 --- /dev/null +++ b/src/Modules/Settings/ShopproStatusSyncService.php @@ -0,0 +1,63 @@ + + */ + public function sync(): array + { + $supportedIntegrationIds = []; + $unsupportedCount = 0; + + foreach ($this->integrations->listIntegrations() as $integration) { + $integrationId = (int) ($integration['id'] ?? 0); + if ($integrationId <= 0 || empty($integration['is_active']) || empty($integration['has_api_key'])) { + continue; + } + + $direction = trim((string) ($integration['order_status_sync_direction'] ?? self::DIRECTION_SHOPPRO_TO_ORDERPRO)); + if ($direction === self::DIRECTION_ORDERPRO_TO_SHOPPRO) { + $unsupportedCount++; + continue; + } + + $supportedIntegrationIds[] = $integrationId; + } + + if ($supportedIntegrationIds === []) { + return [ + 'ok' => true, + 'processed' => 0, + 'checked_integrations' => 0, + 'unsupported_integrations' => $unsupportedCount, + 'message' => 'Brak aktywnych integracji shopPRO z kierunkiem shopPRO -> orderPRO.', + ]; + } + + $result = $this->ordersSyncService->sync([ + 'max_pages' => 3, + 'page_limit' => 50, + 'max_orders' => 200, + 'ignore_orders_fetch_enabled' => true, + 'allowed_integration_ids' => $supportedIntegrationIds, + ]); + $result['ok'] = true; + $result['direction'] = self::DIRECTION_SHOPPRO_TO_ORDERPRO; + $result['unsupported_integrations'] = $unsupportedCount; + + return $result; + } +}