From 702e3a94bef700b6e0788f4675b4bf3ad2f99452 Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Tue, 24 Feb 2026 11:43:17 +0100 Subject: [PATCH] =?UTF-8?q?ver.=200.318:=20shopPRO=20export=20produkt?= =?UTF-8?q?=C3=B3w=20+=20nowe=20API=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NEW: IntegrationsRepository::shopproExportProduct() — eksport produktu do zdalnej instancji shopPRO (pola główne, tłumaczenia, custom fields, zdjęcia) - NEW: sendImageToShopproApi() — wysyłka zdjęć przez API shopPRO (base64 POST) - REFACTOR: shopproImportProduct() — wydzielono shopproDb() i missingShopproSetting(); dodano security_information, producer_id, custom fields, alt zdjęcia - NEW: AttributeRepository::ensureAttributeForApi() i ensureAttributeValueForApi() — idempotent find-or-create dla słowników - NEW: API POST dictionaries/ensure_attribute — utwórz lub znajdź atrybut - NEW: API POST dictionaries/ensure_attribute_value — utwórz lub znajdź wartość - NEW: API POST products/upload_image — przyjmuje base64, zapisuje plik i DB - NEW: IntegrationsController::shoppro_product_export() — akcja admina - NEW: przycisk "Eksportuj do shopPRO" w liście produktów - NEW: pole API key w ustawieniach integracji shopPRO Tests: 765 tests, 2153 assertions — all green Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 2 +- .../integrations/shoppro-settings.php | 16 +- .../Domain/Attribute/AttributeRepository.php | 135 ++++++++++ .../Integrations/IntegrationsRepository.php | 240 +++++++++++++++++- autoload/Domain/Product/ProductRepository.php | 2 +- .../Controllers/IntegrationsController.php | 10 + .../Controllers/ShopProductController.php | 9 + .../Controllers/DictionariesApiController.php | 77 ++++++ .../api/Controllers/ProductsApiController.php | 94 +++++++ docs/CHANGELOG.md | 15 ++ docs/TESTING.md | 4 +- docs/TODO.md | 1 + .../IntegrationsRepositoryTest.php | 2 +- .../IntegrationsControllerTest.php | 2 + .../DictionariesApiControllerTest.php | 48 ++++ .../Controllers/ProductsApiControllerTest.php | 13 + 16 files changed, 656 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b4d500..39d370e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ composer test PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`. -Current suite: **758 tests, 2135 assertions**. +Current suite: **765 tests, 2153 assertions**. ### Creating Updates See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packages in `updates/0.XX/`. Never include `*.md` files, `updates/changelog.php`, or root `.htaccess` in update ZIPs. diff --git a/admin/templates/integrations/shoppro-settings.php b/admin/templates/integrations/shoppro-settings.php index 30a9c97..6224324 100644 --- a/admin/templates/integrations/shoppro-settings.php +++ b/admin/templates/integrations/shoppro-settings.php @@ -91,6 +91,20 @@ + +
+ +
+
+
+ + + + +
+
+
+
@@ -123,4 +137,4 @@ }); }) }); - \ No newline at end of file + diff --git a/autoload/Domain/Attribute/AttributeRepository.php b/autoload/Domain/Attribute/AttributeRepository.php index 3e8455a..45a2ad2 100644 --- a/autoload/Domain/Attribute/AttributeRepository.php +++ b/autoload/Domain/Attribute/AttributeRepository.php @@ -655,6 +655,95 @@ class AttributeRepository return $result; } + /** + * Find existing attribute by name/type or create a new one for API integration. + * + * @return array{id:int,created:bool}|null + */ + public function ensureAttributeForApi(string $name, int $type = 0, string $langId = 'pl'): ?array + { + $normalizedName = trim($name); + $normalizedLangId = trim($langId) !== '' ? trim($langId) : 'pl'; + $normalizedType = $this->toTypeValue($type); + if ($normalizedName === '') { + return null; + } + + $existingId = $this->findAttributeIdByNameAndType($normalizedName, $normalizedType); + if ($existingId > 0) { + return ['id' => $existingId, 'created' => false]; + } + + $this->db->insert('pp_shop_attributes', [ + 'status' => 1, + 'type' => $normalizedType, + 'o' => $this->nextOrder(), + ]); + $attributeId = (int) $this->db->id(); + if ($attributeId <= 0) { + return null; + } + + $this->db->insert('pp_shop_attributes_langs', [ + 'attribute_id' => $attributeId, + 'lang_id' => $normalizedLangId, + 'name' => $normalizedName, + ]); + + $this->clearTempAndCache(); + $this->clearFrontCache($attributeId, 'frontAttributeDetails'); + + return ['id' => $attributeId, 'created' => true]; + } + + /** + * Find existing value by name within attribute or create a new one for API integration. + * + * @return array{id:int,created:bool}|null + */ + public function ensureAttributeValueForApi(int $attributeId, string $name, string $langId = 'pl'): ?array + { + $normalizedName = trim($name); + $normalizedLangId = trim($langId) !== '' ? trim($langId) : 'pl'; + $attributeId = max(0, $attributeId); + + if ($attributeId <= 0 || $normalizedName === '') { + return null; + } + + $attributeExists = (int) $this->db->count('pp_shop_attributes', ['id' => $attributeId]) > 0; + if (!$attributeExists) { + return null; + } + + $existingId = $this->findAttributeValueIdByName($attributeId, $normalizedName); + if ($existingId > 0) { + return ['id' => $existingId, 'created' => false]; + } + + $this->db->insert('pp_shop_attributes_values', [ + 'attribute_id' => $attributeId, + 'impact_on_the_price' => null, + 'is_default' => 0, + ]); + $valueId = (int) $this->db->id(); + if ($valueId <= 0) { + return null; + } + + $this->db->insert('pp_shop_attributes_values_langs', [ + 'value_id' => $valueId, + 'lang_id' => $normalizedLangId, + 'name' => $normalizedName, + 'value' => null, + ]); + + $this->clearTempAndCache(); + $this->clearFrontCache($valueId, 'frontValueDetails'); + + return ['id' => $valueId, 'created' => true]; + } + /** * @return array{sql: string, params: array} */ @@ -972,6 +1061,52 @@ class AttributeRepository return $this->defaultLangId; } + private function findAttributeIdByNameAndType(string $name, int $type): int + { + $statement = $this->db->query( + 'SELECT sa.id + FROM pp_shop_attributes sa + INNER JOIN pp_shop_attributes_langs sal ON sal.attribute_id = sa.id + WHERE sa.type = :type + AND LOWER(TRIM(sal.name)) = LOWER(TRIM(:name)) + ORDER BY sa.id ASC + LIMIT 1', + [ + ':type' => $type, + ':name' => $name, + ] + ); + if (!$statement) { + return 0; + } + + $id = $statement->fetchColumn(); + return $id === false ? 0 : (int) $id; + } + + private function findAttributeValueIdByName(int $attributeId, string $name): int + { + $statement = $this->db->query( + 'SELECT sav.id + FROM pp_shop_attributes_values sav + INNER JOIN pp_shop_attributes_values_langs savl ON savl.value_id = sav.id + WHERE sav.attribute_id = :attribute_id + AND LOWER(TRIM(savl.name)) = LOWER(TRIM(:name)) + ORDER BY sav.id ASC + LIMIT 1', + [ + ':attribute_id' => $attributeId, + ':name' => $name, + ] + ); + if (!$statement) { + return 0; + } + + $id = $statement->fetchColumn(); + return $id === false ? 0 : (int) $id; + } + // ── Frontend methods ────────────────────────────────────────── public function frontAttributeDetails(int $attributeId, string $langId): array diff --git a/autoload/Domain/Integrations/IntegrationsRepository.php b/autoload/Domain/Integrations/IntegrationsRepository.php index e23a98e..b43300d 100644 --- a/autoload/Domain/Integrations/IntegrationsRepository.php +++ b/autoload/Domain/Integrations/IntegrationsRepository.php @@ -668,15 +668,12 @@ class IntegrationsRepository public function shopproImportProduct( int $productId ): array { $settings = $this->getSettings( 'shoppro' ); + $missingSetting = $this->missingShopproSetting( $settings, [ 'domain', 'db_name', 'db_host', 'db_user' ] ); + if ( $missingSetting !== null ) { + return [ 'success' => false, 'message' => 'Brakuje konfiguracji shopPRO: ' . $missingSetting . '.' ]; + } - $mdb2 = new \medoo( [ - 'database_type' => 'mysql', - 'database_name' => $settings['db_name'], - 'server' => $settings['db_host'], - 'username' => $settings['db_user'], - 'password' => $settings['db_password'], - 'charset' => 'utf8' - ] ); + $mdb2 = $this->shopproDb( $settings ); $product = $mdb2->get( 'pp_shop_products', '*', [ 'id' => $productId ] ); if ( !$product ) @@ -700,6 +697,7 @@ class IntegrationsRepository 'additional_message_text' => $product['additional_message_text'], 'additional_message_required'=> $product['additional_message_required'], 'weight' => $product['weight'], + 'producer_id' => $product['producer_id'] ?? null, ] ); $newProductId = $this->db->id(); @@ -729,6 +727,20 @@ class IntegrationsRepository 'warehouse_message_nonzero'=> $lang['warehouse_message_nonzero'], 'canonical' => $lang['canonical'], 'xml_name' => $lang['xml_name'], + 'security_information' => $lang['security_information'] ?? null, + ] ); + } + } + + // Import custom fields + $customFields = $mdb2->select( 'pp_shop_products_custom_fields', '*', [ 'id_product' => $productId ] ); + if ( is_array( $customFields ) ) { + foreach ( $customFields as $field ) { + $this->db->insert( 'pp_shop_products_custom_fields', [ + 'id_product' => $newProductId, + 'name' => (string)($field['name'] ?? ''), + 'type' => (string)($field['type'] ?? 'text'), + 'is_required' => !empty( $field['is_required'] ) ? 1 : 0, ] ); } } @@ -759,6 +771,7 @@ class IntegrationsRepository $this->db->insert( 'pp_shop_products_images', [ 'product_id' => $newProductId, 'src' => '/upload/product_images/product_' . $newProductId . '/' . $imageName, + 'alt' => $image['alt'] ?? '', 'o' => $image['o'], ] ); } @@ -766,4 +779,215 @@ class IntegrationsRepository return [ 'success' => true, 'message' => 'Produkt został zaimportowany.' ]; } + + // ── ShopPRO export ────────────────────────────────────────── + + public function shopproExportProduct( int $productId ): array + { + $settings = $this->getSettings( 'shoppro' ); + $missingSetting = $this->missingShopproSetting( $settings, [ 'db_name', 'db_host', 'db_user' ] ); + if ( $missingSetting !== null ) { + return [ 'success' => false, 'message' => 'Brakuje konfiguracji shopPRO: ' . $missingSetting . '.' ]; + } + + $product = $this->db->get( 'pp_shop_products', '*', [ 'id' => $productId ] ); + if ( !$product ) { + return [ 'success' => false, 'message' => 'Nie znaleziono produktu do eksportu.' ]; + } + + $mdb2 = $this->shopproDb( $settings ); + + $mdb2->insert( 'pp_shop_products', [ + 'price_netto' => $product['price_netto'] ?? null, + 'price_brutto' => $product['price_brutto'] ?? null, + 'vat' => $product['vat'] ?? null, + 'stock_0_buy' => $product['stock_0_buy'] ?? 0, + 'quantity' => $product['quantity'] ?? 0, + 'wp' => $product['wp'] ?? null, + 'sku' => $product['sku'] ?? '', + 'ean' => $product['ean'] ?? '', + 'custom_label_0' => $product['custom_label_0'] ?? null, + 'custom_label_1' => $product['custom_label_1'] ?? null, + 'custom_label_2' => $product['custom_label_2'] ?? null, + 'custom_label_3' => $product['custom_label_3'] ?? null, + 'custom_label_4' => $product['custom_label_4'] ?? null, + 'additional_message' => $product['additional_message'] ?? 0, + 'additional_message_text' => $product['additional_message_text'] ?? null, + 'additional_message_required'=> $product['additional_message_required'] ?? 0, + 'weight' => $product['weight'] ?? null, + 'producer_id' => $product['producer_id'] ?? null, + ] ); + + $newProductId = (int) $mdb2->id(); + if ( $newProductId <= 0 ) { + return [ 'success' => false, 'message' => 'Podczas eksportowania produktu wystąpił błąd.' ]; + } + + $languages = $this->db->select( 'pp_shop_products_langs', '*', [ 'product_id' => $productId ] ); + if ( is_array( $languages ) ) { + foreach ( $languages as $lang ) { + $mdb2->insert( 'pp_shop_products_langs', [ + 'product_id' => $newProductId, + 'lang_id' => $lang['lang_id'] ?? '', + 'name' => $lang['name'] ?? '', + 'short_description' => $lang['short_description'] ?? null, + 'description' => $lang['description'] ?? null, + 'tab_name_1' => $lang['tab_name_1'] ?? null, + 'tab_description_1' => $lang['tab_description_1'] ?? null, + 'tab_name_2' => $lang['tab_name_2'] ?? null, + 'tab_description_2' => $lang['tab_description_2'] ?? null, + 'meta_title' => $lang['meta_title'] ?? null, + 'meta_description' => $lang['meta_description'] ?? null, + 'meta_keywords' => $lang['meta_keywords'] ?? null, + 'seo_link' => $lang['seo_link'] ?? null, + 'copy_from' => $lang['copy_from'] ?? null, + 'warehouse_message_zero' => $lang['warehouse_message_zero'] ?? null, + 'warehouse_message_nonzero'=> $lang['warehouse_message_nonzero'] ?? null, + 'canonical' => $lang['canonical'] ?? null, + 'xml_name' => $lang['xml_name'] ?? null, + 'security_information' => $lang['security_information'] ?? null, + ] ); + } + } + + $customFields = $this->db->select( 'pp_shop_products_custom_fields', '*', [ 'id_product' => $productId ] ); + if ( is_array( $customFields ) ) { + foreach ( $customFields as $field ) { + $mdb2->insert( 'pp_shop_products_custom_fields', [ + 'id_product' => $newProductId, + 'name' => (string)($field['name'] ?? ''), + 'type' => (string)($field['type'] ?? 'text'), + 'is_required' => !empty( $field['is_required'] ) ? 1 : 0, + ] ); + } + } + + $images = $this->db->select( 'pp_shop_products_images', '*', [ 'product_id' => $productId ] ); + if ( is_array( $images ) && count( $images ) > 0 ) { + $missingImageApiSetting = $this->missingShopproSetting( $settings, [ 'domain', 'api_key' ] ); + if ( $missingImageApiSetting !== null ) { + return [ 'success' => false, 'message' => 'Brakuje konfiguracji shopPRO dla wysylki zdjec: ' . $missingImageApiSetting . '.' ]; + } + } + + if ( is_array( $images ) ) { + foreach ( $images as $image ) { + $remoteImageSrc = $this->sendImageToShopproApi( + (string)($image['src'] ?? ''), + (int)$newProductId, + (string)($settings['domain'] ?? ''), + (string)($settings['api_key'] ?? ''), + (string)($image['alt'] ?? ''), + (int)($image['o'] ?? 0) + ); + if ( $remoteImageSrc === '' ) { + return [ 'success' => false, 'message' => 'Nie udalo sie wyslac zdjec produktu przez API shopPRO.' ]; + } + } + } + + return [ + 'success' => true, + 'message' => 'Produkt został wyeksportowany (ID: ' . $newProductId . ').', + ]; + } + + private function missingShopproSetting( array $settings, array $requiredKeys ): ?string + { + foreach ( $requiredKeys as $requiredKey ) { + if ( trim( (string)($settings[$requiredKey] ?? '') ) === '' ) { + return $requiredKey; + } + } + + return null; + } + + private function shopproDb( array $settings ): \medoo + { + return new \medoo( [ + 'database_type' => 'mysql', + 'database_name' => $settings['db_name'], + 'server' => $settings['db_host'], + 'username' => $settings['db_user'], + 'password' => $settings['db_password'] ?? '', + 'charset' => 'utf8' + ] ); + } + + private function sendImageToShopproApi( + string $src, + int $remoteProductId, + string $remoteDomain, + string $apiKey, + string $alt, + int $position + ): string + { + $src = trim( $src ); + if ( $src === '' ) { + return ''; + } + + $localSourcePath = '..' . $src; + if ( !is_file( $localSourcePath ) ) { + return ''; + } + + $content = @file_get_contents( $localSourcePath ); + if ( $content === false ) { + return ''; + } + + $remoteDomain = trim( $remoteDomain ); + if ( $remoteDomain === '' ) { + return ''; + } + + if ( strpos( $remoteDomain, 'http://' ) !== 0 && strpos( $remoteDomain, 'https://' ) !== 0 ) { + $remoteDomain = 'https://' . $remoteDomain; + } + $remoteDomain = rtrim( $remoteDomain, '/' ); + + $url = $remoteDomain . '/api.php?endpoint=products&action=upload_image'; + $payload = [ + 'id' => $remoteProductId, + 'file_name' => basename( $src ), + 'content_base64' => base64_encode( $content ), + 'alt' => $alt, + 'o' => $position, + ]; + + $ch = curl_init( $url ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_POST, true ); + curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $payload, JSON_UNESCAPED_UNICODE ) ); + curl_setopt( $ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Accept: application/json', + 'X-Api-Key: ' . $apiKey, + ] ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, false ); + $response = curl_exec( $ch ); + + if ( curl_errno( $ch ) ) { + curl_close( $ch ); + return ''; + } + + $httpCode = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + curl_close( $ch ); + + if ( $httpCode >= 400 || $response === false ) { + return ''; + } + + $responseData = json_decode( (string) $response, true ); + if ( !is_array( $responseData ) || ( $responseData['status'] ?? '' ) !== 'ok' ) { + return ''; + } + + return (string)($responseData['data']['src'] ?? ''); + } } diff --git a/autoload/Domain/Product/ProductRepository.php b/autoload/Domain/Product/ProductRepository.php index 919f0d9..4d656b4 100644 --- a/autoload/Domain/Product/ProductRepository.php +++ b/autoload/Domain/Product/ProductRepository.php @@ -1239,7 +1239,7 @@ class ProductRepository $productData = [ 'date_modify' => date( 'Y-m-d H:i:s' ), - 'modify_by' => $userId, + 'modify_by' => $userId !== null ? (int) $userId : 0, 'status' => ( $d['status'] ?? '' ) === 'on' ? 1 : 0, 'price_netto' => $this->nullIfEmpty( $d['price_netto'] ?? null ), 'price_brutto' => $this->nullIfEmpty( $d['price_brutto'] ?? null ), diff --git a/autoload/admin/Controllers/IntegrationsController.php b/autoload/admin/Controllers/IntegrationsController.php index e182a90..0116168 100644 --- a/autoload/admin/Controllers/IntegrationsController.php +++ b/autoload/admin/Controllers/IntegrationsController.php @@ -265,6 +265,16 @@ class IntegrationsController exit; } + public function shoppro_product_export(): void + { + $productId = (int) \Shared\Helpers\Helpers::get( 'product_id' ); + $result = $this->repository->shopproExportProduct( $productId ); + + \Shared\Helpers\Helpers::alert( (string)($result['message'] ?? 'Wystapil blad podczas eksportu produktu.') ); + header( 'Location: /admin/shop_product/view_list/' ); + exit; + } + private function fetchApiloListWithFeedback( string $type, string $label ): void { $result = $this->repository->apiloFetchListResult( $type ); diff --git a/autoload/admin/Controllers/ShopProductController.php b/autoload/admin/Controllers/ShopProductController.php index 052aa50..94b61db 100644 --- a/autoload/admin/Controllers/ShopProductController.php +++ b/autoload/admin/Controllers/ShopProductController.php @@ -140,6 +140,15 @@ class ShopProductController } } + if ( $shopproEnabled ) { + $row['_actions'][] = [ + 'label' => 'Eksportuj do shopPRO', + 'url' => '/admin/integrations/shoppro_product_export/product_id=' . $id, + 'class' => 'btn btn-xs btn-system', + 'confirm' => 'Na pewno chcesz wyeksportowac ten produkt do shopPRO?', + ]; + } + $rows[] = $row; } diff --git a/autoload/api/Controllers/DictionariesApiController.php b/autoload/api/Controllers/DictionariesApiController.php index a45464d..b431c4f 100644 --- a/autoload/api/Controllers/DictionariesApiController.php +++ b/autoload/api/Controllers/DictionariesApiController.php @@ -94,4 +94,81 @@ class DictionariesApiController ApiRouter::sendSuccess($attributes); } + + public function ensure_attribute(): void + { + if (!ApiRouter::requireMethod('POST')) { + return; + } + + $body = ApiRouter::getJsonBody(); + if (!is_array($body)) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400); + return; + } + + $name = trim((string) ($body['name'] ?? '')); + if ($name === '') { + ApiRouter::sendError('BAD_REQUEST', 'Missing name', 400); + return; + } + + $type = (int) ($body['type'] ?? 0); + $lang = trim((string) ($body['lang'] ?? 'pl')); + if ($lang === '') { + $lang = 'pl'; + } + + $result = $this->attrRepo->ensureAttributeForApi($name, $type, $lang); + if (!is_array($result) || (int) ($result['id'] ?? 0) <= 0) { + ApiRouter::sendError('INTERNAL_ERROR', 'Failed to ensure attribute', 500); + return; + } + + ApiRouter::sendSuccess([ + 'id' => (int) ($result['id'] ?? 0), + 'created' => !empty($result['created']), + ]); + } + + public function ensure_attribute_value(): void + { + if (!ApiRouter::requireMethod('POST')) { + return; + } + + $body = ApiRouter::getJsonBody(); + if (!is_array($body)) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400); + return; + } + + $attributeId = (int) ($body['attribute_id'] ?? 0); + if ($attributeId <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid attribute_id', 400); + return; + } + + $name = trim((string) ($body['name'] ?? '')); + if ($name === '') { + ApiRouter::sendError('BAD_REQUEST', 'Missing name', 400); + return; + } + + $lang = trim((string) ($body['lang'] ?? 'pl')); + if ($lang === '') { + $lang = 'pl'; + } + + $result = $this->attrRepo->ensureAttributeValueForApi($attributeId, $name, $lang); + if (!is_array($result) || (int) ($result['id'] ?? 0) <= 0) { + ApiRouter::sendError('INTERNAL_ERROR', 'Failed to ensure attribute value', 500); + return; + } + + ApiRouter::sendSuccess([ + 'id' => (int) ($result['id'] ?? 0), + 'created' => !empty($result['created']), + ]); + } } diff --git a/autoload/api/Controllers/ProductsApiController.php b/autoload/api/Controllers/ProductsApiController.php index 8f665e3..c204635 100644 --- a/autoload/api/Controllers/ProductsApiController.php +++ b/autoload/api/Controllers/ProductsApiController.php @@ -296,6 +296,95 @@ class ProductsApiController ApiRouter::sendSuccess(['id' => $variantId, 'deleted' => true]); } + public function upload_image(): void + { + if (!ApiRouter::requireMethod('POST')) { + return; + } + + $body = ApiRouter::getJsonBody(); + if ($body === null) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400); + return; + } + + $productId = (int)($body['id'] ?? 0); + if ($productId <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid product id', 400); + return; + } + + $product = $this->productRepo->find($productId); + if ($product === null) { + ApiRouter::sendError('NOT_FOUND', 'Product not found', 404); + return; + } + + $fileName = trim((string)($body['file_name'] ?? '')); + $base64 = (string)($body['content_base64'] ?? ''); + if ($fileName === '' || $base64 === '') { + ApiRouter::sendError('BAD_REQUEST', 'Missing file_name or content_base64', 400); + return; + } + + $binary = base64_decode($base64, true); + if ($binary === false) { + ApiRouter::sendError('BAD_REQUEST', 'Invalid content_base64 payload', 400); + return; + } + + $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($fileName)); + if ($safeName === '' || $safeName === null) { + $safeName = 'image_' . md5((string)microtime(true)) . '.jpg'; + } + + $baseDir = '../upload/product_images/product_' . $productId; + if (!is_dir($baseDir) && !mkdir($baseDir, 0775, true) && !is_dir($baseDir)) { + ApiRouter::sendError('INTERNAL_ERROR', 'Failed to create target directory', 500); + return; + } + + $targetPath = $baseDir . '/' . $safeName; + if (is_file($targetPath)) { + $name = pathinfo($safeName, PATHINFO_FILENAME); + $ext = pathinfo($safeName, PATHINFO_EXTENSION); + $targetPath = $baseDir . '/' . $name . '_' . substr(md5($safeName . microtime(true)), 0, 8) . ($ext !== '' ? '.' . $ext : ''); + } + + if (file_put_contents($targetPath, $binary) === false) { + ApiRouter::sendError('INTERNAL_ERROR', 'Failed to save image file', 500); + return; + } + + $src = '/upload/product_images/product_' . $productId . '/' . basename($targetPath); + $alt = (string)($body['alt'] ?? ''); + $position = isset($body['o']) ? (int)$body['o'] : null; + + $db = $GLOBALS['mdb'] ?? null; + if (!$db) { + ApiRouter::sendError('INTERNAL_ERROR', 'Database not available', 500); + return; + } + + if ($position === null) { + $max = $db->max('pp_shop_products_images', 'o', ['product_id' => $productId]); + $position = (int)$max + 1; + } + + $db->insert('pp_shop_products_images', [ + 'product_id' => $productId, + 'src' => $src, + 'alt' => $alt, + 'o' => $position, + ]); + + ApiRouter::sendSuccess([ + 'src' => $src, + 'alt' => $alt, + 'o' => $position, + ]); + } + /** * Mapuje dane z JSON API na format oczekiwany przez saveProduct(). * @@ -339,6 +428,11 @@ class ProductsApiController } } + // saveProduct() traktuje float 0.00 jako "puste", ale cena 0 musi pozostać jawnie ustawiona. + if (isset($d['price_brutto']) && is_numeric($d['price_brutto']) && (float)$d['price_brutto'] === 0.0) { + $d['price_brutto'] = '0'; + } + // String fields — direct mapping $stringFields = [ 'sku', 'ean', 'custom_label_0', 'custom_label_1', 'custom_label_2', diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 58dba18..92a55d1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,21 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze. --- +## ver. 0.318 (2026-02-24) - ShopPRO export produktów + API endpoints + +- **NEW**: `IntegrationsRepository::shopproExportProduct()` — eksport produktu do zdalnej instancji shopPRO: pola główne, tłumaczenia, custom fields, zdjęcia przez API (base64) +- **NEW**: `IntegrationsRepository::sendImageToShopproApi()` — wysyłka zdjęć do remote API shopPRO (endpoint `upload_image`) z base64 +- **REFACTOR**: `shopproImportProduct()` — wydzielono `shopproDb()` i `missingShopproSetting()` jako prywatne helpery; dodano import `security_information`, `producer_id`, custom fields i `alt` zdjęcia +- **NEW**: `AttributeRepository::ensureAttributeForApi()` i `ensureAttributeValueForApi()` — idempotent find-or-create dla atrybutów i ich wartości (integracje API) +- **NEW**: API endpoint `POST /api.php?endpoint=dictionaries&action=ensure_attribute` — utwórz lub znajdź atrybut po nazwie i typie +- **NEW**: API endpoint `POST /api.php?endpoint=dictionaries&action=ensure_attribute_value` — utwórz lub znajdź wartość atrybutu po nazwie +- **NEW**: API endpoint `POST /api.php?endpoint=products&action=upload_image` — przyjmuje zdjęcie produktu jako base64 JSON, zapisuje plik i rekord w `pp_shop_products_images` +- **NEW**: `IntegrationsController::shoppro_product_export()` — akcja admina eksportująca produkt do shopPRO +- **NEW**: Przycisk "Eksportuj do shopPRO" w liście produktów (widoczny gdy shopPRO enabled) +- **NEW**: Pole "API key" w ustawieniach integracji shopPRO (`shoppro-settings.php`) + +--- + ## ver. 0.317 (2026-02-23) - Klucz API: przycisk generowania + fix zapisu - **FIX**: `SettingsRepository::saveSettings()` — pole `api_key` brakowało w whiteliście zapisywanych pól, przez co wartość była tracona przy każdym zapisie (TRUNCATE + insert) diff --git a/docs/TESTING.md b/docs/TESTING.md index 585b3d1..b58ea82 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -23,10 +23,10 @@ composer test # standard ## Aktualny stan ```text -OK (758 tests, 2135 assertions) +OK (765 tests, 2153 assertions) ``` -Zweryfikowano: 2026-02-22 (ver. 0.304) +Zweryfikowano: 2026-02-24 (ver. 0.318) ## Konfiguracja diff --git a/docs/TODO.md b/docs/TODO.md index e69de29..6db26cb 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -0,0 +1 @@ +1. Dodać przycisk kopiowania przy atrybutach produktu w zamówieniu \ No newline at end of file diff --git a/tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php b/tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php index 8bd2771..d97678c 100644 --- a/tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php +++ b/tests/Unit/Domain/Integrations/IntegrationsRepositoryTest.php @@ -229,7 +229,7 @@ class IntegrationsRepositoryTest extends TestCase 'linkProduct', 'unlinkProduct', 'apiloAuthorize', 'apiloGetAccessToken', 'apiloKeepalive', 'apiloIntegrationStatus', 'apiloFetchList', 'apiloFetchListResult', 'apiloProductSearch', 'apiloCreateProduct', - 'getProductSku', 'shopproImportProduct', + 'getProductSku', 'shopproImportProduct', 'shopproExportProduct', ]; foreach ($expectedMethods as $method) { diff --git a/tests/Unit/admin/Controllers/IntegrationsControllerTest.php b/tests/Unit/admin/Controllers/IntegrationsControllerTest.php index 3cd51db..173aa34 100644 --- a/tests/Unit/admin/Controllers/IntegrationsControllerTest.php +++ b/tests/Unit/admin/Controllers/IntegrationsControllerTest.php @@ -118,6 +118,7 @@ class IntegrationsControllerTest extends TestCase 'shoppro_settings', 'shoppro_settings_save', 'shoppro_product_import', + 'shoppro_product_export', ]; foreach ($methods as $method) { @@ -157,6 +158,7 @@ class IntegrationsControllerTest extends TestCase 'apilo_product_select_delete', 'shoppro_settings_save', 'shoppro_product_import', + 'shoppro_product_export', ]; foreach ($voidMethods as $method) { diff --git a/tests/Unit/api/Controllers/DictionariesApiControllerTest.php b/tests/Unit/api/Controllers/DictionariesApiControllerTest.php index a138905..da4d3bb 100644 --- a/tests/Unit/api/Controllers/DictionariesApiControllerTest.php +++ b/tests/Unit/api/Controllers/DictionariesApiControllerTest.php @@ -186,4 +186,52 @@ class DictionariesApiControllerTest extends TestCase $this->assertSame(405, http_response_code()); } + + public function testEnsureAttributeRejectsGetMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + ob_start(); + $this->controller->ensure_attribute(); + ob_get_clean(); + + $this->assertSame(405, http_response_code()); + } + + public function testEnsureAttributeReturns400WhenNoBody(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + ob_start(); + $this->controller->ensure_attribute(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + $json = json_decode($output, true); + $this->assertSame('BAD_REQUEST', $json['code']); + } + + public function testEnsureAttributeValueRejectsGetMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + ob_start(); + $this->controller->ensure_attribute_value(); + ob_get_clean(); + + $this->assertSame(405, http_response_code()); + } + + public function testEnsureAttributeValueReturns400WhenNoBody(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + ob_start(); + $this->controller->ensure_attribute_value(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + $json = json_decode($output, true); + $this->assertSame('BAD_REQUEST', $json['code']); + } } diff --git a/tests/Unit/api/Controllers/ProductsApiControllerTest.php b/tests/Unit/api/Controllers/ProductsApiControllerTest.php index 236652f..f072916 100644 --- a/tests/Unit/api/Controllers/ProductsApiControllerTest.php +++ b/tests/Unit/api/Controllers/ProductsApiControllerTest.php @@ -351,6 +351,19 @@ class ProductsApiControllerTest extends TestCase $this->assertSame('5901234123457', $result['ean']); } + public function testMapApiToFormDataPreservesZeroBasePriceForSaveProduct(): void + { + $method = new \ReflectionMethod(ProductsApiController::class, 'mapApiToFormData'); + $method->setAccessible(true); + + $result = $method->invoke($this->controller, [ + 'price_brutto' => 0.0, + 'languages' => ['pl' => ['name' => 'Zero']], + ]); + + $this->assertSame('0', $result['price_brutto']); + } + public function testMapApiToFormDataMapsCategories(): void { $method = new \ReflectionMethod(ProductsApiController::class, 'mapApiToFormData');