- Kolorowe badge'e statusow dostawy w tabelach paczek (show.php + prepare.php) - Link sledzenia z carrier detection (InPost, Apaczka, Orlen, Allegro, Google fallback) - Sekcja Status dostawy w boksie Platnosc i wysylka - Ustawienie interwalu trackingu crona (5-120 min) w zakladce Ustawienia - Tekstowe mapowania statusow Apaczka API (NEW, CONFIRMED, etc.) - Fix: use-statements ApaczkaShipmentService (pre-existing bug) - Fix: pickup date normalization (next day po 16:00) - Fix: przycisk Pobierz etykiete (POST zamiast link do prepare) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
488 lines
16 KiB
PHP
488 lines
16 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Cron;
|
|
|
|
use DateTimeImmutable;
|
|
use PDO;
|
|
use Throwable;
|
|
|
|
final class CronRepository
|
|
{
|
|
public function __construct(private readonly PDO $pdo)
|
|
{
|
|
}
|
|
|
|
public function getBoolSetting(string $key, bool $default): bool
|
|
{
|
|
$value = $this->getSettingValue($key);
|
|
if ($value === null) {
|
|
return $default;
|
|
}
|
|
|
|
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
|
}
|
|
|
|
public function getIntSetting(string $key, int $default, int $min, int $max): int
|
|
{
|
|
$value = $this->getSettingValue($key);
|
|
if ($value === null || !is_numeric($value)) {
|
|
return max($min, min($max, $default));
|
|
}
|
|
|
|
return max($min, min($max, (int) $value));
|
|
}
|
|
|
|
public function getStringSetting(string $key, string $default): string
|
|
{
|
|
$value = $this->getSettingValue($key);
|
|
if ($value === null) {
|
|
return $default;
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
public function upsertSetting(string $key, string $value): void
|
|
{
|
|
$statement = $this->pdo->prepare(
|
|
'INSERT INTO app_settings (setting_key, setting_value, created_at, updated_at)
|
|
VALUES (:setting_key, :setting_value, NOW(), NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
setting_value = VALUES(setting_value),
|
|
updated_at = VALUES(updated_at)'
|
|
);
|
|
$statement->execute([
|
|
'setting_key' => $key,
|
|
'setting_value' => $value,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function listSchedules(): array
|
|
{
|
|
$statement = $this->pdo->query(
|
|
'SELECT id, job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at
|
|
FROM cron_schedules
|
|
ORDER BY priority ASC, job_type ASC'
|
|
);
|
|
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
|
if (!is_array($rows)) {
|
|
return [];
|
|
}
|
|
|
|
return array_map(fn (array $row): array => $this->normalizeScheduleRow($row), $rows);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function listFutureJobs(int $limit = 50): array
|
|
{
|
|
$safeLimit = max(1, min(200, $limit));
|
|
$statement = $this->pdo->prepare(
|
|
'SELECT id, job_type, status, priority, attempts, max_attempts, scheduled_at, last_error, created_at
|
|
FROM cron_jobs
|
|
WHERE status IN ("pending", "processing")
|
|
ORDER BY scheduled_at ASC, priority ASC, id ASC
|
|
LIMIT :limit'
|
|
);
|
|
$statement->bindValue(':limit', $safeLimit, PDO::PARAM_INT);
|
|
$statement->execute();
|
|
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
|
if (!is_array($rows)) {
|
|
return [];
|
|
}
|
|
|
|
return array_map(fn (array $row): array => $this->normalizeJobRow($row), $rows);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function listPastJobs(int $limit = 50, int $offset = 0): array
|
|
{
|
|
$safeLimit = max(1, min(200, $limit));
|
|
$safeOffset = max(0, $offset);
|
|
$statement = $this->pdo->prepare(
|
|
'SELECT id, job_type, status, priority, attempts, max_attempts, scheduled_at, started_at, completed_at, last_error, created_at
|
|
FROM cron_jobs
|
|
WHERE status IN ("completed", "failed", "cancelled")
|
|
ORDER BY completed_at DESC, id DESC
|
|
LIMIT :limit OFFSET :offset'
|
|
);
|
|
$statement->bindValue(':limit', $safeLimit, PDO::PARAM_INT);
|
|
$statement->bindValue(':offset', $safeOffset, PDO::PARAM_INT);
|
|
$statement->execute();
|
|
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
|
if (!is_array($rows)) {
|
|
return [];
|
|
}
|
|
|
|
return array_map(fn (array $row): array => $this->normalizeJobRow($row), $rows);
|
|
}
|
|
|
|
public function countPastJobs(): int
|
|
{
|
|
$statement = $this->pdo->query(
|
|
'SELECT COUNT(*)
|
|
FROM cron_jobs
|
|
WHERE status IN ("completed", "failed", "cancelled")'
|
|
);
|
|
$value = $statement !== false ? $statement->fetchColumn() : 0;
|
|
|
|
return max(0, (int) $value);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function findDueSchedules(DateTimeImmutable $now): array
|
|
{
|
|
$statement = $this->pdo->prepare(
|
|
'SELECT id, job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at
|
|
FROM cron_schedules
|
|
WHERE enabled = 1
|
|
AND (next_run_at IS NULL OR next_run_at <= :now)
|
|
ORDER BY priority ASC, next_run_at ASC, id ASC'
|
|
);
|
|
$statement->execute([
|
|
'now' => $now->format('Y-m-d H:i:s'),
|
|
]);
|
|
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
|
if (!is_array($rows)) {
|
|
return [];
|
|
}
|
|
|
|
return array_map(fn (array $row): array => $this->normalizeScheduleRow($row), $rows);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $schedule
|
|
*/
|
|
public function enqueueJobFromSchedule(array $schedule, DateTimeImmutable $now): void
|
|
{
|
|
$payloadJson = $this->encodeJson($schedule['payload'] ?? null);
|
|
|
|
$jobStatement = $this->pdo->prepare(
|
|
'INSERT INTO cron_jobs (
|
|
job_type, status, priority, payload, attempts, max_attempts,
|
|
scheduled_at, created_at, updated_at
|
|
) VALUES (
|
|
:job_type, "pending", :priority, :payload, 0, :max_attempts,
|
|
:scheduled_at, NOW(), NOW()
|
|
)'
|
|
);
|
|
$jobStatement->execute([
|
|
'job_type' => (string) ($schedule['job_type'] ?? ''),
|
|
'priority' => (int) ($schedule['priority'] ?? 100),
|
|
'payload' => $payloadJson,
|
|
'max_attempts' => max(1, (int) ($schedule['max_attempts'] ?? 3)),
|
|
'scheduled_at' => $now->format('Y-m-d H:i:s'),
|
|
]);
|
|
|
|
$intervalSeconds = max(1, (int) ($schedule['interval_seconds'] ?? 60));
|
|
$nextRunAt = $now->modify('+' . $intervalSeconds . ' seconds');
|
|
|
|
$scheduleStatement = $this->pdo->prepare(
|
|
'UPDATE cron_schedules
|
|
SET last_run_at = :last_run_at,
|
|
next_run_at = :next_run_at,
|
|
updated_at = NOW()
|
|
WHERE id = :id'
|
|
);
|
|
$scheduleStatement->execute([
|
|
'last_run_at' => $now->format('Y-m-d H:i:s'),
|
|
'next_run_at' => $nextRunAt->format('Y-m-d H:i:s'),
|
|
'id' => (int) ($schedule['id'] ?? 0),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function claimNextPendingJob(DateTimeImmutable $now): ?array
|
|
{
|
|
$selectStatement = $this->pdo->prepare(
|
|
'SELECT id, job_type, status, priority, payload, attempts, max_attempts, scheduled_at
|
|
FROM cron_jobs
|
|
WHERE status = "pending"
|
|
AND scheduled_at <= :now
|
|
ORDER BY priority ASC, scheduled_at ASC, id ASC
|
|
LIMIT 1'
|
|
);
|
|
$selectStatement->execute([
|
|
'now' => $now->format('Y-m-d H:i:s'),
|
|
]);
|
|
$row = $selectStatement->fetch(PDO::FETCH_ASSOC);
|
|
if (!is_array($row)) {
|
|
return null;
|
|
}
|
|
|
|
$jobId = (int) ($row['id'] ?? 0);
|
|
if ($jobId <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$updateStatement = $this->pdo->prepare(
|
|
'UPDATE cron_jobs
|
|
SET status = "processing",
|
|
started_at = :started_at,
|
|
attempts = attempts + 1,
|
|
updated_at = NOW()
|
|
WHERE id = :id
|
|
AND status = "pending"'
|
|
);
|
|
$updateStatement->execute([
|
|
'started_at' => $now->format('Y-m-d H:i:s'),
|
|
'id' => $jobId,
|
|
]);
|
|
|
|
if ($updateStatement->rowCount() !== 1) {
|
|
return null;
|
|
}
|
|
|
|
$refreshStatement = $this->pdo->prepare(
|
|
'SELECT id, job_type, status, priority, payload, attempts, max_attempts, scheduled_at, started_at
|
|
FROM cron_jobs
|
|
WHERE id = :id
|
|
LIMIT 1'
|
|
);
|
|
$refreshStatement->execute(['id' => $jobId]);
|
|
$claimed = $refreshStatement->fetch(PDO::FETCH_ASSOC);
|
|
if (!is_array($claimed)) {
|
|
return null;
|
|
}
|
|
|
|
return $this->normalizeJobRow($claimed);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $result
|
|
*/
|
|
public function markJobCompleted(int $jobId, ?array $result = null): void
|
|
{
|
|
$statement = $this->pdo->prepare(
|
|
'UPDATE cron_jobs
|
|
SET status = "completed",
|
|
result = :result,
|
|
completed_at = NOW(),
|
|
last_error = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = :id'
|
|
);
|
|
$statement->execute([
|
|
'result' => $this->encodeJson($result),
|
|
'id' => $jobId,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $result
|
|
*/
|
|
public function markJobFailed(int $jobId, string $error, DateTimeImmutable $now, int $retryDelaySeconds = 60, ?array $result = null): void
|
|
{
|
|
$statement = $this->pdo->prepare(
|
|
'SELECT attempts, max_attempts
|
|
FROM cron_jobs
|
|
WHERE id = :id
|
|
LIMIT 1'
|
|
);
|
|
$statement->execute(['id' => $jobId]);
|
|
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
|
if (!is_array($row)) {
|
|
return;
|
|
}
|
|
|
|
$attempts = (int) ($row['attempts'] ?? 0);
|
|
$maxAttempts = max(1, (int) ($row['max_attempts'] ?? 1));
|
|
$errorMessage = mb_substr(trim($error), 0, 500);
|
|
|
|
if ($attempts < $maxAttempts) {
|
|
$scheduledAt = $now->modify('+' . max(10, $retryDelaySeconds) . ' seconds');
|
|
$retryStatement = $this->pdo->prepare(
|
|
'UPDATE cron_jobs
|
|
SET status = "pending",
|
|
result = :result,
|
|
scheduled_at = :scheduled_at,
|
|
started_at = NULL,
|
|
completed_at = NULL,
|
|
last_error = :last_error,
|
|
updated_at = NOW()
|
|
WHERE id = :id'
|
|
);
|
|
$retryStatement->execute([
|
|
'result' => $this->encodeJson($result),
|
|
'scheduled_at' => $scheduledAt->format('Y-m-d H:i:s'),
|
|
'last_error' => $errorMessage,
|
|
'id' => $jobId,
|
|
]);
|
|
return;
|
|
}
|
|
|
|
$failStatement = $this->pdo->prepare(
|
|
'UPDATE cron_jobs
|
|
SET status = "failed",
|
|
result = :result,
|
|
completed_at = NOW(),
|
|
last_error = :last_error,
|
|
updated_at = NOW()
|
|
WHERE id = :id'
|
|
);
|
|
$failStatement->execute([
|
|
'result' => $this->encodeJson($result),
|
|
'last_error' => $errorMessage,
|
|
'id' => $jobId,
|
|
]);
|
|
}
|
|
|
|
public function upsertSchedule(
|
|
string $jobType,
|
|
int $intervalSeconds,
|
|
int $priority,
|
|
int $maxAttempts,
|
|
?array $payload,
|
|
bool $enabled
|
|
): void {
|
|
$statement = $this->pdo->prepare(
|
|
'INSERT INTO cron_schedules (
|
|
job_type, interval_seconds, priority, max_attempts, payload, enabled, last_run_at, next_run_at, created_at, updated_at
|
|
) VALUES (
|
|
:job_type, :interval_seconds, :priority, :max_attempts, :payload, :enabled, NULL, NOW(), NOW(), NOW()
|
|
)
|
|
ON DUPLICATE KEY UPDATE
|
|
interval_seconds = VALUES(interval_seconds),
|
|
priority = VALUES(priority),
|
|
max_attempts = VALUES(max_attempts),
|
|
payload = VALUES(payload),
|
|
enabled = VALUES(enabled),
|
|
updated_at = VALUES(updated_at)'
|
|
);
|
|
$statement->execute([
|
|
'job_type' => trim($jobType),
|
|
'interval_seconds' => max(1, $intervalSeconds),
|
|
'priority' => max(1, min(255, $priority)),
|
|
'max_attempts' => max(1, min(20, $maxAttempts)),
|
|
'payload' => $this->encodeJson($payload),
|
|
'enabled' => $enabled ? 1 : 0,
|
|
]);
|
|
}
|
|
|
|
public function updateScheduleInterval(string $jobType, int $intervalSeconds): void
|
|
{
|
|
$statement = $this->pdo->prepare(
|
|
'UPDATE cron_schedules
|
|
SET interval_seconds = :interval_seconds,
|
|
updated_at = NOW()
|
|
WHERE job_type = :job_type'
|
|
);
|
|
$statement->execute([
|
|
'interval_seconds' => max(1, $intervalSeconds),
|
|
'job_type' => trim($jobType),
|
|
]);
|
|
}
|
|
|
|
public function getScheduleInterval(string $jobType): ?int
|
|
{
|
|
$statement = $this->pdo->prepare(
|
|
'SELECT interval_seconds FROM cron_schedules WHERE job_type = :job_type LIMIT 1'
|
|
);
|
|
$statement->execute(['job_type' => trim($jobType)]);
|
|
$value = $statement->fetchColumn();
|
|
|
|
return $value !== false ? (int) $value : null;
|
|
}
|
|
|
|
private function getSettingValue(string $key): ?string
|
|
{
|
|
try {
|
|
$statement = $this->pdo->prepare(
|
|
'SELECT setting_value
|
|
FROM app_settings
|
|
WHERE setting_key = :setting_key
|
|
LIMIT 1'
|
|
);
|
|
$statement->execute(['setting_key' => $key]);
|
|
$value = $statement->fetchColumn();
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
|
|
if (!is_string($value)) {
|
|
return null;
|
|
}
|
|
|
|
return trim($value);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalizeScheduleRow(array $row): array
|
|
{
|
|
return [
|
|
'id' => (int) ($row['id'] ?? 0),
|
|
'job_type' => (string) ($row['job_type'] ?? ''),
|
|
'interval_seconds' => (int) ($row['interval_seconds'] ?? 0),
|
|
'priority' => (int) ($row['priority'] ?? 100),
|
|
'max_attempts' => (int) ($row['max_attempts'] ?? 3),
|
|
'payload' => $this->decodeJson((string) ($row['payload'] ?? '')),
|
|
'enabled' => (int) ($row['enabled'] ?? 0) === 1,
|
|
'last_run_at' => (string) ($row['last_run_at'] ?? ''),
|
|
'next_run_at' => (string) ($row['next_run_at'] ?? ''),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalizeJobRow(array $row): array
|
|
{
|
|
return [
|
|
'id' => (int) ($row['id'] ?? 0),
|
|
'job_type' => (string) ($row['job_type'] ?? ''),
|
|
'status' => (string) ($row['status'] ?? ''),
|
|
'priority' => (int) ($row['priority'] ?? 100),
|
|
'payload' => $this->decodeJson((string) ($row['payload'] ?? '')),
|
|
'result' => $this->decodeJson((string) ($row['result'] ?? '')),
|
|
'attempts' => (int) ($row['attempts'] ?? 0),
|
|
'max_attempts' => (int) ($row['max_attempts'] ?? 3),
|
|
'scheduled_at' => (string) ($row['scheduled_at'] ?? ''),
|
|
'started_at' => (string) ($row['started_at'] ?? ''),
|
|
'completed_at' => (string) ($row['completed_at'] ?? ''),
|
|
'last_error' => (string) ($row['last_error'] ?? ''),
|
|
'created_at' => (string) ($row['created_at'] ?? ''),
|
|
];
|
|
}
|
|
|
|
private function encodeJson(mixed $value): ?string
|
|
{
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
if (!is_array($value)) {
|
|
return null;
|
|
}
|
|
if ($value === []) {
|
|
return null;
|
|
}
|
|
|
|
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: null;
|
|
}
|
|
|
|
private function decodeJson(string $value): ?array
|
|
{
|
|
$trimmed = trim($value);
|
|
if ($trimmed === '') {
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode($trimmed, true);
|
|
return is_array($decoded) ? $decoded : null;
|
|
}
|
|
}
|