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\Carriers\BoxNow\BoxNowRateDriver;
|
||||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||||
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
|
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
|
||||||
|
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
|
||||||
use Modules\Core\Shipping\Models\Shipment;
|
use Modules\Core\Shipping\Models\Shipment;
|
||||||
|
|
||||||
class ShippingServiceProvider extends ServiceProvider
|
class ShippingServiceProvider extends ServiceProvider
|
||||||
@@ -73,6 +74,10 @@ class ShippingServiceProvider extends ServiceProvider
|
|||||||
->dailyAt('06:00')
|
->dailyAt('06:00')
|
||||||
->when(fn () => ShippingMethod::where('driver', 'acs')->exists());
|
->when(fn () => ShippingMethod::where('driver', 'acs')->exists());
|
||||||
|
|
||||||
|
$this->app->make(ConsoleSchedule::class)
|
||||||
|
->job(new PollShipmentTrackingJob)
|
||||||
|
->everyThirtyMinutes();
|
||||||
|
|
||||||
$this->overrideRatesPageLivewireComponent();
|
$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\Casts\AsArrayObject;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Lunar\Models\Order;
|
use Lunar\Models\Order;
|
||||||
|
|
||||||
class Shipment extends Model
|
class Shipment extends Model
|
||||||
@@ -21,4 +22,14 @@ class Shipment extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Order::class);
|
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