workingCopy/ecomzone/classes/EcomZoneProductSync.php

724 lines
24 KiB
PHP

<?php
if (!defined("_PS_VERSION_")) {
exit();
}
class EcomZoneProductSync implements IProductSync
{
private EcomZoneClient $client;
private EcomZoneCategoryHandler $categoryHandler;
private int $defaultLangId;
private int $defaultShopId;
public function __construct()
{
$this->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
{
$page = 1;
$totalImported = 0;
$totalAvailable = 0;
$errors = [];
try {
EcomZoneLogger::log("Starting product import", "INFO", [
"per_page" => $perPage,
]);
do {
$catalog = $this->client->getCatalog($page, $perPage);
if (!isset($catalog["data"]) || !is_array($catalog["data"])) {
throw new EcomZoneException(
"Invalid catalog response",
EcomZoneException::API_ERROR
);
}
foreach ($catalog["data"] as $product) {
try {
if ($this->importSingleProduct($product)) {
$totalImported++;
}
} catch (Exception $e) {
$errors[] = [
"sku" => $product["sku"] ?? "unknown",
"error" => $e->getMessage(),
];
EcomZoneLogger::log("Product import failed", "ERROR", [
"sku" => $product["sku"] ?? "unknown",
"error" => $e->getMessage(),
]);
}
}
$totalAvailable = $catalog["total"] ?? 0;
$page++;
EcomZoneLogger::log("Page processed", "INFO", [
"page" => $page - 1,
"imported" => $totalImported,
"total" => $totalAvailable,
]);
} while (
isset($catalog["next_page_url"]) &&
$catalog["next_page_url"] !== null
);
$this->clearCache();
return [
"success" => true,
"imported" => $totalImported,
"total" => $totalAvailable,
"errors" => $errors,
];
} 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'];
$product->price = (float)$data['product_price'];
$product->wholesale_price = (float)($data['recommended_retail_price'] ?? 0);
$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_quantity'] ?? 0);
StockAvailable::setQuantity($product->id, 0, $quantity);
// Update out of stock behavior
StockAvailable::setProductOutOfStock(
$product->id,
$quantity > 0 ? 2 : 0, // 2 = Allow orders, 0 = Deny orders
$this->defaultShopId
);
} 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 = [];
// Process main image
if (!empty($data["image"])) {
$imageUrls[] = $data["image"];
}
// Process additional images
if (!empty($data["images"])) {
$additionalImages = json_decode($data["images"], true);
if (is_array($additionalImages)) {
$imageUrls = array_merge($imageUrls, $additionalImages);
}
}
if (empty($imageUrls)) {
return;
}
// Delete existing images if any
$product->deleteImages();
foreach ($imageUrls as $index => $imageUrl) {
$this->importProductImage($product, $imageUrl, $index === 0);
}
} catch (Exception $e) {
EcomZoneLogger::log("Failed to process product images", "WARNING", [
"sku" => $product->reference,
"error" => $e->getMessage(),
]);
}
}
private function importProductImage(Product $product, string $imageUrl, bool $cover = false): void
{
try {
// Create temporary file
$tempFile = tempnam(_PS_TMP_IMG_DIR_, 'import_');
// Download image
if (!copy($imageUrl, $tempFile)) {
throw new Exception("Failed to download image: " . $imageUrl);
}
// 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)) {
mkdir($imageDir, 0755, true);
}
// Generate image
if (!ImageManager::resize(
$tempFile,
$imagePath . '.jpg'
)) {
throw new Exception("Failed to process image");
}
// Generate thumbnails
$this->generateThumbnails($image);
// Clean up
unlink($tempFile);
} catch (Exception $e) {
EcomZoneLogger::log("Failed to import product image", "ERROR", [
"product" => $product->reference,
"image_url" => $imageUrl,
"error" => $e->getMessage()
]);
}
}
private function generateThumbnails(Image $image): void
{
try {
$imageTypes = ImageType::getImagesTypes('products');
foreach ($imageTypes as $imageType) {
$dir = _PS_PROD_IMG_DIR_ . $image->getExistingImgPath();
if (!file_exists($dir)) {
mkdir($dir, 0755, true);
}
ImageManager::resize(
_PS_PROD_IMG_DIR_ . $image->getExistingImgPath() . '.jpg',
_PS_PROD_IMG_DIR_ . $image->getExistingImgPath() . '-' . stripslashes($imageType['name']) . '.jpg',
(int)$imageType['width'],
(int)$imageType['height']
);
}
} catch (Exception $e) {
EcomZoneLogger::log("Failed to generate thumbnails", "WARNING", [
"image_id" => $image->id,
"error" => $e->getMessage()
]);
}
}
private function generateImageType(
string $srcPath,
string $destPath,
?int $width,
?int $height
): void {
try {
$this->createDirectoryIfNotExists(dirname($destPath));
if ($width === null || $height === null) {
copy($srcPath, $destPath);
return;
}
$imageInfo = getimagesize($srcPath);
if (!$imageInfo) {
throw new Exception("Invalid image file");
}
$srcImage = $this->createImageFromType($srcPath, $imageInfo[2]);
$destImage = imagecreatetruecolor($width, $height);
// Preserve transparency for PNG images
if ($imageInfo[2] === IMAGETYPE_PNG) {
imagealphablending($destImage, false);
imagesavealpha($destImage, true);
$transparent = imagecolorallocatealpha(
$destImage,
255,
255,
255,
127
);
imagefilledrectangle(
$destImage,
0,
0,
$width,
$height,
$transparent
);
}
imagecopyresampled(
$destImage,
$srcImage,
0,
0,
0,
0,
$width,
$height,
$imageInfo[0],
$imageInfo[1]
);
imagejpeg($destImage, $destPath, 95);
imagedestroy($srcImage);
imagedestroy($destImage);
} catch (Exception $e) {
throw new EcomZoneException(
"Failed to generate image type: " . $e->getMessage(),
EcomZoneException::IMAGE_PROCESSING_ERROR,
$e
);
}
}
private function createImageFromType(string $filename, int $type)
{
switch ($type) {
case IMAGETYPE_JPEG:
return imagecreatefromjpeg($filename);
case IMAGETYPE_PNG:
return imagecreatefrompng($filename);
case IMAGETYPE_GIF:
return imagecreatefromgif($filename);
default:
throw new Exception("Unsupported image type: " . $type);
}
}
private function createDirectoryIfNotExists(string $directory): void
{
if (!is_dir($directory)) {
if (!mkdir($directory, 0755, true)) {
throw new Exception(
"Failed to create directory: " . $directory
);
}
}
}
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();
}
}
$token = Configuration::get('ECOMZONE_CRON_TOKEN');
echo $token;