feat: add Shoppro payment status synchronization service

- Implemented ShopproPaymentStatusSyncService to handle payment status synchronization between Shoppro and Orderpro.
- Added methods for resolving watched status codes, finding candidate orders, and syncing individual order payments.
- Introduced ShopproStatusMappingRepository for managing status mappings between Shoppro and Orderpro.
- Created ShopproStatusSyncService to facilitate synchronization of order statuses from Shoppro to Orderpro.
This commit is contained in:
2026-03-08 20:41:10 +01:00
parent 3ba6202770
commit af052e1ff5
50 changed files with 6110 additions and 2602 deletions

View File

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

View File

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

View File

@@ -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),

View File

@@ -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 `<b>...</b>`),
- `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`.

View File

@@ -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<EFBFBD> rejestracj<EFBFBD> historii zam<EFBFBD>wie<EFBFBD>, 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<EFBFBD>lach zam<EFBFBD>wienia 2 razy wy<EFBFBD>wietla si<EFBFBD> ID zam<EFBFBD>wienai z allegro, np: 008d3d60-1743-11f1-b15c-fdb4f87ccfc6
7. [x] Przy imporcie z allegro liczba przesy<EFBFBD>ek jest 0.
8. [x] Kolumna LP w szczeg<EFBFBD>lach zam<EFBFBD>wienia jest zbyt szeroka.
9. [x] Na lis<EFBFBD>ie zam<EFBFBD>wie<EFBFBD> pole po kt<EFBFBD>rym jest domy<EFBFBD>lnie sortowana czyli data zam<EFBFBD>wienia jest puste.
10. [x] Na li<EFBFBD>cie zam<EFBFBD>wie<EFBFBD> ukry<EFBFBD> kolumn<EFBFBD> ostatnia zmiana.
11. [x] W ustawieniach doda<EFBFBD> zak<EFBFBD>adk<EFBFBD> Integracja Apaczka. Doda<EFBFBD> tam pierwsze ustawienie, czyli klucz API.
12. [] synchronizowa<EFBFBD> r<EFBFBD>czn<EFBFBD> zmian<EFBFBD> statusu z allegro
13. [x] W ustawieniach cron https://orderpro.projectpro.pl/settings/cron historia powinna mie<EFBFBD> stronicowanie
14. [] border input<EFBFBD>w, select, textarea, itd zr<EFBFBD>b troszk<EFBFBD> ciemniejszy
15. [] W tym miejscu odwróć kolejność: najpierw źródło potem ID, <div class="orders-ref__meta"><span>f6079660-1af8-11f1-a7c9-231cf6ef29d1</span><span>allegro</span></div>
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

View File

@@ -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
),
]
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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',

View File

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

View File

@@ -73,14 +73,8 @@
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'cron' ? ' is-active' : '' ?>" href="/settings/cron">
<?= $e($t('navigation.cron')) ?>
</a>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'allegro' ? ' is-active' : '' ?>" href="/settings/integrations/allegro">
<?= $e($t('navigation.allegro')) ?>
</a>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'apaczka' ? ' is-active' : '' ?>" href="/settings/integrations/apaczka">
<?= $e($t('navigation.apaczka')) ?>
</a>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'inpost' ? ' is-active' : '' ?>" href="/settings/integrations/inpost">
<?= $e($t('navigation.inpost')) ?>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && in_array($currentSettings, ['integrations', 'allegro', 'apaczka', 'inpost', 'shoppro'], true) ? ' is-active' : '' ?>" href="/settings/integrations">
<?= $e($t('navigation.integrations')) ?>
</a>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'company' ? ' is-active' : '' ?>" href="/settings/company">
<?= $e($t('navigation.company')) ?>

View File

@@ -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) {
</dd>
<dt><?= $e($t('orders.details.fields.total_with_tax')) ?></dt><dd><?= $e((string) ($orderRow['total_with_tax'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.total_paid')) ?></dt><dd><?= $e((string) ($orderRow['total_paid'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.carrier')) ?></dt><dd><?= $e((string) ($orderRow['external_carrier_id'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.carrier')) ?></dt><dd><?= $e($carrierDisplay !== '' ? $carrierDisplay : '-') ?></dd>
<dt><?= $e($t('orders.details.fields.send_date')) ?></dt><dd><?= $e((string) ($orderRow['send_date_max'] ?? '-')) ?></dd>
<dt><?= $e($t('orders.details.fields.shipments_count')) ?></dt><dd><?= $e((string) count($shipmentsList)) ?></dd>
</dl>
@@ -207,12 +209,22 @@ foreach ($addressesList as $address) {
<h3 class="section-title"><?= $e((string) $addrTitle) ?></h3>
<div class="order-address mt-12">
<?php if ($addr === []): ?>
<div class="muted">-</div>
<?php if ($addrType === 'delivery' && $carrierDisplay !== ''): ?>
<div><?= $e($carrierDisplay) ?></div>
<?php else: ?>
<div class="muted">-</div>
<?php endif; ?>
<?php else: ?>
<div><?= $e((string) ($addr['name'] ?? '')) ?></div>
<div><?= $e((string) (($addr['street_name'] ?? '') . ' ' . ($addr['street_number'] ?? ''))) ?></div>
<div><?= $e((string) (($addr['zip_code'] ?? '') . ' ' . ($addr['city'] ?? ''))) ?></div>
<div><?= $e((string) ($addr['country'] ?? '')) ?></div>
<?php if ($addrType === 'delivery' && !empty($addr['parcel_name'])): ?>
<div><?= $e((string) $addr['parcel_name']) ?></div>
<?php endif; ?>
<?php if ($addrType === 'delivery' && !empty($addr['parcel_external_id'])): ?>
<div><?= $e((string) $addr['parcel_external_id']) ?></div>
<?php endif; ?>
<div><?= $e((string) ($addr['phone'] ?? '')) ?></div>
<div><?= $e((string) ($addr['email'] ?? '')) ?></div>
<?php endif; ?>

View File

@@ -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));
?>
<section class="card">
@@ -144,4 +148,26 @@ $pastJobsList = is_array($pastJobs ?? null) ? $pastJobs : [];
</tbody>
</table>
</div>
<?php if ($pastTotalPages > 1): ?>
<div class="table-list__footer">
<div class="pagination">
<a class="pagination__item<?= $pastPage <= 1 ? ' is-disabled' : '' ?>" href="/settings/cron?past_page=1">&laquo;</a>
<a class="pagination__item<?= $pastPage <= 1 ? ' is-disabled' : '' ?>" href="/settings/cron?past_page=<?= $e((string) max(1, $pastPage - 1)) ?>">&lsaquo;</a>
<?php $startPage = max(1, $pastPage - 2); ?>
<?php $endPage = min($pastTotalPages, $pastPage + 2); ?>
<?php for ($page = $startPage; $page <= $endPage; $page++): ?>
<a class="pagination__item<?= $page === $pastPage ? ' is-active' : '' ?>" href="/settings/cron?past_page=<?= $e((string) $page) ?>">
<?= $e((string) $page) ?>
</a>
<?php endfor; ?>
<a class="pagination__item<?= $pastPage >= $pastTotalPages ? ' is-disabled' : '' ?>" href="/settings/cron?past_page=<?= $e((string) min($pastTotalPages, $pastPage + 1)) ?>">&rsaquo;</a>
<a class="pagination__item<?= $pastPage >= $pastTotalPages ? ' is-disabled' : '' ?>" href="/settings/cron?past_page=<?= $e((string) $pastTotalPages) ?>">&raquo;</a>
</div>
<div class="muted">
<?= $e($t('settings.cron.pagination.summary', ['page' => (string) $pastPage, 'total_pages' => (string) $pastTotalPages, 'total' => (string) $pastTotal])) ?>
</div>
</div>
<?php endif; ?>
</section>

View File

@@ -0,0 +1,58 @@
<?php
$items = is_array($rows ?? null) ? $rows : [];
?>
<section class="card">
<h2 class="section-title"><?= $e($t('settings.integrations_hub.title')) ?></h2>
<p class="muted mt-12"><?= $e($t('settings.integrations_hub.description')) ?></p>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
<?php endif; ?>
</section>
<section class="card mt-16 integrations-overview">
<h3 class="section-title"><?= $e($t('settings.integrations_hub.list_title')) ?></h3>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.integrations_hub.fields.provider')) ?></th>
<th><?= $e($t('settings.integrations_hub.fields.instance')) ?></th>
<th><?= $e($t('settings.integrations_hub.fields.authorization')) ?></th>
<th><?= $e($t('settings.integrations_hub.fields.secret')) ?></th>
<th><?= $e($t('settings.integrations_hub.fields.active')) ?></th>
<th><?= $e($t('settings.integrations_hub.fields.last_test')) ?></th>
<th><?= $e($t('settings.integrations_hub.fields.actions')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($items === []): ?>
<tr>
<td class="muted" colspan="7"><?= $e($t('settings.integrations_hub.empty')) ?></td>
</tr>
<?php else: ?>
<?php foreach ($items as $item): ?>
<tr>
<td><?= $e((string) ($item['provider'] ?? '')) ?></td>
<td><?= $e((string) ($item['instance'] ?? '')) ?></td>
<td><?= $e((string) ($item['authorization_status'] ?? '')) ?></td>
<td><?= $e((string) ($item['secret_status'] ?? '')) ?></td>
<td><?= $e(!empty($item['is_active']) ? $t('settings.integrations_hub.active.yes') : $t('settings.integrations_hub.active.no')) ?></td>
<td><?= $e((string) ($item['last_test_at'] ?? '')) ?></td>
<td>
<a class="btn btn--secondary btn--sm" href="<?= $e((string) ($item['configure_url'] ?? '/settings/integrations')) ?>">
<?= $e($t('settings.integrations_hub.actions.settings')) ?>
</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>

View File

@@ -0,0 +1,677 @@
<?php
$list = is_array($rows ?? null) ? $rows : [];
$selected = is_array($selectedIntegration ?? null) ? $selectedIntegration : null;
$formValues = is_array($form ?? null) ? $form : [];
$statusRows = is_array($statusRows ?? null) ? $statusRows : [];
$orderproStatuses = is_array($orderproStatuses ?? null) ? $orderproStatuses : [];
$ordersImportIntervalMinutes = max(1, (int) ($ordersImportIntervalMinutes ?? 5));
$statusSyncIntervalMinutes = max(1, (int) ($statusSyncIntervalMinutes ?? 15));
$paymentSyncIntervalMinutes = max(1, (int) ($paymentSyncIntervalMinutes ?? 10));
$activeTab = (string) ($activeTab ?? 'integration');
$isEdit = ((int) ($formValues['integration_id'] ?? 0)) > 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;
}
?>
<section class="card">
<h2 class="section-title"><?= $e($t('settings.integrations.title')) ?></h2>
<p class="muted mt-12"><?= $e($t('settings.integrations.description')) ?></p>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
<?php endif; ?>
</section>
<section class="card mt-16">
<?php if ($list !== []): ?>
<div class="shoppro-tabs-toolbar">
<label class="form-field shoppro-tabs-toolbar__field">
<span class="field-label"><?= $e($t('settings.integrations.selector.integration')) ?></span>
<select class="form-control" id="shoppro-integration-select">
<?php foreach ($list as $item): ?>
<?php $rowId = (int) ($item['id'] ?? 0); ?>
<?php if ($rowId <= 0) continue; ?>
<option value="<?= $e((string) $rowId) ?>"<?= $rowId === $selectedIntegrationId ? ' selected' : '' ?>>
<?= $e((string) ($item['name'] ?? ('#' . $rowId))) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<div class="shoppro-tabs-toolbar__actions">
<a href="/settings/integrations/shoppro?new=1" class="btn btn--secondary btn--sm"><?= $e($t('settings.integrations.actions.new')) ?></a>
</div>
</div>
<?php endif; ?>
<nav class="content-tabs-nav" aria-label="<?= $e($t('settings.integrations.tabs.label')) ?>">
<button type="button" class="content-tab-btn<?= $activeTab === 'integration' ? ' is-active' : '' ?>" data-tab-target="shoppro-tab-integration">
<?= $e($t('settings.integrations.tabs.integration')) ?>
</button>
<button type="button" class="content-tab-btn<?= $activeTab === 'statuses' ? ' is-active' : '' ?>" data-tab-target="shoppro-tab-statuses">
<?= $e($t('settings.integrations.tabs.statuses')) ?>
</button>
<button type="button" class="content-tab-btn<?= $activeTab === 'settings' ? ' is-active' : '' ?>" data-tab-target="shoppro-tab-settings">
<?= $e($t('settings.integrations.tabs.settings')) ?>
</button>
<button type="button" class="content-tab-btn<?= $activeTab === 'delivery' ? ' is-active' : '' ?>" data-tab-target="shoppro-tab-delivery">
<?= $e($t('settings.integrations.tabs.delivery')) ?>
</button>
</nav>
<div class="content-tab-panel<?= $activeTab === 'integration' ? ' is-active' : '' ?>" data-tab-panel="shoppro-tab-integration">
<section class="mt-16 integrations-overview">
<h3 class="section-title"><?= $e($t('settings.integrations.list_title')) ?></h3>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th><?= $e($t('settings.integrations.fields.name')) ?></th>
<th><?= $e($t('settings.integrations.fields.base_url')) ?></th>
<th><?= $e($t('settings.integrations.fields.active')) ?></th>
<th><?= $e($t('settings.integrations.fields.last_test')) ?></th>
<th><?= $e($t('settings.integrations.fields.actions')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($list === []): ?>
<tr>
<td colspan="6" class="muted"><?= $e($t('settings.integrations.empty')) ?></td>
</tr>
<?php else: ?>
<?php foreach ($list as $item): ?>
<?php
$status = (string) ($item['last_test_status'] ?? '');
$statusLabel = $status === 'ok'
? $t('settings.integrations.test_status.ok')
: ($status === 'error' ? $t('settings.integrations.test_status.error') : $t('settings.integrations.test_status.never'));
?>
<tr>
<td><?= $e((string) ($item['id'] ?? 0)) ?></td>
<td><?= $e((string) ($item['name'] ?? '')) ?></td>
<td><?= $e((string) ($item['base_url'] ?? '')) ?></td>
<td>
<?php if (!empty($item['is_active'])): ?>
<span class="status-pill is-active"><?= $e($t('settings.integrations.active.yes')) ?></span>
<?php else: ?>
<span class="status-pill"><?= $e($t('settings.integrations.active.no')) ?></span>
<?php endif; ?>
</td>
<td>
<div><?= $e($statusLabel) ?></div>
<?php if (!empty($item['last_test_at'])): ?>
<small class="muted">
<?= $e((string) $item['last_test_at']) ?>
<?php if (($item['last_test_http_code'] ?? null) !== null): ?> | HTTP <?= $e((string) ($item['last_test_http_code'])) ?><?php endif; ?>
</small>
<?php endif; ?>
</td>
<td>
<div class="table-row-actions">
<a class="btn btn--secondary btn--sm" href="/settings/integrations/shoppro?id=<?= $e((string) ($item['id'] ?? 0)) ?>">
<?= $e($t('settings.integrations.actions.edit')) ?>
</a>
<form action="/settings/integrations/shoppro/test" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) ($item['id'] ?? 0)) ?>">
<input type="hidden" name="tab" value="integration">
<button type="submit" class="btn btn--primary btn--sm"><?= $e($t('settings.integrations.actions.test')) ?></button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="mt-16">
<h3 class="section-title"><?= $e($isEdit ? $t('settings.integrations.edit_title') : $t('settings.integrations.create_title')) ?></h3>
<form class="statuses-form mt-16" action="/settings/integrations/shoppro/save" method="post" novalidate>
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) ($formValues['integration_id'] ?? 0)) ?>">
<input type="hidden" name="tab" value="integration">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.name')) ?></span>
<input class="form-control" type="text" name="name" value="<?= $e((string) ($formValues['name'] ?? '')) ?>" required>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.base_url')) ?></span>
<input class="form-control" type="url" name="base_url" value="<?= $e((string) ($formValues['base_url'] ?? '')) ?>" placeholder="https://shoppro.project-dc.pl/" required>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.api_key')) ?></span>
<input class="form-control" type="password" name="api_key" value="" placeholder="<?= $e($isEdit ? $t('settings.integrations.api_key_placeholder_edit') : '') ?>" autocomplete="new-password">
<?php if ($isEdit): ?>
<span class="muted"><?= $e(($selected['has_api_key'] ?? false) ? $t('settings.integrations.api_key_saved') : $t('settings.integrations.api_key_missing')) ?></span>
<?php endif; ?>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.timeout_seconds')) ?></span>
<input class="form-control" type="number" min="1" max="120" name="timeout_seconds" value="<?= $e((string) ($formValues['timeout_seconds'] ?? 10)) ?>">
</label>
<label class="form-field">
<span class="field-label">
<input type="checkbox" name="is_active" value="1"<?= ((int) ($formValues['is_active'] ?? 1)) === 1 ? ' checked' : '' ?>>
<?= $e($t('settings.integrations.fields.active_checkbox')) ?>
</span>
</label>
<div class="form-actions mt-12">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.integrations.actions.save')) ?></button>
<a href="/settings/integrations/shoppro?new=1" class="btn btn--secondary"><?= $e($t('settings.integrations.actions.new')) ?></a>
</div>
</form>
</section>
</div>
<div class="content-tab-panel<?= $activeTab === 'statuses' ? ' is-active' : '' ?>" data-tab-panel="shoppro-tab-statuses">
<section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.integrations.statuses.title')) ?></h3>
<p class="muted mt-12"><?= $e($t('settings.integrations.statuses.description')) ?></p>
<?php if (!$isEdit && $list === []): ?>
<p class="muted mt-12"><?= $e($t('settings.integrations.statuses.select_integration_first')) ?></p>
<?php else: ?>
<form class="mt-12" action="/settings/integrations/shoppro/statuses/sync" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) ($formValues['integration_id'] ?? 0)) ?>">
<button type="submit" class="btn btn--secondary"><?= $e($t('settings.integrations.statuses.actions.sync')) ?></button>
</form>
<form class="mt-12" action="/settings/integrations/shoppro/statuses/save" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) ($formValues['integration_id'] ?? 0)) ?>">
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.order_statuses.fields.shoppro_code')) ?></th>
<th><?= $e($t('settings.order_statuses.fields.shoppro_name')) ?></th>
<th><?= $e($t('settings.order_statuses.fields.orderpro_status')) ?></th>
</tr>
</thead>
<tbody>
<?php if ($statusRows === []): ?>
<tr>
<td colspan="3" class="muted"><?= $e($t('settings.integrations.statuses.empty')) ?></td>
</tr>
<?php else: ?>
<?php foreach ($statusRows as $row): ?>
<?php
$shopCode = (string) ($row['shoppro_status_code'] ?? '');
$shopName = (string) ($row['shoppro_status_name'] ?? '');
$selectedOrderpro = strtolower(trim((string) ($row['orderpro_status_code'] ?? '')));
?>
<tr>
<td>
<code><?= $e($shopCode) ?></code>
<input type="hidden" name="shoppro_status_code[]" value="<?= $e($shopCode) ?>">
</td>
<td>
<?= $e($shopName) ?>
<input type="hidden" name="shoppro_status_name[]" value="<?= $e($shopName) ?>">
</td>
<td>
<select class="form-control" name="orderpro_status_code[]">
<option value=""><?= $e($t('settings.order_statuses.fields.no_mapping')) ?></option>
<?php foreach ($orderproStatuses as $status): ?>
<?php $statusCode = strtolower(trim((string) ($status['code'] ?? ''))); ?>
<?php if ($statusCode === '') continue; ?>
<option value="<?= $e($statusCode) ?>"<?= $selectedOrderpro === $statusCode ? ' selected' : '' ?>>
<?= $e((string) ($status['name'] ?? $statusCode)) ?> (<?= $e($statusCode) ?>)
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($statusRows !== []): ?>
<div class="form-actions mt-12">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.order_statuses.actions.save')) ?></button>
</div>
<?php endif; ?>
</form>
<?php endif; ?>
</section>
</div>
<div class="content-tab-panel<?= $activeTab === 'settings' ? ' is-active' : '' ?>" data-tab-panel="shoppro-tab-settings">
<section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.integrations.settings.title')) ?></h3>
<p class="muted mt-12"><?= $e($t('settings.integrations.settings.description')) ?></p>
<?php if (!$isEdit && $list === []): ?>
<p class="muted mt-12"><?= $e($t('settings.integrations.settings.select_integration_first')) ?></p>
<?php else: ?>
<form class="statuses-form mt-12" action="/settings/integrations/shoppro/save" method="post" novalidate>
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) ($formValues['integration_id'] ?? 0)) ?>">
<input type="hidden" name="tab" value="settings">
<input type="hidden" name="name" value="<?= $e((string) ($formValues['name'] ?? '')) ?>">
<input type="hidden" name="base_url" value="<?= $e((string) ($formValues['base_url'] ?? '')) ?>">
<input type="hidden" name="timeout_seconds" value="<?= $e((string) ($formValues['timeout_seconds'] ?? 10)) ?>">
<input type="hidden" name="is_active" value="<?= $e(((int) ($formValues['is_active'] ?? 1)) === 1 ? '1' : '0') ?>">
<div class="integration-settings-group">
<div class="integration-settings-group__head">
<h4 class="integration-settings-group__title"><?= $e($t('settings.integrations.settings.orders_group_title')) ?></h4>
<p class="muted integration-settings-group__desc"><?= $e($t('settings.integrations.settings.orders_group_description')) ?></p>
</div>
<div class="integration-settings-group__grid">
<label class="form-field integration-settings-group__full">
<span class="field-label">
<input type="checkbox" name="orders_fetch_enabled" value="1"<?= ((int) ($formValues['orders_fetch_enabled'] ?? 0)) === 1 ? ' checked' : '' ?>>
<?= $e($t('settings.integrations.fields.orders_fetch_enabled_checkbox')) ?>
</span>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.orders_fetch_start_date')) ?></span>
<input class="form-control" type="date" name="orders_fetch_start_date" value="<?= $e((string) ($formValues['orders_fetch_start_date'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.settings.orders_import_interval_minutes')) ?></span>
<input class="form-control" type="number" min="1" max="1440" name="orders_import_interval_minutes" value="<?= $e((string) $ordersImportIntervalMinutes) ?>">
<span class="muted"><?= $e($t('settings.integrations.settings.orders_import_interval_hint')) ?></span>
</label>
</div>
</div>
<div class="integration-settings-group">
<div class="integration-settings-group__head">
<h4 class="integration-settings-group__title"><?= $e($t('settings.integrations.settings.statuses_group_title')) ?></h4>
<p class="muted integration-settings-group__desc"><?= $e($t('settings.integrations.settings.statuses_group_description')) ?></p>
</div>
<div class="integration-settings-group__grid">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.order_status_sync_direction')) ?></span>
<select class="form-control" name="order_status_sync_direction">
<?php $syncDirection = (string) ($formValues['order_status_sync_direction'] ?? 'shoppro_to_orderpro'); ?>
<option value="shoppro_to_orderpro"<?= $syncDirection === 'shoppro_to_orderpro' ? ' selected' : '' ?>>
<?= $e($t('settings.integrations.fields.order_status_sync_direction_shoppro_to_orderpro')) ?>
</option>
<option value="orderpro_to_shoppro"<?= $syncDirection === 'orderpro_to_shoppro' ? ' selected' : '' ?>>
<?= $e($t('settings.integrations.fields.order_status_sync_direction_orderpro_to_shoppro')) ?>
</option>
</select>
<span class="muted"><?= $e($t('settings.integrations.settings.status_sync_direction_hint')) ?></span>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.settings.status_sync_interval_minutes')) ?></span>
<input class="form-control" type="number" min="1" max="1440" name="status_sync_interval_minutes" value="<?= $e((string) $statusSyncIntervalMinutes) ?>">
<span class="muted"><?= $e($t('settings.integrations.settings.status_sync_interval_hint')) ?></span>
</label>
</div>
</div>
<div class="integration-settings-group">
<div class="integration-settings-group__head">
<h4 class="integration-settings-group__title"><?= $e($t('settings.integrations.settings.payment_group_title')) ?></h4>
<p class="muted integration-settings-group__desc"><?= $e($t('settings.integrations.settings.payment_group_description')) ?></p>
</div>
<div class="integration-settings-group__grid">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.settings.payment_sync_interval_minutes')) ?></span>
<input class="form-control" type="number" min="1" max="1440" name="payment_sync_interval_minutes" value="<?= $e((string) $paymentSyncIntervalMinutes) ?>">
<span class="muted"><?= $e($t('settings.integrations.settings.payment_sync_interval_hint')) ?></span>
</label>
<fieldset class="form-field integration-settings-group__full integration-settings-checkboxes">
<legend class="field-label"><?= $e($t('settings.integrations.settings.payment_sync_status_codes')) ?></legend>
<span class="muted"><?= $e($t('settings.integrations.settings.payment_sync_status_codes_hint')) ?></span>
<div class="integration-settings-checkboxes__list mt-12">
<?php foreach ($orderproStatuses as $status): ?>
<?php $statusCode = strtolower(trim((string) ($status['code'] ?? ''))); ?>
<?php if ($statusCode === '') continue; ?>
<?php $isChecked = in_array($statusCode, $selectedPaymentSyncCodes, true); ?>
<label class="integration-settings-checkboxes__item">
<input type="checkbox" name="payment_sync_status_codes[]" value="<?= $e($statusCode) ?>"<?= $isChecked ? ' checked' : '' ?>>
<span><?= $e((string) ($status['name'] ?? $statusCode)) ?> (<?= $e($statusCode) ?>)</span>
</label>
<?php endforeach; ?>
</div>
</fieldset>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.integrations.actions.save')) ?></button>
</div>
</form>
<?php endif; ?>
</section>
</div>
<div class="content-tab-panel<?= $activeTab === 'delivery' ? ' is-active' : '' ?>" data-tab-panel="shoppro-tab-delivery">
<section class="mt-16">
<h3 class="section-title"><?= $e($t('settings.integrations.delivery.title')) ?></h3>
<p class="muted mt-12"><?= $e($t('settings.integrations.delivery.description')) ?></p>
<?php if (!$isEdit): ?>
<p class="muted mt-12"><?= $e($t('settings.integrations.delivery.select_integration_first')) ?></p>
<?php else: ?>
<?php if ($dmServicesError !== ''): ?>
<div class="alert alert--danger mt-12"><?= $e($dmServicesError) ?></div>
<?php endif; ?>
<?php if ($dmOrderMethods === []): ?>
<p class="muted mt-12"><?= $e($t('settings.integrations.delivery.empty_orders')) ?></p>
<?php else: ?>
<form action="/settings/integrations/shoppro/delivery/save" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) $selectedIntegrationId) ?>">
<div class="table-wrap table-wrap--visible mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.integrations.delivery.fields.order_method')) ?></th>
<th><?= $e($t('settings.integrations.delivery.fields.carrier')) ?></th>
<th><?= $e($t('settings.integrations.delivery.fields.allegro_service')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($dmOrderMethods as $rowIdx => $orderMethod): ?>
<?php
$methodName = trim((string) $orderMethod);
$currentMapping = $dmMappingsByMethod[$methodName] ?? null;
$currentCarrier = $currentMapping !== null ? trim((string) ($currentMapping['carrier'] ?? 'allegro')) : '';
$currentAllegroId = $currentMapping !== null ? trim((string) ($currentMapping['allegro_delivery_method_id'] ?? '')) : '';
$currentServiceName = $currentMapping !== null ? trim((string) ($currentMapping['allegro_service_name'] ?? '')) : '';
?>
<tr data-dm-row="<?= $rowIdx ?>">
<td>
<strong><?= $e($methodName) ?></strong>
<input type="hidden" name="order_delivery_method[]" value="<?= $e($methodName) ?>">
</td>
<td>
<select class="form-control dm-carrier-select" name="carrier[]" data-row="<?= $rowIdx ?>">
<option value="">-- <?= $e($t('settings.integrations.delivery.fields.no_mapping')) ?> --</option>
<option value="allegro"<?= $currentCarrier === 'allegro' && $currentAllegroId !== '' ? ' selected' : '' ?>>Allegro</option>
<option value="inpost"<?= $currentCarrier === 'inpost' ? ' selected' : '' ?>>InPost</option>
</select>
</td>
<td>
<div class="dm-service-wrap" data-row="<?= $rowIdx ?>">
<input type="hidden" name="allegro_delivery_method_id[]" class="dm-hidden-method-id" value="<?= $e($currentAllegroId) ?>">
<input type="hidden" name="allegro_credentials_id[]" class="dm-hidden-credentials-id" value="<?= $e(trim((string) ($currentMapping['allegro_credentials_id'] ?? ''))) ?>">
<input type="hidden" name="allegro_carrier_id[]" class="dm-hidden-carrier-id" value="<?= $e(trim((string) ($currentMapping['allegro_carrier_id'] ?? ''))) ?>">
<input type="hidden" name="allegro_service_name[]" class="dm-hidden-service-name" value="<?= $e($currentServiceName) ?>">
<div class="dm-allegro-panel dm-searchable-select" data-current-id="<?= $e($currentCarrier === 'allegro' ? $currentAllegroId : '') ?>" data-current-name="<?= $e($currentCarrier === 'allegro' ? $currentServiceName : '') ?>" style="<?= $currentCarrier !== 'allegro' || $currentAllegroId === '' && $currentCarrier === '' ? 'display:none' : '' ?>">
<input type="text" class="form-control dm-search-input" placeholder="<?= $e($t('settings.integrations.delivery.fields.search_placeholder')) ?>" value="<?= $e($currentCarrier === 'allegro' ? $currentServiceName : '') ?>" autocomplete="off">
<div class="searchable-select__dropdown dm-dropdown">
<div class="searchable-select__option dm-option-clear" data-value="" data-label="" data-credentials-id="" data-carrier-id="">
<em class="muted">-- <?= $e($t('settings.integrations.delivery.fields.no_mapping')) ?> --</em>
</div>
<?php foreach ($dmAllegroServices as $svc): ?>
<?php
if (!is_array($svc)) {
continue;
}
$svcId = is_array($svc['id'] ?? null) ? $svc['id'] : [];
$svcMethodId = trim((string) ($svcId['deliveryMethodId'] ?? ''));
$svcCredentialsId = trim((string) ($svcId['credentialsId'] ?? ''));
$svcName = trim((string) ($svc['name'] ?? ''));
$svcCarrierId = trim((string) ($svc['carrierId'] ?? ''));
$svcOwner = trim((string) ($svc['owner'] ?? ''));
$svcLabel = $svcName . ' (' . $svcOwner . ')';
?>
<div class="searchable-select__option"
data-value="<?= $e($svcMethodId) ?>"
data-label="<?= $e($svcLabel) ?>"
data-credentials-id="<?= $e($svcCredentialsId) ?>"
data-carrier-id="<?= $e($svcCarrierId) ?>">
<?= $e($svcName) ?> <span class="muted">(<?= $e($svcOwner) ?>)</span>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="dm-inpost-panel" style="<?= $currentCarrier !== 'inpost' ? 'display:none' : '' ?>">
<?php if ($dmInpostServices === []): ?>
<div class="muted"><?= $e($t('settings.integrations.delivery.no_inpost_services')) ?></div>
<?php else: ?>
<select class="form-control dm-inpost-select">
<option value="">-- <?= $e($t('settings.integrations.delivery.fields.no_mapping')) ?> --</option>
<?php foreach ($dmInpostServices as $inSvc): ?>
<?php
if (!is_array($inSvc)) {
continue;
}
$inSvcId = is_array($inSvc['id'] ?? null) ? $inSvc['id'] : [];
$inSvcMethodId = trim((string) ($inSvcId['deliveryMethodId'] ?? ''));
$inSvcCredentialsId = trim((string) ($inSvcId['credentialsId'] ?? ''));
$inSvcCarrierId = trim((string) ($inSvc['carrierId'] ?? ''));
$inSvcName = trim((string) ($inSvc['name'] ?? ''));
?>
<option
value="<?= $e($inSvcMethodId) ?>"
data-credentials-id="<?= $e($inSvcCredentialsId) ?>"
data-carrier-id="<?= $e($inSvcCarrierId) ?>"
<?= $currentCarrier === 'inpost' && $currentAllegroId === $inSvcMethodId ? 'selected' : '' ?>>
<?= $e($inSvcName) ?>
</option>
<?php endforeach; ?>
</select>
<?php endif; ?>
</div>
<div class="dm-empty-panel muted" style="<?= $currentCarrier !== '' ? 'display:none' : ($currentAllegroId !== '' ? 'display:none' : '') ?>">
<?= $e($t('settings.integrations.delivery.fields.select_carrier_first')) ?>
</div>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="form-actions mt-12">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.integrations.delivery.actions.save')) ?></button>
</div>
</form>
<?php endif; ?>
<?php endif; ?>
</section>
</div>
</section>
<script>
(function () {
var tabs = document.querySelectorAll('[data-tab-target]');
var panels = document.querySelectorAll('[data-tab-panel]');
if (tabs.length === 0 || panels.length === 0) {
return;
}
var tabNameMap = {
'shoppro-tab-integration': 'integration',
'shoppro-tab-statuses': 'statuses',
'shoppro-tab-settings': 'settings',
'shoppro-tab-delivery': 'delivery'
};
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
var target = tab.getAttribute('data-tab-target');
var tabName = tabNameMap[target] || 'integration';
var url = new URL(window.location.href);
var currentTab = url.searchParams.get('tab') || 'integration';
if (tabName === 'integration') {
url.searchParams.delete('tab');
} else {
url.searchParams.set('tab', tabName);
}
if (tabName === 'delivery' && currentTab !== 'delivery') {
window.location.href = url.toString();
return;
}
window.history.replaceState(null, '', url.toString());
tabs.forEach(function (node) { node.classList.remove('is-active'); });
panels.forEach(function (panel) { panel.classList.remove('is-active'); });
tab.classList.add('is-active');
var panel = document.querySelector('[data-tab-panel="' + target + '"]');
if (panel) {
panel.classList.add('is-active');
}
});
});
})();
(function () {
var select = document.getElementById('shoppro-integration-select');
if (!select) {
return;
}
select.addEventListener('change', function () {
var url = new URL(window.location.href);
if (select.value) {
url.searchParams.set('id', select.value);
} else {
url.searchParams.delete('id');
}
window.location.href = url.toString();
});
})();
(function () {
document.querySelectorAll('.dm-carrier-select').forEach(function (carrierSelect) {
var rowIdx = carrierSelect.getAttribute('data-row');
var serviceWrap = document.querySelector('.dm-service-wrap[data-row="' + rowIdx + '"]');
if (!serviceWrap) return;
var allegroPanel = serviceWrap.querySelector('.dm-allegro-panel');
var inpostPanel = serviceWrap.querySelector('.dm-inpost-panel');
var emptyPanel = serviceWrap.querySelector('.dm-empty-panel');
var hiddenMethodId = serviceWrap.querySelector('.dm-hidden-method-id');
var hiddenCredentialsId = serviceWrap.querySelector('.dm-hidden-credentials-id');
var hiddenCarrierId = serviceWrap.querySelector('.dm-hidden-carrier-id');
var hiddenServiceName = serviceWrap.querySelector('.dm-hidden-service-name');
function showPanel(carrier) {
if (allegroPanel) allegroPanel.style.display = carrier === 'allegro' ? '' : 'none';
if (inpostPanel) inpostPanel.style.display = carrier === 'inpost' ? '' : 'none';
if (emptyPanel) emptyPanel.style.display = carrier === '' ? '' : 'none';
}
carrierSelect.addEventListener('change', function () {
var carrier = carrierSelect.value;
showPanel(carrier);
if (hiddenMethodId) hiddenMethodId.value = '';
if (hiddenCredentialsId) hiddenCredentialsId.value = '';
if (hiddenCarrierId) hiddenCarrierId.value = '';
if (hiddenServiceName) hiddenServiceName.value = '';
var allegroInput = allegroPanel ? allegroPanel.querySelector('.dm-search-input') : null;
if (allegroInput) allegroInput.value = '';
var inpostSelect = inpostPanel ? inpostPanel.querySelector('.dm-inpost-select') : null;
if (inpostSelect) inpostSelect.value = '';
});
var inpostSelect = inpostPanel ? inpostPanel.querySelector('.dm-inpost-select') : null;
if (inpostSelect) {
inpostSelect.addEventListener('change', function () {
var opt = inpostSelect.options[inpostSelect.selectedIndex];
if (hiddenMethodId) hiddenMethodId.value = inpostSelect.value;
if (hiddenCredentialsId) hiddenCredentialsId.value = opt ? (opt.getAttribute('data-credentials-id') || '') : '';
if (hiddenCarrierId) hiddenCarrierId.value = opt ? (opt.getAttribute('data-carrier-id') || '') : '';
if (hiddenServiceName) hiddenServiceName.value = opt ? opt.textContent.trim() : '';
});
}
});
document.querySelectorAll('.dm-searchable-select').forEach(function (wrapper) {
var searchInput = wrapper.querySelector('.dm-search-input');
var dropdown = wrapper.querySelector('.dm-dropdown');
var serviceWrap = wrapper.closest('.dm-service-wrap');
if (!searchInput || !dropdown || !serviceWrap) return;
var hiddenMethodId = serviceWrap.querySelector('.dm-hidden-method-id');
var hiddenCredentialsId = serviceWrap.querySelector('.dm-hidden-credentials-id');
var hiddenCarrierId = serviceWrap.querySelector('.dm-hidden-carrier-id');
var hiddenServiceName = serviceWrap.querySelector('.dm-hidden-service-name');
var options = dropdown.querySelectorAll('.searchable-select__option');
wrapper.style.position = 'relative';
function selectOption(opt) {
hiddenMethodId.value = opt.getAttribute('data-value') || '';
hiddenCredentialsId.value = opt.getAttribute('data-credentials-id') || '';
hiddenCarrierId.value = opt.getAttribute('data-carrier-id') || '';
hiddenServiceName.value = opt.getAttribute('data-label') || '';
searchInput.value = opt.getAttribute('data-label') || '';
dropdown.classList.remove('is-open');
options.forEach(function (o) { o.classList.remove('is-selected'); });
opt.classList.add('is-selected');
}
function filterOptions(query) {
var q = query.toLowerCase().trim();
options.forEach(function (opt) {
var label = (opt.getAttribute('data-label') || '').toLowerCase();
opt.style.display = (q === '' || label.indexOf(q) !== -1) ? '' : 'none';
});
}
searchInput.addEventListener('focus', function () {
filterOptions(searchInput.value);
dropdown.classList.add('is-open');
});
searchInput.addEventListener('input', function () {
filterOptions(searchInput.value);
dropdown.classList.add('is-open');
});
options.forEach(function (opt) {
opt.addEventListener('mousedown', function (e) {
e.preventDefault();
selectOption(opt);
});
});
searchInput.addEventListener('blur', function () {
setTimeout(function () { dropdown.classList.remove('is-open'); }, 150);
});
var currentId = wrapper.getAttribute('data-current-id') || '';
if (currentId !== '') {
options.forEach(function (opt) {
if (opt.getAttribute('data-value') === currentId) {
opt.classList.add('is-selected');
}
});
}
});
})();
</script>

View File

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

View File

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

View File

@@ -102,17 +102,19 @@ final class CronRepository
/**
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/

View File

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

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Modules\Cron;
use App\Modules\Settings\ShopproPaymentStatusSyncService;
final class ShopproPaymentStatusSyncHandler
{
public function __construct(private readonly ShopproPaymentStatusSyncService $syncService)
{
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function handle(array $payload): array
{
return $this->syncService->sync([
'per_integration_limit' => (int) ($payload['per_integration_limit'] ?? 100),
]);
}
}

View File

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

View File

@@ -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 = '<div class="orders-mini">';
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 .= '<div class="orders-mini__delivery">' . htmlspecialchars($deliveryMethod, ENT_QUOTES, 'UTF-8') . '</div>';

View File

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

View File

@@ -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<string, string>
*/

View File

@@ -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<string, mixed>|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 : '';
}
}

View File

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

View File

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

View File

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

View File

@@ -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<string, mixed>|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<string, mixed>
*/
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);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use RuntimeException;
final class IntegrationSecretCipher
{
public function __construct(private readonly string $secret)
{
}
public 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);
}
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;
}
}

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
final class IntegrationsHubController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly IntegrationsRepository $integrations,
private readonly AllegroIntegrationRepository $allegro,
private readonly ApaczkaIntegrationRepository $apaczka,
private readonly InpostIntegrationRepository $inpost,
private readonly ShopproIntegrationsRepository $shoppro
) {
}
public function index(Request $request): Response
{
$rows = [
$this->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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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'),
];
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
use Throwable;
final class IntegrationsRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<string, mixed>|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<string, mixed>|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<string, mixed>|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;
}
}

View File

@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
final class ShopproApiClient
{
/**
* @return array{ok:bool,http_code:int|null,message:string,items:array<int,array<string,mixed>>,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<string,mixed>|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<string,mixed>|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<string,mixed>|array<int,mixed>|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,
];
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
final class ShopproDeliveryMethodMappingRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array<string, mixed>>
*/
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<int, array<string, string>> $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<int, string>
*/
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 : [];
}
}

View File

@@ -0,0 +1,868 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Cron\CronRepository;
use DateInterval;
use DateTimeImmutable;
use RuntimeException;
use Throwable;
final class ShopproIntegrationsController
{
private const ORDERS_IMPORT_JOB_TYPE = 'shoppro_orders_import';
private const ORDERS_IMPORT_DEFAULT_INTERVAL_SECONDS = 300;
private const ORDERS_IMPORT_DEFAULT_PRIORITY = 90;
private const ORDERS_IMPORT_DEFAULT_MAX_ATTEMPTS = 3;
private const ORDER_STATUS_SYNC_JOB_TYPE = 'shoppro_order_status_sync';
private const ORDER_STATUS_SYNC_DEFAULT_INTERVAL_SECONDS = 900;
private const ORDER_STATUS_SYNC_DEFAULT_PRIORITY = 100;
private const ORDER_STATUS_SYNC_DEFAULT_MAX_ATTEMPTS = 3;
private const PAYMENT_SYNC_JOB_TYPE = 'shoppro_payment_status_sync';
private const PAYMENT_SYNC_DEFAULT_INTERVAL_SECONDS = 600;
private const PAYMENT_SYNC_DEFAULT_PRIORITY = 105;
private const PAYMENT_SYNC_DEFAULT_MAX_ATTEMPTS = 3;
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly ShopproIntegrationsRepository $repository,
private readonly ShopproStatusMappingRepository $statusMappings,
private readonly OrderStatusRepository $orderStatuses,
private readonly CronRepository $cronRepository,
private readonly ShopproDeliveryMethodMappingRepository $deliveryMappings,
private readonly AllegroIntegrationRepository $allegroIntegrationRepository,
private readonly AllegroOAuthClient $allegroOAuthClient,
private readonly AllegroApiClient $allegroApiClient
) {
}
public function index(Request $request): Response
{
$integrations = $this->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<string, mixed>|null $integration
* @return array<string, mixed>
*/
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<string, array{shoppro_status_code:string,shoppro_status_name:string,orderpro_status_code:string}>
*/
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<string, true>
*/
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<int, array{code:string,name:string}>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<int, array<string, mixed>>, 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<string, mixed> $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;
}
}
}

View File

@@ -0,0 +1,591 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
use RuntimeException;
use Throwable;
final class ShopproIntegrationsRepository
{
private const TYPE = 'shoppro';
private const DIRECTION_SHOPPRO_TO_ORDERPRO = 'shoppro_to_orderpro';
private const DIRECTION_ORDERPRO_TO_SHOPPRO = 'orderpro_to_shoppro';
private readonly IntegrationSecretCipher $cipher;
public function __construct(
private readonly PDO $pdo,
private readonly string $secret
) {
$this->cipher = new IntegrationSecretCipher($this->secret);
}
/**
* @return array<int, array<string, mixed>>
*/
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<string, mixed>|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<string, mixed> $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<int,array{code:string,name:string}>}
*/
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<int, string>
*/
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<int, mixed> $rawStatuses
* @return array<int, array{code:string,name:string}>
*/
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,404 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Modules\Orders\OrdersRepository;
use DateTimeImmutable;
use PDO;
use Throwable;
final class ShopproPaymentStatusSyncService
{
private const PAID_STATUS = 2;
private const UNPAID_STATUS = 0;
/**
* @var array<int, string>
*/
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<string, mixed>
*/
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<string, mixed> $integration
* @return array<int, string>
*/
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<int, string> $watchedStatuses
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $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;
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
final class ShopproStatusMappingRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array{shoppro_status_code:string,shoppro_status_name:string,orderpro_status_code:string}>
*/
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<int, array{shoppro_status_code:string,shoppro_status_name:string,orderpro_status_code:string}> $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,
]);
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
final class ShopproStatusSyncService
{
private const DIRECTION_SHOPPRO_TO_ORDERPRO = 'shoppro_to_orderpro';
private const DIRECTION_ORDERPRO_TO_SHOPPRO = 'orderpro_to_shoppro';
public function __construct(
private readonly ShopproIntegrationsRepository $integrations,
private readonly ShopproOrdersSyncService $ordersSyncService
) {
}
/**
* @return array<string, mixed>
*/
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;
}
}