diff --git a/CLAUDE.md b/CLAUDE.md index 9a99ed7..be2d8e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ composer test PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`. -Current suite: **765 tests, 2153 assertions**. +Current suite: **805 tests, 2253 assertions**. ### Creating Updates See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packages in `updates/0.XX/`. Never include `*.md` files, `updates/changelog.php`, or root `.htaccess` in update ZIPs. @@ -116,7 +116,7 @@ All legacy directories (`admin/controls/`, `admin/factory/`, `admin/view/`, `fro - Constructor DI with `$db` (Medoo instance) - Methods serve both admin and frontend (shared Domain, no separate services) -**Domain Modules**: Article, Attribute, Banner, Basket, Cache, Category, Client, Coupon, Dashboard, Dictionaries, Integrations, Languages, Layouts, Newsletter, Order, Pages, PaymentMethod, Producer, Product, ProductSet, Promotion, Scontainers, Settings, ShopStatus, Transport, Update, User +**Domain Modules**: Article, Attribute, Banner, Basket, Cache, Category, Client, Coupon, CronJob, Dashboard, Dictionaries, Integrations, Languages, Layouts, Newsletter, Order, Pages, PaymentMethod, Producer, Product, ProductSet, Promotion, Scontainers, Settings, ShopStatus, Transport, Update, User **Admin Controllers** (`autoload/admin/Controllers/`): - DI via constructor (repositories injected) 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 @@ +orders = $orders; $this->productRepo = $productRepo; $this->settingsRepo = $settingsRepo; $this->transportRepo = $transportRepo; + $this->cronJobRepo = $cronJobRepo; } public function details(int $orderId): array @@ -519,92 +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; - $max_attempts = 50; // ~8h przy cronie co 10 min - - // Zamówienie jeszcze nie wysłane do Apilo — czekaj na crona - if (!(int)$order['apilo_order_id']) { - $attempts = (int)($task['attempts'] ?? 0) + 1; - if ($attempts >= $max_attempts) { - // Przekroczono limit prób — porzuć task - unset($queue[$key]); - } else { - $task['attempts'] = $attempts; - $task['last_error'] = 'awaiting_apilo_order'; - $task['updated_at'] = date('Y-m-d H:i:s'); - $queue[$key] = $task; - } - $processed++; - continue; - } - - $payment_pending = !empty($task['payment']) && (int)$order['paid'] === 1; - if ($payment_pending) { - 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) { - 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 // ========================================================================= @@ -689,7 +607,7 @@ class OrderAdminService 'Brak apilo_order_id — płatność zakolejkowana do sync', ['apilo_order_id' => $order['apilo_order_id'] ?? null] ); - self::queueApiloSync((int)$order['id'], true, null, 'awaiting_apilo_order'); + $this->queueApiloSync((int)$order['id'], true, null, 'awaiting_apilo_order'); } elseif (!$this->syncApiloPayment($order)) { \Domain\Integrations\ApiloLogger::log( $db, @@ -698,7 +616,7 @@ class OrderAdminService 'Sync płatności nieudany — zakolejkowano ponowną próbę', ['apilo_order_id' => $order['apilo_order_id']] ); - self::queueApiloSync((int)$order['id'], true, null, 'payment_sync_failed'); + $this->queueApiloSync((int)$order['id'], true, null, 'payment_sync_failed'); } } @@ -739,7 +657,7 @@ class OrderAdminService 'Brak apilo_order_id — status zakolejkowany do sync', ['apilo_order_id' => $order['apilo_order_id'] ?? null, 'target_status' => $status] ); - self::queueApiloSync((int)$order['id'], false, $status, 'awaiting_apilo_order'); + $this->queueApiloSync((int)$order['id'], false, $status, 'awaiting_apilo_order'); } elseif (!$this->syncApiloStatus($order, $status)) { \Domain\Integrations\ApiloLogger::log( $db, @@ -748,11 +666,11 @@ class OrderAdminService 'Sync statusu nieudany — zakolejkowano ponowną próbę', ['apilo_order_id' => $order['apilo_order_id'], 'target_status' => $status] ); - self::queueApiloSync((int)$order['id'], false, $status, 'status_sync_failed'); + $this->queueApiloSync((int)$order['id'], false, $status, 'status_sync_failed'); } } - private function syncApiloPayment(array $order): bool + public function syncApiloPayment(array $order): bool { global $config; @@ -819,7 +737,7 @@ class OrderAdminService return true; } - private function syncApiloStatus(array $order, int $status): bool + public function syncApiloStatus(array $order, int $status): bool { global $config; @@ -882,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/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/api/ApiRouter.php b/autoload/api/ApiRouter.php index 572d183..3bc8d11 100644 --- a/autoload/api/ApiRouter.php +++ b/autoload/api/ApiRouter.php @@ -87,7 +87,8 @@ 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) { 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/cron.php b/cron.php index 14fc28b..a6bed4f 100644 --- a/cron.php +++ b/cron.php @@ -50,19 +50,26 @@ $mdb = new medoo( [ 'charset' => 'utf8' ] ); -$settings = ( new \Domain\Settings\SettingsRepository( $mdb ) )->allSettings(); -$integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb ); -$apilo_settings = $integrationsRepository -> getSettings( 'apilo' ); +// ========================================================================= +// Auth: cron endpoint protection +// ========================================================================= -// Keepalive tokenu Apilo: odswiezaj token przed wygasnieciem, zeby integracja byla stale aktywna. -if ( (int)($apilo_settings['enabled'] ?? 0) === 1 ) { - $integrationsRepository -> apiloKeepalive( 300 ); - $apilo_settings = $integrationsRepository -> getSettings( 'apilo' ); - $orderRepo = new \Domain\Order\OrderRepository( $mdb ); - $orderAdminService = new \Domain\Order\OrderAdminService( $orderRepo ); - $orderAdminService->processApiloSyncQueue( 10 ); +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) { $pattern = '/^([\w-]+)\s+\|\s+([^,]+),\s+(\d{2}-\d{3})\s+(.+)$/'; @@ -118,93 +125,90 @@ function getImageUrlById($id) { return isset($data['img']) ? $data['img'] : null; } -// pobieranie informacji o produkcie z apilo.com -if ( $apilo_settings['enabled'] and $apilo_settings['sync_products'] and $apilo_settings['access-token'] ) +// ========================================================================= +// 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, 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 ) ) + $json_content = file_get_contents( $json_queue_path ); + $json_queue = $json_content ? json_decode( $json_content, true ) : []; + + if ( is_array( $json_queue ) ) { - $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 ); - $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' => \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'] ] ); - - // Czyszczenie cache produktu - \Shared\Helpers\Helpers::clear_product_cache( (int)$result['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 = $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 ); - $responseData = json_decode( $response, true ); - - if ( $responseData['list'] ) - { - foreach ( $responseData['list'] as $product_price ) + foreach ( $json_queue as $task ) { - //aktualizowanie ceny - if ( $product_price['customPriceWithTax'] ) + $order_id = (int)($task['order_id'] ?? 0); + if ( $order_id <= 0 ) continue; + + 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' => \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 ); - - // Czyszczenie cache produktu - \Shared\Helpers\Helpers::clear_product_cache( (int)$product_id ); + $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 + ); } } } - $integrationsRepository -> saveSetting( 'apilo', '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

'; } -// 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 ] ); +// ========================================================================= +// Handler registration +// ========================================================================= + +// 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 + + $integrationsRepository->apiloKeepalive( 300 ); + echo '

Apilo token keepalive

'; + return true; +}); + +// 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; + + $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; + foreach ( $orders as $order ) { - $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 ) { - $productRepo = new \Domain\Product\ProductRepository( $mdb ); $sku = $productRepo->getSkuWithFallback( (int)$product['product_id'], true ); $products_array[] = [ @@ -237,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, @@ -256,7 +258,6 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se 'media' => null ]; - // Walidacja: sprawdź czy zamówienie ma produkty z cenami > 0 $has_priced_products = false; foreach ( $products_array as $pa ) { @@ -270,15 +271,13 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se { \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'] ] ); + $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(); - + $access_token = $integrationsRepository->apiloGetAccessToken(); $order_date = new DateTime( $order['date_order'] ); - $paczkomatData = parsePaczkomatAddress( $order['inpost_paczkomat'] ); $orlenPointData = parseOrlenAddress( $order['orlen_point'] ); @@ -326,7 +325,7 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se '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'], @@ -361,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; @@ -381,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; @@ -399,16 +396,14 @@ 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'), + 'paymentDate' => $payment_date->format('Y-m-d\TH:i:s\Z'), 'type' => ( new \Domain\PaymentMethod\PaymentMethodRepository( $mdb ) )->getApiloPaymentTypeId( (int)$order['payment_method_id'] ) ]; } @@ -435,30 +430,29 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se $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' ) { - // sprawdzanie czy błąd dotyczy duplikatu idExternal $is_duplicate_idexternal = false; - if ( isset( $response['errors'] ) and is_array( $response['errors'] ) ) + if ( isset( $response['errors'] ) && is_array( $response['errors'] ) ) { foreach ( $response['errors'] as $error ) { - if ( isset( $error['field'] ) and $error['field'] == 'idExternal' and - ( strpos( $error['message'], 'już wykorzystywana' ) !== false or + if ( isset( $error['field'] ) && $error['field'] == 'idExternal' && + ( strpos( $error['message'], 'już wykorzystywana' ) !== false || strpos( $error['message'], 'already' ) !== false ) ) { $is_duplicate_idexternal = true; @@ -469,7 +463,6 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se if ( $is_duplicate_idexternal ) { - // próba pobrania zamówienia z Apilo na podstawie 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 ); @@ -482,22 +475,16 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se $get_response_data = json_decode( $get_response, true ); - if ( isset( $get_response_data['list'] ) and count( $get_response_data['list'] ) > 0 ) + 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'] ] ); + $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 { - echo '
';
-          echo print_r( $response, true );
-          echo print_r( $postData, true );
-          echo '
'; - \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 ); @@ -505,13 +492,7 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se } else { - echo '
';
-        echo print_r( $response, true );
-        echo print_r( $postData, true );
-        echo '
'; - \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 ); @@ -519,39 +500,146 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se } elseif ( $http_code_send >= 400 || !isset( $response['id'] ) ) { - // Błąd serwera lub brak ID w odpowiedzi — logujemy i pomijamy, NIE ustawiamy apilo_order_id - // żeby zamówienie nie wpadło w nieskończoną pętlę, ustawiamy apilo_order_id na -1 (błąd) - $mdb -> update( 'pp_shop_orders', [ 'apilo_order_id' => -1 ], [ 'id' => $order['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 ); \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'] . '

'; } } - // Po wysłaniu zamówień: przetwórz kolejkę sync (płatności/statusy oczekujące na apilo_order_id) - $orderAdminService->processApiloSyncQueue( 10 ); -} + 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 ) : []; -// 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 ); foreach ( $orders as $order ) { if ( $order['apilo_order_id'] ) { - $access_token = $integrationsRepository -> apiloGetAccessToken(); + $access_token = $integrationsRepository->apiloGetAccessToken(); $url = 'https://projectpro.apilo.com/rest/api/orders/' . $order['apilo_order_id'] . '/'; $ch = curl_init( $url ); @@ -565,70 +653,103 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se $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 = ( new \Domain\ShopStatus\ShopStatusRepository( $mdb ) )->getByIntegrationStatusId( 'apilo', (int)$responseData['status'] ); if ( $shop_status_id ) $orderAdminService->changeStatus( (int)$order['id'], $shop_status_id, false ); - \Domain\Integrations\ApiloLogger::log( $mdb, 'status_poll', (int)$order['id'], 'Status pobrany z Apilo (apilo_status: ' . $responseData['status'] . ', shop_status: ' . ($shop_status_id ?: 'brak mapowania') . ')', [ 'apilo_order_id' => $order['apilo_order_id'], 'http_code' => $http_code_poll, 'response' => $responseData ] ); + \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; +}); -/* 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 ) +// 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 ) { - $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' ) - ] ); + $price = $row['price_brutto_promo'] > 0 ? $row['price_brutto_promo'] : $row['price_brutto']; + if ( $price ) + { + $mdb->insert( 'pp_shop_product_price_history', [ + 'id_product' => $row['id'], + 'price' => $price, + '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'] . '

'; } - $mdb -> update( 'pp_shop_products', [ 'price_history_date' => date( 'Y-m-d' ) ], [ 'id' => $row['id'] ] ); + return true; +}); - $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 ) +// 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 ( $parent_id = $mdb -> get( 'pp_shop_products', 'parent_id', [ 'id' => $product1 ] ) ) - $product1 = $parent_id; - - foreach ( $products as $product2 ) + $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' => $product2 ] ) ) - $product2 = $parent_id; + if ( $parent_id = $mdb->get( 'pp_shop_products', 'parent_id', [ 'id' => $product1 ] ) ) + $product1 = $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 . '

'; -} + 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'] . '

'; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a823872..756124c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,27 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze. --- +## ver. 0.324 (2026-02-27) - System kolejki zadań cron + +- **NEW**: `Domain\CronJob\CronJobType` — stałe typów zadań, priorytetów, statusów, exponential backoff +- **NEW**: `Domain\CronJob\CronJobRepository` — CRUD na `pp_cron_jobs` + `pp_cron_schedules` (enqueue, fetchNext, markCompleted, markFailed, hasPendingJob, cleanup, recoverStuck, getDueSchedules, touchSchedule) +- **NEW**: `Domain\CronJob\CronJobProcessor` — orkiestracja: rejestracja handlerów, tworzenie scheduled jobs, przetwarzanie kolejki z priorytetami i retry/backoff +- **NEW**: Tabele `pp_cron_jobs` i `pp_cron_schedules` — kolejka zadań z priorytetami, exponential backoff, harmonogram cykliczny +- **REFACTOR**: `cron.php` — zastąpienie monolitycznego ~550 linii orkiestratorem z CronJobProcessor i zarejestrowanymi handlerami +- **REFACTOR**: `OrderAdminService::queueApiloSync()` — kolejkowanie przez `CronJobRepository::enqueue()` zamiast pliku JSON +- **REFACTOR**: `OrderAdminService::syncApiloPayment()`, `syncApiloStatus()` — zmiana z private na public (używane przez handlery cron) +- **REMOVED**: `OrderAdminService::processApiloSyncQueue()`, `loadApiloSyncQueue()`, `saveApiloSyncQueue()`, `apiloSyncQueuePath()`, stała `APILO_SYNC_QUEUE_FILE` +- **NEW**: Jednorazowa migracja JSON queue → DB w cron.php (automatyczna przy pierwszym uruchomieniu) +- **SECURITY**: `cron.php` — ochrona endpointu: wymaga `$config['cron_key']` w URL (`?key=...`) lub trybu CLI +- **FIX**: `CronJobRepository::fetchNext()` — re-SELECT po UPDATE eliminuje race condition przy równoległych workerach +- **FIX**: `cron.php` — null check dla `$mdb->query()` przed `->fetch()` / `->fetchAll()` (3 miejsca) +- **FIX**: `cron.php` — walidacja odpowiedzi curl w APILO_PRODUCT_SYNC i APILO_PRICELIST_SYNC (zapobiega zapisaniu null do bazy) +- **FIX**: DI wiring — `CronJobRepository` przekazywany do `OrderAdminService` we wszystkich 4 punktach: `admin\App`, `api\ApiRouter`, `front\App`, `cron.php` +- **TESTS**: 41 nowych testów CronJob (CronJobTypeTest, CronJobRepositoryTest, CronJobProcessorTest) +- **MIGRATION**: `migrations/0.324.sql` + +--- + ## ver. 0.323 (2026-02-24) - Import zdjęć, trwałe usuwanie, fix API upload - **FIX**: `IntegrationsRepository::shopproImportProduct()` — kompletny refactor importu zdjęć: walidacja HTTP response, curl timeouty, bezpieczna budowa URL, szczegółowy log do `logs/shoppro-import-debug.log` i `error_log`, czytelny komunikat z wynikiem diff --git a/docs/DATABASE_STRUCTURE.md b/docs/DATABASE_STRUCTURE.md index 80ca4d9..b061a2b 100644 --- a/docs/DATABASE_STRUCTURE.md +++ b/docs/DATABASE_STRUCTURE.md @@ -654,3 +654,49 @@ Tlumaczenia producentow (per jezyk). FK kaskadowe ON DELETE CASCADE. **Aktualizacja 2026-02-15 (ver. 0.273):** modul `/admin/shop_producer` korzysta z `Domain\Producer\ProducerRepository` przez `admin\Controllers\ShopProducerController`. Usunieto legacy `admin\controls\ShopProducer` i `admin\factory\ShopProducer`. `shop\Producer` dziala jako fasada do repozytorium. **Aktualizacja 2026-02-17 (ver. 0.291):** frontend `/shop_producer/*` korzysta z `Domain\Producer\ProducerRepository` przez `front\Controllers\ShopProducerController`; usunięto legacy `front\controls\ShopProducer` i `shop\Producer`. + +## pp_cron_jobs +Kolejka zadań cron z priorytetami i retry/backoff. + +| Kolumna | Opis | +|---------|------| +| id | PK auto increment | +| job_type | Typ zadania (VARCHAR 50) — np. apilo_send_order, price_history | +| status | ENUM: pending, processing, completed, failed, cancelled | +| priority | TINYINT — niższy = ważniejszy (10=krytyczny, 50=wysoki, 100=normalny, 200=niski) | +| payload | JSON z danymi zadania (TEXT NULL) | +| result | JSON z wynikiem (TEXT NULL) | +| attempts | Liczba prób (SMALLINT) | +| max_attempts | Maksymalna liczba prób (SMALLINT, domyślnie 10) | +| last_error | Ostatni błąd (VARCHAR 500) | +| scheduled_at | Kiedy zadanie ma być uruchomione (DATETIME) | +| started_at | Kiedy rozpoczęto przetwarzanie (DATETIME NULL) | +| completed_at | Kiedy zakończono (DATETIME NULL) | +| created_at | Data utworzenia (DATETIME) | +| updated_at | Data ostatniej modyfikacji (DATETIME, ON UPDATE) | + +**Indeksy:** idx_status_priority_scheduled (status, priority, scheduled_at), idx_job_type, idx_status + +**Używane w:** `Domain\CronJob\CronJobRepository`, `Domain\CronJob\CronJobProcessor` + +## pp_cron_schedules +Harmonogram cyklicznych zadań cron. + +| Kolumna | Opis | +|---------|------| +| id | PK auto increment | +| job_type | Typ zadania (VARCHAR 50, UNIQUE) | +| interval_seconds | Interwał uruchomienia w sekundach | +| priority | Priorytet tworzonych zadań (TINYINT) | +| max_attempts | Maks. prób dla tworzonych zadań (SMALLINT) | +| payload | Opcjonalny payload JSON (TEXT NULL) | +| enabled | Czy harmonogram aktywny (TINYINT 1) | +| last_run_at | Ostatnie uruchomienie (DATETIME NULL) | +| next_run_at | Następne planowane uruchomienie (DATETIME NULL) | +| created_at | Data utworzenia (DATETIME) | + +**Indeksy:** idx_enabled_next_run (enabled, next_run_at) + +**Używane w:** `Domain\CronJob\CronJobRepository`, `Domain\CronJob\CronJobProcessor` + +**Dodano w wersji 0.324.** diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index fb17c91..de757a2 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -16,6 +16,7 @@ Kazdy modul zawiera Repository (i opcjonalnie dodatkowe klasy). Konstruktor DI z | Category | CategoryRepository | drzewa kategorii, produkty w kategorii, Redis cache | | Client | ClientRepository | CRUD, auth, adresy, zamowienia | | Coupon | CouponRepository | kupony rabatowe, walidacja, uzycie | +| CronJob | CronJobType, CronJobRepository, CronJobProcessor | kolejka zadan cron (DB), priorytety, retry/backoff, harmonogram | | Dashboard | DashboardRepository | statystyki admin, Redis cache | | Dictionaries | DictionariesRepository | slowniki admin | | Integrations | IntegrationsRepository | Apilo sync, ustawienia | diff --git a/docs/TESTING.md b/docs/TESTING.md index b58ea82..2b56703 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -23,7 +23,7 @@ composer test # standard ## Aktualny stan ```text -OK (765 tests, 2153 assertions) +OK (805 tests, 2253 assertions) ``` Zweryfikowano: 2026-02-24 (ver. 0.318) @@ -52,6 +52,9 @@ tests/ | | |-- Cache/CacheRepositoryTest.php | | |-- Category/CategoryRepositoryTest.php | | |-- Coupon/CouponRepositoryTest.php +| | |-- CronJob/CronJobTypeTest.php +| | |-- CronJob/CronJobRepositoryTest.php +| | |-- CronJob/CronJobProcessorTest.php | | |-- Dictionaries/DictionariesRepositoryTest.php | | |-- Integrations/IntegrationsRepositoryTest.php | | |-- Languages/LanguagesRepositoryTest.php diff --git a/docs/TODO.md b/docs/TODO.md index 6db26cb..6af1edf 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1 +1,3 @@ -1. Dodać przycisk kopiowania przy atrybutach produktu w zamówieniu \ No newline at end of file +1. Dodać przycisk kopiowania przy atrybutach produktu w zamówieniu +2. Poprawić htaccess, żeby w nim nie było w ogóle adresów strona wszystko z bazy. +3. Dodać uwierzytelnienie dwuskładnikowe za pomocą aplikacji. \ No newline at end of file diff --git a/migrations/0.324.sql b/migrations/0.324.sql new file mode 100644 index 0000000..12ac46f --- /dev/null +++ b/migrations/0.324.sql @@ -0,0 +1,48 @@ +-- System kolejki zadań cron +-- Wersja: 0.324 + +CREATE TABLE IF NOT EXISTS pp_cron_jobs ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + job_type VARCHAR(50) NOT NULL, + status ENUM('pending','processing','completed','failed','cancelled') NOT NULL DEFAULT 'pending', + priority TINYINT UNSIGNED NOT NULL DEFAULT 100, + payload TEXT NULL, + result TEXT NULL, + attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0, + max_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 10, + last_error VARCHAR(500) NULL, + scheduled_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + started_at DATETIME NULL, + completed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_status_priority_scheduled (status, priority, scheduled_at), + INDEX idx_job_type (job_type), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS pp_cron_schedules ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + job_type VARCHAR(50) NOT NULL UNIQUE, + interval_seconds INT UNSIGNED NOT NULL, + priority TINYINT UNSIGNED NOT NULL DEFAULT 100, + max_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 3, + payload TEXT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + last_run_at DATETIME NULL, + next_run_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_enabled_next_run (enabled, next_run_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Harmonogramy zadań +INSERT INTO pp_cron_schedules (job_type, interval_seconds, priority, max_attempts) VALUES +('apilo_token_keepalive', 240, 10, 3), +('apilo_send_order', 60, 40, 10), +('apilo_product_sync', 600, 100, 3), +('apilo_pricelist_sync', 3600, 100, 3), +('apilo_status_poll', 600, 100, 3), +('price_history', 86400, 100, 3), +('order_analysis', 600, 100, 3), +('trustmate_invitation', 600, 200, 3), +('google_xml_feed', 3600, 200, 3); diff --git a/tests/Unit/Domain/CronJob/CronJobProcessorTest.php b/tests/Unit/Domain/CronJob/CronJobProcessorTest.php new file mode 100644 index 0000000..66574cb --- /dev/null +++ b/tests/Unit/Domain/CronJob/CronJobProcessorTest.php @@ -0,0 +1,301 @@ +mockRepo = $this->createMock(CronJobRepository::class); + $this->processor = new CronJobProcessor($this->mockRepo); + } + + // --- registerHandler --- + + public function testRegisterHandlerAndProcessJob(): void + { + $handlerCalled = false; + + $this->processor->registerHandler('test_job', function ($payload) use (&$handlerCalled) { + $handlerCalled = true; + return true; + }); + + $this->mockRepo->method('fetchNext')->willReturn([ + ['id' => 1, 'job_type' => 'test_job', 'payload' => null, 'attempts' => 1], + ]); + + $this->mockRepo->expects($this->once())->method('markCompleted')->with(1, null); + + $stats = $this->processor->processQueue(1); + + $this->assertTrue($handlerCalled); + $this->assertSame(1, $stats['processed']); + $this->assertSame(1, $stats['succeeded']); + $this->assertSame(0, $stats['failed']); + } + + // --- processQueue --- + + public function testProcessQueueReturnsEmptyStatsWhenNoJobs(): void + { + $this->mockRepo->method('fetchNext')->willReturn([]); + + $stats = $this->processor->processQueue(5); + + $this->assertSame(0, $stats['processed']); + $this->assertSame(0, $stats['succeeded']); + $this->assertSame(0, $stats['failed']); + $this->assertSame(0, $stats['skipped']); + } + + public function testProcessQueueHandlerReturnsFalse(): void + { + $this->processor->registerHandler('fail_job', function ($payload) { + return false; + }); + + $this->mockRepo->method('fetchNext')->willReturn([ + ['id' => 2, 'job_type' => 'fail_job', 'payload' => null, 'attempts' => 1], + ]); + + $this->mockRepo->expects($this->once())->method('markFailed') + ->with(2, 'Handler returned false', 1); + + $stats = $this->processor->processQueue(1); + + $this->assertSame(1, $stats['failed']); + $this->assertSame(0, $stats['succeeded']); + } + + public function testProcessQueueHandlerThrowsException(): void + { + $this->processor->registerHandler('error_job', function ($payload) { + throw new \RuntimeException('Connection failed'); + }); + + $this->mockRepo->method('fetchNext')->willReturn([ + ['id' => 3, 'job_type' => 'error_job', 'payload' => null, 'attempts' => 2], + ]); + + $this->mockRepo->expects($this->once())->method('markFailed') + ->with(3, 'Connection failed', 2); + + $stats = $this->processor->processQueue(1); + + $this->assertSame(1, $stats['failed']); + } + + public function testProcessQueueNoHandlerRegistered(): void + { + $this->mockRepo->method('fetchNext')->willReturn([ + ['id' => 4, 'job_type' => 'unknown_job', 'payload' => null, 'attempts' => 1], + ]); + + $this->mockRepo->expects($this->once())->method('markFailed') + ->with(4, $this->stringContains('No handler registered'), 1); + + $stats = $this->processor->processQueue(1); + + $this->assertSame(1, $stats['skipped']); + } + + public function testProcessQueueHandlerReturnsArray(): void + { + $resultData = ['synced' => true, 'items' => 5]; + + $this->processor->registerHandler('array_job', function ($payload) use ($resultData) { + return $resultData; + }); + + $this->mockRepo->method('fetchNext')->willReturn([ + ['id' => 5, 'job_type' => 'array_job', 'payload' => null, 'attempts' => 1], + ]); + + $this->mockRepo->expects($this->once())->method('markCompleted') + ->with(5, $resultData); + + $stats = $this->processor->processQueue(1); + + $this->assertSame(1, $stats['succeeded']); + } + + public function testProcessQueuePassesPayloadToHandler(): void + { + $receivedPayload = null; + + $this->processor->registerHandler('payload_job', function ($payload) use (&$receivedPayload) { + $receivedPayload = $payload; + return true; + }); + + $this->mockRepo->method('fetchNext')->willReturn([ + ['id' => 6, 'job_type' => 'payload_job', 'payload' => ['order_id' => 42], 'attempts' => 1], + ]); + + $this->mockRepo->method('markCompleted'); + + $this->processor->processQueue(1); + + $this->assertSame(['order_id' => 42], $receivedPayload); + } + + public function testProcessQueueMultipleJobs(): void + { + $this->processor->registerHandler('ok_job', function ($payload) { + return true; + }); + $this->processor->registerHandler('fail_job', function ($payload) { + return false; + }); + + $this->mockRepo->method('fetchNext')->willReturn([ + ['id' => 10, 'job_type' => 'ok_job', 'payload' => null, 'attempts' => 1], + ['id' => 11, 'job_type' => 'fail_job', 'payload' => null, 'attempts' => 1], + ['id' => 12, 'job_type' => 'ok_job', 'payload' => null, 'attempts' => 1], + ]); + + $stats = $this->processor->processQueue(10); + + $this->assertSame(3, $stats['processed']); + $this->assertSame(2, $stats['succeeded']); + $this->assertSame(1, $stats['failed']); + } + + // --- createScheduledJobs --- + + public function testCreateScheduledJobsFromDueSchedules(): void + { + $this->mockRepo->method('getDueSchedules')->willReturn([ + [ + 'id' => 1, + 'job_type' => 'price_history', + 'interval_seconds' => 86400, + 'priority' => 100, + 'max_attempts' => 3, + 'payload' => null, + ], + ]); + + $this->mockRepo->method('hasPendingJob')->willReturn(false); + + $this->mockRepo->expects($this->once())->method('enqueue') + ->with('price_history', null, 100, 3); + + $this->mockRepo->expects($this->once())->method('touchSchedule') + ->with(1, 86400); + + $created = $this->processor->createScheduledJobs(); + $this->assertSame(1, $created); + } + + public function testCreateScheduledJobsSkipsDuplicates(): void + { + $this->mockRepo->method('getDueSchedules')->willReturn([ + [ + 'id' => 2, + 'job_type' => 'apilo_send_order', + 'interval_seconds' => 60, + 'priority' => 50, + 'max_attempts' => 10, + 'payload' => null, + ], + ]); + + $this->mockRepo->method('hasPendingJob')->willReturn(true); + + $this->mockRepo->expects($this->never())->method('enqueue'); + // touchSchedule still called to prevent re-checking + $this->mockRepo->expects($this->once())->method('touchSchedule'); + + $created = $this->processor->createScheduledJobs(); + $this->assertSame(0, $created); + } + + public function testCreateScheduledJobsWithPayload(): void + { + $this->mockRepo->method('getDueSchedules')->willReturn([ + [ + 'id' => 3, + 'job_type' => 'custom_job', + 'interval_seconds' => 600, + 'priority' => 100, + 'max_attempts' => 3, + 'payload' => '{"key":"value"}', + ], + ]); + + $this->mockRepo->method('hasPendingJob')->willReturn(false); + + $this->mockRepo->expects($this->once())->method('enqueue') + ->with('custom_job', ['key' => 'value'], 100, 3); + + $this->processor->createScheduledJobs(); + } + + public function testCreateScheduledJobsReturnsZeroWhenNoSchedules(): void + { + $this->mockRepo->method('getDueSchedules')->willReturn([]); + + $created = $this->processor->createScheduledJobs(); + $this->assertSame(0, $created); + } + + // --- run --- + + public function testRunExecutesFullPipeline(): void + { + $this->mockRepo->expects($this->once())->method('recoverStuck')->with(30); + $this->mockRepo->method('getDueSchedules')->willReturn([]); + $this->mockRepo->method('fetchNext')->willReturn([]); + $this->mockRepo->expects($this->once())->method('cleanup')->with(30); + + $stats = $this->processor->run(20); + + $this->assertArrayHasKey('scheduled', $stats); + $this->assertArrayHasKey('processed', $stats); + $this->assertArrayHasKey('succeeded', $stats); + $this->assertArrayHasKey('failed', $stats); + $this->assertArrayHasKey('skipped', $stats); + } + + public function testRunReturnsScheduledCount(): void + { + $this->mockRepo->method('getDueSchedules')->willReturn([ + [ + 'id' => 1, + 'job_type' => 'job_a', + 'interval_seconds' => 60, + 'priority' => 100, + 'max_attempts' => 3, + 'payload' => null, + ], + [ + 'id' => 2, + 'job_type' => 'job_b', + 'interval_seconds' => 120, + 'priority' => 100, + 'max_attempts' => 3, + 'payload' => null, + ], + ]); + + $this->mockRepo->method('hasPendingJob')->willReturn(false); + $this->mockRepo->method('fetchNext')->willReturn([]); + + $stats = $this->processor->run(20); + + $this->assertSame(2, $stats['scheduled']); + } +} diff --git a/tests/Unit/Domain/CronJob/CronJobRepositoryTest.php b/tests/Unit/Domain/CronJob/CronJobRepositoryTest.php new file mode 100644 index 0000000..2948877 --- /dev/null +++ b/tests/Unit/Domain/CronJob/CronJobRepositoryTest.php @@ -0,0 +1,385 @@ +mockDb = $this->createMock(\medoo::class); + $this->repo = new CronJobRepository($this->mockDb); + } + + // --- enqueue --- + + public function testEnqueueInsertsJobAndReturnsId(): void + { + $this->mockDb->expects($this->once()) + ->method('insert') + ->with( + 'pp_cron_jobs', + $this->callback(function ($data) { + return $data['job_type'] === 'apilo_send_order' + && $data['status'] === 'pending' + && $data['priority'] === 50 + && $data['max_attempts'] === 10 + && isset($data['scheduled_at']); + }) + ); + + $this->mockDb->method('id')->willReturn('42'); + + $id = $this->repo->enqueue('apilo_send_order', null, CronJobType::PRIORITY_HIGH); + + $this->assertSame(42, $id); + } + + public function testEnqueueWithPayloadEncodesJson(): void + { + $payload = ['order_id' => 123, 'action' => 'sync']; + + $this->mockDb->expects($this->once()) + ->method('insert') + ->with( + 'pp_cron_jobs', + $this->callback(function ($data) use ($payload) { + return $data['payload'] === json_encode($payload); + }) + ); + + $this->mockDb->method('id')->willReturn('1'); + + $this->repo->enqueue('apilo_sync_payment', $payload); + } + + public function testEnqueueWithoutPayloadDoesNotSetPayloadKey(): void + { + $this->mockDb->expects($this->once()) + ->method('insert') + ->with( + 'pp_cron_jobs', + $this->callback(function ($data) { + return !array_key_exists('payload', $data); + }) + ); + + $this->mockDb->method('id')->willReturn('1'); + + $this->repo->enqueue('price_history'); + } + + public function testEnqueueWithScheduledAt(): void + { + $scheduled = '2026-03-01 10:00:00'; + + $this->mockDb->expects($this->once()) + ->method('insert') + ->with( + 'pp_cron_jobs', + $this->callback(function ($data) use ($scheduled) { + return $data['scheduled_at'] === $scheduled; + }) + ); + + $this->mockDb->method('id')->willReturn('1'); + + $this->repo->enqueue('price_history', null, CronJobType::PRIORITY_NORMAL, 10, $scheduled); + } + + public function testEnqueueReturnsNullOnFailure(): void + { + $this->mockDb->method('insert'); + $this->mockDb->method('id')->willReturn(null); + + $id = $this->repo->enqueue('test_job'); + $this->assertNull($id); + } + + // --- fetchNext --- + + public function testFetchNextReturnsEmptyArrayWhenNoJobs(): void + { + $this->mockDb->method('select')->willReturn([]); + + $result = $this->repo->fetchNext(5); + $this->assertSame([], $result); + } + + public function testFetchNextUpdatesStatusToProcessing(): void + { + $pendingJobs = [ + ['id' => 1, 'job_type' => 'test', 'status' => 'pending', 'payload' => null], + ['id' => 2, 'job_type' => 'test2', 'status' => 'pending', 'payload' => '{"x":1}'], + ]; + $claimedJobs = [ + ['id' => 1, 'job_type' => 'test', 'status' => 'processing', 'payload' => null], + ['id' => 2, 'job_type' => 'test2', 'status' => 'processing', 'payload' => '{"x":1}'], + ]; + + $this->mockDb->method('select') + ->willReturnOnConsecutiveCalls($pendingJobs, $claimedJobs); + + $this->mockDb->expects($this->once()) + ->method('update') + ->with( + 'pp_cron_jobs', + $this->callback(function ($data) { + return $data['status'] === 'processing' + && isset($data['started_at']); + }), + $this->callback(function ($where) { + return $where['id'] === [1, 2] + && $where['status'] === 'pending'; + }) + ); + + $result = $this->repo->fetchNext(5); + + $this->assertCount(2, $result); + $this->assertSame('processing', $result[0]['status']); + $this->assertSame('processing', $result[1]['status']); + } + + public function testFetchNextDecodesPayloadJson(): void + { + $jobs = [ + ['id' => 1, 'job_type' => 'test', 'status' => 'pending', 'payload' => '{"order_id":99}'], + ]; + + $this->mockDb->method('select')->willReturn($jobs); + $this->mockDb->method('update'); + + $result = $this->repo->fetchNext(1); + + $this->assertSame(['order_id' => 99], $result[0]['payload']); + } + + // --- markCompleted --- + + public function testMarkCompletedUpdatesStatus(): void + { + $this->mockDb->expects($this->once()) + ->method('update') + ->with( + 'pp_cron_jobs', + $this->callback(function ($data) { + return $data['status'] === 'completed' + && isset($data['completed_at']); + }), + ['id' => 5] + ); + + $this->repo->markCompleted(5); + } + + public function testMarkCompletedWithResult(): void + { + $result = ['synced' => true, 'count' => 3]; + + $this->mockDb->expects($this->once()) + ->method('update') + ->with( + 'pp_cron_jobs', + $this->callback(function ($data) use ($result) { + return $data['result'] === json_encode($result); + }), + ['id' => 7] + ); + + $this->repo->markCompleted(7, $result); + } + + // --- markFailed --- + + public function testMarkFailedWithRetriesLeft(): void + { + // Job with attempts < max_attempts → reschedule with backoff + $this->mockDb->method('get')->willReturn([ + 'max_attempts' => 10, + 'attempts' => 2, + ]); + + $this->mockDb->expects($this->once()) + ->method('update') + ->with( + 'pp_cron_jobs', + $this->callback(function ($data) { + return $data['status'] === 'pending' + && isset($data['scheduled_at']) + && isset($data['last_error']); + }), + ['id' => 3] + ); + + $this->repo->markFailed(3, 'Connection timeout', 2); + } + + public function testMarkFailedWhenMaxAttemptsReached(): void + { + // Job with attempts >= max_attempts → permanent failure + $this->mockDb->method('get')->willReturn([ + 'max_attempts' => 3, + 'attempts' => 3, + ]); + + $this->mockDb->expects($this->once()) + ->method('update') + ->with( + 'pp_cron_jobs', + $this->callback(function ($data) { + return $data['status'] === 'failed' + && isset($data['completed_at']); + }), + ['id' => 4] + ); + + $this->repo->markFailed(4, 'Max retries exceeded'); + } + + public function testMarkFailedTruncatesErrorTo500Chars(): void + { + $this->mockDb->method('get')->willReturn([ + 'max_attempts' => 10, + 'attempts' => 1, + ]); + + $longError = str_repeat('x', 600); + + $this->mockDb->expects($this->once()) + ->method('update') + ->with( + 'pp_cron_jobs', + $this->callback(function ($data) { + return mb_strlen($data['last_error']) <= 500; + }), + ['id' => 1] + ); + + $this->repo->markFailed(1, $longError); + } + + // --- hasPendingJob --- + + public function testHasPendingJobReturnsTrueWhenExists(): void + { + $this->mockDb->method('count') + ->with('pp_cron_jobs', $this->callback(function ($where) { + return $where['job_type'] === 'apilo_sync_payment' + && $where['status'] === ['pending', 'processing']; + })) + ->willReturn(1); + + $this->assertTrue($this->repo->hasPendingJob('apilo_sync_payment')); + } + + public function testHasPendingJobReturnsFalseWhenNone(): void + { + $this->mockDb->method('count')->willReturn(0); + + $this->assertFalse($this->repo->hasPendingJob('apilo_sync_payment')); + } + + public function testHasPendingJobWithPayloadMatch(): void + { + $payload = ['order_id' => 42]; + + $this->mockDb->expects($this->once()) + ->method('count') + ->with('pp_cron_jobs', $this->callback(function ($where) use ($payload) { + return $where['payload'] === json_encode($payload); + })) + ->willReturn(1); + + $this->assertTrue($this->repo->hasPendingJob('apilo_sync_payment', $payload)); + } + + // --- cleanup --- + + public function testCleanupDeletesOldCompletedJobs(): void + { + $this->mockDb->expects($this->once()) + ->method('delete') + ->with( + 'pp_cron_jobs', + $this->callback(function ($where) { + return $where['status'] === ['completed', 'failed', 'cancelled'] + && isset($where['updated_at[<]']); + }) + ); + + $this->repo->cleanup(30); + } + + // --- recoverStuck --- + + public function testRecoverStuckResetsProcessingJobs(): void + { + $this->mockDb->expects($this->once()) + ->method('update') + ->with( + 'pp_cron_jobs', + $this->callback(function ($data) { + return $data['status'] === 'pending' + && $data['started_at'] === null; + }), + $this->callback(function ($where) { + return $where['status'] === 'processing' + && isset($where['started_at[<]']); + }) + ); + + $this->repo->recoverStuck(30); + } + + // --- getDueSchedules --- + + public function testGetDueSchedulesReturnsEnabledSchedules(): void + { + $schedules = [ + ['id' => 1, 'job_type' => 'price_history', 'interval_seconds' => 86400], + ]; + + $this->mockDb->expects($this->once()) + ->method('select') + ->with( + 'pp_cron_schedules', + '*', + $this->callback(function ($where) { + return $where['enabled'] === 1 + && isset($where['OR']); + }) + ) + ->willReturn($schedules); + + $result = $this->repo->getDueSchedules(); + $this->assertCount(1, $result); + } + + // --- touchSchedule --- + + public function testTouchScheduleUpdatesTimestamps(): void + { + $this->mockDb->expects($this->once()) + ->method('update') + ->with( + 'pp_cron_schedules', + $this->callback(function ($data) { + return isset($data['last_run_at']) + && isset($data['next_run_at']); + }), + ['id' => 5] + ); + + $this->repo->touchSchedule(5, 3600); + } +} diff --git a/tests/Unit/Domain/CronJob/CronJobTypeTest.php b/tests/Unit/Domain/CronJob/CronJobTypeTest.php new file mode 100644 index 0000000..90aa14c --- /dev/null +++ b/tests/Unit/Domain/CronJob/CronJobTypeTest.php @@ -0,0 +1,97 @@ +assertContains('apilo_token_keepalive', $types); + $this->assertContains('apilo_send_order', $types); + $this->assertContains('apilo_sync_payment', $types); + $this->assertContains('apilo_sync_status', $types); + $this->assertContains('apilo_product_sync', $types); + $this->assertContains('apilo_pricelist_sync', $types); + $this->assertContains('apilo_status_poll', $types); + $this->assertContains('price_history', $types); + $this->assertContains('order_analysis', $types); + $this->assertContains('trustmate_invitation', $types); + $this->assertContains('google_xml_feed', $types); + $this->assertCount(11, $types); + } + + public function testAllStatusesReturnsAllStatuses(): void + { + $statuses = CronJobType::allStatuses(); + + $this->assertContains('pending', $statuses); + $this->assertContains('processing', $statuses); + $this->assertContains('completed', $statuses); + $this->assertContains('failed', $statuses); + $this->assertContains('cancelled', $statuses); + $this->assertCount(5, $statuses); + } + + public function testPriorityConstants(): void + { + $this->assertSame(10, CronJobType::PRIORITY_CRITICAL); + $this->assertSame(40, CronJobType::PRIORITY_SEND_ORDER); + $this->assertSame(50, CronJobType::PRIORITY_HIGH); + $this->assertSame(100, CronJobType::PRIORITY_NORMAL); + $this->assertSame(200, CronJobType::PRIORITY_LOW); + + // Lower value = higher priority + $this->assertLessThan(CronJobType::PRIORITY_SEND_ORDER, CronJobType::PRIORITY_CRITICAL); + $this->assertLessThan(CronJobType::PRIORITY_HIGH, CronJobType::PRIORITY_SEND_ORDER); + $this->assertLessThan(CronJobType::PRIORITY_NORMAL, CronJobType::PRIORITY_HIGH); + $this->assertLessThan(CronJobType::PRIORITY_LOW, CronJobType::PRIORITY_NORMAL); + } + + public function testCalculateBackoffExponential(): void + { + // Attempt 1: 60s + $this->assertSame(60, CronJobType::calculateBackoff(1)); + // Attempt 2: 120s + $this->assertSame(120, CronJobType::calculateBackoff(2)); + // Attempt 3: 240s + $this->assertSame(240, CronJobType::calculateBackoff(3)); + // Attempt 4: 480s + $this->assertSame(480, CronJobType::calculateBackoff(4)); + } + + public function testCalculateBackoffCapsAtMax(): void + { + // Very high attempt should cap at MAX_BACKOFF_SECONDS (3600) + $this->assertSame(3600, CronJobType::calculateBackoff(10)); + $this->assertSame(3600, CronJobType::calculateBackoff(20)); + } + + public function testJobTypeConstantsMatchStrings(): void + { + $this->assertSame('apilo_token_keepalive', CronJobType::APILO_TOKEN_KEEPALIVE); + $this->assertSame('apilo_send_order', CronJobType::APILO_SEND_ORDER); + $this->assertSame('apilo_sync_payment', CronJobType::APILO_SYNC_PAYMENT); + $this->assertSame('apilo_sync_status', CronJobType::APILO_SYNC_STATUS); + $this->assertSame('apilo_product_sync', CronJobType::APILO_PRODUCT_SYNC); + $this->assertSame('apilo_pricelist_sync', CronJobType::APILO_PRICELIST_SYNC); + $this->assertSame('apilo_status_poll', CronJobType::APILO_STATUS_POLL); + $this->assertSame('price_history', CronJobType::PRICE_HISTORY); + $this->assertSame('order_analysis', CronJobType::ORDER_ANALYSIS); + $this->assertSame('trustmate_invitation', CronJobType::TRUSTMATE_INVITATION); + $this->assertSame('google_xml_feed', CronJobType::GOOGLE_XML_FEED); + } + + public function testStatusConstantsMatchStrings(): void + { + $this->assertSame('pending', CronJobType::STATUS_PENDING); + $this->assertSame('processing', CronJobType::STATUS_PROCESSING); + $this->assertSame('completed', CronJobType::STATUS_COMPLETED); + $this->assertSame('failed', CronJobType::STATUS_FAILED); + $this->assertSame('cancelled', CronJobType::STATUS_CANCELLED); + } +} diff --git a/tests/Unit/Domain/Order/OrderAdminServiceTest.php b/tests/Unit/Domain/Order/OrderAdminServiceTest.php index b74c78d..b28c760 100644 --- a/tests/Unit/Domain/Order/OrderAdminServiceTest.php +++ b/tests/Unit/Domain/Order/OrderAdminServiceTest.php @@ -7,6 +7,8 @@ use Domain\Order\OrderRepository; use Domain\Product\ProductRepository; use Domain\Settings\SettingsRepository; use Domain\Transport\TransportRepository; +use Domain\CronJob\CronJobRepository; +use Domain\CronJob\CronJobType; class OrderAdminServiceTest extends TestCase { @@ -229,108 +231,14 @@ class OrderAdminServiceTest extends TestCase } // ========================================================================= - // processApiloSyncQueue — awaiting apilo_order_id + // queueApiloSync — DB-based via CronJobRepository // ========================================================================= - private function getQueuePath(): string + public function testConstructorAcceptsCronJobRepo(): void { - // Musi odpowiadać ścieżce w OrderAdminService::apiloSyncQueuePath() - // dirname(autoload/Domain/Order/, 2) = autoload/ - return dirname(__DIR__, 4) . '/autoload/temp/apilo-sync-queue.json'; - } - - private function writeQueue(array $queue): void - { - $path = $this->getQueuePath(); - $dir = dirname($path); - if (!is_dir($dir)) { - mkdir($dir, 0777, true); - } - file_put_contents($path, json_encode($queue, JSON_PRETTY_PRINT)); - } - - private function readQueue(): array - { - $path = $this->getQueuePath(); - if (!file_exists($path)) return []; - $content = file_get_contents($path); - return $content ? json_decode($content, true) : []; - } - - protected function tearDown(): void - { - $path = $this->getQueuePath(); - if (file_exists($path)) { - unlink($path); - } - parent::tearDown(); - } - - public function testProcessApiloSyncQueueKeepsTaskWhenApiloOrderIdIsNull(): void - { - // Zamówienie bez apilo_order_id — task powinien zostać w kolejce - $this->writeQueue([ - '42' => [ - 'order_id' => 42, - 'payment' => 1, - 'status' => null, - 'attempts' => 0, - 'last_error' => 'awaiting_apilo_order', - 'updated_at' => '2026-01-01 00:00:00', - ], - ]); - $orderRepo = $this->createMock(OrderRepository::class); - $orderRepo->method('findRawById') - ->with(42) - ->willReturn([ - 'id' => 42, - 'apilo_order_id' => null, - 'paid' => 1, - 'summary' => '100.00', - ]); - - $service = new OrderAdminService($orderRepo); - $processed = $service->processApiloSyncQueue(10); - - $this->assertSame(1, $processed); - - $queue = $this->readQueue(); - $this->assertArrayHasKey('42', $queue); - $this->assertSame('awaiting_apilo_order', $queue['42']['last_error']); - $this->assertSame(1, $queue['42']['attempts']); - } - - public function testProcessApiloSyncQueueRemovesTaskAfterMaxAttempts(): void - { - // Task z 49 próbami — limit to 50, więc powinien zostać usunięty - $this->writeQueue([ - '42' => [ - 'order_id' => 42, - 'payment' => 1, - 'status' => null, - 'attempts' => 49, - 'last_error' => 'awaiting_apilo_order', - 'updated_at' => '2026-01-01 00:00:00', - ], - ]); - - $orderRepo = $this->createMock(OrderRepository::class); - $orderRepo->method('findRawById') - ->with(42) - ->willReturn([ - 'id' => 42, - 'apilo_order_id' => null, - 'paid' => 1, - 'summary' => '100.00', - ]); - - $service = new OrderAdminService($orderRepo); - $processed = $service->processApiloSyncQueue(10); - - $this->assertSame(1, $processed); - - $queue = $this->readQueue(); - $this->assertArrayNotHasKey('42', $queue); + $cronJobRepo = $this->createMock(CronJobRepository::class); + $service = new OrderAdminService($orderRepo, null, null, null, $cronJobRepo); + $this->assertInstanceOf(OrderAdminService::class, $service); } }