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:
98
.vscode/ftp-kr.sync.cache.json
vendored
98
.vscode/ftp-kr.sync.cache.json
vendored
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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`.
|
||||
|
||||
32
DOCS/todo.md
32
DOCS/todo.md
@@ -1,15 +1,17 @@
|
||||
1. [x] Na liście zamówień powiększenie zdjęcia produktu na hover nie na onclick, wtedy to nie może być modal zamykany X
|
||||
2. [x] Dodać rejestrację historii zamówień, i zmiana statusu rejestrowana w Historii zmian zamĂłwienia
|
||||
3. [x] Pobranie zamĂłwienia rejestrowane w histori zmian zamĂłwienia
|
||||
4. [x] Przy imporcie zamówień musi być pobierania forma wysyłki.
|
||||
5. [x] W szczególach zamówienia dorobić opcję zmiany statusu.
|
||||
6. [x] W szczególach zamówienia 2 razy wyświetla się ID zamówienai z allegro, np: 008d3d60-1743-11f1-b15c-fdb4f87ccfc6
|
||||
7. [x] Przy imporcie z allegro liczba przesyłek jest 0.
|
||||
8. [x] Kolumna LP w szczególach zamówienia jest zbyt szeroka.
|
||||
9. [x] Na lisćie zamówień pole po którym jest domyślnie sortowana czyli data zamówienia jest puste.
|
||||
10. [x] Na liście zamówień ukryć kolumnę ostatnia zmiana.
|
||||
11. [x] W ustawieniach dodać zakładkę Integracja Apaczka. Dodać tam pierwsze ustawienie, czyli klucz API.
|
||||
12. [] synchronizować ręczną zmianę statusu z allegro
|
||||
13. [] W ustawieniach cron https://orderpro.projectpro.pl/settings/cron historia powinna mieć stronicowanie
|
||||
14. [] border inputów, select, textarea, itd zrób troszkę ciemniejszy
|
||||
|
||||
1. [x] Na liście zamówień powiększenie zdjęcia produktu na hover nie na onclick, wtedy to nie może być modal zamykany X
|
||||
2. [x] Doda<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
|
||||
|
||||
48
bin/cron.php
48
bin/cron.php
@@ -7,6 +7,9 @@ use App\Modules\Cron\AllegroStatusSyncHandler;
|
||||
use App\Modules\Cron\AllegroTokenRefreshHandler;
|
||||
use App\Modules\Cron\CronRepository;
|
||||
use App\Modules\Cron\CronRunner;
|
||||
use App\Modules\Cron\ShopproOrdersImportHandler;
|
||||
use App\Modules\Cron\ShopproPaymentStatusSyncHandler;
|
||||
use App\Modules\Cron\ShopproStatusSyncHandler;
|
||||
use App\Modules\Orders\OrderImportRepository;
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\AllegroApiClient;
|
||||
@@ -17,6 +20,12 @@ use App\Modules\Settings\AllegroOrderSyncStateRepository;
|
||||
use App\Modules\Settings\AllegroOAuthClient;
|
||||
use App\Modules\Settings\AllegroStatusSyncService;
|
||||
use App\Modules\Settings\AllegroStatusMappingRepository;
|
||||
use App\Modules\Settings\ShopproApiClient;
|
||||
use App\Modules\Settings\ShopproIntegrationsRepository;
|
||||
use App\Modules\Settings\ShopproOrdersSyncService;
|
||||
use App\Modules\Settings\ShopproPaymentStatusSyncService;
|
||||
use App\Modules\Settings\ShopproStatusSyncService;
|
||||
use App\Modules\Settings\ShopproStatusMappingRepository;
|
||||
|
||||
/** @var Application $app */
|
||||
$app = require dirname(__DIR__) . '/bootstrap/app.php';
|
||||
@@ -51,6 +60,33 @@ $ordersSyncService = new AllegroOrdersSyncService(
|
||||
$apiClient,
|
||||
$orderImportService
|
||||
);
|
||||
$shopproSyncService = new ShopproOrdersSyncService(
|
||||
new ShopproIntegrationsRepository(
|
||||
$app->db(),
|
||||
(string) $app->config('app.integrations.secret', '')
|
||||
),
|
||||
new AllegroOrderSyncStateRepository($app->db()),
|
||||
new ShopproApiClient(),
|
||||
new OrderImportRepository($app->db()),
|
||||
new ShopproStatusMappingRepository($app->db()),
|
||||
new OrdersRepository($app->db())
|
||||
);
|
||||
$shopproStatusSyncService = new ShopproStatusSyncService(
|
||||
new ShopproIntegrationsRepository(
|
||||
$app->db(),
|
||||
(string) $app->config('app.integrations.secret', '')
|
||||
),
|
||||
$shopproSyncService
|
||||
);
|
||||
$shopproPaymentSyncService = new ShopproPaymentStatusSyncService(
|
||||
new ShopproIntegrationsRepository(
|
||||
$app->db(),
|
||||
(string) $app->config('app.integrations.secret', '')
|
||||
),
|
||||
new ShopproApiClient(),
|
||||
new OrdersRepository($app->db()),
|
||||
$app->db()
|
||||
);
|
||||
|
||||
$runner = new CronRunner(
|
||||
$cronRepository,
|
||||
@@ -66,9 +102,19 @@ $runner = new CronRunner(
|
||||
'allegro_status_sync' => new AllegroStatusSyncHandler(
|
||||
new AllegroStatusSyncService(
|
||||
$cronRepository,
|
||||
$ordersSyncService
|
||||
$orderImportService,
|
||||
$app->db()
|
||||
)
|
||||
),
|
||||
'shoppro_orders_import' => new ShopproOrdersImportHandler(
|
||||
$shopproSyncService
|
||||
),
|
||||
'shoppro_order_status_sync' => new ShopproStatusSyncHandler(
|
||||
$shopproStatusSyncService
|
||||
),
|
||||
'shoppro_payment_status_sync' => new ShopproPaymentStatusSyncHandler(
|
||||
$shopproPaymentSyncService
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')) ?>
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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">«</a>
|
||||
<a class="pagination__item<?= $pastPage <= 1 ? ' is-disabled' : '' ?>" href="/settings/cron?past_page=<?= $e((string) max(1, $pastPage - 1)) ?>">‹</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)) ?>">›</a>
|
||||
<a class="pagination__item<?= $pastPage >= $pastTotalPages ? ' is-disabled' : '' ?>" href="/settings/cron?past_page=<?= $e((string) $pastTotalPages) ?>">»</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>
|
||||
|
||||
58
resources/views/settings/integrations.php
Normal file
58
resources/views/settings/integrations.php
Normal 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>
|
||||
677
resources/views/settings/shoppro.php
Normal file
677
resources/views/settings/shoppro.php
Normal 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>
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>>
|
||||
*/
|
||||
|
||||
26
src/Modules/Cron/ShopproOrdersImportHandler.php
Normal file
26
src/Modules/Cron/ShopproOrdersImportHandler.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
24
src/Modules/Cron/ShopproPaymentStatusSyncHandler.php
Normal file
24
src/Modules/Cron/ShopproPaymentStatusSyncHandler.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
22
src/Modules/Cron/ShopproStatusSyncHandler.php
Normal file
22
src/Modules/Cron/ShopproStatusSyncHandler.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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 : '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
65
src/Modules/Settings/IntegrationSecretCipher.php
Normal file
65
src/Modules/Settings/IntegrationSecretCipher.php
Normal 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;
|
||||
}
|
||||
}
|
||||
169
src/Modules/Settings/IntegrationsHubController.php
Normal file
169
src/Modules/Settings/IntegrationsHubController.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
155
src/Modules/Settings/IntegrationsRepository.php
Normal file
155
src/Modules/Settings/IntegrationsRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
263
src/Modules/Settings/ShopproApiClient.php
Normal file
263
src/Modules/Settings/ShopproApiClient.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
104
src/Modules/Settings/ShopproDeliveryMethodMappingRepository.php
Normal file
104
src/Modules/Settings/ShopproDeliveryMethodMappingRepository.php
Normal 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 : [];
|
||||
}
|
||||
}
|
||||
868
src/Modules/Settings/ShopproIntegrationsController.php
Normal file
868
src/Modules/Settings/ShopproIntegrationsController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
591
src/Modules/Settings/ShopproIntegrationsRepository.php
Normal file
591
src/Modules/Settings/ShopproIntegrationsRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
1074
src/Modules/Settings/ShopproOrdersSyncService.php
Normal file
1074
src/Modules/Settings/ShopproOrdersSyncService.php
Normal file
File diff suppressed because it is too large
Load Diff
404
src/Modules/Settings/ShopproPaymentStatusSyncService.php
Normal file
404
src/Modules/Settings/ShopproPaymentStatusSyncService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
99
src/Modules/Settings/ShopproStatusMappingRepository.php
Normal file
99
src/Modules/Settings/ShopproStatusMappingRepository.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/Modules/Settings/ShopproStatusSyncService.php
Normal file
63
src/Modules/Settings/ShopproStatusSyncService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user