diff --git a/autoload/Domain/Attribute/AttributeRepository.php b/autoload/Domain/Attribute/AttributeRepository.php index 96bc36e..45a2ad2 100644 --- a/autoload/Domain/Attribute/AttributeRepository.php +++ b/autoload/Domain/Attribute/AttributeRepository.php @@ -48,7 +48,7 @@ class AttributeRepository FROM pp_shop_attributes AS sa WHERE {$whereSql} "; - $stmtCount = $this->db->query($sqlCount, $params); + $stmtCount = $this->db->query($sqlCount, $whereData['params']); $countRows = $stmtCount ? $stmtCount->fetchAll() : []; $total = isset($countRows[0][0]) ? (int)$countRows[0][0] : 0; @@ -534,6 +534,216 @@ class AttributeRepository return $attributes; } + /** + * Zwraca aktywne atrybuty z wartościami i wielojęzycznymi nazwami dla REST API. + * + * @return array> + */ + public function listForApi(): array + { + // 1. Get all active attribute IDs (1 query) + $rows = $this->db->select('pp_shop_attributes', ['id', 'type', 'status'], [ + 'status' => 1, + 'ORDER' => ['o' => 'ASC'], + ]); + if (!is_array($rows) || empty($rows)) { + return []; + } + + $attrIds = []; + foreach ($rows as $row) { + $id = (int)($row['id'] ?? 0); + if ($id > 0) { + $attrIds[] = $id; + } + } + if (empty($attrIds)) { + return []; + } + + // 2. Batch load ALL attribute translations (1 query) + $allAttrTranslations = $this->db->select( + 'pp_shop_attributes_langs', + ['attribute_id', 'lang_id', 'name'], + ['attribute_id' => $attrIds] + ); + $attrNamesMap = []; + if (is_array($allAttrTranslations)) { + foreach ($allAttrTranslations as $t) { + $aId = (int)($t['attribute_id'] ?? 0); + $langId = (string)($t['lang_id'] ?? ''); + if ($aId > 0 && $langId !== '') { + $attrNamesMap[$aId][$langId] = (string)($t['name'] ?? ''); + } + } + } + + // 3. Batch load ALL values for those attribute IDs (1 query) + $allValueRows = $this->db->select( + 'pp_shop_attributes_values', + ['id', 'attribute_id', 'is_default', 'impact_on_the_price'], + [ + 'attribute_id' => $attrIds, + 'ORDER' => ['id' => 'ASC'], + ] + ); + $valuesByAttr = []; + $allValueIds = []; + if (is_array($allValueRows)) { + foreach ($allValueRows as $vRow) { + $valueId = (int)($vRow['id'] ?? 0); + $attrId = (int)($vRow['attribute_id'] ?? 0); + if ($valueId > 0 && $attrId > 0) { + $valuesByAttr[$attrId][] = $vRow; + $allValueIds[] = $valueId; + } + } + } + + // 4. Batch load ALL value translations (1 query) + $valueNamesMap = []; + if (!empty($allValueIds)) { + $allValueTranslations = $this->db->select( + 'pp_shop_attributes_values_langs', + ['value_id', 'lang_id', 'name'], + ['value_id' => $allValueIds] + ); + if (is_array($allValueTranslations)) { + foreach ($allValueTranslations as $vt) { + $vId = (int)($vt['value_id'] ?? 0); + $langId = (string)($vt['lang_id'] ?? ''); + if ($vId > 0 && $langId !== '') { + $valueNamesMap[$vId][$langId] = (string)($vt['name'] ?? ''); + } + } + } + } + + // 5. Assemble result in-memory + $result = []; + foreach ($rows as $row) { + $attributeId = (int)($row['id'] ?? 0); + if ($attributeId <= 0) { + continue; + } + + $names = isset($attrNamesMap[$attributeId]) ? $attrNamesMap[$attributeId] : []; + + $values = []; + if (isset($valuesByAttr[$attributeId])) { + foreach ($valuesByAttr[$attributeId] as $vRow) { + $valueId = (int)$vRow['id']; + $impact = $vRow['impact_on_the_price']; + $values[] = [ + 'id' => $valueId, + 'names' => isset($valueNamesMap[$valueId]) ? $valueNamesMap[$valueId] : [], + 'is_default' => (int)($vRow['is_default'] ?? 0), + 'impact_on_the_price' => ($impact !== null && $impact !== '') ? (float)$impact : null, + ]; + } + } + + $result[] = [ + 'id' => $attributeId, + 'type' => (int)($row['type'] ?? 0), + 'status' => (int)($row['status'] ?? 0), + 'names' => $names, + 'values' => $values, + ]; + } + + return $result; + } + + /** + * Find existing attribute by name/type or create a new one for API integration. + * + * @return array{id:int,created:bool}|null + */ + public function ensureAttributeForApi(string $name, int $type = 0, string $langId = 'pl'): ?array + { + $normalizedName = trim($name); + $normalizedLangId = trim($langId) !== '' ? trim($langId) : 'pl'; + $normalizedType = $this->toTypeValue($type); + if ($normalizedName === '') { + return null; + } + + $existingId = $this->findAttributeIdByNameAndType($normalizedName, $normalizedType); + if ($existingId > 0) { + return ['id' => $existingId, 'created' => false]; + } + + $this->db->insert('pp_shop_attributes', [ + 'status' => 1, + 'type' => $normalizedType, + 'o' => $this->nextOrder(), + ]); + $attributeId = (int) $this->db->id(); + if ($attributeId <= 0) { + return null; + } + + $this->db->insert('pp_shop_attributes_langs', [ + 'attribute_id' => $attributeId, + 'lang_id' => $normalizedLangId, + 'name' => $normalizedName, + ]); + + $this->clearTempAndCache(); + $this->clearFrontCache($attributeId, 'frontAttributeDetails'); + + return ['id' => $attributeId, 'created' => true]; + } + + /** + * Find existing value by name within attribute or create a new one for API integration. + * + * @return array{id:int,created:bool}|null + */ + public function ensureAttributeValueForApi(int $attributeId, string $name, string $langId = 'pl'): ?array + { + $normalizedName = trim($name); + $normalizedLangId = trim($langId) !== '' ? trim($langId) : 'pl'; + $attributeId = max(0, $attributeId); + + if ($attributeId <= 0 || $normalizedName === '') { + return null; + } + + $attributeExists = (int) $this->db->count('pp_shop_attributes', ['id' => $attributeId]) > 0; + if (!$attributeExists) { + return null; + } + + $existingId = $this->findAttributeValueIdByName($attributeId, $normalizedName); + if ($existingId > 0) { + return ['id' => $existingId, 'created' => false]; + } + + $this->db->insert('pp_shop_attributes_values', [ + 'attribute_id' => $attributeId, + 'impact_on_the_price' => null, + 'is_default' => 0, + ]); + $valueId = (int) $this->db->id(); + if ($valueId <= 0) { + return null; + } + + $this->db->insert('pp_shop_attributes_values_langs', [ + 'value_id' => $valueId, + 'lang_id' => $normalizedLangId, + 'name' => $normalizedName, + 'value' => null, + ]); + + $this->clearTempAndCache(); + $this->clearFrontCache($valueId, 'frontValueDetails'); + + return ['id' => $valueId, 'created' => true]; + } + /** * @return array{sql: string, params: array} */ @@ -851,6 +1061,52 @@ class AttributeRepository return $this->defaultLangId; } + private function findAttributeIdByNameAndType(string $name, int $type): int + { + $statement = $this->db->query( + 'SELECT sa.id + FROM pp_shop_attributes sa + INNER JOIN pp_shop_attributes_langs sal ON sal.attribute_id = sa.id + WHERE sa.type = :type + AND LOWER(TRIM(sal.name)) = LOWER(TRIM(:name)) + ORDER BY sa.id ASC + LIMIT 1', + [ + ':type' => $type, + ':name' => $name, + ] + ); + if (!$statement) { + return 0; + } + + $id = $statement->fetchColumn(); + return $id === false ? 0 : (int) $id; + } + + private function findAttributeValueIdByName(int $attributeId, string $name): int + { + $statement = $this->db->query( + 'SELECT sav.id + FROM pp_shop_attributes_values sav + INNER JOIN pp_shop_attributes_values_langs savl ON savl.value_id = sav.id + WHERE sav.attribute_id = :attribute_id + AND LOWER(TRIM(savl.name)) = LOWER(TRIM(:name)) + ORDER BY sav.id ASC + LIMIT 1', + [ + ':attribute_id' => $attributeId, + ':name' => $name, + ] + ); + if (!$statement) { + return 0; + } + + $id = $statement->fetchColumn(); + return $id === false ? 0 : (int) $id; + } + // ── Frontend methods ────────────────────────────────────────── public function frontAttributeDetails(int $attributeId, string $langId): array diff --git a/autoload/Domain/Basket/BasketCalculator.php b/autoload/Domain/Basket/BasketCalculator.php index d6d2737..169aaa4 100644 --- a/autoload/Domain/Basket/BasketCalculator.php +++ b/autoload/Domain/Basket/BasketCalculator.php @@ -94,8 +94,13 @@ class BasketCalculator if ( isset( $val['parent_id'] ) and (int)$val['parent_id'] and isset( $val['product-id'] ) ) $permutation = $productRepo->getProductPermutationHash( (int)$val['product-id'] ); - if ( !$permutation and isset( $val['attributes'] ) and is_array( $val['attributes'] ) and count( $val['attributes'] ) ) - $permutation = implode( '|', $val['attributes'] ); + if ( !$permutation and isset( $val['attributes'] ) and is_array( $val['attributes'] ) and count( $val['attributes'] ) ) { + $attrs = $val['attributes']; + usort( $attrs, function ( $a, $b ) { + return (int) explode( '-', $a )[0] - (int) explode( '-', $b )[0]; + } ); + $permutation = implode( '|', $attrs ); + } $quantity_options = $productRepo->getProductPermutationQuantityOptions( $val['parent_id'] ? $val['parent_id'] : $val['product-id'], diff --git a/autoload/Domain/CronJob/CronJobProcessor.php b/autoload/Domain/CronJob/CronJobProcessor.php new file mode 100644 index 0000000..0f3fbaa --- /dev/null +++ b/autoload/Domain/CronJob/CronJobProcessor.php @@ -0,0 +1,140 @@ + */ + private $handlers = []; + + /** + * @param CronJobRepository $cronRepo + */ + public function __construct(CronJobRepository $cronRepo) + { + $this->cronRepo = $cronRepo; + } + + /** + * Zarejestruj handler dla typu zadania + * + * @param string $jobType + * @param callable $handler fn($payload): bool|array — true/array = success, false/exception = fail + */ + public function registerHandler($jobType, callable $handler) + { + $this->handlers[$jobType] = $handler; + } + + /** + * Utwórz zadania z harmonogramów, których next_run_at <= NOW + * + * @return int Liczba utworzonych zadań + */ + public function createScheduledJobs() + { + $schedules = $this->cronRepo->getDueSchedules(); + $created = 0; + + foreach ($schedules as $schedule) { + $jobType = $schedule['job_type']; + + // Nie twórz duplikatów + if ($this->cronRepo->hasPendingJob($jobType)) { + // Mimo duplikatu, przesuń next_run_at żeby nie sprawdzać co sekundę + $this->cronRepo->touchSchedule($schedule['id'], (int) $schedule['interval_seconds']); + continue; + } + + $payload = null; + if (!empty($schedule['payload'])) { + $payload = json_decode($schedule['payload'], true); + } + + $this->cronRepo->enqueue( + $jobType, + $payload, + (int) $schedule['priority'], + (int) $schedule['max_attempts'] + ); + + $this->cronRepo->touchSchedule($schedule['id'], (int) $schedule['interval_seconds']); + $created++; + } + + return $created; + } + + /** + * Przetwórz kolejkę zadań + * + * @param int $limit + * @return array Statystyki: ['processed' => int, 'succeeded' => int, 'failed' => int, 'skipped' => int] + */ + public function processQueue($limit = 10) + { + $stats = ['processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0]; + + $jobs = $this->cronRepo->fetchNext($limit); + + foreach ($jobs as $job) { + $jobType = $job['job_type']; + $jobId = (int) $job['id']; + $stats['processed']++; + + if (!isset($this->handlers[$jobType])) { + $this->cronRepo->markFailed($jobId, 'No handler registered for job type: ' . $jobType, (int) $job['attempts']); + $stats['skipped']++; + continue; + } + + try { + $result = call_user_func($this->handlers[$jobType], $job['payload']); + + if ($result === false) { + $this->cronRepo->markFailed($jobId, 'Handler returned false', (int) $job['attempts']); + $stats['failed']++; + } else { + $resultData = is_array($result) ? $result : null; + $this->cronRepo->markCompleted($jobId, $resultData); + $stats['succeeded']++; + } + } catch (\Exception $e) { + $this->cronRepo->markFailed($jobId, $e->getMessage(), (int) $job['attempts']); + $stats['failed']++; + } catch (\Throwable $e) { + $this->cronRepo->markFailed($jobId, $e->getMessage(), (int) $job['attempts']); + $stats['failed']++; + } + } + + return $stats; + } + + /** + * Główna metoda: utwórz scheduled jobs + przetwórz kolejkę + * + * @param int $limit + * @return array ['scheduled' => int, 'processed' => int, 'succeeded' => int, 'failed' => int, 'skipped' => int] + */ + public function run($limit = 20) + { + // Odzyskaj stuck jobs + $this->cronRepo->recoverStuck(30); + + // Utwórz zadania z harmonogramów + $scheduled = $this->createScheduledJobs(); + + // Przetwórz kolejkę + $stats = $this->processQueue($limit); + $stats['scheduled'] = $scheduled; + + // Cleanup starych zadań (raz na uruchomienie) + $this->cronRepo->cleanup(30); + + return $stats; + } +} diff --git a/autoload/Domain/CronJob/CronJobRepository.php b/autoload/Domain/CronJob/CronJobRepository.php new file mode 100644 index 0000000..3a872c0 --- /dev/null +++ b/autoload/Domain/CronJob/CronJobRepository.php @@ -0,0 +1,248 @@ +db = $db; + } + + /** + * Dodaj zadanie do kolejki + * + * @param string $jobType + * @param array|null $payload + * @param int $priority + * @param int $maxAttempts + * @param string|null $scheduledAt + * @return int|null ID nowego zadania + */ + public function enqueue($jobType, $payload = null, $priority = CronJobType::PRIORITY_NORMAL, $maxAttempts = 10, $scheduledAt = null) + { + $data = [ + 'job_type' => $jobType, + 'status' => CronJobType::STATUS_PENDING, + 'priority' => $priority, + 'max_attempts' => $maxAttempts, + 'scheduled_at' => $scheduledAt ? $scheduledAt : date('Y-m-d H:i:s'), + ]; + + if ($payload !== null) { + $data['payload'] = json_encode($payload); + } + + $this->db->insert('pp_cron_jobs', $data); + $id = $this->db->id(); + + return $id ? (int) $id : null; + } + + /** + * Atomowe pobranie następnych zadań do przetworzenia. + * + * Uwaga: SELECT + UPDATE nie jest w pełni atomowe bez transakcji. + * Po UPDATE re-SELECT potwierdza, które joby zostały faktycznie przejęte + * (chroni przed race condition przy wielu workerach). + * + * @param int $limit + * @return array + */ + public function fetchNext($limit = 5) + { + $now = date('Y-m-d H:i:s'); + + $jobs = $this->db->select('pp_cron_jobs', '*', [ + 'status' => CronJobType::STATUS_PENDING, + 'scheduled_at[<=]' => $now, + 'ORDER' => ['priority' => 'ASC', 'scheduled_at' => 'ASC'], + 'LIMIT' => $limit, + ]); + + if (empty($jobs)) { + return []; + } + + $ids = array_column($jobs, 'id'); + + $this->db->update('pp_cron_jobs', [ + 'status' => CronJobType::STATUS_PROCESSING, + 'started_at' => $now, + 'attempts[+]' => 1, + ], [ + 'id' => $ids, + 'status' => CronJobType::STATUS_PENDING, + ]); + + // Re-SELECT: potwierdź, które joby zostały faktycznie przejęte + $claimed = $this->db->select('pp_cron_jobs', '*', [ + 'id' => $ids, + 'status' => CronJobType::STATUS_PROCESSING, + 'started_at' => $now, + ]); + + if (empty($claimed)) { + return []; + } + + foreach ($claimed as &$job) { + if ($job['payload'] !== null) { + $job['payload'] = json_decode($job['payload'], true); + } + } + + return $claimed; + } + + /** + * Oznacz zadanie jako zakończone + * + * @param int $jobId + * @param mixed $result + */ + public function markCompleted($jobId, $result = null) + { + $data = [ + 'status' => CronJobType::STATUS_COMPLETED, + 'completed_at' => date('Y-m-d H:i:s'), + ]; + + if ($result !== null) { + $data['result'] = json_encode($result); + } + + $this->db->update('pp_cron_jobs', $data, ['id' => $jobId]); + } + + /** + * Oznacz zadanie jako nieudane z backoffem + * + * @param int $jobId + * @param string $error + * @param int $attempt Numer próby (do obliczenia backoffu) + */ + public function markFailed($jobId, $error, $attempt = 1) + { + $job = $this->db->get('pp_cron_jobs', ['max_attempts', 'attempts'], ['id' => $jobId]); + + $attempts = $job ? (int) $job['attempts'] : $attempt; + $maxAttempts = $job ? (int) $job['max_attempts'] : 10; + + if ($attempts >= $maxAttempts) { + // Przekroczono limit prób — trwale failed + $this->db->update('pp_cron_jobs', [ + 'status' => CronJobType::STATUS_FAILED, + 'last_error' => mb_substr($error, 0, 500), + 'completed_at' => date('Y-m-d H:i:s'), + ], ['id' => $jobId]); + } else { + // Wróć do pending z backoffem + $backoff = CronJobType::calculateBackoff($attempts); + $nextRun = date('Y-m-d H:i:s', time() + $backoff); + + $this->db->update('pp_cron_jobs', [ + 'status' => CronJobType::STATUS_PENDING, + 'last_error' => mb_substr($error, 0, 500), + 'scheduled_at' => $nextRun, + ], ['id' => $jobId]); + } + } + + /** + * Sprawdź czy istnieje pending job danego typu z opcjonalnym payload match + * + * @param string $jobType + * @param array|null $payloadMatch + * @return bool + */ + public function hasPendingJob($jobType, $payloadMatch = null) + { + $where = [ + 'job_type' => $jobType, + 'status' => [CronJobType::STATUS_PENDING, CronJobType::STATUS_PROCESSING], + ]; + + if ($payloadMatch !== null) { + $where['payload'] = json_encode($payloadMatch); + } + + $count = $this->db->count('pp_cron_jobs', $where); + return $count > 0; + } + + /** + * Wyczyść stare zakończone zadania + * + * @param int $olderThanDays + */ + public function cleanup($olderThanDays = 30) + { + $cutoff = date('Y-m-d H:i:s', time() - ($olderThanDays * 86400)); + + $this->db->delete('pp_cron_jobs', [ + 'status' => [CronJobType::STATUS_COMPLETED, CronJobType::STATUS_FAILED, CronJobType::STATUS_CANCELLED], + 'updated_at[<]' => $cutoff, + ]); + } + + /** + * Odzyskaj zablokowane zadania (stuck w processing) + * + * @param int $olderThanMinutes + */ + public function recoverStuck($olderThanMinutes = 30) + { + $cutoff = date('Y-m-d H:i:s', time() - ($olderThanMinutes * 60)); + + $this->db->update('pp_cron_jobs', [ + 'status' => CronJobType::STATUS_PENDING, + 'started_at' => null, + ], [ + 'status' => CronJobType::STATUS_PROCESSING, + 'started_at[<]' => $cutoff, + ]); + } + + /** + * Pobierz harmonogramy gotowe do uruchomienia + * + * @return array + */ + public function getDueSchedules() + { + $now = date('Y-m-d H:i:s'); + + return $this->db->select('pp_cron_schedules', '*', [ + 'enabled' => 1, + 'OR' => [ + 'next_run_at' => null, + 'next_run_at[<=]' => $now, + ], + 'ORDER' => ['priority' => 'ASC'], + ]); + } + + /** + * Aktualizuj harmonogram po uruchomieniu + * + * @param int $scheduleId + * @param int $intervalSeconds + */ + public function touchSchedule($scheduleId, $intervalSeconds) + { + $now = date('Y-m-d H:i:s'); + $nextRun = date('Y-m-d H:i:s', time() + $intervalSeconds); + + $this->db->update('pp_cron_schedules', [ + 'last_run_at' => $now, + 'next_run_at' => $nextRun, + ], ['id' => $scheduleId]); + } +} diff --git a/autoload/Domain/CronJob/CronJobType.php b/autoload/Domain/CronJob/CronJobType.php new file mode 100644 index 0000000..8bee136 --- /dev/null +++ b/autoload/Domain/CronJob/CronJobType.php @@ -0,0 +1,81 @@ +insert('pp_log', [ + 'action' => $action, + 'order_id' => $orderId, + 'message' => $message, + 'context' => $contextJson, + 'date' => date('Y-m-d H:i:s'), + ]); + } +} diff --git a/autoload/Domain/Integrations/IntegrationsRepository.php b/autoload/Domain/Integrations/IntegrationsRepository.php index 8074358..8631ce3 100644 --- a/autoload/Domain/Integrations/IntegrationsRepository.php +++ b/autoload/Domain/Integrations/IntegrationsRepository.php @@ -56,6 +56,63 @@ class IntegrationsRepository return true; } + // ── Logs ──────────────────────────────────────────────────── + + /** + * Pobiera logi z tabeli pp_log z paginacją, sortowaniem i filtrowaniem. + * + * @return array{items:array, total:int} + */ + public function getLogs( array $filters, string $sortColumn, string $sortDir, int $page, int $perPage ): array + { + $where = []; + + if ( !empty( $filters['log_action'] ) ) { + $where['action[~]'] = '%' . $filters['log_action'] . '%'; + } + + if ( !empty( $filters['message'] ) ) { + $where['message[~]'] = '%' . $filters['message'] . '%'; + } + + if ( !empty( $filters['order_id'] ) ) { + $where['order_id'] = (int) $filters['order_id']; + } + + $total = $this->db->count( 'pp_log', $where ); + + $where['ORDER'] = [ $sortColumn => $sortDir ]; + $where['LIMIT'] = [ ( $page - 1 ) * $perPage, $perPage ]; + + $items = $this->db->select( 'pp_log', '*', $where ); + if ( !is_array( $items ) ) { + $items = []; + } + + return [ + 'items' => $items, + 'total' => (int) $total, + ]; + } + + /** + * Usuwa wpis logu po ID. + */ + public function deleteLog( int $id ): bool + { + $this->db->delete( 'pp_log', [ 'id' => $id ] ); + return true; + } + + /** + * Czyści wszystkie logi z tabeli pp_log. + */ + public function clearLogs(): bool + { + $this->db->delete( 'pp_log', [] ); + return true; + } + // ── Product linking (Apilo) ───────────────────────────────── public function linkProduct( int $productId, $externalId, $externalName ): bool @@ -611,15 +668,12 @@ class IntegrationsRepository public function shopproImportProduct( int $productId ): array { $settings = $this->getSettings( 'shoppro' ); + $missingSetting = $this->missingShopproSetting( $settings, [ 'domain', 'db_name', 'db_host', 'db_user' ] ); + if ( $missingSetting !== null ) { + return [ 'success' => false, 'message' => 'Brakuje konfiguracji shopPRO: ' . $missingSetting . '.' ]; + } - $mdb2 = new \medoo( [ - 'database_type' => 'mysql', - 'database_name' => $settings['db_name'], - 'server' => $settings['db_host'], - 'username' => $settings['db_user'], - 'password' => $settings['db_password'], - 'charset' => 'utf8' - ] ); + $mdb2 = $this->shopproDb( $settings ); $product = $mdb2->get( 'pp_shop_products', '*', [ 'id' => $productId ] ); if ( !$product ) @@ -643,6 +697,7 @@ class IntegrationsRepository 'additional_message_text' => $product['additional_message_text'], 'additional_message_required'=> $product['additional_message_required'], 'weight' => $product['weight'], + 'producer_id' => $product['producer_id'] ?? null, ] ); $newProductId = $this->db->id(); @@ -672,41 +727,149 @@ class IntegrationsRepository 'warehouse_message_nonzero'=> $lang['warehouse_message_nonzero'], 'canonical' => $lang['canonical'], 'xml_name' => $lang['xml_name'], + 'security_information' => $lang['security_information'] ?? null, + ] ); + } + } + + // Import custom fields + $customFields = $mdb2->select( 'pp_shop_products_custom_fields', '*', [ 'id_product' => $productId ] ); + if ( is_array( $customFields ) ) { + foreach ( $customFields as $field ) { + $this->db->insert( 'pp_shop_products_custom_fields', [ + 'id_product' => $newProductId, + 'name' => (string)($field['name'] ?? ''), + 'type' => (string)($field['type'] ?? 'text'), + 'is_required' => !empty( $field['is_required'] ) ? 1 : 0, ] ); } } // Import images $images = $mdb2->select( 'pp_shop_products_images', '*', [ 'product_id' => $productId ] ); + $importLog = []; + $domainRaw = preg_replace( '#^https?://#', '', (string)($settings['domain'] ?? '') ); if ( is_array( $images ) ) { foreach ( $images as $image ) { - $imageUrl = 'https://' . $settings['domain'] . $image['src']; + $srcPath = (string)($image['src'] ?? ''); + $imageUrl = 'https://' . rtrim( $domainRaw, '/' ) . '/' . ltrim( $srcPath, '/' ); + $imageName = basename( $srcPath ); + + if ( $imageName === '' ) { + $importLog[] = '[SKIP] Pusta nazwa pliku dla src: ' . $srcPath; + continue; + } $ch = curl_init( $imageUrl ); curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, true ); curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false ); curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, false ); - $imageData = curl_exec( $ch ); + curl_setopt( $ch, CURLOPT_TIMEOUT, 30 ); + curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 ); + $imageData = curl_exec( $ch ); + $httpCode = (int)curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $curlErrno = curl_errno( $ch ); + $curlError = curl_error( $ch ); curl_close( $ch ); - $imageName = basename( $imageUrl ); - $imageDir = '../upload/product_images/product_' . $newProductId; + if ( $curlErrno !== 0 || $imageData === false ) { + $importLog[] = '[ERROR] cURL: ' . $imageUrl . ' — błąd ' . $curlErrno . ': ' . $curlError; + continue; + } + + if ( $httpCode !== 200 ) { + $importLog[] = '[ERROR] HTTP ' . $httpCode . ': ' . $imageUrl; + continue; + } + + $imageDir = dirname( __DIR__, 3 ) . '/upload/product_images/product_' . $newProductId; $imagePath = $imageDir . '/' . $imageName; - if ( !file_exists( $imageDir ) ) - mkdir( $imageDir, 0777, true ); + if ( !file_exists( $imageDir ) && !mkdir( $imageDir, 0777, true ) && !file_exists( $imageDir ) ) { + $importLog[] = '[ERROR] Nie można utworzyć katalogu: ' . $imageDir; + continue; + } - file_put_contents( $imagePath, $imageData ); + $written = file_put_contents( $imagePath, $imageData ); + if ( $written === false ) { + $importLog[] = '[ERROR] Zapis pliku nieudany: ' . $imagePath; + continue; + } $this->db->insert( 'pp_shop_products_images', [ 'product_id' => $newProductId, 'src' => '/upload/product_images/product_' . $newProductId . '/' . $imageName, + 'alt' => $image['alt'] ?? '', 'o' => $image['o'], ] ); + $importLog[] = '[OK] ' . $imageUrl . ' → ' . $imagePath . ' (' . $written . ' B)'; } } - return [ 'success' => true, 'message' => 'Produkt został zaimportowany.' ]; + // Zapisz log importu zdjęć (ścieżka absolutna — niezależna od cwd) + $logDir = dirname( __DIR__, 3 ) . '/logs'; + $logFile = $logDir . '/shoppro-import-debug.log'; + $mkdirOk = file_exists( $logDir ) || mkdir( $logDir, 0755, true ) || file_exists( $logDir ); + $logEntry = '[' . date( 'Y-m-d H:i:s' ) . '] Import produktu #' . $productId . ' → #' . $newProductId . "\n" + . ' Domain: ' . $domainRaw . "\n" + . ' Obrazy źródłowe: ' . count( $images ?: [] ) . "\n"; + foreach ( $importLog as $line ) { + $logEntry .= ' ' . $line . "\n"; + } + // Zawsze loguj do error_log (niezależnie od uprawnień do pliku) + error_log( '[shopPRO shoppro-import] ' . str_replace( "\n", ' | ', $logEntry ) ); + + if ( $mkdirOk && file_put_contents( $logFile, $logEntry, FILE_APPEND ) === false ) { + error_log( '[shopPRO shoppro-import] WARN: nie można zapisać logu do: ' . $logFile ); + } elseif ( !$mkdirOk ) { + error_log( '[shopPRO shoppro-import] WARN: nie można utworzyć katalogu: ' . $logDir ); + } + + // Zbuduj czytelny komunikat z wynikiem importu zdjęć + $imgCount = count( $images ?: [] ); + if ( $imgCount === 0 ) { + $imgSummary = 'Zdjęcia: brak w bazie źródłowej.'; + } else { + $ok = 0; + $errors = []; + foreach ( $importLog as $line ) { + if ( strncmp( $line, '[OK]', 4 ) === 0 ) { + $ok++; + } else { + $errors[] = $line; + } + } + $imgSummary = 'Zdjęcia: ' . $ok . '/' . $imgCount . ' zaimportowanych.'; + if ( !empty( $errors ) ) { + $imgSummary .= ' Błędy: ' . implode( '; ', $errors ); + } + } + + return [ 'success' => true, 'message' => 'Produkt został zaimportowany. ' . $imgSummary ]; } + + private function missingShopproSetting( array $settings, array $requiredKeys ): ?string + { + foreach ( $requiredKeys as $requiredKey ) { + if ( trim( (string)($settings[$requiredKey] ?? '') ) === '' ) { + return $requiredKey; + } + } + + return null; + } + + private function shopproDb( array $settings ): \medoo + { + return new \medoo( [ + 'database_type' => 'mysql', + 'database_name' => $settings['db_name'], + 'server' => $settings['db_host'], + 'username' => $settings['db_user'], + 'password' => $settings['db_password'] ?? '', + 'charset' => 'utf8' + ] ); + } + } diff --git a/autoload/Domain/Order/OrderAdminService.php b/autoload/Domain/Order/OrderAdminService.php index c7b7ab2..405045f 100644 --- a/autoload/Domain/Order/OrderAdminService.php +++ b/autoload/Domain/Order/OrderAdminService.php @@ -7,17 +7,21 @@ class OrderAdminService private $productRepo; private $settingsRepo; private $transportRepo; + /** @var \Domain\CronJob\CronJobRepository|null */ + private $cronJobRepo; public function __construct( OrderRepository $orders, $productRepo = null, $settingsRepo = null, - $transportRepo = null + $transportRepo = null, + $cronJobRepo = null ) { $this->orders = $orders; $this->productRepo = $productRepo; $this->settingsRepo = $settingsRepo; $this->transportRepo = $transportRepo; + $this->cronJobRepo = $cronJobRepo; } public function details(int $orderId): array @@ -30,6 +34,14 @@ class OrderAdminService return $this->orders->orderStatuses(); } + /** + * @return array{names: array, colors: array} + */ + public function statusData(): array + { + return $this->orders->orderStatusData(); + } + /** * @return array{items: array>, total: int} */ @@ -385,17 +397,38 @@ class OrderAdminService global $mdb; if ($orderId <= 0) { + \Domain\Integrations\ApiloLogger::log( + $mdb, + 'resend_order', + $orderId, + 'Nieprawidlowe ID zamowienia (orderId <= 0)', + ['order_id' => $orderId] + ); return false; } $order = $this->orders->findForAdmin($orderId); if (empty($order) || empty($order['apilo_order_id'])) { + \Domain\Integrations\ApiloLogger::log( + $mdb, + 'resend_order', + $orderId, + 'Brak zamowienia lub brak apilo_order_id', + ['order_found' => !empty($order), 'apilo_order_id' => $order['apilo_order_id'] ?? null] + ); return false; } $integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb ); $accessToken = $integrationsRepository -> apiloGetAccessToken(); if (!$accessToken) { + \Domain\Integrations\ApiloLogger::log( + $mdb, + 'resend_order', + $orderId, + 'Nie udalo sie uzyskac tokenu Apilo (access token)', + ['apilo_order_id' => $order['apilo_order_id']] + ); return false; } @@ -417,13 +450,29 @@ class OrderAdminService curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $apiloResultRaw = curl_exec($ch); + $http_code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $apiloResult = json_decode((string)$apiloResultRaw, true); if (!is_array($apiloResult) || (int)($apiloResult['updates'] ?? 0) !== 1) { + \Domain\Integrations\ApiloLogger::log( + $mdb, + 'resend_order', + $orderId, + 'Błąd ponownego wysyłania zamówienia do Apilo (HTTP: ' . $http_code . ')', + ['apilo_order_id' => $order['apilo_order_id'], 'http_code' => $http_code, 'response' => $apiloResult] + ); curl_close($ch); return false; } + \Domain\Integrations\ApiloLogger::log( + $mdb, + 'resend_order', + $orderId, + 'Zamówienie ponownie wysłane do Apilo (apilo_order_id: ' . $order['apilo_order_id'] . ')', + ['apilo_order_id' => $order['apilo_order_id'], 'http_code' => $http_code, 'response' => $apiloResult] + ); + $query = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'pp_shop_orders' AND COLUMN_NAME != 'id'"; $stmt = $mdb->query($query); $columns = $stmt ? $stmt->fetchAll(\PDO::FETCH_COLUMN) : []; @@ -474,75 +523,6 @@ class OrderAdminService return $this->orders->deleteOrder($orderId); } - // ========================================================================= - // Apilo sync queue (migrated from \shop\Order) - // ========================================================================= - - private const APILO_SYNC_QUEUE_FILE = '/temp/apilo-sync-queue.json'; - - public function processApiloSyncQueue(int $limit = 10): int - { - $queue = self::loadApiloSyncQueue(); - if (!\Shared\Helpers\Helpers::is_array_fix($queue)) { - return 0; - } - - $processed = 0; - - foreach ($queue as $key => $task) - { - if ($processed >= $limit) { - break; - } - - $order_id = (int)($task['order_id'] ?? 0); - if ($order_id <= 0) { - unset($queue[$key]); - continue; - } - - $order = $this->orders->findRawById($order_id); - if (!$order) { - unset($queue[$key]); - continue; - } - - $error = ''; - $sync_failed = false; - - $payment_pending = !empty($task['payment']) && (int)$order['paid'] === 1; - if ($payment_pending && (int)$order['apilo_order_id']) { - if (!$this->syncApiloPayment($order)) { - $sync_failed = true; - $error = 'payment_sync_failed'; - } - } - - $status_pending = isset($task['status']) && $task['status'] !== null && $task['status'] !== ''; - if (!$sync_failed && $status_pending && (int)$order['apilo_order_id']) { - if (!$this->syncApiloStatus($order, (int)$task['status'])) { - $sync_failed = true; - $error = 'status_sync_failed'; - } - } - - if ($sync_failed) { - $task['attempts'] = (int)($task['attempts'] ?? 0) + 1; - $task['last_error'] = $error; - $task['updated_at'] = date('Y-m-d H:i:s'); - $queue[$key] = $task; - } else { - unset($queue[$key]); - } - - $processed++; - } - - self::saveApiloSyncQueue($queue); - - return $processed; - } - // ========================================================================= // Private: email // ========================================================================= @@ -600,6 +580,17 @@ class OrderAdminService $apilo_settings = $integrationsRepository->getSettings('apilo'); if (!$apilo_settings['enabled'] || !$apilo_settings['access-token'] || !$apilo_settings['sync_orders']) { + \Domain\Integrations\ApiloLogger::log( + $db, + 'payment_sync', + (int)$order['id'], + 'Pominięto sync płatności — Apilo wyłączone lub brak tokenu/sync_orders', + [ + 'enabled' => $apilo_settings['enabled'] ?? false, + 'has_token' => !empty($apilo_settings['access-token']), + 'sync_orders' => $apilo_settings['sync_orders'] ?? false, + ] + ); return; } @@ -607,8 +598,25 @@ class OrderAdminService self::appendApiloLog("SET AS PAID\n" . print_r($order, true)); } - if ($order['apilo_order_id'] && !$this->syncApiloPayment($order)) { - self::queueApiloSync((int)$order['id'], true, null, 'payment_sync_failed'); + if (!$order['apilo_order_id']) { + // Zamówienie jeszcze nie wysłane do Apilo — kolejkuj sync płatności na później + \Domain\Integrations\ApiloLogger::log( + $db, + 'payment_sync', + (int)$order['id'], + 'Brak apilo_order_id — płatność zakolejkowana do sync', + ['apilo_order_id' => $order['apilo_order_id'] ?? null] + ); + $this->queueApiloSync((int)$order['id'], true, null, 'awaiting_apilo_order'); + } elseif (!$this->syncApiloPayment($order)) { + \Domain\Integrations\ApiloLogger::log( + $db, + 'payment_sync', + (int)$order['id'], + 'Sync płatności nieudany — zakolejkowano ponowną próbę', + ['apilo_order_id' => $order['apilo_order_id']] + ); + $this->queueApiloSync((int)$order['id'], true, null, 'payment_sync_failed'); } } @@ -621,6 +629,18 @@ class OrderAdminService $apilo_settings = $integrationsRepository->getSettings('apilo'); if (!$apilo_settings['enabled'] || !$apilo_settings['access-token'] || !$apilo_settings['sync_orders']) { + \Domain\Integrations\ApiloLogger::log( + $db, + 'status_sync', + (int)$order['id'], + 'Pominięto sync statusu — Apilo wyłączone lub brak tokenu/sync_orders', + [ + 'target_status' => $status, + 'enabled' => $apilo_settings['enabled'] ?? false, + 'has_token' => !empty($apilo_settings['access-token']), + 'sync_orders' => $apilo_settings['sync_orders'] ?? false, + ] + ); return; } @@ -628,19 +648,36 @@ class OrderAdminService self::appendApiloLog("UPDATE STATUS\n" . print_r($order, true)); } - if ($order['apilo_order_id'] && !$this->syncApiloStatus($order, $status)) { - self::queueApiloSync((int)$order['id'], false, $status, 'status_sync_failed'); + if (!$order['apilo_order_id']) { + // Zamówienie jeszcze nie wysłane do Apilo — kolejkuj sync statusu na później + \Domain\Integrations\ApiloLogger::log( + $db, + 'status_sync', + (int)$order['id'], + 'Brak apilo_order_id — status zakolejkowany do sync', + ['apilo_order_id' => $order['apilo_order_id'] ?? null, 'target_status' => $status] + ); + $this->queueApiloSync((int)$order['id'], false, $status, 'awaiting_apilo_order'); + } elseif (!$this->syncApiloStatus($order, $status)) { + \Domain\Integrations\ApiloLogger::log( + $db, + 'status_sync', + (int)$order['id'], + 'Sync statusu nieudany — zakolejkowano ponowną próbę', + ['apilo_order_id' => $order['apilo_order_id'], 'target_status' => $status] + ); + $this->queueApiloSync((int)$order['id'], false, $status, 'status_sync_failed'); } } - private function syncApiloPayment(array $order): bool + public function syncApiloPayment(array $order): bool { global $config; $db = $this->orders->getDb(); $integrationsRepository = new \Domain\Integrations\IntegrationsRepository($db); - if (!(int)$order['apilo_order_id']) { + if (empty($order['apilo_order_id'])) { return true; } @@ -677,20 +714,37 @@ class OrderAdminService self::appendApiloLog("PAYMENT RESPONSE\nHTTP: " . $http_code . "\nCURL: " . $curl_error . "\n" . print_r($apilo_response, true)); } + $success = ($curl_error === '' && $http_code >= 200 && $http_code < 300); + + \Domain\Integrations\ApiloLogger::log( + $db, + 'payment_sync', + (int)$order['id'], + $success + ? 'Płatność zsynchronizowana z Apilo (apilo_order_id: ' . $order['apilo_order_id'] . ')' + : 'Błąd synchronizacji płatności (HTTP: ' . $http_code . ($curl_error ? ', cURL: ' . $curl_error : '') . ')', + [ + 'apilo_order_id' => $order['apilo_order_id'], + 'http_code' => $http_code, + 'curl_error' => $curl_error, + 'response' => json_decode((string)$apilo_response, true), + ] + ); + if ($curl_error !== '') return false; if ($http_code < 200 || $http_code >= 300) return false; return true; } - private function syncApiloStatus(array $order, int $status): bool + public function syncApiloStatus(array $order, int $status): bool { global $config; $db = $this->orders->getDb(); $integrationsRepository = new \Domain\Integrations\IntegrationsRepository($db); - if (!(int)$order['apilo_order_id']) { + if (empty($order['apilo_order_id'])) { return true; } @@ -721,6 +775,24 @@ class OrderAdminService self::appendApiloLog("STATUS RESPONSE\nHTTP: " . $http_code . "\nCURL: " . $curl_error . "\n" . print_r($apilo_result, true)); } + $success = ($curl_error === '' && $http_code >= 200 && $http_code < 300); + + \Domain\Integrations\ApiloLogger::log( + $db, + 'status_sync', + (int)$order['id'], + $success + ? 'Status zsynchronizowany z Apilo (apilo_order_id: ' . $order['apilo_order_id'] . ', status: ' . $status . ')' + : 'Błąd synchronizacji statusu (HTTP: ' . $http_code . ($curl_error ? ', cURL: ' . $curl_error : '') . ')', + [ + 'apilo_order_id' => $order['apilo_order_id'], + 'status' => $status, + 'http_code' => $http_code, + 'curl_error' => $curl_error, + 'response' => json_decode((string)$apilo_result, true), + ] + ); + if ($curl_error !== '') return false; if ($http_code < 200 || $http_code >= 300) return false; @@ -728,59 +800,42 @@ class OrderAdminService } // ========================================================================= - // Private: Apilo sync queue file helpers + // Private: Apilo sync queue (DB-based via CronJobRepository) // ========================================================================= - private static function queueApiloSync(int $order_id, bool $payment, ?int $status, string $error): void + private function queueApiloSync(int $order_id, bool $payment, ?int $status, string $error): void { if ($order_id <= 0) return; - $queue = self::loadApiloSyncQueue(); - $key = (string)$order_id; - $row = is_array($queue[$key] ?? null) ? $queue[$key] : []; + if ($this->cronJobRepo === null) return; + + if ($payment) { + $jobType = \Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT; + $payload = ['order_id' => $order_id]; + + if (!$this->cronJobRepo->hasPendingJob($jobType, $payload)) { + $this->cronJobRepo->enqueue( + $jobType, + $payload, + \Domain\CronJob\CronJobType::PRIORITY_HIGH, + 50 + ); + } + } - $row['order_id'] = $order_id; - $row['payment'] = !empty($row['payment']) || $payment ? 1 : 0; if ($status !== null) { - $row['status'] = $status; + $jobType = \Domain\CronJob\CronJobType::APILO_SYNC_STATUS; + $payload = ['order_id' => $order_id, 'status' => $status]; + + if (!$this->cronJobRepo->hasPendingJob($jobType, $payload)) { + $this->cronJobRepo->enqueue( + $jobType, + $payload, + \Domain\CronJob\CronJobType::PRIORITY_HIGH, + 50 + ); + } } - - $row['attempts'] = (int)($row['attempts'] ?? 0) + 1; - $row['last_error'] = $error; - $row['updated_at'] = date('Y-m-d H:i:s'); - - $queue[$key] = $row; - self::saveApiloSyncQueue($queue); - } - - private static function apiloSyncQueuePath(): string - { - return dirname(__DIR__, 2) . self::APILO_SYNC_QUEUE_FILE; - } - - private static function loadApiloSyncQueue(): array - { - $path = self::apiloSyncQueuePath(); - if (!file_exists($path)) return []; - - $content = file_get_contents($path); - if (!$content) return []; - - $decoded = json_decode($content, true); - if (!is_array($decoded)) return []; - - return $decoded; - } - - private static function saveApiloSyncQueue(array $queue): void - { - $path = self::apiloSyncQueuePath(); - $dir = dirname($path); - if (!is_dir($dir)) { - mkdir($dir, 0777, true); - } - - file_put_contents($path, json_encode($queue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX); } private static function appendApiloLog(string $message): void diff --git a/autoload/Domain/Order/OrderRepository.php b/autoload/Domain/Order/OrderRepository.php index 1c8f23d..adfdfa5 100644 --- a/autoload/Domain/Order/OrderRepository.php +++ b/autoload/Domain/Order/OrderRepository.php @@ -245,25 +245,43 @@ class OrderRepository public function orderStatuses(): array { - $rows = $this->db->select('pp_shop_statuses', ['id', 'status'], [ + $data = $this->orderStatusData(); + return $data['names']; + } + + /** + * Zwraca nazwy i kolory statusów w jednym zapytaniu. + * + * @return array{names: array, colors: array} + */ + public function orderStatusData(): array + { + $rows = $this->db->select('pp_shop_statuses', ['id', 'status', 'color'], [ 'ORDER' => ['o' => 'ASC'], ]); + $names = []; + $colors = []; + if (!is_array($rows)) { - return []; + return ['names' => $names, 'colors' => $colors]; } - $result = []; foreach ($rows as $row) { $id = (int)($row['id'] ?? 0); if ($id < 0) { continue; } - $result[$id] = (string)($row['status'] ?? ''); + $names[$id] = (string)($row['status'] ?? ''); + + $color = trim((string)($row['color'] ?? '')); + if ($color !== '' && preg_match('/^#[0-9a-fA-F]{3,6}$/', $color)) { + $colors[$id] = $color; + } } - return $result; + return ['names' => $names, 'colors' => $colors]; } public function nextOrderId(int $orderId): ?int diff --git a/autoload/Domain/PaymentMethod/PaymentMethodRepository.php b/autoload/Domain/PaymentMethod/PaymentMethodRepository.php index 3196917..d0fee49 100644 --- a/autoload/Domain/PaymentMethod/PaymentMethodRepository.php +++ b/autoload/Domain/PaymentMethod/PaymentMethodRepository.php @@ -120,10 +120,16 @@ class PaymentMethodRepository 'description' => trim((string)($data['description'] ?? '')), 'status' => $this->toSwitchValue($data['status'] ?? 0), 'apilo_payment_type_id' => $this->normalizeApiloPaymentTypeId($data['apilo_payment_type_id'] ?? null), + 'min_order_amount' => $this->normalizeDecimalOrNull($data['min_order_amount'] ?? null), + 'max_order_amount' => $this->normalizeDecimalOrNull($data['max_order_amount'] ?? null), ]; $this->db->update('pp_shop_payment_methods', $row, ['id' => $paymentMethodId]); + $cacheHandler = new \Shared\Cache\CacheHandler(); + $cacheHandler->deletePattern('payment_method*'); + $cacheHandler->deletePattern('payment_methods*'); + return $paymentMethodId; } @@ -232,7 +238,9 @@ class PaymentMethodRepository spm.name, spm.description, spm.status, - spm.apilo_payment_type_id + spm.apilo_payment_type_id, + spm.min_order_amount, + spm.max_order_amount FROM pp_shop_payment_methods AS spm INNER JOIN pp_shop_transport_payment_methods AS stpm ON stpm.id_payment_method = spm.id @@ -325,6 +333,8 @@ class PaymentMethodRepository $row['description'] = (string)($row['description'] ?? ''); $row['status'] = $this->toSwitchValue($row['status'] ?? 0); $row['apilo_payment_type_id'] = $this->normalizeApiloPaymentTypeId($row['apilo_payment_type_id'] ?? null); + $row['min_order_amount'] = $this->normalizeDecimalOrNull($row['min_order_amount'] ?? null); + $row['max_order_amount'] = $this->normalizeDecimalOrNull($row['max_order_amount'] ?? null); return $row; } @@ -350,6 +360,23 @@ class PaymentMethodRepository return $text; } + /** + * @return float|null + */ + private function normalizeDecimalOrNull($value) + { + if ($value === null || $value === false) { + return null; + } + + $text = trim((string)$value); + if ($text === '') { + return null; + } + + return (float)$text; + } + private function toSwitchValue($value): int { if (is_bool($value)) { diff --git a/autoload/Domain/Producer/ProducerRepository.php b/autoload/Domain/Producer/ProducerRepository.php index 9bee873..d91abb7 100644 --- a/autoload/Domain/Producer/ProducerRepository.php +++ b/autoload/Domain/Producer/ProducerRepository.php @@ -357,4 +357,34 @@ class ProducerRepository return 0; } + + /** + * Znajdź producenta po nazwie lub utwórz nowego (dla API). + * + * @return array{id: int, created: bool} + */ + public function ensureProducerForApi(string $name): array + { + $name = trim($name); + if ($name === '') { + return ['id' => 0, 'created' => false]; + } + + $existing = $this->db->get('pp_shop_producer', 'id', ['name' => $name]); + if (!empty($existing)) { + return ['id' => (int)$existing, 'created' => false]; + } + + $this->db->insert('pp_shop_producer', [ + 'name' => $name, + 'status' => 1, + 'img' => null, + ]); + $id = (int)$this->db->id(); + if ($id <= 0) { + return ['id' => 0, 'created' => false]; + } + + return ['id' => $id, 'created' => true]; + } } diff --git a/autoload/Domain/Product/ProductRepository.php b/autoload/Domain/Product/ProductRepository.php index 20098d7..f0dd1e8 100644 --- a/autoload/Domain/Product/ProductRepository.php +++ b/autoload/Domain/Product/ProductRepository.php @@ -508,6 +508,31 @@ class ProductRepository $params[':promoted'] = (int)$promotedFilter; } + // Attribute filters: attribute_{id} = {value_id} + $attrFilters = isset($filters['attributes']) && is_array($filters['attributes']) ? $filters['attributes'] : []; + $attrIdx = 0; + foreach ($attrFilters as $attrId => $valueId) { + $attrId = (int)$attrId; + $valueId = (int)$valueId; + if ($attrId <= 0 || $valueId <= 0) { + continue; + } + $paramAttr = ':attr_id_' . $attrIdx; + $paramVal = ':attr_val_' . $attrIdx; + $where[] = "EXISTS ( + SELECT 1 + FROM pp_shop_products AS psp_var{$attrIdx} + INNER JOIN pp_shop_products_attributes AS pspa{$attrIdx} + ON pspa{$attrIdx}.product_id = psp_var{$attrIdx}.id + WHERE psp_var{$attrIdx}.parent_id = psp.id + AND pspa{$attrIdx}.attribute_id = {$paramAttr} + AND pspa{$attrIdx}.value_id = {$paramVal} + )"; + $params[$paramAttr] = $attrId; + $params[$paramVal] = $valueId; + $attrIdx++; + } + $whereSql = implode(' AND ', $where); $sqlCount = " @@ -632,6 +657,7 @@ class ProductRepository 'set_id' => $product['set_id'] !== null ? (int)$product['set_id'] : null, 'product_unit_id' => $product['product_unit_id'] !== null ? (int)$product['product_unit_id'] : null, 'producer_id' => $product['producer_id'] !== null ? (int)$product['producer_id'] : null, + 'producer_name' => $this->resolveProducerName($product['producer_id']), 'date_add' => $product['date_add'], 'date_modify' => $product['date_modify'], ]; @@ -657,6 +683,7 @@ class ProductRepository 'tab_name_2' => $lang['tab_name_2'], 'tab_description_2' => $lang['tab_description_2'], 'canonical' => $lang['canonical'], + 'security_information' => $lang['security_information'] ?? null, ]; } } @@ -681,21 +708,444 @@ class ProductRepository } } - // Attributes + // Attributes (enriched with names) — batch-loaded $attributes = $this->db->select('pp_shop_products_attributes', ['attribute_id', 'value_id'], ['product_id' => $id]); $result['attributes'] = []; - if (is_array($attributes)) { + if (is_array($attributes) && !empty($attributes)) { + $attrIds = []; + $valueIds = []; foreach ($attributes as $attr) { + $attrIds[] = (int)$attr['attribute_id']; + $valueIds[] = (int)$attr['value_id']; + } + $attrNamesMap = $this->batchLoadAttributeNames($attrIds); + $valueNamesMap = $this->batchLoadValueNames($valueIds); + $attrTypesMap = $this->batchLoadAttributeTypes($attrIds); + + foreach ($attributes as $attr) { + $attrId = (int)$attr['attribute_id']; + $valId = (int)$attr['value_id']; $result['attributes'][] = [ - 'attribute_id' => (int)$attr['attribute_id'], - 'value_id' => (int)$attr['value_id'], + 'attribute_id' => $attrId, + 'attribute_type' => isset($attrTypesMap[$attrId]) ? $attrTypesMap[$attrId] : 0, + 'attribute_names' => isset($attrNamesMap[$attrId]) ? $attrNamesMap[$attrId] : [], + 'value_id' => $valId, + 'value_names' => isset($valueNamesMap[$valId]) ? $valueNamesMap[$valId] : [], ]; } } + // Custom fields (Dodatkowe pola) + $customFields = $this->db->select('pp_shop_products_custom_fields', ['name', 'type', 'is_required'], ['id_product' => $id]); + $result['custom_fields'] = []; + if (is_array($customFields)) { + foreach ($customFields as $cf) { + $result['custom_fields'][] = [ + 'name' => $cf['name'], + 'type' => !empty($cf['type']) ? $cf['type'] : 'text', + 'is_required' => $cf['is_required'], + ]; + } + } + + // Variants (only for parent products) + if (empty($product['parent_id'])) { + $result['variants'] = $this->findVariantsForApi($id); + } + return $result; } + /** + * Pobiera warianty produktu z atrybutami i tłumaczeniami dla REST API. + * + * @param int $productId ID produktu nadrzędnego + * @return array Lista wariantów + */ + public function findVariantsForApi(int $productId): array + { + $stmt = $this->db->query( + 'SELECT id, permutation_hash, sku, ean, price_brutto, price_brutto_promo, + price_netto, price_netto_promo, quantity, stock_0_buy, weight, status + FROM pp_shop_products + WHERE parent_id = :pid + ORDER BY id ASC', + [':pid' => $productId] + ); + + $rows = $stmt ? $stmt->fetchAll(\PDO::FETCH_ASSOC) : []; + if (!is_array($rows)) { + return []; + } + + // Collect all variant IDs, then load attributes in batch + $variantIds = []; + foreach ($rows as $row) { + $variantIds[] = (int)$row['id']; + } + + // Load all attributes for all variants at once + $allAttrsRaw = []; + if (!empty($variantIds)) { + $allAttrsRaw = $this->db->select('pp_shop_products_attributes', ['product_id', 'attribute_id', 'value_id'], ['product_id' => $variantIds]); + if (!is_array($allAttrsRaw)) { + $allAttrsRaw = []; + } + } + + // Group by variant and collect unique IDs for batch loading + $attrsByVariant = []; + $allAttrIds = []; + $allValueIds = []; + foreach ($allAttrsRaw as $a) { + $pid = (int)$a['product_id']; + $aId = (int)$a['attribute_id']; + $vId = (int)$a['value_id']; + $attrsByVariant[$pid][] = ['attribute_id' => $aId, 'value_id' => $vId]; + $allAttrIds[] = $aId; + $allValueIds[] = $vId; + } + + $attrNamesMap = $this->batchLoadAttributeNames($allAttrIds); + $valueNamesMap = $this->batchLoadValueNames($allValueIds); + + $variants = []; + foreach ($rows as $row) { + $variantId = (int)$row['id']; + $variantAttrs = []; + if (isset($attrsByVariant[$variantId])) { + foreach ($attrsByVariant[$variantId] as $a) { + $aId = $a['attribute_id']; + $vId = $a['value_id']; + $variantAttrs[] = [ + 'attribute_id' => $aId, + 'attribute_names' => isset($attrNamesMap[$aId]) ? $attrNamesMap[$aId] : [], + 'value_id' => $vId, + 'value_names' => isset($valueNamesMap[$vId]) ? $valueNamesMap[$vId] : [], + ]; + } + } + + $variants[] = [ + 'id' => $variantId, + 'permutation_hash' => $row['permutation_hash'], + 'sku' => $row['sku'], + 'ean' => $row['ean'], + 'price_brutto' => $row['price_brutto'] !== null ? (float)$row['price_brutto'] : null, + 'price_brutto_promo' => $row['price_brutto_promo'] !== null ? (float)$row['price_brutto_promo'] : null, + 'price_netto' => $row['price_netto'] !== null ? (float)$row['price_netto'] : null, + 'price_netto_promo' => $row['price_netto_promo'] !== null ? (float)$row['price_netto_promo'] : null, + 'quantity' => (int)$row['quantity'], + 'stock_0_buy' => (int)($row['stock_0_buy'] ?? 0), + 'weight' => $row['weight'] !== null ? (float)$row['weight'] : null, + 'status' => (int)$row['status'], + 'attributes' => $variantAttrs, + ]; + } + + return $variants; + } + + /** + * Pobiera pojedynczy wariant po ID dla REST API. + * + * @param int $variantId ID wariantu + * @return array|null Dane wariantu lub null + */ + public function findVariantForApi(int $variantId): ?array + { + $row = $this->db->get('pp_shop_products', '*', ['id' => $variantId]); + if (!$row || empty($row['parent_id'])) { + return null; + } + + $attrs = $this->db->select('pp_shop_products_attributes', ['attribute_id', 'value_id'], ['product_id' => $variantId]); + $variantAttrs = []; + if (is_array($attrs) && !empty($attrs)) { + $attrIds = []; + $valueIds = []; + foreach ($attrs as $a) { + $attrIds[] = (int)$a['attribute_id']; + $valueIds[] = (int)$a['value_id']; + } + $attrNamesMap = $this->batchLoadAttributeNames($attrIds); + $valueNamesMap = $this->batchLoadValueNames($valueIds); + + foreach ($attrs as $a) { + $aId = (int)$a['attribute_id']; + $vId = (int)$a['value_id']; + $variantAttrs[] = [ + 'attribute_id' => $aId, + 'attribute_names' => isset($attrNamesMap[$aId]) ? $attrNamesMap[$aId] : [], + 'value_id' => $vId, + 'value_names' => isset($valueNamesMap[$vId]) ? $valueNamesMap[$vId] : [], + ]; + } + } + + return [ + 'id' => (int)$row['id'], + 'parent_id' => (int)$row['parent_id'], + 'permutation_hash' => $row['permutation_hash'], + 'sku' => $row['sku'], + 'ean' => $row['ean'], + 'price_brutto' => $row['price_brutto'] !== null ? (float)$row['price_brutto'] : null, + 'price_brutto_promo' => $row['price_brutto_promo'] !== null ? (float)$row['price_brutto_promo'] : null, + 'price_netto' => $row['price_netto'] !== null ? (float)$row['price_netto'] : null, + 'price_netto_promo' => $row['price_netto_promo'] !== null ? (float)$row['price_netto_promo'] : null, + 'quantity' => (int)$row['quantity'], + 'stock_0_buy' => (int)($row['stock_0_buy'] ?? 0), + 'weight' => $row['weight'] !== null ? (float)$row['weight'] : null, + 'status' => (int)$row['status'], + 'attributes' => $variantAttrs, + ]; + } + + /** + * Tworzy nowy wariant (kombinację) produktu przez API. + * + * @param int $parentId ID produktu nadrzędnego + * @param array $data Dane wariantu (attributes, sku, ean, price_brutto, etc.) + * @return array|null ['id' => int, 'permutation_hash' => string] lub null przy błędzie + */ + public function createVariantForApi(int $parentId, array $data): ?array + { + $parent = $this->db->get('pp_shop_products', ['id', 'archive', 'parent_id', 'vat'], ['id' => $parentId]); + if (!$parent) { + return null; + } + if (!empty($parent['archive'])) { + return null; + } + if (!empty($parent['parent_id'])) { + return null; + } + + $attributes = isset($data['attributes']) && is_array($data['attributes']) ? $data['attributes'] : []; + if (empty($attributes)) { + return null; + } + + // Build permutation hash + ksort($attributes); + $hashParts = []; + foreach ($attributes as $attrId => $valueId) { + $hashParts[] = (int)$attrId . '-' . (int)$valueId; + } + $permutationHash = implode('|', $hashParts); + + // Check duplicate + $existing = $this->db->count('pp_shop_products', [ + 'AND' => [ + 'parent_id' => $parentId, + 'permutation_hash' => $permutationHash, + ], + ]); + if ($existing > 0) { + return null; + } + + $insertData = [ + 'parent_id' => $parentId, + 'permutation_hash' => $permutationHash, + 'vat' => $parent['vat'] ?? 0, + 'sku' => isset($data['sku']) ? (string)$data['sku'] : null, + 'ean' => isset($data['ean']) ? (string)$data['ean'] : null, + 'price_brutto' => isset($data['price_brutto']) ? (float)$data['price_brutto'] : null, + 'price_netto' => isset($data['price_netto']) ? (float)$data['price_netto'] : null, + 'quantity' => isset($data['quantity']) ? (int)$data['quantity'] : 0, + 'stock_0_buy' => isset($data['stock_0_buy']) ? (int)$data['stock_0_buy'] : 0, + 'weight' => isset($data['weight']) ? (float)$data['weight'] : null, + 'status' => 1, + ]; + + $this->db->insert('pp_shop_products', $insertData); + $variantId = (int)$this->db->id(); + if ($variantId <= 0) { + return null; + } + + // Insert attribute rows + foreach ($attributes as $attrId => $valueId) { + $this->db->insert('pp_shop_products_attributes', [ + 'product_id' => $variantId, + 'attribute_id' => (int)$attrId, + 'value_id' => (int)$valueId, + ]); + } + + return [ + 'id' => $variantId, + 'permutation_hash' => $permutationHash, + ]; + } + + /** + * Aktualizuje wariant produktu przez API. + * + * @param int $variantId ID wariantu + * @param array $data Pola do aktualizacji + * @return bool true jeśli sukces + */ + public function updateVariantForApi(int $variantId, array $data): bool + { + $variant = $this->db->get('pp_shop_products', ['id', 'parent_id'], ['id' => $variantId]); + if (!$variant || empty($variant['parent_id'])) { + return false; + } + + $casts = [ + 'sku' => 'string', + 'ean' => 'string', + 'price_brutto' => 'float_or_null', + 'price_netto' => 'float_or_null', + 'price_brutto_promo' => 'float_or_null', + 'price_netto_promo' => 'float_or_null', + 'quantity' => 'int', + 'stock_0_buy' => 'int', + 'weight' => 'float_or_null', + 'status' => 'int', + ]; + + $updateData = []; + foreach ($casts as $field => $type) { + if (array_key_exists($field, $data)) { + $value = $data[$field]; + if ($type === 'string') { + $updateData[$field] = ($value !== null) ? (string)$value : ''; + } elseif ($type === 'int') { + $updateData[$field] = (int)$value; + } elseif ($type === 'float_or_null') { + $updateData[$field] = ($value !== null && $value !== '') ? (float)$value : null; + } + } + } + + if (empty($updateData)) { + return true; + } + + $this->db->update('pp_shop_products', $updateData, ['id' => $variantId]); + return true; + } + + /** + * Usuwa wariant produktu przez API. + * + * @param int $variantId ID wariantu + * @return bool true jeśli sukces + */ + public function deleteVariantForApi(int $variantId): bool + { + $variant = $this->db->get('pp_shop_products', ['id', 'parent_id'], ['id' => $variantId]); + if (!$variant || empty($variant['parent_id'])) { + return false; + } + + $this->db->delete('pp_shop_products_langs', ['product_id' => $variantId]); + $this->db->delete('pp_shop_products_attributes', ['product_id' => $variantId]); + $this->db->delete('pp_shop_products', ['id' => $variantId]); + return true; + } + + /** + * Batch-loads attribute names for multiple attribute IDs. + * + * @param int[] $attrIds + * @return array> [attrId => [langId => name]] + */ + private function batchLoadAttributeNames(array $attrIds): array + { + if (empty($attrIds)) { + return []; + } + $translations = $this->db->select( + 'pp_shop_attributes_langs', + ['attribute_id', 'lang_id', 'name'], + ['attribute_id' => array_values(array_unique($attrIds))] + ); + $result = []; + if (is_array($translations)) { + foreach ($translations as $t) { + $aId = (int)($t['attribute_id'] ?? 0); + $langId = (string)($t['lang_id'] ?? ''); + if ($aId > 0 && $langId !== '') { + $result[$aId][$langId] = (string)($t['name'] ?? ''); + } + } + } + return $result; + } + + /** + * Batch-loads value names for multiple value IDs. + * + * @param int[] $valueIds + * @return array> [valueId => [langId => name]] + */ + private function batchLoadValueNames(array $valueIds): array + { + if (empty($valueIds)) { + return []; + } + $translations = $this->db->select( + 'pp_shop_attributes_values_langs', + ['value_id', 'lang_id', 'name'], + ['value_id' => array_values(array_unique($valueIds))] + ); + $result = []; + if (is_array($translations)) { + foreach ($translations as $t) { + $vId = (int)($t['value_id'] ?? 0); + $langId = (string)($t['lang_id'] ?? ''); + if ($vId > 0 && $langId !== '') { + $result[$vId][$langId] = (string)($t['name'] ?? ''); + } + } + } + return $result; + } + + /** + * Batch-loads attribute types for multiple attribute IDs. + * + * @param int[] $attrIds + * @return array [attrId => type] + */ + private function batchLoadAttributeTypes(array $attrIds): array + { + if (empty($attrIds)) { + return []; + } + $rows = $this->db->select( + 'pp_shop_attributes', + ['id', 'type'], + ['id' => array_values(array_unique($attrIds))] + ); + $result = []; + if (is_array($rows)) { + foreach ($rows as $row) { + $result[(int)$row['id']] = (int)($row['type'] ?? 0); + } + } + return $result; + } + + /** + * Zwraca nazwę producenta po ID (null jeśli brak). + * + * @param mixed $producerId + * @return string|null + */ + private function resolveProducerName($producerId): ?string + { + if (empty($producerId)) { + return null; + } + $name = $this->db->get('pp_shop_producer', 'name', ['id' => (int)$producerId]); + return ($name !== false && $name !== null) ? (string)$name : null; + } + /** * Szczegóły produktu (admin) — zastępuje factory product_details(). */ @@ -819,7 +1269,7 @@ class ProductRepository $productData = [ 'date_modify' => date( 'Y-m-d H:i:s' ), - 'modify_by' => $userId, + 'modify_by' => $userId !== null ? (int) $userId : 0, 'status' => ( $d['status'] ?? '' ) === 'on' ? 1 : 0, 'price_netto' => $this->nullIfEmpty( $d['price_netto'] ?? null ), 'price_brutto' => $this->nullIfEmpty( $d['price_brutto'] ?? null ), @@ -881,7 +1331,10 @@ class ProductRepository $this->saveImagesOrder( $productId, $d['gallery_order'] ); } - $this->saveCustomFields( $productId, $d['custom_field_name'] ?? [], $d['custom_field_type'] ?? [], $d['custom_field_required'] ?? [] ); + // Zapisz custom fields tylko gdy jawnie podane (partial update przez API może nie zawierać tego klucza) + if ( array_key_exists( 'custom_field_name', $d ) ) { + $this->saveCustomFields( $productId, $d['custom_field_name'] ?? [], $d['custom_field_type'] ?? [], $d['custom_field_required'] ?? [] ); + } if ( !$isNew ) { $this->cleanupDeletedFiles( $productId ); @@ -1195,6 +1648,7 @@ class ProductRepository $this->db->delete( 'pp_shop_products_langs', [ 'product_id' => $productId ] ); $this->db->delete( 'pp_shop_products_images', [ 'product_id' => $productId ] ); $this->db->delete( 'pp_shop_products_files', [ 'product_id' => $productId ] ); + $this->db->delete( 'pp_shop_products_custom_fields', [ 'id_product' => $productId ] ); $this->db->delete( 'pp_shop_products_attributes', [ 'product_id' => $productId ] ); $this->db->delete( 'pp_shop_products', [ 'id' => $productId ] ); $this->db->delete( 'pp_shop_product_sets_products', [ 'product_id' => $productId ] ); @@ -2939,12 +3393,18 @@ class ProductRepository $attributes = \Shared\Helpers\Helpers::removeDuplicates($attributes, 'id'); - $sorted = []; + $toSort = []; foreach ($attributes as $key => $val) { $row = []; $row['id'] = $key; $row['values'] = $val; - $sorted[$attrRepo->getAttributeOrder((int) $key)] = $row; + $toSort[] = ['order' => (int) $attrRepo->getAttributeOrder((int) $key), 'data' => $row]; + } + usort($toSort, function ($a, $b) { return $a['order'] - $b['order']; }); + + $sorted = []; + foreach ($toSort as $i => $item) { + $sorted[$i + 1] = $item['data']; } return $sorted; diff --git a/autoload/Domain/Settings/SettingsRepository.php b/autoload/Domain/Settings/SettingsRepository.php index 883c4ec..f352a89 100644 --- a/autoload/Domain/Settings/SettingsRepository.php +++ b/autoload/Domain/Settings/SettingsRepository.php @@ -71,6 +71,7 @@ class SettingsRepository 'infinitescroll' => $this->isEnabled($values['infinitescroll'] ?? null) ? 1 : 0, 'own_gtm_js' => $values['own_gtm_js'] ?? '', 'own_gtm_html' => $values['own_gtm_html'] ?? '', + 'api_key' => $values['api_key'] ?? '', ]; $warehouseMessageZero = $values['warehouse_message_zero'] ?? []; diff --git a/autoload/Domain/Transport/TransportRepository.php b/autoload/Domain/Transport/TransportRepository.php index de2afaf..c48306e 100644 --- a/autoload/Domain/Transport/TransportRepository.php +++ b/autoload/Domain/Transport/TransportRepository.php @@ -323,7 +323,9 @@ class TransportRepository $transports[] = $tr; } - if ( \Shared\Helpers\Helpers::normalize_decimal( \Domain\Basket\BasketCalculator::summaryPrice( $basket, $coupon ) ) >= \Shared\Helpers\Helpers::normalize_decimal( $settings['free_delivery'] ) ) + $products_summary = (float)\Domain\Basket\BasketCalculator::summaryPrice( $basket, $coupon ); + + if ( \Shared\Helpers\Helpers::normalize_decimal( $products_summary ) >= \Shared\Helpers\Helpers::normalize_decimal( $settings['free_delivery'] ) ) { for ( $i = 0; $i < count( $transports ); $i++ ) { if ( $transports[$i]['delivery_free'] == 1 ) { @@ -332,7 +334,39 @@ class TransportRepository } } - return $transports; + // Ukryj transporty, dla których nie ma żadnej dostępnej formy płatności + $paymentMethodRepo = new \Domain\PaymentMethod\PaymentMethodRepository( $this->db ); + $filtered = []; + + foreach ( $transports as $tr ) + { + $paymentMethods = $paymentMethodRepo->paymentMethodsByTransport( $tr['id'] ); + $order_total = $products_summary + (float)$tr['cost']; + $has_available_pm = false; + + foreach ( $paymentMethods as $pm ) + { + $min = isset( $pm['min_order_amount'] ) ? (float)$pm['min_order_amount'] : null; + $max = isset( $pm['max_order_amount'] ) ? (float)$pm['max_order_amount'] : null; + $available = true; + + if ( $min !== null && $min > 0 && $order_total < $min ) $available = false; + if ( $max !== null && $max > 0 && $order_total > $max ) $available = false; + + if ( $available ) + { + $has_available_pm = true; + break; + } + } + + if ( $has_available_pm ) + { + $filtered[] = $tr; + } + } + + return $filtered; } /** diff --git a/autoload/Domain/Update/UpdateRepository.php b/autoload/Domain/Update/UpdateRepository.php index a3dc55c..429a80a 100644 --- a/autoload/Domain/Update/UpdateRepository.php +++ b/autoload/Domain/Update/UpdateRepository.php @@ -61,10 +61,284 @@ class UpdateRepository return [ 'success' => true, 'log' => $log, 'no_updates' => true ]; } + /** + * Dispatcher — próbuje pobrać manifest, jeśli jest → nowa ścieżka, jeśli brak → legacy. + */ private function downloadAndApply( string $ver, string $dir, array $log ): array { $baseUrl = 'https://shoppro.project-dc.pl/updates/' . $dir; + $manifest = $this->downloadManifest( $baseUrl, $ver ); + + if ( $manifest !== null ) { + $log[] = '[INFO] Znaleziono manifest dla wersji ' . $ver; + return $this->downloadAndApplyWithManifest( $ver, $dir, $manifest, $log ); + } + + $log[] = '[INFO] Brak manifestu, używam trybu legacy'; + return $this->downloadAndApplyLegacy( $ver, $dir, $log ); + } + + /** + * Pobiera manifest JSON dla danej wersji. + * + * @return array|null Zdekodowany manifest lub null jeśli brak + */ + private function downloadManifest( string $baseUrl, string $ver ) + { + $manifestUrl = $baseUrl . '/ver_' . $ver . '_manifest.json'; + + $ch = curl_init( $manifestUrl ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_HEADER, false ); + curl_setopt( $ch, CURLOPT_TIMEOUT, 15 ); + $response = curl_exec( $ch ); + $httpCode = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + curl_close( $ch ); + + if ( !$response || $httpCode !== 200 ) { + return null; + } + + $manifest = json_decode( $response, true ); + if ( !is_array( $manifest ) || !isset( $manifest['version'] ) ) { + return null; + } + + return $manifest; + } + + /** + * Aktualizacja z użyciem manifestu — checksum, backup, SQL z manifestu, usuwanie z manifestu. + */ + private function downloadAndApplyWithManifest( string $ver, string $dir, array $manifest, array $log ): array + { + $baseUrl = 'https://shoppro.project-dc.pl/updates/' . $dir; + + $log[] = '[INFO] Tryb aktualizacji: manifest'; + + // 1. Pobieranie ZIP + $zipUrl = $baseUrl . '/ver_' . $ver . '.zip'; + $log[] = '[INFO] Pobieranie pliku ZIP: ' . $zipUrl; + $file = @file_get_contents( $zipUrl ); + + if ( $file === false ) { + $log[] = '[ERROR] Nie udało się pobrać pliku ZIP'; + return [ 'success' => false, 'log' => $log ]; + } + + $fileSize = strlen( $file ); + $log[] = '[OK] Pobrano plik ZIP, rozmiar: ' . $fileSize . ' bajtów'; + + if ( $fileSize < 100 ) { + $log[] = '[ERROR] Plik ZIP jest za mały (prawdopodobnie błąd pobierania)'; + return [ 'success' => false, 'log' => $log ]; + } + + $dlHandler = @fopen( 'update.zip', 'w' ); + if ( !$dlHandler ) { + $log[] = '[ERROR] Nie udało się otworzyć pliku update.zip do zapisu'; + return [ 'success' => false, 'log' => $log ]; + } + $written = fwrite( $dlHandler, $file ); + fclose( $dlHandler ); + + if ( $written === false || $written === 0 ) { + $log[] = '[ERROR] Nie udało się zapisać pliku ZIP'; + return [ 'success' => false, 'log' => $log ]; + } + + $log[] = '[OK] Zapisano plik ZIP (' . $written . ' bajtów)'; + + // 2. Weryfikacja checksum + if ( isset( $manifest['checksum_zip'] ) ) { + $checksumResult = $this->verifyChecksum( 'update.zip', $manifest['checksum_zip'], $log ); + $log = $checksumResult['log']; + + if ( !$checksumResult['valid'] ) { + @unlink( 'update.zip' ); + return [ 'success' => false, 'log' => $log ]; + } + } + + // 3. Backup plików przed nadpisaniem + $log = $this->createBackup( $manifest, $log ); + + // 4. SQL z manifestu + if ( !empty( $manifest['sql'] ) ) { + $log[] = '[INFO] Wykonywanie zapytań SQL z manifestu (' . count( $manifest['sql'] ) . ')'; + $success = 0; + $errors = 0; + + foreach ( $manifest['sql'] as $query ) { + $query = trim( $query ); + if ( $query !== '' ) { + if ( $this->db->query( $query ) ) { + $success++; + } else { + $errors++; + $log[] = '[WARNING] Błąd SQL: ' . $query; + } + } + } + + $log[] = '[INFO] Wykonano zapytania SQL - sukces: ' . $success . ', błędy: ' . $errors; + } + + // 5. Usuwanie plików z manifestu + if ( !empty( $manifest['files']['deleted'] ) ) { + $deletedCount = 0; + foreach ( $manifest['files']['deleted'] as $relativePath ) { + $fullPath = '../' . $relativePath; + if ( file_exists( $fullPath ) ) { + if ( @unlink( $fullPath ) ) { + $deletedCount++; + } else { + $log[] = '[WARNING] Nie udało się usunąć pliku: ' . $fullPath; + } + } + } + $log[] = '[INFO] Usunięto plików: ' . $deletedCount; + } + + // 6. Usuwanie katalogów z manifestu + if ( !empty( $manifest['directories_deleted'] ) ) { + $deletedDirs = 0; + foreach ( $manifest['directories_deleted'] as $dirPath ) { + $fullPath = '../' . $dirPath; + if ( is_dir( $fullPath ) ) { + \Shared\Helpers\Helpers::delete_dir( $fullPath ); + $deletedDirs++; + } + } + $log[] = '[INFO] Usunięto katalogów: ' . $deletedDirs; + } + + // 7. Rozpakowywanie ZIP + $log = $this->extractZip( 'update.zip', $log ); + + // 8. Aktualizacja wersji + $versionFile = '../libraries/version.ini'; + $handle = @fopen( $versionFile, 'w' ); + if ( !$handle ) { + $log[] = '[ERROR] Nie udało się otworzyć pliku version.ini do zapisu'; + return [ 'success' => false, 'log' => $log ]; + } + fwrite( $handle, $ver ); + fclose( $handle ); + + $log[] = '[OK] Zaktualizowano plik version.ini do wersji: ' . $ver; + $log[] = '[SUCCESS] Aktualizacja do wersji ' . $ver . ' zakończona pomyślnie'; + + return [ 'success' => true, 'log' => $log ]; + } + + /** + * Weryfikuje sumę kontrolną pliku. + * + * @param string $filePath Ścieżka do pliku + * @param string $expectedChecksum Suma w formacie "sha256:abc123..." + * @param array $log Tablica logów + * @return array{valid: bool, log: array} + */ + private function verifyChecksum( string $filePath, string $expectedChecksum, array $log ): array + { + $parts = explode( ':', $expectedChecksum, 2 ); + if ( count( $parts ) !== 2 ) { + $log[] = '[ERROR] Nieprawidłowy format sumy kontrolnej: ' . $expectedChecksum; + return [ 'valid' => false, 'log' => $log ]; + } + + $algorithm = $parts[0]; + $expected = $parts[1]; + + $actual = @hash_file( $algorithm, $filePath ); + + if ( $actual === false ) { + $log[] = '[ERROR] Nie udało się obliczyć sumy kontrolnej pliku'; + return [ 'valid' => false, 'log' => $log ]; + } + + if ( $actual !== $expected ) { + $log[] = '[ERROR] Suma kontrolna nie zgadza się! Oczekiwano: ' . $expected . ', otrzymano: ' . $actual; + return [ 'valid' => false, 'log' => $log ]; + } + + $log[] = '[OK] Suma kontrolna ZIP zgodna'; + return [ 'valid' => true, 'log' => $log ]; + } + + /** + * Tworzy kopię zapasową plików przed aktualizacją. + * + * @param array $manifest Dane z manifestu + * @param array $log Tablica logów + * @return array Zaktualizowana tablica logów + */ + private function createBackup( array $manifest, array $log ): array + { + $version = isset( $manifest['version'] ) ? $manifest['version'] : 'unknown'; + $backupDir = '../backups/' . str_replace( '.', '_', $version ) . '_' . date( 'Ymd_His' ); + + $log[] = '[INFO] Tworzenie kopii zapasowej w: ' . $backupDir; + + $projectRoot = realpath( '../' ); + if ( !$projectRoot ) { + $log[] = '[WARNING] Nie udało się określić katalogu projektu, pomijam backup'; + return $log; + } + + $filesToBackup = []; + if ( isset( $manifest['files']['modified'] ) && is_array( $manifest['files']['modified'] ) ) { + $filesToBackup = array_merge( $filesToBackup, $manifest['files']['modified'] ); + } + if ( isset( $manifest['files']['deleted'] ) && is_array( $manifest['files']['deleted'] ) ) { + $filesToBackup = array_merge( $filesToBackup, $manifest['files']['deleted'] ); + } + + if ( empty( $filesToBackup ) ) { + $log[] = '[INFO] Brak plików do backupu'; + return $log; + } + + $backedUp = 0; + foreach ( $filesToBackup as $relativePath ) { + $sourcePath = $projectRoot . '/' . $relativePath; + if ( !file_exists( $sourcePath ) ) { + continue; + } + + $targetPath = $backupDir . '/' . $relativePath; + $targetDir = dirname( $targetPath ); + + if ( !is_dir( $targetDir ) ) { + @mkdir( $targetDir, 0755, true ); + } + + if ( @copy( $sourcePath, $targetPath ) ) { + $backedUp++; + } else { + $log[] = '[WARNING] Nie udało się skopiować do backupu: ' . $relativePath; + } + } + + $log[] = '[OK] Backup: skopiowano ' . $backedUp . ' plików'; + + @file_put_contents( + $backupDir . '/manifest.json', + json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE ) + ); + + return $log; + } + + /** + * Legacy — stary format aktualizacji (ZIP + _sql.txt + _files.txt). + */ + private function downloadAndApplyLegacy( string $ver, string $dir, array $log ): array + { + $baseUrl = 'https://shoppro.project-dc.pl/updates/' . $dir; + // Pobieranie ZIP $zipUrl = $baseUrl . '/ver_' . $ver . '.zip'; $log[] = '[INFO] Pobieranie pliku ZIP: ' . $zipUrl; @@ -142,7 +416,12 @@ class UpdateRepository return $log; } - $queries = explode( PHP_EOL, $response ); + // Usunięcie UTF-8 BOM i normalizacja końców linii + $response = ltrim( $response, "\xEF\xBB\xBF" ); + $response = str_replace( "\r\n", "\n", $response ); + $response = str_replace( "\r", "\n", $response ); + + $queries = explode( "\n", $response ); $log[] = '[OK] Pobrano ' . count( $queries ) . ' zapytań SQL'; $success = 0; $errors = 0; diff --git a/autoload/Shared/Tpl/Tpl.php b/autoload/Shared/Tpl/Tpl.php index 091c982..fe1a6c2 100644 --- a/autoload/Shared/Tpl/Tpl.php +++ b/autoload/Shared/Tpl/Tpl.php @@ -60,4 +60,9 @@ class Tpl { return $this->vars[$name]; } + + public function __isset($name) + { + return isset($this->vars[$name]); + } } diff --git a/autoload/admin/App.php b/autoload/admin/App.php index a89b6ca..b0c051e 100644 --- a/autoload/admin/App.php +++ b/autoload/admin/App.php @@ -423,7 +423,8 @@ class App new \Domain\Order\OrderRepository( $mdb ), $productRepo, new \Domain\Settings\SettingsRepository( $mdb ), - new \Domain\Transport\TransportRepository( $mdb ) + new \Domain\Transport\TransportRepository( $mdb ), + new \Domain\CronJob\CronJobRepository( $mdb ) ), $productRepo ); diff --git a/autoload/admin/Controllers/IntegrationsController.php b/autoload/admin/Controllers/IntegrationsController.php index 29d6888..e182a90 100644 --- a/autoload/admin/Controllers/IntegrationsController.php +++ b/autoload/admin/Controllers/IntegrationsController.php @@ -2,6 +2,7 @@ namespace admin\Controllers; use Domain\Integrations\IntegrationsRepository; +use admin\ViewModels\Common\PaginatedTableViewModel; class IntegrationsController { @@ -12,6 +13,114 @@ class IntegrationsController $this->repository = $repository; } + public function logs(): string + { + $sortableColumns = ['id', 'action', 'order_id', 'message', 'date']; + + $filterDefinitions = [ + [ + 'key' => 'log_action', + 'label' => 'Akcja', + 'type' => 'text', + ], + [ + 'key' => 'message', + 'label' => 'Wiadomosc', + 'type' => 'text', + ], + [ + 'key' => 'order_id', + 'label' => 'ID zamowienia', + 'type' => 'text', + ], + ]; + + $listRequest = \admin\Support\TableListRequestFactory::fromRequest( + $filterDefinitions, + $sortableColumns, + 'id' + ); + + $result = $this->repository->getLogs( + $listRequest['filters'], + $listRequest['sortColumn'], + $listRequest['sortDir'], + $listRequest['page'], + $listRequest['perPage'] + ); + + $rows = []; + $lp = ($listRequest['page'] - 1) * $listRequest['perPage'] + 1; + + foreach ( $result['items'] as $item ) { + $id = (int)($item['id'] ?? 0); + $context = trim( (string)($item['context'] ?? '') ); + $contextHtml = ''; + if ( $context !== '' ) { + $contextHtml = '' + . ''; + } + + $rows[] = [ + 'lp' => $lp++ . '.', + 'action' => htmlspecialchars( (string)($item['action'] ?? ''), ENT_QUOTES, 'UTF-8' ), + 'order_id' => $item['order_id'] ? (int)$item['order_id'] : '-', + 'message' => htmlspecialchars( (string)($item['message'] ?? ''), ENT_QUOTES, 'UTF-8' ), + 'context' => $contextHtml, + 'date' => !empty( $item['date'] ) ? date( 'Y-m-d H:i:s', strtotime( (string)$item['date'] ) ) : '-', + ]; + } + + $total = (int)$result['total']; + $totalPages = max( 1, (int)ceil( $total / $listRequest['perPage'] ) ); + + $viewModel = new PaginatedTableViewModel( + [ + ['key' => 'lp', 'label' => 'Lp.', 'class' => 'text-center', 'sortable' => false], + ['key' => 'date', 'sort_key' => 'date', 'label' => 'Data', 'class' => 'text-center', 'sortable' => true], + ['key' => 'action', 'sort_key' => 'action', 'label' => 'Akcja', 'sortable' => true], + ['key' => 'order_id', 'sort_key' => 'order_id', 'label' => 'Zamowienie', 'class' => 'text-center', 'sortable' => true], + ['key' => 'message', 'sort_key' => 'message', 'label' => 'Wiadomosc', 'sortable' => true], + ['key' => 'context', 'label' => 'Kontekst', 'sortable' => false, 'raw' => true], + ], + $rows, + $listRequest['viewFilters'], + [ + 'column' => $listRequest['sortColumn'], + 'dir' => $listRequest['sortDir'], + ], + [ + 'page' => $listRequest['page'], + 'per_page' => $listRequest['perPage'], + 'total' => $total, + 'total_pages' => $totalPages, + ], + array_merge( $listRequest['queryFilters'], [ + 'sort' => $listRequest['sortColumn'], + 'dir' => $listRequest['sortDir'], + 'per_page' => $listRequest['perPage'], + ] ), + $listRequest['perPageOptions'], + $sortableColumns, + '/admin/integrations/logs/', + 'Brak wpisow w logach.' + ); + + return \Shared\Tpl\Tpl::view( 'integrations/logs', [ + 'viewModel' => $viewModel, + ] ); + } + + public function logs_clear(): void + { + $this->repository->clearLogs(); + \Shared\Helpers\Helpers::alert( 'Logi zostaly wyczyszczone.' ); + header( 'Location: /admin/integrations/logs/' ); + exit; + } + public function apilo_settings(): string { return \Shared\Tpl\Tpl::view( 'integrations/apilo-settings', [ diff --git a/autoload/admin/Controllers/ProductArchiveController.php b/autoload/admin/Controllers/ProductArchiveController.php index 970b9b4..a9516dd 100644 --- a/autoload/admin/Controllers/ProductArchiveController.php +++ b/autoload/admin/Controllers/ProductArchiveController.php @@ -106,6 +106,14 @@ class ProductArchiveController 'confirm_ok' => 'Przywroc', 'confirm_cancel' => 'Anuluj', ], + [ + 'label' => 'Usun trwale', + 'url' => '/admin/product_archive/delete_permanent/product_id=' . $id, + 'class' => 'btn btn-xs btn-danger', + 'confirm' => 'UWAGA! Operacja nieodwracalna!' . "\n\n" . 'Produkt "' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" zostanie trwale usuniety razem ze wszystkimi zdjeciami i zalacznikami z serwera.' . "\n\n" . 'Czy na pewno chcesz usunac ten produkt?', + 'confirm_ok' => 'Tak, usun trwale', + 'confirm_cancel' => 'Anuluj', + ], ], ]; } @@ -162,4 +170,24 @@ class ProductArchiveController header( 'Location: /admin/product_archive/list/' ); exit; } + + public function delete_permanent(): void + { + $productId = (int) \Shared\Helpers\Helpers::get( 'product_id' ); + + if ( $productId <= 0 ) { + \Shared\Helpers\Helpers::alert( 'Nieprawidłowe ID produktu.' ); + header( 'Location: /admin/product_archive/list/' ); + exit; + } + + if ( $this->productRepository->delete( $productId ) ) { + \Shared\Helpers\Helpers::set_message( 'Produkt został trwale usunięty wraz ze zdjęciami i załącznikami.' ); + } else { + \Shared\Helpers\Helpers::alert( 'Podczas usuwania produktu wystąpił błąd. Proszę spróbować ponownie.' ); + } + + header( 'Location: /admin/product_archive/list/' ); + exit; + } } diff --git a/autoload/admin/Controllers/SettingsController.php b/autoload/admin/Controllers/SettingsController.php index 7d6f2fb..d06e0c3 100644 --- a/autoload/admin/Controllers/SettingsController.php +++ b/autoload/admin/Controllers/SettingsController.php @@ -73,26 +73,45 @@ class SettingsController */ public function globalSearchAjax(): void { - global $mdb; + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store'); - $phrase = trim((string)\Shared\Helpers\Helpers::get('q')); - if ($phrase === '' || mb_strlen($phrase) < 2) { + try { + $this->executeGlobalSearch(); + } catch (\Throwable $e) { echo json_encode([ - 'status' => 'ok', + 'status' => 'error', 'items' => [], ]); - exit; + } + exit; + } + + private function executeGlobalSearch(): void + { + global $mdb; + + $phrase = isset($_REQUEST['q']) ? trim((string)$_REQUEST['q']) : ''; + if ($phrase === '' || mb_strlen($phrase) < 2) { + echo json_encode(['status' => 'ok', 'items' => []]); + return; } $phrase = mb_substr($phrase, 0, 120); - $phraseNormalized = preg_replace('/\s+/', ' ', $phrase); - $phraseNormalized = trim((string)$phraseNormalized); + $phraseNormalized = trim((string)preg_replace('/\s+/', ' ', $phrase)); $like = '%' . $phrase . '%'; $likeNormalized = '%' . $phraseNormalized . '%'; $items = []; - $defaultLang = (string)$this->languagesRepository->defaultLanguage(); + $defaultLang = '1'; + try { + $defaultLang = (string)$this->languagesRepository->defaultLanguage(); + } catch (\Throwable $e) { + // fallback to '1' + } + + // --- Produkty --- try { $productStmt = $mdb->query( 'SELECT ' @@ -115,7 +134,10 @@ class SettingsController $productStmt = false; } - $productRows = $productStmt ? $productStmt->fetchAll() : []; + $productRows = ($productStmt && method_exists($productStmt, 'fetchAll')) + ? $productStmt->fetchAll(\PDO::FETCH_ASSOC) + : []; + if (is_array($productRows)) { foreach ($productRows as $row) { $productId = (int)($row['id'] ?? 0); @@ -147,6 +169,7 @@ class SettingsController } } + // --- Zamowienia --- try { $orderStmt = $mdb->query( 'SELECT ' @@ -178,7 +201,10 @@ class SettingsController $orderStmt = false; } - $orderRows = $orderStmt ? $orderStmt->fetchAll() : []; + $orderRows = ($orderStmt && method_exists($orderStmt, 'fetchAll')) + ? $orderStmt->fetchAll(\PDO::FETCH_ASSOC) + : []; + if (is_array($orderRows)) { foreach ($orderRows as $row) { $orderId = (int)($row['id'] ?? 0); @@ -214,11 +240,12 @@ class SettingsController } } - echo json_encode([ - 'status' => 'ok', - 'items' => array_slice($items, 0, 20), - ]); - exit; + $json = json_encode(['status' => 'ok', 'items' => array_slice($items, 0, 20)]); + if ($json === false) { + echo json_encode(['status' => 'ok', 'items' => []], JSON_UNESCAPED_UNICODE); + return; + } + echo $json; } /** @@ -444,8 +471,7 @@ class SettingsController 'label' => 'Htaccess cache', 'tab' => 'system', ]), - FormField::text('api_key', [ - 'label' => 'Klucz API (ordersPRO)', + FormField::custom('api_key', $this->renderApiKeyField($data['api_key'] ?? ''), [ 'tab' => 'system', ]), @@ -533,4 +559,23 @@ class SettingsController return $data; } + + private function renderApiKeyField(string $value): string + { + $escaped = htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + + $js = "var c='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'," + . "k='';for(var i=0;i<32;i++){k+=c.charAt(Math.floor(Math.random()*c.length));}" + . "document.getElementById('api_key').value=k;"; + + return '
' + . '' + . '
' + . '
' + . '' + . 'Generuj' + . '
' + . '
' + . '
'; + } } diff --git a/autoload/admin/Controllers/ShopOrderController.php b/autoload/admin/Controllers/ShopOrderController.php index 0f0bdc0..3233418 100644 --- a/autoload/admin/Controllers/ShopOrderController.php +++ b/autoload/admin/Controllers/ShopOrderController.php @@ -69,7 +69,9 @@ class ShopOrderController $listRequest['perPage'] ); - $statusesMap = $this->service->statuses(); + $statusData = $this->service->statusData(); + $statusesMap = $statusData['names']; + $statusColorsMap = $statusData['colors']; $rows = []; $lp = ($listRequest['page'] - 1) * $listRequest['perPage'] + 1; @@ -77,7 +79,15 @@ class ShopOrderController $orderId = (int)($item['id'] ?? 0); $orderNumber = (string)($item['number'] ?? ''); $statusId = (int)($item['status'] ?? 0); - $statusLabel = (string)($statusesMap[$statusId] ?? ('Status #' . $statusId)); + $statusLabel = htmlspecialchars((string)($statusesMap[$statusId] ?? ('Status #' . $statusId)), ENT_QUOTES, 'UTF-8'); + $statusColor = isset($statusColorsMap[$statusId]) ? $statusColorsMap[$statusId] : ''; + + if ($statusColor !== '') { + $textColor = $this->contrastTextColor($statusColor); + $statusHtml = '' . $statusLabel . ''; + } else { + $statusHtml = $statusLabel; + } $rows[] = [ 'lp' => $lp++ . '.', @@ -86,13 +96,13 @@ class ShopOrderController 'paid' => ((int)($item['paid'] ?? 0) === 1) ? '' : '', - 'status' => htmlspecialchars($statusLabel, ENT_QUOTES, 'UTF-8'), + 'status' => $statusHtml, 'summary' => number_format((float)($item['summary'] ?? 0), 2, '.', ' ') . ' zł', 'client' => htmlspecialchars((string)($item['client'] ?? ''), ENT_QUOTES, 'UTF-8') . ' | zamówienia: ' . (int)($item['total_orders'] ?? 0) . '', 'address' => (string)($item['address'] ?? ''), 'order_email' => (string)($item['order_email'] ?? ''), 'client_phone' => (string)($item['client_phone'] ?? ''), - 'transport' => (string)($item['transport'] ?? ''), + 'transport' => $this->sanitizeInlineHtml((string)($item['transport'] ?? '')), 'payment_method' => (string)($item['payment_method'] ?? ''), '_actions' => [ [ @@ -127,7 +137,7 @@ class ShopOrderController ['key' => 'address', 'label' => 'Adres', 'sortable' => false], ['key' => 'order_email', 'sort_key' => 'order_email', 'label' => 'Email', 'sortable' => true], ['key' => 'client_phone', 'sort_key' => 'client_phone', 'label' => 'Telefon', 'sortable' => true], - ['key' => 'transport', 'sort_key' => 'transport', 'label' => 'Dostawa', 'sortable' => true], + ['key' => 'transport', 'sort_key' => 'transport', 'label' => 'Dostawa', 'sortable' => true, 'raw' => true], ['key' => 'payment_method', 'sort_key' => 'payment_method', 'label' => 'Płatność', 'sortable' => true], ], $rows, @@ -361,4 +371,26 @@ class ShopOrderController return date('Y-m-d H:i', $ts); } -} \ No newline at end of file + + private function contrastTextColor(string $hex): string + { + $hex = ltrim($hex, '#'); + if (strlen($hex) === 3) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + if (strlen($hex) !== 6) { + return '#fff'; + } + $r = hexdec(substr($hex, 0, 2)); + $g = hexdec(substr($hex, 2, 2)); + $b = hexdec(substr($hex, 4, 2)); + $luminance = (0.299 * $r + 0.587 * $g + 0.114 * $b) / 255; + return $luminance > 0.5 ? '#000' : '#fff'; + } + + private function sanitizeInlineHtml(string $html): string + { + $html = strip_tags($html, ''); + return preg_replace('/<(b|strong|i|em)\s[^>]*>/i', '<$1>', $html); + } +} diff --git a/autoload/admin/Controllers/ShopPaymentMethodController.php b/autoload/admin/Controllers/ShopPaymentMethodController.php index 4255acd..6ae73dc 100644 --- a/autoload/admin/Controllers/ShopPaymentMethodController.php +++ b/autoload/admin/Controllers/ShopPaymentMethodController.php @@ -182,6 +182,8 @@ class ShopPaymentMethodController 'description' => (string)($paymentMethod['description'] ?? ''), 'status' => (int)($paymentMethod['status'] ?? 0), 'apilo_payment_type_id' => $paymentMethod['apilo_payment_type_id'] ?? '', + 'min_order_amount' => $paymentMethod['min_order_amount'] ?? '', + 'max_order_amount' => $paymentMethod['max_order_amount'] ?? '', ]; $fields = [ @@ -203,6 +205,16 @@ class ShopPaymentMethodController 'tab' => 'settings', 'rows' => 5, ]), + FormField::number('min_order_amount', [ + 'label' => 'Min. kwota zamowienia (PLN)', + 'tab' => 'settings', + 'step' => 0.01, + ]), + FormField::number('max_order_amount', [ + 'label' => 'Maks. kwota zamowienia (PLN)', + 'tab' => 'settings', + 'step' => 0.01, + ]), FormField::select('apilo_payment_type_id', [ 'label' => 'Typ platnosci Apilo', 'tab' => 'settings', diff --git a/autoload/admin/Controllers/ShopProductController.php b/autoload/admin/Controllers/ShopProductController.php index 6524a91..bdc288a 100644 --- a/autoload/admin/Controllers/ShopProductController.php +++ b/autoload/admin/Controllers/ShopProductController.php @@ -95,7 +95,7 @@ class ShopProductController . '' . $name . ' ' . 'duplikuj' . '' - . '' . $categories . '' + . '' . $categories . '' . 'SKU: ' . $sku . ', EAN: ' . $ean . ''; $priceHtml = ''; @@ -140,6 +140,7 @@ class ShopProductController } } + $rows[] = $row; } @@ -547,6 +548,11 @@ class ShopProductController FormAction::cancel( $backUrl ), ]; + if ( $productId > 0 ) { + $previewUrl = $this->repository->getProductUrl( $productId ); + $actions[] = FormAction::preview( $previewUrl ); + } + return new FormEditViewModel( 'product-edit', $title, @@ -683,7 +689,7 @@ class ShopProductController foreach ( $products as $key => $val ) { if ( (int) $key !== $productId ) { $selected = ( is_array( $product['products_related'] ?? null ) && in_array( $key, $product['products_related'] ) ) ? ' selected' : ''; - $html .= ''; + $html .= ''; } } $html .= ''; diff --git a/autoload/admin/Controllers/UpdateController.php b/autoload/admin/Controllers/UpdateController.php index 6e6c77d..edc19cd 100644 --- a/autoload/admin/Controllers/UpdateController.php +++ b/autoload/admin/Controllers/UpdateController.php @@ -14,9 +14,12 @@ class UpdateController public function main_view(): string { + $logContent = @file_get_contents( '../libraries/update_log.txt' ); + return \Shared\Tpl\Tpl::view( 'update/main-view', [ 'ver' => \Shared\Helpers\Helpers::get_version(), 'new_ver' => \Shared\Helpers\Helpers::get_new_version(), + 'log' => $logContent ?: '', ] ); } @@ -46,4 +49,17 @@ class UpdateController echo json_encode( $response ); exit; } + + public function checkUpdate(): void + { + \Shared\Helpers\Helpers::set_session( 'new-version', null ); + $newVer = \Shared\Helpers\Helpers::get_new_version(); + $curVer = \Shared\Helpers\Helpers::get_version(); + + echo json_encode( [ + 'has_update' => $newVer > $curVer, + 'new_ver' => $newVer, + ] ); + exit; + } } diff --git a/autoload/admin/ViewModels/Forms/FormAction.php b/autoload/admin/ViewModels/Forms/FormAction.php index 98d6529..afd147f 100644 --- a/autoload/admin/ViewModels/Forms/FormAction.php +++ b/autoload/admin/ViewModels/Forms/FormAction.php @@ -56,6 +56,22 @@ class FormAction ); } + /** + * Predefiniowana akcja Podgląd (otwiera w nowej karcie) + */ + public static function preview(string $url, string $label = 'Podgląd'): self + { + return new self( + 'preview', + $label, + $url, + null, + 'btn btn-info', + 'link', + ['target' => '_blank'] + ); + } + /** * Predefiniowana akcja Anuluj */ diff --git a/autoload/api/ApiRouter.php b/autoload/api/ApiRouter.php index 75f0ab0..3bc8d11 100644 --- a/autoload/api/ApiRouter.php +++ b/autoload/api/ApiRouter.php @@ -46,7 +46,7 @@ class ApiRouter } $controller->$action(); - } catch (\Exception $e) { + } catch (\Throwable $e) { self::sendError('INTERNAL_ERROR', 'Internal server error', 500); } } @@ -87,18 +87,22 @@ class ApiRouter $settingsRepo = new \Domain\Settings\SettingsRepository($db); $productRepo = new \Domain\Product\ProductRepository($db); $transportRepo = new \Domain\Transport\TransportRepository($db); - $service = new \Domain\Order\OrderAdminService($orderRepo, $productRepo, $settingsRepo, $transportRepo); + $cronJobRepo = new \Domain\CronJob\CronJobRepository($db); + $service = new \Domain\Order\OrderAdminService($orderRepo, $productRepo, $settingsRepo, $transportRepo, $cronJobRepo); return new Controllers\OrdersApiController($service, $orderRepo); }, 'products' => function () use ($db) { $productRepo = new \Domain\Product\ProductRepository($db); - return new Controllers\ProductsApiController($productRepo); + $attrRepo = new \Domain\Attribute\AttributeRepository($db); + return new Controllers\ProductsApiController($productRepo, $attrRepo); }, 'dictionaries' => function () use ($db) { $statusRepo = new \Domain\ShopStatus\ShopStatusRepository($db); $transportRepo = new \Domain\Transport\TransportRepository($db); $paymentRepo = new \Domain\PaymentMethod\PaymentMethodRepository($db); - return new Controllers\DictionariesApiController($statusRepo, $transportRepo, $paymentRepo); + $attrRepo = new \Domain\Attribute\AttributeRepository($db); + $producerRepo = new \Domain\Producer\ProducerRepository($db); + return new Controllers\DictionariesApiController($statusRepo, $transportRepo, $paymentRepo, $attrRepo, $producerRepo); }, ]; } diff --git a/autoload/api/Controllers/DictionariesApiController.php b/autoload/api/Controllers/DictionariesApiController.php index 5ed6788..ff21ddc 100644 --- a/autoload/api/Controllers/DictionariesApiController.php +++ b/autoload/api/Controllers/DictionariesApiController.php @@ -2,6 +2,8 @@ namespace api\Controllers; use api\ApiRouter; +use Domain\Attribute\AttributeRepository; +use Domain\Producer\ProducerRepository; use Domain\ShopStatus\ShopStatusRepository; use Domain\Transport\TransportRepository; use Domain\PaymentMethod\PaymentMethodRepository; @@ -11,15 +13,21 @@ class DictionariesApiController private $statusRepo; private $transportRepo; private $paymentRepo; + private $attrRepo; + private $producerRepo; public function __construct( ShopStatusRepository $statusRepo, TransportRepository $transportRepo, - PaymentMethodRepository $paymentRepo + PaymentMethodRepository $paymentRepo, + AttributeRepository $attrRepo, + ProducerRepository $producerRepo ) { $this->statusRepo = $statusRepo; $this->transportRepo = $transportRepo; $this->paymentRepo = $paymentRepo; + $this->attrRepo = $attrRepo; + $this->producerRepo = $producerRepo; } public function statuses(): void @@ -79,4 +87,122 @@ class DictionariesApiController ApiRouter::sendSuccess($result); } + + public function attributes(): void + { + if (!ApiRouter::requireMethod('GET')) { + return; + } + + $attributes = $this->attrRepo->listForApi(); + + ApiRouter::sendSuccess($attributes); + } + + public function ensure_attribute(): void + { + if (!ApiRouter::requireMethod('POST')) { + return; + } + + $body = ApiRouter::getJsonBody(); + if (!is_array($body)) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400); + return; + } + + $name = trim((string) ($body['name'] ?? '')); + if ($name === '') { + ApiRouter::sendError('BAD_REQUEST', 'Missing name', 400); + return; + } + + $type = (int) ($body['type'] ?? 0); + $lang = trim((string) ($body['lang'] ?? 'pl')); + if ($lang === '') { + $lang = 'pl'; + } + + $result = $this->attrRepo->ensureAttributeForApi($name, $type, $lang); + if (!is_array($result) || (int) ($result['id'] ?? 0) <= 0) { + ApiRouter::sendError('INTERNAL_ERROR', 'Failed to ensure attribute', 500); + return; + } + + ApiRouter::sendSuccess([ + 'id' => (int) ($result['id'] ?? 0), + 'created' => !empty($result['created']), + ]); + } + + public function ensure_attribute_value(): void + { + if (!ApiRouter::requireMethod('POST')) { + return; + } + + $body = ApiRouter::getJsonBody(); + if (!is_array($body)) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400); + return; + } + + $attributeId = (int) ($body['attribute_id'] ?? 0); + if ($attributeId <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid attribute_id', 400); + return; + } + + $name = trim((string) ($body['name'] ?? '')); + if ($name === '') { + ApiRouter::sendError('BAD_REQUEST', 'Missing name', 400); + return; + } + + $lang = trim((string) ($body['lang'] ?? 'pl')); + if ($lang === '') { + $lang = 'pl'; + } + + $result = $this->attrRepo->ensureAttributeValueForApi($attributeId, $name, $lang); + if (!is_array($result) || (int) ($result['id'] ?? 0) <= 0) { + ApiRouter::sendError('INTERNAL_ERROR', 'Failed to ensure attribute value', 500); + return; + } + + ApiRouter::sendSuccess([ + 'id' => (int) ($result['id'] ?? 0), + 'created' => !empty($result['created']), + ]); + } + + public function ensure_producer(): void + { + if (!ApiRouter::requireMethod('POST')) { + return; + } + + $body = ApiRouter::getJsonBody(); + if (!is_array($body)) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400); + return; + } + + $name = trim((string) ($body['name'] ?? '')); + if ($name === '') { + ApiRouter::sendError('BAD_REQUEST', 'Missing name', 400); + return; + } + + $result = $this->producerRepo->ensureProducerForApi($name); + if ((int) ($result['id'] ?? 0) <= 0) { + ApiRouter::sendError('INTERNAL_ERROR', 'Failed to ensure producer', 500); + return; + } + + ApiRouter::sendSuccess([ + 'id' => (int) ($result['id'] ?? 0), + 'created' => !empty($result['created']), + ]); + } } diff --git a/autoload/api/Controllers/ProductsApiController.php b/autoload/api/Controllers/ProductsApiController.php index 5af613c..9b8c48b 100644 --- a/autoload/api/Controllers/ProductsApiController.php +++ b/autoload/api/Controllers/ProductsApiController.php @@ -2,15 +2,18 @@ namespace api\Controllers; use api\ApiRouter; +use Domain\Attribute\AttributeRepository; use Domain\Product\ProductRepository; class ProductsApiController { private $productRepo; + private $attrRepo; - public function __construct(ProductRepository $productRepo) + public function __construct(ProductRepository $productRepo, AttributeRepository $attrRepo) { $this->productRepo = $productRepo; + $this->attrRepo = $attrRepo; } public function list(): void @@ -25,6 +28,20 @@ class ProductsApiController 'promoted' => isset($_GET['promoted']) ? $_GET['promoted'] : '', ]; + // Attribute filters: attribute_{id}={value_id} + $attrFilters = []; + foreach ($_GET as $key => $value) { + if (strpos($key, 'attribute_') === 0) { + $attrId = (int)substr($key, 10); + if ($attrId > 0 && (int)$value > 0) { + $attrFilters[$attrId] = (int)$value; + } + } + } + if (!empty($attrFilters)) { + $filters['attributes'] = $attrFilters; + } + $sort = isset($_GET['sort']) ? $_GET['sort'] : 'id'; $sortDir = isset($_GET['sort_dir']) ? $_GET['sort_dir'] : 'DESC'; $page = max(1, (int)(isset($_GET['page']) ? $_GET['page'] : 1)); @@ -90,6 +107,11 @@ class ProductsApiController return; } + if (!is_numeric($body['price_brutto']) || (float)$body['price_brutto'] < 0) { + ApiRouter::sendError('BAD_REQUEST', 'price_brutto must be a non-negative number', 400); + return; + } + $formData = $this->mapApiToFormData($body); $productId = $this->productRepo->saveProduct($formData); @@ -139,6 +161,231 @@ class ProductsApiController ApiRouter::sendSuccess($updated); } + public function variants(): void + { + if (!ApiRouter::requireMethod('GET')) { + return; + } + + $id = (int)(isset($_GET['id']) ? $_GET['id'] : 0); + if ($id <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400); + return; + } + + $product = $this->productRepo->find($id); + if ($product === null) { + ApiRouter::sendError('NOT_FOUND', 'Product not found', 404); + return; + } + + if (!empty($product['parent_id'])) { + ApiRouter::sendError('BAD_REQUEST', 'Cannot get variants of a variant product', 400); + return; + } + + $variants = $this->productRepo->findVariantsForApi($id); + + // Available attributes for this product + $allAttributes = $this->attrRepo->listForApi(); + $usedAttrIds = []; + foreach ($variants as $variant) { + foreach ($variant['attributes'] as $a) { + $usedAttrIds[(int)$a['attribute_id']] = true; + } + } + + $availableAttributes = []; + foreach ($allAttributes as $attr) { + if (isset($usedAttrIds[$attr['id']])) { + $availableAttributes[] = $attr; + } + } + + ApiRouter::sendSuccess([ + 'product_id' => $id, + 'available_attributes' => $availableAttributes, + 'variants' => $variants, + ]); + } + + public function create_variant(): void + { + if (!ApiRouter::requireMethod('POST')) { + return; + } + + $parentId = (int)(isset($_GET['id']) ? $_GET['id'] : 0); + if ($parentId <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400); + return; + } + + $body = ApiRouter::getJsonBody(); + if ($body === null) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400); + return; + } + + if (empty($body['attributes']) || !is_array($body['attributes'])) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or empty attributes', 400); + return; + } + + $result = $this->productRepo->createVariantForApi($parentId, $body); + if ($result === null) { + ApiRouter::sendError('BAD_REQUEST', 'Cannot create variant: parent not found, is archived, is itself a variant, or combination already exists', 400); + return; + } + + $variant = $this->productRepo->findVariantForApi($result['id']); + + http_response_code(201); + echo json_encode([ + 'status' => 'ok', + 'data' => $variant !== null ? $variant : $result, + ], JSON_UNESCAPED_UNICODE); + } + + public function update_variant(): void + { + if (!ApiRouter::requireMethod('PUT')) { + return; + } + + $variantId = (int)(isset($_GET['id']) ? $_GET['id'] : 0); + if ($variantId <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400); + return; + } + + $body = ApiRouter::getJsonBody(); + if ($body === null) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400); + return; + } + + $success = $this->productRepo->updateVariantForApi($variantId, $body); + if (!$success) { + ApiRouter::sendError('NOT_FOUND', 'Variant not found', 404); + return; + } + + $variant = $this->productRepo->findVariantForApi($variantId); + ApiRouter::sendSuccess($variant); + } + + public function delete_variant(): void + { + if (!ApiRouter::requireMethod('DELETE')) { + return; + } + + $variantId = (int)(isset($_GET['id']) ? $_GET['id'] : 0); + if ($variantId <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400); + return; + } + + $success = $this->productRepo->deleteVariantForApi($variantId); + if (!$success) { + ApiRouter::sendError('NOT_FOUND', 'Variant not found', 404); + return; + } + + ApiRouter::sendSuccess(['id' => $variantId, 'deleted' => true]); + } + + public function upload_image(): void + { + if (!ApiRouter::requireMethod('POST')) { + return; + } + + $body = ApiRouter::getJsonBody(); + if ($body === null) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400); + return; + } + + $productId = (int)($body['id'] ?? 0); + if ($productId <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid product id', 400); + return; + } + + $product = $this->productRepo->find($productId); + if ($product === null) { + ApiRouter::sendError('NOT_FOUND', 'Product not found', 404); + return; + } + + $fileName = trim((string)($body['file_name'] ?? '')); + $base64 = (string)($body['content_base64'] ?? ''); + if ($fileName === '' || $base64 === '') { + ApiRouter::sendError('BAD_REQUEST', 'Missing file_name or content_base64', 400); + return; + } + + $binary = base64_decode($base64, true); + if ($binary === false) { + ApiRouter::sendError('BAD_REQUEST', 'Invalid content_base64 payload', 400); + return; + } + + $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($fileName)); + if ($safeName === '' || $safeName === null) { + $safeName = 'image_' . md5((string)microtime(true)) . '.jpg'; + } + + // api.php działa z rootu projektu (nie z admin/), więc ścieżka bez ../ + $baseDir = 'upload/product_images/product_' . $productId; + if (!is_dir($baseDir) && !mkdir($baseDir, 0775, true) && !is_dir($baseDir)) { + ApiRouter::sendError('INTERNAL_ERROR', 'Failed to create target directory', 500); + return; + } + + $targetPath = $baseDir . '/' . $safeName; + if (is_file($targetPath)) { + $name = pathinfo($safeName, PATHINFO_FILENAME); + $ext = pathinfo($safeName, PATHINFO_EXTENSION); + $targetPath = $baseDir . '/' . $name . '_' . substr(md5($safeName . microtime(true)), 0, 8) . ($ext !== '' ? '.' . $ext : ''); + } + + if (file_put_contents($targetPath, $binary) === false) { + ApiRouter::sendError('INTERNAL_ERROR', 'Failed to save image file', 500); + return; + } + + $src = '/upload/product_images/product_' . $productId . '/' . basename($targetPath); + $alt = (string)($body['alt'] ?? ''); + $position = isset($body['o']) ? (int)$body['o'] : null; + + $db = $GLOBALS['mdb'] ?? null; + if (!$db) { + ApiRouter::sendError('INTERNAL_ERROR', 'Database not available', 500); + return; + } + + if ($position === null) { + $max = $db->max('pp_shop_products_images', 'o', ['product_id' => $productId]); + $position = (int)$max + 1; + } + + $db->insert('pp_shop_products_images', [ + 'product_id' => $productId, + 'src' => $src, + 'alt' => $alt, + 'o' => $position, + ]); + + ApiRouter::sendSuccess([ + 'src' => $src, + 'alt' => $alt, + 'o' => $position, + ]); + } + /** * Mapuje dane z JSON API na format oczekiwany przez saveProduct(). * @@ -182,6 +429,11 @@ class ProductsApiController } } + // saveProduct() traktuje float 0.00 jako "puste", ale cena 0 musi pozostać jawnie ustawiona. + if (isset($d['price_brutto']) && is_numeric($d['price_brutto']) && (float)$d['price_brutto'] === 0.0) { + $d['price_brutto'] = '0'; + } + // String fields — direct mapping $stringFields = [ 'sku', 'ean', 'custom_label_0', 'custom_label_1', 'custom_label_2', @@ -246,6 +498,21 @@ class ProductsApiController $d['products_related'] = $body['products_related']; } + // Custom fields (Dodatkowe pola) + if (isset($body['custom_fields']) && is_array($body['custom_fields'])) { + $d['custom_field_name'] = []; + $d['custom_field_type'] = []; + $d['custom_field_required'] = []; + foreach ($body['custom_fields'] as $cf) { + if (!is_array($cf) || empty($cf['name'])) { + continue; + } + $d['custom_field_name'][] = (string)$cf['name']; + $d['custom_field_type'][] = !empty($cf['type']) ? (string)$cf['type'] : 'text'; + $d['custom_field_required'][] = !empty($cf['is_required']) ? 1 : 0; + } + } + return $d; } } diff --git a/autoload/front/App.php b/autoload/front/App.php index 0ad3486..001b9c7 100644 --- a/autoload/front/App.php +++ b/autoload/front/App.php @@ -177,9 +177,10 @@ class App 'ShopOrder' => function() { global $mdb; $orderRepo = new \Domain\Order\OrderRepository( $mdb ); + $cronJobRepo = new \Domain\CronJob\CronJobRepository( $mdb ); return new \front\Controllers\ShopOrderController( $orderRepo, - new \Domain\Order\OrderAdminService( $orderRepo ) + new \Domain\Order\OrderAdminService( $orderRepo, null, null, null, $cronJobRepo ) ); }, 'ShopProducer' => function() { diff --git a/autoload/front/Controllers/ShopBasketController.php b/autoload/front/Controllers/ShopBasketController.php index 526e15a..e166c7e 100644 --- a/autoload/front/Controllers/ShopBasketController.php +++ b/autoload/front/Controllers/ShopBasketController.php @@ -132,6 +132,11 @@ class ShopBasketController $attributes[] = $val; } + // Sort by attribute ID to match permutation_hash order (generated with ksort) + usort( $attributes, function ( $a, $b ) { + return (int) explode( '-', $a )[0] - (int) explode( '-', $b )[0]; + } ); + foreach( $values as $key => $val ) { if ( strpos( $key, 'custom_field' ) !== false ) @@ -372,7 +377,9 @@ class ShopBasketController 'transport_id' => \Shared\Helpers\Helpers::get_session( 'basket-transport-method-id' ), 'transport_methods' => \Shared\Tpl\Tpl::view( 'shop-basket/basket-transport-methods', [ 'transports_methods' => ( new \Domain\Transport\TransportRepository( $GLOBALS['mdb'] ) )->transportMethodsFront( $basket, $coupon ), - 'transport_id' => $basket_transport_method_id + 'transport_id' => $basket_transport_method_id, + 'free_delivery' => (float)($settings['free_delivery'] ?? 0), + 'basket_summary' => (float)\Domain\Basket\BasketCalculator::summaryPrice( $basket, $coupon ) ] ), 'payment_method_id' => $payment_method_id, 'basket_details' => \Shared\Tpl\Tpl::view( 'shop-basket/basket-details', [ @@ -387,6 +394,8 @@ class ShopBasketController private function jsonBasketResponse( $basket, $coupon, $lang_id, $basket_transport_method_id ) { + global $settings; + echo json_encode( [ 'basket' => \Shared\Tpl\Tpl::view( 'shop-basket/basket-details', [ 'basket' => $basket, @@ -398,7 +407,9 @@ class ShopBasketController 'products_count' => count( $basket ), 'transport_methods' => \Shared\Tpl\Tpl::view( 'shop-basket/basket-transport-methods', [ 'transports_methods' => ( new \Domain\Transport\TransportRepository( $GLOBALS['mdb'] ) )->transportMethodsFront( $basket, $coupon ), - 'transport_id' => $basket_transport_method_id + 'transport_id' => $basket_transport_method_id, + 'free_delivery' => (float)($settings['free_delivery'] ?? 0), + 'basket_summary' => (float)\Domain\Basket\BasketCalculator::summaryPrice( $basket, $coupon ) ] ) ] ); exit; diff --git a/autoload/front/Controllers/ShopProductController.php b/autoload/front/Controllers/ShopProductController.php index fc2fd24..5d7f2b1 100644 --- a/autoload/front/Controllers/ShopProductController.php +++ b/autoload/front/Controllers/ShopProductController.php @@ -80,16 +80,17 @@ class ShopProductController { global $lang_id; - $combination = ''; $selected_values = \Shared\Helpers\Helpers::get( 'selected_values' ); - foreach ( $selected_values as $value ) - { - $combination .= $value; - if ( $value != end( $selected_values ) ) - $combination .= '|'; + // Sort by attribute ID to match permutation_hash order (generated with ksort) + if ( is_array( $selected_values ) ) { + usort( $selected_values, function ( $a, $b ) { + return (int) explode( '-', $a )[0] - (int) explode( '-', $b )[0]; + } ); } + $combination = is_array( $selected_values ) ? implode( '|', $selected_values ) : ''; + $product_id = \Shared\Helpers\Helpers::get( 'product_id' ); $productRepo = new \Domain\Product\ProductRepository( $GLOBALS['mdb'] ); $product = $productRepo->findCached( $product_id, $lang_id ); @@ -102,6 +103,10 @@ class ShopProductController private static function getPermutation( $attributes ) { if ( !is_array( $attributes ) || !count( $attributes ) ) return null; + // Sort by attribute ID to match permutation_hash order (generated with ksort) + usort( $attributes, function ( $a, $b ) { + return (int) explode( '-', $a )[0] - (int) explode( '-', $b )[0]; + } ); return implode( '|', $attributes ); } diff --git a/autoload/temp/apilo-sync-queue.json b/autoload/temp/apilo-sync-queue.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/autoload/temp/apilo-sync-queue.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/config.php b/config.php index 8fce17f..0229148 100644 --- a/config.php +++ b/config.php @@ -19,4 +19,6 @@ $config['debug']['apilo'] = false; $config['trustmate']['enabled'] = true; $config['trustmate']['uid'] = '34eb36ba-c715-4cdc-8707-22376c9f14c7'; + +$config['cron_key'] = 'Gi7FzWtkry19hZ1BqT1LKEWfwokQpigh'; ?>