feat: database-backed cron job queue replacing JSON file system

Replace file-based JSON cron queue with DB-backed job queue (pp_cron_jobs,
pp_cron_schedules). New Domain\CronJob module: CronJobType (constants),
CronJobRepository (CRUD, atomic fetch, retry/backoff), CronJobProcessor
(orchestration with handler registration). Priority ordering guarantees
apilo_send_order (40) runs before sync tasks (50). Includes cron.php auth
protection, race condition fix in fetchNext, API response validation,
and DI wiring across all entry points. 41 new tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 13:29:11 +01:00
parent 97d7473753
commit 52119a0724
19 changed files with 1723 additions and 417 deletions

View File

@@ -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
@@ -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