client = new EcomZoneClient(); $this->categoryHandler = new EcomZoneCategoryHandler(); $this->defaultLangId = (int) Configuration::get("PS_LANG_DEFAULT"); $this->defaultShopId = (int) Context::getContext()->shop->id; EcomZoneLogger::log("Product Sync initialized", "INFO"); } public function importProducts(int $perPage = 100): array { try { // Get or initialize sync state $syncState = $this->getSyncState(); // If this is a new sync (page 1 and no imports), reset everything if (empty($syncState) || ($syncState['current_page'] ?? 1) === 1 && ($syncState['total_imported'] ?? 0) === 0) { $this->resetSyncState(); $syncState = []; } $page = $syncState['current_page'] ?? 1; $totalImported = $syncState['total_imported'] ?? 0; $totalAvailable = $syncState['total_available'] ?? 0; $errors = $syncState['errors'] ?? []; $maxProductsPerBatch = 20; // Process 20 products at a time to avoid timeout EcomZoneLogger::log("Starting/Resuming product import", "INFO", [ "page" => $page, "per_page" => $perPage, "total_imported" => $totalImported, "is_new_sync" => empty($syncState) ]); // Get catalog for current page $catalog = $this->client->getCatalog($page, $perPage); if (!isset($catalog["data"]) || !is_array($catalog["data"])) { throw new EcomZoneException( "Invalid catalog response", EcomZoneException::API_ERROR ); } // Update total available on first page or if it changes if ($page === 1 || ($catalog["total"] ?? 0) !== $totalAvailable) { $totalAvailable = $catalog["total"] ?? 0; } $productsProcessed = 0; $batchErrors = []; // Process only a batch of products foreach ($catalog["data"] as $product) { if ($productsProcessed >= $maxProductsPerBatch) { break; } try { if ($this->importSingleProduct($product)) { $totalImported++; } } catch (Exception $e) { $batchErrors[] = [ "sku" => $product["sku"] ?? "unknown", "error" => $e->getMessage(), ]; EcomZoneLogger::log("Product import failed", "ERROR", [ "sku" => $product["sku"] ?? "unknown", "error" => $e->getMessage(), ]); } $productsProcessed++; } // Update errors array with new batch errors $errors = array_merge($errors, $batchErrors); // Calculate progress $progress = [ 'current_page' => $page, 'total_imported' => $totalImported, 'total_available' => $totalAvailable, 'errors' => $errors, 'is_complete' => false, 'products_processed_on_page' => $productsProcessed ]; // Check if we need to continue to next page if ($productsProcessed < count($catalog["data"])) { // Still more products to process on this page $progress['products_processed_on_page'] = $productsProcessed; } else { // Move to next page if available if (isset($catalog["next_page_url"]) && $catalog["next_page_url"] !== null) { $progress['current_page'] = $page + 1; $progress['products_processed_on_page'] = 0; } else { $progress['is_complete'] = true; } } // Save progress $this->saveSyncState($progress); // Clear cache if complete if ($progress['is_complete']) { $this->clearCache(); } return [ "success" => true, "imported" => $totalImported, "total" => $totalAvailable, "errors" => $errors, "is_complete" => $progress['is_complete'], "current_page" => $progress['current_page'] ]; } catch (Exception $e) { EcomZoneLogger::log("Product import process failed", "ERROR", [ "error" => $e->getMessage(), ]); throw $e; } } public function importSingleProduct(array $productData): bool { try { // Get SKU from catalog data $sku = $productData["sku"] ?? $productData["data"]["sku"] ?? null; if (!$sku) { throw new EcomZoneException("Missing SKU in product data"); } // Fetch complete product data from product endpoint $fullProductData = $this->client->getProduct($sku); if (!isset($fullProductData["data"])) { throw new EcomZoneException("Invalid product response"); } $data = $fullProductData["data"]; EcomZoneLogger::log("Processing product", "INFO", [ "sku" => $sku, "categories" => $data["categories"] ?? null, "google_categories" => $data["google_categories"] ?? null ]); $this->validateProductData($data); // Start transaction Db::getInstance()->execute("START TRANSACTION"); $product = $this->getOrCreateProduct($sku); // Update product data $this->updateProductData($product, $data); $this->updateProductCategories($product, $data); $this->updateProductStock($product, $data); $this->processProductImages($product, $data); // Commit transaction Db::getInstance()->execute("COMMIT"); return true; } catch (Exception $e) { Db::getInstance()->execute("ROLLBACK"); EcomZoneLogger::log("Product import failed", "ERROR", [ "sku" => $sku ?? "unknown", "error" => $e->getMessage() ]); return false; } } private function validateProductData(array $data): void { $requiredFields = [ 'sku', 'product_name', 'product_price', 'description', 'stock' ]; foreach ($requiredFields as $field) { if (empty($data[$field])) { throw new EcomZoneException( "Missing required field: {$field}", EcomZoneException::VALIDATION_ERROR ); } } } private function getOrCreateProduct(string $sku): Product { try { $productId = Product::getIdByReference($sku); if ($productId) { $product = new Product($productId); if (!Validate::isLoadedObject($product)) { throw new Exception("Invalid product object"); } return $product; } $product = new Product(); $product->reference = $sku; return $product; } catch (Exception $e) { throw new EcomZoneException( "Failed to get/create product: " . $e->getMessage(), EcomZoneException::INVALID_PRODUCT_DATA, $e ); } } private function updateProductData(Product $product, array $data): void { try { // Basic information $product->name[$this->defaultLangId] = $data['product_name']; $product->description[$this->defaultLangId] = $data['long_description'] ?? $data['description']; $product->description_short[$this->defaultLangId] = $data['description']; // Update prices $product->wholesale_price = (float)$data['product_price']; // Original price as wholesale price $product->price = (float)($data['recommended_retail_price'] ?? $data['product_price']); // Recommended retail price for shop display $product->reference = $data['sku']; $product->ean13 = $data['ean'] ?? ''; // Shop configuration $product->id_shop_default = $this->defaultShopId; $product->active = true; $product->visibility = 'both'; $product->available_for_order = true; $product->show_price = true; $product->indexed = 1; // Update dimensions if available if (!empty($data['dimensions'])) { $dimensions = json_decode($data['dimensions'], true); if (is_array($dimensions)) { $product->weight = (float)($dimensions['weight'] ?? 0); $product->height = (float)($dimensions['height'] ?? 0); $product->width = (float)($dimensions['width'] ?? 0); $product->depth = (float)($dimensions['length'] ?? 0); } } // Set tax rules if configured $taxRulesGroupId = (int)Configuration::get('PS_TAX_RULES_GROUP_DEFAULT'); if ($taxRulesGroupId) { $product->id_tax_rules_group = $taxRulesGroupId; } // Save the product if (!$product->id) { if (!$product->add()) { throw new EcomZoneException('Failed to create product'); } } else { if (!$product->update()) { throw new EcomZoneException('Failed to update product'); } } // Update shop association if (Shop::isFeatureActive()) { Shop::setContext(Shop::CONTEXT_SHOP, $this->defaultShopId); $product->id_shop_list = [$this->defaultShopId]; $product->id_shop_default = $this->defaultShopId; // Associate product with shop Db::getInstance()->execute(' INSERT IGNORE INTO '._DB_PREFIX_.'product_shop (id_product, id_shop, price, wholesale_price) VALUES ('.(int)$product->id.', '.(int)$this->defaultShopId.', '.(float)$product->price.', '.(float)$product->wholesale_price.') '); } // Process specifications if available if (!empty($data['specifications'])) { $this->updateProductFeatures($product, $data['specifications']); } } catch (Exception $e) { throw new EcomZoneException( 'Failed to update product data: ' . $e->getMessage(), EcomZoneException::INVALID_PRODUCT_DATA, $e ); } } private function updateProductCategories(Product $product, array $data): void { try { $categoryIds = []; // Process main categories if (!empty($data['categories'])) { $categories = json_decode($data['categories'], true); if (is_array($categories)) { foreach ($categories as $categoryPath) { // Skip special categories that start with '0' if (strpos($categoryPath, '0') === 0) { continue; } // Create each category as a single path $categoryId = $this->categoryHandler->createCategoryPath([$categoryPath]); if ($categoryId) { $categoryIds[] = $categoryId; } } } } // Process Google categories if (!empty($data['google_categories'])) { $googleCategories = json_decode($data['google_categories'], true); if (is_array($googleCategories)) { $categoryId = $this->categoryHandler->createCategoryPath($googleCategories); if ($categoryId) { $categoryIds[] = $categoryId; } } } // Ensure we have at least the default category if (empty($categoryIds)) { $categoryIds[] = (int)Configuration::get('PS_HOME_CATEGORY'); } // Remove duplicates $categoryIds = array_unique($categoryIds); // Set default category $product->id_category_default = reset($categoryIds); // Save the product to ensure it has an ID if (!$product->id) { $product->add(); } else { $product->update(); } // Clear existing category associations Db::getInstance()->execute(' DELETE FROM `'._DB_PREFIX_.'category_product` WHERE id_product = '.(int)$product->id ); // Insert new category associations foreach ($categoryIds as $categoryId) { Db::getInstance()->execute(' INSERT INTO `'._DB_PREFIX_.'category_product` (id_category, id_product, position) VALUES ('.(int)$categoryId.', '.(int)$product->id.', 0) '); } // Update shop associations if needed if (Shop::isFeatureActive()) { $product->id_shop_list = [$this->defaultShopId]; $product->id_shop_default = $this->defaultShopId; } EcomZoneLogger::log('Categories updated', 'DEBUG', [ 'product' => $product->reference, 'category_ids' => $categoryIds ]); } catch (Exception $e) { throw new EcomZoneException( 'Failed to update product categories: ' . $e->getMessage(), EcomZoneException::CATEGORY_CREATE_ERROR, $e ); } } private function updateProductStock(Product $product, array $data): void { try { $quantity = (int)($data['stock'] ?? 0); // Make sure product is saved first if (!$product->id) { $product->save(); } // Update stock quantity StockAvailable::setQuantity( $product->id, 0, // Combination ID (0 for products without combinations) $quantity ); // Update out of stock behavior StockAvailable::setProductOutOfStock( $product->id, 2, // Always allow orders $this->defaultShopId ); EcomZoneLogger::log("Stock updated", "DEBUG", [ "product" => $product->reference, "quantity" => $quantity ]); } catch (Exception $e) { EcomZoneLogger::log("Failed to update stock", "WARNING", [ "product" => $product->reference, "error" => $e->getMessage() ]); } } private function processProductImages(Product $product, array $data): void { try { $imageUrls = []; EcomZoneLogger::log("Raw product data for images", "DEBUG", [ "product" => $product->reference, "data" => json_encode($data) ]); // Handle different possible data structures if (isset($data['images']) && is_string($data['images'])) { $images = json_decode($data['images'], true); if (json_last_error() === JSON_ERROR_NONE && is_array($images)) { $imageUrls = array_merge($imageUrls, $images); } } elseif (isset($data['images']) && is_array($data['images'])) { $imageUrls = array_merge($imageUrls, $data['images']); } // Add main image if available if (!empty($data['image'])) { array_unshift($imageUrls, $data['image']); // Add as first image } // Filter out empty or invalid URLs $imageUrls = array_filter($imageUrls, function($url) { return !empty($url) && filter_var($url, FILTER_VALIDATE_URL); }); if (empty($imageUrls)) { EcomZoneLogger::log("No valid images found for product", "WARNING", [ "product" => $product->reference, "data" => json_encode($data) ]); return; } // Delete existing images if any $product->deleteImages(); $successCount = 0; foreach ($imageUrls as $index => $imageUrl) { try { EcomZoneLogger::log("Processing image", "DEBUG", [ "product" => $product->reference, "image_url" => $imageUrl, "index" => $index ]); $this->importProductImage($product, $imageUrl, $index === 0); $successCount++; } catch (Exception $e) { EcomZoneLogger::log("Failed to process image", "WARNING", [ "product" => $product->reference, "image_url" => $imageUrl, "error" => $e->getMessage() ]); continue; } } if ($successCount === 0 && !empty($imageUrls)) { throw new Exception(sprintf( "Failed to process any images for the product. Attempted %d images.", count($imageUrls) )); } EcomZoneLogger::log("Processed product images", "INFO", [ "product" => $product->reference, "total_images" => count($imageUrls), "successful_imports" => $successCount ]); } catch (Exception $e) { EcomZoneLogger::log("Failed to process product images", "ERROR", [ "sku" => $product->reference, "error" => $e->getMessage(), "data" => json_encode($data) ]); throw $e; } } private function importProductImage(Product $product, string $imageUrl, bool $cover = false): void { try { // Create temporary directory if it doesn't exist $tempDir = _PS_MODULE_DIR_ . 'ecomzone/tmp/'; if (!is_dir($tempDir)) { mkdir($tempDir, 0755, true); chmod($tempDir, 0755); } // Clean URL - handle double escaped slashes $cleanUrl = stripslashes(stripslashes(trim($imageUrl))); EcomZoneLogger::log("Processing image URL", "DEBUG", [ "product" => $product->reference, "original_url" => $imageUrl, "cleaned_url" => $cleanUrl ]); // Create temp file $tempFile = tempnam($tempDir, 'import_'); if ($tempFile) { chmod($tempFile, 0644); } // Use the client class to download the image $imageResult = $this->client->downloadImage($cleanUrl); $imageData = $imageResult['data']; $contentType = $imageResult['content_type']; // Save image data to temp file file_put_contents($tempFile, $imageData); // Verify the downloaded file is a valid image $imageInfo = @getimagesize($tempFile); if (!$imageInfo) { throw new Exception("Downloaded file is not a valid image. Content type: $contentType"); } // Create image object $image = new Image(); $image->id_product = $product->id; $image->position = Image::getHighestPosition($product->id) + 1; $image->cover = $cover; if (!$image->add()) { throw new Exception("Failed to create image record"); } // Create product image directory if it doesn't exist $imagePath = _PS_PROD_IMG_DIR_ . $image->getExistingImgPath(); $imageDir = dirname($imagePath); if (!file_exists($imageDir)) { if (!mkdir($imageDir, 0755, true)) { throw new Exception("Failed to create image directory: " . $imageDir); } chmod($imageDir, 0755); } // Generate image if (!ImageManager::resize( $tempFile, $imagePath . '.jpg', null, null, 'jpg' )) { throw new Exception("Failed to process image"); } // Ensure the generated image has correct permissions chmod($imagePath . '.jpg', 0644); // Generate thumbnails $this->generateThumbnails($image); // Clean up if (file_exists($tempFile)) { unlink($tempFile); } EcomZoneLogger::log("Image imported successfully", "DEBUG", [ "product" => $product->reference, "image_url" => $cleanUrl, "mime_type" => $imageInfo['mime'] ]); } catch (Exception $e) { EcomZoneLogger::log("Failed to import product image", "ERROR", [ "product" => $product->reference, "image_url" => $imageUrl, "clean_url" => $cleanUrl ?? '', "error" => $e->getMessage(), "content_type" => $contentType ?? null ]); // Clean up on error if (isset($tempFile) && file_exists($tempFile)) { unlink($tempFile); } // Delete image record if it was created if (isset($image) && $image->id) { $image->delete(); } throw $e; } } private function generateThumbnails(Image $image): void { try { $imageTypes = ImageType::getImagesTypes('products'); $sourceFile = _PS_PROD_IMG_DIR_ . $image->getExistingImgPath() . '.jpg'; if (!file_exists($sourceFile)) { throw new Exception("Source image file not found"); } foreach ($imageTypes as $imageType) { $destination = _PS_PROD_IMG_DIR_ . $image->getExistingImgPath() . '-' . stripslashes($imageType['name']) . '.jpg'; $dir = dirname($destination); if (!file_exists($dir)) { if (!mkdir($dir, 0755, true)) { throw new Exception("Failed to create thumbnail directory: " . $dir); } chmod($dir, 0755); } if (!ImageManager::resize( $sourceFile, $destination, (int)$imageType['width'], (int)$imageType['height'] )) { throw new Exception("Failed to generate thumbnail: " . $imageType['name']); } // Set proper permissions for the thumbnail chmod($destination, 0644); } } catch (Exception $e) { EcomZoneLogger::log("Failed to generate thumbnails", "WARNING", [ "image_id" => $image->id, "error" => $e->getMessage() ]); throw $e; } } private function updateProductFeatures(Product $product, string $specificationsJson): void { try { $specifications = json_decode($specificationsJson, true); if (!is_array($specifications)) { return; } foreach ($specifications as $featureName => $featureValue) { if (empty($featureValue)) { continue; } // Convert array or object to string if necessary if (is_array($featureValue) || is_object($featureValue)) { $featureValue = json_encode($featureValue); } // Ensure we have a string value $featureValue = (string)$featureValue; // Get or create feature using DB query $featureId = $this->getOrCreateFeature($featureName); if (!$featureId) { continue; } // Get or create feature value $featureValueId = $this->getOrCreateFeatureValue($featureId, $featureValue); if (!$featureValueId) { continue; } // Associate feature with product $product->addFeaturesToDB( [$featureId => $featureValueId], $this->defaultLangId ); } EcomZoneLogger::log("Features updated successfully", "DEBUG", [ "product" => $product->reference, "specifications" => $specifications ]); } catch (Exception $e) { EcomZoneLogger::log( "Failed to update product features", "WARNING", [ "product" => $product->reference, "error" => $e->getMessage(), ] ); } } private function getOrCreateFeature(string $name): ?int { // Try to find existing feature $featureId = (int)Db::getInstance()->getValue( 'SELECT f.id_feature FROM `'._DB_PREFIX_.'feature_lang` fl JOIN `'._DB_PREFIX_.'feature` f ON f.id_feature = fl.id_feature WHERE fl.name = "'.pSQL($name).'" AND fl.id_lang = '.(int)$this->defaultLangId ); if ($featureId) { return $featureId; } // Create new feature $feature = new Feature(); $feature->name = array_fill_keys(Language::getIDs(), $name); return $feature->add() ? (int)$feature->id : null; } private function getOrCreateFeatureValue(int $featureId, string $value): ?int { // Try to find existing feature value $valueId = (int)Db::getInstance()->getValue( 'SELECT fv.id_feature_value FROM `'._DB_PREFIX_.'feature_value_lang` fvl JOIN `'._DB_PREFIX_.'feature_value` fv ON fv.id_feature_value = fvl.id_feature_value WHERE fv.id_feature = '.(int)$featureId.' AND fvl.value = "'.pSQL($value).'" AND fvl.id_lang = '.(int)$this->defaultLangId ); if ($valueId) { return $valueId; } // Create new feature value $featureValue = new FeatureValue(); $featureValue->id_feature = $featureId; $featureValue->value = array_fill_keys(Language::getIDs(), $value); return $featureValue->add() ? (int)$featureValue->id : null; } private function updateProductDimensions( Product $product, string $dimensionsJson ): void { try { $dimensions = json_decode($dimensionsJson, true); if (!is_array($dimensions)) { return; } $product->weight = (float) ($dimensions["weight"] ?? 0); $product->height = (float) ($dimensions["height"] ?? 0); $product->width = (float) ($dimensions["width"] ?? 0); $product->depth = (float) ($dimensions["length"] ?? 0); $product->update(); } catch (Exception $e) { EcomZoneLogger::log( "Failed to update product dimensions", "WARNING", [ "product" => $product->reference, "error" => $e->getMessage(), ] ); } } private function clearCache(): void { Tools::clearAllCache(); Tools::clearSmartyCache(); Tools::clearXMLCache(); Media::clearCache(); PrestaShopAutoload::getInstance()->generateIndex(); } private function getSyncState(): array { $state = Configuration::get('ECOMZONE_SYNC_STATE'); return $state ? json_decode($state, true) : []; } private function saveSyncState(array $state): void { Configuration::updateValue('ECOMZONE_SYNC_STATE', json_encode($state)); } public function resetSyncState(): void { // Delete both sync state and progress Configuration::deleteByName('ECOMZONE_SYNC_STATE'); Configuration::deleteByName('ECOMZONE_SYNC_PROGRESS'); Configuration::deleteByName('ECOMZONE_LAST_SYNC'); EcomZoneLogger::log("Sync state reset", "INFO"); } } $token = Configuration::get('ECOMZONE_CRON_TOKEN'); echo $token;