From 435a4dd290891fc497facdfccbadf51c7959c480 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 19 Jul 2026 17:41:09 +0300 Subject: [PATCH] Feat: Adding Shippment Tracking --- ...7_21_000001_create_shipment_info_table.php | 28 +++++ src/Providers/ShippingServiceProvider.php | 5 + src/Shipping/Contracts/SupportsTracking.php | 23 ++++ .../TrackingCheckpoint.php | 22 ++++ src/Shipping/Enums/TrackingStatus.php | 29 +++++ .../Events/ShipmentStatusUpdatedByCarrier.php | 17 +++ src/Shipping/Jobs/PollShipmentTrackingJob.php | 107 ++++++++++++++++++ src/Shipping/Models/Shipment.php | 11 ++ src/Shipping/Models/ShipmentInfo.php | 31 +++++ 9 files changed, 273 insertions(+) create mode 100644 database/migrations/2026_07_21_000001_create_shipment_info_table.php create mode 100644 src/Shipping/Contracts/SupportsTracking.php create mode 100644 src/Shipping/DataTransferObjects/TrackingCheckpoint.php create mode 100644 src/Shipping/Enums/TrackingStatus.php create mode 100644 src/Shipping/Events/ShipmentStatusUpdatedByCarrier.php create mode 100644 src/Shipping/Jobs/PollShipmentTrackingJob.php create mode 100644 src/Shipping/Models/ShipmentInfo.php diff --git a/database/migrations/2026_07_21_000001_create_shipment_info_table.php b/database/migrations/2026_07_21_000001_create_shipment_info_table.php new file mode 100644 index 0000000..fbfe887 --- /dev/null +++ b/database/migrations/2026_07_21_000001_create_shipment_info_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('shipment_id')->constrained('shipments')->cascadeOnDelete(); + $table->string('status'); + $table->string('carrier_status')->nullable(); + $table->text('message')->nullable(); + $table->string('location')->nullable(); + $table->timestamp('occurred_at'); + $table->json('meta')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('shipment_info'); + } +}; diff --git a/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php index 0adecbb..c93b899 100644 --- a/src/Providers/ShippingServiceProvider.php +++ b/src/Providers/ShippingServiceProvider.php @@ -19,6 +19,7 @@ use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService; use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; +use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob; use Modules\Core\Shipping\Models\Shipment; class ShippingServiceProvider extends ServiceProvider @@ -73,6 +74,10 @@ class ShippingServiceProvider extends ServiceProvider ->dailyAt('06:00') ->when(fn () => ShippingMethod::where('driver', 'acs')->exists()); + $this->app->make(ConsoleSchedule::class) + ->job(new PollShipmentTrackingJob) + ->everyThirtyMinutes(); + $this->overrideRatesPageLivewireComponent(); }); } diff --git a/src/Shipping/Contracts/SupportsTracking.php b/src/Shipping/Contracts/SupportsTracking.php new file mode 100644 index 0000000..4ec4457 --- /dev/null +++ b/src/Shipping/Contracts/SupportsTracking.php @@ -0,0 +1,23 @@ + + */ + public function trackShipment(Shipment $shipment): Collection; +} diff --git a/src/Shipping/DataTransferObjects/TrackingCheckpoint.php b/src/Shipping/DataTransferObjects/TrackingCheckpoint.php new file mode 100644 index 0000000..3ce5e94 --- /dev/null +++ b/src/Shipping/DataTransferObjects/TrackingCheckpoint.php @@ -0,0 +1,22 @@ + true, + default => false, + }; + } +} diff --git a/src/Shipping/Events/ShipmentStatusUpdatedByCarrier.php b/src/Shipping/Events/ShipmentStatusUpdatedByCarrier.php new file mode 100644 index 0000000..590dfd9 --- /dev/null +++ b/src/Shipping/Events/ShipmentStatusUpdatedByCarrier.php @@ -0,0 +1,17 @@ +keys()->filter( + fn (string $carrier) => $this->fulfillmentService($carrier) instanceof SupportsTracking + ); + + if ($trackableCarriers->isEmpty()) { + return; + } + + Shipment::query() + ->whereIn('carrier', $trackableCarriers) + ->whereNull('cancelled_at') + ->whereDoesntHave('shipmentInfo', function ($query) { + $query->whereIn('status', [ + TrackingStatus::Delivered->value, + TrackingStatus::Returned->value, + TrackingStatus::Cancelled->value, + ]); + }) + ->chunkById(50, function ($shipments) { + $shipments->groupBy('carrier')->each( + fn ($group, $carrier) => $this->pollCarrierShipments($carrier, $group) + ); + }); + } + + private function pollCarrierShipments(string $carrier, $shipments): void + { + $service = $this->fulfillmentService($carrier); + + if (! $service instanceof SupportsTracking) { + return; + } + + foreach ($shipments as $shipment) { + $this->recordNewCheckpoints($shipment, $service->trackShipment($shipment)); + } + } + + private function recordNewCheckpoints(Shipment $shipment, $checkpoints): void + { + $existing = $shipment->shipmentInfo() + ->get(['status', 'occurred_at']) + ->map(fn ($info) => $info->status->value.'|'.$info->occurred_at->toIso8601String()) + ->flip(); + + foreach ($checkpoints as $checkpoint) { + $fingerprint = $checkpoint->status->value.'|'.$checkpoint->occurredAt->toIso8601String(); + + if ($existing->has($fingerprint)) { + continue; + } + + $info = ShipmentInfo::create([ + 'shipment_id' => $shipment->id, + 'status' => $checkpoint->status, + 'carrier_status' => $checkpoint->carrierStatus, + 'message' => $checkpoint->message, + 'location' => $checkpoint->location, + 'occurred_at' => $checkpoint->occurredAt, + 'meta' => $checkpoint->meta, + ]); + + ShipmentStatusUpdatedByCarrier::dispatch($info); + } + } + + private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface + { + return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]); + } +} diff --git a/src/Shipping/Models/Shipment.php b/src/Shipping/Models/Shipment.php index 368a110..c278f4c 100644 --- a/src/Shipping/Models/Shipment.php +++ b/src/Shipping/Models/Shipment.php @@ -5,6 +5,7 @@ namespace Modules\Core\Shipping\Models; use Illuminate\Database\Eloquent\Casts\AsArrayObject; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Lunar\Models\Order; class Shipment extends Model @@ -21,4 +22,14 @@ class Shipment extends Model { return $this->belongsTo(Order::class); } + + public function shipmentInfo(): HasMany + { + return $this->hasMany(ShipmentInfo::class); + } + + public function latestShipmentInfo(): ?ShipmentInfo + { + return $this->shipmentInfo()->latest('occurred_at')->first(); + } } diff --git a/src/Shipping/Models/ShipmentInfo.php b/src/Shipping/Models/ShipmentInfo.php new file mode 100644 index 0000000..b2a401d --- /dev/null +++ b/src/Shipping/Models/ShipmentInfo.php @@ -0,0 +1,31 @@ + TrackingStatus::class, + 'occurred_at' => 'datetime', + 'meta' => AsArrayObject::class, + ]; + + public function shipment(): BelongsTo + { + return $this->belongsTo(Shipment::class); + } +}