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:
2026-03-01 22:05:21 +01:00
parent bcf078baac
commit d1576bc4ab
28 changed files with 1503 additions and 104 deletions

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' ? ' &uarr;' : ' &darr;') : '' ?>
<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' ? ' &uarr;' : ' &darr;') : '' ?>
<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' ? ' &uarr;' : ' &darr;') : '' ?>
</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' ? ' &uarr;' : ' &darr;') : '' ?>
</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' ? ' &uarr;' : ' &darr;') : '' ?>
</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' ? ' &uarr;' : ' &darr;') : '' ?>
</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' ? ' &uarr;' : ' &darr;') : '' ?>
</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' ? ' &uarr;' : ' &darr;') : '' ?>
</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' ? ' &uarr;' : ' &darr;') : '' ?>
</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])) ?>">&laquo;</a>
<a class="pagination__item<?= $page <= 1 ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => max(1, $page - 1)])) ?>">&lsaquo;</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)])) ?>">&rsaquo;</a>
<a class="pagination__item<?= $page >= $totalPages ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => $totalPages])) ?>">&raquo;</a>
</div>
</div>
<?php endif; ?> <?php endif; ?>
</section> </section>

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View 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,
];
}
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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