feat: Implement pagination and filtering for linked offers by integration
- Refactored `listLinkedOffersByIntegration` to `paginateLinkedOffersByIntegration` in `MarketplaceRepository`. - Added pagination support with `page` and `per_page` filters. - Introduced sorting options for offers. - Created `listOfferChannelsByIntegration` method to retrieve distinct sales channels. - Enhanced SQL queries to support dynamic filtering based on provided parameters. feat: Add new fields for products and SKU generation - Introduced new fields: `new_to_date`, `additional_message`, `additional_message_required`, and `additional_message_text` in the `products` table. - Added `findAllSkus` method in `ProductRepository` to retrieve all SKUs. - Created `ProductSkuGenerator` class to handle SKU generation based on a configurable format. - Implemented `nextSku` method to generate the next available SKU. feat: Enhance product settings management in the UI - Added new settings page for product SKU format in `SettingsController`. - Implemented form handling for saving SKU format settings. - Updated the view to include SKU format configuration options. feat: Implement cron job for refreshing ShopPro offer titles - Created `ShopProOfferTitlesRefreshHandler` to handle the cron job for refreshing offer titles. - Integrated with the `OfferImportService` to import offers from ShopPro. docs: Update database schema documentation - Added documentation for new fields in the `products` table and new cron job for offer title refresh. - Documented the purpose and structure of the `app_settings` table. migrations: Add necessary migrations for new features - Created migration to add `products_sku_format` setting in `app_settings`. - Added migration to introduce new fields in the `products` table. - Created migration for the new cron job schedule for refreshing ShopPro offer titles.
This commit is contained in:
146
.vscode/ftp-kr.sync.cache.json
vendored
146
.vscode/ftp-kr.sync.cache.json
vendored
@@ -3,8 +3,8 @@
|
|||||||
"public_html": {
|
"public_html": {
|
||||||
"AGENTS.md": {
|
"AGENTS.md": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 983,
|
"size": 1105,
|
||||||
"lmtime": 1771876594909,
|
"lmtime": 1772398011560,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -16,8 +16,14 @@
|
|||||||
},
|
},
|
||||||
"cron.php": {
|
"cron.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 2656,
|
"size": 2953,
|
||||||
"lmtime": 1771954648781,
|
"lmtime": 1772397935851,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"fix_gs1_brand.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 5808,
|
||||||
|
"lmtime": 1772132695646,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"migrate.php": {
|
"migrate.php": {
|
||||||
@@ -31,12 +37,6 @@
|
|||||||
"size": 7348,
|
"size": 7348,
|
||||||
"lmtime": 1771964550467,
|
"lmtime": 1771964550467,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
|
||||||
"fix_gs1_brand.php": {
|
|
||||||
"type": "-",
|
|
||||||
"size": 5808,
|
|
||||||
"lmtime": 1772132695646,
|
|
||||||
"modified": false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bootstrap": {
|
"bootstrap": {
|
||||||
@@ -165,6 +165,24 @@
|
|||||||
"size": 1962,
|
"size": 1962,
|
||||||
"lmtime": 1772212375028,
|
"lmtime": 1772212375028,
|
||||||
"modified": false
|
"modified": false
|
||||||
|
},
|
||||||
|
"20260301_000014_add_products_sku_format_setting.sql": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 200,
|
||||||
|
"lmtime": 1772395832205,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"20260301_000015_add_shoppro_settings_fields_to_products.sql": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 340,
|
||||||
|
"lmtime": 1772395109789,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"20260301_000016_add_shoppro_offer_titles_refresh_schedule.sql": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 535,
|
||||||
|
"lmtime": 1772397943532,
|
||||||
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"seeders": {}
|
"seeders": {}
|
||||||
@@ -246,8 +264,14 @@
|
|||||||
},
|
},
|
||||||
"TODO.md": {
|
"TODO.md": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 677,
|
"size": 395,
|
||||||
"lmtime": 1772213566566,
|
"lmtime": 1772395982288,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"DB_SCHEMA.md": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 4702,
|
||||||
|
"lmtime": 1772398004815,
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1525,8 +1549,8 @@
|
|||||||
"lang": {
|
"lang": {
|
||||||
"pl.php": {
|
"pl.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 23210,
|
"size": 24349,
|
||||||
"lmtime": 1772216124634,
|
"lmtime": 1772397947563,
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1596,14 +1620,14 @@
|
|||||||
"layouts": {
|
"layouts": {
|
||||||
"app.php": {
|
"app.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 3997,
|
"size": 4012,
|
||||||
"lmtime": 1772213532035,
|
"lmtime": 1772220106049,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"auth.php": {
|
"auth.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 860,
|
"size": 865,
|
||||||
"lmtime": 1772213533314,
|
"lmtime": 1772220107387,
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1616,8 +1640,8 @@
|
|||||||
},
|
},
|
||||||
"offers.php": {
|
"offers.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 11081,
|
"size": 19158,
|
||||||
"lmtime": 1772216038911,
|
"lmtime": 1772397952604,
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1630,8 +1654,8 @@
|
|||||||
},
|
},
|
||||||
"edit.php": {
|
"edit.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 25177,
|
"size": 27948,
|
||||||
"lmtime": 1772213273221,
|
"lmtime": 1772397133535,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"index.php": {
|
"index.php": {
|
||||||
@@ -1648,34 +1672,40 @@
|
|||||||
},
|
},
|
||||||
"show.php": {
|
"show.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 9833,
|
"size": 9854,
|
||||||
"lmtime": 1772210477665,
|
"lmtime": 1772220108463,
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"cron.php": {
|
"cron.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 6822,
|
"size": 6992,
|
||||||
"lmtime": 1771961105049,
|
"lmtime": 1772395792152,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"database.php": {
|
"database.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 4126,
|
"size": 4291,
|
||||||
"lmtime": 1771961120742,
|
"lmtime": 1772395778355,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"gs1.php": {
|
"gs1.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 3147,
|
"size": 3312,
|
||||||
"lmtime": 1771961074302,
|
"lmtime": 1772395799007,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"integrations.php": {
|
"integrations.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 9194,
|
"size": 9364,
|
||||||
"lmtime": 1771961118581,
|
"lmtime": 1772395786681,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"products.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 2037,
|
||||||
|
"lmtime": 1772395769190,
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1692,8 +1722,8 @@
|
|||||||
"routes": {
|
"routes": {
|
||||||
"web.php": {
|
"web.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 10039,
|
"size": 10852,
|
||||||
"lmtime": 1772216828150,
|
"lmtime": 1772397082719,
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1741,8 +1771,8 @@
|
|||||||
"Core": {
|
"Core": {
|
||||||
"Application.php": {
|
"Application.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 9212,
|
"size": 9549,
|
||||||
"lmtime": 1771955095243,
|
"lmtime": 1772397927382,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"Database": {
|
"Database": {
|
||||||
@@ -1868,8 +1898,8 @@
|
|||||||
},
|
},
|
||||||
"CronJobType.php": {
|
"CronJobType.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 709,
|
"size": 897,
|
||||||
"lmtime": 1771954354779,
|
"lmtime": 1772397902687,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"ProductLinksHealthCheckHandler.php": {
|
"ProductLinksHealthCheckHandler.php": {
|
||||||
@@ -1877,19 +1907,25 @@
|
|||||||
"size": 5247,
|
"size": 5247,
|
||||||
"lmtime": 1771954535742,
|
"lmtime": 1771954535742,
|
||||||
"modified": false
|
"modified": false
|
||||||
|
},
|
||||||
|
"ShopProOfferTitlesRefreshHandler.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 3788,
|
||||||
|
"lmtime": 1772397918784,
|
||||||
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Marketplace": {
|
"Marketplace": {
|
||||||
"MarketplaceController.php": {
|
"MarketplaceController.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 9893,
|
"size": 28819,
|
||||||
"lmtime": 1772216848957,
|
"lmtime": 1772398277623,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"MarketplaceRepository.php": {
|
"MarketplaceRepository.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 4917,
|
"size": 10298,
|
||||||
"lmtime": 1771922289322,
|
"lmtime": 1772398268053,
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1934,20 +1970,26 @@
|
|||||||
"Products": {
|
"Products": {
|
||||||
"ProductRepository.php": {
|
"ProductRepository.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 28350,
|
"size": 29887,
|
||||||
"lmtime": 1772212492614,
|
"lmtime": 1772395707501,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"ProductsController.php": {
|
"ProductsController.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 48255,
|
"size": 49058,
|
||||||
"lmtime": 1772213190553,
|
"lmtime": 1772395718310,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"ProductService.php": {
|
"ProductService.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 15635,
|
"size": 17193,
|
||||||
"lmtime": 1771935807204,
|
"lmtime": 1772395136766,
|
||||||
|
"modified": false
|
||||||
|
},
|
||||||
|
"ProductSkuGenerator.php": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 3044,
|
||||||
|
"lmtime": 1772395702627,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"ProductValidator.php": {
|
"ProductValidator.php": {
|
||||||
@@ -1958,8 +2000,8 @@
|
|||||||
},
|
},
|
||||||
"ShopProExportService.php": {
|
"ShopProExportService.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 45242,
|
"size": 45644,
|
||||||
"lmtime": 1772216412254,
|
"lmtime": 1772395159115,
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1978,8 +2020,8 @@
|
|||||||
},
|
},
|
||||||
"SettingsController.php": {
|
"SettingsController.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 59948,
|
"size": 63025,
|
||||||
"lmtime": 1772212549465,
|
"lmtime": 1772395757277,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"ShopProClient.php": {
|
"ShopProClient.php": {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
## Utrwalanie stalych wymagan
|
## Utrwalanie stalych wymagan
|
||||||
- Trwale wymagania techniczne zapisuj w tym pliku (`AGENTS.md`) w root projektu.
|
- Trwale wymagania techniczne zapisuj w tym pliku (`AGENTS.md`) w root projektu.
|
||||||
- Dla zmiennych srodowiskowych utrzymuj tez wpisy w `.env.example`.
|
- Dla zmiennych srodowiskowych utrzymuj tez wpisy w `.env.example`.
|
||||||
|
- Utrzymuj aktualny opis schematu bazy danych w `DOCS/DB_SCHEMA.md` (aktualizacja przy kazdej zmianie migracji/schematu).
|
||||||
|
|
||||||
## Alerty i potwierdzenia UI
|
## Alerty i potwierdzenia UI
|
||||||
- W aplikacji uzywaj modulu `resources/modules/jquery-alerts` (build do `public/assets/js/modules/jquery-alerts.js` i `public/assets/css/modules/jquery-alerts.css`).
|
- W aplikacji uzywaj modulu `resources/modules/jquery-alerts` (build do `public/assets/js/modules/jquery-alerts.js` i `public/assets/css/modules/jquery-alerts.css`).
|
||||||
|
|||||||
119
DOCS/DB_SCHEMA.md
Normal file
119
DOCS/DB_SCHEMA.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Struktura bazy danych (orderPRO)
|
||||||
|
|
||||||
|
## Cel pliku
|
||||||
|
- Ten dokument opisuje aktualny schemat bazy danych na podstawie migracji w `database/migrations`.
|
||||||
|
- Aktualizuj ten plik przy każdej zmianie schematu (nowa tabela, kolumna, indeks, klucz obcy).
|
||||||
|
|
||||||
|
## Tabele i przeznaczenie
|
||||||
|
|
||||||
|
### `users`
|
||||||
|
- Uzytkownicy panelu.
|
||||||
|
- Klucz unikalny: `email`.
|
||||||
|
|
||||||
|
### `products`
|
||||||
|
- Glowna tabela produktow lokalnych.
|
||||||
|
- Najwazniejsze pola: `type`, `sku`, `ean`, `status`, `promoted`, `vat`, `weight`, `price_*`, `quantity`.
|
||||||
|
- Pola shopPRO: `new_to_date`, `additional_message`, `additional_message_required`, `additional_message_text`.
|
||||||
|
- Dodatkowe: `producer_id`, `producer_name`, `product_unit_id`, `custom_fields_json`.
|
||||||
|
- Soft delete: `deleted_at`.
|
||||||
|
|
||||||
|
### `product_translations`
|
||||||
|
- Globalne tresci produktu per jezyk (`lang`).
|
||||||
|
- Najwazniejsze pola: `name`, `short_description`, `description`, SEO, `security_information`.
|
||||||
|
- Unikalnosc: `(product_id, lang)`.
|
||||||
|
|
||||||
|
### `product_integration_translations`
|
||||||
|
- Nadpisania tresci produktu per integracja shopPRO.
|
||||||
|
- Pola: `name`, `short_description`, `description` (NULL = fallback do `product_translations`).
|
||||||
|
- Unikalnosc: `(product_id, integration_id)`.
|
||||||
|
|
||||||
|
### `product_images`
|
||||||
|
- Obrazy produktu (`storage_path`, `is_main`, `sort_order`).
|
||||||
|
|
||||||
|
### `product_categories`
|
||||||
|
- Relacja M:N produkt-kategoria (lokalna).
|
||||||
|
|
||||||
|
### `attributes`
|
||||||
|
- Definicje atrybutow wariantow.
|
||||||
|
|
||||||
|
### `attribute_translations`
|
||||||
|
- Tlumaczenia nazw atrybutow per jezyk.
|
||||||
|
|
||||||
|
### `attribute_values`
|
||||||
|
- Wartosci atrybutow (np. kolor, rozmiar), z opcjonalnym `impact_on_price`.
|
||||||
|
|
||||||
|
### `attribute_value_translations`
|
||||||
|
- Tlumaczenia wartosci atrybutow per jezyk.
|
||||||
|
|
||||||
|
### `product_variants`
|
||||||
|
- Warianty produktu.
|
||||||
|
- Najwazniejsze pola: `permutation_hash`, `sku`, `ean`, `status`, `price_*`, `weight`, `stock_0_buy`.
|
||||||
|
- Unikalnosc: `sku`, `(product_id, permutation_hash)`.
|
||||||
|
|
||||||
|
### `product_variant_attributes`
|
||||||
|
- Relacja wariant -> (atrybut, wartosc).
|
||||||
|
- Klucz glowny: `(variant_id, attribute_id)`.
|
||||||
|
|
||||||
|
### `product_change_log`
|
||||||
|
- Log zmian produktow (audyt JSON: `before_json`, `after_json`).
|
||||||
|
|
||||||
|
### `sales_channels`
|
||||||
|
- Slownik kanalow sprzedazy (`shoppro`, `allegro`, `erli`, ...).
|
||||||
|
|
||||||
|
### `product_channel_map`
|
||||||
|
- Mapowanie lokalnego produktu do kanalu/integracji i ID zewnetrznych.
|
||||||
|
- Najwazniejsze pola: `integration_id`, `external_product_id`, `external_variant_id`, `sync_state`, `link_type`, `link_status`, `confidence`.
|
||||||
|
- Pola audytowe powiazania: `linked_at`, `linked_by_user_id`, `unlinked_at`, `unlinked_by_user_id`, `sync_meta_json`.
|
||||||
|
|
||||||
|
### `channel_offers`
|
||||||
|
- Cache ofert zewnetrznych dla integracji.
|
||||||
|
- Najwazniejsze pola: `external_product_id`, `external_variant_id`, `external_offer_id`, `name` (tytul oferty), `sku`, `ean`, `offer_status`, `last_seen_at`, `payload_json`.
|
||||||
|
- Unikalnosc: `(integration_id, external_product_id, external_variant_id)`.
|
||||||
|
|
||||||
|
### `product_link_events`
|
||||||
|
- Historia zdarzen na powiazaniach (`product_channel_map`).
|
||||||
|
|
||||||
|
### `integrations`
|
||||||
|
- Konfiguracja instancji integracji (obecnie shopPRO).
|
||||||
|
- Najwazniejsze pola: `type`, `name`, `base_url`, `api_key_encrypted`, `timeout_seconds`, `is_active`.
|
||||||
|
|
||||||
|
### `integration_test_logs`
|
||||||
|
- Historia testow polaczen integracji.
|
||||||
|
|
||||||
|
### `cron_jobs`
|
||||||
|
- Kolejka jobow crona.
|
||||||
|
|
||||||
|
### `cron_schedules`
|
||||||
|
- Harmonogramy okresowych jobow.
|
||||||
|
- Aktualnie zawiera m.in.:
|
||||||
|
- `product_links_health_check`
|
||||||
|
- `shoppro_offer_titles_refresh` (odswiezanie tytulow ofert; domyslnie co 30 dni)
|
||||||
|
|
||||||
|
### `product_link_alerts`
|
||||||
|
- Alerty zdrowia powiazan produktu.
|
||||||
|
|
||||||
|
### `app_settings`
|
||||||
|
- Ustawienia aplikacyjne key-value.
|
||||||
|
- Przykładowe klucze: `cron_run_on_web`, `cron_web_limit`, `gs1_*`, `products_sku_format`.
|
||||||
|
|
||||||
|
## Relacje (skrot)
|
||||||
|
- `product_translations.product_id -> products.id`
|
||||||
|
- `product_integration_translations.product_id -> products.id`
|
||||||
|
- `product_integration_translations.integration_id -> integrations.id`
|
||||||
|
- `product_images.product_id -> products.id`
|
||||||
|
- `product_variants.product_id -> products.id`
|
||||||
|
- `product_variant_attributes.variant_id -> product_variants.id`
|
||||||
|
- `product_variant_attributes.attribute_id -> attributes.id`
|
||||||
|
- `product_variant_attributes.value_id -> attribute_values.id`
|
||||||
|
- `product_channel_map.product_id -> products.id`
|
||||||
|
- `product_channel_map.channel_id -> sales_channels.id`
|
||||||
|
- `product_channel_map.integration_id -> integrations.id`
|
||||||
|
- `channel_offers.integration_id -> integrations.id`
|
||||||
|
- `channel_offers.channel_id -> sales_channels.id`
|
||||||
|
- `product_link_events.product_channel_map_id -> product_channel_map.id`
|
||||||
|
- `product_link_alerts.product_channel_map_id -> product_channel_map.id`
|
||||||
|
|
||||||
|
## Jak utrzymywac dokument
|
||||||
|
1. Po dodaniu migracji zaktualizuj sekcje tabel/kolumn/relacji.
|
||||||
|
2. Gdy dodajesz nowe klucze do `app_settings`, dopisz je tu.
|
||||||
|
3. Przy zmianach harmonogramu crona zaktualizuj liste jobow w `cron_schedules`.
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
5. Rozbudować dane o producencie o pola z shopPRO
|
5. Rozbudować dane o producencie o pola z shopPRO
|
||||||
9. Opisy tytuly dla każdej z integracji osobno
|
|
||||||
10. Dla integracji shopPRO możliwość przypisania do kategorii (pobierane w locie przez API)
|
|
||||||
11. Nowa zakładka ze stanami magazynowyi z inputami do szybkiego wpisania aktualnego stanu magazynowego
|
11. Nowa zakładka ze stanami magazynowyi z inputami do szybkiego wpisania aktualnego stanu magazynowego
|
||||||
12. Opcja generowania lokalnego SKU (najlepiej nowy format)
|
|
||||||
13. Możliwość edycji pojedynczych wartości dla integracji shopPRO
|
13. Możliwość edycji pojedynczych wartości dla integracji shopPRO
|
||||||
14. Możliwość wysyłania wybranych zdjęć przy eksporcie pojedynczego produktu.
|
14. Możliwość wysyłania wybranych zdjęć przy eksporcie pojedynczego produktu.
|
||||||
15. Obłsuga w produkcie zakłądki "Ustawienia" przy imporcie i eksporcie
|
|
||||||
16. Obsługa pola Pozwól zamawiać gdy stan 0:
|
16. Obsługa pola Pozwól zamawiać gdy stan 0:
|
||||||
17. Integracja z https://kie.ai/
|
17. Integracja z https://kie.ai/
|
||||||
@@ -7,6 +7,7 @@ use App\Modules\Cron\CronJobProcessor;
|
|||||||
use App\Modules\Cron\CronJobRepository;
|
use App\Modules\Cron\CronJobRepository;
|
||||||
use App\Modules\Cron\CronJobType;
|
use App\Modules\Cron\CronJobType;
|
||||||
use App\Modules\Cron\ProductLinksHealthCheckHandler;
|
use App\Modules\Cron\ProductLinksHealthCheckHandler;
|
||||||
|
use App\Modules\Cron\ShopProOfferTitlesRefreshHandler;
|
||||||
use App\Modules\ProductLinks\ChannelOffersRepository;
|
use App\Modules\ProductLinks\ChannelOffersRepository;
|
||||||
use App\Modules\ProductLinks\OfferImportService;
|
use App\Modules\ProductLinks\OfferImportService;
|
||||||
use App\Modules\ProductLinks\ProductLinksRepository;
|
use App\Modules\ProductLinks\ProductLinksRepository;
|
||||||
@@ -72,8 +73,13 @@ try {
|
|||||||
$linksRepository,
|
$linksRepository,
|
||||||
$offersRepository
|
$offersRepository
|
||||||
);
|
);
|
||||||
|
$offerTitlesRefreshHandler = new ShopProOfferTitlesRefreshHandler(
|
||||||
|
$integrationRepository,
|
||||||
|
$offerImportService
|
||||||
|
);
|
||||||
|
|
||||||
$processor->registerHandler(CronJobType::PRODUCT_LINKS_HEALTH_CHECK, $linksHealthCheckHandler);
|
$processor->registerHandler(CronJobType::PRODUCT_LINKS_HEALTH_CHECK, $linksHealthCheckHandler);
|
||||||
|
$processor->registerHandler(CronJobType::SHOPPRO_OFFER_TITLES_REFRESH, $offerTitlesRefreshHandler);
|
||||||
|
|
||||||
$result = $processor->run($limit);
|
$result = $processor->run($limit);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
INSERT INTO app_settings (setting_key, setting_value, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
('products_sku_format', 'PP000000', NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
updated_at = VALUES(updated_at);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE products
|
||||||
|
ADD COLUMN new_to_date DATE NULL AFTER promoted,
|
||||||
|
ADD COLUMN additional_message TINYINT(1) NOT NULL DEFAULT 0 AFTER new_to_date,
|
||||||
|
ADD COLUMN additional_message_required TINYINT(1) NOT NULL DEFAULT 0 AFTER additional_message,
|
||||||
|
ADD COLUMN additional_message_text TEXT NULL AFTER additional_message_required;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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_offer_titles_refresh',
|
||||||
|
2592000,
|
||||||
|
170,
|
||||||
|
3,
|
||||||
|
NULL,
|
||||||
|
1,
|
||||||
|
NULL,
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
interval_seconds = VALUES(interval_seconds),
|
||||||
|
priority = VALUES(priority),
|
||||||
|
max_attempts = VALUES(max_attempts),
|
||||||
|
payload = VALUES(payload),
|
||||||
|
enabled = VALUES(enabled),
|
||||||
|
updated_at = VALUES(updated_at);
|
||||||
@@ -36,7 +36,7 @@ return [
|
|||||||
'fields' => [
|
'fields' => [
|
||||||
'integration' => 'Integracja',
|
'integration' => 'Integracja',
|
||||||
'linked_offers_count' => 'Powiazane oferty',
|
'linked_offers_count' => 'Powiazane oferty',
|
||||||
'offer_name' => 'Oferta',
|
'offer_name' => 'Tytul oferty marketplace',
|
||||||
'external_product_id' => 'External product ID',
|
'external_product_id' => 'External product ID',
|
||||||
'external_variant_id' => 'External variant ID',
|
'external_variant_id' => 'External variant ID',
|
||||||
'external_offer_id' => 'External offer ID',
|
'external_offer_id' => 'External offer ID',
|
||||||
@@ -50,9 +50,11 @@ return [
|
|||||||
'open_offers' => 'Pokaz oferty',
|
'open_offers' => 'Pokaz oferty',
|
||||||
'back_to_marketplace' => 'Wroc do Marketplace',
|
'back_to_marketplace' => 'Wroc do Marketplace',
|
||||||
'assign_categories' => 'Przypisz kategorie',
|
'assign_categories' => 'Przypisz kategorie',
|
||||||
|
'edit_offer' => 'Edytuj',
|
||||||
],
|
],
|
||||||
'flash' => [
|
'flash' => [
|
||||||
'integration_not_found' => 'Nie znaleziono aktywnej integracji.',
|
'integration_not_found' => 'Nie znaleziono aktywnej integracji.',
|
||||||
|
'product_updated' => 'Produkt zostal zaktualizowany w integracji shopPRO.',
|
||||||
],
|
],
|
||||||
'category_modal' => [
|
'category_modal' => [
|
||||||
'title' => 'Przypisz kategorie',
|
'title' => 'Przypisz kategorie',
|
||||||
@@ -140,6 +142,7 @@ return [
|
|||||||
'add' => 'Dodaj produkt',
|
'add' => 'Dodaj produkt',
|
||||||
'import_shoppro' => 'Import z shopPRO',
|
'import_shoppro' => 'Import z shopPRO',
|
||||||
'export_shoppro' => 'Eksport do shopPRO',
|
'export_shoppro' => 'Eksport do shopPRO',
|
||||||
|
'generate_next_sku' => 'Generuj kolejne SKU',
|
||||||
'preview' => 'Podglad',
|
'preview' => 'Podglad',
|
||||||
'links' => 'Powiazania',
|
'links' => 'Powiazania',
|
||||||
'edit' => 'Edytuj',
|
'edit' => 'Edytuj',
|
||||||
@@ -159,6 +162,10 @@ return [
|
|||||||
'confirm' => [
|
'confirm' => [
|
||||||
'delete' => 'Czy na pewno usunac produkt #:id?',
|
'delete' => 'Czy na pewno usunac produkt #:id?',
|
||||||
],
|
],
|
||||||
|
'sku_generator' => [
|
||||||
|
'failed' => 'Nie udalo sie wygenerowac SKU.',
|
||||||
|
'confirm_title' => 'Blad',
|
||||||
|
],
|
||||||
'fields' => [
|
'fields' => [
|
||||||
'name' => 'Nazwa',
|
'name' => 'Nazwa',
|
||||||
'type' => 'Typ',
|
'type' => 'Typ',
|
||||||
@@ -507,6 +514,23 @@ return [
|
|||||||
'save_failed' => 'Nie udalo sie zapisac ustawien GS1.',
|
'save_failed' => 'Nie udalo sie zapisac ustawien GS1.',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'products' => [
|
||||||
|
'title' => 'Produkty',
|
||||||
|
'description' => 'Ustawienia generatora SKU dla produktow.',
|
||||||
|
'fields' => [
|
||||||
|
'sku_format' => 'Format SKU',
|
||||||
|
],
|
||||||
|
'sku_format_hint' => 'Przyklad: PP000000. Ciag zer oznacza licznik inkrementowany i uzupelniany zerami.',
|
||||||
|
'actions' => [
|
||||||
|
'save' => 'Zapisz ustawienia produktow',
|
||||||
|
],
|
||||||
|
'flash' => [
|
||||||
|
'saved' => 'Ustawienia produktow zostaly zapisane.',
|
||||||
|
'save_failed' => 'Nie udalo sie zapisac ustawien produktow.',
|
||||||
|
'invalid_no_counter' => 'Format SKU musi zawierac czesc liczbowa (zera), np. PP000000.',
|
||||||
|
'invalid_too_long' => 'Format SKU jest za dlugi (maksymalnie 128 znakow).',
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
<?php $integrationData = is_array($integration ?? null) ? $integration : []; ?>
|
<?php $integrationData = is_array($integration ?? null) ? $integration : []; ?>
|
||||||
<?php $rows = is_array($offers ?? null) ? $offers : []; ?>
|
<?php $rows = is_array($offers ?? null) ? $offers : []; ?>
|
||||||
<?php $integrationId = (int) ($integrationData['id'] ?? 0); ?>
|
<?php $integrationId = (int) ($integrationData['id'] ?? 0); ?>
|
||||||
|
<?php $filters = is_array($filters ?? null) ? $filters : []; ?>
|
||||||
|
<?php $channelOptions = is_array($channelOptions ?? null) ? $channelOptions : []; ?>
|
||||||
|
<?php $pagination = is_array($pagination ?? null) ? $pagination : []; ?>
|
||||||
|
<?php
|
||||||
|
$currentSort = (string) ($filters['sort'] ?? 'updated_at');
|
||||||
|
$currentDir = strtoupper((string) ($filters['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
|
||||||
|
$page = max(1, (int) ($pagination['page'] ?? 1));
|
||||||
|
$totalPages = max(1, (int) ($pagination['total_pages'] ?? 1));
|
||||||
|
$total = max(0, (int) ($pagination['total'] ?? count($rows)));
|
||||||
|
$perPage = max(1, (int) ($pagination['per_page'] ?? 20));
|
||||||
|
$buildUrl = static function (array $params = []) use ($integrationId, $filters): string {
|
||||||
|
$merged = array_merge($filters, $params);
|
||||||
|
foreach ($merged as $key => $value) {
|
||||||
|
if ($value === '' || $value === null) {
|
||||||
|
unset($merged[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$query = http_build_query($merged);
|
||||||
|
$base = '/marketplace/' . $integrationId;
|
||||||
|
return $query !== '' ? ($base . '?' . $query) : $base;
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h1><?= $e($t('marketplace.offers_title', ['name' => (string) ($integrationData['name'] ?? '')])) ?></h1>
|
<h1><?= $e($t('marketplace.offers_title', ['name' => (string) ($integrationData['name'] ?? '')])) ?></h1>
|
||||||
@@ -13,6 +35,44 @@
|
|||||||
<?php if (!empty($errorMessage)): ?>
|
<?php if (!empty($errorMessage)): ?>
|
||||||
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
|
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($successMessage)): ?>
|
||||||
|
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="get" action="/marketplace/<?= $e((string) $integrationId) ?>" class="table-list-filters mt-12">
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.filters.search')) ?></span>
|
||||||
|
<input class="form-control" type="text" name="search" value="<?= $e((string) ($filters['search'] ?? '')) ?>" placeholder="Oferta, SKU, EAN, external ID">
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('marketplace.fields.channel')) ?></span>
|
||||||
|
<select class="form-control" name="channel">
|
||||||
|
<option value=""><?= $e($t('products.filters.any')) ?></option>
|
||||||
|
<?php foreach ($channelOptions as $channelName): ?>
|
||||||
|
<option value="<?= $e((string) $channelName) ?>"<?= (string) ($filters['channel'] ?? '') === (string) $channelName ? ' selected' : '' ?>>
|
||||||
|
<?= $e((string) $channelName) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.filters.per_page')) ?></span>
|
||||||
|
<select class="form-control" name="per_page">
|
||||||
|
<?php foreach ([10, 20, 50, 100] as $opt): ?>
|
||||||
|
<option value="<?= $e((string) $opt) ?>"<?= $perPage === $opt ? ' selected' : '' ?>><?= $e((string) $opt) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<input type="hidden" name="sort" value="<?= $e((string) ($filters['sort'] ?? 'updated_at')) ?>">
|
||||||
|
<input type="hidden" name="sort_dir" value="<?= $e((string) ($filters['sort_dir'] ?? 'DESC')) ?>">
|
||||||
|
<input type="hidden" name="page" value="1">
|
||||||
|
<div class="filters-actions">
|
||||||
|
<button class="btn btn--primary" type="submit"><?= $e($t('products.actions.filter')) ?></button>
|
||||||
|
<a class="btn btn--secondary" href="/marketplace/<?= $e((string) $integrationId) ?>"><?= $e($t('products.actions.reset')) ?></a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="muted mt-12"><?= $e($t('products.pagination.summary', ['total' => (string) $total])) ?></p>
|
||||||
|
|
||||||
<?php if ($rows === []): ?>
|
<?php if ($rows === []): ?>
|
||||||
<p class="muted mt-12"><?= $e($t('marketplace.empty_offers')) ?></p>
|
<p class="muted mt-12"><?= $e($t('marketplace.empty_offers')) ?></p>
|
||||||
@@ -21,23 +81,61 @@
|
|||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?= $e($t('marketplace.fields.offer_name')) ?></th>
|
<th>
|
||||||
<th><?= $e($t('marketplace.fields.external_product_id')) ?></th>
|
<a href="<?= $e($buildUrl(['sort' => 'offer_name', 'sort_dir' => ($currentSort === 'offer_name' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
|
||||||
<th><?= $e($t('marketplace.fields.external_variant_id')) ?></th>
|
<?= $e($t('marketplace.fields.offer_name')) ?><?= $currentSort === 'offer_name' ? ($currentDir === 'ASC' ? ' ↑' : ' ↓') : '' ?>
|
||||||
<th><?= $e($t('marketplace.fields.external_offer_id')) ?></th>
|
</a>
|
||||||
<th><?= $e($t('marketplace.fields.channel')) ?></th>
|
</th>
|
||||||
<th><?= $e($t('marketplace.fields.product')) ?></th>
|
<th>
|
||||||
<th>SKU</th>
|
<a href="<?= $e($buildUrl(['sort' => 'external_product_id', 'sort_dir' => ($currentSort === 'external_product_id' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
|
||||||
<th>EAN</th>
|
<?= $e($t('marketplace.fields.external_product_id')) ?><?= $currentSort === 'external_product_id' ? ($currentDir === 'ASC' ? ' ↑' : ' ↓') : '' ?>
|
||||||
<th><?= $e($t('marketplace.fields.updated_at')) ?></th>
|
</a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<a href="<?= $e($buildUrl(['sort' => 'external_variant_id', 'sort_dir' => ($currentSort === 'external_variant_id' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
|
||||||
|
<?= $e($t('marketplace.fields.external_variant_id')) ?><?= $currentSort === 'external_variant_id' ? ($currentDir === 'ASC' ? ' ↑' : ' ↓') : '' ?>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<a href="<?= $e($buildUrl(['sort' => 'external_offer_id', 'sort_dir' => ($currentSort === 'external_offer_id' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
|
||||||
|
<?= $e($t('marketplace.fields.external_offer_id')) ?><?= $currentSort === 'external_offer_id' ? ($currentDir === 'ASC' ? ' ↑' : ' ↓') : '' ?>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<a href="<?= $e($buildUrl(['sort' => 'channel_name', 'sort_dir' => ($currentSort === 'channel_name' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
|
||||||
|
<?= $e($t('marketplace.fields.channel')) ?><?= $currentSort === 'channel_name' ? ($currentDir === 'ASC' ? ' ↑' : ' ↓') : '' ?>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<a href="<?= $e($buildUrl(['sort' => 'product_name', 'sort_dir' => ($currentSort === 'product_name' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
|
||||||
|
<?= $e($t('marketplace.fields.product')) ?><?= $currentSort === 'product_name' ? ($currentDir === 'ASC' ? ' ↑' : ' ↓') : '' ?>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<a href="<?= $e($buildUrl(['sort' => 'product_sku', 'sort_dir' => ($currentSort === 'product_sku' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
|
||||||
|
SKU<?= $currentSort === 'product_sku' ? ($currentDir === 'ASC' ? ' ↑' : ' ↓') : '' ?>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<a href="<?= $e($buildUrl(['sort' => 'product_ean', 'sort_dir' => ($currentSort === 'product_ean' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
|
||||||
|
EAN<?= $currentSort === 'product_ean' ? ($currentDir === 'ASC' ? ' ↑' : ' ↓') : '' ?>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<a href="<?= $e($buildUrl(['sort' => 'updated_at', 'sort_dir' => ($currentSort === 'updated_at' && $currentDir === 'ASC') ? 'DESC' : 'ASC', 'page' => 1])) ?>" class="table-sort-link">
|
||||||
|
<?= $e($t('marketplace.fields.updated_at')) ?><?= $currentSort === 'updated_at' ? ($currentDir === 'ASC' ? ' ↑' : ' ↓') : '' ?>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th><?= $e($t('marketplace.fields.actions')) ?></th>
|
||||||
<th>Kategorie</th>
|
<th>Kategorie</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($rows as $row): ?>
|
<?php foreach ($rows as $row): ?>
|
||||||
<?php $productId = (int) ($row['product_id'] ?? 0); ?>
|
<?php $productId = (int) ($row['product_id'] ?? 0); ?>
|
||||||
|
<?php $externalProductId = (int) ($row['external_product_id'] ?? 0); ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= $e((string) ($row['offer_name'] ?? '')) ?></td>
|
<td><?= $e(trim((string) ($row['offer_name'] ?? '')) !== '' ? (string) ($row['offer_name'] ?? '') : '-') ?></td>
|
||||||
<td><?= $e((string) ($row['external_product_id'] ?? '')) ?></td>
|
<td><?= $e((string) ($row['external_product_id'] ?? '')) ?></td>
|
||||||
<td><?= $e((string) ($row['external_variant_id'] ?? '')) ?></td>
|
<td><?= $e((string) ($row['external_variant_id'] ?? '')) ?></td>
|
||||||
<td><?= $e((string) ($row['external_offer_id'] ?? '')) ?></td>
|
<td><?= $e((string) ($row['external_offer_id'] ?? '')) ?></td>
|
||||||
@@ -50,6 +148,16 @@
|
|||||||
<td><?= $e((string) ($row['product_sku'] ?? '')) ?></td>
|
<td><?= $e((string) ($row['product_sku'] ?? '')) ?></td>
|
||||||
<td><?= $e((string) ($row['product_ean'] ?? '')) ?></td>
|
<td><?= $e((string) ($row['product_ean'] ?? '')) ?></td>
|
||||||
<td><?= $e((string) ($row['updated_at'] ?? '')) ?></td>
|
<td><?= $e((string) ($row['updated_at'] ?? '')) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ($externalProductId > 0): ?>
|
||||||
|
<a
|
||||||
|
class="btn btn--secondary btn--sm"
|
||||||
|
href="/marketplace/<?= $e((string) $integrationId) ?>/product/<?= $e((string) $externalProductId) ?>/edit"
|
||||||
|
><?= $e($t('marketplace.actions.edit_offer')) ?></a>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="muted">-</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -63,6 +171,25 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="table-list__footer mt-12">
|
||||||
|
<div class="pagination">
|
||||||
|
<?php $startPage = max(1, $page - 2); ?>
|
||||||
|
<?php $endPage = min($totalPages, $page + 2); ?>
|
||||||
|
|
||||||
|
<a class="pagination__item<?= $page <= 1 ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => 1])) ?>">«</a>
|
||||||
|
<a class="pagination__item<?= $page <= 1 ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => max(1, $page - 1)])) ?>">‹</a>
|
||||||
|
|
||||||
|
<?php for ($i = $startPage; $i <= $endPage; $i++): ?>
|
||||||
|
<a class="pagination__item<?= $i === $page ? ' is-active' : '' ?>" href="<?= $e($buildUrl(['page' => $i])) ?>">
|
||||||
|
<?= $e((string) $i) ?>
|
||||||
|
</a>
|
||||||
|
<?php endfor; ?>
|
||||||
|
|
||||||
|
<a class="pagination__item<?= $page >= $totalPages ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => min($totalPages, $page + 1)])) ?>">›</a>
|
||||||
|
<a class="pagination__item<?= $page >= $totalPages ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => $totalPages])) ?>">»</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,18 @@
|
|||||||
.wysiwyg-wrap .ql-editor { min-height: var(--editor-min-height, 80px); }
|
.wysiwyg-wrap .ql-editor { min-height: var(--editor-min-height, 80px); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$integrationEditMode = (bool) ($integrationEditMode ?? false);
|
||||||
|
$productFormAction = (string) ($productFormAction ?? '/products/update');
|
||||||
|
$productBackUrl = (string) ($productBackUrl ?? '/products');
|
||||||
|
?>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h1><?= $e($t('products.edit.title', ['id' => (string) ($productId ?? 0)])) ?></h1>
|
<h1><?= $e((string) ($title ?? $t('products.edit.title', ['id' => (string) ($productId ?? 0)]))) ?></h1>
|
||||||
<p class="muted"><?= $e($t('products.edit.description')) ?></p>
|
<p class="muted"><?= $e($t('products.edit.description')) ?></p>
|
||||||
|
<?php if ($integrationEditMode): ?>
|
||||||
|
<p class="muted mt-8">Tryb integracyjny: zapis aktualizuje bezposrednio produkt w shopPRO i synchronizuje dane lokalne.</p>
|
||||||
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card mt-16">
|
<section class="card mt-16">
|
||||||
@@ -21,15 +30,17 @@
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php $images = is_array($productImages ?? null) ? $productImages : []; ?>
|
<?php $images = is_array($productImages ?? null) ? $productImages : []; ?>
|
||||||
<form class="product-form mt-16" method="post" action="/products/update" enctype="multipart/form-data">
|
<form class="product-form mt-16" method="post" action="<?= $e($productFormAction) ?>" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
<input type="hidden" name="id" value="<?= $e((string) ($productId ?? 0)) ?>">
|
<input type="hidden" name="id" value="<?= $e((string) ($productId ?? 0)) ?>">
|
||||||
|
<input type="hidden" id="product-image-csrf" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
|
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label class="form-field">
|
<div class="form-field">
|
||||||
<span class="field-label">SKU</span>
|
<span class="field-label">SKU</span>
|
||||||
<input class="form-control" type="text" name="sku" value="<?= $e((string) ($form['sku'] ?? '')) ?>">
|
<input class="form-control" type="text" id="product-sku-input" name="sku" value="<?= $e((string) ($form['sku'] ?? '')) ?>">
|
||||||
</label>
|
<button type="button" class="btn btn--secondary mt-12" id="product-generate-sku-btn"><?= $e($t('products.actions.generate_next_sku')) ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
<span class="field-label">EAN</span>
|
<span class="field-label">EAN</span>
|
||||||
@@ -214,10 +225,10 @@
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if (!$integrationEditMode): ?>
|
||||||
<section class="card mt-16">
|
<section class="card mt-16">
|
||||||
<h3><?= $e($t('products.images.title')) ?></h3>
|
<h3><?= $e($t('products.images.title')) ?></h3>
|
||||||
<p class="muted"><?= $e($t('products.images.description')) ?></p>
|
<p class="muted"><?= $e($t('products.images.description')) ?></p>
|
||||||
<input type="hidden" id="product-image-csrf" value="<?= $e($csrfToken ?? '') ?>">
|
|
||||||
|
|
||||||
<div class="product-images-grid mt-12" id="product-images-grid" data-product-id="<?= $e((string) ($productId ?? 0)) ?>">
|
<div class="product-images-grid mt-12" id="product-images-grid" data-product-id="<?= $e((string) ($productId ?? 0)) ?>">
|
||||||
<?php foreach ($images as $image): ?>
|
<?php foreach ($images as $image): ?>
|
||||||
@@ -262,14 +273,68 @@
|
|||||||
<p class="muted" id="product-image-upload-status"></p>
|
<p class="muted" id="product-image-upload-status"></p>
|
||||||
<p class="muted"><?= $e($t('products.images.main_hint')) ?></p>
|
<p class="muted"><?= $e($t('products.images.main_hint')) ?></p>
|
||||||
</section>
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="form-actions mt-16">
|
<div class="form-actions mt-16">
|
||||||
<button class="btn btn--primary" type="submit"><?= $e($t('products.actions.save')) ?></button>
|
<button class="btn btn--primary" type="submit"><?= $e($t('products.actions.save')) ?></button>
|
||||||
<a class="btn btn--secondary" href="/products"><?= $e($t('products.actions.back')) ?></a>
|
<a class="btn btn--secondary" href="<?= $e($productBackUrl) ?>"><?= $e($t('products.actions.back')) ?></a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var skuInput = document.getElementById('product-sku-input');
|
||||||
|
var generateSkuBtn = document.getElementById('product-generate-sku-btn');
|
||||||
|
var tokenInput = document.getElementById('product-image-csrf');
|
||||||
|
if (!skuInput || !generateSkuBtn || !tokenInput) return;
|
||||||
|
|
||||||
|
var csrfToken = tokenInput.value || '';
|
||||||
|
var errTitle = <?= json_encode((string) $t('products.sku_generator.confirm_title'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
var errDefault = <?= json_encode((string) $t('products.sku_generator.failed'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
if (window.OrderProAlerts && typeof window.OrderProAlerts.alert === 'function') {
|
||||||
|
window.OrderProAlerts.alert({
|
||||||
|
title: errTitle,
|
||||||
|
message: message || errDefault,
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var uploadStatus = document.getElementById('product-image-upload-status');
|
||||||
|
if (uploadStatus) {
|
||||||
|
uploadStatus.textContent = message || errDefault;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateSkuBtn.addEventListener('click', async function() {
|
||||||
|
generateSkuBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
var payload = new FormData();
|
||||||
|
payload.append('_token', csrfToken);
|
||||||
|
|
||||||
|
var response = await fetch('/products/next-sku', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
var result = await response.json();
|
||||||
|
if (!response.ok || result.ok !== true || !result.sku) {
|
||||||
|
throw new Error(result.message || errDefault);
|
||||||
|
}
|
||||||
|
|
||||||
|
skuInput.value = String(result.sku);
|
||||||
|
} catch (error) {
|
||||||
|
showError((error && error.message) ? error.message : errDefault);
|
||||||
|
} finally {
|
||||||
|
generateSkuBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var grid = document.getElementById('product-images-grid');
|
var grid = document.getElementById('product-images-grid');
|
||||||
@@ -524,17 +589,15 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
|
var initialTab = <?= json_encode((string) ($initialContentTab ?? ''), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
var nav = document.getElementById('content-tabs-nav');
|
var nav = document.getElementById('content-tabs-nav');
|
||||||
if (!nav) return;
|
if (!nav) return;
|
||||||
|
|
||||||
nav.addEventListener('click', function(e) {
|
function setTab(tabId) {
|
||||||
var btn = e.target.closest('.content-tab-btn');
|
if (!tabId) return;
|
||||||
|
var btn = nav.querySelector('.content-tab-btn[data-tab="' + tabId.replace(/"/g, '\\"') + '"]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|
||||||
var tabId = btn.getAttribute('data-tab');
|
|
||||||
if (!tabId) return;
|
|
||||||
|
|
||||||
// deactivate all
|
|
||||||
nav.querySelectorAll('.content-tab-btn').forEach(function(b) {
|
nav.querySelectorAll('.content-tab-btn').forEach(function(b) {
|
||||||
b.classList.remove('is-active');
|
b.classList.remove('is-active');
|
||||||
});
|
});
|
||||||
@@ -542,10 +605,22 @@
|
|||||||
p.classList.remove('is-active');
|
p.classList.remove('is-active');
|
||||||
});
|
});
|
||||||
|
|
||||||
// activate selected
|
|
||||||
btn.classList.add('is-active');
|
btn.classList.add('is-active');
|
||||||
var panel = document.getElementById('content-tab-' + tabId);
|
var panel = document.getElementById('content-tab-' + tabId);
|
||||||
if (panel) panel.classList.add('is-active');
|
if (panel) panel.classList.add('is-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest('.content-tab-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
var tabId = btn.getAttribute('data-tab');
|
||||||
|
if (!tabId) return;
|
||||||
|
setTab(tabId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (initialTab) {
|
||||||
|
setTab(initialTab);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ $webLimit = max(1, (int) ($webCronLimit ?? 5));
|
|||||||
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
|
||||||
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
|
||||||
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
|
||||||
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>
|
||||||
</nav>
|
</nav>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ $logs = (array) ($runLogs ?? []);
|
|||||||
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
|
||||||
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
|
||||||
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
|
||||||
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>
|
||||||
</nav>
|
</nav>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
|
||||||
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
|
||||||
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
|
||||||
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>
|
||||||
</nav>
|
</nav>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ $isEdit = ((int) ($formValues['integration_id'] ?? 0)) > 0;
|
|||||||
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
|
||||||
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
|
||||||
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
|
||||||
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>
|
||||||
</nav>
|
</nav>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
39
resources/views/settings/products.php
Normal file
39
resources/views/settings/products.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<section class="card">
|
||||||
|
<h1><?= $e($t('settings.title')) ?></h1>
|
||||||
|
<p class="muted"><?= $e($t('settings.description')) ?></p>
|
||||||
|
|
||||||
|
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
|
||||||
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'database' ? ' is-active' : '' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
|
||||||
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
|
||||||
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'cron' ? ' is-active' : '' ?>" href="/settings/cron"><?= $e($t('settings.cron.title')) ?></a>
|
||||||
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'gs1' ? ' is-active' : '' ?>" href="/settings/gs1"><?= $e($t('settings.gs1.title')) ?></a>
|
||||||
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'products' ? ' is-active' : '' ?>" href="/settings/products"><?= $e($t('settings.products.title')) ?></a>
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<h2 class="section-title"><?= $e($t('settings.products.title')) ?></h2>
|
||||||
|
<p class="muted mt-12"><?= $e($t('settings.products.description')) ?></p>
|
||||||
|
|
||||||
|
<form action="/settings/products/save" method="post" class="mt-16">
|
||||||
|
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('settings.products.fields.sku_format')) ?></span>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
type="text"
|
||||||
|
name="products_sku_format"
|
||||||
|
maxlength="128"
|
||||||
|
value="<?= $e((string) ($productsSkuFormat ?? 'PP000000')) ?>"
|
||||||
|
placeholder="PP000000"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p class="muted mt-12"><?= $e($t('settings.products.sku_format_hint')) ?></p>
|
||||||
|
|
||||||
|
<div class="form-actions mt-16">
|
||||||
|
<button type="submit" class="btn btn--primary"><?= $e($t('settings.products.actions.save')) ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@@ -18,6 +18,7 @@ use App\Modules\ProductLinks\ProductLinksController;
|
|||||||
use App\Modules\ProductLinks\ProductLinksRepository;
|
use App\Modules\ProductLinks\ProductLinksRepository;
|
||||||
use App\Modules\ProductLinks\ProductLinksService;
|
use App\Modules\ProductLinks\ProductLinksService;
|
||||||
use App\Modules\Products\ProductRepository;
|
use App\Modules\Products\ProductRepository;
|
||||||
|
use App\Modules\Products\ProductSkuGenerator;
|
||||||
use App\Modules\Products\ProductsController;
|
use App\Modules\Products\ProductsController;
|
||||||
use App\Modules\Products\ProductService;
|
use App\Modules\Products\ProductService;
|
||||||
use App\Modules\Products\ProductValidator;
|
use App\Modules\Products\ProductValidator;
|
||||||
@@ -73,6 +74,7 @@ return static function (Application $app): void {
|
|||||||
);
|
);
|
||||||
$productValidator = new ProductValidator();
|
$productValidator = new ProductValidator();
|
||||||
$productService = new ProductService($app->db(), $productRepository, $productValidator);
|
$productService = new ProductService($app->db(), $productRepository, $productValidator);
|
||||||
|
$productSkuGenerator = new ProductSkuGenerator($appSettingsRepository, $productRepository);
|
||||||
$shopProExportService = new ShopProExportService($app->db(), $productRepository, $integrationRepository, $shopProClient);
|
$shopProExportService = new ShopProExportService($app->db(), $productRepository, $integrationRepository, $shopProClient);
|
||||||
$gs1Service = new GS1Service($productRepository, $appSettingsRepository);
|
$gs1Service = new GS1Service($productRepository, $appSettingsRepository);
|
||||||
$productsController = new ProductsController(
|
$productsController = new ProductsController(
|
||||||
@@ -81,6 +83,7 @@ return static function (Application $app): void {
|
|||||||
$auth,
|
$auth,
|
||||||
$productRepository,
|
$productRepository,
|
||||||
$productService,
|
$productService,
|
||||||
|
$productSkuGenerator,
|
||||||
$integrationRepository,
|
$integrationRepository,
|
||||||
$productLinksService,
|
$productLinksService,
|
||||||
$shopProExportService,
|
$shopProExportService,
|
||||||
@@ -93,7 +96,9 @@ return static function (Application $app): void {
|
|||||||
$marketplaceRepository,
|
$marketplaceRepository,
|
||||||
$integrationRepository,
|
$integrationRepository,
|
||||||
$shopProClient,
|
$shopProClient,
|
||||||
$productRepository
|
$productRepository,
|
||||||
|
$productService,
|
||||||
|
$productValidator
|
||||||
);
|
);
|
||||||
$authMiddleware = new AuthMiddleware($auth);
|
$authMiddleware = new AuthMiddleware($auth);
|
||||||
|
|
||||||
@@ -136,6 +141,8 @@ return static function (Application $app): void {
|
|||||||
$router->get('/products/{id}', [$productsController, 'show'], [$authMiddleware]);
|
$router->get('/products/{id}', [$productsController, 'show'], [$authMiddleware]);
|
||||||
$router->get('/marketplace', [$marketplaceController, 'index'], [$authMiddleware]);
|
$router->get('/marketplace', [$marketplaceController, 'index'], [$authMiddleware]);
|
||||||
$router->get('/marketplace/{integration_id}', [$marketplaceController, 'offers'], [$authMiddleware]);
|
$router->get('/marketplace/{integration_id}', [$marketplaceController, 'offers'], [$authMiddleware]);
|
||||||
|
$router->get('/marketplace/{integration_id}/product/{external_product_id}/edit', [$marketplaceController, 'editProduct'], [$authMiddleware]);
|
||||||
|
$router->post('/marketplace/{integration_id}/product/{external_product_id}/update', [$marketplaceController, 'updateProduct'], [$authMiddleware]);
|
||||||
$router->get('/marketplace/{integration_id}/categories', [$marketplaceController, 'categoriesJson'], [$authMiddleware]);
|
$router->get('/marketplace/{integration_id}/categories', [$marketplaceController, 'categoriesJson'], [$authMiddleware]);
|
||||||
$router->get('/marketplace/{integration_id}/product/{external_product_id}/categories', [$marketplaceController, 'productCategoriesJson'], [$authMiddleware]);
|
$router->get('/marketplace/{integration_id}/product/{external_product_id}/categories', [$marketplaceController, 'productCategoriesJson'], [$authMiddleware]);
|
||||||
$router->post('/marketplace/{integration_id}/product/{external_product_id}/categories', [$marketplaceController, 'saveProductCategoriesJson'], [$authMiddleware]);
|
$router->post('/marketplace/{integration_id}/product/{external_product_id}/categories', [$marketplaceController, 'saveProductCategoriesJson'], [$authMiddleware]);
|
||||||
@@ -150,6 +157,7 @@ return static function (Application $app): void {
|
|||||||
$router->post('/products/images/upload', [$productsController, 'uploadImage'], [$authMiddleware]);
|
$router->post('/products/images/upload', [$productsController, 'uploadImage'], [$authMiddleware]);
|
||||||
$router->post('/products/images/set-main', [$productsController, 'setMainImage'], [$authMiddleware]);
|
$router->post('/products/images/set-main', [$productsController, 'setMainImage'], [$authMiddleware]);
|
||||||
$router->post('/products/images/delete', [$productsController, 'deleteImage'], [$authMiddleware]);
|
$router->post('/products/images/delete', [$productsController, 'deleteImage'], [$authMiddleware]);
|
||||||
|
$router->post('/products/next-sku', [$productsController, 'nextSku'], [$authMiddleware]);
|
||||||
$router->post('/products/links/create', [$productLinksController, 'create'], [$authMiddleware]);
|
$router->post('/products/links/create', [$productLinksController, 'create'], [$authMiddleware]);
|
||||||
$router->post('/products/links/relink', [$productLinksController, 'relink'], [$authMiddleware]);
|
$router->post('/products/links/relink', [$productLinksController, 'relink'], [$authMiddleware]);
|
||||||
$router->post('/products/links/unlink', [$productLinksController, 'unlink'], [$authMiddleware]);
|
$router->post('/products/links/unlink', [$productLinksController, 'unlink'], [$authMiddleware]);
|
||||||
@@ -175,4 +183,6 @@ return static function (Application $app): void {
|
|||||||
$router->post('/settings/cron/save', [$settingsController, 'saveCronSettings'], [$authMiddleware]);
|
$router->post('/settings/cron/save', [$settingsController, 'saveCronSettings'], [$authMiddleware]);
|
||||||
$router->get('/settings/gs1', [$settingsController, 'gs1'], [$authMiddleware]);
|
$router->get('/settings/gs1', [$settingsController, 'gs1'], [$authMiddleware]);
|
||||||
$router->post('/settings/gs1/save', [$settingsController, 'gs1Save'], [$authMiddleware]);
|
$router->post('/settings/gs1/save', [$settingsController, 'gs1Save'], [$authMiddleware]);
|
||||||
|
$router->get('/settings/products', [$settingsController, 'products'], [$authMiddleware]);
|
||||||
|
$router->post('/settings/products/save', [$settingsController, 'productsSave'], [$authMiddleware]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use App\Modules\Cron\CronJobProcessor;
|
|||||||
use App\Modules\Cron\CronJobRepository;
|
use App\Modules\Cron\CronJobRepository;
|
||||||
use App\Modules\Cron\CronJobType;
|
use App\Modules\Cron\CronJobType;
|
||||||
use App\Modules\Cron\ProductLinksHealthCheckHandler;
|
use App\Modules\Cron\ProductLinksHealthCheckHandler;
|
||||||
|
use App\Modules\Cron\ShopProOfferTitlesRefreshHandler;
|
||||||
use App\Modules\ProductLinks\ChannelOffersRepository;
|
use App\Modules\ProductLinks\ChannelOffersRepository;
|
||||||
use App\Modules\ProductLinks\OfferImportService;
|
use App\Modules\ProductLinks\OfferImportService;
|
||||||
use App\Modules\ProductLinks\ProductLinksRepository;
|
use App\Modules\ProductLinks\ProductLinksRepository;
|
||||||
@@ -261,8 +262,13 @@ final class Application
|
|||||||
$linksRepository,
|
$linksRepository,
|
||||||
$offersRepository
|
$offersRepository
|
||||||
);
|
);
|
||||||
|
$offerTitlesRefreshHandler = new ShopProOfferTitlesRefreshHandler(
|
||||||
|
$integrationRepository,
|
||||||
|
$offerImportService
|
||||||
|
);
|
||||||
|
|
||||||
$processor->registerHandler(CronJobType::PRODUCT_LINKS_HEALTH_CHECK, $linksHealthCheckHandler);
|
$processor->registerHandler(CronJobType::PRODUCT_LINKS_HEALTH_CHECK, $linksHealthCheckHandler);
|
||||||
|
$processor->registerHandler(CronJobType::SHOPPRO_OFFER_TITLES_REFRESH, $offerTitlesRefreshHandler);
|
||||||
$result = $processor->run($limit);
|
$result = $processor->run($limit);
|
||||||
|
|
||||||
$this->logger->info('Cron web run completed', $result);
|
$this->logger->info('Cron web run completed', $result);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Modules\Cron;
|
|||||||
final class CronJobType
|
final class CronJobType
|
||||||
{
|
{
|
||||||
public const PRODUCT_LINKS_HEALTH_CHECK = 'product_links_health_check';
|
public const PRODUCT_LINKS_HEALTH_CHECK = 'product_links_health_check';
|
||||||
|
public const SHOPPRO_OFFER_TITLES_REFRESH = 'shoppro_offer_titles_refresh';
|
||||||
|
|
||||||
public const PRIORITY_HIGH = 50;
|
public const PRIORITY_HIGH = 50;
|
||||||
public const PRIORITY_NORMAL = 100;
|
public const PRIORITY_NORMAL = 100;
|
||||||
@@ -15,6 +16,7 @@ final class CronJobType
|
|||||||
{
|
{
|
||||||
return match (trim($jobType)) {
|
return match (trim($jobType)) {
|
||||||
self::PRODUCT_LINKS_HEALTH_CHECK => 110,
|
self::PRODUCT_LINKS_HEALTH_CHECK => 110,
|
||||||
|
self::SHOPPRO_OFFER_TITLES_REFRESH => 170,
|
||||||
default => self::PRIORITY_NORMAL,
|
default => self::PRIORITY_NORMAL,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -23,6 +25,7 @@ final class CronJobType
|
|||||||
{
|
{
|
||||||
return match (trim($jobType)) {
|
return match (trim($jobType)) {
|
||||||
self::PRODUCT_LINKS_HEALTH_CHECK => 3,
|
self::PRODUCT_LINKS_HEALTH_CHECK => 3,
|
||||||
|
self::SHOPPRO_OFFER_TITLES_REFRESH => 3,
|
||||||
default => 3,
|
default => 3,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
106
src/Modules/Cron/ShopProOfferTitlesRefreshHandler.php
Normal file
106
src/Modules/Cron/ShopProOfferTitlesRefreshHandler.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Cron;
|
||||||
|
|
||||||
|
use App\Modules\ProductLinks\OfferImportService;
|
||||||
|
use App\Modules\Settings\IntegrationRepository;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class ShopProOfferTitlesRefreshHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly IntegrationRepository $integrations,
|
||||||
|
private readonly OfferImportService $offerImportService
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @param array<string, mixed> $job
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function __invoke(array $payload = [], array $job = []): array
|
||||||
|
{
|
||||||
|
$forcedIntegrationId = max(0, (int) ($payload['integration_id'] ?? 0));
|
||||||
|
$activeIntegrations = array_values(array_filter(
|
||||||
|
$this->integrations->listByType('shoppro'),
|
||||||
|
static function (array $integration) use ($forcedIntegrationId): bool {
|
||||||
|
$id = (int) ($integration['id'] ?? 0);
|
||||||
|
if ($forcedIntegrationId > 0 && $id !== $forcedIntegrationId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $id > 0
|
||||||
|
&& ($integration['is_active'] ?? false) === true
|
||||||
|
&& ($integration['has_api_key'] ?? false) === true;
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($activeIntegrations === []) {
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'message' => 'Brak aktywnych integracji z kluczem API do odswiezenia tytulow ofert.',
|
||||||
|
'integrations' => 0,
|
||||||
|
'updated_offers' => 0,
|
||||||
|
'failed_offers' => 0,
|
||||||
|
'integration_failures' => 0,
|
||||||
|
'errors' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$updatedOffers = 0;
|
||||||
|
$failedOffers = 0;
|
||||||
|
$integrationFailures = 0;
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($activeIntegrations as $integration) {
|
||||||
|
$integrationId = (int) ($integration['id'] ?? 0);
|
||||||
|
if ($integrationId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$credentials = $this->integrations->findApiCredentials($integrationId);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$integrationFailures++;
|
||||||
|
if (count($errors) < 5) {
|
||||||
|
$errors[] = 'Integracja #' . $integrationId . ': ' . $exception->getMessage();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($credentials === null || trim((string) ($credentials['api_key'] ?? '')) === '') {
|
||||||
|
$integrationFailures++;
|
||||||
|
if (count($errors) < 5) {
|
||||||
|
$errors[] = 'Integracja #' . $integrationId . ': brak poprawnych danych API.';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$import = $this->offerImportService->importShopProOffers($credentials);
|
||||||
|
if (($import['ok'] ?? false) !== true) {
|
||||||
|
$integrationFailures++;
|
||||||
|
if (count($errors) < 5) {
|
||||||
|
$errors[] = 'Integracja #' . $integrationId . ': ' . trim((string) ($import['message'] ?? 'Blad importu ofert.'));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updatedOffers += (int) ($import['imported'] ?? 0);
|
||||||
|
$failedOffers += (int) ($import['failed'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => $integrationFailures === 0,
|
||||||
|
'message' => $integrationFailures === 0
|
||||||
|
? 'Odswiezenie tytulow ofert zakonczone.'
|
||||||
|
: 'Odswiezenie tytulow zakonczone z bledami integracji.',
|
||||||
|
'integrations' => count($activeIntegrations),
|
||||||
|
'updated_offers' => $updatedOffers,
|
||||||
|
'failed_offers' => $failedOffers,
|
||||||
|
'integration_failures' => $integrationFailures,
|
||||||
|
'errors' => $errors,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ use App\Core\Support\Flash;
|
|||||||
use App\Core\View\Template;
|
use App\Core\View\Template;
|
||||||
use App\Modules\Auth\AuthService;
|
use App\Modules\Auth\AuthService;
|
||||||
use App\Modules\Products\ProductRepository;
|
use App\Modules\Products\ProductRepository;
|
||||||
|
use App\Modules\Products\ProductService;
|
||||||
|
use App\Modules\Products\ProductValidator;
|
||||||
use App\Modules\Settings\IntegrationRepository;
|
use App\Modules\Settings\IntegrationRepository;
|
||||||
use App\Modules\Settings\ShopProClient;
|
use App\Modules\Settings\ShopProClient;
|
||||||
|
|
||||||
@@ -23,7 +25,9 @@ final class MarketplaceController
|
|||||||
private readonly MarketplaceRepository $marketplace,
|
private readonly MarketplaceRepository $marketplace,
|
||||||
private readonly IntegrationRepository $integrationRepository,
|
private readonly IntegrationRepository $integrationRepository,
|
||||||
private readonly ShopProClient $shopProClient,
|
private readonly ShopProClient $shopProClient,
|
||||||
private readonly ProductRepository $productRepository
|
private readonly ProductRepository $productRepository,
|
||||||
|
private readonly ProductService $productService,
|
||||||
|
private readonly ProductValidator $productValidator
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +64,18 @@ final class MarketplaceController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$integrations = $this->marketplace->listActiveIntegrationsWithCounts();
|
$integrations = $this->marketplace->listActiveIntegrationsWithCounts();
|
||||||
$offers = $this->marketplace->listLinkedOffersByIntegration($integrationId);
|
$filtersValues = [
|
||||||
|
'search' => trim((string) $request->input('search', '')),
|
||||||
|
'channel' => trim((string) $request->input('channel', '')),
|
||||||
|
'sort' => (string) $request->input('sort', 'updated_at'),
|
||||||
|
'sort_dir' => (string) $request->input('sort_dir', 'DESC'),
|
||||||
|
'page' => max(1, (int) $request->input('page', 1)),
|
||||||
|
'per_page' => max(1, min(100, (int) $request->input('per_page', 20))),
|
||||||
|
];
|
||||||
|
$offersResult = $this->marketplace->paginateLinkedOffersByIntegration($integrationId, $filtersValues, 'pl');
|
||||||
|
$offers = (array) ($offersResult['items'] ?? []);
|
||||||
|
$totalPages = max(1, (int) ceil(((int) ($offersResult['total'] ?? 0)) / (int) ($offersResult['per_page'] ?? 20)));
|
||||||
|
$channelOptions = $this->marketplace->listOfferChannelsByIntegration($integrationId);
|
||||||
|
|
||||||
$html = $this->template->render('marketplace/offers', [
|
$html = $this->template->render('marketplace/offers', [
|
||||||
'title' => $this->translator->get('marketplace.offers_title', ['name' => (string) ($integration['name'] ?? '')]),
|
'title' => $this->translator->get('marketplace.offers_title', ['name' => (string) ($integration['name'] ?? '')]),
|
||||||
@@ -71,12 +86,223 @@ final class MarketplaceController
|
|||||||
'marketplaceIntegrations' => $integrations,
|
'marketplaceIntegrations' => $integrations,
|
||||||
'integration' => $integration,
|
'integration' => $integration,
|
||||||
'offers' => $offers,
|
'offers' => $offers,
|
||||||
|
'filters' => $filtersValues,
|
||||||
|
'channelOptions' => $channelOptions,
|
||||||
|
'pagination' => [
|
||||||
|
'page' => (int) ($offersResult['page'] ?? 1),
|
||||||
|
'total_pages' => $totalPages,
|
||||||
|
'total' => (int) ($offersResult['total'] ?? 0),
|
||||||
|
'per_page' => (int) ($offersResult['per_page'] ?? 20),
|
||||||
|
],
|
||||||
'errorMessage' => (string) Flash::get('marketplace_error', ''),
|
'errorMessage' => (string) Flash::get('marketplace_error', ''),
|
||||||
|
'successMessage' => (string) Flash::get('marketplace_success', ''),
|
||||||
], 'layouts/app');
|
], 'layouts/app');
|
||||||
|
|
||||||
return Response::html($html);
|
return Response::html($html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function editProduct(Request $request): Response
|
||||||
|
{
|
||||||
|
$integrationId = max(0, (int) $request->input('integration_id', 0));
|
||||||
|
$externalProductId = max(0, (int) $request->input('external_product_id', 0));
|
||||||
|
if ($integrationId <= 0 || $externalProductId <= 0) {
|
||||||
|
Flash::set('marketplace_error', $this->translator->get('marketplace.flash.integration_not_found'));
|
||||||
|
return Response::redirect('/marketplace');
|
||||||
|
}
|
||||||
|
|
||||||
|
$integration = $this->marketplace->findActiveIntegrationById($integrationId);
|
||||||
|
if ($integration === null) {
|
||||||
|
Flash::set('marketplace_error', $this->translator->get('marketplace.flash.integration_not_found'));
|
||||||
|
return Response::redirect('/marketplace');
|
||||||
|
}
|
||||||
|
|
||||||
|
$credentials = $this->integrationRepository->findApiCredentials($integrationId);
|
||||||
|
if ($credentials === null) {
|
||||||
|
Flash::set('marketplace_error', $this->translator->get('marketplace.flash.integration_not_found'));
|
||||||
|
return Response::redirect('/marketplace/' . $integrationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$localProductId = $this->integrationRepository->findMappedProductId(
|
||||||
|
'shoppro',
|
||||||
|
(string) $externalProductId,
|
||||||
|
$integrationId
|
||||||
|
);
|
||||||
|
if ($localProductId === null || $localProductId <= 0) {
|
||||||
|
Flash::set('marketplace_error', $this->translator->get('products.flash.not_found'));
|
||||||
|
return Response::redirect('/marketplace/' . $integrationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$remoteResult = $this->shopProClient->fetchProductById(
|
||||||
|
(string) ($credentials['base_url'] ?? ''),
|
||||||
|
(string) ($credentials['api_key'] ?? ''),
|
||||||
|
(int) ($credentials['timeout_seconds'] ?? 10),
|
||||||
|
$externalProductId
|
||||||
|
);
|
||||||
|
if (($remoteResult['ok'] ?? false) !== true) {
|
||||||
|
Flash::set('marketplace_error', (string) ($remoteResult['message'] ?? 'Nie mozna pobrac produktu z integracji.'));
|
||||||
|
return Response::redirect('/marketplace/' . $integrationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$externalProduct = is_array($remoteResult['product'] ?? null) ? $remoteResult['product'] : null;
|
||||||
|
if ($externalProduct === null) {
|
||||||
|
Flash::set('marketplace_error', 'Brak danych produktu z integracji.');
|
||||||
|
return Response::redirect('/marketplace/' . $integrationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = $this->mapExternalProductToForm($externalProduct, $externalProductId);
|
||||||
|
$old = (array) Flash::get('products_form_old', []);
|
||||||
|
if ($old !== []) {
|
||||||
|
$form = array_merge($form, $old);
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeIntegrations = $this->integrationRepository->listByType('shoppro');
|
||||||
|
$integrationTranslationsMap = [];
|
||||||
|
foreach ($this->productRepository->findIntegrationTranslations($localProductId) as $row) {
|
||||||
|
$integrationTranslationsMap[(int) ($row['integration_id'] ?? 0)] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lang = $this->resolveProductLanguage($externalProduct);
|
||||||
|
$integrationTranslationsMap[$integrationId] = [
|
||||||
|
'integration_id' => $integrationId,
|
||||||
|
'name' => trim((string) ($lang['name'] ?? '')),
|
||||||
|
'short_description' => trim((string) ($lang['short_description'] ?? '')),
|
||||||
|
'description' => trim((string) ($lang['description'] ?? '')),
|
||||||
|
];
|
||||||
|
|
||||||
|
$html = $this->template->render('products/edit', [
|
||||||
|
'title' => 'Edycja produktu z integracji #' . $externalProductId,
|
||||||
|
'activeMenu' => 'marketplace',
|
||||||
|
'user' => $this->auth->user(),
|
||||||
|
'csrfToken' => Csrf::token(),
|
||||||
|
'marketplaceIntegrations' => $this->marketplace->listActiveIntegrationsWithCounts(),
|
||||||
|
'productId' => $localProductId,
|
||||||
|
'form' => $form,
|
||||||
|
'productImages' => [],
|
||||||
|
'errors' => (array) Flash::get('products_form_errors', []),
|
||||||
|
'activeIntegrations' => $activeIntegrations,
|
||||||
|
'integrationTranslationsMap' => $integrationTranslationsMap,
|
||||||
|
'productFormAction' => $this->editPath($integrationId, $externalProductId, true),
|
||||||
|
'productBackUrl' => '/marketplace/' . $integrationId,
|
||||||
|
'integrationEditMode' => true,
|
||||||
|
'initialContentTab' => 'integration-' . $integrationId,
|
||||||
|
], 'layouts/app');
|
||||||
|
|
||||||
|
return Response::html($html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateProduct(Request $request): Response
|
||||||
|
{
|
||||||
|
$csrfToken = (string) $request->input('_token', '');
|
||||||
|
if (!Csrf::validate($csrfToken)) {
|
||||||
|
Flash::set('marketplace_error', $this->translator->get('auth.errors.csrf_expired'));
|
||||||
|
return Response::redirect('/marketplace');
|
||||||
|
}
|
||||||
|
|
||||||
|
$integrationId = max(0, (int) $request->input('integration_id', 0));
|
||||||
|
$externalProductId = max(0, (int) $request->input('external_product_id', 0));
|
||||||
|
if ($integrationId <= 0 || $externalProductId <= 0) {
|
||||||
|
Flash::set('marketplace_error', $this->translator->get('marketplace.flash.integration_not_found'));
|
||||||
|
return Response::redirect('/marketplace');
|
||||||
|
}
|
||||||
|
|
||||||
|
$editPath = $this->editPath($integrationId, $externalProductId, false);
|
||||||
|
$integration = $this->marketplace->findActiveIntegrationById($integrationId);
|
||||||
|
if ($integration === null) {
|
||||||
|
Flash::set('marketplace_error', $this->translator->get('marketplace.flash.integration_not_found'));
|
||||||
|
return Response::redirect('/marketplace');
|
||||||
|
}
|
||||||
|
|
||||||
|
$credentials = $this->integrationRepository->findApiCredentials($integrationId);
|
||||||
|
if ($credentials === null) {
|
||||||
|
Flash::set('marketplace_error', $this->translator->get('marketplace.flash.integration_not_found'));
|
||||||
|
return Response::redirect('/marketplace/' . $integrationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$localProductId = $this->integrationRepository->findMappedProductId(
|
||||||
|
'shoppro',
|
||||||
|
(string) $externalProductId,
|
||||||
|
$integrationId
|
||||||
|
);
|
||||||
|
if ($localProductId === null || $localProductId <= 0) {
|
||||||
|
Flash::set('marketplace_error', $this->translator->get('products.flash.not_found'));
|
||||||
|
return Response::redirect('/marketplace/' . $integrationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->payloadFromRequest($request);
|
||||||
|
Flash::set('products_form_old', $payload);
|
||||||
|
|
||||||
|
$validationErrors = $this->productValidator->validate($payload, true);
|
||||||
|
$sku = trim((string) ($payload['sku'] ?? ''));
|
||||||
|
if ($sku !== '' && $this->productRepository->existsSku($sku, $localProductId)) {
|
||||||
|
$validationErrors[] = 'Podane SKU produktu jest juz zajete.';
|
||||||
|
}
|
||||||
|
if ($validationErrors !== []) {
|
||||||
|
Flash::set('products_form_errors', $validationErrors);
|
||||||
|
return Response::redirect($editPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedIntegrationIds = array_map(
|
||||||
|
static fn (array $i): int => (int) ($i['id'] ?? 0),
|
||||||
|
$this->integrationRepository->listByType('shoppro')
|
||||||
|
);
|
||||||
|
$integrationContent = $request->input('integration_content', []);
|
||||||
|
$remotePayload = $this->buildRemoteUpdatePayload(
|
||||||
|
$payload,
|
||||||
|
is_array($integrationContent) ? $integrationContent : [],
|
||||||
|
$integrationId,
|
||||||
|
$localProductId
|
||||||
|
);
|
||||||
|
$remoteUpdate = $this->shopProClient->updateProduct(
|
||||||
|
(string) ($credentials['base_url'] ?? ''),
|
||||||
|
(string) ($credentials['api_key'] ?? ''),
|
||||||
|
(int) ($credentials['timeout_seconds'] ?? 10),
|
||||||
|
$externalProductId,
|
||||||
|
$remotePayload
|
||||||
|
);
|
||||||
|
if (($remoteUpdate['ok'] ?? false) !== true) {
|
||||||
|
Flash::set('products_form_errors', [(string) ($remoteUpdate['message'] ?? 'Nie mozna zapisac produktu w shopPRO.')]);
|
||||||
|
return Response::redirect($editPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$updatedOfferName = trim((string) ($remotePayload['languages']['pl']['name'] ?? ''));
|
||||||
|
if ($updatedOfferName !== '') {
|
||||||
|
$this->marketplace->updateCachedOfferNameForExternalProduct(
|
||||||
|
$integrationId,
|
||||||
|
(string) $externalProductId,
|
||||||
|
$updatedOfferName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->productService->update($localProductId, $payload, $this->auth->user());
|
||||||
|
if (($result['ok'] ?? false) !== true) {
|
||||||
|
Flash::set('products_form_errors', (array) ($result['errors'] ?? ['Nie udalo sie zapisac lokalnych zmian produktu.']));
|
||||||
|
return Response::redirect($editPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($integrationContent)) {
|
||||||
|
foreach ($integrationContent as $rawIntegrationId => $content) {
|
||||||
|
$contentIntegrationId = (int) $rawIntegrationId;
|
||||||
|
if ($contentIntegrationId <= 0 || !is_array($content) || !in_array($contentIntegrationId, $allowedIntegrationIds, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->productRepository->upsertIntegrationTranslation(
|
||||||
|
$localProductId,
|
||||||
|
$contentIntegrationId,
|
||||||
|
isset($content['name']) ? trim((string) $content['name']) : null,
|
||||||
|
isset($content['short_description']) ? trim((string) $content['short_description']) : null,
|
||||||
|
isset($content['description']) ? trim((string) $content['description']) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Flash::set('products_form_old', []);
|
||||||
|
Flash::set('products_form_errors', []);
|
||||||
|
Flash::set('marketplace_success', $this->translator->get('marketplace.flash.product_updated'));
|
||||||
|
|
||||||
|
return Response::redirect('/marketplace/' . $integrationId);
|
||||||
|
}
|
||||||
|
|
||||||
public function categoriesJson(Request $request): Response
|
public function categoriesJson(Request $request): Response
|
||||||
{
|
{
|
||||||
$integrationId = max(0, (int) $request->input('integration_id', 0));
|
$integrationId = max(0, (int) $request->input('integration_id', 0));
|
||||||
@@ -227,5 +453,177 @@ final class MarketplaceController
|
|||||||
|
|
||||||
return Response::json(['ok' => true]);
|
return Response::json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function editPath(int $integrationId, int $externalProductId, bool $forRenderAction): string
|
||||||
|
{
|
||||||
|
$suffix = $forRenderAction ? 'update' : 'edit';
|
||||||
|
return '/marketplace/' . $integrationId . '/product/' . $externalProductId . '/' . $suffix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $externalProduct
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function mapExternalProductToForm(array $externalProduct, int $externalProductId): array
|
||||||
|
{
|
||||||
|
$lang = $this->resolveProductLanguage($externalProduct);
|
||||||
|
$name = trim((string) ($lang['name'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
$name = 'shopPRO #' . $externalProductId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => (array_key_exists('variants', $externalProduct) && is_array($externalProduct['variants']) && $externalProduct['variants'] !== [])
|
||||||
|
? 'variant_parent'
|
||||||
|
: 'simple',
|
||||||
|
'name' => $name,
|
||||||
|
'sku' => trim((string) ($externalProduct['sku'] ?? '')),
|
||||||
|
'ean' => trim((string) ($externalProduct['ean'] ?? '')),
|
||||||
|
'status' => ((int) ($externalProduct['status'] ?? 1)) === 1 ? '1' : '0',
|
||||||
|
'promoted' => ((int) ($externalProduct['promoted'] ?? 0)) === 1 ? '1' : '0',
|
||||||
|
'vat' => (string) ($externalProduct['vat'] ?? ''),
|
||||||
|
'weight' => (string) ($externalProduct['weight'] ?? ''),
|
||||||
|
'quantity' => (string) ($externalProduct['quantity'] ?? '0'),
|
||||||
|
'price_input_mode' => 'brutto',
|
||||||
|
'price_brutto' => (string) ($externalProduct['price_brutto'] ?? ''),
|
||||||
|
'price_netto' => (string) ($externalProduct['price_netto'] ?? ''),
|
||||||
|
'price_brutto_promo' => (string) ($externalProduct['price_brutto_promo'] ?? ''),
|
||||||
|
'price_netto_promo' => (string) ($externalProduct['price_netto_promo'] ?? ''),
|
||||||
|
'short_description' => trim((string) ($lang['short_description'] ?? '')),
|
||||||
|
'description' => trim((string) ($lang['description'] ?? '')),
|
||||||
|
'meta_title' => trim((string) ($lang['meta_title'] ?? '')),
|
||||||
|
'meta_description' => trim((string) ($lang['meta_description'] ?? '')),
|
||||||
|
'meta_keywords' => trim((string) ($lang['meta_keywords'] ?? '')),
|
||||||
|
'seo_link' => trim((string) ($lang['seo_link'] ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $externalProduct
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function resolveProductLanguage(array $externalProduct): array
|
||||||
|
{
|
||||||
|
$languages = $externalProduct['languages'] ?? null;
|
||||||
|
if (!is_array($languages)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($languages['pl']) && is_array($languages['pl'])) {
|
||||||
|
return $languages['pl'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($languages as $language) {
|
||||||
|
if (is_array($language)) {
|
||||||
|
return $language;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function payloadFromRequest(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => (string) $request->input('type', 'simple'),
|
||||||
|
'name' => (string) $request->input('name', ''),
|
||||||
|
'sku' => (string) $request->input('sku', ''),
|
||||||
|
'ean' => (string) $request->input('ean', ''),
|
||||||
|
'status' => (string) $request->input('status', '1'),
|
||||||
|
'promoted' => (string) $request->input('promoted', '0'),
|
||||||
|
'vat' => (string) $request->input('vat', '23'),
|
||||||
|
'weight' => (string) $request->input('weight', ''),
|
||||||
|
'quantity' => (string) $request->input('quantity', '0'),
|
||||||
|
'price_input_mode' => (string) $request->input('price_input_mode', 'brutto'),
|
||||||
|
'price_brutto' => (string) $request->input('price_brutto', ''),
|
||||||
|
'price_netto' => (string) $request->input('price_netto', ''),
|
||||||
|
'price_brutto_promo' => (string) $request->input('price_brutto_promo', ''),
|
||||||
|
'price_netto_promo' => (string) $request->input('price_netto_promo', ''),
|
||||||
|
'short_description' => (string) $request->input('short_description', ''),
|
||||||
|
'description' => (string) $request->input('description', ''),
|
||||||
|
'meta_title' => (string) $request->input('meta_title', ''),
|
||||||
|
'meta_description' => (string) $request->input('meta_description', ''),
|
||||||
|
'meta_keywords' => (string) $request->input('meta_keywords', ''),
|
||||||
|
'seo_link' => (string) $request->input('seo_link', ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @param array<string, mixed> $integrationContent
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function buildRemoteUpdatePayload(
|
||||||
|
array $payload,
|
||||||
|
array $integrationContent,
|
||||||
|
int $integrationId,
|
||||||
|
int $localProductId
|
||||||
|
): array {
|
||||||
|
$integrationOverride = $integrationContent[$integrationId] ?? null;
|
||||||
|
$overrideName = is_array($integrationOverride) ? trim((string) ($integrationOverride['name'] ?? '')) : '';
|
||||||
|
$overrideShort = is_array($integrationOverride) ? trim((string) ($integrationOverride['short_description'] ?? '')) : '';
|
||||||
|
$overrideDesc = is_array($integrationOverride) ? trim((string) ($integrationOverride['description'] ?? '')) : '';
|
||||||
|
|
||||||
|
$name = trim((string) ($payload['name'] ?? ''));
|
||||||
|
if ($overrideName !== '') {
|
||||||
|
$name = $overrideName;
|
||||||
|
}
|
||||||
|
if ($name === '') {
|
||||||
|
$name = 'orderPRO #' . $localProductId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$shortDescription = trim((string) ($payload['short_description'] ?? ''));
|
||||||
|
if ($overrideShort !== '') {
|
||||||
|
$shortDescription = $overrideShort;
|
||||||
|
}
|
||||||
|
|
||||||
|
$description = trim((string) ($payload['description'] ?? ''));
|
||||||
|
if ($overrideDesc !== '') {
|
||||||
|
$description = $overrideDesc;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'price_brutto' => round((float) ($payload['price_brutto'] ?? 0), 2),
|
||||||
|
'price_brutto_promo' => $this->nullableFloat($payload['price_brutto_promo'] ?? null, 2),
|
||||||
|
'price_netto' => $this->nullableFloat($payload['price_netto'] ?? null, 2),
|
||||||
|
'price_netto_promo' => $this->nullableFloat($payload['price_netto_promo'] ?? null, 2),
|
||||||
|
'vat' => $this->nullableFloat($payload['vat'] ?? null, 2),
|
||||||
|
'quantity' => round((float) ($payload['quantity'] ?? 0), 3),
|
||||||
|
'status' => ((int) ($payload['status'] ?? 0)) === 1 ? 1 : 0,
|
||||||
|
'promoted' => ((int) ($payload['promoted'] ?? 0)) === 1 ? 1 : 0,
|
||||||
|
'sku' => $this->nullableText($payload['sku'] ?? null),
|
||||||
|
'ean' => $this->nullableText($payload['ean'] ?? null),
|
||||||
|
'weight' => $this->nullableFloat($payload['weight'] ?? null, 3),
|
||||||
|
'languages' => [
|
||||||
|
'pl' => [
|
||||||
|
'name' => $name,
|
||||||
|
'short_description' => $this->nullableText($shortDescription),
|
||||||
|
'description' => $this->nullableText($description),
|
||||||
|
'meta_title' => $this->nullableText($payload['meta_title'] ?? null),
|
||||||
|
'meta_description' => $this->nullableText($payload['meta_description'] ?? null),
|
||||||
|
'meta_keywords' => $this->nullableText($payload['meta_keywords'] ?? null),
|
||||||
|
'seo_link' => $this->nullableText($payload['seo_link'] ?? null),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableText(mixed $value): ?string
|
||||||
|
{
|
||||||
|
$text = trim((string) $value);
|
||||||
|
return $text === '' ? null : $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableFloat(mixed $value, int $precision = 2): ?float
|
||||||
|
{
|
||||||
|
$text = trim((string) $value);
|
||||||
|
if ($text === '' || !is_numeric($text)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round((float) $text, $precision);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,10 +77,36 @@ final class MarketplaceRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array<string, mixed>>
|
* @param array<string, mixed> $filters
|
||||||
|
* @return array{items:array<int, array<string, mixed>>, total:int, page:int, per_page:int}
|
||||||
*/
|
*/
|
||||||
public function listLinkedOffersByIntegration(int $integrationId): array
|
public function paginateLinkedOffersByIntegration(int $integrationId, array $filters, string $lang = 'pl'): array
|
||||||
{
|
{
|
||||||
|
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||||
|
$perPage = max(1, min(100, (int) ($filters['per_page'] ?? 20)));
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
[$whereSql, $params] = $this->buildOfferFilters($integrationId, $filters);
|
||||||
|
$sort = $this->resolveOfferSort((string) ($filters['sort'] ?? 'updated_at'));
|
||||||
|
$sortDir = strtoupper((string) ($filters['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
|
$countStmt = $this->pdo->prepare(
|
||||||
|
'SELECT COUNT(*)
|
||||||
|
FROM product_channel_map pcm
|
||||||
|
INNER JOIN products p ON p.id = pcm.product_id AND p.deleted_at IS NULL
|
||||||
|
LEFT JOIN product_translations pt ON pt.product_id = p.id AND pt.lang = :lang_count
|
||||||
|
LEFT JOIN sales_channels sc ON sc.id = pcm.channel_id
|
||||||
|
LEFT JOIN channel_offers co
|
||||||
|
ON co.integration_id = pcm.integration_id
|
||||||
|
AND co.external_product_id = pcm.external_product_id
|
||||||
|
AND (
|
||||||
|
(co.external_variant_id IS NULL AND pcm.external_variant_id IS NULL)
|
||||||
|
OR co.external_variant_id = pcm.external_variant_id
|
||||||
|
)
|
||||||
|
' . $whereSql
|
||||||
|
);
|
||||||
|
$countStmt->execute(array_merge(['lang_count' => $lang], $params));
|
||||||
|
$total = (int) $countStmt->fetchColumn();
|
||||||
|
|
||||||
$statement = $this->pdo->prepare(
|
$statement = $this->pdo->prepare(
|
||||||
'SELECT pcm.id,
|
'SELECT pcm.id,
|
||||||
pcm.product_id,
|
pcm.product_id,
|
||||||
@@ -104,24 +130,24 @@ final class MarketplaceRepository
|
|||||||
(co.external_variant_id IS NULL AND pcm.external_variant_id IS NULL)
|
(co.external_variant_id IS NULL AND pcm.external_variant_id IS NULL)
|
||||||
OR co.external_variant_id = pcm.external_variant_id
|
OR co.external_variant_id = pcm.external_variant_id
|
||||||
)
|
)
|
||||||
WHERE pcm.integration_id = :integration_id
|
' . $whereSql . '
|
||||||
AND pcm.link_status = :link_status
|
ORDER BY ' . $sort . ' ' . $sortDir . '
|
||||||
AND pcm.external_product_id IS NOT NULL
|
LIMIT :limit OFFSET :offset'
|
||||||
AND pcm.external_product_id <> ""
|
|
||||||
ORDER BY pcm.updated_at DESC, pcm.id DESC'
|
|
||||||
);
|
);
|
||||||
$statement->execute([
|
foreach (array_merge(['lang' => $lang], $params) as $key => $value) {
|
||||||
'lang' => 'pl',
|
$statement->bindValue(':' . $key, $value);
|
||||||
'integration_id' => $integrationId,
|
}
|
||||||
'link_status' => 'active',
|
$statement->bindValue(':limit', $perPage, PDO::PARAM_INT);
|
||||||
]);
|
$statement->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
$rows = $statement->fetchAll();
|
$rows = $statement->fetchAll();
|
||||||
if (!is_array($rows)) {
|
if (!is_array($rows)) {
|
||||||
return [];
|
$rows = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_map(
|
return [
|
||||||
|
'items' => array_map(
|
||||||
static fn (array $row): array => [
|
static fn (array $row): array => [
|
||||||
'id' => (int) ($row['id'] ?? 0),
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
'product_id' => (int) ($row['product_id'] ?? 0),
|
'product_id' => (int) ($row['product_id'] ?? 0),
|
||||||
@@ -136,7 +162,118 @@ final class MarketplaceRepository
|
|||||||
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
||||||
],
|
],
|
||||||
$rows
|
$rows
|
||||||
);
|
),
|
||||||
}
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function listOfferChannelsByIntegration(int $integrationId): array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT DISTINCT sc.name
|
||||||
|
FROM product_channel_map pcm
|
||||||
|
LEFT JOIN sales_channels sc ON sc.id = pcm.channel_id
|
||||||
|
WHERE pcm.integration_id = :integration_id
|
||||||
|
AND pcm.link_status = :link_status
|
||||||
|
AND pcm.external_product_id IS NOT NULL
|
||||||
|
AND pcm.external_product_id <> ""
|
||||||
|
AND sc.name IS NOT NULL
|
||||||
|
AND sc.name <> ""
|
||||||
|
ORDER BY sc.name ASC'
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
'integration_id' => $integrationId,
|
||||||
|
'link_status' => 'active',
|
||||||
|
]);
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(array_map(
|
||||||
|
static fn (mixed $v): string => trim((string) $v),
|
||||||
|
$rows
|
||||||
|
), static fn (string $v): bool => $v !== ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
* @return array{0:string,1:array<string,mixed>}
|
||||||
|
*/
|
||||||
|
private function buildOfferFilters(int $integrationId, array $filters): array
|
||||||
|
{
|
||||||
|
$where = [
|
||||||
|
'pcm.integration_id = :integration_id',
|
||||||
|
'pcm.link_status = :link_status',
|
||||||
|
'pcm.external_product_id IS NOT NULL',
|
||||||
|
'pcm.external_product_id <> ""',
|
||||||
|
];
|
||||||
|
$params = [
|
||||||
|
'integration_id' => $integrationId,
|
||||||
|
'link_status' => 'active',
|
||||||
|
];
|
||||||
|
|
||||||
|
$search = trim((string) ($filters['search'] ?? ''));
|
||||||
|
if ($search !== '') {
|
||||||
|
$where[] = '(co.name LIKE :search
|
||||||
|
OR pcm.external_product_id LIKE :search
|
||||||
|
OR pcm.external_variant_id LIKE :search
|
||||||
|
OR co.external_offer_id LIKE :search
|
||||||
|
OR pt.name LIKE :search
|
||||||
|
OR p.sku LIKE :search
|
||||||
|
OR p.ean LIKE :search)';
|
||||||
|
$params['search'] = '%' . $search . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$channel = trim((string) ($filters['channel'] ?? ''));
|
||||||
|
if ($channel !== '') {
|
||||||
|
$where[] = 'sc.name = :channel';
|
||||||
|
$params['channel'] = $channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['WHERE ' . implode(' AND ', $where), $params];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveOfferSort(string $sort): string
|
||||||
|
{
|
||||||
|
return match ($sort) {
|
||||||
|
'offer_name' => 'co.name',
|
||||||
|
'external_product_id' => 'pcm.external_product_id',
|
||||||
|
'external_variant_id' => 'pcm.external_variant_id',
|
||||||
|
'external_offer_id' => 'co.external_offer_id',
|
||||||
|
'channel_name' => 'sc.name',
|
||||||
|
'product_name' => 'pt.name',
|
||||||
|
'product_sku' => 'p.sku',
|
||||||
|
'product_ean' => 'p.ean',
|
||||||
|
default => 'pcm.updated_at',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateCachedOfferNameForExternalProduct(int $integrationId, string $externalProductId, string $offerName): int
|
||||||
|
{
|
||||||
|
$normalizedExternalProductId = trim($externalProductId);
|
||||||
|
if ($integrationId <= 0 || $normalizedExternalProductId === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'UPDATE channel_offers
|
||||||
|
SET name = :name, updated_at = :updated_at
|
||||||
|
WHERE integration_id = :integration_id
|
||||||
|
AND external_product_id = :external_product_id'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'name' => trim($offerName),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
'integration_id' => $integrationId,
|
||||||
|
'external_product_id' => $normalizedExternalProductId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $statement->rowCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,6 +121,29 @@ final class ProductRepository
|
|||||||
return $stmt->fetchColumn() !== false;
|
return $stmt->fetchColumn() !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function findAllSkus(): array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->query(
|
||||||
|
'SELECT sku
|
||||||
|
FROM products
|
||||||
|
WHERE sku IS NOT NULL
|
||||||
|
AND sku <> ""
|
||||||
|
AND deleted_at IS NULL'
|
||||||
|
);
|
||||||
|
$rows = $stmt === false ? [] : $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(array_map(
|
||||||
|
static fn (mixed $value): string => trim((string) $value),
|
||||||
|
$rows
|
||||||
|
), static fn (string $sku): bool => $sku !== ''));
|
||||||
|
}
|
||||||
|
|
||||||
public function findIdBySku(string $sku): ?int
|
public function findIdBySku(string $sku): ?int
|
||||||
{
|
{
|
||||||
$normalized = trim($sku);
|
$normalized = trim($sku);
|
||||||
@@ -157,11 +180,15 @@ final class ProductRepository
|
|||||||
{
|
{
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'INSERT INTO products (
|
'INSERT INTO products (
|
||||||
uuid, type, sku, ean, status, promoted, vat, weight,
|
uuid, type, sku, ean, status, promoted, new_to_date,
|
||||||
|
additional_message, additional_message_required, additional_message_text,
|
||||||
|
vat, weight,
|
||||||
price_brutto, price_brutto_promo, price_netto, price_netto_promo,
|
price_brutto, price_brutto_promo, price_netto, price_netto_promo,
|
||||||
quantity, producer_id, producer_name, product_unit_id, custom_fields_json, created_at, updated_at
|
quantity, producer_id, producer_name, product_unit_id, custom_fields_json, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:uuid, :type, :sku, :ean, :status, :promoted, :vat, :weight,
|
:uuid, :type, :sku, :ean, :status, :promoted, :new_to_date,
|
||||||
|
:additional_message, :additional_message_required, :additional_message_text,
|
||||||
|
:vat, :weight,
|
||||||
:price_brutto, :price_brutto_promo, :price_netto, :price_netto_promo,
|
:price_brutto, :price_brutto_promo, :price_netto, :price_netto_promo,
|
||||||
:quantity, :producer_id, :producer_name, :product_unit_id, :custom_fields_json, :created_at, :updated_at
|
:quantity, :producer_id, :producer_name, :product_unit_id, :custom_fields_json, :created_at, :updated_at
|
||||||
)'
|
)'
|
||||||
@@ -198,6 +225,10 @@ final class ProductRepository
|
|||||||
ean = :ean,
|
ean = :ean,
|
||||||
status = :status,
|
status = :status,
|
||||||
promoted = :promoted,
|
promoted = :promoted,
|
||||||
|
new_to_date = :new_to_date,
|
||||||
|
additional_message = :additional_message,
|
||||||
|
additional_message_required = :additional_message_required,
|
||||||
|
additional_message_text = :additional_message_text,
|
||||||
vat = :vat,
|
vat = :vat,
|
||||||
weight = :weight,
|
weight = :weight,
|
||||||
price_brutto = :price_brutto,
|
price_brutto = :price_brutto,
|
||||||
@@ -616,6 +647,10 @@ final class ProductRepository
|
|||||||
'ean' => (string) ($row['ean'] ?? ''),
|
'ean' => (string) ($row['ean'] ?? ''),
|
||||||
'status' => (int) ($row['status'] ?? 1),
|
'status' => (int) ($row['status'] ?? 1),
|
||||||
'promoted' => (int) ($row['promoted'] ?? 0),
|
'promoted' => (int) ($row['promoted'] ?? 0),
|
||||||
|
'new_to_date' => isset($row['new_to_date']) && $row['new_to_date'] !== null ? (string) $row['new_to_date'] : null,
|
||||||
|
'additional_message' => (int) ($row['additional_message'] ?? 0),
|
||||||
|
'additional_message_required' => (int) ($row['additional_message_required'] ?? 0),
|
||||||
|
'additional_message_text' => isset($row['additional_message_text']) ? (string) $row['additional_message_text'] : null,
|
||||||
'vat' => $row['vat'] === null ? null : (float) $row['vat'],
|
'vat' => $row['vat'] === null ? null : (float) $row['vat'],
|
||||||
'weight' => $row['weight'] === null ? null : (float) $row['weight'],
|
'weight' => $row['weight'] === null ? null : (float) $row['weight'],
|
||||||
'price_brutto' => $row['price_brutto'] === null ? null : (float) $row['price_brutto'],
|
'price_brutto' => $row['price_brutto'] === null ? null : (float) $row['price_brutto'],
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ final class ProductService
|
|||||||
return ['ok' => false, 'errors' => ['Produkt nie istnieje.']];
|
return ['ok' => false, 'errors' => ['Produkt nie istnieje.']];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// These fields are not edited in orderPRO UI, so keep current values when absent in request.
|
||||||
|
$input = $this->mergeMissingShopProSettingsFromExisting($input, $existing);
|
||||||
|
|
||||||
$errors = $this->validator->validate($input, true);
|
$errors = $this->validator->validate($input, true);
|
||||||
if ($errors !== []) {
|
if ($errors !== []) {
|
||||||
return ['ok' => false, 'errors' => $errors];
|
return ['ok' => false, 'errors' => $errors];
|
||||||
@@ -164,6 +167,10 @@ final class ProductService
|
|||||||
'ean' => $this->nullableString($input['ean'] ?? null),
|
'ean' => $this->nullableString($input['ean'] ?? null),
|
||||||
'status' => (int) ($input['status'] ?? 1),
|
'status' => (int) ($input['status'] ?? 1),
|
||||||
'promoted' => (int) ($input['promoted'] ?? 0),
|
'promoted' => (int) ($input['promoted'] ?? 0),
|
||||||
|
'new_to_date' => $this->nullableString($input['new_to_date'] ?? null),
|
||||||
|
'additional_message' => ((int) ($input['additional_message'] ?? 0)) === 1 ? 1 : 0,
|
||||||
|
'additional_message_required' => ((int) ($input['additional_message_required'] ?? 0)) === 1 ? 1 : 0,
|
||||||
|
'additional_message_text' => $this->nullableString($input['additional_message_text'] ?? null),
|
||||||
'vat' => $vat,
|
'vat' => $vat,
|
||||||
'weight' => $this->nullableFloat($input['weight'] ?? null, 3),
|
'weight' => $this->nullableFloat($input['weight'] ?? null, 3),
|
||||||
'price_brutto' => $pricePair['brutto'] ?? 0.00,
|
'price_brutto' => $pricePair['brutto'] ?? 0.00,
|
||||||
@@ -322,6 +329,10 @@ final class ProductService
|
|||||||
'ean' => $product['ean'] ?? null,
|
'ean' => $product['ean'] ?? null,
|
||||||
'status' => $product['status'] ?? 1,
|
'status' => $product['status'] ?? 1,
|
||||||
'promoted' => $product['promoted'] ?? 0,
|
'promoted' => $product['promoted'] ?? 0,
|
||||||
|
'new_to_date' => $product['new_to_date'] ?? null,
|
||||||
|
'additional_message' => ((int) ($product['additional_message'] ?? 0)) === 1 ? 1 : 0,
|
||||||
|
'additional_message_required' => ((int) ($product['additional_message_required'] ?? 0)) === 1 ? 1 : 0,
|
||||||
|
'additional_message_text' => $product['additional_message_text'] ?? null,
|
||||||
'vat' => $product['vat'] ?? null,
|
'vat' => $product['vat'] ?? null,
|
||||||
'weight' => $product['weight'] ?? null,
|
'weight' => $product['weight'] ?? null,
|
||||||
'price_brutto' => $product['price_brutto'] ?? 0,
|
'price_brutto' => $product['price_brutto'] ?? 0,
|
||||||
@@ -337,6 +348,29 @@ final class ProductService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
* @param array<string, mixed> $existing
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function mergeMissingShopProSettingsFromExisting(array $input, array $existing): array
|
||||||
|
{
|
||||||
|
$keys = [
|
||||||
|
'new_to_date',
|
||||||
|
'additional_message',
|
||||||
|
'additional_message_required',
|
||||||
|
'additional_message_text',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (!array_key_exists($key, $input)) {
|
||||||
|
$input[$key] = $existing[$key] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $input;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
103
src/Modules/Products/ProductSkuGenerator.php
Normal file
103
src/Modules/Products/ProductSkuGenerator.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Products;
|
||||||
|
|
||||||
|
use App\Modules\Settings\AppSettingsRepository;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class ProductSkuGenerator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AppSettingsRepository $appSettings,
|
||||||
|
private readonly ProductRepository $products
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function format(): string
|
||||||
|
{
|
||||||
|
return $this->normalizeFormat($this->appSettings->get('products_sku_format', 'PP000000'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextSku(): string
|
||||||
|
{
|
||||||
|
$format = $this->format();
|
||||||
|
[$prefix, $width, $suffix] = $this->parseFormat($format);
|
||||||
|
|
||||||
|
$maxNumber = 0;
|
||||||
|
foreach ($this->products->findAllSkus() as $sku) {
|
||||||
|
$number = $this->extractNumber($sku, $prefix, $width, $suffix);
|
||||||
|
if ($number !== null && $number > $maxNumber) {
|
||||||
|
$maxNumber = $number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidateNumber = max(1, $maxNumber + 1);
|
||||||
|
while (true) {
|
||||||
|
$candidate = $prefix . str_pad((string) $candidateNumber, $width, '0', STR_PAD_LEFT) . $suffix;
|
||||||
|
if (!$this->products->existsSku($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidateNumber++;
|
||||||
|
if ($candidateNumber > 999999999) {
|
||||||
|
throw new RuntimeException('Nie udalo sie wygenerowac kolejnego SKU.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeFormat(?string $raw): string
|
||||||
|
{
|
||||||
|
$value = trim((string) $raw);
|
||||||
|
return $value === '' ? 'PP000000' : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:string,1:int,2:string}
|
||||||
|
*/
|
||||||
|
private function parseFormat(string $format): array
|
||||||
|
{
|
||||||
|
if (mb_strlen($format) > 128) {
|
||||||
|
throw new RuntimeException('Format SKU jest za dlugi (maksymalnie 128 znakow).');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/0+/', $format, $matches, PREG_OFFSET_CAPTURE) !== 1) {
|
||||||
|
throw new RuntimeException('Format SKU musi zawierac czesc liczbowa (zera), np. PP000000.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = (string) $matches[0][0];
|
||||||
|
$offset = (int) $matches[0][1];
|
||||||
|
$width = strlen($token);
|
||||||
|
|
||||||
|
$prefix = substr($format, 0, $offset);
|
||||||
|
$suffix = substr($format, $offset + $width);
|
||||||
|
|
||||||
|
return [$prefix, $width, $suffix === false ? '' : $suffix];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractNumber(string $sku, string $prefix, int $width, string $suffix): ?int
|
||||||
|
{
|
||||||
|
$prefixLen = strlen($prefix);
|
||||||
|
$suffixLen = strlen($suffix);
|
||||||
|
$skuLen = strlen($sku);
|
||||||
|
|
||||||
|
if ($skuLen !== ($prefixLen + $width + $suffixLen)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($prefix !== '' && !str_starts_with($sku, $prefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($suffix !== '' && !str_ends_with($sku, $suffix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$numberPart = substr($sku, $prefixLen, $width);
|
||||||
|
if ($numberPart === false || preg_match('/^\d+$/', $numberPart) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $numberPart;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ final class ProductsController
|
|||||||
private readonly AuthService $auth,
|
private readonly AuthService $auth,
|
||||||
private readonly ProductRepository $products,
|
private readonly ProductRepository $products,
|
||||||
private readonly ProductService $service,
|
private readonly ProductService $service,
|
||||||
|
private readonly ProductSkuGenerator $skuGenerator,
|
||||||
private readonly IntegrationRepository $integrations,
|
private readonly IntegrationRepository $integrations,
|
||||||
private readonly ProductLinksService $productLinks,
|
private readonly ProductLinksService $productLinks,
|
||||||
private readonly ShopProExportService $shopProExport,
|
private readonly ShopProExportService $shopProExport,
|
||||||
@@ -334,6 +335,29 @@ final class ProductsController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function nextSku(Request $request): Response
|
||||||
|
{
|
||||||
|
$csrfToken = (string) $request->input('_token', '');
|
||||||
|
if (!Csrf::validate($csrfToken)) {
|
||||||
|
return Response::json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $this->translator->get('auth.errors.csrf_expired'),
|
||||||
|
], 419);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Response::json([
|
||||||
|
'ok' => true,
|
||||||
|
'sku' => $this->skuGenerator->nextSku(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
return Response::json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $this->translator->get('products.sku_generator.failed') . ' ' . $exception->getMessage(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function exportShopPro(Request $request): Response
|
public function exportShopPro(Request $request): Response
|
||||||
{
|
{
|
||||||
$csrfToken = (string) $request->input('_token', '');
|
$csrfToken = (string) $request->input('_token', '');
|
||||||
|
|||||||
@@ -360,6 +360,10 @@ final class ShopProExportService
|
|||||||
'quantity' => round((float) ($product['quantity'] ?? 0), 3),
|
'quantity' => round((float) ($product['quantity'] ?? 0), 3),
|
||||||
'status' => ((int) ($product['status'] ?? 0)) === 1 ? 1 : 0,
|
'status' => ((int) ($product['status'] ?? 0)) === 1 ? 1 : 0,
|
||||||
'promoted' => ((int) ($product['promoted'] ?? 0)) === 1 ? 1 : 0,
|
'promoted' => ((int) ($product['promoted'] ?? 0)) === 1 ? 1 : 0,
|
||||||
|
'new_to_date' => $this->nullableText($product['new_to_date'] ?? null),
|
||||||
|
'additional_message' => ((int) ($product['additional_message'] ?? 0)) === 1 ? 1 : 0,
|
||||||
|
'additional_message_required' => ((int) ($product['additional_message_required'] ?? 0)) === 1 ? 1 : 0,
|
||||||
|
'additional_message_text' => $this->nullableText($product['additional_message_text'] ?? null),
|
||||||
'sku' => $this->nullableText($product['sku'] ?? null),
|
'sku' => $this->nullableText($product['sku'] ?? null),
|
||||||
'ean' => $this->nullableText($product['ean'] ?? null),
|
'ean' => $this->nullableText($product['ean'] ?? null),
|
||||||
'weight' => $this->nullableFloat($product['weight'] ?? null, 3),
|
'weight' => $this->nullableFloat($product['weight'] ?? null, 3),
|
||||||
|
|||||||
@@ -198,6 +198,62 @@ final class SettingsController
|
|||||||
return Response::redirect('/settings/gs1');
|
return Response::redirect('/settings/gs1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function products(Request $request): Response
|
||||||
|
{
|
||||||
|
$errorMessage = (string) Flash::get('settings_error', '');
|
||||||
|
$successMessage = (string) Flash::get('settings_success', '');
|
||||||
|
|
||||||
|
$html = $this->template->render('settings/products', [
|
||||||
|
'title' => $this->translator->get('settings.products.title'),
|
||||||
|
'activeMenu' => 'settings',
|
||||||
|
'activeSettings' => 'products',
|
||||||
|
'user' => $this->auth->user(),
|
||||||
|
'csrfToken' => Csrf::token(),
|
||||||
|
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
|
||||||
|
'productsSkuFormat' => $this->appSettings->get('products_sku_format', 'PP000000'),
|
||||||
|
'errorMessage' => $errorMessage,
|
||||||
|
'successMessage' => $successMessage,
|
||||||
|
], 'layouts/app');
|
||||||
|
|
||||||
|
return Response::html($html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function productsSave(Request $request): Response
|
||||||
|
{
|
||||||
|
$csrfToken = (string) $request->input('_token', '');
|
||||||
|
if (!Csrf::validate($csrfToken)) {
|
||||||
|
Flash::set('settings_error', $this->translator->get('auth.errors.csrf_expired'));
|
||||||
|
return Response::redirect('/settings/products');
|
||||||
|
}
|
||||||
|
|
||||||
|
$skuFormat = trim((string) $request->input('products_sku_format', 'PP000000'));
|
||||||
|
if ($skuFormat === '') {
|
||||||
|
$skuFormat = 'PP000000';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($skuFormat) > 128) {
|
||||||
|
Flash::set('settings_error', $this->translator->get('settings.products.flash.invalid_too_long'));
|
||||||
|
return Response::redirect('/settings/products');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/0+/', $skuFormat) !== 1) {
|
||||||
|
Flash::set('settings_error', $this->translator->get('settings.products.flash.invalid_no_counter'));
|
||||||
|
return Response::redirect('/settings/products');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->appSettings->set('products_sku_format', $skuFormat);
|
||||||
|
Flash::set('settings_success', $this->translator->get('settings.products.flash.saved'));
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Flash::set(
|
||||||
|
'settings_error',
|
||||||
|
$this->translator->get('settings.products.flash.save_failed') . ' ' . $exception->getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::redirect('/settings/products');
|
||||||
|
}
|
||||||
|
|
||||||
public function integrations(Request $request): Response
|
public function integrations(Request $request): Response
|
||||||
{
|
{
|
||||||
$integrationId = max(0, (int) $request->input('id', 0));
|
$integrationId = max(0, (int) $request->input('id', 0));
|
||||||
@@ -849,6 +905,10 @@ final class SettingsController
|
|||||||
'ean' => $ean !== '' ? $ean : null,
|
'ean' => $ean !== '' ? $ean : null,
|
||||||
'status' => ((int) ($externalProduct['status'] ?? 1)) === 1 ? 1 : 0,
|
'status' => ((int) ($externalProduct['status'] ?? 1)) === 1 ? 1 : 0,
|
||||||
'promoted' => ((int) ($externalProduct['promoted'] ?? 0)) === 1 ? 1 : 0,
|
'promoted' => ((int) ($externalProduct['promoted'] ?? 0)) === 1 ? 1 : 0,
|
||||||
|
'new_to_date' => $this->normalizeExternalDate($externalProduct['new_to_date'] ?? null),
|
||||||
|
'additional_message' => ((int) ($externalProduct['additional_message'] ?? 0)) === 1 ? 1 : 0,
|
||||||
|
'additional_message_required' => ((int) ($externalProduct['additional_message_required'] ?? 0)) === 1 ? 1 : 0,
|
||||||
|
'additional_message_text' => $this->nullableText($externalProduct['additional_message_text'] ?? null),
|
||||||
'vat' => $vat,
|
'vat' => $vat,
|
||||||
'weight' => $this->nullableFloat($externalProduct['weight'] ?? null, 3),
|
'weight' => $this->nullableFloat($externalProduct['weight'] ?? null, 3),
|
||||||
'price_brutto' => round((float) ($externalProduct['price_brutto'] ?? 0), 2),
|
'price_brutto' => round((float) ($externalProduct['price_brutto'] ?? 0), 2),
|
||||||
@@ -1391,6 +1451,21 @@ final class SettingsController
|
|||||||
return $text === '' ? null : $text;
|
return $text === '' ? null : $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeExternalDate(mixed $value): ?string
|
||||||
|
{
|
||||||
|
$text = trim((string) $value);
|
||||||
|
if ($text === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$datePart = substr($text, 0, 10);
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $datePart) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $datePart;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user