feat(28-shipment-tracking-ui): badge'e statusow dostawy, linki sledzenia, ustawienia interwalu trackingu

- 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>
This commit is contained in:
2026-03-23 23:04:05 +01:00
parent 228c0e96cf
commit 98a0077204
17 changed files with 1108 additions and 174 deletions

View File

@@ -370,6 +370,31 @@ final class CronRepository
]);
}
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 {

View File

@@ -55,6 +55,9 @@ final class CronSettingsController
$pastPage = 1;
}
$trackingIntervalSeconds = $this->cronRepository->getScheduleInterval('shipment_tracking_sync');
$trackingIntervalMinutes = $trackingIntervalSeconds !== null ? (int) ($trackingIntervalSeconds / 60) : 15;
$html = $this->template->render('settings/cron', [
'title' => $this->translator->get('settings.cron.title'),
'activeMenu' => 'settings',
@@ -63,6 +66,7 @@ final class CronSettingsController
'csrfToken' => Csrf::token(),
'runOnWeb' => $runOnWeb,
'webLimit' => $webLimit,
'trackingIntervalMinutes' => $trackingIntervalMinutes,
'schedules' => $schedules,
'futureJobs' => $futureJobs,
'pastJobs' => $pastJobs,
@@ -91,9 +95,13 @@ final class CronSettingsController
$webLimitRaw = (int) $request->input('cron_web_limit', $this->webLimitDefault);
$webLimit = max(1, min(100, $webLimitRaw));
$trackingMinutesRaw = (int) $request->input('tracking_interval_minutes', 15);
$trackingMinutes = max(5, min(120, $trackingMinutesRaw));
try {
$this->cronRepository->upsertSetting('cron_run_on_web', $runOnWeb ? '1' : '0');
$this->cronRepository->upsertSetting('cron_web_limit', (string) $webLimit);
$this->cronRepository->updateScheduleInterval('shipment_tracking_sync', $trackingMinutes * 60);
Flash::set('settings_success', $this->translator->get('settings.cron.flash.saved'));
} catch (Throwable $exception) {
Flash::set('settings_error', $this->translator->get('settings.cron.flash.save_failed') . ' ' . $exception->getMessage());

View File

@@ -7,8 +7,8 @@ use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\ApaczkaApiClient;
use App\Modules\Settings\ApaczkaIntegrationRepository;
use App\Modules\Settings\CompanySettingsRepository;
use AppCorexceptionsIntegrationConfigException;
use AppCorexceptionsShipmentException;
use App\Core\Exceptions\IntegrationConfigException;
use App\Core\Exceptions\ShipmentException;
use Throwable;
final class ApaczkaShipmentService implements ShipmentProviderInterface
@@ -117,6 +117,9 @@ final class ApaczkaShipmentService implements ShipmentProviderInterface
}
$carrierLabel = trim((string) ($serviceDefinition['name'] ?? ''));
if ($carrierLabel === '') {
$carrierLabel = (string) ($this->packages->resolveCarrierName('apaczka', $deliveryMethodId) ?? '');
}
$packageId = $this->packages->create([
'order_id' => $orderId,
@@ -724,6 +727,13 @@ final class ApaczkaShipmentService implements ShipmentProviderInterface
$pickupDate = date('Y-m-d');
}
$pickupDate = $this->normalizeCourierPickupDate($pickupDate);
// If pickup is today and current time past safe cutoff, move to next business day
if ($pickupDate === date('Y-m-d') && (int) date('H') >= 16) {
$nextDay = date('Y-m-d', strtotime('+1 day', strtotime($pickupDate)));
$pickupDate = $this->normalizeCourierPickupDate($nextDay);
}
$hoursFrom = trim((string) ($formData['pickup_hours_from'] ?? ''));
if (preg_match('/^\d{2}:\d{2}$/', $hoursFrom) !== 1) {
$hoursFrom = '09:00';
@@ -748,6 +758,11 @@ final class ApaczkaShipmentService implements ShipmentProviderInterface
$ts = time();
}
$today = date('Y-m-d');
if (date('Y-m-d', $ts) < $today) {
$ts = time();
}
// Apaczka rejects Sunday as pickup date.
$weekday = (int) date('N', $ts);
if ($weekday === 7) {

View File

@@ -131,6 +131,18 @@ final class DeliveryStatus
'8' => self::PROBLEM,
'9' => self::READY_FOR_PICKUP,
'10' => self::IN_TRANSIT,
'NEW' => self::CREATED,
'PENDING' => self::CREATED,
'CONFIRMED' => self::CONFIRMED,
'PICKED_UP' => self::IN_TRANSIT,
'IN_TRANSIT' => self::IN_TRANSIT,
'OUT_FOR_DELIVERY' => self::OUT_FOR_DELIVERY,
'DELIVERED' => self::DELIVERED,
'RETURNED' => self::RETURNED,
'CANCELLED' => self::CANCELLED,
'ERROR' => self::PROBLEM,
'WAITING_FOR_PICKUP' => self::READY_FOR_PICKUP,
'REDIRECT' => self::IN_TRANSIT,
];
private const APACZKA_DESCRIPTIONS = [
@@ -145,6 +157,18 @@ final class DeliveryStatus
'8' => 'Błąd zamówienia',
'9' => 'Oczekuje na odbiór w punkcie',
'10' => 'Przekierowana',
'NEW' => 'Zamówienie utworzone',
'PENDING' => 'Oczekuje na przetworzenie',
'CONFIRMED' => 'Zamówienie potwierdzone',
'PICKED_UP' => 'Odebrana przez kuriera',
'IN_TRANSIT' => 'W transporcie',
'OUT_FOR_DELIVERY' => 'W doręczeniu',
'DELIVERED' => 'Doręczona',
'RETURNED' => 'Zwrócona do nadawcy',
'CANCELLED' => 'Anulowana',
'ERROR' => 'Błąd zamówienia',
'WAITING_FOR_PICKUP' => 'Oczekuje na odbiór w punkcie',
'REDIRECT' => 'Przekierowana',
];
private const ALLEGRO_MAP = [
@@ -200,4 +224,61 @@ final class DeliveryStatus
{
return in_array($status, self::TERMINAL_STATUSES, true);
}
public static function trackingUrl(string $provider, string $trackingNumber, string $carrierId = ''): ?string
{
$number = trim($trackingNumber);
if ($number === '') {
return null;
}
$encoded = rawurlencode($number);
if ($provider === 'inpost') {
return 'https://inpost.pl/sledzenie-przesylek?number=' . $encoded;
}
if ($provider === 'allegro_wza') {
return 'https://allegro.pl/przesylka/' . $encoded;
}
if ($carrierId !== '') {
$url = self::matchCarrierByName($encoded, strtolower(trim($carrierId)));
if ($url !== null) {
return $url;
}
}
return 'https://www.google.com/search?q=' . $encoded . '+sledzenie+przesylki';
}
private static function matchCarrierByName(string $encoded, string $carrier): ?string
{
if (str_contains($carrier, 'dpd')) {
return 'https://tracktrace.dpd.com.pl/parcelDetails?p1=' . $encoded;
}
if (str_contains($carrier, 'dhl')) {
return 'https://www.dhl.com/pl-pl/home/sledzenie-przesylki.html?tracking-id=' . $encoded;
}
if (str_contains($carrier, 'inpost') || str_contains($carrier, 'paczkomat')) {
return 'https://inpost.pl/sledzenie-przesylek?number=' . $encoded;
}
if (str_contains($carrier, 'orlen') || str_contains($carrier, 'ruch')) {
return 'https://www.orlenpaczka.pl/sledz-paczke/?numer=' . $encoded;
}
if (str_contains($carrier, 'poczta') || str_contains($carrier, 'pocztex')) {
return 'https://emonitoring.poczta-polska.pl/?numer=' . $encoded;
}
if (str_contains($carrier, 'ups')) {
return 'https://www.ups.com/track?tracknum=' . $encoded;
}
if (str_contains($carrier, 'fedex')) {
return 'https://www.fedex.com/fedextrack/?trknbr=' . $encoded;
}
if (str_contains($carrier, 'gls')) {
return 'https://gls-group.com/PL/pl/sledzenie-paczek?match=' . $encoded;
}
return null;
}
}

View File

@@ -181,6 +181,24 @@ final class ShipmentPackageRepository
return is_array($row) ? $row : null;
}
public function resolveCarrierName(string $provider, string $serviceId): ?string
{
if ($serviceId === '') {
return null;
}
$statement = $this->pdo->prepare(
'SELECT provider_service_name FROM carrier_delivery_method_mappings
WHERE provider = :provider AND provider_service_id = :service_id
LIMIT 1'
);
$statement->execute(['provider' => $provider, 'service_id' => $serviceId]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
$name = trim((string) ($row['provider_service_name'] ?? ''));
return $name !== '' ? $name : null;
}
private function nullStr(string $value): ?string
{
$trimmed = trim($value);