- Implemented `InvoicingPeriodDraftOverlayTest` with coverage for blocking and permitting invoicing actions based on draft states, transactions, and metadata. - Created `ReferenceSuggestionsApiTest` to validate ranked and filtered suggestions across bookings, orders, and vehicles with varied match relevance, context, and frequency. - Added `order_reference_suggestions_service` class, including query methods, normalization utilities, and aggregation logic for reference suggestions. - Enhanced query handling in `InvoicingPeriodDraftOverlayFakeDb` to validate SQL constraints and column cache resets in overlapping invoicing contexts.
868 lines
30 KiB
PHP
868 lines
30 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use Exception;
|
|
use mysqli_result;
|
|
use objects\logs_o;
|
|
|
|
/**
|
|
* Queue service for asynchronous e-conomic transfer jobs.
|
|
*/
|
|
class economic_transfer_queue
|
|
{
|
|
public const STATUS_QUEUED = 'QUEUED';
|
|
public const STATUS_PROCESSING = 'PROCESSING';
|
|
public const STATUS_COMPLETED = 'COMPLETED';
|
|
public const STATUS_FAILED = 'FAILED';
|
|
|
|
public const TYPE_ORDER_DRAFT_EXPORT = 'ORDER_DRAFT_EXPORT';
|
|
public const TYPE_ORDER_INVOICE_EXPORT = 'ORDER_INVOICE_EXPORT';
|
|
public const TYPE_COLLECTED_INVOICE_EXPORT = 'COLLECTED_INVOICE_EXPORT';
|
|
private const STALE_PROCESSING_LOCK_SECONDS = 900;
|
|
|
|
private economic_transfer_executor $executor;
|
|
|
|
public function __construct(?economic_transfer_executor $executor = null)
|
|
{
|
|
$this->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);
|
|
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 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 1 = 1
|
|
$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->getJobById($job_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.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
|
|
{
|
|
global $db;
|
|
|
|
$existing_job = $this->getJobById($job_id);
|
|
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');
|
|
}
|
|
|
|
$stmt = $db->prepare(
|
|
"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 (!$stmt) {
|
|
throw new Exception('Failed to prepare retry statement');
|
|
}
|
|
|
|
$queued = self::STATUS_QUEUED;
|
|
$failed = self::STATUS_FAILED;
|
|
$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 = $this->getJobById($job_id);
|
|
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): ?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)
|
|
),
|
|
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
|
|
$transfer_type,
|
|
'$.collected_invoice_id',
|
|
(int)($payload['collected_invoice_id'] ?? 0)
|
|
),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value): ?array
|
|
{
|
|
global $db;
|
|
|
|
if ($target_value < 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) = ?
|
|
ORDER BY id DESC
|
|
LIMIT 1"
|
|
);
|
|
if (!$stmt) {
|
|
return null;
|
|
}
|
|
|
|
$queued = self::STATUS_QUEUED;
|
|
$processing = self::STATUS_PROCESSING;
|
|
$stmt->bind_param('sssi', $transfer_type, $queued, $processing, $target_value);
|
|
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);
|
|
}
|
|
}
|