Feat: Adding Shippment Tracking
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('shipment_info', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Contracts;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
/**
|
||||
* Optional capability for carriers that expose shipment tracking. Carriers
|
||||
* without a tracking API simply don't implement it.
|
||||
*/
|
||||
interface SupportsTracking
|
||||
{
|
||||
/**
|
||||
* Return the shipment's known checkpoints from the carrier — as many
|
||||
* as the carrier's API returns in one call, not just the latest one.
|
||||
* Deduplication against what's already stored happens elsewhere.
|
||||
*
|
||||
* @return Collection<int, TrackingCheckpoint>
|
||||
*/
|
||||
public function trackShipment(Shipment $shipment): Collection;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\DataTransferObjects;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
|
||||
/**
|
||||
* One normalized checkpoint in a shipment's carrier-reported history.
|
||||
* Returned (potentially several at once) by SupportsTracking::trackShipment().
|
||||
*/
|
||||
class TrackingCheckpoint
|
||||
{
|
||||
public function __construct(
|
||||
public readonly TrackingStatus $status,
|
||||
public readonly ?string $carrierStatus,
|
||||
public readonly ?string $message,
|
||||
public readonly ?string $location,
|
||||
public readonly CarbonInterface $occurredAt,
|
||||
public readonly array $meta = [],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Enums;
|
||||
|
||||
/**
|
||||
* Normalized shipment status, mapped from each carrier's own status codes
|
||||
* (e.g. ACS's shipment_status/non_delivery_reason_code, Box Now's parcel
|
||||
* state) so the rest of the system never needs to know carrier-specific
|
||||
* vocabulary.
|
||||
*/
|
||||
enum TrackingStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case InTransit = 'in_transit';
|
||||
case OutForDelivery = 'out_for_delivery';
|
||||
case Delivered = 'delivered';
|
||||
case Failed = 'failed';
|
||||
case Returned = 'returned';
|
||||
case Cancelled = 'cancelled';
|
||||
case Unknown = 'unknown';
|
||||
|
||||
public function isTerminal(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::Delivered, self::Returned, self::Cancelled => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Modules\Core\Shipping\Models\ShipmentInfo;
|
||||
|
||||
/**
|
||||
* Fired once per newly-recorded ShipmentInfo checkpoint. Listeners (e.g.
|
||||
* customer notifications, order status sync) are added separately.
|
||||
*/
|
||||
class ShipmentStatusUpdatedByCarrier
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(public readonly ShipmentInfo $shipmentInfo) {}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Lunar\Shipping\Facades\Shipping;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\Contracts\SupportsTracking;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
use Modules\Core\Shipping\Models\ShipmentInfo;
|
||||
|
||||
/**
|
||||
* Carrier-agnostic: polls every Shipment not yet in a terminal state,
|
||||
* skipping carriers whose fulfillment service doesn't implement
|
||||
* SupportsTracking. New checkpoints are recorded in shipment_info and
|
||||
* dispatch ShipmentStatusUpdatedByCarrier — one event per new checkpoint.
|
||||
*/
|
||||
class PollShipmentTrackingJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 60;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$trackableCarriers = collect(Shipping::getSupportedDrivers())->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]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Shipping\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
|
||||
/**
|
||||
* One recorded checkpoint in a Shipment's carrier-reported tracking
|
||||
* history. Append-only — never updated in place, so the full history is
|
||||
* preserved rather than overwriting the last-known status.
|
||||
*/
|
||||
class ShipmentInfo extends Model
|
||||
{
|
||||
protected $table = 'shipment_info';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'status' => TrackingStatus::class,
|
||||
'occurred_at' => 'datetime',
|
||||
'meta' => AsArrayObject::class,
|
||||
];
|
||||
|
||||
public function shipment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Shipment::class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user