Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f30a7324e | |||
| 435a4dd290 | |||
| d34e450526 | |||
| 4acabe4185 | |||
| 37c2e1194c | |||
| 89255687a1 | |||
| 3599329b57 |
@@ -1,26 +0,0 @@
|
|||||||
<?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
|
|
||||||
{
|
|
||||||
$prefix = config('lunar.database.table_prefix');
|
|
||||||
|
|
||||||
Schema::table("{$prefix}shipping_rates", function (Blueprint $table) {
|
|
||||||
$table->string('pricing_mode')->default('live')->after('enabled');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
$prefix = config('lunar.database.table_prefix');
|
|
||||||
|
|
||||||
Schema::table("{$prefix}shipping_rates", function (Blueprint $table) {
|
|
||||||
$table->dropColumn('pricing_mode');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,33 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Shipping\Carriers\Acs;
|
namespace Modules\Core\Shipping\Carriers\Acs;
|
||||||
|
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Lunar\Models\Order;
|
use Lunar\Models\Order;
|
||||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||||
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
|
||||||
|
use Modules\Core\Shipping\Contracts\SupportsTracking;
|
||||||
|
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||||
use Modules\Core\Shipping\DataTransferObjects\ManifestResult;
|
use Modules\Core\Shipping\DataTransferObjects\ManifestResult;
|
||||||
|
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
|
||||||
|
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint;
|
||||||
|
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||||
use Modules\Core\Shipping\Models\Shipment;
|
use Modules\Core\Shipping\Models\Shipment;
|
||||||
|
|
||||||
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching
|
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AcsClient $client,
|
private readonly AcsClient $client,
|
||||||
private readonly AreaResolver $areaResolver,
|
private readonly AreaResolver $areaResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function createShipment(Order $order, array $overrides = []): Shipment
|
public function createShipment(Order $order, ShipmentRequest $request): Shipment
|
||||||
{
|
{
|
||||||
$address = $order->shippingAddress;
|
$address = $order->shippingAddress;
|
||||||
$destination = $this->areaResolver->resolve($address->postcode);
|
$destination = $this->areaResolver->resolve($address->postcode);
|
||||||
|
$weight = $request->weight ?? 0.5;
|
||||||
|
|
||||||
$response = $this->client->call('ACS_Create_Voucher', array_merge([
|
$params = [
|
||||||
'Pickup_Date' => now()->toDateString(),
|
'Pickup_Date' => now()->toDateString(),
|
||||||
'Sender' => config('acs.sender.name'),
|
'Sender' => config('acs.sender.name'),
|
||||||
'Recipient_Name' => trim("{$address->first_name} {$address->last_name}"),
|
'Recipient_Name' => trim("{$address->first_name} {$address->last_name}"),
|
||||||
@@ -33,9 +40,17 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
|
|||||||
'Acs_Station_Branch_Destination' => $destination->branchId,
|
'Acs_Station_Branch_Destination' => $destination->branchId,
|
||||||
'Billing_Code' => config('acs.billing_code'),
|
'Billing_Code' => config('acs.billing_code'),
|
||||||
'Charge_Type' => 2,
|
'Charge_Type' => 2,
|
||||||
'Item_Quantity' => 1,
|
'Item_Quantity' => $request->packageCount,
|
||||||
'Weight' => 0.5,
|
'Weight' => $weight,
|
||||||
], $overrides))->throwIfError();
|
];
|
||||||
|
|
||||||
|
if ($request->paymentMode === 'cod') {
|
||||||
|
$params['Cod_Ammount'] = $request->amountToCollect ?? $order->total->decimal;
|
||||||
|
$params['Cod_Payment_Way'] = 0; // cash
|
||||||
|
$params['Acs_Delivery_Products'] = 'COD';
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->client->call('ACS_Create_Voucher', $params)->throwIfError();
|
||||||
|
|
||||||
$voucherNo = (string) $response->valueOutput['Voucher_No'];
|
$voucherNo = (string) $response->valueOutput['Voucher_No'];
|
||||||
|
|
||||||
@@ -45,12 +60,12 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
|
|||||||
'tracking_reference' => $voucherNo,
|
'tracking_reference' => $voucherNo,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'station_destination' => $destination->stationId,
|
'station_destination' => $destination->stationId,
|
||||||
'weight' => $overrides['Weight'] ?? 0.5,
|
'weight' => $weight,
|
||||||
'pickup_date' => now()->toDateString(),
|
'pickup_date' => now()->toDateString(),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (($overrides['Item_Quantity'] ?? 1) > 1) {
|
if ($request->packageCount > 1) {
|
||||||
$this->persistMultipartVouchers($shipment);
|
$this->persistMultipartVouchers($shipment);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +129,60 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
|
|||||||
return ManifestResult::success($pickupListNo, $shipments);
|
return ManifestResult::success($pickupListNo, $shipments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function trackShipment(Shipment $shipment): Collection
|
||||||
|
{
|
||||||
|
$response = $this->client->call('ACS_TrackingDetails', [
|
||||||
|
'Voucher_No' => $shipment->tracking_reference,
|
||||||
|
])->throwIfError();
|
||||||
|
|
||||||
|
$rows = $response->tableOutput['Table_Data'] ?? [];
|
||||||
|
|
||||||
|
// ACS's per-checkpoint data (checkpoint_action) is free text with no
|
||||||
|
// status code, so the final checkpoint's status is corroborated
|
||||||
|
// against the structured summary call rather than guessed from text.
|
||||||
|
$isDelivered = $this->isDelivered($shipment);
|
||||||
|
|
||||||
|
return collect($rows)->values()->map(function (array $row, int $index) use ($rows, $isDelivered) {
|
||||||
|
$isLast = $index === count($rows) - 1;
|
||||||
|
|
||||||
|
return new TrackingCheckpoint(
|
||||||
|
status: $isLast && $isDelivered
|
||||||
|
? TrackingStatus::Delivered
|
||||||
|
: $this->guessStatusFromAction($row['checkpoint_action'] ?? ''),
|
||||||
|
carrierStatus: $row['checkpoint_action'] ?? null,
|
||||||
|
message: $row['checkpoint_action'] ?? null,
|
||||||
|
location: $row['checkpoint_location'] ?? null,
|
||||||
|
occurredAt: Carbon::parse($row['checkpoint_date_time']),
|
||||||
|
meta: $row,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isDelivered(Shipment $shipment): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = $this->client->call('ACS_Trackingsummary', [
|
||||||
|
'Voucher_No' => $shipment->tracking_reference,
|
||||||
|
])->throwIfError();
|
||||||
|
} catch (AcsApiException) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ($response->valueOutput['shipment_status'] ?? 0) === 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function guessStatusFromAction(string $action): TrackingStatus
|
||||||
|
{
|
||||||
|
$action = strtolower($action);
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
str_contains($action, 'delivery to consignee') => TrackingStatus::Delivered,
|
||||||
|
str_contains($action, 'on delivery') => TrackingStatus::OutForDelivery,
|
||||||
|
str_contains($action, 'arrival') || str_contains($action, 'departure') => TrackingStatus::InTransit,
|
||||||
|
default => TrackingStatus::Pending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private function persistMultipartVouchers(Shipment $mainShipment): void
|
private function persistMultipartVouchers(Shipment $mainShipment): void
|
||||||
{
|
{
|
||||||
$response = $this->client->call('ACS_Get_Multipart_Vouchers', [
|
$response = $this->client->call('ACS_Get_Multipart_Vouchers', [
|
||||||
|
|||||||
@@ -4,15 +4,17 @@ namespace Modules\Core\Shipping\Carriers\Acs;
|
|||||||
|
|
||||||
use Lunar\DataTypes\Price;
|
use Lunar\DataTypes\Price;
|
||||||
use Lunar\DataTypes\ShippingOption;
|
use Lunar\DataTypes\ShippingOption;
|
||||||
use Lunar\Facades\Pricing;
|
|
||||||
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
|
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
|
||||||
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
||||||
use Lunar\Shipping\Models\ShippingRate;
|
use Lunar\Shipping\Models\ShippingRate;
|
||||||
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||||
|
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
||||||
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
||||||
|
|
||||||
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
||||||
{
|
{
|
||||||
|
use ResolvesFixedPricing;
|
||||||
|
|
||||||
public ShippingRate $shippingRate;
|
public ShippingRate $shippingRate;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -36,39 +38,19 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
|
|||||||
$shippingMethod = $shippingRate->shippingMethod;
|
$shippingMethod = $shippingRate->shippingMethod;
|
||||||
$cart = $shippingOptionRequest->cart;
|
$cart = $shippingOptionRequest->cart;
|
||||||
|
|
||||||
|
if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') {
|
||||||
|
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
|
||||||
|
}
|
||||||
|
|
||||||
$postcode = $cart->shippingAddress?->postcode;
|
$postcode = $cart->shippingAddress?->postcode;
|
||||||
|
|
||||||
if (! $postcode) {
|
if (! $postcode) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($shippingRate->pricing_mode ?? 'live') === 'fixed') {
|
|
||||||
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode);
|
return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveFixedPrice(ShippingRate $shippingRate, $shippingMethod, $cart): ?ShippingOption
|
|
||||||
{
|
|
||||||
$subTotal = $cart->lines->sum('subTotal.value');
|
|
||||||
|
|
||||||
$pricing = Pricing::for($shippingRate)->qty($subTotal)->get();
|
|
||||||
|
|
||||||
if (! $pricing->matched) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ShippingOption(
|
|
||||||
name: $shippingMethod->name ?: $this->name(),
|
|
||||||
description: $shippingMethod->description ?: $this->description(),
|
|
||||||
identifier: $shippingRate->getIdentifier(),
|
|
||||||
price: $pricing->matched->price,
|
|
||||||
taxClass: $shippingRate->getTaxClass(),
|
|
||||||
taxReference: $shippingRate->getTaxReference(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption
|
private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Shipping\Carriers\BoxNow;
|
namespace Modules\Core\Shipping\Carriers\BoxNow;
|
||||||
|
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Lunar\Models\Order;
|
use Lunar\Models\Order;
|
||||||
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
|
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
|
||||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||||
|
use Modules\Core\Shipping\Contracts\SupportsTracking;
|
||||||
|
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
|
||||||
|
use Modules\Core\Shipping\DataTransferObjects\TrackingCheckpoint;
|
||||||
|
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||||
use Modules\Core\Shipping\Models\Shipment;
|
use Modules\Core\Shipping\Models\Shipment;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,28 +19,32 @@ use Modules\Core\Shipping\Models\Shipment;
|
|||||||
* CarrierFulfillmentInterface (not SupportsManifestBatching).
|
* CarrierFulfillmentInterface (not SupportsManifestBatching).
|
||||||
*
|
*
|
||||||
* Box Now delivers to lockers, not addresses. The storefront locker-picker
|
* Box Now delivers to lockers, not addresses. The storefront locker-picker
|
||||||
* is out of scope for this pass — createShipment() expects the chosen
|
* is out of scope for this pass — createShipment() requires the chosen
|
||||||
* locker's Box Now locationId via $overrides['locationId'] (e.g. set
|
* locker's Box Now locationId via ShipmentRequest::$destinationLocationId
|
||||||
* manually by admin staff until checkout UI exists).
|
* (e.g. set manually by admin staff until checkout UI exists).
|
||||||
*/
|
*/
|
||||||
class BoxNowFulfillmentService implements CarrierFulfillmentInterface
|
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
|
||||||
{
|
{
|
||||||
public function __construct(private readonly BoxNowClient $client) {}
|
public function __construct(private readonly BoxNowClient $client) {}
|
||||||
|
|
||||||
public function createShipment(Order $order, array $overrides = []): Shipment
|
public function createShipment(Order $order, ShipmentRequest $request): Shipment
|
||||||
{
|
{
|
||||||
$address = $order->shippingAddress;
|
$address = $order->shippingAddress;
|
||||||
$destinationLocationId = $overrides['locationId'] ?? null;
|
$destinationLocationId = $request->destinationLocationId;
|
||||||
|
|
||||||
if (! $destinationLocationId) {
|
if (! $destinationLocationId) {
|
||||||
throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.');
|
throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$isCod = $request->paymentMode === 'cod';
|
||||||
|
|
||||||
$response = $this->client->request('post', '/delivery-requests', [
|
$response = $this->client->request('post', '/delivery-requests', [
|
||||||
'orderNumber' => $order->reference.'-'.$order->id,
|
'orderNumber' => $order->reference.'-'.$order->id,
|
||||||
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
|
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
|
||||||
'paymentMode' => 'prepaid',
|
'paymentMode' => $isCod ? 'cod' : 'prepaid',
|
||||||
'amountToBeCollected' => '0.00',
|
'amountToBeCollected' => $isCod
|
||||||
|
? number_format($request->amountToCollect ?? $order->total->decimal, 2, '.', '')
|
||||||
|
: '0.00',
|
||||||
'origin' => [
|
'origin' => [
|
||||||
'contactNumber' => config('boxnow.sender.phone'),
|
'contactNumber' => config('boxnow.sender.phone'),
|
||||||
'contactEmail' => config('boxnow.sender.email'),
|
'contactEmail' => config('boxnow.sender.email'),
|
||||||
@@ -52,8 +62,8 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface
|
|||||||
'id' => (string) $order->id,
|
'id' => (string) $order->id,
|
||||||
'name' => 'Order '.$order->reference,
|
'name' => 'Order '.$order->reference,
|
||||||
'value' => '0.00',
|
'value' => '0.00',
|
||||||
'compartmentSize' => $overrides['compartmentSize'] ?? 1,
|
'compartmentSize' => 1,
|
||||||
'weight' => $overrides['weight'] ?? 0,
|
'weight' => $request->weight ?? 0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
@@ -89,4 +99,52 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface
|
|||||||
|
|
||||||
$shipment->update(['cancelled_at' => now()]);
|
$shipment->update(['cancelled_at' => now()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function trackShipment(Shipment $shipment): Collection
|
||||||
|
{
|
||||||
|
$response = $this->client->request('get', '/parcels', [
|
||||||
|
'parcelId' => $shipment->tracking_reference,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$parcel = $response['data'][0] ?? null;
|
||||||
|
|
||||||
|
if (! $parcel) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$events = $parcel['events'] ?? [];
|
||||||
|
|
||||||
|
// Fall back to a single checkpoint from the parcel's current state
|
||||||
|
// if Box Now didn't return a detailed events history.
|
||||||
|
if (empty($events)) {
|
||||||
|
$events = [[
|
||||||
|
'type' => $parcel['state'] ?? 'new',
|
||||||
|
'locationDisplayName' => null,
|
||||||
|
'createTime' => $parcel['updateTime'] ?? $parcel['createTime'] ?? now()->toIso8601String(),
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($events)->map(fn (array $event) => new TrackingCheckpoint(
|
||||||
|
status: $this->mapState($event['type'] ?? $parcel['state'] ?? 'new'),
|
||||||
|
carrierStatus: $event['type'] ?? $parcel['state'] ?? null,
|
||||||
|
message: null,
|
||||||
|
location: $event['locationDisplayName'] ?? null,
|
||||||
|
occurredAt: Carbon::parse($event['createTime']),
|
||||||
|
meta: $event,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapState(string $state): TrackingStatus
|
||||||
|
{
|
||||||
|
return match ($state) {
|
||||||
|
'new' => TrackingStatus::Pending,
|
||||||
|
'in-transit', 'in-depot' => TrackingStatus::InTransit,
|
||||||
|
'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery,
|
||||||
|
'delivered' => TrackingStatus::Delivered,
|
||||||
|
'returned', 'accepted-for-return' => TrackingStatus::Returned,
|
||||||
|
'cancelled' => TrackingStatus::Cancelled,
|
||||||
|
'expired-return', 'missing' => TrackingStatus::Failed,
|
||||||
|
default => TrackingStatus::Unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,21 @@
|
|||||||
namespace Modules\Core\Shipping\Carriers\BoxNow;
|
namespace Modules\Core\Shipping\Carriers\BoxNow;
|
||||||
|
|
||||||
use Lunar\DataTypes\ShippingOption;
|
use Lunar\DataTypes\ShippingOption;
|
||||||
use Lunar\Facades\Pricing;
|
|
||||||
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
|
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
|
||||||
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
||||||
use Lunar\Shipping\Models\ShippingRate;
|
use Lunar\Shipping\Models\ShippingRate;
|
||||||
|
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Box Now has no pricing API, so this always resolves the admin-configured
|
* Box Now has no pricing API, so this always resolves the method's normal
|
||||||
* price/price-breaks on the ShippingRate — the same mechanism the built-in
|
* charge_by + price-break configuration — the same mechanism the built-in
|
||||||
* flat-rate driver uses. Unlike AcsRateDriver, this does not implement
|
* flat-rate/ship-by drivers use. Does not implement SupportsLivePricing:
|
||||||
* SupportsLivePricing: there is nothing to toggle between.
|
* there is no live option to offer.
|
||||||
*/
|
*/
|
||||||
class BoxNowRateDriver implements ShippingRateInterface
|
class BoxNowRateDriver implements ShippingRateInterface
|
||||||
{
|
{
|
||||||
|
use ResolvesFixedPricing;
|
||||||
|
|
||||||
public ShippingRate $shippingRate;
|
public ShippingRate $shippingRate;
|
||||||
|
|
||||||
public function name(): string
|
public function name(): string
|
||||||
@@ -30,25 +32,10 @@ class BoxNowRateDriver implements ShippingRateInterface
|
|||||||
|
|
||||||
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
|
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
|
||||||
{
|
{
|
||||||
$shippingRate = $shippingOptionRequest->shippingRate;
|
return $this->resolveFixedPrice(
|
||||||
$shippingMethod = $shippingRate->shippingMethod;
|
$shippingOptionRequest->shippingRate,
|
||||||
$cart = $shippingOptionRequest->cart;
|
$shippingOptionRequest->shippingRate->shippingMethod,
|
||||||
|
$shippingOptionRequest->cart,
|
||||||
$subTotal = $cart->lines->sum('subTotal.value');
|
|
||||||
|
|
||||||
$pricing = Pricing::for($shippingRate)->qty($subTotal)->get();
|
|
||||||
|
|
||||||
if (! $pricing->matched) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ShippingOption(
|
|
||||||
name: $shippingMethod->name ?: $this->name(),
|
|
||||||
description: $shippingMethod->description ?: $this->description(),
|
|
||||||
identifier: $shippingRate->getIdentifier(),
|
|
||||||
price: $pricing->matched->price,
|
|
||||||
taxClass: $shippingRate->getTaxClass(),
|
|
||||||
taxReference: $shippingRate->getTaxReference(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Concerns;
|
||||||
|
|
||||||
|
use Lunar\DataTypes\ShippingOption;
|
||||||
|
use Lunar\Facades\Pricing;
|
||||||
|
use Lunar\Shipping\Models\ShippingMethod;
|
||||||
|
use Lunar\Shipping\Models\ShippingRate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared by any carrier driver that also supports Lunar's own price-break
|
||||||
|
* pricing (charge_by = cart_total | weight) as a fallback to, or standalone
|
||||||
|
* alternative for, live API pricing. Mirrors the vendor ShipBy driver's
|
||||||
|
* charge_by handling exactly, so behavior is consistent with the rest of
|
||||||
|
* Lunar's shipping system rather than inventing a separate mechanism.
|
||||||
|
*/
|
||||||
|
trait ResolvesFixedPricing
|
||||||
|
{
|
||||||
|
private function resolveFixedPrice(ShippingRate $shippingRate, ShippingMethod $shippingMethod, $cart): ?ShippingOption
|
||||||
|
{
|
||||||
|
$chargeBy = $shippingMethod->data['charge_by'] ?? 'cart_total';
|
||||||
|
|
||||||
|
$tier = $chargeBy === 'weight'
|
||||||
|
? $cart->lines->load('purchasable')->sum(fn ($line) => ($line->purchasable->weight_value ?? 0) * $line->quantity)
|
||||||
|
: $cart->lines->sum('subTotal.value');
|
||||||
|
|
||||||
|
$pricing = Pricing::for($shippingRate)->qty($tier)->get();
|
||||||
|
|
||||||
|
if (! $pricing->matched) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ShippingOption(
|
||||||
|
name: $shippingMethod->name ?: $this->name(),
|
||||||
|
description: $shippingMethod->description ?: $this->description(),
|
||||||
|
identifier: $shippingRate->getIdentifier(),
|
||||||
|
price: $pricing->matched->price,
|
||||||
|
taxClass: $shippingRate->getTaxClass(),
|
||||||
|
taxReference: $shippingRate->getTaxReference(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Modules\Core\Shipping\Contracts;
|
namespace Modules\Core\Shipping\Contracts;
|
||||||
|
|
||||||
use Lunar\Models\Order;
|
use Lunar\Models\Order;
|
||||||
|
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
|
||||||
use Modules\Core\Shipping\Models\Shipment;
|
use Modules\Core\Shipping\Models\Shipment;
|
||||||
|
|
||||||
interface CarrierFulfillmentInterface
|
interface CarrierFulfillmentInterface
|
||||||
@@ -10,7 +11,7 @@ interface CarrierFulfillmentInterface
|
|||||||
/**
|
/**
|
||||||
* Create a shipment with the carrier for the given order.
|
* Create a shipment with the carrier for the given order.
|
||||||
*/
|
*/
|
||||||
public function createShipment(Order $order, array $overrides = []): Shipment;
|
public function createShipment(Order $order, ShipmentRequest $request): Shipment;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the printable label for a shipment (raw file bytes).
|
* Fetch the printable label for a shipment (raw file bytes).
|
||||||
|
|||||||
@@ -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,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\DataTransferObjects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carrier-agnostic input for CarrierFulfillmentInterface::createShipment().
|
||||||
|
* Every field is optional — a carrier reads only what it needs and ignores
|
||||||
|
* the rest (e.g. destinationLocationId only matters to locker-delivery
|
||||||
|
* carriers like Box Now; ACS has no use for it).
|
||||||
|
*/
|
||||||
|
class ShipmentRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly ?float $weight = null,
|
||||||
|
public readonly int $packageCount = 1,
|
||||||
|
public readonly ?string $destinationLocationId = null,
|
||||||
|
public readonly ?string $paymentMode = null,
|
||||||
|
public readonly ?float $amountToCollect = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use Lunar\Admin\Support\Extending\ViewPageExtension;
|
|||||||
use Lunar\Models\Order;
|
use Lunar\Models\Order;
|
||||||
use Lunar\Shipping\Models\ShippingMethod;
|
use Lunar\Shipping\Models\ShippingMethod;
|
||||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||||
|
use Modules\Core\Shipping\DataTransferObjects\ShipmentRequest;
|
||||||
|
|
||||||
class OrderViewExtension extends ViewPageExtension
|
class OrderViewExtension extends ViewPageExtension
|
||||||
{
|
{
|
||||||
@@ -26,6 +27,15 @@ class OrderViewExtension extends ViewPageExtension
|
|||||||
->icon('heroicon-o-truck')
|
->icon('heroicon-o-truck')
|
||||||
->modalSubmitActionLabel('Create Shipment')
|
->modalSubmitActionLabel('Create Shipment')
|
||||||
->form([
|
->form([
|
||||||
|
Forms\Components\TextInput::make('weight')
|
||||||
|
->label('Package weight (kg)')
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->helperText('Leave blank to use the carrier\'s default.'),
|
||||||
|
Forms\Components\TextInput::make('destination_location_id')
|
||||||
|
->label('Box Now locker ID')
|
||||||
|
->helperText('Only required for Box Now shipments.')
|
||||||
|
->default(fn (Order $record) => $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null),
|
||||||
Forms\Components\Toggle::make('confirm')
|
Forms\Components\Toggle::make('confirm')
|
||||||
->label('Confirm')
|
->label('Confirm')
|
||||||
->helperText('This will create a real shipment with the carrier.')
|
->helperText('This will create a real shipment with the carrier.')
|
||||||
@@ -39,7 +49,7 @@ class OrderViewExtension extends ViewPageExtension
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->action(function (Order $record, Actions\Action $action) {
|
->action(function (Order $record, array $data, Actions\Action $action) {
|
||||||
$service = $this->resolveFulfillmentService($record);
|
$service = $this->resolveFulfillmentService($record);
|
||||||
|
|
||||||
if (! $service) {
|
if (! $service) {
|
||||||
@@ -53,8 +63,13 @@ class OrderViewExtension extends ViewPageExtension
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$request = new ShipmentRequest(
|
||||||
|
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
|
||||||
|
destinationLocationId: $data['destination_location_id'] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$service->createShipment($record);
|
$service->createShipment($record, $request);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
report($e);
|
report($e);
|
||||||
|
|
||||||
|
|||||||
@@ -6,20 +6,88 @@ use Filament\Forms\Components\Component;
|
|||||||
use Filament\Forms\Components\Concerns\HasChildComponents;
|
use Filament\Forms\Components\Concerns\HasChildComponents;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Form;
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Forms\Get;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Lunar\Admin\Support\Extending\ResourceExtension;
|
use Lunar\Admin\Support\Extending\ResourceExtension;
|
||||||
use Lunar\Shipping\Facades\Shipping;
|
use Lunar\Shipping\Facades\Shipping;
|
||||||
|
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
||||||
|
|
||||||
class ShippingMethodResourceExtension extends ResourceExtension
|
class ShippingMethodResourceExtension extends ResourceExtension
|
||||||
{
|
{
|
||||||
public function extendForm(Form $form): Form
|
public function extendForm(Form $form): Form
|
||||||
{
|
{
|
||||||
return $form->schema(
|
return $form->schema(
|
||||||
$this->replaceDriverField($form->getComponents())
|
$this->replaceChargeByField(
|
||||||
|
$this->replaceDriverField($form->getComponents())
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend the vendor's cart_total/weight charge_by Select with a third
|
||||||
|
* "live" option — only offered when the currently selected driver
|
||||||
|
* supports live pricing (see SupportsLivePricing). Picking it is what
|
||||||
|
* tells the driver to call its carrier API instead of resolving a
|
||||||
|
* price break.
|
||||||
|
*/
|
||||||
|
private function replaceChargeByField(array $components): array
|
||||||
|
{
|
||||||
|
return array_map(function (Component $component) {
|
||||||
|
if (method_exists($component, 'getName') && $component->getName() === 'charge_by') {
|
||||||
|
return $this->chargeBySelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
|
||||||
|
$component->schema(
|
||||||
|
$this->replaceChargeByField($component->getChildComponents())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $component;
|
||||||
|
}, $components);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function chargeBySelect(): Select
|
||||||
|
{
|
||||||
|
return Select::make('charge_by')
|
||||||
|
->label('Charge by')
|
||||||
|
->options(function (Get $get) {
|
||||||
|
$options = [
|
||||||
|
'cart_total' => 'Cart Total',
|
||||||
|
'weight' => 'Weight',
|
||||||
|
];
|
||||||
|
|
||||||
|
// "charge_by" is nested inside a Group with
|
||||||
|
// ->statePath('data'), while "driver" sits one level up, at
|
||||||
|
// the form root. Note: an *absolute* path here would need to
|
||||||
|
// additionally account for the page's own form wrapper
|
||||||
|
// (EditRecord::getFormStatePath() === 'data'), which relative
|
||||||
|
// paths never cross — so "../driver" (relative) is the
|
||||||
|
// correct, page-independent way to reach it, not an
|
||||||
|
// absolute 'driver' string.
|
||||||
|
if ($this->driverSupportsLivePricing($get('../driver'))) {
|
||||||
|
$options['live'] = 'Live API pricing';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
})
|
||||||
|
->live();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function driverSupportsLivePricing(?string $driver): bool
|
||||||
|
{
|
||||||
|
if (! $driver) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Shipping::driver($driver) instanceof SupportsLivePricing;
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function extendTable(Table $table): Table
|
public function extendTable(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table->columns(
|
return $table->columns(
|
||||||
@@ -78,6 +146,7 @@ class ShippingMethodResourceExtension extends ResourceExtension
|
|||||||
->label('Type')
|
->label('Type')
|
||||||
->options(fn () => collect(Shipping::getSupportedDrivers())
|
->options(fn () => collect(Shipping::getSupportedDrivers())
|
||||||
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
|
->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
|
||||||
->default('flat-rate');
|
->default('flat-rate')
|
||||||
|
->live();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,13 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Shipping\Filament\Pages;
|
namespace Modules\Core\Shipping\Filament\Pages;
|
||||||
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Form;
|
use Filament\Forms\Form;
|
||||||
use Filament\Forms\Get;
|
use Filament\Forms\Get;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Lunar\Shipping\Facades\Shipping;
|
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates;
|
||||||
use Lunar\Shipping\Models\ShippingMethod;
|
use Lunar\Shipping\Models\ShippingMethod;
|
||||||
use Lunar\Shipping\Models\ShippingRate;
|
use Lunar\Shipping\Models\ShippingRate;
|
||||||
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates;
|
|
||||||
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bound in place of the vendor ManageShippingRates page via the container
|
* Bound in place of the vendor ManageShippingRates page via the container
|
||||||
@@ -20,11 +17,13 @@ use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
|||||||
* ShippingZoneResource::getPages() — is untouched; the container simply
|
* ShippingZoneResource::getPages() — is untouched; the container simply
|
||||||
* hands back this subclass whenever the vendor class is resolved.
|
* hands back this subclass whenever the vendor class is resolved.
|
||||||
*
|
*
|
||||||
* Adds a per-rate "Pricing" toggle (live API vs. fixed price) for methods
|
* Hides the price / price-break fields for a rate whose method has
|
||||||
* whose driver supports live pricing (see SupportsLivePricing). Rates on
|
* charge_by = "live" (see ShippingMethodResourceExtension, which adds that
|
||||||
* methods without live pricing behave exactly as the vendor page always did
|
* option to methods whose driver supports live pricing) — those fields
|
||||||
* — no toggle shown, price fields always required, vendor save logic used
|
* would otherwise be dead configuration the driver never reads. Pricing
|
||||||
* as-is.
|
* strategy (cart_total / weight / live) stays entirely on the Shipping
|
||||||
|
* Method, matching Lunar's own existing charge_by convention; nothing new
|
||||||
|
* is stored on the rate itself.
|
||||||
*/
|
*/
|
||||||
class ManageShippingRates extends BaseManageShippingRates
|
class ManageShippingRates extends BaseManageShippingRates
|
||||||
{
|
{
|
||||||
@@ -33,56 +32,13 @@ class ManageShippingRates extends BaseManageShippingRates
|
|||||||
$form = parent::form($form);
|
$form = parent::form($form);
|
||||||
|
|
||||||
return $form->schema(
|
return $form->schema(
|
||||||
$this->insertPricingModeFieldAfterShippingMethod(
|
$this->hidePriceFieldsWhenLive($form->getComponents())
|
||||||
$this->hidePriceFieldsWhenLive($form->getComponents())
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Insert the "Pricing" field immediately after "shipping_method_id" so
|
|
||||||
* it reads as a pair, rather than appending it elsewhere in the form.
|
|
||||||
*/
|
|
||||||
private function insertPricingModeFieldAfterShippingMethod(array $components): array
|
|
||||||
{
|
|
||||||
$result = [];
|
|
||||||
|
|
||||||
foreach ($components as $component) {
|
|
||||||
$result[] = $component;
|
|
||||||
|
|
||||||
if (method_exists($component, 'getName') && $component->getName() === 'shipping_method_id') {
|
|
||||||
$result[] = $this->pricingModeField();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function pricingModeField(): Select
|
|
||||||
{
|
|
||||||
return Select::make('pricing_mode')
|
|
||||||
->label('Pricing')
|
|
||||||
->options([
|
|
||||||
'live' => 'Use live API pricing',
|
|
||||||
'fixed' => 'Use a fixed price',
|
|
||||||
])
|
|
||||||
->default('live')
|
|
||||||
->live()
|
|
||||||
->visible(fn (Get $get) => static::methodHasLivePricing($get('shipping_method_id')))
|
|
||||||
->columnSpan(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hide the vendor's price / price-break fields whenever this rate is
|
|
||||||
* set to live pricing — they'd otherwise be dead configuration,
|
|
||||||
* silently ignored by the driver. Only the base "price" field was ever
|
|
||||||
* required by the vendor form; the "prices" repeater (price breaks) is
|
|
||||||
* always optional, so its required() state is left untouched.
|
|
||||||
*/
|
|
||||||
private function hidePriceFieldsWhenLive(array $components): array
|
private function hidePriceFieldsWhenLive(array $components): array
|
||||||
{
|
{
|
||||||
$isFixedOrNotLiveCapable = fn (Get $get) => ! static::methodHasLivePricing($get('shipping_method_id'))
|
$isNotLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) !== 'live';
|
||||||
|| $get('pricing_mode') === 'fixed';
|
|
||||||
|
|
||||||
foreach ($components as $component) {
|
foreach ($components as $component) {
|
||||||
if (! method_exists($component, 'getName')) {
|
if (! method_exists($component, 'getName')) {
|
||||||
@@ -90,14 +46,11 @@ class ManageShippingRates extends BaseManageShippingRates
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($component->getName() === 'price') {
|
if ($component->getName() === 'price') {
|
||||||
$component->visible($isFixedOrNotLiveCapable)
|
$component->visible($isNotLive)->required($isNotLive)->dehydrated(true);
|
||||||
->required($isFixedOrNotLiveCapable)
|
|
||||||
->dehydrated(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($component->getName() === 'prices') {
|
if ($component->getName() === 'prices') {
|
||||||
$component->visible($isFixedOrNotLiveCapable)
|
$component->visible($isNotLive)->dehydrated(true);
|
||||||
->dehydrated(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +67,7 @@ class ManageShippingRates extends BaseManageShippingRates
|
|||||||
return TextColumn::make('basePrices.0')
|
return TextColumn::make('basePrices.0')
|
||||||
->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label'))
|
->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label'))
|
||||||
->formatStateUsing(function ($state, ShippingRate $record) {
|
->formatStateUsing(function ($state, ShippingRate $record) {
|
||||||
if (static::methodHasLivePricing($record->shipping_method_id) && $record->pricing_mode === 'live') {
|
if (static::methodChargeBy($record->shipping_method_id) === 'live') {
|
||||||
return 'Live API pricing';
|
return 'Live API pricing';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,37 +82,23 @@ class ManageShippingRates extends BaseManageShippingRates
|
|||||||
|
|
||||||
protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void
|
protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void
|
||||||
{
|
{
|
||||||
$isLive = static::methodHasLivePricing($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id)
|
if (static::methodChargeBy($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) === 'live') {
|
||||||
&& ($data['pricing_mode'] ?? 'live') === 'live';
|
|
||||||
|
|
||||||
$shippingRate->pricing_mode = $isLive ? 'live' : 'fixed';
|
|
||||||
$shippingRate->save();
|
|
||||||
|
|
||||||
if ($isLive) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
parent::saveShippingRate($shippingRate, $data);
|
parent::saveShippingRate($shippingRate, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function methodHasLivePricing(ShippingMethod|int|string|null $method): bool
|
protected static function methodChargeBy(ShippingMethod|int|string|null $method): ?string
|
||||||
{
|
{
|
||||||
if (blank($method)) {
|
if (blank($method)) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $method instanceof ShippingMethod) {
|
if (! $method instanceof ShippingMethod) {
|
||||||
$method = ShippingMethod::find($method);
|
$method = ShippingMethod::find($method);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $method) {
|
return $method?->data['charge_by'] ?? null;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return Shipping::driver($method->driver) instanceof SupportsLivePricing;
|
|
||||||
} catch (\InvalidArgumentException) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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