From 4cf7039759bce9ea346918bbe5e31dd4f45f2ac3 Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Fri, 27 Feb 2026 14:51:30 +0100 Subject: [PATCH] feat(cronjob): implement CronJobProcessor and CronJobRepository for job scheduling and processing - Added CronJobProcessor class to handle job creation and queue processing. - Implemented CronJobRepository for database interactions related to cron jobs. - Introduced CronJobType class to define job types, priorities, and statuses. - Created ApiloLogger for logging actions related to job processing. - Initialized apilo-sync-queue.json for job queue management. --- .../Domain/Attribute/AttributeRepository.php | 258 ++++- autoload/Domain/Basket/BasketCalculator.php | 9 +- autoload/Domain/CronJob/CronJobProcessor.php | 140 +++ autoload/Domain/CronJob/CronJobRepository.php | 248 +++++ autoload/Domain/CronJob/CronJobType.php | 81 ++ autoload/Domain/Integrations/ApiloLogger.php | 30 + .../Integrations/IntegrationsRepository.php | 195 +++- autoload/Domain/Order/OrderAdminService.php | 301 +++--- autoload/Domain/Order/OrderRepository.php | 28 +- .../PaymentMethod/PaymentMethodRepository.php | 29 +- .../Domain/Producer/ProducerRepository.php | 30 + autoload/Domain/Product/ProductRepository.php | 476 ++++++++- .../Domain/Settings/SettingsRepository.php | 1 + .../Domain/Transport/TransportRepository.php | 38 +- autoload/Domain/Update/UpdateRepository.php | 281 +++++- autoload/Shared/Tpl/Tpl.php | 5 + autoload/admin/App.php | 3 +- .../Controllers/IntegrationsController.php | 109 +++ .../Controllers/ProductArchiveController.php | 28 + .../admin/Controllers/SettingsController.php | 79 +- .../admin/Controllers/ShopOrderController.php | 44 +- .../ShopPaymentMethodController.php | 12 + .../Controllers/ShopProductController.php | 10 +- .../admin/Controllers/UpdateController.php | 16 + .../admin/ViewModels/Forms/FormAction.php | 16 + autoload/api/ApiRouter.php | 12 +- .../Controllers/DictionariesApiController.php | 128 ++- .../api/Controllers/ProductsApiController.php | 269 ++++- autoload/front/App.php | 3 +- .../Controllers/ShopBasketController.php | 15 +- .../Controllers/ShopProductController.php | 17 +- autoload/temp/apilo-sync-queue.json | 1 + config.php | 2 + cron.php | 926 ++++++++---------- 34 files changed, 3099 insertions(+), 741 deletions(-) create mode 100644 autoload/Domain/CronJob/CronJobProcessor.php create mode 100644 autoload/Domain/CronJob/CronJobRepository.php create mode 100644 autoload/Domain/CronJob/CronJobType.php create mode 100644 autoload/Domain/Integrations/ApiloLogger.php create mode 100644 autoload/temp/apilo-sync-queue.json 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 b8cefd3..81f3ea0 100644 --- a/config.php +++ b/config.php @@ -18,4 +18,6 @@ $config['debug']['apilo'] = false; $config['trustmate']['enabled'] = true; $config['trustmate']['uid'] = '9e2d3181-b56e-4d3d-a532-f96b09dc215f'; + +$config['cron_key'] = 'Gi7FzWtkry19hZ1BqT1LKEWfwokQpigh'; ?> diff --git a/cron.php b/cron.php index 0a4e56b..a6bed4f 100644 --- a/cron.php +++ b/cron.php @@ -1,6 +1,4 @@ 'utf8' ] ); -$settings = \front\factory\Settings::settings_details(); -$baselinker_settings = \front\factory\Shop::baselinker_settings(); -$apilo_settings = \admin\factory\Integrations::apilo_settings(); -$baselinker_settings = \admin\factory\Integrations::baselinker_settings(); -$sellasist_settings = \admin\factory\Integrations::sellasist_settings(); +// ========================================================================= +// Auth: cron endpoint protection +// ========================================================================= + +if ( php_sapi_name() !== 'cli' ) +{ + $cron_key = isset( $config['cron_key'] ) ? $config['cron_key'] : ''; + $provided_key = isset( $_GET['key'] ) ? $_GET['key'] : ''; + + if ( $cron_key === '' || $provided_key !== $cron_key ) + { + http_response_code( 403 ); + exit( 'Forbidden' ); + } +} + +// ========================================================================= +// Helper functions (used by handlers) +// ========================================================================= function parsePaczkomatAddress($input) { @@ -107,357 +125,99 @@ function getImageUrlById($id) { return isset($data['img']) ? $data['img'] : null; } -// pobieranie informacji o produkcie z sellasist.pl -if ( $sellasist_settings['enabled'] and $sellasist_settings['sync_products'] and $sellasist_settings['api_code'] ) +// ========================================================================= +// Shared dependencies +// ========================================================================= + +$settings = ( new \Domain\Settings\SettingsRepository( $mdb ) )->allSettings(); +$integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb ); +$orderRepo = new \Domain\Order\OrderRepository( $mdb ); +$cronRepo = new \Domain\CronJob\CronJobRepository( $mdb ); +$orderAdminService = new \Domain\Order\OrderAdminService( $orderRepo, null, null, null, $cronRepo ); + +$processor = new \Domain\CronJob\CronJobProcessor( $cronRepo ); + +// ========================================================================= +// One-time migration: JSON queue → DB +// ========================================================================= + +$json_queue_path = __DIR__ . '/temp/apilo-sync-queue.json'; +if ( file_exists( $json_queue_path ) ) { - if ( $result = $mdb -> query( 'SELECT id, sellasist_product_id, sellasist_get_data_date, sellasist_product_name FROM pp_shop_products WHERE sellasist_product_id IS NOT NULL AND sellasist_product_id != 0 AND ( sellasist_get_data_date IS NULL OR sellasist_get_data_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-10 minutes', time() ) ) . '\' ) ORDER BY sellasist_get_data_date ASC LIMIT 1' ) -> fetch( \PDO::FETCH_ASSOC ) ) + $json_content = file_get_contents( $json_queue_path ); + $json_queue = $json_content ? json_decode( $json_content, true ) : []; + + if ( is_array( $json_queue ) ) { - $url = "https://projectpro.sellasist.pl/api/v1/products/" . $result['sellasist_product_id'] . "/"; - - $api_code = \admin\factory\Integrations::sellasist_settings( 'api_code' ); - - $ch = curl_init( $url ); - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); - curl_setopt( $ch, CURLOPT_HTTPHEADER, [ - "apiKey: " . $api_code, - "accept: application/json" - ] ); - - $response = curl_exec( $ch ); - $responseData = json_decode( $response, true ); - - if ( $responseData['id'] ) + foreach ( $json_queue as $task ) { - $price_brutto = $responseData['price']; - $vat = $mdb -> get( 'pp_shop_products', 'vat', [ 'apilo_product_id' => $result['apilo_product_id'] ] ); - $price_netto = $price_brutto / ( ( 100 + $vat ) / 100 ); + $order_id = (int)($task['order_id'] ?? 0); + if ( $order_id <= 0 ) continue; - $mdb -> update( 'pp_shop_products', [ 'price_netto' => \S::normalize_decimal( $price_netto, 2 ), 'price_brutto' => \S::normalize_decimal( $price_brutto, 2 ) ], [ 'sellasist_product_id' => $result['sellasist_product_id'] ] ); - - \admin\factory\ShopProduct::update_product_combinations_prices( (int)$result['id'], $price_brutto, $vat, null ); - } - - // aktualizowanie stanu magazynowego - $mdb -> update( 'pp_shop_products', [ 'quantity' => $responseData['storages'][0]['quantity'] ], [ 'sellasist_product_id' => $result['sellasist_product_id'] ] ); - - $mdb -> update( 'pp_shop_products', [ 'sellasist_get_data_date' => date( 'Y-m-d H:i:s' ) ], [ 'sellasist_product_id' => $result['sellasist_product_id'] ] ); - echo '

Zaktualizowałem dane produktu ' . $result['sellasist_product_name'] . ' #' . $result['id'] . '

'; - } -} - -// pobieranie informacji o produkcie z apilo.com -if ( $apilo_settings['enabled'] and $apilo_settings['sync_products'] and $apilo_settings['access-token'] ) -{ - if ( $result = $mdb -> query( 'SELECT id, apilo_product_id, apilo_get_data_date, apilo_product_name FROM pp_shop_products WHERE apilo_product_id IS NOT NULL AND apilo_product_id != 0 AND ( apilo_get_data_date IS NULL OR apilo_get_data_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-10 minutes', time() ) ) . '\' ) ORDER BY apilo_get_data_date ASC LIMIT 1' ) -> fetch( \PDO::FETCH_ASSOC ) ) - { - $access_token = \admin\factory\Integrations::apilo_get_access_token(); - $url = 'https://projectpro.apilo.com/rest/api/warehouse/product/' . $result['apilo_product_id'] . '/'; - $curl = curl_init( $url ); - curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true ); - curl_setopt( $curl, CURLOPT_HTTPHEADER, [ - "Authorization: Bearer " . $access_token, - "Accept: application/json" - ] ); - - $response = curl_exec( $curl ); - $responseData = json_decode( $response, true ); - - // aktualizowanie stanu magazynowego - $mdb -> update( 'pp_shop_products', [ 'quantity' => $responseData['quantity'] ], [ 'apilo_product_id' => $result['apilo_product_id'] ] ); - // aktualizowanie ceny - $mdb -> update( 'pp_shop_products', [ 'price_netto' => \S::normalize_decimal( $responseData['priceWithoutTax'], 2 ), 'price_brutto' => \S::normalize_decimal( $responseData['priceWithTax'], 2 ) ], [ 'apilo_product_id' => $result['apilo_product_id'] ] ); - - $mdb -> update( 'pp_shop_products', [ 'apilo_get_data_date' => date( 'Y-m-d H:i:s' ) ], [ 'apilo_product_id' => $result['apilo_product_id'] ] ); - - echo '

Zaktualizowałem dane produktu (APILO) ' . $result['apilo_product_name'] . ' #' . $result['id'] . '

'; - } -} - -// synchronizacja cen apilo.com -if ( $apilo_settings['enabled'] and $apilo_settings['access-token'] and ( !$apilo_settings['pricelist_update_date'] or $apilo_settings['pricelist_update_date'] <= date( 'Y-m-d H:i:s', strtotime( '-1 hour', time() ) ) ) ) -{ - $access_token = \admin\factory\Integrations::apilo_get_access_token(); - - $url = 'https://projectpro.apilo.com/rest/api/warehouse/price-calculated/?price=' . $apilo_settings['pricelist_id']; - - $curl = curl_init( $url ); - curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true ); - curl_setopt( $curl, CURLOPT_CUSTOMREQUEST, "GET" ); - curl_setopt( $curl, CURLOPT_HTTPHEADER, [ - "Authorization: Bearer " . $access_token, - "Accept: application/json", - "Content-Type: application/json" - ] ); - - $response = curl_exec( $curl ); - $responseData = json_decode( $response, true ); - - if ( $responseData['list'] ) - { - foreach ( $responseData['list'] as $product_price ) - { - //aktualizowanie ceny - if ( $product_price['customPriceWithTax'] ) + if ( !empty($task['payment']) ) { - $price_brutto = $product_price['customPriceWithTax']; - $vat = $vat = $mdb -> get( 'pp_shop_products', 'vat', [ 'apilo_product_id' => $result['apilo_product_id'] ] ); - $price_netto = $price_brutto / ( ( 100 + $vat ) / 100 ); - - $mdb -> update( 'pp_shop_products', [ 'price_netto' => \S::normalize_decimal( $price_netto, 2 ), 'price_brutto' => \S::normalize_decimal( $price_brutto, 2 ) ], [ 'apilo_product_id' => $product_price['product'] ] ); - $product_id = $mdb -> get( 'pp_shop_products', 'id', [ 'apilo_product_id' => $product_price['product'] ] ); - - \admin\factory\ShopProduct::update_product_combinations_prices( (int)$product_id, $price_brutto, $vat, null ); + $cronRepo->enqueue( + \Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT, + ['order_id' => $order_id], + \Domain\CronJob\CronJobType::PRIORITY_HIGH, + 50 + ); + } + if ( isset($task['status']) && $task['status'] !== null && $task['status'] !== '' ) + { + $cronRepo->enqueue( + \Domain\CronJob\CronJobType::APILO_SYNC_STATUS, + ['order_id' => $order_id, 'status' => (int)$task['status']], + \Domain\CronJob\CronJobType::PRIORITY_HIGH, + 50 + ); } } } - \admin\factory\Integrations::apilo_settings_save( 'pricelist_update_date', date( 'Y-m-d H:i:s' ) ); - echo '

Zaktualizowałem ceny produktów (APILO)

'; + + unlink( $json_queue_path ); + echo '

Migracja kolejki JSON → DB zakończona

'; } -// pobieranie informachji o produkcie w tym: cen, cen promocyjnych, wagi, stanów magazynowych -if ( $baselinker_settings['enabled'] and $baselinker_settings['sync_products'] and $baselinker_settings['api_code'] ) -{ - if ( $result = $mdb -> query( 'SELECT id, baselinker_product_id, baselinker_get_data_date FROM pp_shop_products WHERE baselinker_product_id IS NOT NULL AND baselinker_product_id != 0 AND ( baselinker_get_data_date IS NULL OR baselinker_get_data_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-1440 minutes', time() ) ) . '\' ) ORDER BY baselinker_get_data_date ASC LIMIT 1' ) -> fetch( \PDO::FETCH_ASSOC ) ) - { - $methodParams = '{ - "inventory_id": "' . $baselinker_settings['inventory_id'] . '", - "products": [' . $result['baselinker_product_id'] . '] - }'; +// ========================================================================= +// Handler registration +// ========================================================================= - $apiParams = [ - "token" => $baselinker_settings['api_code'], - "method" => "getInventoryProductsData", - "parameters" => $methodParams - ]; +// 1. Apilo token keepalive (priorytet: krytyczny) +$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_TOKEN_KEEPALIVE, function($payload) use ($integrationsRepository) { + $apilo_settings = $integrationsRepository->getSettings('apilo'); + if ( !(int)($apilo_settings['enabled'] ?? 0) ) return true; // skip if disabled - $curl = curl_init( "https://api.baselinker.com/connector.php" ); - curl_setopt( $curl, CURLOPT_POST, 1 ); - curl_setopt( $curl, CURLOPT_POSTFIELDS, http_build_query( $apiParams ) ); - curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true ); - $response = json_decode( curl_exec( $curl ), true ); + $integrationsRepository->apiloKeepalive( 300 ); + echo '

Apilo token keepalive

'; + return true; +}); - $i = 0; +// 2. Apilo send order (priorytet: wysoki) +$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SEND_ORDER, function($payload) use ($mdb, $integrationsRepository, $orderAdminService, $config) { + $apilo_settings = $integrationsRepository->getSettings('apilo'); + if ( !$apilo_settings['enabled'] || !$apilo_settings['sync_orders'] || !$apilo_settings['access-token'] || $apilo_settings['sync_orders_date_start'] > date('Y-m-d H:i:s') ) return true; - if ( $response['status'] == 'SUCCESS' and count( $response['products'] ) ) - { - foreach ( $response['products'] as $baselinker_product_id => $baselinker_product ) - { - // aktualizowanie ceny - if ( $vat = $mdb -> get( 'pp_shop_products', 'vat', [ 'baselinker_product_id' => $baselinker_product_id ] ) ) - { - $price_brutto = $baselinker_product['prices'][$baselinker_settings['price_group']]; - $price_netto = $baselinker_product['prices'][$baselinker_settings['price_group']] / ( ( 100 + $vat ) / 100 ); + $orders = $mdb->select( 'pp_shop_orders', '*', [ 'AND' => [ 'apilo_order_id' => null, 'date_order[>=]' => $apilo_settings['sync_orders_date_start'] ], 'ORDER' => [ 'date_order' => 'ASC' ], 'LIMIT' => 1 ] ); + if ( empty($orders) ) return true; - $price_brutto_promo = $baselinker_product['prices'][ $baselinker_settings['price_group_promo'] ]; - - if ( $price_brutto_promo and $price_brutto_promo != $price_brutto ) - { - $price_netto_promo = $baselinker_product['prices'][$baselinker_settings['price_group_promo']] / ( ( 100 + $vat ) / 100 ); - $mdb -> update( 'pp_shop_products', [ 'price_netto_promo' => \S::normalize_decimal( $price_netto_promo, 2 ), 'price_brutto_promo' => \S::normalize_decimal( $price_brutto_promo, 2 ) ], [ 'baselinker_product_id' => $baselinker_product_id ] ); - } - else - $mdb -> update( 'pp_shop_products', [ 'price_netto_promo' => null, 'price_brutto_promo' => null ], [ 'baselinker_product_id' => $baselinker_product_id ] ); - - $mdb -> update( 'pp_shop_products', [ 'price_netto' => \S::normalize_decimal( $price_netto, 2 ), 'price_brutto' => \S::normalize_decimal( $price_brutto, 2 ) ], [ 'baselinker_product_id' => $baselinker_product_id ] ); - - $product_id = $mdb -> get( 'pp_shop_products', 'id', [ 'baselinker_product_id' => $baselinker_product_id ] ); - $vat = $mdb -> get( 'pp_shop_products', 'vat', [ 'baselinker_product_id' => $baselinker_product_id ] ); - - \admin\factory\ShopProduct::update_product_combinations_prices( (int)$product_id, $price_brutto, $vat, $price_brutto_promo ); - } - - // aktualizowanie wagi - $mdb -> update( 'pp_shop_products', [ 'weight' => $baselinker_product['weight'] ], [ 'baselinker_product_id' => $baselinker_product_id ] ); - - // aktualizowanie stanu magazynowego - $mdb -> update( 'pp_shop_products', [ 'quantity' => $baselinker_product['stock'][$baselinker_settings['stock_id']] ], [ 'baselinker_product_id' => $baselinker_product_id ] ); - - $mdb -> update( 'pp_shop_products', [ 'baselinker_get_data_date' => date( 'Y-m-d H:i:s' ) ], [ 'baselinker_product_id' => $baselinker_product_id ] ); - - echo '

Zaktualizowałem dane produktu ' . $baselinker_product['text_fields']['name'] . ' #' . $result['id'] . '

'; - } - \S::clear_redis_cache(); - } - else - { - $mdb -> update( 'pp_shop_products', [ 'baselinker_get_data_date' => date( 'Y-m-d H:i:s' ) ], [ 'baselinker_product_id' => $baselinker_product_id ] ); - echo '

Z powodu błędu pominąłem produkt o ID: ' . $result['id'] . '

'; - } - } -} - -// sprawdzanie statusów zamówień w sellasist.pl jeżeli zamówienie nie jest zrealizowane, anulowane lub nieodebrane -if ( $sellasist_settings['enabled'] and $sellasist_settings['sync_orders'] and $sellasist_settings['api_code'] and $sellasist_settings['sync_orders_date_start'] <= date( 'Y-m-d H:i:s' ) ) -{ - $order = $mdb -> query( 'SELECT id, sellasist_order_id, sellasist_order_status_date, number FROM pp_shop_orders WHERE sellasist_order_id IS NOT NULL AND sellasist_order_id != 0 AND ( status != 6 AND status != 8 AND status != 9 ) AND ( sellasist_order_status_date IS NULL OR sellasist_order_status_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-30 minutes', time() ) ) . '\' ) ORDER BY sellasist_order_status_date ASC LIMIT 1' ) -> fetch( \PDO::FETCH_ASSOC ); - if ( $order['sellasist_order_id'] ) - { - $url = "https://projectpro.sellasist.pl/api/v1/orders/" . $order['sellasist_order_id'] . "/"; - - $api_code = \admin\factory\Integrations::sellasist_settings( 'api_code' ); - - $ch = curl_init( $url ); - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); - curl_setopt( $ch, CURLOPT_HTTPHEADER, [ - "apiKey: " . $api_code, - "accept: application/json" - ] ); - - $response = curl_exec( $ch ); - $responseData = json_decode( $response, true ); - - if ( $responseData['id'] and $responseData['status']['id'] ) { - $shop_status_id = \front\factory\ShopStatuses::get_shop_status_by_integration_status_id( 'sellasist', $responseData['status']['id'] ); - $mdb -> update( 'pp_shop_orders', [ 'status' => $shop_status_id, 'sellasist_order_status_date' => date( 'Y-m-d H:i:s' ) ], [ 'id' => $order['id'] ] ); - echo '

Zaktualizowałem status zamówienia ' . $order['number'] . '

'; - } - } -} - -// wysyłanie zamówień do sellasist.pl -if ( $sellasist_settings['enabled'] and $sellasist_settings['sync_orders'] and $sellasist_settings['api_code'] and $sellasist_settings['sync_orders_date_start'] <= date( 'Y-m-d H:i:s' ) ) -{ - $orders = $mdb -> select( 'pp_shop_orders', '*', [ 'AND' => [ 'sellasist_order_id' => null, 'date_order[<=]' => date( 'Y-m-d H:i:s', strtotime( '-1 minutes', time() ) ), 'date_order[>=]' => $sellasist_settings['sync_orders_date_start'] ], 'ORDER' => [ 'date_order' => 'ASC' ], 'LIMIT' => 1 ] ); foreach ( $orders as $order ) { - $z = 0; - - $products = $mdb -> select( 'pp_shop_order_products', '*', [ 'order_id' => $order['id'] ] ); + $products = $mdb->select( 'pp_shop_order_products', '*', [ 'order_id' => $order['id'] ] ); + $productRepo = new \Domain\Product\ProductRepository( $mdb ); $products_array = []; + $order_message = ''; foreach ( $products as $product ) { - $json_data['carts'][] = [ - 'id' => $product['product_id'], - 'product_id' => \front\factory\ShopProduct::get_sellasist_product_id( $product['product_id'] ), - 'name' => $product['name'], - 'quantity' => $product['quantity'], - 'price' => $product['price_brutto_promo'] ? $product['price_brutto_promo'] : $product['price_brutto'], - 'message' => strip_tags( $product['attributes'] ) . ' | ' . $product['message'] - ]; - } - - $json_data['id'] = $order['id']; - $json_data['currency'] = 'pln'; - $json_data['payment_status'] = $order['paid'] ? 'paid' : 'unpaid'; - $json_data['paid'] = $order['paid'] ? str_replace( ',', '.', $order['summary'] ) : 0; - $json_data['status'] = \front\factory\ShopStatuses::get_sellasist_status_id( $order['status'] ); - $json_data['email'] = $order['client_email']; - // date - $json_data['date'] = date( 'Y-m-d H:i:s', strtotime( $order['date_order'] ) ); - // shipment_price - $json_data['shipment_price'] = $order['transport_cost']; - // payment_id - $json_data['payment_id'] = \front\factory\ShopPaymentMethod::get_sellasist_payment_method_id( $order['payment_method_id'] ); - // payment_name - $json_data['payment_name'] = $order['payment_method']; - // shipment_id - $json_data['shipment_id'] = \front\factory\ShopTransport::get_sellasist_transport_id( $order['transport_id'] ); - // shipment_name - $json_data['shipment_name'] = strip_tags( $order['transport'] ); - // invoice - $json_data['invoice'] = 0; - // comment - $json_data['comment'] = $order['message']; - // bill_address - $json_data['bill_address'] = [ - 'name' => $order['client_name'], - 'surname' => $order['client_surname'], - 'street' => $order['client_street'], - 'city' => $order['client_city'], - 'postcode' => $order['client_postal_code'], - 'phone' => $order['client_phone'], - 'email' => $order['client_email'], - 'country' => [ - 'id' => 170, - 'name' => 'Poland', - 'code' => 'PL' - ] - ]; - // shipment_address - $json_data['shipment_address'] = [ - 'name' => $order['client_name'], - 'surname' => $order['client_surname'], - 'street' => $order['client_street'], - 'city' => $order['client_city'], - 'postcode' => $order['client_postal_code'], - 'phone' => $order['client_phone'], - 'email' => $order['client_email'], - 'country' => [ - 'id' => 170, - 'name' => 'Poland', - 'code' => 'PL' - ] - ]; - - // pickup_point - if ( $order['inpost_paczkomat'] ) - { - $pickup = explode( ' | ', $order['inpost_paczkomat'] ); - $pickup_code = $pickup[0]; - $pickup_address = $pickup[1]; - - $json_data['pickup_point'] = [ - 'code' => $pickup_code, - 'type' => 'inpost', - 'address' => $pickup_address - ]; - } - - // URL docelowe - $url = "https://projectpro.sellasist.pl/api/v1/orders"; - - // Nagłówki - $headers = array( - "accept: application/json", - "apiKey: " . $sellasist_settings['api_code'], - "Content-Type: application/json" - ); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_POST, 1); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($json_data) ); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - $response = curl_exec($ch); - if (curl_errno($ch)) { - echo 'Błąd cURL: ' . curl_error($ch); - } - curl_close($ch); - $response = json_decode( $response, true ); - - if ( $response['status'] == 'exist' ) { - - $mdb -> update( 'pp_shop_orders', [ 'sellasist_order_id' => $response['order_id'] ], [ 'id' => $order['id'] ] ); - - } else if ( $response['id'] ) { - - $mdb -> update( 'pp_shop_orders', [ 'sellasist_order_id' => $response['id'] ], [ 'id' => $order['id'] ] ); - echo '

Wysłałem zamówienie do sellasist.pl

'; - - } - } -} - -// wysyłanie zamówień do apilo -if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_settings['access-token'] and $apilo_settings['sync_orders_date_start'] <= date( 'Y-m-d H:i:s' ) ) -{ - $orders = $mdb -> select( 'pp_shop_orders', '*', [ 'AND' => [ 'apilo_order_id' => null, 'date_order[>=]' => $apilo_settings['sync_orders_date_start'] ], 'ORDER' => [ 'date_order' => 'ASC' ], 'LIMIT' => 1 ] ); - foreach ( $orders as $order ) - { - $products = $mdb -> select( 'pp_shop_order_products', '*', [ 'order_id' => $order['id'] ] ); - $products_array = []; - foreach ( $products as $product ) - { - $sku = \front\factory\ShopProduct::get_product_sku( $product['product_id'], true ); + $sku = $productRepo->getSkuWithFallback( (int)$product['product_id'], true ); $products_array[] = [ 'idExternal' => $product['product_id'], - 'ean' => \front\factory\ShopProduct::get_product_ean( $product['product_id'], true ), + 'ean' => $productRepo->getEanWithFallback( (int)$product['product_id'], true ), 'sku' => $sku ? $sku : md5( $product['product_id'] ), 'originalName' => $product['name'], - 'originalPriceWithTax' => $product['price_brutto_promo'] ? str_replace( ',', '.', $product['price_brutto_promo'] ) : str_replace( ',', '.', $product['price_brutto'] ), - 'originalPriceWithoutTax' => $product['price_brutto_promo'] ? str_replace( ',', '.', round( $product['price_brutto_promo'] / ( 1 + $product['vat']/100 ), 2 ) ) : str_replace( ',', '.', round( $product['price_brutto'] / ( 1 + $product['vat']/100 ), 2 ) ), + 'originalPriceWithTax' => (float)$product['price_brutto_promo'] > 0 ? str_replace( ',', '.', $product['price_brutto_promo'] ) : str_replace( ',', '.', $product['price_brutto'] ), + 'originalPriceWithoutTax' => (float)$product['price_brutto_promo'] > 0 ? str_replace( ',', '.', round( $product['price_brutto_promo'] / ( 1 + $product['vat']/100 ), 2 ) ) : str_replace( ',', '.', round( $product['price_brutto'] / ( 1 + $product['vat']/100 ), 2 ) ), 'quantity' => $product['quantity'], 'tax' => number_format( $product['vat'], 2, '.', '' ), 'status' => 1, @@ -481,11 +241,9 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se $order_message .= '
'; } - //TODO: ostatnio był problem kiedy wiadomość miała mniej 1024 znaki ale zawierała przeniesienie tekstu '
' i do tego jeszcze miała emoji. Wtedy APILO tego nie przepuszczał. if ( strlen( $order_message ) > 850 ) $order_message = '

Wiadomość do zamówienia była zbyt długa. Sprawdź szczegóły w panelu sklepu

'; - // add transport as product $products_array[] = [ 'idExternal' => '', 'ean' => null, @@ -500,10 +258,26 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se 'media' => null ]; - $access_token = \admin\factory\Integrations::apilo_get_access_token(); + $has_priced_products = false; + foreach ( $products_array as $pa ) + { + if ( $pa['type'] == 1 && (float)$pa['originalPriceWithTax'] > 0 ) + { + $has_priced_products = true; + break; + } + } + if ( !$has_priced_products ) + { + \Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Pominięto zamówienie - wszystkie produkty mają cenę 0.00', [ 'products' => $products_array ] ); + \Shared\Helpers\Helpers::send_email( 'biuro@project-pro.pl', 'Apilo: zamówienie #' . $order['id'] . ' ma zerowe ceny produktów', 'Zamówienie #' . $order['id'] . ' nie zostało wysłane do Apilo, ponieważ wszystkie produkty mają cenę 0.00 PLN. Sprawdź zamówienie w panelu sklepu.' ); + $mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => -2 ], [ 'id' => $order['id'] ] ); + echo '

Pominięto zamówienie #' . $order['id'] . ' - zerowe ceny produktów

'; + continue; + } + $access_token = $integrationsRepository->apiloGetAccessToken(); $order_date = new DateTime( $order['date_order'] ); - $paczkomatData = parsePaczkomatAddress( $order['inpost_paczkomat'] ); $orlenPointData = parseOrlenAddress( $order['orlen_point'] ); @@ -540,18 +314,18 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se if ( $order['transport_id'] == 9 and $order['orlen_point'] == null ) { - \S::send_email( 'biuro@project-pro.pl', 'Błąd integracji APILO - brak punktu ORLEN PACZKA', 'W zamówieniu #' . $order['id'] . ' wybrano dostawę ORLEN PACZKA, ale nie podano punktu odbioru. Proszę o uzupełnienie danych w panelu sklepu.' ); + \Shared\Helpers\Helpers::send_email( 'biuro@project-pro.pl', 'Błąd integracji APILO - brak punktu ORLEN PACZKA', 'W zamówieniu #' . $order['id'] . ' wybrano dostawę ORLEN PACZKA, ale nie podano punktu odbioru. Proszę o uzupełnienie danych w panelu sklepu.' ); } $postData = [ 'idExternal' => $order['id'], 'isInvoice' => $order['firm_name'] ? true : false, 'customerLogin' => $order['client_email'], - 'paymentType' => (int)\front\factory\ShopPaymentMethod::get_apilo_payment_method_id( $order['payment_method_id'] ), + 'paymentType' => (int)( new \Domain\PaymentMethod\PaymentMethodRepository( $mdb ) )->getApiloPaymentTypeId( (int)$order['payment_method_id'] ), 'originalCurrency' => 'PLN', 'originalAmountTotalWithTax' => str_replace( ',', '.', $order['summary'] ), 'orderItems' => $products_array, - 'orderedAt' => $order_date -> format('Y-m-d\TH:i:s\Z'), + 'orderedAt' => $order_date->format('Y-m-d\TH:i:s\Z'), 'addressCustomer' => [ 'name' => $order['client_name'] . ' ' . $order['client_surname'], 'phone' => $order['client_phone'], @@ -568,12 +342,12 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se 'city' => $city, 'zipCode' => $postal_code ], - 'carrierAccount' => (int)\front\factory\ShopTransport::get_apilo_carrier_account_id( $order['transport_id'] ), + 'carrierAccount' => (int)( new \Domain\Transport\TransportRepository( $mdb ) )->getApiloCarrierAccountId( (int)$order['transport_id'] ), 'orderNotes' => [ [ 'type' => 1, 'comment' => 'Wiadomość do zamówienia:
' . $order['message'] . '

' . $order_message ] ], - 'status' => (int)\front\factory\ShopStatuses::get_apilo_status_id( $order['status'] ), + 'status' => (int)( new \Domain\ShopStatus\ShopStatusRepository( $mdb ) )->getApiloStatusId( (int)$order['status'] ), 'platformId' => $apilo_settings['platform-id'] ]; @@ -586,7 +360,6 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se $postData['addressInvoice']['companyTaxNumber'] = $order['firm_nip']; } - // jeżeli paczkomat if ( $order['inpost_paczkomat'] ) { $postData['addressDelivery']['parcelName'] = $order['inpost_paczkomat'] ? 'Paczkomat: ' . $order['inpost_paczkomat'] : null; @@ -606,7 +379,6 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se } } - // jeżeli orlen paczka if ( $order['orlen_point'] ) { $postData['addressDelivery']['parcelName'] = $order['orlen_point'] ? 'Automat ORLEN ' . $order['orlen_point'] : null; @@ -624,17 +396,15 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se $postData['addressDelivery']['zipCode'] = $postalCode; $postData['addressDelivery']['city'] = $city; } - } if ( $order['paid'] ) { $payment_date = new DateTime( $order['date_order'] ); - $postData['orderPayments'][] = [ 'amount' => str_replace( ',', '.', $order['summary'] ), - 'paymentDate' => $payment_date -> format('Y-m-d\TH:i:s\Z'), - 'type' => \front\factory\ShopPaymentMethod::get_apilo_payment_method_id( $order['payment_method_id'] ) + 'paymentDate' => $payment_date->format('Y-m-d\TH:i:s\Z'), + 'type' => ( new \Domain\PaymentMethod\PaymentMethodRepository( $mdb ) )->getApiloPaymentTypeId( (int)$order['payment_method_id'] ) ]; } @@ -651,53 +421,225 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); $response = curl_exec( $ch ); if (curl_errno( $ch ) ) { - echo 'Błąd cURL: ' . curl_error( $ch ); + $curl_error_send = curl_error( $ch ); + \Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Błąd cURL przy wysyłaniu zamówienia: ' . $curl_error_send, [ 'curl_error' => $curl_error_send ] ); + echo 'Błąd cURL: ' . $curl_error_send; } + $http_code_send = (int)curl_getinfo( $ch, CURLINFO_HTTP_CODE ); curl_close( $ch ); $response = json_decode( $response, true ); - if ( $config['debug']['apilo'] ) + if ( isset($config['debug']['apilo']) && $config['debug']['apilo'] ) { file_put_contents( $_SERVER['DOCUMENT_ROOT'] . '/logs/apilo.txt', date( 'Y-m-d H:i:s' ) . " --- SEND ORDER TO APILO\n\n", FILE_APPEND ); file_put_contents( $_SERVER['DOCUMENT_ROOT'] . '/logs/apilo.txt', print_r( $postData, true ) . "\n\n", FILE_APPEND ); file_put_contents( $_SERVER['DOCUMENT_ROOT'] . '/logs/apilo.txt', print_r( $response, true ) . "\n\n", FILE_APPEND ); } - if ( $response['message'] == 'Order already exists' ) + if ( isset($response['message']) && $response['message'] == 'Order already exists' ) { $apilo_order_id = str_replace( 'Order id: ', '', $response['description'] ); - $mdb -> update( 'pp_shop_orders', [ 'apilo_order_id' => $apilo_order_id ], [ 'id' => $order['id'] ] ); + $mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => $apilo_order_id ], [ 'id' => $order['id'] ] ); + \Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Zamówienie już istnieje w Apilo (apilo_order_id: ' . $apilo_order_id . ')', [ 'http_code' => $http_code_send, 'response' => $response ] ); echo '

Zaktualizowałem id zamówienia na podstawie zamówienia apilo.com

'; } - elseif ( $response['message'] == 'Validation error' ) + elseif ( isset($response['message']) && $response['message'] == 'Validation error' ) { - echo '
';
-      echo print_r( $response, true );
-      echo print_r( $postData, true );
-      echo '
'; + $is_duplicate_idexternal = false; + if ( isset( $response['errors'] ) && is_array( $response['errors'] ) ) + { + foreach ( $response['errors'] as $error ) + { + if ( isset( $error['field'] ) && $error['field'] == 'idExternal' && + ( strpos( $error['message'], 'już wykorzystywana' ) !== false || + strpos( $error['message'], 'already' ) !== false ) ) + { + $is_duplicate_idexternal = true; + break; + } + } + } - $email_data = print_r( $response, true ); + if ( $is_duplicate_idexternal ) + { + $ch_get = curl_init(); + curl_setopt( $ch_get, CURLOPT_URL, "https://projectpro.apilo.com/rest/api/orders/?idExternal=" . $order['id'] ); + curl_setopt( $ch_get, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch_get, CURLOPT_HTTPHEADER, array( + "Authorization: Bearer " . $access_token, + "Accept: application/json" + )); + $get_response = curl_exec( $ch_get ); + curl_close( $ch_get ); + + $get_response_data = json_decode( $get_response, true ); + + if ( isset( $get_response_data['list'] ) && count( $get_response_data['list'] ) > 0 ) + { + $apilo_order_id = $get_response_data['list'][0]['id']; + $mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => $apilo_order_id ], [ 'id' => $order['id'] ] ); + \Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Duplikat idExternal - pobrano apilo_order_id: ' . $apilo_order_id, [ 'http_code' => $http_code_send, 'response' => $response, 'get_response' => $get_response_data ] ); + echo '

Zamówienie już istnieje w Apilo. Zaktualizowano ID zamówienia: ' . $apilo_order_id . '

'; + } + else + { + \Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Błąd: duplikat idExternal, ale nie znaleziono zamówienia w Apilo', [ 'http_code' => $http_code_send, 'response' => $response, 'get_response' => $get_response_data ] ); + $email_data = print_r( $response, true ); + $email_data .= print_r( $postData, true ); + \Shared\Helpers\Helpers::send_email( 'biuro@project-pro.pl', 'Błąd wysyłania zamówienia do apilo.com - nie znaleziono zamówienia', $email_data ); + } + } + else + { + \Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Błąd walidacji wysyłania zamówienia do Apilo', [ 'http_code' => $http_code_send, 'response' => $response ] ); + $email_data = print_r( $response, true ); + $email_data .= print_r( $postData, true ); + \Shared\Helpers\Helpers::send_email( 'biuro@project-pro.pl', 'Błąd wysyłania zamówienia do apilo.com', $email_data ); + } + } + elseif ( $http_code_send >= 400 || !isset( $response['id'] ) ) + { + $mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => -1 ], [ 'id' => $order['id'] ] ); + \Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Błąd wysyłania zamówienia do Apilo (HTTP ' . $http_code_send . ')', [ 'http_code' => $http_code_send, 'response' => $response ] ); + $email_data = 'HTTP Code: ' . $http_code_send . "\n\n"; + $email_data .= print_r( $response, true ); $email_data .= print_r( $postData, true ); - \S::send_email( 'biuro@project-pro.pl', 'Błąd wysyłania zamówienia do apilo.com', $email_data ); + \Shared\Helpers\Helpers::send_email( 'biuro@project-pro.pl', 'Błąd wysyłania zamówienia #' . $order['id'] . ' do apilo.com (HTTP ' . $http_code_send . ')', $email_data ); + echo '

Błąd wysyłania zamówienia do apilo.com: ID: ' . $order['id'] . ' (HTTP ' . $http_code_send . ')

'; } else { - $mdb -> update( 'pp_shop_orders', [ 'apilo_order_id' => $response['id'] ], [ 'id' => $order['id'] ] ); + $mdb->update( 'pp_shop_orders', [ 'apilo_order_id' => $response['id'] ], [ 'id' => $order['id'] ] ); + \Domain\Integrations\ApiloLogger::log( $mdb, 'send_order', (int)$order['id'], 'Zamówienie wysłane do Apilo (apilo_order_id: ' . $response['id'] . ')', [ 'http_code' => $http_code_send, 'response' => $response ] ); echo '

Wysłałem zamówienie do apilo.com: ID: ' . $order['id'] . ' - ' . $response['id'] . '

'; } } -} -// sprawdzanie statusów zamówień w apilo.com jeżeli zamówienie nie jest zrealizowane, anulowane lub nieodebrane -if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_settings['access-token'] and $apilo_settings['sync_orders_date_start'] <= date( 'Y-m-d H:i:s' ) ) -{ - $orders = $mdb -> query( 'SELECT id, apilo_order_id, apilo_order_status_date, number FROM pp_shop_orders WHERE apilo_order_id IS NOT NULL AND ( status != 6 AND status != 8 AND status != 9 ) AND ( apilo_order_status_date IS NULL OR apilo_order_status_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-10 minutes', time() ) ) . '\' ) ORDER BY apilo_order_status_date ASC LIMIT 5' ) -> fetchAll( \PDO::FETCH_ASSOC ); + return true; +}); + +// 3. Apilo sync payment (event-driven — enqueued by OrderAdminService) +$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SYNC_PAYMENT, function($payload) use ($mdb, $orderRepo, $orderAdminService) { + $order_id = (int)($payload['order_id'] ?? 0); + if ( $order_id <= 0 ) return true; + + $order = $orderRepo->findRawById( $order_id ); + if ( !$order ) return true; + + if ( empty($order['apilo_order_id']) ) return false; // retry — awaiting apilo_order_id + + if ( (int)$order['paid'] !== 1 ) return true; // not paid — nothing to sync + + return $orderAdminService->syncApiloPayment( $order ); +}); + +// 4. Apilo sync status (event-driven — enqueued by OrderAdminService) +$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_SYNC_STATUS, function($payload) use ($mdb, $orderRepo, $orderAdminService) { + $order_id = (int)($payload['order_id'] ?? 0); + $status = isset($payload['status']) ? (int)$payload['status'] : null; + if ( $order_id <= 0 || $status === null ) return true; + + $order = $orderRepo->findRawById( $order_id ); + if ( !$order ) return true; + + if ( empty($order['apilo_order_id']) ) return false; // retry — awaiting apilo_order_id + + return $orderAdminService->syncApiloStatus( $order, $status ); +}); + +// 5. Apilo product sync +$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_PRODUCT_SYNC, function($payload) use ($mdb, $integrationsRepository) { + $apilo_settings = $integrationsRepository->getSettings('apilo'); + if ( !$apilo_settings['enabled'] || !$apilo_settings['sync_products'] || !$apilo_settings['access-token'] ) return true; + + $stmt = $mdb->query( 'SELECT id, apilo_product_id, apilo_get_data_date, apilo_product_name FROM pp_shop_products WHERE apilo_product_id IS NOT NULL AND apilo_product_id != 0 AND ( apilo_get_data_date IS NULL OR apilo_get_data_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-10 minutes', time() ) ) . '\' ) ORDER BY apilo_get_data_date ASC LIMIT 1' ); + $result = $stmt ? $stmt->fetch( \PDO::FETCH_ASSOC ) : null; + if ( !$result ) return true; + + $access_token = $integrationsRepository->apiloGetAccessToken(); + $url = 'https://projectpro.apilo.com/rest/api/warehouse/product/' . $result['apilo_product_id'] . '/'; + $curl = curl_init( $url ); + curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $curl, CURLOPT_HTTPHEADER, [ + "Authorization: Bearer " . $access_token, + "Accept: application/json" + ] ); + + $response = curl_exec( $curl ); + if ( $response === false ) return false; + + $responseData = json_decode( $response, true ); + if ( !is_array( $responseData ) || !isset( $responseData['quantity'] ) ) return false; + + $mdb->update( 'pp_shop_products', [ 'quantity' => $responseData['quantity'] ], [ 'apilo_product_id' => $result['apilo_product_id'] ] ); + $mdb->update( 'pp_shop_products', [ 'price_netto' => \Shared\Helpers\Helpers::normalize_decimal( $responseData['priceWithoutTax'], 2 ), 'price_brutto' => \Shared\Helpers\Helpers::normalize_decimal( $responseData['priceWithTax'], 2 ) ], [ 'apilo_product_id' => $result['apilo_product_id'] ] ); + $mdb->update( 'pp_shop_products', [ 'apilo_get_data_date' => date( 'Y-m-d H:i:s' ) ], [ 'apilo_product_id' => $result['apilo_product_id'] ] ); + \Shared\Helpers\Helpers::clear_product_cache( (int)$result['id'] ); + + echo '

Zaktualizowałem dane produktu (APILO) ' . $result['apilo_product_name'] . ' #' . $result['id'] . '

'; + return true; +}); + +// 6. Apilo pricelist sync +$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_PRICELIST_SYNC, function($payload) use ($mdb, $integrationsRepository) { + $apilo_settings = $integrationsRepository->getSettings('apilo'); + if ( !$apilo_settings['enabled'] || !$apilo_settings['access-token'] ) return true; + + $access_token = $integrationsRepository->apiloGetAccessToken(); + $url = 'https://projectpro.apilo.com/rest/api/warehouse/price-calculated/?price=' . $apilo_settings['pricelist_id']; + + $curl = curl_init( $url ); + curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $curl, CURLOPT_CUSTOMREQUEST, "GET" ); + curl_setopt( $curl, CURLOPT_HTTPHEADER, [ + "Authorization: Bearer " . $access_token, + "Accept: application/json", + "Content-Type: application/json" + ] ); + + $response = curl_exec( $curl ); + if ( $response === false ) return false; + + $responseData = json_decode( $response, true ); + if ( !is_array( $responseData ) ) return false; + + if ( isset($responseData['list']) && $responseData['list'] ) + { + foreach ( $responseData['list'] as $product_price ) + { + if ( $product_price['customPriceWithTax'] ) + { + $price_brutto = $product_price['customPriceWithTax']; + $vat = $mdb->get( 'pp_shop_products', 'vat', [ 'apilo_product_id' => $product_price['product'] ] ); + $price_netto = $price_brutto / ( ( 100 + $vat ) / 100 ); + + $mdb->update( 'pp_shop_products', [ 'price_netto' => \Shared\Helpers\Helpers::normalize_decimal( $price_netto, 2 ), 'price_brutto' => \Shared\Helpers\Helpers::normalize_decimal( $price_brutto, 2 ) ], [ 'apilo_product_id' => $product_price['product'] ] ); + $product_id = $mdb->get( 'pp_shop_products', 'id', [ 'apilo_product_id' => $product_price['product'] ] ); + + ( new \Domain\Product\ProductRepository( $mdb ) )->updateCombinationPricesFromBase( (int)$product_id, $price_brutto, $vat, null ); + \Shared\Helpers\Helpers::clear_product_cache( (int)$product_id ); + } + } + } + $integrationsRepository->saveSetting( 'apilo', 'pricelist_update_date', date( 'Y-m-d H:i:s' ) ); + echo '

Zaktualizowałem ceny produktów (APILO)

'; + return true; +}); + +// 7. Apilo status poll +$processor->registerHandler( \Domain\CronJob\CronJobType::APILO_STATUS_POLL, function($payload) use ($mdb, $integrationsRepository, $orderRepo, $orderAdminService) { + $apilo_settings = $integrationsRepository->getSettings('apilo'); + if ( !$apilo_settings['enabled'] || !$apilo_settings['sync_orders'] || !$apilo_settings['access-token'] ) return true; + + $stmt = $mdb->query( 'SELECT id, apilo_order_id, apilo_order_status_date, number FROM pp_shop_orders WHERE apilo_order_id IS NOT NULL AND ( status != 6 AND status != 8 AND status != 9 ) AND ( apilo_order_status_date IS NULL OR apilo_order_status_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-10 minutes', time() ) ) . '\' ) ORDER BY apilo_order_status_date ASC LIMIT 5' ); + $orders = $stmt ? $stmt->fetchAll( \PDO::FETCH_ASSOC ) : []; + foreach ( $orders as $order ) { if ( $order['apilo_order_id'] ) { - $access_token = \admin\factory\Integrations::apilo_get_access_token(); + $access_token = $integrationsRepository->apiloGetAccessToken(); $url = 'https://projectpro.apilo.com/rest/api/orders/' . $order['apilo_order_id'] . '/'; $ch = curl_init( $url ); @@ -708,204 +650,106 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se ] ); $response = curl_exec( $ch ); + $http_code_poll = (int)curl_getinfo( $ch, CURLINFO_HTTP_CODE ); $responseData = json_decode( $response, true ); - if ( $responseData['id'] and $responseData['status'] ) + if ( isset($responseData['id']) && $responseData['id'] && isset($responseData['status']) && $responseData['status'] ) { - $shop_status_id = \front\factory\ShopStatuses::get_shop_status_by_integration_status_id( 'apilo', $responseData['status'] ); - - $order_tmp = new Order( $order['id'] ); + $shop_status_id = ( new \Domain\ShopStatus\ShopStatusRepository( $mdb ) )->getByIntegrationStatusId( 'apilo', (int)$responseData['status'] ); if ( $shop_status_id ) - $order_tmp -> update_status( $shop_status_id, false ); + $orderAdminService->changeStatus( (int)$order['id'], $shop_status_id, false ); - $order_tmp -> update_aplio_order_status_date( date( 'Y-m-d H:i:s' ) ); + \Domain\Integrations\ApiloLogger::log( $mdb, 'status_poll', (int)$order['id'], 'Status pobrany z Apilo (apilo_status: ' . $responseData['status'] . ', shop_status: ' . ($shop_status_id ? $shop_status_id : 'brak mapowania') . ')', [ 'apilo_order_id' => $order['apilo_order_id'], 'http_code' => $http_code_poll, 'response' => $responseData ] ); + + $orderRepo->updateApiloStatusDate( (int)$order['id'], date( 'Y-m-d H:i:s' ) ); echo '

Zaktualizowałem status zamówienia ' . $order['number'] . '

'; } } } -} + return true; +}); -// sprawdzanie statusów zamówień w baselinker.com jeżeli zamówienie nie jest zrealizowane, anulowane lub nieodebrane -if ( $baselinker_settings['enabled'] and $baselinker_settings['sync_orders'] and $baselinker_settings['api_code'] and $baselinker_settings['sync_orders_date_start'] <= date( 'Y-m-d H:i:s' ) ) -{ - $order = $mdb -> query( 'SELECT id, baselinker_order_id, baselinker_order_status_date, number FROM pp_shop_orders WHERE baselinker_order_id IS NOT NULL AND baselinker_order_id != 0 AND ( status != 6 AND status != 8 AND status != 9 ) AND ( baselinker_order_status_date IS NULL OR baselinker_order_status_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-30 minutes', time() ) ) . '\' ) ORDER BY baselinker_order_status_date ASC LIMIT 1' ) -> fetch( \PDO::FETCH_ASSOC ); - if ( $order['baselinker_order_id'] ) +// 8. Price history +$processor->registerHandler( \Domain\CronJob\CronJobType::PRICE_HISTORY, function($payload) use ($mdb) { + $results = $mdb->select( 'pp_shop_products', [ 'id', 'price_brutto', 'price_brutto_promo' ], [ 'OR' => [ 'price_history_date[!]' => date( 'Y-m-d' ), 'price_history_date' => null ], 'ORDER' => [ 'price_history_date' => 'ASC' ], 'LIMIT' => 100 ] ); + + foreach ( $results as $row ) { - $methodParams = '{ - "order_id": ' . $order['baselinker_order_id'] . ' - }'; - - $apiParams = [ - "token" => $baselinker_settings['api_code'], - "method" => "getOrders", - "parameters" => $methodParams - ]; - - $curl = curl_init( "https://api.baselinker.com/connector.php" ); - curl_setopt( $curl, CURLOPT_POST, 1 ); - curl_setopt( $curl, CURLOPT_POSTFIELDS, http_build_query( $apiParams ) ); - curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true ); - $response = json_decode( curl_exec( $curl ), true ); - - if ( $response['status'] == 'SUCCESS' and count( $response['orders'] ) ) + $price = $row['price_brutto_promo'] > 0 ? $row['price_brutto_promo'] : $row['price_brutto']; + if ( $price ) { - $shop_status_id = \shop\ShopStatus::get_shop_status_by_baselinker_status( (int) $response['orders'][0]['order_status_id'] ); + $mdb->insert( 'pp_shop_product_price_history', [ + 'id_product' => $row['id'], + 'price' => $price, + 'date' => date( 'Y-m-d' ) + ] ); + } - $order_tmp = new Order( $order['id'] ); - $order_tmp -> update_status( $shop_status_id, false ); - $order_tmp -> update_baselinker_order_status_date( date( 'Y-m-d H:i:s' ) ); - echo '

Zaktualizowałem status zamówienia ' . $order['number'] . '

'; - } - else - { - $mdb -> update( 'pp_shop_orders', [ 'baselinker_order_status_date' => date( 'Y-m-d H:i:s' ) ], [ 'id' => $order['id'] ] ); - echo '

Z powodu błędu pominąłem zamówienie o ID: ' . $order['id'] . '

'; - } + $mdb->update( 'pp_shop_products', [ 'price_history_date' => date( 'Y-m-d' ) ], [ 'id' => $row['id'] ] ); + $mdb->delete( 'pp_shop_product_price_history', [ 'date[<=]' => date( 'Y-m-d', strtotime( '-31 days', time() ) ) ] ); + echo '

Zapisuję historyczną cenę dla produktu #' . $row['id'] . '

'; } -} -// wysyłanie zamówień do baselinker -if ( $baselinker_settings['enabled'] and $baselinker_settings['sync_orders'] and $baselinker_settings['api_code'] and $baselinker_settings['sync_orders_date_start'] <= date( 'Y-m-d H:i:s' ) ) -{ - $orders = $mdb -> select( 'pp_shop_orders', '*', [ 'AND' => [ 'baselinker_order_id' => null, 'date_order[<=]' => date( 'Y-m-d H:i:s', strtotime( '-1 minutes', time() ) ), 'date_order[>=]' => $baselinker_settings['sync_orders_date_start'] ], 'ORDER' => [ 'date_order' => 'ASC' ], 'LIMIT' => 1 ] ); + return true; +}); + +// 9. Order analysis +$processor->registerHandler( \Domain\CronJob\CronJobType::ORDER_ANALYSIS, function($payload) use ($mdb) { + $orders = $mdb->select( 'pp_shop_orders', 'id', [ 'parsed' => 0, 'LIMIT' => 1 ] ); foreach ( $orders as $order ) { - if ( $order['transport_id'] == 2 ) + $products = $mdb->select( 'pp_shop_order_products', 'product_id', [ 'order_id' => $order ] ); + foreach ( $products as $product1 ) { - $pickup = explode( ' | ', $order['inpost_paczkomat'] ); - $pickup_name = $pickup[0]; - $pickup_address = $pickup[1]; - } + if ( $parent_id = $mdb->get( 'pp_shop_products', 'parent_id', [ 'id' => $product1 ] ) ) + $product1 = $parent_id; - $methodParams = '{ - "order_status_id": "' . \front\factory\ShopStatuses::get_baselinker_order_status_id( $order['status'] ) . '", - "date_add": "' . strtotime( $order['date_order'] ) . '", - "user_comments": "' . preg_replace('/\s+/', ' ', \S::remove_special_chars( $order['message'] ) ) . '", - "admin_comments": "' . $order['notes'] . '", - "phone": "' . $order['client_phone'] . '", - "email": "' . $order['client_email'] . '", - "user_login": "' . $order['client_name'] . ' ' . $order['client_surname'] . '", - "currency": "PLN", - "payment_method": "' . $order['payment_method'] . '", - "payment_method_cod": "' . ( $order['payment_method_id'] == 3 ? 1 : 0 ) . '", - "paid": "' . $order['paid'] . '", - "delivery_method": "' . strip_tags( $order['transport'] ) . '", - "delivery_price": "' . $order['transport_cost'] . '", - "delivery_fullname": "' . $order['client_name'] . ' ' . $order['client_surname'] . '", - "delivery_company": "' . $order['client_firm'] . '", - "delivery_address": "' . $order['client_street'] . '", - "delivery_city": "' . $order['client_city'] . '", - "delivery_postcode": "' . $order['client_postal_code'] . '", - "delivery_country_code": "PL", - "delivery_point_id": "' . $pickup_name . '", - "delivery_point_name": "' . ( $pickup_name != '' ? 'Paczkomat ' . $pickup_name : '' ) . '", - "delivery_point_address": "' . $pickup_address . '", - "delivery_point_postcode": "", - "delivery_point_city": "", - "invoice_fullname": "", - "invoice_company": "", - "invoice_nip": "", - "invoice_address": "", - "invoice_city": "", - "invoice_postcode": "", - "invoice_country_code": "", - "want_invoice": "0", - "extra_field_1": "", - "extra_field_2": "", - "products": ['; - $products = $mdb -> select( 'pp_shop_order_products', '*', [ 'order_id' => $order['id'] ] ); - foreach ( $products as $product ) - { - $methodParams .= '{ - "storage": "db", - "storage_id": 0, - "product_id": "' .\shop\Product::get_baselinker_product_id( (int)$product['product_id'] ) . '", - "variant_id": "", - "name": "' . htmlspecialchars( $product['name'] ) . '", - "sku": "' . \shop\Product::get_product_sku( (int)$product['product_id'] ) . '", - "ean": "", - "attributes": "' . strip_tags( $product['attributes'] ) . ' | ' . strip_tags( str_replace( '
', ' | ', preg_replace( '/\s+/', ' ', $product['custom_fields'] ) ) ) . ' | ' . preg_replace( '/\s+/', ' ', htmlspecialchars( $product['message'] ) ) . '", - "price_brutto": ' . ( $product['price_brutto_promo'] ? $product['price_brutto_promo'] : $product['price_brutto'] ) . ', - "tax_rate": ' . $product['vat'] . ', - "quantity": ' . $product['quantity'] . ', - "weight": 1 - }'; - - if ( $product != end( $products ) ) - $methodParams .= ','; - } - $methodParams .= '] - }'; - - $apiParams = [ - "token" => $baselinker_settings['api_code'], - "method" => "addOrder", - "parameters" => $methodParams - ]; - - $curl = curl_init( "https://api.baselinker.com/connector.php" ); - curl_setopt( $curl, CURLOPT_POST, 1 ); - curl_setopt( $curl, CURLOPT_POSTFIELDS, http_build_query( $apiParams ) ); - curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true ); - $response = json_decode( curl_exec( $curl ), true ); - - if ( $response['status'] == 'SUCCESS' ) - { - $mdb -> update( 'pp_shop_orders', [ 'baselinker_order_id' => $response['order_id'] ], [ 'id' => $order['id'] ] ); - echo '

Wysłałem zamówienie do Baselinker ' . $order['number'] . '

'; - } - } -} - -/* zapisywanie historii cen produktów */ -$results = $mdb -> select( 'pp_shop_products', [ 'id', 'price_brutto', 'price_brutto_promo' ], [ 'OR' => [ 'price_history_date[!]' => date( 'Y-m-d' ), 'price_history_date' => null ], 'ORDER' => [ 'price_history_date' => 'ASC' ], 'LIMIT' => 100 ] ); -foreach ( $results as $row ) -{ - if ( $price ) - { - $mdb -> insert( 'pp_shop_product_price_history', [ - 'id_product' => $row['id'], - 'price' => $row['price_brutto_promo'] > 0 ? $row['price_brutto_promo'] : $row['price_brutto'], - 'date' => date( 'Y-m-d' ) - ] ); - } - - $mdb -> update( 'pp_shop_products', [ 'price_history_date' => date( 'Y-m-d' ) ], [ 'id' => $row['id'] ] ); - - $mdb -> delete( 'pp_shop_product_price_history', [ 'date[<=]' => date( 'Y-m-d', strtotime( '-31 days', time() ) ) ] ); - echo '

Zapisuję historyczną cenę dla produktu #' . $row['id'] . '

'; -} - -/* parsowanie zamówień m.in. pod kątem najczęściej sprzedawanych razem produktów */ -$orders = $mdb -> select( 'pp_shop_orders', 'id', [ 'parsed' => 0, 'LIMIT' => 1 ] ); -foreach ( $orders as $order ) -{ - $products = $mdb -> select( 'pp_shop_order_products', 'product_id', [ 'order_id' => $order ] ); - foreach ( $products as $product1 ) - { - if ( $parent_id = $mdb -> get( 'pp_shop_products', 'parent_id', [ 'id' => $product1 ] ) ) - $product1 = $parent_id; - - foreach ( $products as $product2 ) - { - if ( $parent_id = $mdb -> get( 'pp_shop_products', 'parent_id', [ 'id' => $product2 ] ) ) - $product2 = $parent_id; - - if ( $product1 != $product2 ) + foreach ( $products as $product2 ) { - $intersection_id = $mdb -> query( 'SELECT * FROM pp_shop_orders_products_intersection WHERE product_1_id = :product_1_id AND product_2_id = :product_2_id OR product_1_id = :product_2_id AND product_2_id = :product_1_id', [ 'product_1_id' => (int)$product1, 'product_2_id' => (int)$product2 ] ) -> fetch( \PDO::FETCH_ASSOC ); - if ( $intersection_id ) + if ( $parent_id = $mdb->get( 'pp_shop_products', 'parent_id', [ 'id' => $product2 ] ) ) + $product2 = $parent_id; + + if ( $product1 != $product2 ) { - $mdb -> update( 'pp_shop_orders_products_intersection', [ 'count' => $intersection_id['count'] + 1 ], [ 'id' => $intersection_id['id'] ] ); - } - else - { - $mdb -> insert( 'pp_shop_orders_products_intersection', [ 'product_1_id' => (int)$product1, 'product_2_id' => (int)$product2, 'count' => 1 ] ); + $stmt = $mdb->query( 'SELECT * FROM pp_shop_orders_products_intersection WHERE product_1_id = :product_1_id AND product_2_id = :product_2_id OR product_1_id = :product_2_id AND product_2_id = :product_1_id', [ 'product_1_id' => (int)$product1, 'product_2_id' => (int)$product2 ] ); + $intersection_id = $stmt ? $stmt->fetch( \PDO::FETCH_ASSOC ) : null; + if ( $intersection_id ) + { + $mdb->update( 'pp_shop_orders_products_intersection', [ 'count' => $intersection_id['count'] + 1 ], [ 'id' => $intersection_id['id'] ] ); + } + else + { + $mdb->insert( 'pp_shop_orders_products_intersection', [ 'product_1_id' => (int)$product1, 'product_2_id' => (int)$product2, 'count' => 1 ] ); + } } } } + $mdb->update( 'pp_shop_orders', [ 'parsed' => 1 ], [ 'id' => $order ] ); + echo '

Parsuję zamówienie #' . $order . '

'; } - $mdb -> update( 'pp_shop_orders', [ 'parsed' => 1 ], [ 'id' => $order ] ); - echo '

Parsuję zamówienie #' . $order . '

'; -} \ No newline at end of file + return true; +}); + +// 10. Google XML feed +$processor->registerHandler( \Domain\CronJob\CronJobType::GOOGLE_XML_FEED, function($payload) use ($mdb) { + ( new \Domain\Product\ProductRepository( $mdb ) )->generateGoogleFeedXml(); + echo '

Wygenerowano Google XML Feed

'; + return true; +}); + +// 11. TrustMate invitation — handled by separate cron-turstmate.php (requires browser context) +$processor->registerHandler( \Domain\CronJob\CronJobType::TRUSTMATE_INVITATION, function($payload) use ($config) { + if ( !isset($config['trustmate']['enabled']) || !$config['trustmate']['enabled'] ) return true; + // TrustMate requires browser context (JavaScript). Handled by cron-turstmate.php. + return true; +}); + +// ========================================================================= +// Run processor +// ========================================================================= + +$result = $processor->run( 20 ); + +echo '
'; +echo '

CronJob stats: scheduled=' . $result['scheduled'] . ', processed=' . $result['processed'] . ', succeeded=' . $result['succeeded'] . ', failed=' . $result['failed'] . ', skipped=' . $result['skipped'] . '

';