executor = $executor ?? new economic_transfer_executor(); economic_transfer_queue_schema_bootstrap::ensureTables(); } /** * @throws Exception */ public function enqueue(string $transfer_type, array $payload, int $created_by = 0, int $max_attempts = 3): array { global $db; $created_by = max(0, $created_by); $max_attempts = max(1, min(10, $max_attempts)); $transfer_type = $this->validateTransferType($transfer_type); $payload = $this->normalizePayloadForTransferType($transfer_type, $payload, $created_by); $active_job = $this->findActiveJobByTarget($transfer_type, $payload, $created_by); if ($active_job !== null) { $target_label = $this->buildTargetLabel($transfer_type, $payload); $this->logQueueEvent( 1, $created_by, 'ECONOMIC_TRANSFER_JOB_DEDUPED', 'Transfer job deduped for active target (' . $target_label . '), returning existing job #' . (int)($active_job['id'] ?? 0) ); return $active_job; } $payload_json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($payload_json === false) { throw new Exception('Failed to serialize queue payload'); } $stmt = $db->prepare( "INSERT INTO economic_transfer_queue_jobs (transfer_type, payload_json, status, progress_percent, progress_message, attempts, max_attempts, created_by) VALUES (?, ?, ?, 0, 'Queued', 0, ?, ?)" ); if (!$stmt) { throw new Exception('Failed to prepare queue insert statement'); } $status = self::STATUS_QUEUED; $stmt->bind_param('sssii', $transfer_type, $payload_json, $status, $max_attempts, $created_by); if (!$stmt->execute()) { throw new Exception('Failed to enqueue transfer job'); } $job_id = (int)$db->insert_id(); $stmt->close(); $this->logQueueEvent( 1, $created_by, 'ECONOMIC_TRANSFER_JOB_ENQUEUED', 'Transfer job #' . $job_id . ' queued: ' . $transfer_type ); $job = $this->getJobById($job_id); if ($job === null) { throw new Exception('Failed to load queued transfer job'); } return $job; } public function getJobById(int $job_id): ?array { global $db; $stmt = $db->prepare("SELECT * FROM economic_transfer_queue_jobs WHERE id = ? LIMIT 1"); if (!$stmt) { return null; } $stmt->bind_param('i', $job_id); if (!$stmt->execute()) { $stmt->close(); return null; } $result = $stmt->get_result(); $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; $stmt->close(); if (!$row) { return null; } return $this->normalizeJobRow($row); } public function listJobs(array $statuses = [], int $limit = 50, int $offset = 0, ?string $transfer_type = null): array { global $db; $limit = max(1, min(500, $limit)); $offset = max(0, $offset); $where = $this->buildListJobsWhereClause($statuses, $transfer_type); $sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset"; $result = $db->query($sql); if (!$result instanceof mysqli_result) { return []; } $jobs = []; while ($row = $result->fetch_assoc()) { $jobs[] = $this->normalizeJobRow($row); } return $jobs; } public function getJobByIdForUser(int $job_id, int $created_by): ?array { global $db; $job_id = max(0, $job_id); $created_by = max(0, $created_by); if ($job_id < 1 || $created_by < 1) { return null; } $stmt = $db->prepare("SELECT * FROM economic_transfer_queue_jobs WHERE id = ? AND created_by = ? LIMIT 1"); if (!$stmt) { return null; } $stmt->bind_param('ii', $job_id, $created_by); if (!$stmt->execute()) { $stmt->close(); return null; } $result = $stmt->get_result(); $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; $stmt->close(); if (!$row) { return null; } return $this->normalizeJobRow($row); } public function listJobsForCreatedBy(array $statuses = [], int $limit = 50, int $offset = 0, ?string $transfer_type = null, int $created_by = 0): array { global $db; $created_by = max(0, $created_by); if ($created_by < 1) { return []; } $limit = max(1, min(500, $limit)); $offset = max(0, $offset); $where = $this->buildListJobsWhereClause($statuses, $transfer_type); $where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by; $sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset"; $result = $db->query($sql); if (!$result instanceof mysqli_result) { return []; } $jobs = []; while ($row = $result->fetch_assoc()) { $jobs[] = $this->normalizeJobRow($row); } return $jobs; } public function countJobsForCreatedBy(array $statuses = [], ?string $transfer_type = null, int $created_by = 0): int { global $db; $created_by = max(0, $created_by); if ($created_by < 1) { return 0; } $where = $this->buildListJobsWhereClause($statuses, $transfer_type); $where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by; $sql = "SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs $where"; $result = $db->query($sql); if (!$result instanceof mysqli_result) { return 0; } $row = $result->fetch_assoc(); if (!is_array($row) || !isset($row['total'])) { return 0; } return max(0, (int)$row['total']); } public function countJobs(array $statuses = [], ?string $transfer_type = null): int { global $db; $where = $this->buildListJobsWhereClause($statuses, $transfer_type); $sql = "SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs $where"; $result = $db->query($sql); if (!$result instanceof mysqli_result) { return 0; } $row = $result->fetch_assoc(); if (!is_array($row) || !isset($row['total'])) { return 0; } return max(0, (int)$row['total']); } public function listMonitorJobsForUser(int $user_id, int $limit = 50, ?string $transfer_type = null): array { global $db; $user_id = max(0, $user_id); $limit = max(1, min(100, $limit)); try { $normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== '' ? $this->validateTransferType($transfer_type) : null; } catch (Exception) { return []; } $transfer_condition = ''; if ($normalized_transfer_type !== null) { $transfer_condition = "AND q.transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'"; } $sql = "SELECT q.* FROM economic_transfer_queue_jobs q LEFT JOIN economic_transfer_queue_job_dismissals d ON d.queue_job_id = q.id AND d.user_id = $user_id AND d.dismissed_status = q.status WHERE q.created_by = $user_id $transfer_condition AND ( q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "') OR d.queue_job_id IS NULL ) ORDER BY CASE WHEN q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "') THEN 0 ELSE 1 END, q.id DESC LIMIT $limit"; $result = $db->query($sql); if (!$result instanceof mysqli_result) { return []; } $jobs = []; while ($row = $result->fetch_assoc()) { $jobs[] = $this->normalizeJobRow($row); } return $jobs; } /** * @throws Exception */ public function dismissTerminalJobForUser(int $job_id, int $user_id): array { global $db; $job_id = max(0, $job_id); $user_id = max(0, $user_id); if ($job_id < 1 || $user_id < 1) { throw new Exception('Queue job and user are required'); } $job = $this->getJobByIdForUser($job_id, $user_id); if ($job === null) { throw new Exception('Queue job not found'); } $status = strtoupper((string)($job['status'] ?? '')); if (!in_array($status, [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) { throw new Exception('Only completed or failed queue jobs can be dismissed'); } $stmt = $db->prepare( "INSERT INTO economic_transfer_queue_job_dismissals (queue_job_id, user_id, dismissed_status, dismissed_at) VALUES (?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()" ); if (!$stmt) { throw new Exception('Failed to prepare queue dismissal statement'); } $stmt->bind_param('iis', $job_id, $user_id, $status); if (!$stmt->execute()) { $stmt->close(); throw new Exception('Failed to dismiss queue job'); } $stmt->close(); return $job; } public function dismissTerminalJobsForUser(int $user_id, ?string $transfer_type = null): int { global $db; $user_id = max(0, $user_id); if ($user_id < 1) { return 0; } try { $normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== '' ? $this->validateTransferType($transfer_type) : null; } catch (Exception) { return 0; } $transfer_condition = ''; if ($normalized_transfer_type !== null) { $transfer_condition = "AND q.transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'"; } $sql = "INSERT INTO economic_transfer_queue_job_dismissals (queue_job_id, user_id, dismissed_status, dismissed_at) SELECT q.id, $user_id, q.status, NOW() FROM economic_transfer_queue_jobs q LEFT JOIN economic_transfer_queue_job_dismissals d ON d.queue_job_id = q.id AND d.user_id = $user_id AND d.dismissed_status = q.status WHERE q.created_by = $user_id AND q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "') $transfer_condition AND d.queue_job_id IS NULL ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()"; $db->query($sql); return max(0, (int)($db->affected_rows ?? 0)); } /** * @throws Exception */ public function retryJob(int $job_id): array { return $this->retryJobInternal($job_id); } public function retryJobForUser(int $job_id, int $created_by): array { return $this->retryJobInternal($job_id, $created_by); } private function retryJobInternal(int $job_id, ?int $created_by = null): array { global $db; $job_id = max(0, $job_id); $created_by = $created_by === null ? null : max(0, $created_by); $existing_job = $created_by === null ? $this->getJobById($job_id) : $this->getJobByIdForUser($job_id, $created_by); if ($existing_job === null) { throw new Exception('Queue job not found'); } if ((string)($existing_job['status'] ?? '') !== self::STATUS_FAILED) { throw new Exception('Only failed jobs can be retried'); } if ((int)($existing_job['attempts'] ?? 0) >= (int)($existing_job['max_attempts'] ?? 1)) { throw new Exception('Queue job reached max retry attempts'); } $sql = "UPDATE economic_transfer_queue_jobs SET status = ?, progress_percent = 0, progress_message = 'Queued for retry', error_message = NULL, result_json = NULL, started_at = NULL, completed_at = NULL, locked_at = NULL WHERE id = ? AND status = ?"; if ($created_by !== null) { $sql .= " AND created_by = ?"; } $stmt = $db->prepare($sql); if (!$stmt) { throw new Exception('Failed to prepare retry statement'); } $queued = self::STATUS_QUEUED; $failed = self::STATUS_FAILED; if ($created_by !== null) { $stmt->bind_param('sisi', $queued, $job_id, $failed, $created_by); } else { $stmt->bind_param('sis', $queued, $job_id, $failed); } $stmt->execute(); $affected = $stmt->affected_rows; $stmt->close(); if ($affected < 1) { throw new Exception('Failed to retry queue job'); } $this->clearDismissalsForJob($job_id); $job = $created_by === null ? $this->getJobById($job_id) : $this->getJobByIdForUser($job_id, $created_by); if ($job === null) { throw new Exception('Retry updated job could not be loaded'); } return $job; } public function processPending(int $limit = 5): array { return $this->processPendingInternal($limit); } /** * @throws Exception */ public function processPendingByTransferType(string $transfer_type, int $limit = 10): array { return $this->processPendingInternal($limit, $this->validateTransferType($transfer_type)); } private function processPendingInternal(int $limit = 5, ?string $transfer_type = null): array { $limit = max(1, min(100, $limit)); $this->releaseStaleProcessingLocks(); $processed = 0; $completed = 0; $failed = 0; $jobs = []; $empty_claims = 0; for ($i = 0; $i < $limit; $i++) { $job = $this->claimNextJob($transfer_type); if ($job === null) { $empty_claims++; if ($empty_claims >= 3) { break; } continue; } $empty_claims = 0; $processed++; $jobs[] = $job['id']; $this->updateProgress((int)$job['id'], 15, 'Running transfer'); try { $result = $this->executeJob($job); $this->markCompleted((int)$job['id'], $result); $completed++; } catch (Exception $e) { $this->markFailed((int)$job['id'], $e->getMessage()); $failed++; } catch (\Throwable $e) { $this->markFailed((int)$job['id'], $e->getMessage()); $failed++; } } return [ 'processed' => $processed, 'completed' => $completed, 'failed' => $failed, 'jobs' => $jobs, ]; } private function claimNextJob(?string $transfer_type = null): ?array { global $db; $sql = "SELECT id FROM economic_transfer_queue_jobs WHERE status = ? AND attempts < max_attempts AND (next_retry_at IS NULL OR next_retry_at <= NOW())"; if ($transfer_type !== null) { $sql .= " AND transfer_type = ?"; } $sql .= " ORDER BY id ASC LIMIT 1"; $stmt = $db->prepare($sql); if (!$stmt) { return null; } $queued = self::STATUS_QUEUED; if ($transfer_type !== null) { $stmt->bind_param('ss', $queued, $transfer_type); } else { $stmt->bind_param('s', $queued); } if (!$stmt->execute()) { $stmt->close(); return null; } $result = $stmt->get_result(); $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; $stmt->close(); if (!$row || !isset($row['id'])) { return null; } $job_id = (int)$row['id']; $stmt = $db->prepare( "UPDATE economic_transfer_queue_jobs SET status = ?, progress_percent = 5, progress_message = 'Processing', started_at = NOW(), locked_at = NOW() WHERE id = ? AND status = ?" ); if (!$stmt) { return null; } $processing = self::STATUS_PROCESSING; $stmt->bind_param('sis', $processing, $job_id, $queued); $stmt->execute(); $affected = $stmt->affected_rows; $stmt->close(); if ($affected < 1) { return null; } return $this->getJobById($job_id); } /** * @throws Exception */ private function executeJob(array $job): array { $payload = (array)($job['payload'] ?? []); $transfer_type = (string)($job['transfer_type'] ?? ''); $requested_by = (int)($payload['requested_by'] ?? ($job['created_by'] ?? 0)); $this->updateProgress((int)$job['id'], 40, 'Validating job payload'); return match ($transfer_type) { self::TYPE_ORDER_DRAFT_EXPORT => $this->executeOrderDraftExport($job, $payload, $requested_by), self::TYPE_ORDER_INVOICE_EXPORT => $this->executeOrderInvoiceExport($job, $payload, $requested_by), self::TYPE_COLLECTED_INVOICE_EXPORT => $this->executeCollectedInvoiceExport($job, $payload, $requested_by), default => throw new Exception('Unsupported transfer type: ' . $transfer_type), }; } /** * @throws Exception */ private function executeOrderDraftExport(array $job, array $payload, int $requested_by): array { $order_id = (int)($payload['order_id'] ?? 0); if ($order_id < 1) { throw new Exception('order_id is required'); } $this->updateProgress((int)$job['id'], 65, 'Exporting order draft invoice'); return $this->executor->exportOrderDraftInvoice($order_id, $requested_by); } /** * @throws Exception */ private function executeOrderInvoiceExport(array $job, array $payload, int $requested_by): array { $order_id = (int)($payload['order_id'] ?? 0); if ($order_id < 1) { throw new Exception('order_id is required'); } $this->updateProgress((int)$job['id'], 65, 'Exporting booked invoice'); return $this->executor->exportOrderInvoice($order_id, $requested_by); } /** * @throws Exception */ private function executeCollectedInvoiceExport(array $job, array $payload, int $requested_by): array { $collected_invoice_id = (int)($payload['collected_invoice_id'] ?? 0); if ($collected_invoice_id < 1) { throw new Exception('collected_invoice_id is required'); } $send_as_is = (bool)($payload['send_as_is'] ?? false); $this->updateProgress((int)$job['id'], 65, 'Exporting collected invoice'); return $this->executor->exportCollectedInvoice($collected_invoice_id, $send_as_is, $requested_by); } private function updateProgress(int $job_id, int $percent, string $message): void { global $db; $percent = max(0, min(100, $percent)); $escaped_message = $db->escape_string($message); $sql = "UPDATE economic_transfer_queue_jobs SET progress_percent = $percent, progress_message = '$escaped_message' WHERE id = $job_id"; $db->query($sql); } private function markCompleted(int $job_id, array $result): void { global $db; $result_json = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($result_json === false) { $result_json = json_encode(['result' => 'serialization_error']); } $escaped_result = $db->escape_string((string)$result_json); $sql = "UPDATE economic_transfer_queue_jobs SET status = '" . self::STATUS_COMPLETED . "', progress_percent = 100, progress_message = 'Completed', result_json = '$escaped_result', error_message = NULL, completed_at = NOW(), locked_at = NULL WHERE id = $job_id"; $db->query($sql); $this->logQueueEvent( 1, 0, 'ECONOMIC_TRANSFER_JOB_COMPLETED', 'Transfer job #' . $job_id . ' completed' ); } private function markFailed(int $job_id, string $error_message): void { global $db; $error_message = trim($error_message); if ($error_message === '') { $error_message = 'Unknown transfer queue error'; } $escaped_error = $db->escape_string($error_message); $sql = "UPDATE economic_transfer_queue_jobs SET status = '" . self::STATUS_FAILED . "', progress_message = 'Failed', error_message = '$escaped_error', attempts = attempts + 1, completed_at = NOW(), locked_at = NULL WHERE id = $job_id"; $db->query($sql); $this->logQueueEvent( 3, 0, 'ECONOMIC_TRANSFER_JOB_FAILED', 'Transfer job #' . $job_id . ' failed: ' . $error_message ); } private function decodeJsonValue(mixed $json): mixed { if (!is_string($json) || trim($json) === '') { return null; } $decoded = json_decode($json, true); return json_last_error() === JSON_ERROR_NONE ? $decoded : null; } private function normalizeJobRow(array $row): array { return [ 'id' => (int)$row['id'], 'transfer_type' => (string)($row['transfer_type'] ?? ''), 'status' => (string)($row['status'] ?? self::STATUS_QUEUED), 'progress_percent' => (int)($row['progress_percent'] ?? 0), 'progress_message' => $row['progress_message'] ?? null, 'attempts' => (int)($row['attempts'] ?? 0), 'max_attempts' => (int)($row['max_attempts'] ?? 0), 'error_message' => $row['error_message'] ?? null, 'payload' => $this->decodeJsonValue($row['payload_json'] ?? null), 'result' => $this->decodeJsonValue($row['result_json'] ?? null), 'created_by' => isset($row['created_by']) ? (int)$row['created_by'] : null, 'created_at' => $row['created_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, 'started_at' => $row['started_at'] ?? null, 'completed_at' => $row['completed_at'] ?? null, 'next_retry_at' => $row['next_retry_at'] ?? null, ]; } private function clearDismissalsForJob(int $job_id): void { global $db; $job_id = max(0, $job_id); if ($job_id < 1) { return; } $db->query("DELETE FROM economic_transfer_queue_job_dismissals WHERE queue_job_id = $job_id"); } /** * Release jobs stuck in PROCESSING due to crashes or killed workers. */ private function releaseStaleProcessingLocks(): void { global $db; $timeout = (int)self::STALE_PROCESSING_LOCK_SECONDS; $sql = "UPDATE economic_transfer_queue_jobs SET status = '" . self::STATUS_QUEUED . "', progress_message = 'Re-queued after stale processing lock', locked_at = NULL, started_at = NULL WHERE status = '" . self::STATUS_PROCESSING . "' AND locked_at IS NOT NULL AND locked_at < (NOW() - INTERVAL $timeout SECOND)"; $db->query($sql); } /** * @throws Exception */ private function normalizePayloadForTransferType(string $transfer_type, array $payload, int $created_by): array { $normalized_payload = $payload; if (isset($normalized_payload['requested_by'])) { $normalized_payload['requested_by'] = max(0, (int)$normalized_payload['requested_by']); } return match ($transfer_type) { self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->normalizeOrderPayload($normalized_payload, $created_by), self::TYPE_COLLECTED_INVOICE_EXPORT => $this->normalizeCollectedInvoicePayload($normalized_payload, $created_by), default => $this->rejectPayload($created_by, 'Unsupported transfer type payload: ' . $transfer_type), }; } /** * @throws Exception */ private function normalizeOrderPayload(array $payload, int $created_by): array { $order_id = $payload['order_id'] ?? null; if ($order_id === null || !is_numeric($order_id) || (int)$order_id < 1) { return $this->rejectPayload($created_by, 'order_id is required and must be a positive number'); } $payload['order_id'] = (int)$order_id; return $payload; } /** * @throws Exception */ private function normalizeCollectedInvoicePayload(array $payload, int $created_by): array { $collected_invoice_id = $payload['collected_invoice_id'] ?? null; if ($collected_invoice_id === null || !is_numeric($collected_invoice_id) || (int)$collected_invoice_id < 1) { return $this->rejectPayload($created_by, 'collected_invoice_id is required and must be a positive number'); } $payload['collected_invoice_id'] = (int)$collected_invoice_id; $payload['send_as_is'] = $this->normalizeBooleanPayloadValue($payload['send_as_is'] ?? false, 'send_as_is', $created_by); return $payload; } /** * @throws Exception */ private function normalizeBooleanPayloadValue(mixed $value, string $field_name, int $created_by): bool { if (is_bool($value)) { return $value; } if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) { $numeric = (int)$value; if ($numeric === 0 || $numeric === 1) { return $numeric === 1; } return $this->rejectPayload($created_by, $field_name . ' must be a boolean'); } if (is_string($value)) { $normalized = strtolower(trim($value)); if (in_array($normalized, ['true', 'false', '1', '0'], true)) { return in_array($normalized, ['true', '1'], true); } return $this->rejectPayload($created_by, $field_name . ' must be a boolean'); } return $this->rejectPayload($created_by, $field_name . ' must be a boolean'); } private function findActiveJobByTarget(string $transfer_type, array $payload, int $created_by): ?array { return match ($transfer_type) { self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget( $transfer_type, '$.order_id', (int)($payload['order_id'] ?? 0), $created_by ), self::TYPE_COLLECTED_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget( $transfer_type, '$.collected_invoice_id', (int)($payload['collected_invoice_id'] ?? 0), $created_by ), default => null, }; } private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value, int $created_by): ?array { global $db; $created_by = max(0, $created_by); if ($target_value < 1 || $created_by < 1) { return null; } $stmt = $db->prepare( "SELECT id FROM economic_transfer_queue_jobs WHERE transfer_type = ? AND status IN (?, ?) AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ? AND created_by = ? ORDER BY id DESC LIMIT 1" ); if (!$stmt) { return null; } $queued = self::STATUS_QUEUED; $processing = self::STATUS_PROCESSING; $stmt->bind_param('sssii', $transfer_type, $queued, $processing, $target_value, $created_by); if (!$stmt->execute()) { $stmt->close(); return null; } $result = $stmt->get_result(); $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; $stmt->close(); if (!$row || !isset($row['id'])) { return null; } return $this->getJobById((int)$row['id']); } private function buildTargetLabel(string $transfer_type, array $payload): string { return match ($transfer_type) { self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => 'order_id=' . (int)($payload['order_id'] ?? 0), self::TYPE_COLLECTED_INVOICE_EXPORT => 'collected_invoice_id=' . (int)($payload['collected_invoice_id'] ?? 0), default => 'unknown', }; } /** * @throws Exception */ private function rejectPayload(int $created_by, string $message): never { $this->logQueueEvent( 3, max(0, $created_by), 'ECONOMIC_TRANSFER_JOB_VALIDATION_REJECTED', 'Transfer job enqueue rejected: ' . $message ); throw new Exception($message); } private function logQueueEvent(int $status_code, int $user_id, string $event, string $message): void { try { (new logs_o())->add( 'economic_transfer_queue', 'global', $status_code, $user_id, $event, $message ); } catch (\Throwable) { // Logging is best effort for queue operations. } } /** * @throws Exception */ private function validateTransferType(string $transfer_type): string { $transfer_type = strtoupper(trim($transfer_type)); if (!in_array($transfer_type, [ self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT, self::TYPE_COLLECTED_INVOICE_EXPORT, ], true)) { throw new Exception('Unsupported transfer type: ' . $transfer_type); } return $transfer_type; } private function sanitizeStatuses(array $statuses): array { return array_values(array_unique(array_filter(array_map(static function ($status): string { return strtoupper(trim((string)$status)); }, $statuses), static function ($status): bool { return in_array($status, [ self::STATUS_QUEUED, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_FAILED, ], true); }))); } private function buildListJobsWhereClause(array $statuses = [], ?string $transfer_type = null): string { global $db; $conditions = []; $clean_statuses = $this->sanitizeStatuses($statuses); if (!empty($clean_statuses)) { $escaped_statuses = array_map(static function ($status) use ($db): string { return "'" . $db->escape_string($status) . "'"; }, $clean_statuses); $conditions[] = 'status IN (' . implode(',', $escaped_statuses) . ')'; } if ($transfer_type !== null && trim($transfer_type) !== '') { try { $normalized_transfer_type = $this->validateTransferType($transfer_type); } catch (Exception) { return 'WHERE 1 = 0'; } $conditions[] = "transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'"; } if (empty($conditions)) { return ''; } return 'WHERE ' . implode(' AND ', $conditions); } }