diff --git a/AGENTS.md b/AGENTS.md index 730c8be..66c39d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,4 +28,4 @@ To ma pomóc zachować spójność zmian i dokumentacji. ## INNE -Przejdźmy teraz do refaktoringu wszystkiego co związane z https://shoppro.project-dc.pl/admin/articles_archive/, nowe widoki, klasy (usuwanie starych), poprawa routingu, przeszukanie innych klas pod względem zależności. Zapisz plan a później realizuj krok po kroku. \ No newline at end of file +Przejdźmy teraz do refaktoringu wszystkiego co związane z https://shoppro.project-dc.pl/admin/articles_archive/, nowe widoki, klasy (usuwanie starych), poprawa routingu, przeszukanie innych klas pod względem zależności. Zapisz plan i przedstaw mi go a po akceptacji realizuj krok po kroku w trybie Human In The Loop \ No newline at end of file diff --git a/DATABASE_STRUCTURE.md b/DATABASE_STRUCTURE.md index 44c989e..6cacc26 100644 --- a/DATABASE_STRUCTURE.md +++ b/DATABASE_STRUCTURE.md @@ -19,7 +19,7 @@ Główna tabela produktów. | vat | Stawka VAT | | ean | Kod EAN | | sku | Kod SKU | -| baselinker_product_name | Nazwa produktu w Baselinker | +| apilo_product_id | ID produktu w Apilo | | apilo_product_name | Nazwa produktu w Apilo | **Używane w:** `Domain\Product\ProductRepository`, `admin\factory\ShopProduct` @@ -319,3 +319,27 @@ Tlumaczenia kontenerow statycznych (per jezyk). **Aktualizacja 2026-02-12 (ver. 0.259):** modul `/admin/scontainers` korzysta z `Domain\Scontainers\ScontainersRepository` (DI kontroler + fasada legacy). **Aktualizacja 2026-02-12 (ver. 0.260):** modul `/admin/articles_archive` korzysta z `Domain\Article\ArticleRepository` (`listArchivedForAdmin`, `restore`, `deletePermanently`) przez `admin\Controllers\ArticlesArchiveController`. + +## pp_shop_apilo_settings +Ustawienia integracji Apilo (key-value). + +| Kolumna | Opis | +|---------|------| +| id | PK | +| name | Klucz ustawienia (np. client-id, access-token) | +| value | Wartosc ustawienia | + +**Uzywane w:** `Domain\Integrations\IntegrationsRepository`, `admin\Controllers\IntegrationsController`, `admin\factory\Integrations` + +## pp_shop_shoppro_settings +Ustawienia integracji ShopPRO (key-value). + +| Kolumna | Opis | +|---------|------| +| id | PK | +| name | Klucz ustawienia (np. domain, db_name) | +| value | Wartosc ustawienia | + +**Uzywane w:** `Domain\Integrations\IntegrationsRepository`, `admin\Controllers\IntegrationsController`, `admin\factory\Integrations` + +**Aktualizacja 2026-02-13:** modul `/admin/integrations/` korzysta z `Domain\Integrations\IntegrationsRepository` (DI kontroler + fasada legacy). Usunieto integracje Sellasist i Baselinker. diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index 7b07d2c..ce15768 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -57,26 +57,13 @@ shop\product:{product_id}:{lang_id}:{permutation_hash} ### Plik: `cron.php` -#### Sellasist -- **Aktualizacja produktów:** Linia 111-149 -- **Funkcje:** Aktualizacja cen i stanów magazynowych -- **Częstotliwość:** Co 10 minut -- **Czyszczenie cache:** Linia 146 - #### Apilo -- **Aktualizacja pojedynczego produktu:** Linia 152-176 - - Częstotliwość: Co 10 minut - - Czyszczenie cache: Linia 173 +- **Aktualizacja pojedynczego produktu:** synchronizacja cen i stanow + - Czestotliwosc: Co 10 minut +- **Synchronizacja cennika:** masowa aktualizacja cen z Apilo + - Czestotliwosc: Co 1 godzine -- **Synchronizacja cennika:** Linia 179-218 - - Częstotliwość: Co 1 godzinę - - Czyszczenie cache: Linia 212 - -#### Baselinker -- **Aktualizacja produktów:** Linia 220-289 -- **Funkcje:** Ceny, stany magazynowe, wagi -- **Częstotliwość:** Co 24 godziny (1440 minut) -- **Czyszczenie cache:** Linia 278 +**Uwaga:** Integracje Sellasist i Baselinker zostaly usuniete w ver. 0.263. ## Panel Administratora @@ -130,9 +117,10 @@ shopPRO/ ### Tabele integracji - Kolumny w `pp_shop_products`: - - `sellasist_product_id`, `sellasist_get_data_date` - - `apilo_product_id`, `apilo_get_data_date` - - `baselinker_product_id`, `baselinker_get_data_date` + - `apilo_product_id`, `apilo_product_name`, `apilo_get_data_date` +- Tabele ustawien: + - `pp_shop_apilo_settings` (key-value) + - `pp_shop_shoppro_settings` (key-value) ## Konfiguracja @@ -387,9 +375,9 @@ Aktualnie w suite są też testy modułów `Dictionaries`, `Articles` i `Users` - **UPDATE:** widoki Users przeniesione z `grid/gridEdit` na `components/table-list` i `components/form-edit` ## Aktualizacja 2026-02-12 (finalizacja Users) -- Modu users dziaa na `Domain\\User\\UserRepository` + `admin\\Controllers\\UsersController`. -- Usunito legacy klasy: `autoload/admin/controls/class.Users.php`, `autoload/admin/factory/class.Users.php`, `autoload/admin/view/class.Users.php`. -- Walidacja: przy wczonym 2FA pole `twofa_email` jest wymagane. +- Modu� users dzia�a na `Domain\\User\\UserRepository` + `admin\\Controllers\\UsersController`. +- Usuni�to legacy klasy: `autoload/admin/controls/class.Users.php`, `autoload/admin/factory/class.Users.php`, `autoload/admin/view/class.Users.php`. +- Walidacja: przy w��czonym 2FA pole `twofa_email` jest wymagane. - Widoki users przeniesione na `components/table-list` i `components/form-edit`. - **NOWE:** `Domain\\Languages\\LanguagesRepository` - repozytorium jezykow i tlumaczen (lista, zapis, usuwanie, max_order) - **NOWE:** `admin\\Controllers\\LanguagesController` - kontroler DI (`list/view_list`, `language_*`, `translation_*`) @@ -479,3 +467,11 @@ Aktualnie w suite są też testy modułów `Dictionaries`, `Articles` i `Users` - UPDATE: routing DI (`admin\\Site`) ma fabryke kontrolera `Pages`. - UPDATE: zalezne endpointy `cookie_*` i `generate_seo_link` przepiete na `/admin/pages/*`. - CLEANUP: usuniete legacy pliki `autoload/admin/controls/class.Pages.php`, `autoload/admin/view/class.Pages.php`, `autoload/admin/factory/class.Pages.php`, `admin/ajax/pages.php`. + +## Aktualizacja 2026-02-13 (Integrations refactor, ver. 0.263) +- NOWE: `Domain\Integrations\IntegrationsRepository` (settings Apilo/ShopPRO, OAuth, product linking, API fetch). +- NOWE: `admin\Controllers\IntegrationsController` (DI) dla akcji Apilo (settings, authorization, fetch lists, product CRUD) i ShopPRO (settings, product import). +- UPDATE: `admin\factory\Integrations` jako fasada delegujaca do repozytorium (tylko Apilo + ShopPRO). +- CLEANUP: **usunieto integracje Sellasist i Baselinker z calego projektu** - kontrolery, factory, szablony, referencje w cron.php, Order, ShopStatuses, ShopTransport, ShopPaymentMethod, ShopProduct, config.php, front/factory/*. +- CLEANUP: usuniete pliki: `autoload/admin/controls/class.Integrations.php`, `autoload/admin/controls/class.Baselinker.php`, `autoload/admin/factory/class.Baselinker.php`, `autoload/front/factory/class.Shop.php`, `autoload/shop/class.ShopStatus.php`, szablony sellasist/baselinker. +- Testy: **OK (212 tests, 577 assertions)**. diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 2addebf..dd34300 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -260,12 +260,23 @@ grep -r "Product::getQuantity" . - Legacy cleanup: usuniety `autoload/admin/controls/class.Users.php` - Testy: 25 testow repozytorium (CRUD, logon, 2FA, checkLogin) + 12 testow kontrolera (kontrakty + normalizeUser) +- **Integrations** (migracja kontrolera i repozytorium + cleanup Sellasist/Baselinker) + - ✅ IntegrationsRepository - **ZMIGROWANE** (2026-02-13) 🎉 + - Nowa klasa: `Domain\Integrations\IntegrationsRepository` (settings Apilo/ShopPRO, OAuth, product linking, API fetch) + - Nowy kontroler: `admin\Controllers\IntegrationsController` (DI, instancyjny) + - Router: `admin\Site` - factory wpis dla modulu `Integrations` + - Fasada: `admin\factory\Integrations` deleguje do repozytorium (tylko Apilo + ShopPRO) + - **CLEANUP:** usunieto integracje Sellasist i Baselinker z calego projektu + - Usuniete pliki: `controls/Integrations`, `controls/Baselinker`, `factory/Baselinker`, `front/factory/Shop`, `shop/ShopStatus`, szablony sellasist/baselinker + - Wyczyszczone referencje w: cron.php, Order, ShopStatuses, ShopTransport, ShopPaymentMethod, ShopProduct, config.php, front/factory/* + - Testy: 16 nowych testow (repozytorium) + 10 testow kontrolera + - Aktualizacja: ver. 0.263 + ### 📋 Do zrobienia - Order - Category - ShopAttribute - ShopProduct (factory) -- Pages (`browse_list` i widoki drzewiaste nadal w legacy `admin\controls` / `admin\view`) ## Testowanie @@ -286,17 +297,19 @@ tests/ │ │ ├── Dictionaries/DictionariesRepositoryTest.php │ │ ├── Product/ProductRepositoryTest.php │ │ ├── Settings/SettingsRepositoryTest.php -│ │ └── User/UserRepositoryTest.php +│ │ ├── User/UserRepositoryTest.php +│ │ └── Integrations/IntegrationsRepositoryTest.php │ └── admin/ │ └── Controllers/ │ ├── ArticlesControllerTest.php │ ├── DictionariesControllerTest.php +│ ├── IntegrationsControllerTest.php │ ├── ProductArchiveControllerTest.php │ ├── SettingsControllerTest.php │ └── UsersControllerTest.php └── Integration/ ``` -**Łącznie: 119 testów, 256 asercji** +**Łącznie: 212 testów, 577 asercji** ### Przykład testu ```php @@ -381,10 +394,11 @@ vendor/bin/phpstan analyse autoload/Domain 6. **ProductArchive** ✅ (migracja kontrolera + cleanup szablonów, ver. 0.252) 7. **Filemanager** ✅ (migracja routingu + fix `Invalid Key`, ver. 0.252) 8. **Users** ✅ (repo + kontroler + 2FA + legacy cleanup, ver. 0.253) -9. **Order** -10. **Category** -11. **ShopAttribute** -12. **Pages** (`browse_list` i powiązane widoki nadal legacy) +9. **Pages** ✅ (repo + kontroler + drzewo stron + AJAX endpoints, ver. 0.262) +10. **Integrations** ✅ (repo + kontroler + cleanup Sellasist/Baselinker, ver. 0.263) +11. **Order** +12. **Category** +13. **ShopAttribute** - **Form Edit System** - Nowy uniwersalny system formularzy edycji - ✅ Klasy ViewModel: `FormFieldType`, `FormField`, `FormTab`, `FormAction`, `FormEditViewModel` @@ -589,7 +603,7 @@ Gdy `persist = true`: - **NOWE:** `admin\\Controllers\\LanguagesController` (DI) dla akcji `view_list/list`, `language_*`, `translation_*` - **UPDATE:** `admin\\factory\\Languages` jako fasada delegujaca do repozytorium - **CLEANUP:** usunieto legacy `admin\\controls\\Languages` oraz `admin\\view\\Languages` -- **UPDATE:** poprawki globalne `components/table-list` dla krotkich kolumn/filtrw +- **UPDATE:** poprawki globalne `components/table-list` dla krotkich kolumn/filtr�w ## Aktualizacja 2026-02-12 (ver. 0.255) - UPDATE: SettingsController, BannerController, DictionariesController, ArticlesController pobieraja liste jezykow przez Domain/Languages/LanguagesRepository (DI) zamiast legacy admin/factory/Languages. @@ -700,3 +714,14 @@ Gdy `persist = true`: - UPDATE: endpointy zalezne od Pages w innych modulach (`articles`, `layouts`, `shop-category`, `shop-product`) przepiete z `admin/ajax.php?a=*` na `/admin/pages/*`. - CLEANUP: usuniete `autoload/admin/controls/class.Pages.php`, `autoload/admin/view/class.Pages.php`, `autoload/admin/factory/class.Pages.php`, `admin/ajax/pages.php`; `admin/ajax.php` nie includuje juz `ajax/pages.php`. - Testy: **OK (186 tests, 478 assertions)**. + +## Aktualizacja 2026-02-13 - Integrations (/admin/integrations) +- NOWE: `Domain\Integrations\IntegrationsRepository` (settings Apilo/ShopPRO, OAuth, product linking, API fetch lists, product search/create, ShopPRO import). +- NOWE: `admin\Controllers\IntegrationsController` (DI) dla akcji: `apilo_settings`, `apilo_settings_save`, `apilo_authorization`, `get_platform_list`, `get_status_types_list`, `get_carrier_account_list`, `get_payment_types_list`, `apilo_create_product`, `apilo_product_search`, `apilo_product_select_save`, `apilo_product_select_delete`, `shoppro_settings`, `shoppro_settings_save`, `shoppro_product_import`. +- UPDATE: `admin\factory\Integrations` jako fasada delegujaca do `Domain\Integrations\IntegrationsRepository` (tylko Apilo + ShopPRO). +- **CLEANUP: usunieto integracje Sellasist i Baselinker z calego projektu:** + - Usuniete klasy: `admin\controls\Integrations`, `admin\controls\Baselinker`, `admin\factory\Baselinker`, `front\factory\Shop`, `shop\ShopStatus` + - Usuniete szablony: `integrations/sellasist-settings.php`, `integrations/baselinker-settings.php`, `admin/templates/baselinker/` + - Wyczyszczone referencje w: `cron.php`, `cron/cron-xml.php`, `shop\Order`, `admin\controls\ShopStatuses`, `admin\controls\ShopTransport`, `admin\controls\ShopPaymentMethod`, `admin\controls\ShopProduct`, `admin\factory\ShopStatuses`, `admin\factory\ShopTransport`, `admin\factory\ShopProduct`, `front\factory\ShopStatuses`, `front\factory\ShopTransport`, `front\factory\ShopPaymentMethod`, `front\factory\ShopProduct`, `front\factory\ShopOrder`, `shop\Product`, `config.php` + - Wyczyszczone szablony: `shop-statuses/*`, `shop-transport/*`, `shop-payment-method/*`, `shop-product/*`, `site/main-layout.php` +- Testy: **OK (212 tests, 577 assertions)**. diff --git a/TESTING.md b/TESTING.md index 7978a3b..5e0ed91 100644 --- a/TESTING.md +++ b/TESTING.md @@ -52,11 +52,13 @@ tests/ | | |-- Dictionaries/DictionariesRepositoryTest.php | | |-- Product/ProductRepositoryTest.php | | |-- Settings/SettingsRepositoryTest.php -| | `-- User/UserRepositoryTest.php +| | |-- User/UserRepositoryTest.php +| | `-- Integrations/IntegrationsRepositoryTest.php | `-- admin/ | `-- Controllers/ | |-- ArticlesControllerTest.php | |-- DictionariesControllerTest.php +| |-- IntegrationsControllerTest.php | |-- ProductArchiveControllerTest.php | |-- SettingsControllerTest.php | `-- UsersControllerTest.php @@ -281,3 +283,17 @@ Nowe testy dodane 2026-02-13: Zaktualizowane testy 2026-02-13: - `tests/Unit/admin/Controllers/ArticlesControllerTest.php` (konstruktor z `Domain\\Pages\\PagesRepository`) + +## Aktualizacja suite (Integrations refactor, ver. 0.263) +Ostatnio zweryfikowano: 2026-02-13 + +```text +OK (212 tests, 577 assertions) +``` + +Nowe testy dodane 2026-02-13: +- `tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php` (16 testow: getSettings, getSetting, saveSetting, linkProduct, unlinkProduct, getProductSku, apiloGetAccessToken, invalid provider, settings table mapping) +- `tests/Unit/admin/Controllers/IntegrationsControllerTest.php` (10 testow: kontrakty metod, return types, brak metod sellasist/baselinker) + +Zaktualizowane pliki: +- `tests/bootstrap.php` (dodany stub `S::remove_special_chars()`) diff --git a/admin/templates/baselinker/bundling-products.php b/admin/templates/baselinker/bundling-products.php deleted file mode 100644 index d434df2..0000000 --- a/admin/templates/baselinker/bundling-products.php +++ /dev/null @@ -1,33 +0,0 @@ -
\ No newline at end of file diff --git a/admin/templates/integrations/baselinker-settings.php b/admin/templates/integrations/baselinker-settings.php deleted file mode 100644 index 0ca02b2..0000000 --- a/admin/templates/integrations/baselinker-settings.php +++ /dev/null @@ -1,139 +0,0 @@ -Znaleziono ' + data.products.length + ' produktów
'; - html += ''; - html += ''; - html += 'Znaleziono ' + data.products.length + ' produktów
'; - html += 'Nie znaleziono produktów
'; - // button to create product on baselinker html += 'Utwórz produkt'; html += 'Nie znaleziono produktów
'; - // button to create product on baselinker - html += 'Utwórz produkt'; - html += 'Znaleziono ' + data.products.length + ' produktów
'; - html += 'Zaktualizowałem dane produktu ' . $result['sellasist_product_name'] . ' #' . $result['id'] . '
'; - } -} - // pobieranie informacji o produkcie z apilo.com if ( $apilo_settings['enabled'] and $apilo_settings['sync_products'] and $apilo_settings['access-token'] ) { @@ -230,233 +185,6 @@ if ( $apilo_settings['enabled'] and $apilo_settings['access-token'] and ( !$apil echo 'Zaktualizowałem ceny produktów (APILO)
'; } -// pobieranie informachji o produkcie w tym: cen, cen promocyjnych, wagi, stanów magazynowych -if ( $baselinker_settings['enabled'] and $baselinker_settings['sync_products'] and $baselinker_settings['api_code'] ) -{ - if ( $result = $mdb -> query( 'SELECT id, baselinker_product_id, baselinker_get_data_date FROM pp_shop_products WHERE baselinker_product_id IS NOT NULL AND baselinker_product_id != 0 AND ( baselinker_get_data_date IS NULL OR baselinker_get_data_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-1440 minutes', time() ) ) . '\' ) ORDER BY baselinker_get_data_date ASC LIMIT 1' ) -> fetch( \PDO::FETCH_ASSOC ) ) - { - $methodParams = '{ - "inventory_id": "' . $baselinker_settings['inventory_id'] . '", - "products": [' . $result['baselinker_product_id'] . '] - }'; - - $apiParams = [ - "token" => $baselinker_settings['api_code'], - "method" => "getInventoryProductsData", - "parameters" => $methodParams - ]; - - $curl = curl_init( "https://api.baselinker.com/connector.php" ); - curl_setopt( $curl, CURLOPT_POST, 1 ); - curl_setopt( $curl, CURLOPT_POSTFIELDS, http_build_query( $apiParams ) ); - curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true ); - $response = json_decode( curl_exec( $curl ), true ); - - $i = 0; - - if ( $response['status'] == 'SUCCESS' and count( $response['products'] ) ) - { - foreach ( $response['products'] as $baselinker_product_id => $baselinker_product ) - { - // aktualizowanie ceny - if ( $vat = $mdb -> get( 'pp_shop_products', 'vat', [ 'baselinker_product_id' => $baselinker_product_id ] ) ) - { - $price_brutto = $baselinker_product['prices'][$baselinker_settings['price_group']]; - $price_netto = $baselinker_product['prices'][$baselinker_settings['price_group']] / ( ( 100 + $vat ) / 100 ); - - $price_brutto_promo = $baselinker_product['prices'][ $baselinker_settings['price_group_promo'] ]; - - if ( $price_brutto_promo and $price_brutto_promo != $price_brutto ) - { - $price_netto_promo = $baselinker_product['prices'][$baselinker_settings['price_group_promo']] / ( ( 100 + $vat ) / 100 ); - $mdb -> update( 'pp_shop_products', [ 'price_netto_promo' => \S::normalize_decimal( $price_netto_promo, 2 ), 'price_brutto_promo' => \S::normalize_decimal( $price_brutto_promo, 2 ) ], [ 'baselinker_product_id' => $baselinker_product_id ] ); - } - else - $mdb -> update( 'pp_shop_products', [ 'price_netto_promo' => null, 'price_brutto_promo' => null ], [ 'baselinker_product_id' => $baselinker_product_id ] ); - - $mdb -> update( 'pp_shop_products', [ 'price_netto' => \S::normalize_decimal( $price_netto, 2 ), 'price_brutto' => \S::normalize_decimal( $price_brutto, 2 ) ], [ 'baselinker_product_id' => $baselinker_product_id ] ); - - $product_id = $mdb -> get( 'pp_shop_products', 'id', [ 'baselinker_product_id' => $baselinker_product_id ] ); - $vat = $mdb -> get( 'pp_shop_products', 'vat', [ 'baselinker_product_id' => $baselinker_product_id ] ); - - \admin\factory\ShopProduct::update_product_combinations_prices( (int)$product_id, $price_brutto, $vat, $price_brutto_promo ); - } - - // aktualizowanie wagi - $mdb -> update( 'pp_shop_products', [ 'weight' => $baselinker_product['weight'] ], [ 'baselinker_product_id' => $baselinker_product_id ] ); - - // aktualizowanie stanu magazynowego - $mdb -> update( 'pp_shop_products', [ 'quantity' => $baselinker_product['stock'][$baselinker_settings['stock_id']] ], [ 'baselinker_product_id' => $baselinker_product_id ] ); - - $mdb -> update( 'pp_shop_products', [ 'baselinker_get_data_date' => date( 'Y-m-d H:i:s' ) ], [ 'baselinker_product_id' => $baselinker_product_id ] ); - - // Czyszczenie cache produktu - \S::clear_product_cache( (int)$result['id'] ); - - echo 'Zaktualizowałem dane produktu ' . $baselinker_product['text_fields']['name'] . ' #' . $result['id'] . '
'; - } - } - else - { - $mdb -> update( 'pp_shop_products', [ 'baselinker_get_data_date' => date( 'Y-m-d H:i:s' ) ], [ 'baselinker_product_id' => $baselinker_product_id ] ); - echo 'Z powodu błędu pominąłem produkt o ID: ' . $result['id'] . '
'; - } - } -} - -// sprawdzanie statusów zamówień w sellasist.pl jeżeli zamówienie nie jest zrealizowane, anulowane lub nieodebrane -if ( $sellasist_settings['enabled'] and $sellasist_settings['sync_orders'] and $sellasist_settings['api_code'] and $sellasist_settings['sync_orders_date_start'] <= date( 'Y-m-d H:i:s' ) ) -{ - $order = $mdb -> query( 'SELECT id, sellasist_order_id, sellasist_order_status_date, number FROM pp_shop_orders WHERE sellasist_order_id IS NOT NULL AND sellasist_order_id != 0 AND ( status != 6 AND status != 8 AND status != 9 ) AND ( sellasist_order_status_date IS NULL OR sellasist_order_status_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-30 minutes', time() ) ) . '\' ) ORDER BY sellasist_order_status_date ASC LIMIT 1' ) -> fetch( \PDO::FETCH_ASSOC ); - if ( $order['sellasist_order_id'] ) - { - $url = "https://projectpro.sellasist.pl/api/v1/orders/" . $order['sellasist_order_id'] . "/"; - - $api_code = \admin\factory\Integrations::sellasist_settings( 'api_code' ); - - $ch = curl_init( $url ); - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); - curl_setopt( $ch, CURLOPT_HTTPHEADER, [ - "apiKey: " . $api_code, - "accept: application/json" - ] ); - - $response = curl_exec( $ch ); - $responseData = json_decode( $response, true ); - - if ( $responseData['id'] and $responseData['status']['id'] ) { - $shop_status_id = \front\factory\ShopStatuses::get_shop_status_by_integration_status_id( 'sellasist', $responseData['status']['id'] ); - $mdb -> update( 'pp_shop_orders', [ 'status' => $shop_status_id, 'sellasist_order_status_date' => date( 'Y-m-d H:i:s' ) ], [ 'id' => $order['id'] ] ); - echo 'Zaktualizowałem status zamówienia ' . $order['number'] . '
'; - } - } -} - -// wysyłanie zamówień do sellasist.pl -if ( $sellasist_settings['enabled'] and $sellasist_settings['sync_orders'] and $sellasist_settings['api_code'] and $sellasist_settings['sync_orders_date_start'] <= date( 'Y-m-d H:i:s' ) ) -{ - $orders = $mdb -> select( 'pp_shop_orders', '*', [ 'AND' => [ 'sellasist_order_id' => null, 'date_order[<=]' => date( 'Y-m-d H:i:s', strtotime( '-1 minutes', time() ) ), 'date_order[>=]' => $sellasist_settings['sync_orders_date_start'] ], 'ORDER' => [ 'date_order' => 'ASC' ], 'LIMIT' => 1 ] ); - foreach ( $orders as $order ) - { - $z = 0; - - $products = $mdb -> select( 'pp_shop_order_products', '*', [ 'order_id' => $order['id'] ] ); - $products_array = []; - foreach ( $products as $product ) - { - $json_data['carts'][] = [ - 'id' => $product['product_id'], - 'product_id' => \front\factory\ShopProduct::get_sellasist_product_id( $product['product_id'] ), - 'name' => $product['name'], - 'quantity' => $product['quantity'], - 'price' => $product['price_brutto_promo'] ? $product['price_brutto_promo'] : $product['price_brutto'], - 'message' => strip_tags( $product['attributes'] ) . ' | ' . $product['message'] - ]; - } - - $json_data['id'] = $order['id']; - $json_data['currency'] = 'pln'; - $json_data['payment_status'] = $order['paid'] ? 'paid' : 'unpaid'; - $json_data['paid'] = $order['paid'] ? str_replace( ',', '.', $order['summary'] ) : 0; - $json_data['status'] = \front\factory\ShopStatuses::get_sellasist_status_id( $order['status'] ); - $json_data['email'] = $order['client_email']; - // date - $json_data['date'] = date( 'Y-m-d H:i:s', strtotime( $order['date_order'] ) ); - // shipment_price - $json_data['shipment_price'] = $order['transport_cost']; - // payment_id - $json_data['payment_id'] = \front\factory\ShopPaymentMethod::get_sellasist_payment_method_id( $order['payment_method_id'] ); - // payment_name - $json_data['payment_name'] = $order['payment_method']; - // shipment_id - $json_data['shipment_id'] = \front\factory\ShopTransport::get_sellasist_transport_id( $order['transport_id'] ); - // shipment_name - $json_data['shipment_name'] = strip_tags( $order['transport'] ); - // invoice - $json_data['invoice'] = 0; - // comment - $json_data['comment'] = $order['message']; - // bill_address - $json_data['bill_address'] = [ - 'name' => $order['client_name'], - 'surname' => $order['client_surname'], - 'street' => $order['client_street'], - 'city' => $order['client_city'], - 'postcode' => $order['client_postal_code'], - 'phone' => $order['client_phone'], - 'email' => $order['client_email'], - 'country' => [ - 'id' => 170, - 'name' => 'Poland', - 'code' => 'PL' - ] - ]; - // shipment_address - $json_data['shipment_address'] = [ - 'name' => $order['client_name'], - 'surname' => $order['client_surname'], - 'street' => $order['client_street'], - 'city' => $order['client_city'], - 'postcode' => $order['client_postal_code'], - 'phone' => $order['client_phone'], - 'email' => $order['client_email'], - 'country' => [ - 'id' => 170, - 'name' => 'Poland', - 'code' => 'PL' - ] - ]; - - // pickup_point - if ( $order['inpost_paczkomat'] ) - { - $pickup = explode( ' | ', $order['inpost_paczkomat'] ); - $pickup_code = $pickup[0]; - $pickup_address = $pickup[1]; - - $json_data['pickup_point'] = [ - 'code' => $pickup_code, - 'type' => 'inpost', - 'address' => $pickup_address - ]; - } - - // URL docelowe - $url = "https://projectpro.sellasist.pl/api/v1/orders"; - - // Nagłówki - $headers = array( - "accept: application/json", - "apiKey: " . $sellasist_settings['api_code'], - "Content-Type: application/json" - ); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_POST, 1); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($json_data) ); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - $response = curl_exec($ch); - if (curl_errno($ch)) { - echo 'Błąd cURL: ' . curl_error($ch); - } - curl_close($ch); - $response = json_decode( $response, true ); - - if ( $response['status'] == 'exist' ) { - - $mdb -> update( 'pp_shop_orders', [ 'sellasist_order_id' => $response['order_id'] ], [ 'id' => $order['id'] ] ); - - } else if ( $response['id'] ) { - - $mdb -> update( 'pp_shop_orders', [ 'sellasist_order_id' => $response['id'] ], [ 'id' => $order['id'] ] ); - echo 'Wysłałem zamówienie do sellasist.pl
'; - - } - } -} - // wysyłanie zamówień do apilo if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_settings['access-token'] and $apilo_settings['sync_orders_date_start'] <= date( 'Y-m-d H:i:s' ) ) { @@ -796,138 +524,6 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se } } -// sprawdzanie statusów zamówień w baselinker.com jeżeli zamówienie nie jest zrealizowane, anulowane lub nieodebrane -if ( $baselinker_settings['enabled'] and $baselinker_settings['sync_orders'] and $baselinker_settings['api_code'] and $baselinker_settings['sync_orders_date_start'] <= date( 'Y-m-d H:i:s' ) ) -{ - $order = $mdb -> query( 'SELECT id, baselinker_order_id, baselinker_order_status_date, number FROM pp_shop_orders WHERE baselinker_order_id IS NOT NULL AND baselinker_order_id != 0 AND ( status != 6 AND status != 8 AND status != 9 ) AND ( baselinker_order_status_date IS NULL OR baselinker_order_status_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-30 minutes', time() ) ) . '\' ) ORDER BY baselinker_order_status_date ASC LIMIT 1' ) -> fetch( \PDO::FETCH_ASSOC ); - if ( $order['baselinker_order_id'] ) - { - $methodParams = '{ - "order_id": ' . $order['baselinker_order_id'] . ' - }'; - - $apiParams = [ - "token" => $baselinker_settings['api_code'], - "method" => "getOrders", - "parameters" => $methodParams - ]; - - $curl = curl_init( "https://api.baselinker.com/connector.php" ); - curl_setopt( $curl, CURLOPT_POST, 1 ); - curl_setopt( $curl, CURLOPT_POSTFIELDS, http_build_query( $apiParams ) ); - curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true ); - $response = json_decode( curl_exec( $curl ), true ); - - if ( $response['status'] == 'SUCCESS' and count( $response['orders'] ) ) - { - $shop_status_id = \shop\ShopStatus::get_shop_status_by_baselinker_status( (int) $response['orders'][0]['order_status_id'] ); - - $order_tmp = new Order( $order['id'] ); - $order_tmp -> update_status( $shop_status_id, false ); - $order_tmp -> update_baselinker_order_status_date( date( 'Y-m-d H:i:s' ) ); - echo 'Zaktualizowałem status zamówienia ' . $order['number'] . '
'; - } - else - { - $mdb -> update( 'pp_shop_orders', [ 'baselinker_order_status_date' => date( 'Y-m-d H:i:s' ) ], [ 'id' => $order['id'] ] ); - echo 'Z powodu błędu pominąłem zamówienie o ID: ' . $order['id'] . '
'; - } - } -} - -// wysyłanie zamówień do baselinker -if ( $baselinker_settings['enabled'] and $baselinker_settings['sync_orders'] and $baselinker_settings['api_code'] and $baselinker_settings['sync_orders_date_start'] <= date( 'Y-m-d H:i:s' ) ) -{ - $orders = $mdb -> select( 'pp_shop_orders', '*', [ 'AND' => [ 'baselinker_order_id' => null, 'date_order[<=]' => date( 'Y-m-d H:i:s', strtotime( '-1 minutes', time() ) ), 'date_order[>=]' => $baselinker_settings['sync_orders_date_start'] ], 'ORDER' => [ 'date_order' => 'ASC' ], 'LIMIT' => 1 ] ); - foreach ( $orders as $order ) - { - if ( $order['transport_id'] == 2 ) - { - $pickup = explode( ' | ', $order['inpost_paczkomat'] ); - $pickup_name = $pickup[0]; - $pickup_address = $pickup[1]; - } - - $methodParams = '{ - "order_status_id": "' . \front\factory\ShopStatuses::get_baselinker_order_status_id( $order['status'] ) . '", - "date_add": "' . strtotime( $order['date_order'] ) . '", - "user_comments": "' . preg_replace('/\s+/', ' ', \S::remove_special_chars( $order['message'] ) ) . '", - "admin_comments": "' . $order['notes'] . '", - "phone": "' . $order['client_phone'] . '", - "email": "' . $order['client_email'] . '", - "user_login": "' . $order['client_name'] . ' ' . $order['client_surname'] . '", - "currency": "PLN", - "payment_method": "' . $order['payment_method'] . '", - "payment_method_cod": "' . ( $order['payment_method_id'] == 3 ? 1 : 0 ) . '", - "paid": "' . $order['paid'] . '", - "delivery_method": "' . strip_tags( $order['transport'] ) . '", - "delivery_price": "' . $order['transport_cost'] . '", - "delivery_fullname": "' . $order['client_name'] . ' ' . $order['client_surname'] . '", - "delivery_company": "' . $order['client_firm'] . '", - "delivery_address": "' . $order['client_street'] . '", - "delivery_city": "' . $order['client_city'] . '", - "delivery_postcode": "' . $order['client_postal_code'] . '", - "delivery_country_code": "PL", - "delivery_point_id": "' . $pickup_name . '", - "delivery_point_name": "' . ( $pickup_name != '' ? 'Paczkomat ' . $pickup_name : '' ) . '", - "delivery_point_address": "' . $pickup_address . '", - "delivery_point_postcode": "", - "delivery_point_city": "", - "invoice_fullname": "", - "invoice_company": "", - "invoice_nip": "", - "invoice_address": "", - "invoice_city": "", - "invoice_postcode": "", - "invoice_country_code": "", - "want_invoice": "0", - "extra_field_1": "", - "extra_field_2": "", - "products": ['; - $products = $mdb -> select( 'pp_shop_order_products', '*', [ 'order_id' => $order['id'] ] ); - foreach ( $products as $product ) - { - $methodParams .= '{ - "storage": "db", - "storage_id": 0, - "product_id": "' .\shop\Product::get_baselinker_product_id( (int)$product['product_id'] ) . '", - "variant_id": "", - "name": "' . htmlspecialchars( $product['name'] ) . '", - "sku": "' . \shop\Product::get_product_sku( (int)$product['product_id'] ) . '", - "ean": "", - "attributes": "' . strip_tags( $product['attributes'] ) . ' | ' . strip_tags( str_replace( 'Wysłałem zamówienie do Baselinker ' . $order['number'] . '
'; - } - } -} - /* zapisywanie historii cen produktów */ $results = $mdb -> select( 'pp_shop_products', [ 'id', 'price_brutto', 'price_brutto_promo' ], [ 'OR' => [ 'price_history_date[!]' => date( 'Y-m-d' ), 'price_history_date' => null ], 'ORDER' => [ 'price_history_date' => 'ASC' ], 'LIMIT' => 100 ] ); foreach ( $results as $row ) diff --git a/cron/cron-xml.php b/cron/cron-xml.php index 288d71c..6eb1f1e 100644 --- a/cron/cron-xml.php +++ b/cron/cron-xml.php @@ -51,7 +51,6 @@ $mdb = new medoo( [ ] ); $settings = \front\factory\Settings::settings_details(); -$baselinker_settings = \front\factory\Shop::baselinker_settings(); $lang_id = \front\factory\Languages::default_language(); \admin\factory\ShopProduct::generate_google_feed_xml(); \ No newline at end of file diff --git a/tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php b/tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php new file mode 100644 index 0000000..06ec914 --- /dev/null +++ b/tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php @@ -0,0 +1,204 @@ +mockDb = $this->createMock(\medoo::class); + $this->repository = new IntegrationsRepository($this->mockDb); + } + + public function testGetSettingsReturnsArray(): void + { + $stmt = $this->createMock(\PDOStatement::class); + $stmt->expects($this->once()) + ->method('fetchAll') + ->with(\PDO::FETCH_ASSOC) + ->willReturn([ + ['name' => 'client-id', 'value' => 'abc123'], + ['name' => 'client-secret', 'value' => 'secret'], + ]); + + $this->mockDb->expects($this->once()) + ->method('query') + ->with('SELECT * FROM pp_shop_apilo_settings') + ->willReturn($stmt); + + $settings = $this->repository->getSettings('apilo'); + + $this->assertIsArray($settings); + $this->assertSame('abc123', $settings['client-id']); + $this->assertSame('secret', $settings['client-secret']); + } + + public function testGetSettingReturnsValue(): void + { + $this->mockDb->expects($this->once()) + ->method('get') + ->with('pp_shop_apilo_settings', 'value', ['name' => 'client-id']) + ->willReturn('abc123'); + + $this->assertSame('abc123', $this->repository->getSetting('apilo', 'client-id')); + } + + public function testGetSettingReturnsNullWhenNotFound(): void + { + $this->mockDb->expects($this->once()) + ->method('get') + ->with('pp_shop_apilo_settings', 'value', ['name' => 'nonexistent']) + ->willReturn(false); + + $this->assertNull($this->repository->getSetting('apilo', 'nonexistent')); + } + + public function testSaveSettingUpdatesExistingValue(): void + { + $this->mockDb->expects($this->once()) + ->method('count') + ->with('pp_shop_apilo_settings', ['name' => 'client-id']) + ->willReturn(1); + + $this->mockDb->expects($this->once()) + ->method('update') + ->with('pp_shop_apilo_settings', ['value' => 'new-value'], ['name' => 'client-id']); + + $this->assertTrue($this->repository->saveSetting('apilo', 'client-id', 'new-value')); + } + + public function testSaveSettingInsertsNewValue(): void + { + $this->mockDb->expects($this->once()) + ->method('count') + ->with('pp_shop_shoppro_settings', ['name' => 'domain']) + ->willReturn(0); + + $this->mockDb->expects($this->once()) + ->method('insert') + ->with('pp_shop_shoppro_settings', ['name' => 'domain', 'value' => 'example.com']); + + $this->assertTrue($this->repository->saveSetting('shoppro', 'domain', 'example.com')); + } + + public function testInvalidProviderThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->repository->getSettings('sellasist'); + } + + public function testLinkProductUpdatesDatabase(): void + { + $this->mockDb->expects($this->once()) + ->method('update') + ->with( + 'pp_shop_products', + $this->callback(function ($data) { + return isset($data['apilo_product_id']) && isset($data['apilo_product_name']); + }), + ['id' => 42] + ) + ->willReturn(1); + + $this->assertTrue($this->repository->linkProduct(42, 'ext-123', 'Test Product')); + } + + public function testUnlinkProductClearsFields(): void + { + $this->mockDb->expects($this->once()) + ->method('update') + ->with( + 'pp_shop_products', + ['apilo_product_id' => null, 'apilo_product_name' => null], + ['id' => 42] + ) + ->willReturn(1); + + $this->assertTrue($this->repository->unlinkProduct(42)); + } + + public function testGetProductSkuReturnsValue(): void + { + $this->mockDb->expects($this->once()) + ->method('get') + ->with('pp_shop_products', 'sku', ['id' => 10]) + ->willReturn('SKU-100'); + + $this->assertSame('SKU-100', $this->repository->getProductSku(10)); + } + + public function testGetProductSkuReturnsNullForMissing(): void + { + $this->mockDb->expects($this->once()) + ->method('get') + ->with('pp_shop_products', 'sku', ['id' => 999]) + ->willReturn(false); + + $this->assertNull($this->repository->getProductSku(999)); + } + + public function testApiloGetAccessTokenReturnsNullWithoutSettings(): void + { + $stmt = $this->createMock(\PDOStatement::class); + $stmt->method('fetchAll')->willReturn([]); + + $this->mockDb->method('query')->willReturn($stmt); + + $this->assertNull($this->repository->apiloGetAccessToken()); + } + + public function testApiloFetchListThrowsForInvalidType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->repository->apiloFetchList('invalid'); + } + + public function testAllPublicMethodsExist(): void + { + $expectedMethods = [ + 'getSettings', 'getSetting', 'saveSetting', + 'linkProduct', 'unlinkProduct', + 'apiloAuthorize', 'apiloGetAccessToken', + 'apiloFetchList', 'apiloProductSearch', 'apiloCreateProduct', + 'getProductSku', 'shopproImportProduct', + ]; + + foreach ($expectedMethods as $method) { + $this->assertTrue( + method_exists($this->repository, $method), + "Method $method does not exist" + ); + } + } + + public function testSettingsTableMapping(): void + { + // Verify apilo maps correctly + $stmt = $this->createMock(\PDOStatement::class); + $stmt->method('fetchAll')->willReturn([]); + $this->mockDb->method('query') + ->with($this->stringContains('pp_shop_apilo_settings')) + ->willReturn($stmt); + + $this->assertIsArray($this->repository->getSettings('apilo')); + } + + public function testShopproProviderWorks(): void + { + $stmt = $this->createMock(\PDOStatement::class); + $stmt->method('fetchAll')->willReturn([ + ['name' => 'domain', 'value' => 'test.com'], + ]); + $this->mockDb->method('query') + ->with($this->stringContains('pp_shop_shoppro_settings')) + ->willReturn($stmt); + + $settings = $this->repository->getSettings('shoppro'); + $this->assertSame('test.com', $settings['domain']); + } +} diff --git a/tests/Unit/admin/Controllers/IntegrationsControllerTest.php b/tests/Unit/admin/Controllers/IntegrationsControllerTest.php new file mode 100644 index 0000000..79e87bd --- /dev/null +++ b/tests/Unit/admin/Controllers/IntegrationsControllerTest.php @@ -0,0 +1,171 @@ +repository = $this->createMock(IntegrationsRepository::class); + $this->controller = new IntegrationsController($this->repository); + } + + public function testConstructorAcceptsDependencies(): void + { + $controller = new IntegrationsController($this->repository); + $this->assertInstanceOf(IntegrationsController::class, $controller); + } + + public function testConstructorRequiresRepository(): void + { + $reflection = new \ReflectionClass(IntegrationsController::class); + $constructor = $reflection->getConstructor(); + $params = $constructor->getParameters(); + + $this->assertCount(1, $params); + $this->assertEquals( + 'Domain\Integrations\IntegrationsRepository', + $params[0]->getType()->getName() + ); + } + + public function testHasAllApiloSettingsMethods(): void + { + $methods = [ + 'apilo_settings', + 'apilo_settings_save', + 'apilo_authorization', + ]; + + foreach ($methods as $method) { + $this->assertTrue( + method_exists($this->controller, $method), + "Method $method does not exist" + ); + } + } + + public function testHasAllApiloDataFetchMethods(): void + { + $methods = [ + 'get_platform_list', + 'get_status_types_list', + 'get_carrier_account_list', + 'get_payment_types_list', + ]; + + foreach ($methods as $method) { + $this->assertTrue( + method_exists($this->controller, $method), + "Method $method does not exist" + ); + } + } + + public function testHasAllApiloProductMethods(): void + { + $methods = [ + 'apilo_create_product', + 'apilo_product_search', + 'apilo_product_select_save', + 'apilo_product_select_delete', + ]; + + foreach ($methods as $method) { + $this->assertTrue( + method_exists($this->controller, $method), + "Method $method does not exist" + ); + } + } + + public function testHasAllShopproMethods(): void + { + $methods = [ + 'shoppro_settings', + 'shoppro_settings_save', + 'shoppro_product_import', + ]; + + foreach ($methods as $method) { + $this->assertTrue( + method_exists($this->controller, $method), + "Method $method does not exist" + ); + } + } + + public function testApiloSettingsReturnsString(): void + { + $reflection = new \ReflectionClass($this->controller); + $this->assertEquals('string', (string) $reflection->getMethod('apilo_settings')->getReturnType()); + } + + public function testShopproSettingsReturnsString(): void + { + $reflection = new \ReflectionClass($this->controller); + $this->assertEquals('string', (string) $reflection->getMethod('shoppro_settings')->getReturnType()); + } + + public function testVoidReturnTypes(): void + { + $reflection = new \ReflectionClass($this->controller); + + $voidMethods = [ + 'apilo_settings_save', + 'apilo_authorization', + 'get_platform_list', + 'get_status_types_list', + 'get_carrier_account_list', + 'get_payment_types_list', + 'apilo_create_product', + 'apilo_product_search', + 'apilo_product_select_save', + 'apilo_product_select_delete', + 'shoppro_settings_save', + 'shoppro_product_import', + ]; + + foreach ($voidMethods as $method) { + $this->assertEquals( + 'void', + (string) $reflection->getMethod($method)->getReturnType(), + "Method $method should return void" + ); + } + } + + public function testDoesNotHaveSellasistMethods(): void + { + $reflection = new \ReflectionClass($this->controller); + $methods = array_map(fn($m) => $m->getName(), $reflection->getMethods()); + + foreach ($methods as $method) { + $this->assertStringNotContainsString( + 'sellasist', + strtolower($method), + "Controller should not have sellasist method: $method" + ); + } + } + + public function testDoesNotHaveBaselinkerMethods(): void + { + $reflection = new \ReflectionClass($this->controller); + $methods = array_map(fn($m) => $m->getName(), $reflection->getMethods()); + + foreach ($methods as $method) { + $this->assertStringNotContainsString( + 'baselinker', + strtolower($method), + "Controller should not have baselinker method: $method" + ); + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ada8d9d..9f04a03 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -50,6 +50,7 @@ if (!class_exists('S')) { public static function clear_redis_cache() {} public static function clear_product_cache($id) {} public static function send_email($to, $subject, $body) { return true; } + public static function remove_special_chars($str) { return str_ireplace(['\'', '"', ',', ';', '<', '>'], ' ', $str); } } } diff --git a/updates/0.20/ver_0.263.zip b/updates/0.20/ver_0.263.zip new file mode 100644 index 0000000..78b29a3 Binary files /dev/null and b/updates/0.20/ver_0.263.zip differ diff --git a/updates/0.20/ver_0.263_files.txt b/updates/0.20/ver_0.263_files.txt new file mode 100644 index 0000000..5743abe --- /dev/null +++ b/updates/0.20/ver_0.263_files.txt @@ -0,0 +1,8 @@ +F: ../admin/templates/baselinker/bundling-products.php +F: ../admin/templates/integrations/baselinker-settings.php +F: ../admin/templates/integrations/sellasist-settings.php +F: ../autoload/admin/controls/class.Baselinker.php +F: ../autoload/admin/controls/class.Integrations.php +F: ../autoload/admin/factory/class.Baselinker.php +F: ../autoload/front/factory/class.Shop.php +F: ../autoload/shop/class.ShopStatus.php diff --git a/updates/changelog.php b/updates/changelog.php index b0795e6..edffadc 100644 --- a/updates/changelog.php +++ b/updates/changelog.php @@ -1,4 +1,11 @@ -ver. 0.262 - 13.02.2026