category -> product association implemented
This commit is contained in:
1 parent
0b8c5de8b0
commit
c8bcfe6bd6
41 files changed
+2153
-634
No files matched your search
@@ -0,0 +1,33 @@
|
||||
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "",
|
||||
"data": {
|
||||
"sku": "2246-911",
|
||||
"product_name": "KitchPro",
|
||||
"product_price": "12.480",
|
||||
"currency": "EUR",
|
||||
"recommended_retail_price": "32.990",
|
||||
"image": "https:\/\/zoho-site.com\/cdn\/img\/649036",
|
||||
"images": "[\"https:\\\/\\\/ourshopcdn.com\\\/01\\\/56\\\/db\\\/40\\\/fb\\\/5d\\\/5e\\\/9f\\\/1c\\\/40\\\/8
|
||||
"gif_or_video": "https:\/\/zoho-site.com\/cdn\/img\/1290557",
|
||||
"description": "Kitchen food processor",
|
||||
"long_description": "Multi-function kitchen tool grinds, slices, chops and mashes foods to help you quickly",
|
||||
"categories": "[\"Kitchen\",\"0Promo\",\"0Warranty\",\"0Cross-sell\",\"0Clearance\"]",
|
||||
"google_categories": "[\"Home & Garden\",\"Kitchen & Dining\",\"Kitchen Appliances\",\"Food Mixers & Blende",
|
||||
"specifications": "{\"description\":\"KitchPro is a multi-purpose food processor. It is designed to chop, m}",
|
||||
"dimensions": "{\"weight\":0,\"height\":0,\"width\":0,\"length\":0}",
|
||||
"stock": "269",
|
||||
"updated_at": "2025-02-13 14:01:03",
|
||||
"products_id": [
|
||||
17842
|
||||
],
|
||||
"ean": "3831125001095",
|
||||
"has_variations": false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
php cron.php token=2bff5bb42beb2069ee5dc2fdb6f68933
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,53 @@
|
||||
|
||||
<?php
|
||||
if (!defined("_PS_VERSION_")) {
|
||||
exit();
|
||||
}
|
||||
|
||||
class EcomZoneApiTest
|
||||
{
|
||||
private $client;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->client = new EcomZoneClient();
|
||||
}
|
||||
|
||||
public function testProductFetch()
|
||||
{
|
||||
try {
|
||||
// Get first page with one product
|
||||
$result = $this->client->getCatalog(1, 1);
|
||||
|
||||
EcomZoneLogger::log("API Test Result", "INFO", [
|
||||
"result" => $result,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (Exception $e) {
|
||||
EcomZoneLogger::log("API Test Failed", "ERROR", [
|
||||
"error" => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
public function getProducts($page = 1, $perPage = 10)
|
||||
{
|
||||
try {
|
||||
$result = $this->client->getCatalog($page, $perPage);
|
||||
|
||||
return [
|
||||
"products" => $result["data"] ?? [],
|
||||
"total" => $result["total"] ?? 0,
|
||||
"current_page" => $page,
|
||||
"per_page" => $perPage,
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
EcomZoneLogger::log("Failed to get products", "ERROR", [
|
||||
"error" => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
if (!defined("_PS_VERSION_")) {
|
||||
exit();
|
||||
}
|
||||
|
||||
class EcomZoneCategoryHandler implements ICategory
|
||||
{
|
||||
private int $defaultLangId;
|
||||
private array $categoryCache = [];
|
||||
private array $categoryLinkRewriteCache = [];
|
||||
private int $defaultShopId;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->defaultLangId = (int) Configuration::get("PS_LANG_DEFAULT");
|
||||
$this->defaultShopId = (int) Context::getContext()->shop->id;
|
||||
|
||||
EcomZoneLogger::log("Category Handler initialized", "INFO", [
|
||||
"default_lang" => $this->defaultLangId,
|
||||
"shop_id" => $this->defaultShopId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createCategory(string $name, ?int $parentId = null): int
|
||||
{
|
||||
try {
|
||||
if (isset($this->categoryCache[$name])) {
|
||||
return $this->categoryCache[$name];
|
||||
}
|
||||
|
||||
$parentId =
|
||||
$parentId ?: (int) Configuration::get("PS_HOME_CATEGORY");
|
||||
|
||||
$category = new Category();
|
||||
$category->name = [$this->defaultLangId => trim($name)];
|
||||
$category->link_rewrite = [
|
||||
$this->defaultLangId => $this->generateLinkRewrite($name),
|
||||
];
|
||||
$category->id_parent = $parentId;
|
||||
$category->active = true;
|
||||
$category->id_shop_default = $this->defaultShopId;
|
||||
|
||||
// Set shop association for multistore
|
||||
if (Shop::isFeatureActive()) {
|
||||
$category->id_shop_list = [$this->defaultShopId];
|
||||
}
|
||||
|
||||
if (!$category->add()) {
|
||||
throw new EcomZoneException(
|
||||
"Failed to create category: $name",
|
||||
EcomZoneException::CATEGORY_CREATE_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
$this->categoryCache[$name] = (int) $category->id;
|
||||
|
||||
EcomZoneLogger::log("Category created", "INFO", [
|
||||
"name" => $name,
|
||||
"id" => $category->id,
|
||||
"parent_id" => $parentId,
|
||||
]);
|
||||
|
||||
return $this->categoryCache[$name];
|
||||
} catch (Exception $e) {
|
||||
EcomZoneLogger::log("Category creation failed", "ERROR", [
|
||||
"name" => $name,
|
||||
"error" => $e->getMessage(),
|
||||
]);
|
||||
throw new EcomZoneException(
|
||||
"Category creation failed: " . $e->getMessage(),
|
||||
EcomZoneException::CATEGORY_CREATE_ERROR,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function getCategoryIdByName(string $name, ?int $parentId = null): ?int
|
||||
{
|
||||
try {
|
||||
if (isset($this->categoryCache[$name])) {
|
||||
return $this->categoryCache[$name];
|
||||
}
|
||||
|
||||
$name = trim($name);
|
||||
$sql = 'SELECT c.id_category
|
||||
FROM `' .
|
||||
_DB_PREFIX_ .
|
||||
'category_lang` cl
|
||||
JOIN `' .
|
||||
_DB_PREFIX_ .
|
||||
'category` c ON c.id_category = cl.id_category
|
||||
WHERE cl.name = "' .
|
||||
pSQL($name) .
|
||||
'"
|
||||
AND cl.id_lang = ' .
|
||||
(int) $this->defaultLangId .
|
||||
'
|
||||
AND c.active = 1';
|
||||
|
||||
if ($parentId !== null) {
|
||||
$sql .= ' AND c.id_parent = ' . (int) $parentId;
|
||||
}
|
||||
|
||||
$categoryId = (int) Db::getInstance()->getValue($sql);
|
||||
|
||||
if ($categoryId) {
|
||||
$this->categoryCache[$name] = $categoryId;
|
||||
return $categoryId;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (Exception $e) {
|
||||
EcomZoneLogger::log("Category lookup failed", "ERROR", [
|
||||
"name" => $name,
|
||||
"error" => $e->getMessage(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function processCategoriesFromString(string $categories): array
|
||||
{
|
||||
$categoryNames = array_map('trim', explode('>', $categories));
|
||||
$categoryIds = [];
|
||||
$parentId = null;
|
||||
|
||||
foreach ($categoryNames as $name) {
|
||||
$categoryId = $this->getCategoryIdByName($name, $parentId);
|
||||
if (!$categoryId) {
|
||||
$categoryId = $this->createCategory($name, $parentId);
|
||||
}
|
||||
if ($categoryId) {
|
||||
$categoryIds[] = $categoryId;
|
||||
$parentId = $categoryId;
|
||||
}
|
||||
}
|
||||
|
||||
return $categoryIds;
|
||||
}
|
||||
|
||||
private function generateLinkRewrite(string $name): string
|
||||
{
|
||||
if (isset($this->categoryLinkRewriteCache[$name])) {
|
||||
return $this->categoryLinkRewriteCache[$name];
|
||||
}
|
||||
|
||||
$linkRewrite = Tools::str2url($name);
|
||||
|
||||
// Ensure uniqueness
|
||||
$suffix = 1;
|
||||
$originalLinkRewrite = $linkRewrite;
|
||||
while ($this->linkRewriteExists($linkRewrite)) {
|
||||
$linkRewrite = $originalLinkRewrite . "-" . $suffix++;
|
||||
}
|
||||
|
||||
$this->categoryLinkRewriteCache[$name] = $linkRewrite;
|
||||
return $linkRewrite;
|
||||
}
|
||||
|
||||
private function linkRewriteExists(string $linkRewrite): bool
|
||||
{
|
||||
return (bool) Db::getInstance()->getValue(
|
||||
'SELECT COUNT(*)
|
||||
FROM `' .
|
||||
_DB_PREFIX_ .
|
||||
'category_lang` cl
|
||||
JOIN `' .
|
||||
_DB_PREFIX_ .
|
||||
'category_shop` cs
|
||||
ON cl.id_category = cs.id_category
|
||||
WHERE cl.link_rewrite = "' .
|
||||
pSQL($linkRewrite) .
|
||||
'"
|
||||
AND cl.id_lang = ' .
|
||||
(int) $this->defaultLangId .
|
||||
'
|
||||
AND cs.id_shop = ' .
|
||||
(int) $this->defaultShopId
|
||||
);
|
||||
}
|
||||
|
||||
public function createCategoryPath(array $categoryNames): int
|
||||
{
|
||||
$parentId = (int) Configuration::get("PS_HOME_CATEGORY");
|
||||
$lastCategoryId = $parentId;
|
||||
|
||||
foreach ($categoryNames as $categoryName) {
|
||||
$categoryName = trim($categoryName);
|
||||
if (empty($categoryName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$categoryId = $this->getCategoryIdByName($categoryName);
|
||||
if (!$categoryId) {
|
||||
$categoryId = $this->createCategory(
|
||||
$categoryName,
|
||||
$parentId
|
||||
);
|
||||
}
|
||||
$lastCategoryId = $categoryId;
|
||||
$parentId = $categoryId;
|
||||
} catch (Exception $e) {
|
||||
EcomZoneLogger::log("Category path creation failed", "ERROR", [
|
||||
"category" => $categoryName,
|
||||
"error" => $e->getMessage(),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $lastCategoryId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,92 @@
|
||||
<?php
|
||||
if (!defined("_PS_VERSION_")) {
|
||||
exit();
|
||||
}
|
||||
|
||||
class EcomZoneClient
|
||||
{
|
||||
private $apiToken;
|
||||
private $apiUrl;
|
||||
private $maxRetries = 3;
|
||||
private $timeout = 30;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Hardcoded API token for testing
|
||||
$this->apiToken = 'klRyAdrXaxL0s6PEUp7LDlH6T8aPSCtBY8NiEHsHiWpc6646K2TZPi5KMxUg';
|
||||
$this->apiUrl = Configuration::get('ECOMZONE_API_URL');
|
||||
$this->apiToken = Configuration::get("ECOMZONE_API_TOKEN");
|
||||
$this->apiUrl = Configuration::get("ECOMZONE_API_URL");
|
||||
|
||||
if (empty($this->apiToken)) {
|
||||
throw new EcomZoneException(
|
||||
"API token not configured",
|
||||
EcomZoneException::API_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
if (empty($this->apiUrl)) {
|
||||
throw new Exception('API URL not configured');
|
||||
throw new EcomZoneException(
|
||||
"API URL not configured",
|
||||
EcomZoneException::API_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
EcomZoneLogger::log("API Client initialized", "INFO", [
|
||||
"url" => $this->apiUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getCatalog($page = 1, $perPage = 100)
|
||||
{
|
||||
$url = $this->apiUrl . '/catalog?page=' . $page . '&per_page=' . $perPage;
|
||||
$url = $this->apiUrl . "/catalog";
|
||||
$params = [
|
||||
"page" => $page,
|
||||
"per_page" => $perPage,
|
||||
];
|
||||
|
||||
EcomZoneLogger::log("Making API request", 'INFO', ['url' => $url]);
|
||||
return $this->makeRequest('GET', $url);
|
||||
return $this->makeRequest("GET", $url, $params);
|
||||
}
|
||||
|
||||
public function getProduct($sku)
|
||||
{
|
||||
return $this->makeRequest('GET', $this->apiUrl . '/product/' . $sku);
|
||||
$url = $this->apiUrl . "/product/" . urlencode($sku);
|
||||
return $this->makeRequest("GET", $url);
|
||||
}
|
||||
|
||||
public function createOrder($orderData)
|
||||
{
|
||||
return $this->makeRequest('POST', $this->apiUrl . '/ordering', $orderData);
|
||||
}
|
||||
|
||||
public function getOrder($orderId)
|
||||
{
|
||||
return $this->makeRequest('GET', $this->apiUrl . '/order/' . $orderId);
|
||||
}
|
||||
|
||||
private function makeRequest($method, $url, $data = null)
|
||||
private function makeRequest($method, $url, $params = [], $data = null)
|
||||
{
|
||||
$retryCount = 0;
|
||||
$maxRetries = 5;
|
||||
$backoff = 1; // initial backoff time in seconds
|
||||
do {
|
||||
$curl = curl_init();
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . $this->apiToken,
|
||||
'Accept: application/json',
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
$lastError = null;
|
||||
|
||||
do {
|
||||
try {
|
||||
EcomZoneLogger::log("Making API request", "INFO", [
|
||||
"method" => $method,
|
||||
"url" => $url,
|
||||
"params" => $params,
|
||||
"retry" => $retryCount,
|
||||
]);
|
||||
|
||||
// Add query parameters
|
||||
if (!empty($params)) {
|
||||
$url .=
|
||||
(strpos($url, "?") === false ? "?" : "&") .
|
||||
http_build_query($params);
|
||||
}
|
||||
|
||||
$curl = curl_init();
|
||||
$options = [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_ENCODING => '',
|
||||
CURLOPT_ENCODING => "",
|
||||
CURLOPT_MAXREDIRS => 10,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_SSL_VERIFYPEER => false, // Temporarily disable SSL verification for testing
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: Bearer " . $this->apiToken,
|
||||
"Accept: application/json",
|
||||
"Content-Type: application/json",
|
||||
],
|
||||
];
|
||||
|
||||
if ($data !== null) {
|
||||
@@ -69,39 +94,59 @@ class EcomZoneClient
|
||||
}
|
||||
|
||||
curl_setopt_array($curl, $options);
|
||||
|
||||
$response = curl_exec($curl);
|
||||
$err = curl_error($curl);
|
||||
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
|
||||
EcomZoneLogger::log("API Response", 'INFO', [
|
||||
'url' => $url,
|
||||
'method' => $method,
|
||||
'http_code' => $httpCode,
|
||||
'curl_error' => $err,
|
||||
'response' => $response
|
||||
]);
|
||||
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
|
||||
if ($err) {
|
||||
if (strpos($err, 'Connection timed out') !== false && $retryCount < $maxRetries) {
|
||||
EcomZoneLogger::log("Connection timed out. Retrying in $backoff seconds...", 'WARNING');
|
||||
sleep($backoff);
|
||||
$backoff *= 2; // exponential backoff
|
||||
$retryCount++;
|
||||
} else {
|
||||
throw new Exception('cURL Error: ' . $err);
|
||||
if ($error) {
|
||||
throw new Exception("cURL Error: " . $error);
|
||||
}
|
||||
} else if ($httpCode >= 400) {
|
||||
throw new Exception('API Error: HTTP ' . $httpCode . ' - ' . $response);
|
||||
} else {
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
throw new Exception(
|
||||
"HTTP Error: " . $httpCode . " Response: " . $response
|
||||
);
|
||||
}
|
||||
|
||||
$decodedResponse = json_decode($response, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new Exception('Invalid JSON response: ' . json_last_error_msg());
|
||||
throw new Exception("Invalid JSON response");
|
||||
}
|
||||
|
||||
EcomZoneLogger::log("API request successful", "INFO", [
|
||||
"http_code" => $httpCode,
|
||||
]);
|
||||
|
||||
return $decodedResponse;
|
||||
} catch (Exception $e) {
|
||||
$lastError = $e;
|
||||
$retryCount++;
|
||||
|
||||
EcomZoneLogger::log("API request failed", "ERROR", [
|
||||
"error" => $e->getMessage(),
|
||||
"retry" => $retryCount,
|
||||
]);
|
||||
|
||||
if ($retryCount < $this->maxRetries) {
|
||||
sleep(pow(2, $retryCount));
|
||||
continue;
|
||||
}
|
||||
} while ($retryCount < $maxRetries);
|
||||
|
||||
throw new EcomZoneException(
|
||||
"API request failed after {$this->maxRetries} retries: " .
|
||||
$e->getMessage(),
|
||||
EcomZoneException::API_ERROR,
|
||||
$e
|
||||
);
|
||||
}
|
||||
} while ($retryCount < $this->maxRetries);
|
||||
|
||||
throw new EcomZoneException(
|
||||
"Max retries reached: " . $lastError->getMessage(),
|
||||
EcomZoneException::API_ERROR,
|
||||
$lastError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
class EcomZoneCronTask extends ModuleGrid
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->name = 'ecomzone_cron';
|
||||
$this->title = $this->l('EcomZone Product Sync');
|
||||
$this->cron_frequency = 3600 * 2; // Run every hour, change this to 2 hours!!!
|
||||
}
|
||||
|
||||
public function install()
|
||||
{
|
||||
if (!parent::install()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Configuration::updateValue('ECOMZONE_LAST_CRON_RUN', '');
|
||||
return true;
|
||||
}
|
||||
|
||||
public function run()
|
||||
{
|
||||
try {
|
||||
EcomZoneLogger::log('Starting scheduled product sync');
|
||||
|
||||
$productSync = new EcomZoneProductSync();
|
||||
$result = $produgtSync->importProducts();
|
||||
|
||||
Configuration::updateValue('ECOMZONE_LAST_CRON_RUN', date('Y-m-d H:i:s'));
|
||||
EcomZoneLogger::log('Scheduled product sync completed', 'INFO', $result);
|
||||
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
EcomZoneLogger::log('Scheduled product sync failed', 'ERROR', ['error' => $e->getMessage()]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
if (!defined("_PS_VERSION_")) {
|
||||
exit();
|
||||
}
|
||||
|
||||
class EcomZoneException extends Exception
|
||||
{
|
||||
public const INVALID_PRODUCT_DATA = 1001;
|
||||
public const CATEGORY_CREATE_ERROR = 1002;
|
||||
public const IMAGE_PROCESSING_ERROR = 1003;
|
||||
public const API_ERROR = 1004;
|
||||
public const VALIDATION_ERROR = 1005;
|
||||
}
|
||||
@@ -1,25 +1,70 @@
|
||||
<?php
|
||||
if (!defined("_PS_VERSION_")) {
|
||||
exit();
|
||||
}
|
||||
|
||||
class EcomZoneLogger
|
||||
{
|
||||
const LOG_FILE = _PS_ROOT_DIR_ . '/var/logs/ecomzone.log';
|
||||
private static $logFile = 'ecomzone.log';
|
||||
private static $maxLogSize = 10485760; // 10MB
|
||||
|
||||
public static function log($message, $level = 'INFO', $context = [])
|
||||
public static function log($message, $level = "INFO", $context = [])
|
||||
{
|
||||
$date = date('Y-m-d H:i:s');
|
||||
try {
|
||||
$logDir = _PS_MODULE_DIR_ . 'ecomzone/log/';
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$contextStr = !empty($context) ? json_encode($context) : '';
|
||||
$logMessage = "[$date] [$level] $message $contextStr\n";
|
||||
|
||||
file_put_contents(self::LOG_FILE, $logMessage, FILE_APPEND);
|
||||
$logMessage = sprintf(
|
||||
"[%s] %s: %s %s\n",
|
||||
$timestamp,
|
||||
$level,
|
||||
$message,
|
||||
$contextStr
|
||||
);
|
||||
|
||||
// Also log to PrestaShop
|
||||
file_put_contents(
|
||||
$logDir . self::$logFile,
|
||||
$logMessage,
|
||||
FILE_APPEND
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
// Fallback to PrestaShop's logger
|
||||
PrestaShopLogger::addLog(
|
||||
"EcomZone: $message",
|
||||
($level === 'ERROR' ? 3 : 1),
|
||||
null,
|
||||
'EcomZone',
|
||||
null,
|
||||
true
|
||||
($level === 'ERROR' ? 3 : 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static function initLogFile()
|
||||
{
|
||||
if (!file_exists(self::$logFile)) {
|
||||
$logDir = dirname(self::$logFile);
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
touch(self::$logFile);
|
||||
chmod(self::$logFile, 0666);
|
||||
}
|
||||
}
|
||||
|
||||
private static function rotateLogIfNeeded()
|
||||
{
|
||||
if (!file_exists(self::$logFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filesize(self::$logFile) > self::$maxLogSize) {
|
||||
$backup = self::$logFile . "." . date("Y-m-d-H-i-s") . ".bak";
|
||||
rename(self::$logFile, $backup);
|
||||
touch(self::$logFile);
|
||||
chmod(self::$logFile, 0666);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
class EcomZoneOrderSync
|
||||
{
|
||||
private $client;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->client = new EcomZoneClient();
|
||||
}
|
||||
|
||||
public function syncOrder($orderId)
|
||||
{
|
||||
try {
|
||||
EcomZoneLogger::log("Starting order sync", 'INFO', ['order_id' => $orderId]);
|
||||
|
||||
$order = new Order($orderId);
|
||||
$customer = new Customer($order->id_customer);
|
||||
$address = new Address($order->id_address_delivery);
|
||||
|
||||
$orderData = $this->prepareOrderData($order, $customer, $address);
|
||||
$result = $this->client->createOrder($orderData);
|
||||
|
||||
EcomZoneLogger::log("Order sync completed", 'INFO', [
|
||||
'order_id' => $orderId,
|
||||
'result' => $result
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (Exception $e) {
|
||||
EcomZoneLogger::log("Order sync failed", 'ERROR', [
|
||||
'order_id' => $orderId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function prepareOrderData($order, $customer, $address)
|
||||
{
|
||||
return [
|
||||
'order_index' => $order->id,
|
||||
'ext_id' => $order->reference,
|
||||
'payment' => [
|
||||
'method' => $this->getPaymentMethod($order),
|
||||
'customer_price' => $order->total_paid
|
||||
],
|
||||
'customer_data' => [
|
||||
'full_name' => $customer->firstname . ' ' . $customer->lastname,
|
||||
'email' => $customer->email,
|
||||
'phone_number' => $address->phone,
|
||||
'country' => Country::getIsoById($address->id_country),
|
||||
'address' => $address->address1 . ' ' . $address->address2,
|
||||
'city' => $address->city,
|
||||
'post_code' => $address->postcode
|
||||
],
|
||||
'items' => $this->getOrderItems($order)
|
||||
];
|
||||
}
|
||||
|
||||
private function getPaymentMethod($order)
|
||||
{
|
||||
// Map PrestaShop payment modules to eComZone payment methods
|
||||
return $order->module === 'cashondelivery' ? 'cod' : 'pp';
|
||||
}
|
||||
|
||||
private function getOrderItems($order)
|
||||
{
|
||||
$items = [];
|
||||
$products = $order->getProducts();
|
||||
|
||||
foreach ($products as $product) {
|
||||
$items[] = [
|
||||
'full_sku' => $product['reference'],
|
||||
'quantity' => $product['product_quantity']
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
<?php
|
||||
if (!defined("_PS_VERSION_")) {
|
||||
exit();
|
||||
}
|
||||
|
||||
class EcomZoneProductImport
|
||||
{
|
||||
private $module;
|
||||
private $defaultLangId;
|
||||
private $defaultShopId;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->module = Module::getInstanceByName("ecomzone");
|
||||
$this->defaultLangId = (int) Configuration::get("PS_LANG_DEFAULT");
|
||||
$this->defaultShopId = (int) Context::getContext()->shop->id;
|
||||
}
|
||||
|
||||
public function importProduct($productData)
|
||||
{
|
||||
try {
|
||||
$data = $productData["data"];
|
||||
|
||||
// Start transaction
|
||||
Db::getInstance()->execute("START TRANSACTION");
|
||||
|
||||
// Get or create product
|
||||
$product = $this->getOrCreateProduct($data["sku"]);
|
||||
|
||||
// Update basic information
|
||||
$this->updateBasicInfo($product, $data);
|
||||
|
||||
// Update categories
|
||||
$this->updateCategories($product, $data);
|
||||
|
||||
// Update images
|
||||
$this->updateImages($product, $data);
|
||||
|
||||
// Update specifications
|
||||
$this->updateSpecifications($product, $data);
|
||||
|
||||
// Update stock
|
||||
$this->updateStock($product, $data);
|
||||
|
||||
// Commit transaction
|
||||
Db::getInstance()->execute("COMMIT");
|
||||
|
||||
EcomZoneLogger::log("Product imported successfully", "INFO", [
|
||||
"sku" => $data["sku"],
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
Db::getInstance()->execute("ROLLBACK");
|
||||
EcomZoneLogger::log("Product import failed", "ERROR", [
|
||||
"sku" => $data["sku"] ?? "unknown",
|
||||
"error" => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function getOrCreateProduct($sku)
|
||||
{
|
||||
$productId = Product::getIdByReference($sku);
|
||||
|
||||
if ($productId) {
|
||||
$product = new Product($productId);
|
||||
} else {
|
||||
$product = new Product();
|
||||
$product->reference = $sku;
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
private function updateBasicInfo($product, $data)
|
||||
{
|
||||
$product->name[$this->defaultLangId] = $data["product_name"];
|
||||
$product->description[$this->defaultLangId] = $data["long_description"];
|
||||
$product->description_short[$this->defaultLangId] =
|
||||
$data["description"];
|
||||
$product->price = (float) $data["product_price"];
|
||||
$product->wholesale_price =
|
||||
(float) ($data["recommended_retail_price"] ?? 0);
|
||||
$product->ean13 = $data["ean"] ?? "";
|
||||
|
||||
// Product settings
|
||||
$product->active = true;
|
||||
$product->visibility = "both";
|
||||
$product->available_for_order = true;
|
||||
$product->show_price = true;
|
||||
$product->indexed = 1;
|
||||
|
||||
// Dimensions
|
||||
if (!empty($data["dimensions"])) {
|
||||
$dimensions = json_decode($data["dimensions"], true);
|
||||
$product->weight = (float) ($dimensions["weight"] ?? 0);
|
||||
$product->height = (float) ($dimensions["height"] ?? 0);
|
||||
$product->width = (float) ($dimensions["width"] ?? 0);
|
||||
$product->depth = (float) ($dimensions["length"] ?? 0);
|
||||
}
|
||||
|
||||
if (!$product->id) {
|
||||
$product->add();
|
||||
} else {
|
||||
$product->update();
|
||||
}
|
||||
}
|
||||
|
||||
private function updateCategories($product, $data)
|
||||
{
|
||||
$categoryIds = [];
|
||||
|
||||
// Process main categories
|
||||
if (!empty($data["categories"])) {
|
||||
$categories = json_decode($data["categories"], true);
|
||||
foreach ($categories as $categoryName) {
|
||||
if (strpos($categoryName, "0") === 0) {
|
||||
continue;
|
||||
} // Skip special categories
|
||||
$categoryId = $this->getOrCreateCategory($categoryName);
|
||||
if ($categoryId) {
|
||||
$categoryIds[] = $categoryId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process Google categories
|
||||
if (!empty($data["google_categories"])) {
|
||||
$googleCategories = json_decode($data["google_categories"], true);
|
||||
foreach ($googleCategories as $categoryName) {
|
||||
$categoryId = $this->getOrCreateCategory($categoryName);
|
||||
if ($categoryId) {
|
||||
$categoryIds[] = $categoryId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we have at least default category
|
||||
if (empty($categoryIds)) {
|
||||
$categoryIds[] = (int) Configuration::get("PS_HOME_CATEGORY");
|
||||
}
|
||||
|
||||
$categoryIds = array_unique($categoryIds);
|
||||
$product->id_category_default = reset($categoryIds);
|
||||
$product->updateCategories($categoryIds);
|
||||
}
|
||||
|
||||
private function getOrCreateCategory($name)
|
||||
{
|
||||
$name = trim($name);
|
||||
if (empty($name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to find existing category
|
||||
$categoryId = (int) Db::getInstance()->getValue(
|
||||
'SELECT c.id_category
|
||||
FROM `' .
|
||||
_DB_PREFIX_ .
|
||||
'category_lang` cl
|
||||
JOIN `' .
|
||||
_DB_PREFIX_ .
|
||||
'category` c ON c.id_category = cl.id_category
|
||||
WHERE cl.name = "' .
|
||||
pSQL($name) .
|
||||
'"
|
||||
AND cl.id_lang = ' .
|
||||
(int) $this->defaultLangId
|
||||
);
|
||||
|
||||
if ($categoryId) {
|
||||
return $categoryId;
|
||||
}
|
||||
|
||||
// Create new category
|
||||
$category = new Category();
|
||||
$category->name = [$this->defaultLangId => $name];
|
||||
$category->link_rewrite = [
|
||||
$this->defaultLangId => Tools::str2url($name),
|
||||
];
|
||||
$category->id_parent = (int) Configuration::get("PS_HOME_CATEGORY");
|
||||
$category->active = true;
|
||||
|
||||
if ($category->add()) {
|
||||
return (int) $category->id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function updateImages($product, $data)
|
||||
{
|
||||
$imageUrls = [];
|
||||
|
||||
// Main image
|
||||
if (!empty($data["image"])) {
|
||||
$imageUrls[] = $data["image"];
|
||||
}
|
||||
|
||||
// Additional images
|
||||
if (!empty($data["images"])) {
|
||||
$additionalImages = json_decode($data["images"], true);
|
||||
if (is_array($additionalImages)) {
|
||||
$imageUrls = array_merge($imageUrls, $additionalImages);
|
||||
}
|
||||
}
|
||||
|
||||
// GIF or video thumbnail
|
||||
if (!empty($data["gif_or_video"])) {
|
||||
$imageUrls[] = $data["gif_or_video"];
|
||||
}
|
||||
|
||||
if (empty($imageUrls)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete existing images
|
||||
$product->deleteImages();
|
||||
|
||||
// Import new images
|
||||
foreach ($imageUrls as $index => $imageUrl) {
|
||||
$this->importImage($product, $imageUrl, $index === 0);
|
||||
}
|
||||
}
|
||||
|
||||
private function importImage($product, $imageUrl, $cover = false)
|
||||
{
|
||||
$tmpFile = tempnam(_PS_TMP_IMG_DIR_, "ecomzone_");
|
||||
|
||||
try {
|
||||
if (!copy($imageUrl, $tmpFile)) {
|
||||
throw new Exception("Failed to download image");
|
||||
}
|
||||
|
||||
$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");
|
||||
}
|
||||
|
||||
$imagesTypes = ImageType::getImagesTypes("products");
|
||||
foreach ($imagesTypes as $type) {
|
||||
ImageManager::resize(
|
||||
$tmpFile,
|
||||
_PS_PROD_IMG_DIR_ .
|
||||
$image->getExistingImgPath() .
|
||||
"." .
|
||||
$type["name"] .
|
||||
".jpg",
|
||||
$type["width"],
|
||||
$type["height"]
|
||||
);
|
||||
}
|
||||
|
||||
// Save original image
|
||||
ImageManager::resize(
|
||||
$tmpFile,
|
||||
_PS_PROD_IMG_DIR_ . $image->getExistingImgPath() . ".jpg"
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
EcomZoneLogger::log("Image import failed", "ERROR", [
|
||||
"url" => $imageUrl,
|
||||
"error" => $e->getMessage(),
|
||||
]);
|
||||
} finally {
|
||||
if (file_exists($tmpFile)) {
|
||||
unlink($tmpFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function updateSpecifications($product, $data)
|
||||
{
|
||||
if (empty($data["specifications"])) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$specifications = json_decode($data["specifications"], true);
|
||||
if (!is_array($specifications)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($specifications as $name => $value) {
|
||||
if (empty($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get or create feature
|
||||
$featureId = Feature::getIdByName($name);
|
||||
if (!$featureId) {
|
||||
$feature = new Feature();
|
||||
$feature->name[$this->defaultLangId] = $name;
|
||||
if (!$feature->add()) {
|
||||
continue;
|
||||
}
|
||||
$featureId = $feature->id;
|
||||
}
|
||||
|
||||
// Get or create feature value
|
||||
$featureValue = new FeatureValue();
|
||||
$featureValue->id_feature = $featureId;
|
||||
$featureValue->value[$this->defaultLangId] = $value;
|
||||
if (!$featureValue->add()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Associate with product
|
||||
$product->addFeaturesToDB([$featureId => $featureValue->id]);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
EcomZoneLogger::log("Failed to update specifications", "ERROR", [
|
||||
"sku" => $product->reference,
|
||||
"error" => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function updateStock($product, $data)
|
||||
{
|
||||
try {
|
||||
$quantity = (int) ($data["stock_quantity"] ?? $data["stock"] ?? 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) {
|
||||
throw new EcomZoneException(
|
||||
"Failed to update stock: " . $e->getMessage(),
|
||||
EcomZoneException::INVALID_PRODUCT_DATA,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,558 @@
|
||||
<?php
|
||||
if (!defined("_PS_VERSION_")) {
|
||||
exit();
|
||||
}
|
||||
|
||||
class EcomZoneProductSync
|
||||
class EcomZoneProductSync implements IProductSync
|
||||
{
|
||||
private $client;
|
||||
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");
|
||||
}
|
||||
|
||||
private function resizeImage($src, $dest, $width, $height)
|
||||
public function importProducts(int $perPage = 100): array
|
||||
{
|
||||
list($srcWidth, $srcHeight, $type) = getimagesize($src);
|
||||
$srcImage = $this->createImageFromType($src, $type);
|
||||
$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);
|
||||
imagecopyresampled($destImage, $srcImage, 0, 0, 0, 0, $width, $height, $srcWidth, $srcHeight);
|
||||
|
||||
$this->saveImageFromType($destImage, $dest, $type);
|
||||
// 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($filename, $type)
|
||||
private function createImageFromType(string $filename, int $type)
|
||||
{
|
||||
switch ($type) {
|
||||
case IMAGETYPE_JPEG:
|
||||
@@ -37,235 +566,158 @@ class EcomZoneProductSync
|
||||
}
|
||||
}
|
||||
|
||||
private function saveImageFromType($image, $filename, $type)
|
||||
private function createDirectoryIfNotExists(string $directory): void
|
||||
{
|
||||
switch ($type) {
|
||||
case IMAGETYPE_JPEG:
|
||||
$this->createDirectoryIfNotExists(dirname($filename));
|
||||
imagejpeg($image, $filename);
|
||||
break;
|
||||
case IMAGETYPE_PNG:
|
||||
$this->createDirectoryIfNotExists(dirname($filename));
|
||||
imagepng($image, $filename);
|
||||
break;
|
||||
case IMAGETYPE_GIF:
|
||||
$this->createDirectoryIfNotExists(dirname($filename));
|
||||
imagegif($image, $filename);
|
||||
break;
|
||||
case IMAGETYPE_GIF:
|
||||
imagegif($image, $filename);
|
||||
break;
|
||||
default:
|
||||
throw new Exception("Unsupported image type: " . $type);
|
||||
if (!is_dir($directory)) {
|
||||
if (!mkdir($directory, 0755, true)) {
|
||||
throw new Exception(
|
||||
"Failed to create directory: " . $directory
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function importProducts($perPage = 100)
|
||||
private function updateProductFeatures(Product $product, string $specificationsJson): void
|
||||
{
|
||||
$page = 1;
|
||||
$totalImported = 0;
|
||||
$totalAvailable = 0;
|
||||
|
||||
EcomZoneLogger::log("Starting product import - perPage: $perPage");
|
||||
|
||||
EcomZoneLogger::log("Number of products to be imported per page: $perPage", 'INFO');
|
||||
|
||||
try {
|
||||
do {
|
||||
$catalog = $this->client->getCatalog($page, $perPage);
|
||||
$importedCount = 0;
|
||||
foreach ($catalog['data'] as $product) {
|
||||
if ($this->importSingleProduct($product)) {
|
||||
$importedCount++;
|
||||
$specifications = json_decode($specificationsJson, true);
|
||||
if (!is_array($specifications)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$totalImported += $importedCount;
|
||||
$totalAvailable = $catalog['total'];
|
||||
$page++;
|
||||
foreach ($specifications as $featureName => $featureValue) {
|
||||
if (empty($featureValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
EcomZoneLogger::log("Imported page $page", 'INFO', [
|
||||
'total_imported' => $totalImported,
|
||||
'page' => $page,
|
||||
'total_available' => $totalAvailable
|
||||
]);
|
||||
// Convert array or object to string if necessary
|
||||
if (is_array($featureValue) || is_object($featureValue)) {
|
||||
$featureValue = json_encode($featureValue);
|
||||
}
|
||||
|
||||
} while ($page <= ceil($totalAvailable / $perPage) && isset($catalog['next_page_url']) && $catalog['next_page_url'] !== null);
|
||||
// Ensure we have a string value
|
||||
$featureValue = (string)$featureValue;
|
||||
|
||||
EcomZoneLogger::log("Finished importing products", 'INFO', [
|
||||
'total_imported' => $totalImported,
|
||||
'total_available' => $totalAvailable
|
||||
// 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
|
||||
]);
|
||||
|
||||
// Clear cache
|
||||
} 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();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'imported' => $totalImported,
|
||||
'total' => $totalAvailable
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
EcomZoneLogger::log("Error importing products: " . $e->getMessage(), 'ERROR');
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function importSingleProduct($productData)
|
||||
{
|
||||
// Extract data from nested structure
|
||||
$data = isset($productData['data']) ? $productData['data'] : $productData;
|
||||
|
||||
if (!isset($data['sku']) || !isset($data['product_name']) || !isset($data['description']) || !isset($data['product_price'])) {
|
||||
EcomZoneLogger::log("Invalid product data", 'ERROR', ['data' => $productData]);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if product already exists by reference
|
||||
$productId = Db::getInstance()->getValue('
|
||||
SELECT id_product
|
||||
FROM ' . _DB_PREFIX_ . 'product
|
||||
WHERE reference = "' . pSQL($data['sku']) . '"
|
||||
');
|
||||
|
||||
$product = $productId ? new Product($productId) : new Product();
|
||||
|
||||
$defaultLangId = (int)Configuration::get('PS_LANG_DEFAULT');
|
||||
|
||||
$product->reference = $data['sku'];
|
||||
$product->name[$defaultLangId] = $data['product_name'];
|
||||
$product->description[$defaultLangId] = $data['long_description'] ?? $data['description'];
|
||||
$product->description_short[$defaultLangId] = $data['description'];
|
||||
$product->price = $data['product_price'];
|
||||
$product->active = true;
|
||||
$product->quantity = (int)$data['stock'];
|
||||
$homeCategoryId = (int)Configuration::get('PS_HOME_CATEGORY');
|
||||
$product->id_category_default = $homeCategoryId;
|
||||
$product->addToCategories([$homeCategoryId]);
|
||||
$product->visibility = 'both'; // Ensure product is visible in both catalog and search
|
||||
$product->active = true;
|
||||
$product->quantity = (int)$data['stock'];
|
||||
$homeCategoryId = (int)Configuration::get('PS_HOME_CATEGORY');
|
||||
$product->id_category_default = $homeCategoryId;
|
||||
$product->addToCategories([$homeCategoryId]);
|
||||
|
||||
|
||||
// Save product first to get ID
|
||||
if (!$product->id) {
|
||||
$product->add();
|
||||
} else {
|
||||
$product->update();
|
||||
}
|
||||
StockAvailable::setQuantity($product->id, 0, (int)$data['stock']);
|
||||
$product->available_for_order = true; // Ensure product is available for order
|
||||
$product->show_price = true; // Ensure price is shown
|
||||
|
||||
// Handle image import if URL is provided
|
||||
if (isset($data['image']) && !empty($data['image'])) {
|
||||
$this->importProductImage($product, $data['image']);
|
||||
}
|
||||
|
||||
StockAvailable::setQuantity($product->id, 0, (int)$data['stock']);
|
||||
|
||||
EcomZoneLogger::log("Imported product", 'INFO', [
|
||||
'sku' => $data['sku'],
|
||||
'id' => $product->id,
|
||||
'name' => $data['product_name']
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
EcomZoneLogger::log("Error importing product", 'ERROR', [
|
||||
'sku' => $data['sku'],
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function importProductImage($product, $imageUrl)
|
||||
{
|
||||
try {
|
||||
// Create temporary file
|
||||
$tmpFile = tempnam(_PS_TMP_IMG_DIR_, 'ecomzone_');
|
||||
|
||||
// Download image
|
||||
if (!copy($imageUrl, $tmpFile)) {
|
||||
throw new Exception("Failed to download image from: " . $imageUrl);
|
||||
}
|
||||
|
||||
// Get image info
|
||||
$imageInfo = getimagesize($tmpFile);
|
||||
if (!$imageInfo) {
|
||||
unlink($tmpFile);
|
||||
throw new Exception("Invalid image file");
|
||||
}
|
||||
|
||||
// Validate image dimensions and file size
|
||||
if ($imageInfo[0] > 2000 || $imageInfo[1] > 2000 || filesize($tmpFile) > 5000000) {
|
||||
unlink($tmpFile);
|
||||
throw new Exception("Image dimensions or file size exceed limits");
|
||||
}
|
||||
|
||||
// Generate unique name
|
||||
$imageName = $product->reference . '-' . time() . '.' . pathinfo($imageUrl, PATHINFO_EXTENSION);
|
||||
|
||||
// Delete existing images if any
|
||||
$product->deleteImages();
|
||||
|
||||
// Add new image
|
||||
$image = new Image();
|
||||
$image->id_product = $product->id;
|
||||
$image->position = 1;
|
||||
$image->cover = true;
|
||||
|
||||
// Save the image to the correct directory
|
||||
$imagePath = _PS_PROD_IMG_DIR_ . $image->getImgPath() . '.' . $image->image_format;
|
||||
$this->createDirectoryIfNotExists(dirname($imagePath));
|
||||
if (!copy($tmpFile, $imagePath)) {
|
||||
unlink($tmpFile);
|
||||
throw new Exception("Failed to save image to: " . $imagePath);
|
||||
}
|
||||
|
||||
// Associate the image with the product
|
||||
if (!$image->add()) {
|
||||
unlink($tmpFile);
|
||||
throw new Exception("Failed to add image to product");
|
||||
}
|
||||
|
||||
// Manually resize the image and generate thumbnails
|
||||
$this->resizeImage($imagePath, _PS_PROD_IMG_DIR_ . $image->getImgPath() . '-home_default.' . $image->image_format, 250, 250);
|
||||
$this->resizeImage($imagePath, _PS_PROD_IMG_DIR_ . $image->getImgPath() . '-large_default.' . $image->image_format, 800, 800);
|
||||
|
||||
// Cleanup
|
||||
unlink($tmpFile);
|
||||
EcomZoneLogger::log("Imported product image", 'INFO', [
|
||||
'sku' => $product->reference,
|
||||
'image' => $imageUrl
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
EcomZoneLogger::log("Error importing product image", 'ERROR', [
|
||||
'sku' => $product->reference,
|
||||
'image' => $imageUrl,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function createDirectoryIfNotExists($directory)
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
$token = Configuration::get('ECOMZONE_CRON_TOKEN');
|
||||
echo $token;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
if (!defined('_PS_VERSION_')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
interface ICategory
|
||||
{
|
||||
public function processCategoriesFromString(string $categories): array;
|
||||
public function createCategory(string $name, ?int $parentId = null): int;
|
||||
public function getCategoryIdByName(string $name, ?int $parentId = null): ?int;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
if (!defined('_PS_VERSION_')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
interface IProductSync
|
||||
{
|
||||
public function importProducts(int $perPage = 100): array;
|
||||
public function importSingleProduct(array $productData): bool;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<module>
|
||||
<name>ecomzone</name>
|
||||
<displayName><![CDATA[EcomZone Dropshipping]]></displayName>
|
||||
<version><![CDATA[1.0.0]]></version>
|
||||
<description><![CDATA[Integration with EcomZone Dropshipping API]]></description>
|
||||
<author><![CDATA[EcomZone]]></author>
|
||||
<tab><![CDATA[market_place]]></tab>
|
||||
<is_configurable>1</is_configurable>
|
||||
<need_instance>0</need_instance>
|
||||
<limited_countries></limited_countries>
|
||||
</module>
|
||||
+101
-21
@@ -1,24 +1,50 @@
|
||||
<?php
|
||||
include(dirname(__FILE__) . '/../../config/config.inc.php');
|
||||
include(dirname(__FILE__) . '/../../init.php');
|
||||
ob_start(); // Start output buffering
|
||||
|
||||
// Include the module class
|
||||
require_once dirname(__FILE__) . '/ecomzone.php';
|
||||
// Include PrestaShop configuration
|
||||
require_once dirname(__FILE__) . "/../../config/config.inc.php";
|
||||
require_once dirname(__FILE__) . "/../../init.php";
|
||||
|
||||
// Ensure module is loaded
|
||||
$module = Module::getInstanceByName('ecomzone');
|
||||
if (!$module) {
|
||||
die('Module not found');
|
||||
// Autoloader for module classes
|
||||
spl_autoload_register(function ($className) {
|
||||
$classMap = [
|
||||
'EcomZoneException' => 'classes/EcomZoneException.php',
|
||||
'EcomZoneClient' => 'classes/EcomZoneClient.php',
|
||||
'EcomZoneLogger' => 'classes/EcomZoneLogger.php',
|
||||
'EcomZoneProductSync' => 'classes/EcomZoneProductSync.php',
|
||||
'EcomZoneProductImport' => 'classes/EcomZoneProductImport.php',
|
||||
'EcomZoneCategoryHandler' => 'classes/EcomZoneCategoryHandler.php',
|
||||
'IProductSync' => 'classes/interfaces/IProductSync.php',
|
||||
'ICategory' => 'classes/interfaces/ICategory.php'
|
||||
];
|
||||
|
||||
if (isset($classMap[$className])) {
|
||||
require_once dirname(__FILE__) . '/' . $classMap[$className];
|
||||
}
|
||||
});
|
||||
|
||||
// Function to send JSON response
|
||||
function sendJsonResponse($data, $statusCode = 200)
|
||||
{
|
||||
if (php_sapi_name() === 'cli') {
|
||||
echo json_encode($data, JSON_PRETTY_PRINT) . "\n";
|
||||
} else {
|
||||
if (!headers_sent()) {
|
||||
http_response_code($statusCode);
|
||||
header("Content-Type: application/json");
|
||||
}
|
||||
echo json_encode($data);
|
||||
}
|
||||
exit($statusCode === 200 ? 0 : 1);
|
||||
}
|
||||
|
||||
// Security token check
|
||||
$token = null;
|
||||
|
||||
// Get token from either GET parameter or command line argument
|
||||
$token = '';
|
||||
if (php_sapi_name() === 'cli') {
|
||||
global $argv;
|
||||
foreach ($argv as $arg) {
|
||||
if (preg_match('/^--token=(.+)$/', $arg, $matches)) {
|
||||
$token = $matches[1];
|
||||
if (strpos($arg, 'token=') === 0) {
|
||||
$token = substr($arg, 6);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -26,18 +52,72 @@ if (php_sapi_name() === 'cli') {
|
||||
$token = Tools::getValue('token');
|
||||
}
|
||||
|
||||
$configToken = Configuration::get('ECOMZONE_CRON_TOKEN');
|
||||
|
||||
echo "Config token: " . $configToken . "\n";
|
||||
echo "Provided token: " . $token . "\n";
|
||||
// Security check
|
||||
$configToken = Configuration::get("ECOMZONE_CRON_TOKEN");
|
||||
|
||||
if (empty($token) || $token !== $configToken) {
|
||||
die('Invalid token. Provided: ' . $token . ', Expected: ' . $configToken);
|
||||
sendJsonResponse([
|
||||
'success' => false,
|
||||
'error' => 'Invalid token',
|
||||
'provided_token' => $token,
|
||||
'expected_token' => $configToken
|
||||
], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $module->runCronTasks();
|
||||
echo json_encode(['success' => true, 'result' => $result]);
|
||||
// Initialize logger
|
||||
EcomZoneLogger::log("Starting cron execution", "INFO", [
|
||||
"time" => date("Y-m-d H:i:s"),
|
||||
]);
|
||||
|
||||
// Check last run time to prevent too frequent execution
|
||||
$lastRun = Configuration::get("ECOMZONE_LAST_CRON_RUN");
|
||||
$minInterval = 360;
|
||||
|
||||
if (!empty($lastRun) && strtotime($lastRun) + $minInterval > time()) {
|
||||
EcomZoneLogger::log("Skipping cron - too soon since last run", "INFO", [
|
||||
"last_run" => $lastRun,
|
||||
"next_run" => date("Y-m-d H:i:s", strtotime($lastRun) + $minInterval),
|
||||
]);
|
||||
|
||||
sendJsonResponse([
|
||||
"success" => false,
|
||||
"message" => "Too soon since last run",
|
||||
"last_run" => $lastRun,
|
||||
"next_run" => date("Y-m-d H:i:s", strtotime($lastRun) + $minInterval),
|
||||
]);
|
||||
}
|
||||
|
||||
// Initialize Product Sync
|
||||
$productSync = new EcomZoneProductSync();
|
||||
|
||||
// Start the import process
|
||||
$result = $productSync->importProducts(100); // Import 100 products per page
|
||||
|
||||
// Update last run time
|
||||
Configuration::updateValue("ECOMZONE_LAST_CRON_RUN", date("Y-m-d H:i:s"));
|
||||
|
||||
// Log completion and send response
|
||||
EcomZoneLogger::log("Cron execution completed", "INFO", [
|
||||
"imported" => $result["imported"],
|
||||
"total" => $result["total"],
|
||||
"errors" => count($result["errors"]),
|
||||
]);
|
||||
|
||||
sendJsonResponse([
|
||||
"success" => true,
|
||||
"result" => $result,
|
||||
"timestamp" => date("Y-m-d H:i:s"),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
EcomZoneLogger::log("Cron execution failed", "ERROR", [
|
||||
"error" => $e->getMessage(),
|
||||
"trace" => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
sendJsonResponse([
|
||||
"success" => false,
|
||||
"error" => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
|
||||
+272
-146
@@ -1,219 +1,345 @@
|
||||
<?php
|
||||
|
||||
if (!defined('_PS_VERSION_')) {
|
||||
exit;
|
||||
if (!defined("_PS_VERSION_")) {
|
||||
exit();
|
||||
}
|
||||
|
||||
class EcomZone extends Module
|
||||
class Ecomzone extends Module
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->name = 'ecomzone';
|
||||
$this->tab = 'market_place';
|
||||
$this->version = '1.0.0';
|
||||
$this->author = 'Your Name';
|
||||
$this->name = "ecomzone";
|
||||
$this->tab = "shipping_logistics";
|
||||
$this->version = "1.0.0";
|
||||
$this->author = "EcomZone";
|
||||
$this->need_instance = 0;
|
||||
$this->bootstrap = true;
|
||||
$this->ps_versions_compliancy = [
|
||||
"min" => "8.0.0",
|
||||
"max" => "8.99.99",
|
||||
];
|
||||
|
||||
parent::__construct();
|
||||
|
||||
// Register autoloader
|
||||
spl_autoload_register([$this, 'autoload']);
|
||||
|
||||
$this->displayName = $this->l('EcomZone Dropshipping');
|
||||
$this->description = $this->l('Integration with EcomZone Dropshipping API');
|
||||
}
|
||||
|
||||
/**
|
||||
* Autoload classes
|
||||
*/
|
||||
public function autoload($className)
|
||||
{
|
||||
// Only handle our module's classes
|
||||
if (strpos($className, 'EcomZone') !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$classPath = dirname(__FILE__) . '/classes/' . $className . '.php';
|
||||
if (file_exists($classPath)) {
|
||||
require_once $classPath;
|
||||
}
|
||||
$this->displayName = $this->trans(
|
||||
"EcomZone Dropshipping",
|
||||
[],
|
||||
"Modules.Ecomzone.Admin"
|
||||
);
|
||||
$this->description = $this->trans(
|
||||
"Integration with EcomZone Dropshipping API",
|
||||
[],
|
||||
"Modules.Ecomzone.Admin"
|
||||
);
|
||||
$this->confirmUninstall = $this->trans(
|
||||
"Are you sure you want to uninstall?",
|
||||
[],
|
||||
"Modules.Ecomzone.Admin"
|
||||
);
|
||||
}
|
||||
|
||||
public function install()
|
||||
{
|
||||
return parent::install() &&
|
||||
$this->registerHook('actionOrderStatusUpdate') &&
|
||||
Configuration::updateValue('ECOMZONE_API_TOKEN', '') &&
|
||||
Configuration::updateValue('ECOMZONE_API_URL', 'https://dropship.ecomzone.eu/api') &&
|
||||
Configuration::updateValue('ECOMZONE_LAST_SYNC', '') &&
|
||||
if (!parent::install()
|
||||
|| !$this->registerHook('actionCronJob')
|
||||
|| !$this->registerHook('actionProductUpdate')
|
||||
|| !$this->registerHook('actionProductDelete')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set default configuration values
|
||||
Configuration::updateValue('ECOMZONE_API_URL', 'https://dropship.ecomzone.eu/api');
|
||||
Configuration::updateValue('ECOMZONE_CRON_TOKEN', Tools::encrypt(uniqid()));
|
||||
}
|
||||
Configuration::updateValue('ECOMZONE_LAST_SYNC', '');
|
||||
|
||||
private function createLogFile()
|
||||
{
|
||||
if (!file_exists(dirname(EcomZoneLogger::LOG_FILE))) {
|
||||
mkdir(dirname(EcomZoneLogger::LOG_FILE), 0755, true);
|
||||
}
|
||||
|
||||
if (!file_exists(EcomZoneLogger::LOG_FILE)) {
|
||||
touch(EcomZoneLogger::LOG_FILE);
|
||||
chmod(EcomZoneLogger::LOG_FILE, 0666);
|
||||
}
|
||||
// Create required directories
|
||||
$this->createRequiredDirectories();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function createRequiredDirectories()
|
||||
{
|
||||
$dirs = [
|
||||
_PS_MODULE_DIR_ . $this->name . '/log',
|
||||
_PS_MODULE_DIR_ . $this->name . '/tmp'
|
||||
];
|
||||
|
||||
foreach ($dirs as $dir) {
|
||||
if (!file_exists($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function uninstall()
|
||||
{
|
||||
return parent::uninstall() &&
|
||||
Configuration::deleteByName('ECOMZONE_API_TOKEN') &&
|
||||
Configuration::deleteByName('ECOMZONE_API_URL') &&
|
||||
Configuration::deleteByName('ECOMZONE_CRON_TOKEN') &&
|
||||
Configuration::deleteByName('ECOMZONE_LAST_CRON_RUN');
|
||||
Configuration::deleteByName("ECOMZONE_API_URL") &&
|
||||
Configuration::deleteByName("ECOMZONE_API_TOKEN") &&
|
||||
Configuration::deleteByName("ECOMZONE_CRON_TOKEN") &&
|
||||
Configuration::deleteByName("ECOMZONE_LAST_SYNC");
|
||||
}
|
||||
|
||||
public function getContent()
|
||||
{
|
||||
$output = '';
|
||||
$output = "";
|
||||
|
||||
if (Tools::isSubmit('submitEcomZoneModule')) {
|
||||
Configuration::updateValue('ECOMZONE_API_TOKEN', Tools::getValue('ECOMZONE_API_TOKEN'));
|
||||
$output .= $this->displayConfirmation($this->l('Settings updated'));
|
||||
if (Tools::isSubmit("submitEcomZoneModule")) {
|
||||
$apiToken = Tools::getValue("ECOMZONE_API_TOKEN");
|
||||
if (Configuration::updateValue("ECOMZONE_API_TOKEN", $apiToken)) {
|
||||
$output .= $this->displayConfirmation(
|
||||
$this->trans(
|
||||
"Settings updated",
|
||||
[],
|
||||
"Modules.Ecomzone.Admin"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle manual product import
|
||||
if (Tools::isSubmit('importP<Down>oducts')) {
|
||||
// Handle product fetch request
|
||||
if (Tools::isSubmit("fetchProducts") || Tools::getValue("page")) {
|
||||
try {
|
||||
$productSync = new EcomZoneProductSync();
|
||||
$result = $productSync->importProducts();
|
||||
$output .= $this->displayConfirmation(
|
||||
sprintf($this->l('Imported %d products'), $result['imported'])
|
||||
$page = (int) Tools::getValue("page", 1);
|
||||
$perPage = 10;
|
||||
|
||||
$result = $this->makeApiRequest(
|
||||
"catalog?page={$page}&per_page={$perPage}"
|
||||
);
|
||||
|
||||
if (!empty($result["data"])) {
|
||||
$totalPages = ceil($result["total"] / $perPage);
|
||||
|
||||
$this->context->smarty->assign([
|
||||
"API_PRODUCTS" => $result["data"],
|
||||
"PAGINATION" => [
|
||||
"total_pages" => $totalPages,
|
||||
"current_page" => $page,
|
||||
"total_items" => $result["total"],
|
||||
],
|
||||
]);
|
||||
|
||||
if (Tools::isSubmit("fetchProducts")) {
|
||||
$output .= $this->displayConfirmation(
|
||||
$this->trans(
|
||||
"Products fetched successfully",
|
||||
[],
|
||||
"Modules.Ecomzone.Admin"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$output .= $this->displayError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Add debug info
|
||||
$debugInfo = $this->getDebugInfo();
|
||||
|
||||
$shopUrl = Tools::getShopDomainSsl(true);
|
||||
$shopRoot = _PS_ROOT_DIR_;
|
||||
|
||||
$this->context->smarty->assign([
|
||||
'ECOMZONE_API_TOKEN' => Configuration::get('ECOMZONE_API_TOKEN'),
|
||||
'ECOMZONE_CRON_TOKEN' => Configuration::get('ECOMZONE_CRON_TOKEN'),
|
||||
'ECOMZONE_DEBUG_INFO' => $debugInfo,
|
||||
'ECOMZONE_LOGS' => $this->getRecentLogs(),
|
||||
'shop_url' => $shopUrl,
|
||||
'shop_root' => $shopRoot
|
||||
"module_dir" => $this->_path,
|
||||
"ECOMZONE_API_TOKEN" => Configuration::get("ECOMZONE_API_TOKEN"),
|
||||
"ECOMZONE_CRON_TOKEN" => Configuration::get("ECOMZONE_CRON_TOKEN"),
|
||||
"ECOMZONE_LAST_SYNC" => Configuration::get("ECOMZONE_LAST_SYNC"),
|
||||
"current_url" =>
|
||||
$this->context->link->getAdminLink("AdminModules", false) .
|
||||
"&configure=" .
|
||||
$this->name .
|
||||
"&tab_module=" .
|
||||
$this->tab .
|
||||
"&module_name=" .
|
||||
$this->name,
|
||||
"shop_url" => $this->context->link->getBaseLink(),
|
||||
]);
|
||||
|
||||
return $output . $this->display(__FILE__, 'views/templates/admin/configure.tpl');
|
||||
return $output .
|
||||
$this->display(__FILE__, "views/templates/admin/configure.tpl");
|
||||
}
|
||||
|
||||
private function getDebugInfo()
|
||||
private function makeApiRequest($endpoint, $method = "GET", $data = null)
|
||||
{
|
||||
$lastCronRun = Configuration::get('ECOMZONE_LAST_CRON_RUN');
|
||||
$nextRun = !empty($lastCronRun) ?
|
||||
date('Y-m-d H:i:s', strtotime($lastCronRun) + 3600) :
|
||||
$this->l('Not scheduled yet');
|
||||
$apiUrl = Configuration::get("ECOMZONE_API_URL");
|
||||
$apiToken = Configuration::get("ECOMZONE_API_TOKEN");
|
||||
|
||||
return [
|
||||
'php_version' => PHP_VERSION,
|
||||
'prestashop_version' => _PS_VERSION_,
|
||||
'module_version' => $this->version,
|
||||
'curl_enabled' => function_exists('curl_version'),
|
||||
'api_url' => Configuration::get('ECOMZONE_API_URL'),
|
||||
'last_sync' => Configuration::get('ECOMZONE_LAST_SYNC'),
|
||||
'last_cron_run' => $lastCronRun ?: $this->l('Never'),
|
||||
'next_cron_run' => $nextRun,
|
||||
'log_file' => EcomZoneLogger::LOG_FILE,
|
||||
'log_file_exists' => file_exists(EcomZoneLogger::LOG_FILE),
|
||||
'log_file_writable' => is_writable(EcomZoneLogger::LOG_FILE)
|
||||
if (empty($apiToken)) {
|
||||
throw new Exception(
|
||||
$this->trans(
|
||||
"API token not configured",
|
||||
[],
|
||||
"Modules.Ecomzone.Admin"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$curl = curl_init();
|
||||
$url = rtrim($apiUrl, "/") . "/" . ltrim($endpoint, "/");
|
||||
|
||||
$options = [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_ENCODING => "",
|
||||
CURLOPT_MAXREDIRS => 10,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: Bearer " . $apiToken,
|
||||
"Accept: application/json",
|
||||
"Content-Type: application/json",
|
||||
],
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
];
|
||||
|
||||
if ($data !== null) {
|
||||
$options[CURLOPT_POSTFIELDS] = json_encode($data);
|
||||
}
|
||||
|
||||
private function getRecentLogs($lines = 50)
|
||||
{
|
||||
if (!file_exists(EcomZoneLogger::LOG_FILE)) {
|
||||
return [];
|
||||
curl_setopt_array($curl, $options);
|
||||
|
||||
$response = curl_exec($curl);
|
||||
$err = curl_error($curl);
|
||||
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
|
||||
curl_close($curl);
|
||||
|
||||
if ($err) {
|
||||
throw new Exception("cURL Error: " . $err);
|
||||
}
|
||||
|
||||
$logs = array_slice(file(EcomZoneLogger::LOG_FILE), -$lines);
|
||||
return array_map('trim', $logs);
|
||||
}
|
||||
|
||||
public function hookActionOrderStatusUpdate($params)
|
||||
{
|
||||
$order = $params['order'];
|
||||
$newOrderStatus = $params['newOrderStatus'];
|
||||
|
||||
// Sync order when it's paid
|
||||
if ($newOrderStatus->paid == 1) {
|
||||
try {
|
||||
$orderSync = new EcomZoneOrderSync();
|
||||
$result = $orderSync->syncOrder($order->id);
|
||||
|
||||
// Log the result
|
||||
PrestaShopLogger::addLog(
|
||||
'EcomZone order sync: ' . json_encode($result),
|
||||
1,
|
||||
null,
|
||||
'Order',
|
||||
$order->id,
|
||||
true
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
PrestaShopLogger::addLog(
|
||||
'EcomZone order sync error: ' . $e->getMessage(),
|
||||
3,
|
||||
null,
|
||||
'Order',
|
||||
$order->id,
|
||||
true
|
||||
if ($httpCode >= 400) {
|
||||
$errorData = json_decode($response, true);
|
||||
$errorMessage = isset($errorData['error'])
|
||||
? $errorData['error']
|
||||
: "HTTP Error: " . $httpCode;
|
||||
throw new EcomZoneException(
|
||||
$errorMessage,
|
||||
EcomZoneException::API_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
$decodedResponse = json_decode($response, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new Exception("Invalid JSON response");
|
||||
}
|
||||
|
||||
return $decodedResponse;
|
||||
}
|
||||
|
||||
public function hookActionCronJob($params)
|
||||
{
|
||||
$cronTask = new EcomZoneCronTask();
|
||||
try {
|
||||
$lastSync = Configuration::get("ECOMZONE_LAST_SYNC");
|
||||
$syncInterval = 24 * 3600; // 24 hours
|
||||
|
||||
// Check if it's time to run
|
||||
$lastRun = Configuration::get('ECOMZONE_LAST_CRON_RUN');
|
||||
if (empty($lastRun) || (strtotime($lastRun) + $cronTask->cron_frequency) <= time()) {
|
||||
return $cronTask->run();
|
||||
if (
|
||||
!empty($lastSync) &&
|
||||
strtotime($lastSync) + $syncInterval > time()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return true;
|
||||
$page = 1;
|
||||
$perPage = 100;
|
||||
$totalImported = 0;
|
||||
$errors = [];
|
||||
|
||||
do {
|
||||
$result = $this->makeApiRequest(
|
||||
"catalog?page={$page}&per_page={$perPage}"
|
||||
);
|
||||
|
||||
if (empty($result["data"])) {
|
||||
break;
|
||||
}
|
||||
|
||||
public function runCronTasks()
|
||||
foreach ($result["data"] as $product) {
|
||||
try {
|
||||
if ($this->importSingleProduct($product)) {
|
||||
$totalImported++;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$errors[] = [
|
||||
"sku" => $product["sku"] ?? "unknown",
|
||||
"error" => $e->getMessage()
|
||||
];
|
||||
}
|
||||
// Clear memory after each product
|
||||
gc_collect_cycles();
|
||||
}
|
||||
|
||||
$page++;
|
||||
} while (!empty($result["next_page_url"]));
|
||||
|
||||
Configuration::updateValue(
|
||||
"ECOMZONE_LAST_SYNC",
|
||||
date("Y-m-d H:i:s")
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
EcomZoneLogger::log("Cron job execution failed", "ERROR", [
|
||||
"error" => $e->getMessage(),
|
||||
"trace" => $e->getTraceAsString()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function importSingleProduct(array $productData): bool
|
||||
{
|
||||
try {
|
||||
EcomZoneLogger::log('Starting scheduled product sync');
|
||||
|
||||
$lastRun = Configuration::get('ECOMZONE_LAST_CRON_RUN');
|
||||
$frequency = 3600; // 1 hour in seconds
|
||||
|
||||
if (!empty($lastRun) && (strtotime($lastRun) + $frequency) > time()) {
|
||||
EcomZoneLogger::log('Skipping cron - too soon since last run');
|
||||
$importer = new EcomZoneProductImport();
|
||||
return $importer->importProduct($productData);
|
||||
} catch (Exception $e) {
|
||||
EcomZoneLogger::log("Product import failed", "ERROR", [
|
||||
"sku" => $productData['data']['sku'] ?? 'unknown',
|
||||
"error" => $e->getMessage()
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$productSync = new EcomZoneProductSync();
|
||||
$result = $productSync->importProducts();
|
||||
private function importProductImage(
|
||||
Product $product,
|
||||
string $imageUrl,
|
||||
bool $cover = false
|
||||
): void {
|
||||
// Implementation of the method
|
||||
}
|
||||
|
||||
Configuration::updateValue('ECOMZONE_LAST_CRON_RUN', date('Y-m-d H:i:s'));
|
||||
EcomZoneLogger::log('Scheduled product sync completed', 'INFO', $result);
|
||||
private function updateBasicInfo(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->reference = $data["sku"];
|
||||
$product->ean13 = $data["ean"] ?? "";
|
||||
|
||||
return $result;
|
||||
// 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;
|
||||
|
||||
// Critical: Save the product
|
||||
if (!$product->save()) {
|
||||
throw new EcomZoneException(
|
||||
"Failed to save product",
|
||||
EcomZoneException::INVALID_PRODUCT_DATA
|
||||
);
|
||||
}
|
||||
|
||||
// Add to default shop
|
||||
$product->addToShop($this->defaultShopId);
|
||||
} catch (Exception $e) {
|
||||
EcomZoneLogger::log('Scheduled product sync failed', 'ERROR', ['error' => $e->getMessage()]);
|
||||
throw $e;
|
||||
throw new EcomZoneException(
|
||||
"Failed to update product info: " . $e->getMessage(),
|
||||
EcomZoneException::INVALID_PRODUCT_DATA,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
header("Expires: Mon, 26 Jul 1997 05:00:00 GMT");
|
||||
header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT");
|
||||
header("Cache-Control: no-store, no-cache, must-revalidate");
|
||||
header("Cache-Control: post-check=0, pre-check=0", false);
|
||||
header("Pragma: no-cache");
|
||||
header("Location: ../");
|
||||
exit();
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
if (!defined("_PS_VERSION_")) {
|
||||
exit();
|
||||
}
|
||||
|
||||
interface ICategory
|
||||
{
|
||||
public function createCategory(string $name, ?int $parentId = null): int;
|
||||
public function getCategoryIdByName(string $name): ?int;
|
||||
public function processCategoriesFromString(string $categoriesJson): array;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
<?php
|
||||
if (!defined("_PS_VERSION_")) {
|
||||
exit();
|
||||
}
|
||||
|
||||
interface IProductSync
|
||||
{
|
||||
public function importProducts(int $perPage = 100): array;
|
||||
public function importSingleProduct(array $productData): bool;
|
||||
}
|
||||
|
||||
@@ -1,90 +1,215 @@
|
||||
<form method="post" action="{$current|escape:'html':'UTF-8'}&token={$token|escape:'html':'UTF-8'}">
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<i class="icon-cogs"></i> {l s='EcomZone Configuration' mod='ecomzone'}
|
||||
</div>
|
||||
<div class="form-wrapper">
|
||||
|
||||
{* views/templates/admin/configure.tpl *}
|
||||
|
||||
<div class="panel">
|
||||
<h3><i class="icon icon-cogs"></i> {l s='EcomZone Configuration' mod='ecomzone'}</h3>
|
||||
<form method="post" class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label class="control-label col-lg-3">{l s='API Token' mod='ecomzone'}</label>
|
||||
<div class="col-lg-9">
|
||||
<input type="text" name="ECOMZONE_API_TOKEN" value="{$ECOMZONE_API_TOKEN}" />
|
||||
<div class="col-lg-6">
|
||||
<input type="text" name="ECOMZONE_API_TOKEN"
|
||||
value="{$ECOMZONE_API_TOKEN|escape:'html':'UTF-8'}"
|
||||
class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-lg-3">{l s='Last Sync' mod='ecomzone'}</label>
|
||||
<div class="col-lg-6">
|
||||
<p class="form-control-static">
|
||||
{if $ECOMZONE_LAST_SYNC}
|
||||
{$ECOMZONE_LAST_SYNC|escape:'html':'UTF-8'}
|
||||
{else}
|
||||
{l s='Never' mod='ecomzone'}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
<button type="submit" name="submitEcomZoneModule" class="btn btn-default pull-right">
|
||||
<i class="process-icon-save"></i> {l s='Save' mod='ecomzone'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<i class="icon-cogs"></i> {l s='Manual Actions' mod='ecomzone'}
|
||||
</div>
|
||||
<div class="form-wrapper">
|
||||
<h3><i class="icon icon-cloud-download"></i> {l s='Product Import' mod='ecomzone'}</h3>
|
||||
<div class="panel-body">
|
||||
<form method="post" class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="col-lg-9 col-lg-offset-3">
|
||||
<button type="submit" name="importProducts" class="btn btn-default">
|
||||
<i class="process-icon-download"></i> {l s='Import Products' mod='ecomzone'}
|
||||
<button type="submit" name="fetchProducts" class="btn btn-primary">
|
||||
<i class="process-icon-download"></i> {l s='Fetch Products' mod='ecomzone'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<i class="icon-clock"></i> {l s='Cron Setup Instructions' mod='ecomzone'}
|
||||
</div>
|
||||
<div class="form-wrapper">
|
||||
<p>{l s='Add the following command to your server crontab to run every hour:' mod='ecomzone'}</p>
|
||||
<pre>0 * * * * curl -s "{$shop_url}modules/ecomzone/cron.php?token={$ECOMZONE_CRON_TOKEN}"</pre>
|
||||
<p>{l s='Or using PHP CLI:' mod='ecomzone'}</p>
|
||||
<pre>0 * * * * php {$shop_root}modules/ecomzone/cron.php --token={$ECOMZONE_CRON_TOKEN}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<i class="icon-info"></i> {l s='Debug Information' mod='ecomzone'}
|
||||
</div>
|
||||
<div class="form-wrapper">
|
||||
{if isset($API_PRODUCTS) && $API_PRODUCTS}
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
{foreach from=$ECOMZONE_DEBUG_INFO key=key item=value}
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<td><strong>{$key|escape:'html':'UTF-8'}</strong></td>
|
||||
<td>{$value|escape:'html':'UTF-8'}</td>
|
||||
<th>{l s='SKU' mod='ecomzone'}</th>
|
||||
<th>{l s='Name' mod='ecomzone'}</th>
|
||||
<th>{l s='Price' mod='ecomzone'}</th>
|
||||
<th>{l s='Stock' mod='ecomzone'}</th>
|
||||
<th class="text-right">{l s='Actions' mod='ecomzone'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreach from=$API_PRODUCTS item=product}
|
||||
<tr>
|
||||
<td>{$product.sku|escape:'html':'UTF-8'}</td>
|
||||
<td>{$product.product_name|escape:'html':'UTF-8'}</td>
|
||||
<td>{$product.product_price|escape:'html':'UTF-8'}</td>
|
||||
<td>{$product.stock|escape:'html':'UTF-8'}</td>
|
||||
<td class="text-right">
|
||||
<form method="post" style="display:inline;">
|
||||
<input type="hidden" name="import_sku"
|
||||
value="{$product.sku|escape:'html':'UTF-8'}" />
|
||||
<button type="submit" name="importSingleProduct"
|
||||
class="btn btn-xs btn-primary"
|
||||
title="{l s='Import this product' mod='ecomzone'}">
|
||||
<i class="icon-download"></i> {l s='Import' mod='ecomzone'}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{if isset($PAGINATION)}
|
||||
<div class="row">
|
||||
<div class="col-lg-12 text-center">
|
||||
<ul class="pagination">
|
||||
{if $PAGINATION.current_page > 1}
|
||||
<li>
|
||||
<a href="{$current_url|escape:'html':'UTF-8'}&page=1" title="{l s='First page' mod='ecomzone'}">
|
||||
<i class="icon-double-angle-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{$current_url|escape:'html':'UTF-8'}&page={$PAGINATION.current_page-1}"
|
||||
title="{l s='Previous page' mod='ecomzone'}">
|
||||
<i class="icon-angle-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
{assign var=p value=$PAGINATION.current_page-2}
|
||||
{if $p < 1}
|
||||
{assign var=p value=1}
|
||||
{/if}
|
||||
{assign var=stop value=$p+4}
|
||||
{if $stop > $PAGINATION.total_pages}
|
||||
{assign var=stop value=$PAGINATION.total_pages}
|
||||
{assign var=p value=$stop-4}
|
||||
{if $p < 1}
|
||||
{assign var=p value=1}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{while $p <= $stop}
|
||||
<li {if $p == $PAGINATION.current_page}class="active"{/if}>
|
||||
<a href="{$current_url|escape:'html':'UTF-8'}&page={$p}">{$p}</a>
|
||||
</li>
|
||||
{assign var=p value=$p+1}
|
||||
{/while}
|
||||
|
||||
{if $PAGINATION.current_page < $PAGINATION.total_pages}
|
||||
<li>
|
||||
<a href="{$current_url|escape:'html':'UTF-8'}&page={$PAGINATION.current_page+1}"
|
||||
title="{l s='Next page' mod='ecomzone'}">
|
||||
<i class="icon-angle-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{$current_url|escape:'html':'UTF-8'}&page={$PAGINATION.total_pages}"
|
||||
title="{l s='Last page' mod='ecomzone'}">
|
||||
<i class="icon-double-angle-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3><i class="icon icon-clock-o"></i> {l s='Automatic Sync' mod='ecomzone'}</h3>
|
||||
<div class="panel-body">
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label class="control-label col-lg-3">{l s='Cron URL' mod='ecomzone'}</label>
|
||||
<div class="col-lg-9">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" readonly="readonly"
|
||||
value="{$shop_url}modules/ecomzone/cron.php?token={$ECOMZONE_CRON_TOKEN|escape:'html':'UTF-8'}" />
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-default" type="button" onclick="selectText(this.parentElement.previousElementSibling)">
|
||||
<i class="icon-copy"></i> {l s='Copy' mod='ecomzone'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
{l s='Add this URL to your server\'s crontab to run every 24 hours.' mod='ecomzone'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if isset($ECOMZONE_LOGS) && $ECOMZONE_LOGS}
|
||||
<div class="panel">
|
||||
<h3><i class="icon icon-list"></i> {l s='Recent Logs' mod='ecomzone'}</h3>
|
||||
<div class="panel-body">
|
||||
<div class="log-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="table table-striped">
|
||||
<tbody>
|
||||
{foreach from=$ECOMZONE_LOGS item=log}
|
||||
<tr>
|
||||
<td><strong>{l s='Last Cron Run' mod='ecomzone'}</strong></td>
|
||||
<td>{$ECOMZONE_LAST_CRON_RUN|escape:'html':'UTF-8'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>{l s='Next Scheduled Run' mod='ecomzone'}</strong></td>
|
||||
<td>{$ECOMZONE_NEXT_CRON_RUN|escape:'html':'UTF-8'}</td>
|
||||
<td style="font-family: monospace;">{$log|escape:'html':'UTF-8'}</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<i class="icon-list"></i> {l s='Recent Logs' mod='ecomzone'}
|
||||
</div>
|
||||
<div class="form-wrapper">
|
||||
<div class="log-container" style="max-height: 400px; overflow-y: auto;">
|
||||
{foreach from=$ECOMZONE_LOGS item=log}
|
||||
<div class="log-line">{$log|escape:'html':'UTF-8'}</div>
|
||||
{/foreach}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
function selectText(element) {
|
||||
element.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showSuccessMessage('{l s='Copied to clipboard!' mod='ecomzone'}');
|
||||
} catch (err) {
|
||||
showErrorMessage('{l s='Failed to copy. Please copy manually.' mod='ecomzone'}');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style type="text/css">
|
||||
.log-container {
|
||||
background: #f8f8f8;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.log-container table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.input-group .form-control {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
header("Expires: Mon, 26 Jul 1997 05:00:00 GMT");
|
||||
header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT");
|
||||
header("Cache-Control: no-store, no-cache, must-revalidate");
|
||||
header("Cache-Control: post-check=0, pre-check=0", false);
|
||||
header("Pragma: no-cache");
|
||||
header("Location: ../");
|
||||
exit();
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
header("Expires: Mon, 26 Jul 1997 05:00:00 GMT");
|
||||
header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT");
|
||||
header("Cache-Control: no-store, no-cache, must-revalidate");
|
||||
header("Cache-Control: post-check=0, pre-check=0", false);
|
||||
header("Pragma: no-cache");
|
||||
header("Location: ../");
|
||||
exit();
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "",
|
||||
"data": {
|
||||
"sku": "2246-911",
|
||||
"product_name": "KitchPro",
|
||||
"product_price": "12.480",
|
||||
"currency": "EUR",
|
||||
"recommended_retail_price": "32.990",
|
||||
"image": "https:\/\/zoho-site.com\/cdn\/img\/649036",
|
||||
"images": "[\"https:\\\/\\\/ourshopcdn.com\\\/01\\\/56\\\/db\\\/40\\\/fb\\\/5d\\\/5e\\\/9f\\\/1c\\\/40\\\/8
|
||||
"gif_or_video": "https:\/\/zoho-site.com\/cdn\/img\/1290557",
|
||||
"description": "Kitchen food processor",
|
||||
"long_description": "Multi-function kitchen tool grinds, slices, chops and mashes foods to help you quickly
|
||||
"categories": "[\"Kitchen\",\"0Promo\",\"0Warranty\",\"0Cross-sell\",\"0Clearance\"]",
|
||||
"google_categories": "[\"Home & Garden\",\"Kitchen & Dining\",\"Kitchen Appliances\",\"Food Mixers & Blende
|
||||
"specifications": "{\"description\":\"KitchPro is a multi-purpose food processor. It is designed to chop, m
|
||||
"dimensions": "{\"weight\":0,\"height\":0,\"width\":0,\"length\":0}",
|
||||
"stock": "269",
|
||||
"updated_at": "2025-02-13 14:01:03",
|
||||
"products_id": [
|
||||
17842
|
||||
],
|
||||
"ean": "3831125001095",
|
||||
"has_variations": false
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user